diff --git a/crates/sqlquack-lint/src/lint/_helpers/terminal_shape.rs b/crates/sqlquack-lint/src/lint/_helpers/terminal_shape.rs index c4a8b003c..29ef98307 100644 --- a/crates/sqlquack-lint/src/lint/_helpers/terminal_shape.rs +++ b/crates/sqlquack-lint/src/lint/_helpers/terminal_shape.rs @@ -209,7 +209,7 @@ pub(super) fn collect_terminal_shape_facts( let terminal_is_plain = from_position.is_some_and(|position| { let projection = &tail[..position]; let relation = tail.get(position + 1); - let remainder = &tail[position.saturating_add(2)..]; + let remainder = tail.get(position + 2..).unwrap_or_default(); !projection.is_empty() && plain_terminal_projection(tokens, projection) && relation.is_some_and(|&index| { diff --git a/crates/sqlquack-lint/src/lint/tests.rs b/crates/sqlquack-lint/src/lint/tests.rs index cb25cbd85..8befcc58b 100644 --- a/crates/sqlquack-lint/src/lint/tests.rs +++ b/crates/sqlquack-lint/src/lint/tests.rs @@ -16,6 +16,8 @@ mod long_chains; mod sql_lint; #[path = "tests/test_types.rs"] mod test_types; +#[path = "tests/test_trailing_from.rs"] +mod trailing_from; #[path = "tests/test_unused_ctes.rs"] mod unused_ctes; #[path = "tests/test_unused_table_alias.rs"] diff --git a/crates/sqlquack-lint/src/lint/tests/test_trailing_from.rs b/crates/sqlquack-lint/src/lint/tests/test_trailing_from.rs new file mode 100644 index 000000000..9dd331e25 --- /dev/null +++ b/crates/sqlquack-lint/src/lint/tests/test_trailing_from.rs @@ -0,0 +1,64 @@ +use crate::lint::tests::helpers::diagnostics_for_dialect; +use crate::lint::tests::test_types::TrailingFromTestCase; + +/// A query whose final FROM names no relation: the terminal-shape rule reads past that FROM. +const SQL: &str = "WITH t AS (SELECT 1) SELECT * FROM"; + +#[test] +fn given_a_query_ending_in_from_when_linting_then_each_dialect_refuses_or_lints_it() { + let test_cases = [ + TrailingFromTestCase { + description: "Snowflake refuses it", + dialect: "snowflake", + expected_parse_error: true, + }, + TrailingFromTestCase { + description: "DuckDB refuses it", + dialect: "duckdb", + expected_parse_error: true, + }, + TrailingFromTestCase { + description: "SQLite refuses it", + dialect: "sqlite", + expected_parse_error: true, + }, + TrailingFromTestCase { + description: "PostgreSQL refuses it", + dialect: "postgres", + expected_parse_error: true, + }, + TrailingFromTestCase { + description: "Generic refuses it", + dialect: "generic", + expected_parse_error: true, + }, + TrailingFromTestCase { + description: "BigQuery refuses it", + dialect: "bigquery", + expected_parse_error: true, + }, + TrailingFromTestCase { + description: "Databricks parses it, so the terminal-shape rule lints it", + dialect: "databricks", + expected_parse_error: false, + }, + ]; + for test_case in test_cases { + let result = diagnostics_for_dialect(SQL, test_case.dialect, &["SQLCLEAN035"]); + assert_eq!( + result + .as_ref() + .err() + .is_some_and(|error| error.contains("Parse error")), + test_case.expected_parse_error, + "{}: {result:?}", + test_case.description + ); + assert_eq!( + result.is_ok(), + !test_case.expected_parse_error, + "{}: {result:?}", + test_case.description + ); + } +} diff --git a/crates/sqlquack-lint/src/lint/tests/test_types.rs b/crates/sqlquack-lint/src/lint/tests/test_types.rs index f7262c6d2..fb1adc9c0 100644 --- a/crates/sqlquack-lint/src/lint/tests/test_types.rs +++ b/crates/sqlquack-lint/src/lint/tests/test_types.rs @@ -115,6 +115,12 @@ pub(crate) struct NestingRefusalTestCase { pub expected_code: &'static str, } +pub(crate) struct TrailingFromTestCase { + pub description: &'static str, + pub dialect: &'static str, + pub expected_parse_error: bool, +} + pub(crate) struct CrossJoinTestCase { pub description: &'static str, pub sql: &'static str,