Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions cases/translation/functions/get-path.cast-then-path.toml
Original file line number Diff line number Diff line change
@@ -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"]
33 changes: 33 additions & 0 deletions cases/translation/functions/window-without-over.toml
Original file line number Diff line number Diff line change
@@ -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"]
1 change: 1 addition & 0 deletions crates/sqlquack-core/src/diagnostics/codes.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
12 changes: 12 additions & 0 deletions crates/sqlquack-core/src/diagnostics/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"#,
},
];
24 changes: 22 additions & 2 deletions crates/sqlquack-core/src/refusals/_helpers/rejections.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,17 @@ pub fn snowflake_rejections(
(source, lines): (&str, &SourceLines),
) -> Vec<Refusal> {
let characters: OnceCell<Vec<char>> = 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)),
Expand Down
24 changes: 24 additions & 0 deletions crates/sqlquack-core/src/refusals/tests/test_refusals.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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; \
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -557,7 +559,7 @@ fn placement(
continue;
}
if !placed.window_function
&& matches!(
&& (matches!(
node,
Expression::RowNumber(_)
| Expression::Rank(_)
Expand All @@ -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",
Expand Down
49 changes: 49 additions & 0 deletions crates/sqlquack-semantics/src/lineage/tests/test_checks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -428,3 +476,4 @@ fn given_a_postgres_query_when_checking_scope_rules_then_only_postgres_errors_ar
);
}
}

8 changes: 8 additions & 0 deletions crates/sqlquack/tests/cli/test_check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,14 @@ fn given_snowflake_sql_when_checked_then_problems_are_coded_and_the_exit_status_
expected_stdout_prefix: "",
expected_stderr_mention: "--> <stdin>: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"],
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -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"
}
Loading
Loading