diff --git a/sqllogictest-bin/src/engines.rs b/sqllogictest-bin/src/engines.rs index fcf6029..cad1e7e 100644 --- a/sqllogictest-bin/src/engines.rs +++ b/sqllogictest-bin/src/engines.rs @@ -73,17 +73,17 @@ pub(crate) async fn connect( EngineConfig::MySql => Engines::MySql( MySql::connect(config.into()) .await - .map_err(|e| EnginesError(e.into()))?, + .map_err(EnginesError::without_state)?, ), EngineConfig::Postgres => Engines::Postgres( PostgresSimple::connect(config.into()) .await - .map_err(|e| EnginesError(e.into()))?, + .map_err(EnginesError::without_state)?, ), EngineConfig::PostgresExtended => Engines::PostgresExtended( PostgresExtended::connect(config.into()) .await - .map_err(|e| EnginesError(e.into()))?, + .map_err(EnginesError::without_state)?, ), EngineConfig::External(cmd_tmpl) => { let (host, port) = config.random_addr(); @@ -98,24 +98,36 @@ pub(crate) async fn connect( Engines::External( ExternalDriver::connect(cmd) .await - .map_err(|e| EnginesError(e.into()))?, + .map_err(EnginesError::without_state)?, ) } }) } #[derive(Debug)] -pub(crate) struct EnginesError(anyhow::Error); +pub(crate) struct EnginesError { + error: anyhow::Error, + sqlstate: Option, +} + +impl EnginesError { + fn without_state(error: impl Into) -> Self { + Self { + error: error.into(), + sqlstate: None, + } + } +} impl Display for EnginesError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.0.fmt(f) + self.error.fmt(f) } } impl std::error::Error for EnginesError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - self.0.source() + self.error.source() } } @@ -130,6 +142,10 @@ macro_rules! dispatch_engines { }}; } +fn error_sql_state(_engine: &E, error: &E::Error) -> Option { + E::error_sql_state(error) +} + #[async_trait] impl AsyncDB for Engines { type Error = EnginesError; @@ -137,9 +153,10 @@ impl AsyncDB for Engines { async fn run(&mut self, sql: &str) -> Result, Self::Error> { dispatch_engines!(self, e, { - e.run(sql) - .await - .map_err(|e| EnginesError(anyhow::Error::from(e))) + e.run(sql).await.map_err(|error| EnginesError { + sqlstate: error_sql_state(e, &error), + error: anyhow::Error::from(error), + }) }) } @@ -158,4 +175,8 @@ impl AsyncDB for Engines { async fn shutdown(&mut self) { dispatch_engines!(self, e, { e.shutdown().await }) } + + fn error_sql_state(err: &Self::Error) -> Option { + err.sqlstate.clone() + } } diff --git a/sqllogictest-engines/src/mysql.rs b/sqllogictest-engines/src/mysql.rs index 8152dba..842fa5c 100644 --- a/sqllogictest-engines/src/mysql.rs +++ b/sqllogictest-engines/src/mysql.rs @@ -81,4 +81,12 @@ impl sqllogictest::AsyncDB for MySql { async fn run_command(command: Command) -> std::io::Result { tokio::process::Command::from(command).output().await } + + fn error_sql_state(err: &Self::Error) -> Option { + if let mysql_async::Error::Server(err) = err { + Some(err.state.clone()) + } else { + None + } + } } diff --git a/sqllogictest-engines/src/postgres/extended.rs b/sqllogictest-engines/src/postgres/extended.rs index fa4d167..1e9e180 100644 --- a/sqllogictest-engines/src/postgres/extended.rs +++ b/sqllogictest-engines/src/postgres/extended.rs @@ -326,4 +326,8 @@ impl sqllogictest::AsyncDB for Postgres { async fn run_command(command: Command) -> std::io::Result { tokio::process::Command::from(command).output().await } + + fn error_sql_state(err: &Self::Error) -> Option { + err.code().map(|s| s.code().to_owned()) + } } diff --git a/sqllogictest-engines/src/postgres/simple.rs b/sqllogictest-engines/src/postgres/simple.rs index 38b0877..2c68735 100644 --- a/sqllogictest-engines/src/postgres/simple.rs +++ b/sqllogictest-engines/src/postgres/simple.rs @@ -77,4 +77,8 @@ impl sqllogictest::AsyncDB for Postgres { async fn run_command(command: Command) -> std::io::Result { tokio::process::Command::from(command).output().await } + + fn error_sql_state(err: &Self::Error) -> Option { + err.code().map(|s| s.code().to_owned()) + } } diff --git a/sqllogictest/src/parser.rs b/sqllogictest/src/parser.rs index 475380c..ff359c1 100644 --- a/sqllogictest/src/parser.rs +++ b/sqllogictest/src/parser.rs @@ -377,12 +377,28 @@ pub enum ExpectedError { /// The actual error message that's exactly the same as the expected one is considered as a /// match. Multiline(String), + /// An expected SQL state code. + /// + /// The actual SQL state that matches the expected one is considered as a match. + SqlState(String), } impl ExpectedError { /// Parses an inline regex variant from tokens. fn parse_inline_tokens(tokens: &[&str]) -> Result { - Self::new_inline(tokens.join(" ")) + let joined = tokens.join(" "); + + // Check if this is a sqlstate error pattern: error(sqlstate) + if let Some(captures) = regex::Regex::new(r"^\(([^)]+)\)$") + .unwrap() + .captures(&joined) + { + if let Some(sqlstate) = captures.get(1) { + return Ok(Self::SqlState(sqlstate.as_str().to_string())); + } + } + + Self::new_inline(joined) } /// Creates an inline expected error message from a regex string. @@ -406,8 +422,10 @@ impl ExpectedError { /// Unparses the expected message after `statement`. fn fmt_inline(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "error")?; - if let Self::Inline(regex) = self { - write!(f, " {regex}")?; + match self { + Self::Inline(regex) => write!(f, " {regex}")?, + Self::SqlState(sqlstate) => write!(f, " ({sqlstate})")?, + Self::Empty | Self::Multiline(_) => {} } Ok(()) } @@ -423,11 +441,14 @@ impl ExpectedError { } /// Returns whether the given error message matches the expected one. - pub fn is_match(&self, err: &str) -> bool { + pub fn is_match(&self, err: &str, sqlstate: Option<&str>) -> bool { match self { Self::Empty => true, Self::Inline(regex) => regex.is_match(err), Self::Multiline(results) => results.trim() == err.trim(), + Self::SqlState(expected_state) => { + sqlstate.map_or(false, |state| state == expected_state) + } } } @@ -460,6 +481,7 @@ impl std::fmt::Display for ExpectedError { ExpectedError::Empty => write!(f, "(any)"), ExpectedError::Inline(regex) => write!(f, "(regex) {}", regex), ExpectedError::Multiline(results) => write!(f, "(multiline) {}", results.trim()), + ExpectedError::SqlState(sqlstate) => write!(f, "(sqlstate) {}", sqlstate), } } } @@ -470,6 +492,7 @@ impl PartialEq for ExpectedError { (Self::Empty, Self::Empty) => true, (Self::Inline(l0), Self::Inline(r0)) => l0.as_str() == r0.as_str(), (Self::Multiline(l0), Self::Multiline(r0)) => l0 == r0, + (Self::SqlState(l0), Self::SqlState(r0)) => l0 == r0, _ => false, } } diff --git a/sqllogictest/src/runner.rs b/sqllogictest/src/runner.rs index 1be55dc..d4c9276 100644 --- a/sqllogictest/src/runner.rs +++ b/sqllogictest/src/runner.rs @@ -98,6 +98,11 @@ pub trait AsyncDB { async fn run_command(mut command: Command) -> std::io::Result { command.output() } + + /// Extract the SQL state from the error. + fn error_sql_state(_err: &Self::Error) -> Option { + None + } } /// The database to be tested. @@ -117,6 +122,11 @@ pub trait DB { fn engine_name(&self) -> &str { "" } + + /// Extract the SQL state from the error. + fn error_sql_state(_err: &Self::Error) -> Option { + None + } } /// Compat-layer for the new AsyncDB and DB trait @@ -139,6 +149,10 @@ where fn engine_name(&self) -> &str { D::engine_name(self) } + + fn error_sql_state(err: &Self::Error) -> Option { + D::error_sql_state(err) + } } /// The error type for running sqllogictest. @@ -282,12 +296,14 @@ pub enum TestErrorKind { actual_stdout: String, }, // Remember to also update [`TestErrorKindDisplay`] if this message is changed. - #[error("{kind} is expected to fail with error:\n\t{expected_err}\nbut got error:\n\t{err}\n[SQL] {sql}")] + #[error("{kind} is expected to fail with error:\n\t{expected_err}\nbut got error:\n\t{}{err}\n[SQL] {sql}", .actual_sqlstate.as_ref().map(|s| format!("(sqlstate {s}) ")).unwrap_or_default())] ErrorMismatch { sql: String, err: AnyError, expected_err: String, kind: RecordKind, + /// The actual SQL state when the expected error was a SqlState type + actual_sqlstate: Option, }, #[error("statement is expected to affect {expected} rows, but actually {actual}\n[SQL] {sql}")] StatementResultMismatch { @@ -355,12 +371,16 @@ impl Display for TestErrorKindDisplay<'_> { err, expected_err, kind, - } => write!( - f, - "{kind} is expected to fail with error:\n\t{}\nbut got error:\n\t{}\n[SQL] {sql}", - expected_err.bright_green(), - err.bright_red(), - ), + actual_sqlstate, + } => { + write!( + f, + "{kind} is expected to fail with error:\n\t{}\nbut got error:\n\t{}{}\n[SQL] {sql}", + expected_err.bright_green(), + actual_sqlstate.as_ref().map(|s| format!("(sqlstate {s}) ")).unwrap_or_default(), + err.bright_red(), + ) + } TestErrorKind::QueryResultMismatch { sql, expected, @@ -1112,12 +1132,16 @@ impl> Runner { } (None, StatementExpect::Ok) => {} (Some(e), StatementExpect::Error(expected_error)) => { - if !expected_error.is_match(&e.to_string()) { + let sqlstate = e + .downcast_ref::() + .and_then(|concrete_err| D::error_sql_state(concrete_err)); + if !expected_error.is_match(&e.to_string(), sqlstate.as_deref()) { return Err(TestErrorKind::ErrorMismatch { sql, err: Arc::clone(e), expected_err: expected_error.to_string(), kind: RecordKind::Statement, + actual_sqlstate: sqlstate, } .at(loc)); } @@ -1151,12 +1175,16 @@ impl> Runner { .at(loc)); } (Some(e), QueryExpect::Error(expected_error)) => { - if !expected_error.is_match(&e.to_string()) { + let sqlstate = e + .downcast_ref::() + .and_then(|concrete_err| D::error_sql_state(concrete_err)); + if !expected_error.is_match(&e.to_string(), sqlstate.as_deref()) { return Err(TestErrorKind::ErrorMismatch { sql, err: Arc::clone(e), expected_err: expected_error.to_string(), kind: RecordKind::Query, + actual_sqlstate: sqlstate, } .at(loc)); } @@ -1701,7 +1729,7 @@ pub fn update_record_with_output( }), // Error match (Some(e), StatementExpect::Error(expected_error)) - if expected_error.is_match(&e.to_string()) => + if expected_error.is_match(&e.to_string(), None) => { None } @@ -1738,7 +1766,7 @@ pub fn update_record_with_output( ) => match (error, expected) { // Error match (Some(e), QueryExpect::Error(expected_error)) - if expected_error.is_match(&e.to_string()) => + if expected_error.is_match(&e.to_string(), None) => { None } diff --git a/tests/harness.rs b/tests/harness.rs index cd42e99..c21f48e 100644 --- a/tests/harness.rs +++ b/tests/harness.rs @@ -13,11 +13,30 @@ impl FakeDB { } #[derive(Debug)] -pub struct FakeDBError(String); +pub struct FakeDBError { + message: String, + sql_state: Option, +} + +impl FakeDBError { + fn new(message: String) -> Self { + Self { + message, + sql_state: None, + } + } + + fn with_sql_state(message: String, sql_state: String) -> Self { + Self { + message, + sql_state: Some(sql_state), + } + } +} impl std::fmt::Display for FakeDBError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) + write!(f, "{}", self.message) } } @@ -71,15 +90,63 @@ impl sqllogictest::DB for FakeDB { return Ok(DBOutput::StatementComplete(0)); } if sql.starts_with("desc") { - return Err(FakeDBError( + return Err(FakeDBError::new( "The operation (describe) is not supported. Did you mean [describe]?".to_string(), )); } if sql.contains("multiline error") { - return Err(FakeDBError( + return Err(FakeDBError::new( "Hey!\n\nYou got:\n Multiline FakeDBError!".to_string(), )); } - Err(FakeDBError("Hey you got FakeDBError!".to_string())) + + // Handle SQL state error testing + // Order matters: more specific patterns should come first + if sql.contains("non_existent_column") || sql.contains("missing_column") { + return Err(FakeDBError::with_sql_state( + "column \"missing_column\" does not exist".to_string(), + "42703".to_string(), + )); + } + if sql.contains("FORM") || sql.contains("SELEKT") || sql.contains("INVALID SYNTAX") { + return Err(FakeDBError::with_sql_state( + "syntax error at or near \"FORM\"".to_string(), + "42601".to_string(), + )); + } + if sql.contains("1/0") || sql.contains("/0") { + return Err(FakeDBError::with_sql_state( + "division by zero".to_string(), + "22012".to_string(), + )); + } + if sql.contains("duplicate") || sql.contains("UNIQUE") || sql.contains("violation") { + return Err(FakeDBError::with_sql_state( + "duplicate key value violates unique constraint".to_string(), + "23505".to_string(), + )); + } + if sql.contains("non_existent_table") + || sql.contains("missing_table") + || sql.contains("table_that_does_not_exist") + || sql.contains("final_missing_table") + || sql.contains("query_missing_table") + || sql.contains("another_missing_table") + || sql.contains("yet_another_missing_table") + || sql.contains("definitely_missing_table") + || sql.contains("postgres_missing_table") + || sql.contains("some_missing_table") + { + return Err(FakeDBError::with_sql_state( + "relation \"missing_table\" does not exist".to_string(), + "42P01".to_string(), + )); + } + + Err(FakeDBError::new("Hey you got FakeDBError!".to_string())) + } + + fn error_sql_state(err: &Self::Error) -> Option { + err.sql_state.clone() } } diff --git a/tests/slt/error_sqlstate.slt b/tests/slt/error_sqlstate.slt new file mode 100644 index 0000000..c7843f9 --- /dev/null +++ b/tests/slt/error_sqlstate.slt @@ -0,0 +1,160 @@ +# Test file for error(sqlstate) syntax support +# This file tests the parsing and matching of SQL state error codes +# in both statement and query contexts using the enhanced FakeDB + +# Test 1: Table not found error (SQL state 42P01) +# This tests the most common error case +statement error (42P01) +SELECT * FROM non_existent_table + +query error (42P01) +SELECT * FROM missing_table + +# Test 2: Column not found error (SQL state 42703) +statement error (42703) +SELECT non_existent_column FROM some_table + +query error (42703) +SELECT missing_column FROM another_table + +# Test 3: Syntax error (SQL state 42601) +statement error (42601) +SELECT * FORM some_table + +query error (42601) +SELEKT * FROM some_table + +# Test 4: Division by zero error (SQL state 22012) +statement error (22012) +SELECT 1/0 + +query error (22012) +SELECT id/0 FROM some_table + +# Test 5: Duplicate key error (SQL state 23505) +# Simulate duplicate key constraint violations +statement error (23505) +INSERT INTO table_with_duplicate VALUES (1) + +query error (23505) +INSERT INTO table_with_UNIQUE_constraint VALUES (1) + +# Test 6: Test backward compatibility - ensure regular error matching still works +statement error Hey you got FakeDBError +SELECT * FROM any_table + +statement error relation.*does not exist +SELECT * FROM missing_table + +# Test 7: Test both inline and multiline error formats work alongside sqlstate +statement error (42P01) +SELECT * FROM another_missing_table + +statement error +give me a multiline error +---- +Hey! + +You got: + Multiline FakeDBError! + + +# Test 8: Test empty error (any error should match) +statement error +SELECT * FROM some_table + +query error +SELECT anything FROM anywhere + +# Test 9: Verify parsing of different SQL state formats +# Test case preservation +statement error (42P01) +SELECT * FROM missing_table + +statement error (42P01) +SELECT * FROM missing_table + +statement error (42703) +SELECT missing_column FROM table + +# Test 10: Test complex SQL with SQL state matching +statement error (42601) +SELECT col1, col2 FORM table WHERE id = 1 + +query error (42601) +SELEKT COUNT(*) FROM table GROUP BY col1 + +# Test 11: Test division by zero in different contexts +statement error (22012) +SELECT price/0 FROM products + +query error (22012) +SELECT quantity/0 AS invalid FROM inventory + +# Test 12: Multiple table references with missing table error +statement error (42P01) +SELECT t1.id, t2.name FROM missing_table t1 JOIN other_table t2 ON t1.id = t2.id + +query error (42P01) +SELECT * FROM (SELECT * FROM non_existent_table) AS subquery + +# Test 13: Test that SQL state matching is exact +# This should match the SQL state exactly, not partially +statement error (42P01) +SELECT * FROM missing_table WHERE id = 1 + +# These should NOT match because they don't contain the right patterns +statement error Hey you got FakeDBError +SELECT * FROM some_existing_table + +statement error Hey you got FakeDBError +INSERT INTO real_table VALUES (1) + +# Test 14: Verify normal operations still work +statement ok +create table test_table (id int) + +statement ok +insert into test_table values (1) + +statement ok +drop table test_table + +# Test 15: Test description operation error (from original harness) +statement error The operation \(describe\) is not supported +desc table some_table + +query error The operation \(describe\) is not supported +desc table another_table + +# Test 16: Test that multiple SQL patterns work correctly +statement error (42703) +SELECT missing_column, another_missing_column FROM table + +statement error (42601) +SELEKT * FORM table WHERE col = 'value' + +# Test 17: Ensure the SQL state pattern parsing is robust +# Test with extra whitespace and different formats +statement error (42P01) +SELECT * FROM missing_table + +# This should be treated as regex, not SQL state (invalid format) +statement error missing.*table +SELECT * FROM missing_table + +# Test 18: Final verification that all error types work +statement error (42P01) +SELECT * FROM final_missing_table + +query error (42703) +SELECT final_missing_column FROM table + +statement error (42601) +FINAL INVALID SYNTAX FORM + +query error (22012) +SELECT final/0 FROM table + +statement error (23505) +INSERT final duplicate key violation diff --git a/tests/slt/error_sqlstate_parsing.slt b/tests/slt/error_sqlstate_parsing.slt new file mode 100644 index 0000000..ea66a6a --- /dev/null +++ b/tests/slt/error_sqlstate_parsing.slt @@ -0,0 +1,72 @@ +# Unit tests for error(sqlstate) parsing functionality +# This file focuses on testing the parser's ability to correctly parse +# different SQL state error patterns + +# Test 1: Basic SQL state pattern parsing +# Verify that the parser correctly identifies sqlstate patterns +statement error (42P01) +SELECT invalid_syntax FROM non_existent_table + +# Test 2: Alphanumeric SQL state codes +# Some SQL states contain letters (like PostgreSQL codes) +statement error (42P01) +SELECT * FROM missing_table + +# Test 3: Different SQL state lengths +# Test both 5-character standard SQL states and variations +statement error (42P01) +INSERT INTO non_existent_table VALUES (1) + +statement error (42703) +SELECT non_existent_column FROM missing_table + +# Test 4: Mixed case SQL state codes (should be preserved as-is) +statement error (42P01) +SELECT * FROM another_missing_table + +statement error (42P01) +SELECT * FROM yet_another_missing_table + +# Test 5: Ensure regular error patterns still work +# This verifies backward compatibility +statement error relation.*does not exist +SELECT * FROM definitely_missing_table + +statement error relation.*does not exist +SELECT * FROM postgres_missing_table + +# Test 6: Test both statement and query error contexts +query error (42P01) +SELECT * FROM query_missing_table + +query error (42P01) +SELECT * FROM query_missing_table2 + +# Test 7: Ensure multiline errors still work with regular expressions +statement error +give me a multiline error +---- +Hey! + +You got: + Multiline FakeDBError! + + +# Test 8: Test empty error (any error should match) +statement error +SELECT * FROM some_missing_table + +# Test 9: Test syntax error SQL state codes +statement error (42601) +SELECT * FORM missing_table + +statement error (42601) +SELEKT * FROM missing_table + +# Test 10: Edge cases - what happens with different parentheses patterns +# Note: These should be treated as regular regex patterns, not SQL states +statement error Hey you got FakeDBError +SELECT * FROM some_other_table + +statement error missing.*table +SELECT * FROM final_missing_table