From 8418fc4ed9fd412fd1cd3eb103df235c2226f9cb Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 02:37:34 +0000 Subject: [PATCH 1/6] Match Snowflake on lone type words, empty conversion calls and CREATE EXPERIMENT TYPE From recorded receipts: - PRECISION or VARYING alone as a type is a parse error in faithful modes (Snowflake 1003); DOUBLE PRECISION, CHARACTER VARYING and unknown type names (2040, a compile error) still parse. - TO_TIMESTAMP ( ) and the other expression-list conversion calls parse with no arguments in faithful modes, as Snowflake does (a compile error there). IDENTIFIER ( ) stays refused. - CREATE EXPERIMENT TYPE = [ FROM SPECIFICATION ] is a parsed CreateObject instead of opaque text, so a missing FROM or SPECIFICATION is a parse error (1003). - CREATE TYPE ... with no object kind is a parse error (1003). --- .../_helpers/ddl/snowflake_create_from.rs | 7 ++ .../src/parser/_helpers/functions/typed.rs | 7 +- .../src/parser/_helpers/statements/dml.rs | 2 +- .../_helpers/statements/snowflake_context.rs | 10 +++ .../src/parser/_helpers/types/casts.rs | 4 + .../src/parser/_helpers/types/data_types.rs | 1 + .../tests/parser/test_snowflake_fixes.rs | 84 ++++++++++++++++++- 7 files changed, 112 insertions(+), 3 deletions(-) diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_create_from.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_create_from.rs index d161b5273..0dbe828ee 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_create_from.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_create_from.rs @@ -113,6 +113,13 @@ impl Parser { let name = self.parse_object_name()?; let mut operands: Vec = Vec::new(); let mut properties: Vec = Vec::new(); + if matches!(kind, "EXPERIMENT") + && (self.check(TokenType::Type) || self.check_identifier("TYPE")) + { + self.skip(); + self.expect_token(TokenType::Eq)?; + properties.push(property("TYPE", None, Some(self.parse_object_value()?))); + } let source = match kind { "MODEL" => { if self.match_token(TokenType::With) { diff --git a/crates/sqlquack-syntax/src/parser/_helpers/functions/typed.rs b/crates/sqlquack-syntax/src/parser/_helpers/functions/typed.rs index ece629793..125162403 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/functions/typed.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/functions/typed.rs @@ -69,7 +69,12 @@ impl Parser { match behavior { crate::functions::dispatch::models::ParserDispatchBehavior::ExprListFunction => { - let args = self.parse_expression_list()?; + let args = if self.applies_snowflake_context_rules() && self.check(TokenType::RParen) + { + Vec::new() + } else { + self.parse_expression_list()? + }; self.expect_token(TokenType::RParen)?; Ok(Some(make_unquoted_function(name, args))) } diff --git a/crates/sqlquack-syntax/src/parser/_helpers/statements/dml.rs b/crates/sqlquack-syntax/src/parser/_helpers/statements/dml.rs index 6fa4cd8e5..f0b58b054 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/statements/dml.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/statements/dml.rs @@ -480,7 +480,7 @@ impl Parser { { if self.is_snowflake() && self.tokens.get(self.current + 1).is_some_and(|t| { - ["CLONE", "FROM", "AS", "USING", "LIKE", "("] + ["CLONE", "FROM", "AS", "USING", "LIKE", "(", "TYPE"] .iter() .any(|word| t.text.eq_ignore_ascii_case(word)) }) diff --git a/crates/sqlquack-syntax/src/parser/_helpers/statements/snowflake_context.rs b/crates/sqlquack-syntax/src/parser/_helpers/statements/snowflake_context.rs index 2c454b1d1..ee469042b 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/statements/snowflake_context.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/statements/snowflake_context.rs @@ -31,6 +31,16 @@ impl Parser { } } + /// Refuses PRECISION or VARYING alone as a type, which Snowflake reads only after DOUBLE or CHARACTER. + pub(crate) fn reject_snowflake_lone_type_word(&self, name: &str) -> Result<()> { + match self.applies_snowflake_context_rules() && matches!(name, "PRECISION" | "VARYING") { + true => Err(self.parse_error(format!( + "{name} is not a Snowflake type on its own; it only follows DOUBLE or CHARACTER" + ))), + false => Ok(()), + } + } + /// Refuses TABLE( ) or IDENTIFIER( ) around nothing or a bare name, except a named argument's TABLE(name). pub(crate) fn reject_snowflake_name_wrappers(&self, statements: &[Expression]) -> Result<()> { use crate::traversal::types::ExpressionWalk; diff --git a/crates/sqlquack-syntax/src/parser/_helpers/types/casts.rs b/crates/sqlquack-syntax/src/parser/_helpers/types/casts.rs index a1a4703d2..76d4c6c23 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/types/casts.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/types/casts.rs @@ -382,6 +382,7 @@ impl Parser { let (subtype, srid) = self.parse_spatial_type_args()?; return Ok(DataType::Geometry { subtype, srid }); } + let is_quoted = self.check(TokenType::QuotedIdentifier); let mut raw_name = self.expect_identifier_or_keyword()?; while self.match_token(TokenType::Dot) { let part = self.expect_identifier_or_keyword()?; @@ -389,6 +390,9 @@ impl Parser { raw_name.push_str(&part); } let mut name = raw_name.to_ascii_uppercase(); + if !is_quoted { + self.reject_snowflake_lone_type_word(&name)?; + } if name == NATIONAL { let next_upper = if !self.is_at_end() { diff --git a/crates/sqlquack-syntax/src/parser/_helpers/types/data_types.rs b/crates/sqlquack-syntax/src/parser/_helpers/types/data_types.rs index 55bfc3698..3b0ca7f54 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/types/data_types.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/types/data_types.rs @@ -208,6 +208,7 @@ impl Parser { }); } let name = raw_name.to_ascii_uppercase(); + self.reject_snowflake_lone_type_word(&name)?; let base_type = match name.as_str() { name if DataType::from_unsigned_name(name).is_some() => { diff --git a/crates/sqlquack-syntax/tests/parser/test_snowflake_fixes.rs b/crates/sqlquack-syntax/tests/parser/test_snowflake_fixes.rs index f75f89d38..1dfd213ad 100644 --- a/crates/sqlquack-syntax/tests/parser/test_snowflake_fixes.rs +++ b/crates/sqlquack-syntax/tests/parser/test_snowflake_fixes.rs @@ -776,7 +776,32 @@ fn given_a_snowflake_create_model_gateway_or_experiment_when_parsing_then_its_so SnowflakeVerdictTestCase { description: "keeps CREATE EXPERIMENT v1 TYPE = 'AI_FUNCTION_EVALUATION' FROM SPECIFICATIO", sql: "CREATE EXPERIMENT v1 TYPE = 'AI_FUNCTION_EVALUATION' FROM SPECIFICATION $$v1 $$ ;", - expected_kind: Some("raw"), + expected_kind: Some("create_object"), + }, + SnowflakeVerdictTestCase { + description: "keeps an experiment TYPE without a specification (recorded as parsed)", + sql: "CREATE EXPERIMENT v1 TYPE = m1 ;", + expected_kind: Some("create_object"), + }, + SnowflakeVerdictTestCase { + description: "an experiment specification without FROM (recorded errno 1003)", + sql: "CREATE EXPERIMENT v1 TYPE = 'AI_FUNCTION_EVALUATION' SPECIFICATION $$v1 $$ ;", + expected_kind: None, + }, + SnowflakeVerdictTestCase { + description: "an experiment FROM without SPECIFICATION (recorded errno 1003)", + sql: "CREATE EXPERIMENT v1 TYPE = 'AI_FUNCTION_OPTIMIZATION' FROM $$v1 $$ ;", + expected_kind: None, + }, + SnowflakeVerdictTestCase { + description: "an experiment TYPE with no value (recorded errno 1003)", + sql: "CREATE EXPERIMENT v1 TYPE = ;", + expected_kind: None, + }, + SnowflakeVerdictTestCase { + description: "a name and TYPE with no object kind (recorded errno 1003)", + sql: "CREATE v1 TYPE = 'AI_FUNCTION_EVALUATION' FROM SPECIFICATION $$v1 $$ ;", + expected_kind: None, }, SnowflakeVerdictTestCase { description: "keeps ALTER TABLE v1 MODIFY DATA METRIC FUNCTION v1 ON ( v1 ) SUSPEND ;", @@ -1253,3 +1278,60 @@ fn given_snowflake_secret_resource_monitor_and_integration_alters_when_parsing_t ); } } + +#[test] +fn given_a_snowflake_cast_or_conversion_call_when_parsing_then_the_recorded_verdicts_hold() { + let test_cases = [ + SnowflakeVerdictTestCase { + description: "PRECISION alone as a type (recorded errno 1003)", + sql: "SELECT CAST ( NULL AS PRECISION ) ;", + expected_kind: None, + }, + SnowflakeVerdictTestCase { + description: "VARYING alone as a type (recorded errno 1003)", + sql: "SELECT CAST ( NULL AS VARYING ) ;", + expected_kind: None, + }, + SnowflakeVerdictTestCase { + description: "keeps DOUBLE PRECISION", + sql: "SELECT CAST ( NULL AS DOUBLE PRECISION ) ;", + expected_kind: Some("select"), + }, + SnowflakeVerdictTestCase { + description: "keeps CHARACTER VARYING", + sql: "SELECT CAST ( NULL AS CHARACTER VARYING ) ;", + expected_kind: Some("select"), + }, + SnowflakeVerdictTestCase { + description: "keeps an unknown type name, a compile error (recorded errno 2040)", + sql: "SELECT CAST ( NULL AS FOOBAR ) ;", + expected_kind: Some("select"), + }, + SnowflakeVerdictTestCase { + description: + "keeps TO_TIMESTAMP with no arguments, a compile error (recorded as parsed)", + sql: "SELECT TO_TIMESTAMP ( ) ;", + expected_kind: Some("select"), + }, + SnowflakeVerdictTestCase { + description: + "keeps TRY_TO_TIMESTAMP with no arguments, a compile error (recorded as parsed)", + sql: "SELECT TRY_TO_TIMESTAMP ( ) ;", + expected_kind: Some("select"), + }, + SnowflakeVerdictTestCase { + description: "IDENTIFIER with no arguments (recorded errno 1003)", + sql: "SELECT * FROM IDENTIFIER ( ) ;", + expected_kind: None, + }, + ]; + + for test_case in test_cases { + assert_eq!( + snowflake_kind(test_case.sql), + test_case.expected_kind.map(str::to_owned), + "{}", + test_case.description + ); + } +} From 558f9f6d4c3217b44381942028d1afe5cef9935c Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 02:57:42 +0000 Subject: [PATCH 2/6] Judge local syntax verdicts under the product's function-call depth guard The coverage checker parsed with the syntax crate's default guard, which stops at 64 nested function calls, while every product parse allows 256 (sqlquack-core MAX_FUNCTION_CALL_DEPTH). Two documented 100-deep cases (ABS, COALESCE) that Snowflake and the product both accept were reported as local parse failures. The checker now uses the product's limit. --- Cargo.lock | 1 + crates/sqlquack-syntax-coverage/Cargo.toml | 1 + .../src/cli/_helpers/local.rs | 14 +++++++++++--- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 149ecdafe..1d43e432d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1498,6 +1498,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "sqlquack-core", "sqlquack-syntax", "tempfile", "toml", diff --git a/crates/sqlquack-syntax-coverage/Cargo.toml b/crates/sqlquack-syntax-coverage/Cargo.toml index 5eba4daef..46a1baa62 100644 --- a/crates/sqlquack-syntax-coverage/Cargo.toml +++ b/crates/sqlquack-syntax-coverage/Cargo.toml @@ -14,6 +14,7 @@ clap.workspace = true serde.workspace = true serde_json.workspace = true sha2.workspace = true +sqlquack-core.workspace = true sqlquack-syntax.workspace = true toml.workspace = true diff --git a/crates/sqlquack-syntax-coverage/src/cli/_helpers/local.rs b/crates/sqlquack-syntax-coverage/src/cli/_helpers/local.rs index 8d3b0c6e9..81ab1b5e7 100644 --- a/crates/sqlquack-syntax-coverage/src/cli/_helpers/local.rs +++ b/crates/sqlquack-syntax-coverage/src/cli/_helpers/local.rs @@ -2,7 +2,10 @@ use std::process::Command; -use sqlquack_syntax::{CallMode, Dialect, Expression, ExpressionWalk, ParseOptions}; +use sqlquack_core::parsing::constants::MAX_FUNCTION_CALL_DEPTH; +use sqlquack_syntax::{ + CallMode, ComplexityGuardOptions, Dialect, Expression, ExpressionWalk, ParseOptions, +}; use crate::check::models::{LocalFailure, LocalVerdict}; use crate::formats::main::content_sha256::content_sha256; @@ -12,12 +15,17 @@ const PARSER_PATH: &str = "crates/sqlquack-syntax"; /// The code prefix of every complexity-guard error, which is never a syntax verdict. const GUARD_PREFIX: &str = "E_GUARD_"; -/// SQLQuack's Snowflake verdict; a Command or Raw node anywhere makes the parse opaque. +/// SQLQuack's Snowflake verdict under the product's call-depth guard; a Command or Raw node makes it opaque. pub(crate) fn snowflake_verdict(sql: &str) -> LocalVerdict { match sqlquack_syntax::parse_statements_with( sql, Dialect::Snowflake, - &ParseOptions::default(), + &ParseOptions { + complexity_guard: Some(ComplexityGuardOptions { + max_function_call_depth: Some(MAX_FUNCTION_CALL_DEPTH), + ..ComplexityGuardOptions::default() + }), + }, CallMode::Faithful, ) { Ok(statements) => LocalVerdict::Accepted { From 0837c8bd91d5ad77a043d39ad2641e8403207dbf Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 03:19:13 +0000 Subject: [PATCH 3/6] Record instance-less !NAME() calls as a known disagreement (D213) Snowflake parses SELECT !SPCS_CANCEL_JOB ( ) (receipt kffa979cc1d0a, errno 2139) and SELECT !SPCS_WAIT_FOR ( v1 , v1 ) (receipt kf2fa04c919eb, errno 904) and fails them at compile. Like ==, SQLQuack keeps refusing them at parse rather than add a model-attribute node with no instance. --- .../tests/parser/test_snowflake_fixes.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/sqlquack-syntax/tests/parser/test_snowflake_fixes.rs b/crates/sqlquack-syntax/tests/parser/test_snowflake_fixes.rs index 1dfd213ad..e9bd54418 100644 --- a/crates/sqlquack-syntax/tests/parser/test_snowflake_fixes.rs +++ b/crates/sqlquack-syntax/tests/parser/test_snowflake_fixes.rs @@ -1319,6 +1319,16 @@ fn given_a_snowflake_cast_or_conversion_call_when_parsing_then_the_recorded_verd sql: "SELECT TRY_TO_TIMESTAMP ( ) ;", expected_kind: Some("select"), }, + SnowflakeVerdictTestCase { + description: "!SPCS_CANCEL_JOB with no instance, kept refused like == (recorded errno 2139, D213)", + sql: "SELECT !SPCS_CANCEL_JOB ( ) ;", + expected_kind: None, + }, + SnowflakeVerdictTestCase { + description: "!SPCS_WAIT_FOR with no instance, kept refused like == (recorded as parsed, errno 904, D213)", + sql: "SELECT !SPCS_WAIT_FOR ( v1 , v1 ) ;", + expected_kind: None, + }, SnowflakeVerdictTestCase { description: "IDENTIFIER with no arguments (recorded errno 1003)", sql: "SELECT * FROM IDENTIFIER ( ) ;", From 22225fd9f028406861ec87659932db3161311dcc Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 03:22:30 +0000 Subject: [PATCH 4/6] Mark three account-state calls as never submitted The auditor upheld A's exclusions from the brq-execute-00 packet (coverage/syntax/audits/receipt-exclusions-brq-execute-00.toml on wip/syntax-omission-audit 325ea0f7). Nine of the twelve held cases already had not_submitted entries. These three did not: SYSTEM$ENFORCE_PRIVATELINK_ACCESS_ONLY, SYSTEM$ENABLE_PREVIEW_ACCESS and SYSTEM$DISABLE_PREVIEW_ACCESS. Their o001 obligations now report as unverifiable_not_submitted instead of missing a receipt, and they stay in the denominator. --- coverage/syntax/unobservable/functions.toml | 36 +++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/coverage/syntax/unobservable/functions.toml b/coverage/syntax/unobservable/functions.toml index bf6d10d37..efb38d070 100644 --- a/coverage/syntax/unobservable/functions.toml +++ b/coverage/syntax/unobservable/functions.toml @@ -506,3 +506,39 @@ cases = [ ] runs = [] probes = [] + +[[unobservable]] +obligation = "sf.functions.system-disable-preview-access.o001" +form = "sf.functions.system-disable-preview-access" +construct = "the documented form with every required part and no optional part: SELECT SYSTEM$DISABLE_PREVIEW_ACCESS ( ) ;" +reason = "not submitted: held by the capture guard in packet brq-execute-00 under its account-state rule (it changes account-wide preview feature access)" +cause = "not_submitted" +cases = [ + { case_id = "sf.functions.system-disable-preview-access.k1fad69043462", sql_sha256 = "1fad690434621ade8731ed5e4bca6d9cc7ab2265928df74af46c5bc01039afdb" }, +] +runs = [] +probes = [] + +[[unobservable]] +obligation = "sf.functions.system-enable-preview-access.o001" +form = "sf.functions.system-enable-preview-access" +construct = "the documented form with every required part and no optional part: SELECT SYSTEM$ENABLE_PREVIEW_ACCESS ( ) ;" +reason = "not submitted: held by the capture guard in packet brq-execute-00 under its account-state rule (it changes account-wide preview feature access)" +cause = "not_submitted" +cases = [ + { case_id = "sf.functions.system-enable-preview-access.k24490f3e8782", sql_sha256 = "24490f3e8782143d077daf9758752bc75a6937b642fa3e041f72ed2e26d4dda9" }, +] +runs = [] +probes = [] + +[[unobservable]] +obligation = "sf.functions.system-enforce-privatelink-access-only.o001" +form = "sf.functions.system-enforce-privatelink-access-only" +construct = "the documented form with every required part and no optional part: SELECT SYSTEM$ENFORCE_PRIVATELINK_ACCESS_ONLY ( ) ;" +reason = "not submitted: held by the capture guard in packet brq-execute-00 under its account-state rule (it blocks public access to the account)" +cause = "not_submitted" +cases = [ + { case_id = "sf.functions.system-enforce-privatelink-access-only.kfff6ddce6695", sql_sha256 = "fff6ddce6695aaa8e8b7c143f7eea607173ee1e9d1d3420c9ea061dcba17179e" }, +] +runs = [] +probes = [] From 8184a3ae983d2f7fa9a212ae58d0cf8c15b2eade Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 03:32:00 +0000 Subject: [PATCH 5/6] Key multi-word calls such as GROUPING SETS with underscores The parser names GROUPING SETS as a call with a space, so the statements file listed fn:GROUPING SETS, which form links cannot name: construct keys never hold whitespace. Function construct keys now replace spaces with underscores, giving fn:GROUPING_SETS. --- crates/sqlquack-core/src/classification/_helpers/identity.rs | 5 ++++- .../sqlquack-core/src/classification/tests/test_classify.rs | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/sqlquack-core/src/classification/_helpers/identity.rs b/crates/sqlquack-core/src/classification/_helpers/identity.rs index 029d0e129..59f2033e5 100644 --- a/crates/sqlquack-core/src/classification/_helpers/identity.rs +++ b/crates/sqlquack-core/src/classification/_helpers/identity.rs @@ -28,7 +28,10 @@ fn function_construct(name: &str) -> Construct { { return Construct::new(ConstructFamily::Function, PROJECT_FUNCTION_CONSTRUCT); } - Construct::new(ConstructFamily::Function, name.to_ascii_uppercase()) + Construct::new( + ConstructFamily::Function, + name.to_ascii_uppercase().replace(' ', "_"), + ) } fn cast_constructs(variant: &str, cast: &Cast, dialect: SourceDialect) -> Vec { diff --git a/crates/sqlquack-core/src/classification/tests/test_classify.rs b/crates/sqlquack-core/src/classification/tests/test_classify.rs index c06a2305e..51439d87e 100644 --- a/crates/sqlquack-core/src/classification/tests/test_classify.rs +++ b/crates/sqlquack-core/src/classification/tests/test_classify.rs @@ -13,6 +13,11 @@ fn given_snowflake_query_when_classifying_then_reports_each_construct_family() { sql: "SELECT zeroifnull(o.amount) AS amount FROM orders o", expected_keys: &["fn:ZEROIFNULL", "node:alias", "node:column", "node:select"], }, + ConstructTestCase { + description: "a multi-word call such as GROUPING SETS keys with underscores, never a space", + sql: "SELECT a, b, SUM(c) FROM t GROUP BY GROUPING SETS ((a), (b))", + expected_keys: &["fn:GROUPING_SETS"], + }, ConstructTestCase { description: "casts record the node, the base type and the `::` spelling", sql: "SELECT o.amount::NUMBER(10, 2) AS amount FROM orders o", From 4c6bb93b1aca6bdc4c82771a73942b4ae1a660c1 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 03:32:00 +0000 Subject: [PATCH 6/6] Write a statement SQLQuack cannot parse as one line, not an aborted file verify --translation-statements stopped the whole file at the first recorded statement SQLQuack's parser refused. Such a statement is now written like any other, with its outcome and codes, its token spelling keys as its constructs, and an optional unparsed field holding the parse failure. syntax-coverage results reads the field. The three D151 cases that hit it (DATE_PART and EXTRACT with a quoted part before FROM, MINHASH with DISTINCT) are compile errors Snowflake rejects at parse, so SQLQuack's refusal agrees with Snowflake. --- .../src/results/models.rs | 3 +++ .../src/results/tests/helpers.rs | 1 + .../src/statements/_helpers/constructs.rs | 19 ++++++++++--------- .../main/write_translation_statements.rs | 4 +++- crates/sqlquack/src/statements/models.rs | 3 +++ crates/sqlquack/tests/cli/helpers.rs | 16 ++++++++++++++++ .../tests/cli/test_evidence_commands.rs | 10 +++++++++- 7 files changed, 45 insertions(+), 11 deletions(-) diff --git a/crates/sqlquack-syntax-coverage/src/results/models.rs b/crates/sqlquack-syntax-coverage/src/results/models.rs index eaf03f86a..c98973ff7 100644 --- a/crates/sqlquack-syntax-coverage/src/results/models.rs +++ b/crates/sqlquack-syntax-coverage/src/results/models.rs @@ -28,6 +28,9 @@ pub struct RecordedStatement { pub refused: Vec, /// Every construct the statement uses, sorted. pub constructs: Vec, + /// Why SQLQuack's parser refused the statement; its constructs are then only token spellings. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub unparsed: Option, } /// What one statement shows; only the first two are results matching Snowflake. diff --git a/crates/sqlquack-syntax-coverage/src/results/tests/helpers.rs b/crates/sqlquack-syntax-coverage/src/results/tests/helpers.rs index 13441d72b..63d0bdf0c 100644 --- a/crates/sqlquack-syntax-coverage/src/results/tests/helpers.rs +++ b/crates/sqlquack-syntax-coverage/src/results/tests/helpers.rs @@ -99,6 +99,7 @@ pub(crate) fn statement( codes: vec!["SQ0003".to_owned()], refused: Vec::new(), constructs: with_call_shapes(constructs), + unparsed: None, } } diff --git a/crates/sqlquack/src/statements/_helpers/constructs.rs b/crates/sqlquack/src/statements/_helpers/constructs.rs index 186cda0b4..672ca7b82 100644 --- a/crates/sqlquack/src/statements/_helpers/constructs.rs +++ b/crates/sqlquack/src/statements/_helpers/constructs.rs @@ -31,22 +31,23 @@ pub(crate) fn project_key(key: String, own: &BTreeSet) -> String { } } -/// Every construct and spelling key the case's statement uses, sorted, its own functions as one project key. -pub(crate) fn constructs(case: &LoadedCase, own: &BTreeSet) -> Result, String> { +/// Every construct and spelling key the case's statement uses, sorted, and why it did not parse, if not. +pub(crate) fn constructs( + case: &LoadedCase, + own: &BTreeSet, +) -> (Vec, Option) { let sql = &case.source_sql; let lines = SourceLines::new(sql); - let statements = parse_source(sql, SourceDialect::Snowflake).map_err(|failure| { - format!( - "{}: a recorded statement does not parse: {failure:?}", - case.reference - ) - })?; let mut keys: BTreeSet = token_spellings(sql); + let statements = match parse_source(sql, SourceDialect::Snowflake) { + Ok(statements) => statements, + Err(failure) => return (keys.into_iter().collect(), Some(failure.to_string())), + }; for statement in &statements { keys.extend(tree_spellings(statement)); for occurrence in classify(statement, &lines, SourceDialect::Snowflake) { keys.insert(project_key(occurrence.construct.key(), own)); } } - Ok(keys.into_iter().collect()) + (keys.into_iter().collect(), None) } diff --git a/crates/sqlquack/src/statements/main/write_translation_statements.rs b/crates/sqlquack/src/statements/main/write_translation_statements.rs index ec0057243..5f64a9c30 100644 --- a/crates/sqlquack/src/statements/main/write_translation_statements.rs +++ b/crates/sqlquack/src/statements/main/write_translation_statements.rs @@ -30,6 +30,7 @@ pub fn write_translation_statements( }; let (outcome, codes, refused) = outcome(case, verdict, converter); let own = own_functions(case); + let (constructs, unparsed) = constructs(case, &own); statements.push(Statement { reference: verdict.reference.clone(), hash: verdict.hash.clone(), @@ -39,7 +40,8 @@ pub fn write_translation_statements( .into_iter() .map(|key| project_key(key, &own)) .collect(), - constructs: constructs(case, &own)?, + constructs, + unparsed, }); } let count = statements.len(); diff --git a/crates/sqlquack/src/statements/models.rs b/crates/sqlquack/src/statements/models.rs index d9836931b..24271be09 100644 --- a/crates/sqlquack/src/statements/models.rs +++ b/crates/sqlquack/src/statements/models.rs @@ -39,6 +39,9 @@ pub struct Statement { pub refused: Vec, /// Every construct the Snowflake statement uses, which links it to catalogue forms. pub constructs: Vec, + /// Why SQLQuack's parser refused the statement; its constructs are then only token spellings. + #[serde(skip_serializing_if = "Option::is_none")] + pub unparsed: Option, } /// The whole file `sqlquack verify --translation-statements` writes. diff --git a/crates/sqlquack/tests/cli/helpers.rs b/crates/sqlquack/tests/cli/helpers.rs index e116adb4b..43dbcd3c5 100644 --- a/crates/sqlquack/tests/cli/helpers.rs +++ b/crates/sqlquack/tests/cli/helpers.rs @@ -990,6 +990,19 @@ pub(crate) fn compare_outcome(test_case: &CompareTestCase) -> String { format!("{} {}", outcomes[0]["id"], outcomes[0]["outcome"]) } +/// A case Snowflake rejects at parse (1003) and SQLQuack's parser refuses too. +const UNPARSED_CASE: &str = r#" +[[case]] +id = "quoted_part_from" +compile_error = true +description = "A quoted part before FROM, which Snowflake rejects at parse." +columns = [{ name = "d", type = "DATE" }] +rows = [["'2024-11-05'"]] +query = "SELECT DATE_PART('month' FROM d) AS m FROM input ORDER BY ordinal" +order = "ordered" +tags = ["error"] +"#; + /// Each statement `verify --translation-statements` writes for the batch-2 aggregates and fidelity. pub(crate) fn translation_statements() -> Vec { let directory = tempfile::tempdir().expect("temporary directory"); @@ -1010,6 +1023,9 @@ pub(crate) fn translation_statements() -> Vec { cases.join("aggregates_batch2.toml"), ) .expect("case copied"); + let batch = cases.join("aggregates_batch2.toml"); + let text = std::fs::read_to_string(&batch).expect("case read") + UNPARSED_CASE; + std::fs::write(&batch, text).expect("unparsed case added"); copy_files( &root.join("evidence/results"), &directory.path().join("evidence/results"), diff --git a/crates/sqlquack/tests/cli/test_evidence_commands.rs b/crates/sqlquack/tests/cli/test_evidence_commands.rs index 3e8f1688a..df9edf225 100644 --- a/crates/sqlquack/tests/cli/test_evidence_commands.rs +++ b/crates/sqlquack/tests/cli/test_evidence_commands.rs @@ -90,6 +90,14 @@ fn given_recorded_cases_when_verify_writes_translation_statements_then_each_outc .collect() }; let test_cases = [ + TranslationStatementTestCase { + description: "a statement SQLQuack cannot parse is one refused line, not an aborted file", + id: "quoted_part_from", + expected_outcome: "unrecorded_refused", + expected_codes: &["SQ0001"], + expected_refused: &["statement"], + expected_use: "spelling:quoted_string", + }, TranslationStatementTestCase { description: "matching rows", id: "any_value_all_null", @@ -183,5 +191,5 @@ fn given_recorded_cases_when_verify_writes_translation_statements_then_each_outc .is_some_and(|reference| reference.starts_with("aggregate/")) }) .count(); - assert_eq!(aggregates, 50); + assert_eq!(aggregates, 51); }