diff --git a/cases/translation/functions/get-path.cast-then-path.toml b/cases/translation/functions/get-path.cast-then-path.toml new file mode 100644 index 000000000..38039dccb --- /dev/null +++ b/cases/translation/functions/get-path.cast-then-path.toml @@ -0,0 +1,30 @@ +category = "translation" +construct = "sf.functions.get-path.colon.syntax" +session_profile = "utc-v1" + +[[case]] +id = "t001_variant_cast_then_path" +description = "A colon path directly after a ::VARIANT cast, without parentheses. Records whether Snowflake accepts it." +columns = [{ name = "k", type = "NUMBER(38,0)" }] +rows = [["1"]] +query = "SELECT PARSE_JSON('{\"city\":\"x\"}')::VARIANT:city AS r FROM input ORDER BY ordinal" +order = "ordered" +tags = ["type-combination"] + +[[case]] +id = "t002_structured_object_cast_then_path" +description = "A colon path directly after a ::OBJECT(city VARCHAR) cast, without parentheses. Records whether Snowflake accepts it." +columns = [{ name = "k", type = "NUMBER(38,0)" }] +rows = [["1"]] +query = "SELECT OBJECT_CONSTRUCT('city', 'x')::OBJECT(city VARCHAR):city AS r FROM input ORDER BY ordinal" +order = "ordered" +tags = ["type-combination"] + +[[case]] +id = "t003_parenthesised_structured_object_path" +description = "Control: the same structured OBJECT cast in parentheses, then the colon path." +columns = [{ name = "k", type = "NUMBER(38,0)" }] +rows = [["1"]] +query = "SELECT (OBJECT_CONSTRUCT('city', 'x')::OBJECT(city VARCHAR)):city AS r FROM input ORDER BY ordinal" +order = "ordered" +tags = ["type-combination"] diff --git a/cases/translation/functions/window-without-over.toml b/cases/translation/functions/window-without-over.toml new file mode 100644 index 000000000..72bab8ebe --- /dev/null +++ b/cases/translation/functions/window-without-over.toml @@ -0,0 +1,33 @@ +category = "translation" +construct = "sf.functions.cume-dist" +session_profile = "utc-v1" + +[[case]] +id = "t001_cume_dist_without_over" +compile_error = true +description = "CUME_DIST() written without OVER, which Snowflake refuses when compiling (2064, missing window specification). Catches: a value returned instead of the error" +columns = [{ name = "k", type = "NUMBER(38,0)" }] +rows = [["1"]] +query = "SELECT CUME_DIST() AS r FROM (SELECT 1 AS x) t" +order = "ordered" +tags = ["type-combination", "error"] + +[[case]] +id = "t002_percent_rank_without_over" +compile_error = true +description = "PERCENT_RANK() written without OVER, which Snowflake refuses when compiling (2064, missing window specification). Catches: a value returned instead of the error" +columns = [{ name = "k", type = "NUMBER(38,0)" }] +rows = [["1"]] +query = "SELECT PERCENT_RANK() AS r FROM (SELECT 1 AS x) t" +order = "ordered" +tags = ["type-combination", "error"] + +[[case]] +id = "t003_nth_value_without_over" +compile_error = true +description = "NTH_VALUE(x, 1) written without OVER, which Snowflake refuses when compiling (2064, missing window specification). Catches: a value returned instead of the error" +columns = [{ name = "k", type = "NUMBER(38,0)" }] +rows = [["1"]] +query = "SELECT NTH_VALUE(x, 1) AS r FROM (SELECT 1 AS x) t" +order = "ordered" +tags = ["type-combination", "error"] diff --git a/crates/sqlquack-core/src/diagnostics/codes.lock b/crates/sqlquack-core/src/diagnostics/codes.lock index 876146d43..2c3c4d32b 100644 --- a/crates/sqlquack-core/src/diagnostics/codes.lock +++ b/crates/sqlquack-core/src/diagnostics/codes.lock @@ -20,3 +20,4 @@ SQ0201 set_operation_all SQ0202 lock_wait SQ0203 qualify_without_window SQ0204 star_filter_drops_named_column +SQ0205 window_without_over diff --git a/crates/sqlquack-core/src/diagnostics/constants.rs b/crates/sqlquack-core/src/diagnostics/constants.rs index 1757bdcd4..7bb469f72 100644 --- a/crates/sqlquack-core/src/diagnostics/constants.rs +++ b/crates/sqlquack-core/src/diagnostics/constants.rs @@ -302,4 +302,16 @@ pub const CODES: &[CodeEntry] = &[ case fails it whatever the table holds, so no schema is needed to see it.", example: r#"echo "SELECT * ILIKE '%_id' REPLACE (price * 2 AS price) FROM orders" | sqlquack check"#, }, + CodeEntry { + code: "SQ0205", + name: "window_without_over", + alias: None, + title: "A window function needs an OVER clause", + help: Some("add an OVER clause, such as `OVER (ORDER BY ...)`"), + explanation: "CUME_DIST, PERCENT_RANK and NTH_VALUE are window functions only: \ + Snowflake rejects a call to one without an OVER clause when compiling the query \ + (Snowflake error 2064, missing window specification), so the statement never runs \ + there. Add the OVER clause that says which rows the function ranks or reads.", + example: r#"echo "SELECT CUME_DIST() AS d FROM orders" | sqlquack check"#, + }, ]; diff --git a/crates/sqlquack-core/src/refusals/_helpers/rejections.rs b/crates/sqlquack-core/src/refusals/_helpers/rejections.rs index d45f7486d..d73c37313 100644 --- a/crates/sqlquack-core/src/refusals/_helpers/rejections.rs +++ b/crates/sqlquack-core/src/refusals/_helpers/rejections.rs @@ -3,9 +3,29 @@ use sqlquack_syntax::expressions::names::models::Star; use sqlquack_syntax::{Expression, ExpressionWalk}; -/// The registry name of the rejection a node causes, with the reason, if it causes one. -pub(crate) fn rejection(node: &Expression) -> Option<(&'static str, String)> { +/// Window functions Snowflake refuses without OVER (error 2064, missing window specification). +const WINDOW_ONLY_CALLS: [&str; 3] = ["CUME_DIST", "PERCENT_RANK", "NTH_VALUE"]; + +/// The rejection a node causes, if any, given the calls an OVER clause applies to (`windowed`). +pub(crate) fn rejection( + node: &Expression, + windowed: &[&Expression], +) -> Option<(&'static str, String)> { match node { + Expression::Function(call) + if WINDOW_ONLY_CALLS + .iter() + .any(|name| call.name.eq_ignore_ascii_case(name)) + && !windowed.iter().any(|window| std::ptr::eq(*window, node)) => + { + Some(( + "window_without_over", + format!( + "{} is a window function, which Snowflake runs only with an OVER clause", + call.name.to_ascii_uppercase() + ), + )) + } Expression::Intersect(operation) if operation.all => Some(set_operation_all("INTERSECT")), Expression::Except(operation) if operation.all => { Some(set_operation_all("EXCEPT or MINUS")) diff --git a/crates/sqlquack-core/src/refusals/main/snowflake_rejections.rs b/crates/sqlquack-core/src/refusals/main/snowflake_rejections.rs index fcc28d4af..4c79d9289 100644 --- a/crates/sqlquack-core/src/refusals/main/snowflake_rejections.rs +++ b/crates/sqlquack-core/src/refusals/main/snowflake_rejections.rs @@ -17,10 +17,17 @@ pub fn snowflake_rejections( (source, lines): (&str, &SourceLines), ) -> Vec { let characters: OnceCell> = OnceCell::new(); + let windowed: Vec<&Expression> = statement + .dfs() + .filter_map(|node| match node { + Expression::WindowFunction(window) => Some(&window.this), + _ => None, + }) + .collect(); statement .dfs() .filter_map(|node| { - let (name, reason) = rejection(node)?; + let (name, reason) = rejection(node, &windowed)?; let text = characters.get_or_init(|| source.chars().collect()); Some(Refusal { construct: Some(Construct::new(ConstructFamily::Syntax, name)), diff --git a/crates/sqlquack-core/src/refusals/tests/test_refusals.rs b/crates/sqlquack-core/src/refusals/tests/test_refusals.rs index 2ac00ac8f..8d6d049df 100644 --- a/crates/sqlquack-core/src/refusals/tests/test_refusals.rs +++ b/crates/sqlquack-core/src/refusals/tests/test_refusals.rs @@ -114,6 +114,30 @@ fn given_sql_snowflake_parses_then_rejects_when_refusing_then_each_rejection_is_ ], expected_codes: vec!["SQ0204", "SQ0204"], }, + RejectionTestCase { + description: "CUME_DIST, PERCENT_RANK and NTH_VALUE without OVER are rejected, also nested", + sql: "SELECT CUME_DIST() AS c FROM orders; \ + SELECT percent_rank() + 1 AS p FROM orders; \ + SELECT order_id FROM orders WHERE NTH_VALUE(price, 1) > 0", + expected_rejections: vec![ + "window_without_over", + "window_without_over", + "window_without_over", + ], + expected_codes: vec!["SQ0205", "SQ0205", "SQ0205"], + }, + RejectionTestCase { + description: "the same calls under OVER are left to conversion, an inner one without it is not", + sql: "SELECT CUME_DIST() OVER (ORDER BY price) AS c, \ + PERCENT_RANK() OVER (PARTITION BY customer_id ORDER BY price) AS p, \ + NTH_VALUE(price, 1) OVER (ORDER BY order_id) AS n FROM orders; \ + SELECT NTH_VALUE(price, 2) FROM LAST IGNORE NULLS OVER (ORDER BY order_id) AS n FROM orders; \ + SELECT NTH_VALUE(price, 1) FROM FIRST RESPECT NULLS OVER (ORDER BY order_id) AS n FROM orders; \ + SELECT NTH_VALUE(price, 1) IGNORE NULLS OVER (ORDER BY order_id) AS n FROM orders; \ + SELECT NTH_VALUE(CUME_DIST(), 1) OVER (ORDER BY order_id) AS n FROM orders", + expected_rejections: vec!["window_without_over"], + expected_codes: vec!["SQ0205"], + }, RejectionTestCase { description: "a star without ILIKE, or ILIKE without REPLACE or RENAME, is left to conversion", sql: "SELECT * REPLACE (price * 2 AS price) FROM orders; \ diff --git a/crates/sqlquack-semantics/src/lineage/_helpers/checks/scope_rules.rs b/crates/sqlquack-semantics/src/lineage/_helpers/checks/scope_rules.rs index 37c07eb96..5cf613e52 100644 --- a/crates/sqlquack-semantics/src/lineage/_helpers/checks/scope_rules.rs +++ b/crates/sqlquack-semantics/src/lineage/_helpers/checks/scope_rules.rs @@ -18,6 +18,8 @@ const COLUMNS_FUNCTION: &str = "columns"; const NOT_GROUPED: &str = "Column must be grouped or used in an aggregate function"; const MISPLACED_AGGREGATE: &str = "Aggregate function is not allowed in this clause or inside another aggregate"; +/// Window functions Snowflake refuses without OVER (2064) that its faithful parse keeps as named calls. +const WINDOW_ONLY_CALLS: [&str; 3] = ["CUME_DIST", "PERCENT_RANK", "NTH_VALUE"]; const MISPLACED_WINDOW: &str = "Window function is not allowed in this clause or inside another aggregate/window function"; @@ -557,7 +559,7 @@ fn placement( continue; } if !placed.window_function - && matches!( + && (matches!( node, Expression::RowNumber(_) | Expression::Rank(_) @@ -567,7 +569,12 @@ fn placement( | Expression::Lead(_) | Expression::FirstValue(_) | Expression::LastValue(_) + | Expression::NthValue(_) ) + || matches!(node, Expression::CumeDist(call) if call.args.is_empty()) + || matches!(node, Expression::PercentRank(call) if call.args.is_empty()) + || matches!(node, Expression::Function(call) + if WINDOW_ONLY_CALLS.iter().any(|name| call.name.eq_ignore_ascii_case(name)))) { findings.push(rule( "E232", diff --git a/crates/sqlquack-semantics/src/lineage/tests/test_checks.rs b/crates/sqlquack-semantics/src/lineage/tests/test_checks.rs index a36c1ee43..6160d78ac 100644 --- a/crates/sqlquack-semantics/src/lineage/tests/test_checks.rs +++ b/crates/sqlquack-semantics/src/lineage/tests/test_checks.rs @@ -32,6 +32,30 @@ fn given_a_duckdb_select_when_checking_its_scope_rules_then_broken_rules_are_fou sql: "SELECT ROW_NUMBER() AS r FROM orders", expected_findings: &[("E232", "Window function requires an OVER clause", 0)], }, + QueryFindingsTestCase { + description: "CUME_DIST without OVER", + sql: "SELECT CUME_DIST() AS w FROM orders", + expected_findings: &[("E232", "Window function requires an OVER clause", 0)], + }, + QueryFindingsTestCase { + description: "PERCENT_RANK without OVER", + sql: "SELECT PERCENT_RANK() AS w FROM orders", + expected_findings: &[("E232", "Window function requires an OVER clause", 0)], + }, + QueryFindingsTestCase { + description: "NTH_VALUE without OVER", + sql: "SELECT NTH_VALUE(id, 1) AS w FROM orders", + expected_findings: &[("E232", "Window function requires an OVER clause", 17)], + }, + QueryFindingsTestCase { + description: "CUME_DIST, PERCENT_RANK and NTH_VALUE under OVER", + sql: "SELECT CUME_DIST() OVER (ORDER BY id) AS c, PERCENT_RANK() OVER (ORDER BY id) AS p, \ + NTH_VALUE(id, 1) OVER (ORDER BY id) AS n, \ + NTH_VALUE(id, 2) FROM LAST IGNORE NULLS OVER (ORDER BY id) AS l, \ + NTH_VALUE(id, 1) FROM FIRST RESPECT NULLS OVER (ORDER BY id) AS f, \ + NTH_VALUE(id, 1) IGNORE NULLS OVER (ORDER BY id) AS i FROM orders", + expected_findings: &[], + }, QueryFindingsTestCase { description: "two FROM items under one alias", sql: "SELECT o.id FROM orders o JOIN customers o ON TRUE", @@ -172,6 +196,30 @@ fn given_a_snowflake_query_when_checking_built_in_calls_and_types_then_definite_ 18, )], }, + QueryFindingsTestCase { + description: "CUME_DIST without OVER, as Snowflake parses it", + sql: "SELECT CUME_DIST() AS w FROM orders", + expected_findings: &[("E232", "Window function requires an OVER clause", 7)], + }, + QueryFindingsTestCase { + description: "PERCENT_RANK without OVER, as Snowflake parses it", + sql: "SELECT PERCENT_RANK() AS w FROM orders", + expected_findings: &[("E232", "Window function requires an OVER clause", 7)], + }, + QueryFindingsTestCase { + description: "NTH_VALUE without OVER, as Snowflake parses it", + sql: "SELECT NTH_VALUE(id, 1) AS w FROM orders", + expected_findings: &[("E232", "Window function requires an OVER clause", 7)], + }, + QueryFindingsTestCase { + description: "CUME_DIST, PERCENT_RANK and NTH_VALUE under OVER", + sql: "SELECT CUME_DIST() OVER (ORDER BY id) AS c, PERCENT_RANK() OVER (ORDER BY id) AS p, \ + NTH_VALUE(id, 1) OVER (ORDER BY id) AS n, \ + NTH_VALUE(id, 2) FROM LAST IGNORE NULLS OVER (ORDER BY id) AS l, \ + NTH_VALUE(id, 1) FROM FIRST RESPECT NULLS OVER (ORDER BY id) AS f, \ + NTH_VALUE(id, 1) IGNORE NULLS OVER (ORDER BY id) AS i FROM orders", + expected_findings: &[], + }, QueryFindingsTestCase { description: "a number condition in WHERE", sql: "SELECT id FROM orders WHERE amount", @@ -428,3 +476,4 @@ fn given_a_postgres_query_when_checking_scope_rules_then_only_postgres_errors_ar ); } } + diff --git a/crates/sqlquack/tests/cli/test_check.rs b/crates/sqlquack/tests/cli/test_check.rs index 1b43e592a..68a6df592 100644 --- a/crates/sqlquack/tests/cli/test_check.rs +++ b/crates/sqlquack/tests/cli/test_check.rs @@ -60,6 +60,14 @@ fn given_snowflake_sql_when_checked_then_problems_are_coded_and_the_exit_status_ expected_stdout_prefix: "", expected_stderr_mention: "--> :1:25\n", }, + CheckCommandTestCase { + description: "a window function without OVER is SQ0205 and exits 1", + arguments: &["check"], + sql: "SELECT PERCENT_RANK() AS p FROM orders", + expected_exit_code: Some(1), + expected_stdout_prefix: "", + expected_stderr_mention: "error[SQ0205]", + }, CheckCommandTestCase { description: "SQL that does not parse is SQ0001 and exits 1", arguments: &["check"], diff --git a/evidence/results/22ccd171bbd822a887451dc251ad16012e1e2cc5e054a8ace797e9cc44712438.json b/evidence/results/22ccd171bbd822a887451dc251ad16012e1e2cc5e054a8ace797e9cc44712438.json new file mode 100644 index 000000000..87cc8a432 --- /dev/null +++ b/evidence/results/22ccd171bbd822a887451dc251ad16012e1e2cc5e054a8ace797e9cc44712438.json @@ -0,0 +1,55 @@ +{ + "arrow_sha256": null, + "case_hash": "22ccd171bbd822a887451dc251ad16012e1e2cc5e054a8ace797e9cc44712438", + "columns": [], + "connector": "snowflake-connector-python 4.7.2", + "duckdb_version": "v2.0.0", + "effective_parameters": { + "BINARY_INPUT_FORMAT": "HEX", + "BINARY_OUTPUT_FORMAT": "HEX", + "DATE_INPUT_FORMAT": "AUTO", + "DATE_OUTPUT_FORMAT": "YYYY-MM-DD", + "DEFAULT_NULL_ORDERING": "LAST", + "GEOGRAPHY_OUTPUT_FORMAT": "GeoJSON", + "GEOMETRY_OUTPUT_FORMAT": "GeoJSON", + "JSON_INDENT": "2", + "QUOTED_IDENTIFIERS_IGNORE_CASE": "false", + "STRICT_JSON_OUTPUT": "false", + "TIMESTAMP_DAY_IS_ALWAYS_24H": "false", + "TIMESTAMP_INPUT_FORMAT": "AUTO", + "TIMESTAMP_LTZ_OUTPUT_FORMAT": "", + "TIMESTAMP_NTZ_OUTPUT_FORMAT": "YYYY-MM-DD HH24:MI:SS.FF3", + "TIMESTAMP_OUTPUT_FORMAT": "YYYY-MM-DD HH24:MI:SS.FF3 TZHTZM", + "TIMESTAMP_TYPE_MAPPING": "TIMESTAMP_NTZ", + "TIMESTAMP_TZ_OUTPUT_FORMAT": "", + "TIMEZONE": "UTC", + "TIME_INPUT_FORMAT": "AUTO", + "TIME_OUTPUT_FORMAT": "HH24:MI:SS", + "TWO_DIGIT_CENTURY_START": "1970", + "USE_CACHED_RESULT": "false", + "WEEK_OF_YEAR_POLICY": "0", + "WEEK_START": "0" + }, + "error": { + "errno": 2064, + "message": null, + "sqlstate": "42601" + }, + "format": 1, + "outcome": "error", + "parameters_sha256": "d094d15417903748055eca8fde8cf2884751253939f05ad5ad8d9bf14299ac61", + "query_id_sha256": "f7698193528af7ec4813e5de9ec1c230767190abf9c5cab360040668c776d91e", + "recorded_at": "2026-11-06T07:18:08.465533+00:00", + "recorder": "sqlquack-record 0.1.0", + "row_count": null, + "session_parameters": { + "DEFAULT_NULL_ORDERING": "LAST", + "QUOTED_IDENTIFIERS_IGNORE_CASE": false, + "TIMESTAMP_TYPE_MAPPING": "TIMESTAMP_NTZ", + "TIMEZONE": "UTC", + "USE_CACHED_RESULT": false, + "WEEK_OF_YEAR_POLICY": 0, + "WEEK_START": 0 + }, + "warehouse_version": "10.40.102" +} diff --git a/evidence/results/3362db7ab26c350e5f51dd0c63b4b31d08be80e0b91605da058c3b39ff5d896d.arrow b/evidence/results/3362db7ab26c350e5f51dd0c63b4b31d08be80e0b91605da058c3b39ff5d896d.arrow new file mode 100644 index 000000000..c88c2b456 Binary files /dev/null and b/evidence/results/3362db7ab26c350e5f51dd0c63b4b31d08be80e0b91605da058c3b39ff5d896d.arrow differ diff --git a/evidence/results/3362db7ab26c350e5f51dd0c63b4b31d08be80e0b91605da058c3b39ff5d896d.json b/evidence/results/3362db7ab26c350e5f51dd0c63b4b31d08be80e0b91605da058c3b39ff5d896d.json new file mode 100644 index 000000000..af3347396 --- /dev/null +++ b/evidence/results/3362db7ab26c350e5f51dd0c63b4b31d08be80e0b91605da058c3b39ff5d896d.json @@ -0,0 +1,61 @@ +{ + "arrow_sha256": "78aa36fd259a1ffb633095ab8e45edf947528ae4153b9fb9c05638319a1c8ac3", + "case_hash": "3362db7ab26c350e5f51dd0c63b4b31d08be80e0b91605da058c3b39ff5d896d", + "columns": [ + { + "internal_size": 134217728, + "name": "R", + "nullable": true, + "precision": null, + "scale": null, + "type_code": 2, + "type_name": "TEXT" + } + ], + "connector": "snowflake-connector-python 4.7.2", + "duckdb_version": "v2.0.0", + "effective_parameters": { + "BINARY_INPUT_FORMAT": "HEX", + "BINARY_OUTPUT_FORMAT": "HEX", + "DATE_INPUT_FORMAT": "AUTO", + "DATE_OUTPUT_FORMAT": "YYYY-MM-DD", + "DEFAULT_NULL_ORDERING": "LAST", + "GEOGRAPHY_OUTPUT_FORMAT": "GeoJSON", + "GEOMETRY_OUTPUT_FORMAT": "GeoJSON", + "JSON_INDENT": "2", + "QUOTED_IDENTIFIERS_IGNORE_CASE": "false", + "STRICT_JSON_OUTPUT": "false", + "TIMESTAMP_DAY_IS_ALWAYS_24H": "false", + "TIMESTAMP_INPUT_FORMAT": "AUTO", + "TIMESTAMP_LTZ_OUTPUT_FORMAT": "", + "TIMESTAMP_NTZ_OUTPUT_FORMAT": "YYYY-MM-DD HH24:MI:SS.FF3", + "TIMESTAMP_OUTPUT_FORMAT": "YYYY-MM-DD HH24:MI:SS.FF3 TZHTZM", + "TIMESTAMP_TYPE_MAPPING": "TIMESTAMP_NTZ", + "TIMESTAMP_TZ_OUTPUT_FORMAT": "", + "TIMEZONE": "UTC", + "TIME_INPUT_FORMAT": "AUTO", + "TIME_OUTPUT_FORMAT": "HH24:MI:SS", + "TWO_DIGIT_CENTURY_START": "1970", + "USE_CACHED_RESULT": "false", + "WEEK_OF_YEAR_POLICY": "0", + "WEEK_START": "0" + }, + "error": null, + "format": 1, + "outcome": "rows", + "parameters_sha256": "d094d15417903748055eca8fde8cf2884751253939f05ad5ad8d9bf14299ac61", + "query_id_sha256": "e6e58604fde117db9380abeeb426b8f544e6c112949eef2d182f9b5443ad8ede", + "recorded_at": "2026-11-06T06:33:11.556124+00:00", + "recorder": "sqlquack-record 0.1.0", + "row_count": 1, + "session_parameters": { + "DEFAULT_NULL_ORDERING": "LAST", + "QUOTED_IDENTIFIERS_IGNORE_CASE": false, + "TIMESTAMP_TYPE_MAPPING": "TIMESTAMP_NTZ", + "TIMEZONE": "UTC", + "USE_CACHED_RESULT": false, + "WEEK_OF_YEAR_POLICY": 0, + "WEEK_START": 0 + }, + "warehouse_version": "10.40.102" +} diff --git a/evidence/results/3494587befb1edb2e0867ef73daf4c045dd3fc7453490a33de25e9ac9b31cc52.json b/evidence/results/3494587befb1edb2e0867ef73daf4c045dd3fc7453490a33de25e9ac9b31cc52.json new file mode 100644 index 000000000..ce33c8795 --- /dev/null +++ b/evidence/results/3494587befb1edb2e0867ef73daf4c045dd3fc7453490a33de25e9ac9b31cc52.json @@ -0,0 +1,55 @@ +{ + "arrow_sha256": null, + "case_hash": "3494587befb1edb2e0867ef73daf4c045dd3fc7453490a33de25e9ac9b31cc52", + "columns": [], + "connector": "snowflake-connector-python 4.7.2", + "duckdb_version": "v2.0.0", + "effective_parameters": { + "BINARY_INPUT_FORMAT": "HEX", + "BINARY_OUTPUT_FORMAT": "HEX", + "DATE_INPUT_FORMAT": "AUTO", + "DATE_OUTPUT_FORMAT": "YYYY-MM-DD", + "DEFAULT_NULL_ORDERING": "LAST", + "GEOGRAPHY_OUTPUT_FORMAT": "GeoJSON", + "GEOMETRY_OUTPUT_FORMAT": "GeoJSON", + "JSON_INDENT": "2", + "QUOTED_IDENTIFIERS_IGNORE_CASE": "false", + "STRICT_JSON_OUTPUT": "false", + "TIMESTAMP_DAY_IS_ALWAYS_24H": "false", + "TIMESTAMP_INPUT_FORMAT": "AUTO", + "TIMESTAMP_LTZ_OUTPUT_FORMAT": "", + "TIMESTAMP_NTZ_OUTPUT_FORMAT": "YYYY-MM-DD HH24:MI:SS.FF3", + "TIMESTAMP_OUTPUT_FORMAT": "YYYY-MM-DD HH24:MI:SS.FF3 TZHTZM", + "TIMESTAMP_TYPE_MAPPING": "TIMESTAMP_NTZ", + "TIMESTAMP_TZ_OUTPUT_FORMAT": "", + "TIMEZONE": "UTC", + "TIME_INPUT_FORMAT": "AUTO", + "TIME_OUTPUT_FORMAT": "HH24:MI:SS", + "TWO_DIGIT_CENTURY_START": "1970", + "USE_CACHED_RESULT": "false", + "WEEK_OF_YEAR_POLICY": "0", + "WEEK_START": "0" + }, + "error": { + "errno": 2064, + "message": null, + "sqlstate": "42601" + }, + "format": 1, + "outcome": "error", + "parameters_sha256": "d094d15417903748055eca8fde8cf2884751253939f05ad5ad8d9bf14299ac61", + "query_id_sha256": "f105714f3b4a26e6fb37903190aabc16e315282866c22c7d941a1d1ab9b8dc87", + "recorded_at": "2026-11-06T07:18:08.465533+00:00", + "recorder": "sqlquack-record 0.1.0", + "row_count": null, + "session_parameters": { + "DEFAULT_NULL_ORDERING": "LAST", + "QUOTED_IDENTIFIERS_IGNORE_CASE": false, + "TIMESTAMP_TYPE_MAPPING": "TIMESTAMP_NTZ", + "TIMEZONE": "UTC", + "USE_CACHED_RESULT": false, + "WEEK_OF_YEAR_POLICY": 0, + "WEEK_START": 0 + }, + "warehouse_version": "10.40.102" +} diff --git a/evidence/results/7ca5bf74d8e5e862bb37c4fb22a4899f8eb598441541055a23c4dc3d76fa1f94.arrow b/evidence/results/7ca5bf74d8e5e862bb37c4fb22a4899f8eb598441541055a23c4dc3d76fa1f94.arrow new file mode 100644 index 000000000..c1e871448 Binary files /dev/null and b/evidence/results/7ca5bf74d8e5e862bb37c4fb22a4899f8eb598441541055a23c4dc3d76fa1f94.arrow differ diff --git a/evidence/results/7ca5bf74d8e5e862bb37c4fb22a4899f8eb598441541055a23c4dc3d76fa1f94.json b/evidence/results/7ca5bf74d8e5e862bb37c4fb22a4899f8eb598441541055a23c4dc3d76fa1f94.json new file mode 100644 index 000000000..ae5408a12 --- /dev/null +++ b/evidence/results/7ca5bf74d8e5e862bb37c4fb22a4899f8eb598441541055a23c4dc3d76fa1f94.json @@ -0,0 +1,61 @@ +{ + "arrow_sha256": "13d21a4fc813f47997cb0d107a6fc8a8f14429ef3531144d7bc3611b5d6f8c8d", + "case_hash": "7ca5bf74d8e5e862bb37c4fb22a4899f8eb598441541055a23c4dc3d76fa1f94", + "columns": [ + { + "internal_size": null, + "name": "R", + "nullable": true, + "precision": null, + "scale": null, + "type_code": 5, + "type_name": "VARIANT" + } + ], + "connector": "snowflake-connector-python 4.7.2", + "duckdb_version": "v2.0.0", + "effective_parameters": { + "BINARY_INPUT_FORMAT": "HEX", + "BINARY_OUTPUT_FORMAT": "HEX", + "DATE_INPUT_FORMAT": "AUTO", + "DATE_OUTPUT_FORMAT": "YYYY-MM-DD", + "DEFAULT_NULL_ORDERING": "LAST", + "GEOGRAPHY_OUTPUT_FORMAT": "GeoJSON", + "GEOMETRY_OUTPUT_FORMAT": "GeoJSON", + "JSON_INDENT": "2", + "QUOTED_IDENTIFIERS_IGNORE_CASE": "false", + "STRICT_JSON_OUTPUT": "false", + "TIMESTAMP_DAY_IS_ALWAYS_24H": "false", + "TIMESTAMP_INPUT_FORMAT": "AUTO", + "TIMESTAMP_LTZ_OUTPUT_FORMAT": "", + "TIMESTAMP_NTZ_OUTPUT_FORMAT": "YYYY-MM-DD HH24:MI:SS.FF3", + "TIMESTAMP_OUTPUT_FORMAT": "YYYY-MM-DD HH24:MI:SS.FF3 TZHTZM", + "TIMESTAMP_TYPE_MAPPING": "TIMESTAMP_NTZ", + "TIMESTAMP_TZ_OUTPUT_FORMAT": "", + "TIMEZONE": "UTC", + "TIME_INPUT_FORMAT": "AUTO", + "TIME_OUTPUT_FORMAT": "HH24:MI:SS", + "TWO_DIGIT_CENTURY_START": "1970", + "USE_CACHED_RESULT": "false", + "WEEK_OF_YEAR_POLICY": "0", + "WEEK_START": "0" + }, + "error": null, + "format": 1, + "outcome": "rows", + "parameters_sha256": "d094d15417903748055eca8fde8cf2884751253939f05ad5ad8d9bf14299ac61", + "query_id_sha256": "1d729f6088f6279d86ba55f0310708b0bd2a82411ebba51f32234b739ecdeebd", + "recorded_at": "2026-11-06T06:33:11.556124+00:00", + "recorder": "sqlquack-record 0.1.0", + "row_count": 1, + "session_parameters": { + "DEFAULT_NULL_ORDERING": "LAST", + "QUOTED_IDENTIFIERS_IGNORE_CASE": false, + "TIMESTAMP_TYPE_MAPPING": "TIMESTAMP_NTZ", + "TIMEZONE": "UTC", + "USE_CACHED_RESULT": false, + "WEEK_OF_YEAR_POLICY": 0, + "WEEK_START": 0 + }, + "warehouse_version": "10.40.102" +} diff --git a/evidence/results/9babe630df393a56dfe78717ce0a61f6de565e062c2fb3df382b39ae2ff6be61.arrow b/evidence/results/9babe630df393a56dfe78717ce0a61f6de565e062c2fb3df382b39ae2ff6be61.arrow new file mode 100644 index 000000000..c88c2b456 Binary files /dev/null and b/evidence/results/9babe630df393a56dfe78717ce0a61f6de565e062c2fb3df382b39ae2ff6be61.arrow differ diff --git a/evidence/results/9babe630df393a56dfe78717ce0a61f6de565e062c2fb3df382b39ae2ff6be61.json b/evidence/results/9babe630df393a56dfe78717ce0a61f6de565e062c2fb3df382b39ae2ff6be61.json new file mode 100644 index 000000000..0b60456bd --- /dev/null +++ b/evidence/results/9babe630df393a56dfe78717ce0a61f6de565e062c2fb3df382b39ae2ff6be61.json @@ -0,0 +1,61 @@ +{ + "arrow_sha256": "78aa36fd259a1ffb633095ab8e45edf947528ae4153b9fb9c05638319a1c8ac3", + "case_hash": "9babe630df393a56dfe78717ce0a61f6de565e062c2fb3df382b39ae2ff6be61", + "columns": [ + { + "internal_size": 134217728, + "name": "R", + "nullable": true, + "precision": null, + "scale": null, + "type_code": 2, + "type_name": "TEXT" + } + ], + "connector": "snowflake-connector-python 4.7.2", + "duckdb_version": "v2.0.0", + "effective_parameters": { + "BINARY_INPUT_FORMAT": "HEX", + "BINARY_OUTPUT_FORMAT": "HEX", + "DATE_INPUT_FORMAT": "AUTO", + "DATE_OUTPUT_FORMAT": "YYYY-MM-DD", + "DEFAULT_NULL_ORDERING": "LAST", + "GEOGRAPHY_OUTPUT_FORMAT": "GeoJSON", + "GEOMETRY_OUTPUT_FORMAT": "GeoJSON", + "JSON_INDENT": "2", + "QUOTED_IDENTIFIERS_IGNORE_CASE": "false", + "STRICT_JSON_OUTPUT": "false", + "TIMESTAMP_DAY_IS_ALWAYS_24H": "false", + "TIMESTAMP_INPUT_FORMAT": "AUTO", + "TIMESTAMP_LTZ_OUTPUT_FORMAT": "", + "TIMESTAMP_NTZ_OUTPUT_FORMAT": "YYYY-MM-DD HH24:MI:SS.FF3", + "TIMESTAMP_OUTPUT_FORMAT": "YYYY-MM-DD HH24:MI:SS.FF3 TZHTZM", + "TIMESTAMP_TYPE_MAPPING": "TIMESTAMP_NTZ", + "TIMESTAMP_TZ_OUTPUT_FORMAT": "", + "TIMEZONE": "UTC", + "TIME_INPUT_FORMAT": "AUTO", + "TIME_OUTPUT_FORMAT": "HH24:MI:SS", + "TWO_DIGIT_CENTURY_START": "1970", + "USE_CACHED_RESULT": "false", + "WEEK_OF_YEAR_POLICY": "0", + "WEEK_START": "0" + }, + "error": null, + "format": 1, + "outcome": "rows", + "parameters_sha256": "d094d15417903748055eca8fde8cf2884751253939f05ad5ad8d9bf14299ac61", + "query_id_sha256": "2f4f33e3be3a953aedf94a082bb8542e8ef8b709e7befbc9fa9c9e5f44ee3763", + "recorded_at": "2026-11-06T06:33:11.556124+00:00", + "recorder": "sqlquack-record 0.1.0", + "row_count": 1, + "session_parameters": { + "DEFAULT_NULL_ORDERING": "LAST", + "QUOTED_IDENTIFIERS_IGNORE_CASE": false, + "TIMESTAMP_TYPE_MAPPING": "TIMESTAMP_NTZ", + "TIMEZONE": "UTC", + "USE_CACHED_RESULT": false, + "WEEK_OF_YEAR_POLICY": 0, + "WEEK_START": 0 + }, + "warehouse_version": "10.40.102" +} diff --git a/evidence/results/d54a21b01972927df8289b313a918425acaf3c08934e219873944ff068f97100.json b/evidence/results/d54a21b01972927df8289b313a918425acaf3c08934e219873944ff068f97100.json new file mode 100644 index 000000000..7408ab708 --- /dev/null +++ b/evidence/results/d54a21b01972927df8289b313a918425acaf3c08934e219873944ff068f97100.json @@ -0,0 +1,55 @@ +{ + "arrow_sha256": null, + "case_hash": "d54a21b01972927df8289b313a918425acaf3c08934e219873944ff068f97100", + "columns": [], + "connector": "snowflake-connector-python 4.7.2", + "duckdb_version": "v2.0.0", + "effective_parameters": { + "BINARY_INPUT_FORMAT": "HEX", + "BINARY_OUTPUT_FORMAT": "HEX", + "DATE_INPUT_FORMAT": "AUTO", + "DATE_OUTPUT_FORMAT": "YYYY-MM-DD", + "DEFAULT_NULL_ORDERING": "LAST", + "GEOGRAPHY_OUTPUT_FORMAT": "GeoJSON", + "GEOMETRY_OUTPUT_FORMAT": "GeoJSON", + "JSON_INDENT": "2", + "QUOTED_IDENTIFIERS_IGNORE_CASE": "false", + "STRICT_JSON_OUTPUT": "false", + "TIMESTAMP_DAY_IS_ALWAYS_24H": "false", + "TIMESTAMP_INPUT_FORMAT": "AUTO", + "TIMESTAMP_LTZ_OUTPUT_FORMAT": "", + "TIMESTAMP_NTZ_OUTPUT_FORMAT": "YYYY-MM-DD HH24:MI:SS.FF3", + "TIMESTAMP_OUTPUT_FORMAT": "YYYY-MM-DD HH24:MI:SS.FF3 TZHTZM", + "TIMESTAMP_TYPE_MAPPING": "TIMESTAMP_NTZ", + "TIMESTAMP_TZ_OUTPUT_FORMAT": "", + "TIMEZONE": "UTC", + "TIME_INPUT_FORMAT": "AUTO", + "TIME_OUTPUT_FORMAT": "HH24:MI:SS", + "TWO_DIGIT_CENTURY_START": "1970", + "USE_CACHED_RESULT": "false", + "WEEK_OF_YEAR_POLICY": "0", + "WEEK_START": "0" + }, + "error": { + "errno": 2064, + "message": null, + "sqlstate": "42601" + }, + "format": 1, + "outcome": "error", + "parameters_sha256": "d094d15417903748055eca8fde8cf2884751253939f05ad5ad8d9bf14299ac61", + "query_id_sha256": "33673c1a3247d78cb1521eb0294343ed8824a905ad3dd11ec3401b8f385c1771", + "recorded_at": "2026-11-06T07:18:08.465533+00:00", + "recorder": "sqlquack-record 0.1.0", + "row_count": null, + "session_parameters": { + "DEFAULT_NULL_ORDERING": "LAST", + "QUOTED_IDENTIFIERS_IGNORE_CASE": false, + "TIMESTAMP_TYPE_MAPPING": "TIMESTAMP_NTZ", + "TIMEZONE": "UTC", + "USE_CACHED_RESULT": false, + "WEEK_OF_YEAR_POLICY": 0, + "WEEK_START": 0 + }, + "warehouse_version": "10.40.102" +} diff --git a/website/src/content/docs/docs/diagnostics/reference.mdx b/website/src/content/docs/docs/diagnostics/reference.mdx index 4b3d01460..b3114cf4a 100644 --- a/website/src/content/docs/docs/diagnostics/reference.mdx +++ b/website/src/content/docs/docs/diagnostics/reference.mdx @@ -33,6 +33,7 @@ renumbered. See [Reading diagnostics](/docs/diagnostics/overview/) for the outpu | [`SQ0202`](#sq0202) | `lock_wait` | Snowflake does not support WAIT in FOR UPDATE | | [`SQ0203`](#sq0203) | `qualify_without_window` | QUALIFY needs a window function | | [`SQ0204`](#sq0204) | `star_filter_drops_named_column` | REPLACE or RENAME names a column the star's ILIKE pattern removed | +| [`SQ0205`](#sq0205) | `window_without_over` | A window function needs an OVER clause | ## Conversion @@ -321,3 +322,17 @@ In `* ILIKE 'pattern' REPLACE (...)` or `RENAME (...)`, Snowflake applies the pa ```sh echo "SELECT * ILIKE '%_id' REPLACE (price * 2 AS price) FROM orders" | sqlquack check ``` + +### SQ0205 + +**A window function needs an OVER clause** (`window_without_over`) + +CUME_DIST, PERCENT_RANK and NTH_VALUE are window functions only: Snowflake rejects a call to one without an OVER clause when compiling the query (Snowflake error 2064, missing window specification), so the statement never runs there. Add the OVER clause that says which rows the function ranks or reads. + +**Help:** add an OVER clause, such as `OVER (ORDER BY ...)` + +**Example:** + +```sh +echo "SELECT CUME_DIST() AS d FROM orders" | sqlquack check +```