From 7a83df693bceb7f0efa72696bca620b87096253b Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Thu, 5 Nov 2026 15:43:57 +0000 Subject: [PATCH 01/25] fix(syntax): keep DEFAULT, column COMMENT and column constraint properties in Snowflake trees DEFAULT in INSERT VALUES and multi-table intoClause VALUES is a default_keyword node, not a variable or column; ALTER DYNAMIC TABLE MODIFY COMMENT / UNSET COMMENT is an alter_column node; inline RELY/NORELY are kept with the constraint they follow, and a named column-level FOREIGN KEY keeps its constraint name. --- .../src/expressions/columns/models.rs | 4 +++ .../src/expressions/names/models.rs | 4 +++ .../src/expressions/tree/_helpers/api.rs | 2 ++ .../src/expressions/tree/models.rs | 2 ++ .../_helpers/ddl/snowflake_alter_dynamic.rs | 32 +++++++++++++++++-- .../src/parser/_helpers/ddl/table_options.rs | 17 ++++++++++ .../src/parser/_helpers/statements/analyze.rs | 11 +++++-- .../src/parser/_helpers/statements/dml.rs | 22 ++++++++++++- .../sqlquack-syntax/src/parser/constants.rs | 3 ++ 9 files changed, 91 insertions(+), 6 deletions(-) diff --git a/crates/sqlquack-syntax/src/expressions/columns/models.rs b/crates/sqlquack-syntax/src/expressions/columns/models.rs index 345719b03..2bde22a10 100644 --- a/crates/sqlquack-syntax/src/expressions/columns/models.rs +++ b/crates/sqlquack-syntax/src/expressions/columns/models.rs @@ -39,6 +39,9 @@ pub struct ColumnDef { /// Track original order of constraints for accurate regeneration #[serde(default)] pub constraint_order: Vec, + /// Snowflake RELY/NORELY, each with the `constraint_order` index of the constraint it follows. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub constraint_properties: Vec<(usize, String)>, /// Teradata: FORMAT 'pattern' #[serde(default)] pub format: Option, @@ -138,6 +141,7 @@ impl ColumnDef { comment: None, constraints: Vec::new(), constraint_order: Vec::new(), + constraint_properties: Vec::new(), format: None, title: None, inline_length: None, diff --git a/crates/sqlquack-syntax/src/expressions/names/models.rs b/crates/sqlquack-syntax/src/expressions/names/models.rs index 00db5df96..a54fdbfe2 100644 --- a/crates/sqlquack-syntax/src/expressions/names/models.rs +++ b/crates/sqlquack-syntax/src/expressions/names/models.rs @@ -123,6 +123,10 @@ pub struct BooleanLiteral { #[derive(sqlquack_syntax_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Null; +/// The DEFAULT keyword where a value is expected, such as a VALUES item: the column's default. +#[derive(sqlquack_syntax_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DefaultKeyword; + /// An identifier (table, column, alias, ...); `quoted` records whether it was delimited. #[derive(sqlquack_syntax_derive::AstNode, Debug, Clone, Serialize, Deserialize)] pub struct Identifier { diff --git a/crates/sqlquack-syntax/src/expressions/tree/_helpers/api.rs b/crates/sqlquack-syntax/src/expressions/tree/_helpers/api.rs index b46b19a63..b3acb079f 100644 --- a/crates/sqlquack-syntax/src/expressions/tree/_helpers/api.rs +++ b/crates/sqlquack-syntax/src/expressions/tree/_helpers/api.rs @@ -17,6 +17,7 @@ impl Expression { Expression::Literal(_) => "literal", Expression::Boolean(_) => "boolean", Expression::Null(_) => "null", + Expression::DefaultKeyword(_) => "default_keyword", Expression::Identifier(_) => "identifier", Expression::Column(_) => "column", Expression::Table(_) => "table", @@ -1298,6 +1299,7 @@ impl Expression { } } Expression::Null(_) => "NULL", + Expression::DefaultKeyword(_) => "DEFAULT", _ => "", } } diff --git a/crates/sqlquack-syntax/src/expressions/tree/models.rs b/crates/sqlquack-syntax/src/expressions/tree/models.rs index d73e73190..c0ac9cdc7 100644 --- a/crates/sqlquack-syntax/src/expressions/tree/models.rs +++ b/crates/sqlquack-syntax/src/expressions/tree/models.rs @@ -227,6 +227,7 @@ use crate::expressions::functions::models::JSON; use crate::expressions::names::models::BooleanLiteral; use crate::expressions::names::models::ByteString; use crate::expressions::names::models::Column; +use crate::expressions::names::models::DefaultKeyword; use crate::expressions::names::models::HexStringExpr; use crate::expressions::names::models::Identifier; use crate::expressions::names::models::Literal; @@ -742,6 +743,7 @@ pub enum Expression { Literal(Box), Boolean(BooleanLiteral), Null(Null), + DefaultKeyword(DefaultKeyword), Identifier(Identifier), Column(Box), diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_alter_dynamic.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_alter_dynamic.rs index 02b90807d..5c0d6b113 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_alter_dynamic.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_alter_dynamic.rs @@ -1,7 +1,9 @@ //! Snowflake ALTER DYNAMIC TABLE. +use crate::expressions::columns::models::AlterColumn; use crate::expressions::names::models::Identifier; use crate::expressions::schema_objects::models::ObjectProperty; +use crate::expressions::statements::models::Var; use crate::expressions::tree::models::Expression; use crate::parser::_helpers::ddl::snowflake_alter::alter_object; use crate::parser::_helpers::ddl::snowflake_alter::property; @@ -10,6 +12,7 @@ use crate::parser::constants::DROP_ROW_ACCESS_POLICY; use crate::parser::constants::SET; use crate::parser::constants::SET_MASKING_POLICY; use crate::parser::constants::SET_TAG; +use crate::parser::constants::UNSET_COMMENT; use crate::parser::models::Parser; use crate::tokens::models::TokenType; use crate::tokens::types::Result; @@ -309,8 +312,33 @@ impl Parser { ])?; let mut properties: Vec = Vec::new(); match action.as_str() { - "COMMENT" => { - properties.push(property("COMMENT", None, Some(self.parse_string_value()?))); + "COMMENT" | "UNSET COMMENT" => { + let set = action != UNSET_COMMENT; + let comment = if set { + Some(Box::new(self.parse_string_value()?)) + } else { + None + }; + columns.push(Expression::AlterColumn(Box::new(AlterColumn { + this: Box::new(column), + dtype: None, + collate: None, + using: None, + default: None, + drop: (!set).then(|| { + Box::new(Expression::Var(Box::new(Var { + this: "COMMENT".to_owned(), + }))) + }), + comment, + allow_null: None, + visible: None, + rename_to: None, + }))); + if !self.match_token(TokenType::Comma) { + break; + } + continue; } "SET MASKING POLICY" | "SET PROJECTION POLICY" => { properties.push(property(&action, None, Some(self.policy_operand()?))); diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/table_options.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/table_options.rs index 357a19a7d..80bf83e11 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/table_options.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/table_options.rs @@ -380,6 +380,13 @@ impl Parser { if self.match_identifier("TTL") { let expr = self.parse_expression()?; col_def.ttl_expr = Some(Box::new(expr)); + } else if self.is_snowflake() + && !col_def.constraint_order.is_empty() + && (self.check_identifier("RELY") || self.check_identifier("NORELY")) + { + let word = self.advance_text()?.to_ascii_uppercase(); + let after = col_def.constraint_order.len() - 1; + col_def.constraint_properties.push((after, word)); } else { if self.skip_column_modifier() { continue; @@ -494,9 +501,19 @@ impl Parser { fn parse_column_named_constraint(&mut self, mut col_def: ColumnDef) -> Result { let constraint_name = self.expect_identifier()?; + let foreign_key = self.check(TokenType::ForeignKey) + && self.check_next(TokenType::Key) + && self + .peek_nth(2) + .is_some_and(|token| token.token_type == TokenType::References); + if foreign_key { + self.skip(); + self.skip(); + } if self.match_token(TokenType::References) { let mut fk_ref = self.parse_foreign_key_ref()?; fk_ref.constraint_name = Some(constraint_name); + fk_ref.has_foreign_key_keywords |= foreign_key; col_def .constraints .push(ColumnConstraint::References(Box::new(fk_ref))); diff --git a/crates/sqlquack-syntax/src/parser/_helpers/statements/analyze.rs b/crates/sqlquack-syntax/src/parser/_helpers/statements/analyze.rs index 20a0a9cf4..0e8150f16 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/statements/analyze.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/statements/analyze.rs @@ -7,6 +7,7 @@ use std::sync::Arc; use crate::expressions::names::main::identifier_from_token::identifier_from_token; use crate::expressions::names::models::BooleanLiteral; use crate::expressions::names::models::Column; +use crate::expressions::names::models::DefaultKeyword; use crate::expressions::names::models::Identifier; use crate::expressions::names::models::Literal; use crate::expressions::operators::models::Alias; @@ -995,9 +996,13 @@ impl Parser { loop { let expr = if self.match_token(TokenType::Default) { - Expression::Var(Box::new(crate::expressions::statements::models::Var { - this: "DEFAULT".to_string(), - })) + if self.is_snowflake() { + Expression::DefaultKeyword(DefaultKeyword) + } else { + Expression::Var(Box::new(crate::expressions::statements::models::Var { + this: "DEFAULT".to_string(), + })) + } } else { self.parse_expression()? }; diff --git a/crates/sqlquack-syntax/src/parser/_helpers/statements/dml.rs b/crates/sqlquack-syntax/src/parser/_helpers/statements/dml.rs index 6fa4cd8e5..7f817ce3c 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/statements/dml.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/statements/dml.rs @@ -1,6 +1,7 @@ //! Parser statements: dml. use crate::expressions::names::models::BooleanLiteral; +use crate::expressions::names::models::DefaultKeyword; use crate::expressions::names::models::Identifier; use crate::expressions::names::models::TableRef; use crate::expressions::operators::models::ConditionalInsert; @@ -635,7 +636,7 @@ impl Parser { let values = if self.match_token(TokenType::Values) { self.expect_token(TokenType::LParen)?; - let row = self.parse_expression_list()?; + let row = self.parse_into_clause_values()?; self.expect_token(TokenType::RParen)?; vec![row] } else { @@ -699,4 +700,23 @@ impl Parser { }, )))) } + + /// A multi-table intoClause's VALUES items: an expression or the DEFAULT keyword, each. + fn parse_into_clause_values(&mut self) -> Result> { + let mut values: Vec = Vec::new(); + loop { + let ends_item = self.peek_nth(1).is_some_and(|next| { + matches!(next.token_type, TokenType::Comma | TokenType::RParen) + }); + if self.check(TokenType::Default) && ends_item { + self.skip(); + values.push(Expression::DefaultKeyword(DefaultKeyword)); + } else { + values.push(self.parse_expression()?); + } + if !self.match_token(TokenType::Comma) { + return Ok(values); + } + } + } } diff --git a/crates/sqlquack-syntax/src/parser/constants.rs b/crates/sqlquack-syntax/src/parser/constants.rs index a304c98d2..592cff07e 100644 --- a/crates/sqlquack-syntax/src/parser/constants.rs +++ b/crates/sqlquack-syntax/src/parser/constants.rs @@ -194,6 +194,9 @@ pub(crate) const SET_MASKING_POLICY: &str = "SET MASKING POLICY"; /// The text "SET TAG", as comparisons test it. pub(crate) const SET_TAG: &str = "SET TAG"; +/// The text "UNSET COMMENT", as comparisons test it. +pub(crate) const UNSET_COMMENT: &str = "UNSET COMMENT"; + /// The argument count of a two-argument call. pub(crate) const BINARY_ARITY: usize = 2; From fac59700afafce6b845886f38cbaea4efb67aeed Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:08:02 +0000 Subject: [PATCH 02/25] test(syntax): pin DEFAULT, column COMMENT and RELY/FK-name trees for Snowflake --- .../tests/parser/test_snowflake_recorded.rs | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs b/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs index 6fb860578..d090f18bc 100644 --- a/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs +++ b/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs @@ -785,6 +785,60 @@ fn given_snowflake_create_and_begin_name_when_parsing_then_the_tree_keeps_the_el pointer: "/0/use/parts", expected_value: json!(null), }, + ProjectionTestCase { + description: "DEFAULT in INSERT VALUES is the keyword, not a variable", + sql: "INSERT INTO v1 VALUES ( DEFAULT , \"DEFAULT\" ) ;", + pointer: "/0/insert/values/0/0", + expected_value: json!({"default_keyword": null}), + }, + ProjectionTestCase { + description: "a quoted \"DEFAULT\" in INSERT VALUES stays a column", + sql: "INSERT INTO v1 VALUES ( DEFAULT , \"DEFAULT\" ) ;", + pointer: "/0/insert/values/0/1/column/name/quoted", + expected_value: json!(true), + }, + ProjectionTestCase { + description: "DEFAULT in a multi-table intoClause is the keyword, not a column", + sql: "INSERT ALL INTO v1 VALUES ( DEFAULT , v1 ) SELECT 1 ;", + pointer: "/0/multitable_inserts/expressions/0/conditional_insert/this/insert/values/0/0", + expected_value: json!({"default_keyword": null}), + }, + ProjectionTestCase { + description: "a source column in a multi-table intoClause stays a column", + sql: "INSERT ALL INTO v1 VALUES ( DEFAULT , v1 ) SELECT 1 ;", + pointer: "/0/multitable_inserts/expressions/0/conditional_insert/this/insert/values/0/1/column/name/name", + expected_value: json!("v1"), + }, + ProjectionTestCase { + description: "DEFAULT in a conditional multi-table intoClause is the keyword", + sql: "INSERT FIRST WHEN TRUE THEN INTO v1 VALUES ( DEFAULT ) SELECT 1 ;", + pointer: "/0/multitable_inserts/expressions/0/conditional_insert/this/insert/values/0/0", + expected_value: json!({"default_keyword": null}), + }, + ProjectionTestCase { + description: "ALTER DYNAMIC TABLE MODIFY COMMENT keeps the comment as the column's comment", + sql: "ALTER DYNAMIC TABLE v1 MODIFY ( COLUMN v1 UNSET COMMENT , v2 COMMENT 'x' ) ;", + pointer: "/0/alter_object/operands/1/alter_column/comment/literal/value", + expected_value: json!("x"), + }, + ProjectionTestCase { + description: "ALTER DYNAMIC TABLE MODIFY UNSET COMMENT drops the column's comment", + sql: "ALTER DYNAMIC TABLE v1 MODIFY ( COLUMN v1 UNSET COMMENT , v2 COMMENT 'x' ) ;", + pointer: "/0/alter_object/operands/0/alter_column/drop", + expected_value: json!({"var": {"this": "COMMENT"}}), + }, + ProjectionTestCase { + description: "a named column FOREIGN KEY keeps its constraint name", + sql: "ALTER TABLE v1 ADD COLUMN v1 INT CONSTRAINT c9 FOREIGN KEY REFERENCES v2 ( v1 ) RELY ;", + pointer: "/0/alter_table/actions/0/AddColumn/column/constraints/0/References/constraint_name", + expected_value: json!("c9"), + }, + ProjectionTestCase { + description: "RELY stays with the column constraint it follows", + sql: "ALTER TABLE v1 ADD COLUMN v1 INT NOT NULL CONSTRAINT c9 UNIQUE NORELY ;", + pointer: "/0/alter_table/actions/0/AddColumn/column/constraint_properties", + expected_value: json!([[1, "NORELY"]]), + }, ]; for test_case in test_cases { From aaff34d6575e37334b0ecce104d314b2fa066301 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:19:22 +0000 Subject: [PATCH 03/25] fix(syntax): check Snowflake COPY INTO and COPY FILES against the recorded grammar A token check before COPY is read refuses what Snowflake refuses: missing INTO or FROM, strings or spaced quoted names as stage parts, reserved words as names, a load's query without a stage source, empty or keyless option values, and PARTITION without BY. A Snowflake COPY that passes the check but that the typed reader cannot read in full (COPY FILES, PARTITION BY, metadata string keys, empty stage name parts) is kept as a command. --- .../polyglot-check/expected_differences.toml | 63 +++ .../tests/runs/test_exact.rs | 21 + .../src/parser/_helpers/ddl/mod.rs | 1 + .../src/parser/_helpers/ddl/scripting.rs | 7 + .../src/parser/_helpers/ddl/snowflake_copy.rs | 446 ++++++++++++++++++ .../tests/parser/test_snowflake_fixes.rs | 124 +++++ 6 files changed, 662 insertions(+) create mode 100644 crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_copy.rs diff --git a/corpora/polyglot-check/expected_differences.toml b/corpora/polyglot-check/expected_differences.toml index 8d364e9d3..25a831785 100644 --- a/corpora/polyglot-check/expected_differences.toml +++ b/corpora/polyglot-check/expected_differences.toml @@ -813,6 +813,41 @@ dialects = ["Snowflake"] sqlquack_error = "Expected AS before the CTE body" note = "prose read as a CTE without AS; polyglot fails here too, with another error" +[[rejection]] +id = "snowflake_copy_prose_into_with" +sql = "COPY INTO with ? source failed: {:?}" +dialects = ["Snowflake"] +sqlquack_error = "Expected a table name" +note = "an assertion message read as a COPY; the COPY grammar check refuses it before polyglot's parser fails on it with another error" + +[[rejection]] +id = "snowflake_copy_prose_on_error" +sql = "COPY ON_ERROR=? failed: {:?}" +dialects = ["Snowflake"] +sqlquack_error = "Expected INTO after COPY" +note = "an assertion message read as a COPY; the COPY grammar check refuses it before polyglot's parser fails on it with another error" + +[[rejection]] +id = "snowflake_copy_prose_reversed" +sql = "COPY reversed param order failed: {:?}" +dialects = ["Snowflake"] +sqlquack_error = "Expected INTO after COPY" +note = "an assertion message read as a COPY; the COPY grammar check refuses it before polyglot's parser fails on it with another error" + +[[rejection]] +id = "snowflake_copy_prose_minimal" +sql = "COPY minimal with ? source failed: {:?}" +dialects = ["Snowflake"] +sqlquack_error = "Expected INTO after COPY" +note = "an assertion message read as a COPY; the COPY grammar check refuses it before polyglot's parser fails on it with another error" + +[[rejection]] +id = "snowflake_copy_prose_roundtrip" +sql = "COPY roundtrip failed to re-parse: {:?}" +dialects = ["Snowflake"] +sqlquack_error = "Expected INTO after COPY" +note = "an assertion message read as a COPY; the COPY grammar check refuses it before polyglot's parser fails on it with another error" + [[rejection]] id = "snowflake_reserved_cte_name" sql = "WITH all AS (SELECT 1 AS count) SELECT all.count FROM all" @@ -827,6 +862,34 @@ dialects = ["Snowflake"] sqlquack_error = "Reserved word TABLE cannot be an unquoted object name" note = "TABLE is a Snowflake reserved word, so it cannot name a table unquoted" +[[rejection]] +id = "snowflake_copy_unload_reserved_table_credentials" +sql = "COPY INTO 's3://example/data.csv'\n FROM EXTRA.EXAMPLE.TABLE\n CREDENTIALS = ()\n FILE_FORMAT = (TYPE = CSV COMPRESSION = NONE NULL_IF = ('') FIELD_OPTIONALLY_ENCLOSED_BY = '\"')\n HEADER = TRUE\n OVERWRITE = TRUE\n SINGLE = TRUE\n " +dialects = ["Snowflake"] +sqlquack_error = "Expected a table name" +note = "TABLE is a Snowflake reserved word, so it cannot be the unquoted last part of the unloaded table's name either; Snowflake refuses d1.s1.SELECT as an object name with syntax error 1003 (DEFINE STREAM d1.s1.v1 ON TABLE d1.s1.SELECT and DEFINE SEQUENCE d1.s1.SELECT)" + +[[rejection]] +id = "snowflake_copy_unload_reserved_table_credentials_flat" +sql = "COPY INTO 's3://example/data.csv'\nFROM EXTRA.EXAMPLE.TABLE\nCREDENTIALS = ()\nFILE_FORMAT = (TYPE=CSV COMPRESSION=NONE NULL_IF=(\n ''\n) FIELD_OPTIONALLY_ENCLOSED_BY='\"')\nHEADER = TRUE\nOVERWRITE = TRUE\nSINGLE = TRUE" +dialects = ["Snowflake"] +sqlquack_error = "Expected a table name" +note = "TABLE is a Snowflake reserved word, so it cannot be the unquoted last part of the unloaded table's name either; Snowflake refuses d1.s1.SELECT as an object name with syntax error 1003 (DEFINE STREAM d1.s1.v1 ON TABLE d1.s1.SELECT and DEFINE SEQUENCE d1.s1.SELECT)" + +[[rejection]] +id = "snowflake_copy_unload_reserved_table_integration" +sql = "COPY INTO 's3://example/data.csv'\n FROM EXTRA.EXAMPLE.TABLE\n STORAGE_INTEGRATION = S3_INTEGRATION\n FILE_FORMAT = (TYPE=CSV COMPRESSION=NONE NULL_IF=('') FIELD_OPTIONALLY_ENCLOSED_BY='\"')\n HEADER = TRUE\n OVERWRITE = TRUE\n SINGLE = TRUE\n " +dialects = ["Snowflake"] +sqlquack_error = "Expected a table name" +note = "TABLE is a Snowflake reserved word, so it cannot be the unquoted last part of the unloaded table's name either; Snowflake refuses d1.s1.SELECT as an object name with syntax error 1003 (DEFINE STREAM d1.s1.v1 ON TABLE d1.s1.SELECT and DEFINE SEQUENCE d1.s1.SELECT)" + +[[rejection]] +id = "snowflake_copy_unload_reserved_table_integration_flat" +sql = "COPY INTO 's3://example/data.csv' FROM EXTRA.EXAMPLE.TABLE STORAGE_INTEGRATION = S3_INTEGRATION FILE_FORMAT = (TYPE=CSV COMPRESSION=NONE NULL_IF=('') FIELD_OPTIONALLY_ENCLOSED_BY='\"') HEADER = TRUE OVERWRITE = TRUE SINGLE = TRUE" +dialects = ["Snowflake"] +sqlquack_error = "Expected a table name" +note = "TABLE is a Snowflake reserved word, so it cannot be the unquoted last part of the unloaded table's name either; Snowflake refuses d1.s1.SELECT as an object name with syntax error 1003 (DEFINE STREAM d1.s1.v1 ON TABLE d1.s1.SELECT and DEFINE SEQUENCE d1.s1.SELECT)" + [[rejection]] id = "snowflake_empty_call_argument" sql = "SELECT ROUND(SCALE => 1, EXPR => 2.25, , ROUNDING_MODE => 'HALF_TO_EVEN') AS value" diff --git a/crates/sqlquack-polyglot-check/tests/runs/test_exact.rs b/crates/sqlquack-polyglot-check/tests/runs/test_exact.rs index 882bdf25c..7d39181d9 100644 --- a/crates/sqlquack-polyglot-check/tests/runs/test_exact.rs +++ b/crates/sqlquack-polyglot-check/tests/runs/test_exact.rs @@ -22,6 +22,11 @@ fn given_committed_corpora_when_parsed_by_both_parsers_then_results_are_identica ("malformed_typed_type_text".to_owned(), 1), ("snowflake_bare_values_cte".to_owned(), 1), ("snowflake_cte_without_as_prose".to_owned(), 1), + ("snowflake_copy_prose_into_with".to_owned(), 1), + ("snowflake_copy_prose_on_error".to_owned(), 1), + ("snowflake_copy_prose_reversed".to_owned(), 1), + ("snowflake_copy_prose_minimal".to_owned(), 1), + ("snowflake_copy_prose_roundtrip".to_owned(), 1), ("snowflake_reserved_type_name".to_owned(), 1), ("snowflake_string_part_extract".to_owned(), 1), ("snowflake_function_body_number".to_owned(), 1), @@ -105,6 +110,22 @@ fn given_committed_corpora_when_parsed_by_both_parsers_then_results_are_identica ("snowflake_empty_call_argument".to_owned(), 1), ("snowflake_reserved_cte_name".to_owned(), 1), ("snowflake_reserved_truncate_target".to_owned(), 1), + ( + "snowflake_copy_unload_reserved_table_credentials".to_owned(), + 1, + ), + ( + "snowflake_copy_unload_reserved_table_credentials_flat".to_owned(), + 1, + ), + ( + "snowflake_copy_unload_reserved_table_integration".to_owned(), + 1, + ), + ( + "snowflake_copy_unload_reserved_table_integration_flat".to_owned(), + 1, + ), ( "snowflake_empty_select_list_trailing_order_by".to_owned(), 1, diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/mod.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/mod.rs index f2bb4f863..a4e6fe283 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/mod.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/mod.rs @@ -15,6 +15,7 @@ pub(crate) mod snowflake_alter_resources; pub(crate) mod snowflake_alter_tables; pub(crate) mod snowflake_alter_user; pub(crate) mod snowflake_commands; +pub(crate) mod snowflake_copy; pub(crate) mod snowflake_create_clone; pub(crate) mod snowflake_create_from; pub(crate) mod snowflake_create_properties; diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/scripting.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/scripting.rs index 641a04ca3..0bc78c2e6 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/scripting.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/scripting.rs @@ -824,7 +824,14 @@ impl Parser { /// Parses COPY INTO a table or a location (Snowflake, PostgreSQL). pub(crate) fn parse_copy(&mut self) -> Result { self.expect_token(TokenType::Copy)?; + if self.is_snowflake() { + return self.parse_snowflake_copy(); + } + self.parse_copy_typed() + } + /// Reads COPY's typed tree; the position is after COPY. + pub(crate) fn parse_copy_typed(&mut self) -> Result { let is_into = self.match_token(TokenType::Into); let this = self.parse_copy_target()?; diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_copy.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_copy.rs new file mode 100644 index 000000000..31efad0ad --- /dev/null +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_copy.rs @@ -0,0 +1,446 @@ +//! Snowflake's COPY INTO and COPY FILES grammar, checked on the statement's tokens before it is read. + +use crate::expressions::statements::models::Command; +use crate::expressions::tree::models::Expression; +use crate::parser::constants::SNOWFLAKE_RESERVED_KEYWORDS; +use crate::parser::models::Parser; +use crate::tokens::models::TokenType; +use crate::tokens::types::Result; + +impl Parser { + /// Reads a Snowflake COPY that passes the grammar check: the typed tree when it covers the statement, else a command. + pub(crate) fn parse_snowflake_copy(&mut self) -> Result { + self.check_snowflake_copy()?; + let start = self.current; + let end = self.copy_statement_end(); + if let Ok(copy) = self.parse_copy_typed() { + if self.current == end { + return Ok(copy); + } + } + self.current = start; + let mut tokens = vec![("COPY".to_owned(), TokenType::Copy)]; + while self.current < end { + let token = self.advance()?; + tokens.push((self.raw_sql_token_text(&token), token.token_type)); + } + Ok(Expression::Command(Box::new(Command { + this: self.join_command_tokens(tokens), + }))) + } + + /// Refuses a Snowflake COPY that breaks `COPY [FILES] INTO [FROM ] [