diff --git a/crates/sqlquack-syntax/src/parser/_helpers/expressions/paths.rs b/crates/sqlquack-syntax/src/parser/_helpers/expressions/paths.rs index fb24f6c76..358040e8b 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/expressions/paths.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/expressions/paths.rs @@ -51,6 +51,7 @@ impl Parser { | Expression::Function(_) | Expression::ParseJson(_) | Expression::Parameter(_) + | Expression::Paren(_) ); if !is_valid_json_path_base { @@ -1157,6 +1158,7 @@ impl Parser { inferred_type: None, }); let method_call = self.parse_method_call_expression(this, col_ident, args); + let method_call = self.parse_qualified_window(method_call)?; return self.maybe_parse_subscript(method_call); } diff --git a/crates/sqlquack-syntax/src/parser/_helpers/functions/generic.rs b/crates/sqlquack-syntax/src/parser/_helpers/functions/generic.rs index 313f0e3a2..0a061e367 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/functions/generic.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/functions/generic.rs @@ -1240,6 +1240,7 @@ impl Parser { }; self.expect_token(TokenType::RParen)?; expr = self.parse_method_call_expression(expr, ident, args); + expr = self.parse_qualified_window(expr)?; } else { expr = Expression::Dot(Box::new(DotAccess { this: expr, diff --git a/crates/sqlquack-syntax/src/parser/_helpers/functions/snowflake_calls.rs b/crates/sqlquack-syntax/src/parser/_helpers/functions/snowflake_calls.rs index 222000361..6e739c859 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/functions/snowflake_calls.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/functions/snowflake_calls.rs @@ -225,4 +225,12 @@ impl Parser { self.parse_method_call_expression(schema, method, args), )) } + + /// Reads the OVER clause of a schema-qualified Snowflake call, `.( ... ) OVER ( ... )`. + pub(crate) fn parse_qualified_window(&mut self, call: Expression) -> Result { + if self.applies_snowflake_context_rules() && self.check(TokenType::Over) { + return self.maybe_parse_over(call); + } + Ok(call) + } } diff --git a/crates/sqlquack-syntax/src/parser/_helpers/types/cast_base_types.rs b/crates/sqlquack-syntax/src/parser/_helpers/types/cast_base_types.rs index c739a5e71..f10493f83 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/types/cast_base_types.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/types/cast_base_types.rs @@ -108,7 +108,14 @@ impl Parser { _ => { let base = self.convert_name_to_type(&name)?; { - if matches!(base, DataType::Custom { .. }) && self.check(TokenType::Dot) { + if matches!(base, DataType::Custom { .. }) + && self.applies_snowflake_context_rules() + && self.check(TokenType::LParen) + { + self.current -= 1; + self.parse_data_type()? + } else if matches!(base, DataType::Custom { .. }) && self.check(TokenType::Dot) + { let mut type_name = raw_name.to_string(); while self.match_token(TokenType::Dot) { let tok = self.advance()?; diff --git a/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs b/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs index a7c8506e2..6fb860578 100644 --- a/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs +++ b/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs @@ -761,6 +761,24 @@ fn given_snowflake_create_and_begin_name_when_parsing_then_the_tree_keeps_the_el pointer: "/0/use/parts/1/name", expected_value: json!("v1"), }, + ProjectionTestCase { + description: "a schema-qualified window call keeps its schema", + sql: "SELECT s1.f1 ( 1 ) OVER ( PARTITION BY 1 )", + pointer: "/0/select/expressions/0/window_function/this/method_call/this/column/name/name", + expected_value: json!("s1"), + }, + ProjectionTestCase { + description: "a path after parentheses keeps the parenthesized base", + sql: "SELECT ( PARSE_JSON ( '{}' ) ) : a . b", + pointer: "/0/select/expressions/0/j_s_o_n_extract/expression/literal/value", + expected_value: json!("a.b"), + }, + ProjectionTestCase { + description: ":: PERIOD keeps its element type like CAST", + sql: "SELECT NULL :: PERIOD ( DATE )", + pointer: "/0/select/expressions/0/cast/to/name", + expected_value: json!("PERIOD(DATE)"), + }, ProjectionTestCase { description: "USE SCHEMA with a quoted dotted name has one part", sql: "USE SCHEMA \"v1 Q.v1\"", @@ -955,3 +973,78 @@ fn given_receipts_contradicted_by_pr_68_when_parsing_then_acceptance_matches_sno ); } } + +#[test] +fn given_expression_gap_receipts_when_parsing_then_acceptance_matches_snowflake() { + let test_cases = [ + AcceptanceTestCase { + description: "a schema-qualified call takes OVER (recorded errno 2060, import 159)", + sql: "SELECT s1.f1 ( 1 ) OVER ( PARTITION BY 1 ) ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "a database-qualified call takes OVER (recorded errno 2060, import 159)", + sql: "SELECT d1.s1.f1 ( 1 ) OVER ( ORDER BY 1 ) ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "OVER needs its window (recorded errno 1003, import 159)", + sql: "SELECT s1.f1 ( 1 ) OVER ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "a path follows a parenthesized window call (recorded success, import 159)", + sql: "SELECT ( FIRST_VALUE ( PARSE_JSON ( '{\"a\":{\"b\":1}}' ) ) OVER ( ORDER BY 1 ) ) : a . b ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "a colon after parentheses needs a path (recorded errno 1003, import 159)", + sql: "SELECT ( PARSE_JSON ( '{\"a\":1}' ) ) : ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: ":: takes PERIOD with its element type (recorded success, import 159)", + sql: "SELECT '[2026-01-01, 2026-01-02)' :: PERIOD ( DATE ) ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: ":: PERIOD needs an element type (recorded errno 1003, import 159)", + sql: "SELECT NULL :: PERIOD ( ) ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "RECURSIVE cannot follow another CTE (recorded errno 1003, import 159)", + sql: "WITH v1 AS ( SELECT 1 AS n ) , RECURSIVE r ( n ) AS ( SELECT 1 UNION ALL SELECT n + 1 FROM r WHERE n < 3 ) SELECT n FROM r ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "a later CTE may recurse without RECURSIVE (recorded success, import 159)", + sql: "WITH v1 AS ( SELECT 1 AS n ) , r ( n ) AS ( SELECT 1 UNION ALL SELECT n + 1 FROM r WHERE n < 3 ) SELECT n FROM r ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "a lambda takes at least one parameter (recorded errno 1003)", + sql: "SELECT ACCUMULATE ( 1 , ( ) -> 0 , ( s , x ) -> s + x , ( s1 , s2 ) -> s1 + s2 , s -> s ) ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + ]; + + for test_case in test_cases { + assert_eq!( + accepted(test_case.sql, test_case.dialect), + test_case.expected_accepted, + "{}", + test_case.description + ); + } +}