From 8a7eb282c3cf3ef74d532325bc8fd79abd4af36c Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:11:08 +0000 Subject: [PATCH 1/9] feat(syntax): check Snowflake streams against their grammar CREATE STREAM kept everything after STREAM as raw text, so malformed statements were accepted. A stream on a table, view, external, dynamic or event table or a stage now builds a CreateObject: its tags and COPY GRANTS before ON, the source, AT or BEFORE where the source takes it, and the properties that source documents. CREATE OR ALTER STREAM and DEFINE STREAM read a table only. A time travel point may name a STREAM. --- .../src/parser/_helpers/ddl/mod.rs | 1 + .../_helpers/ddl/snowflake_create_clone.rs | 7 +- .../ddl/snowflake_create_properties.rs | 3 + .../_helpers/ddl/snowflake_external_table.rs | 5 +- .../parser/_helpers/ddl/snowflake_streams.rs | 164 ++++++++++++++++++ .../sqlquack-syntax-snowflake/tests/parser.rs | 2 + .../tests/parser/test_snowflake_views.rs | 61 +++++++ 7 files changed, 240 insertions(+), 3 deletions(-) create mode 100644 crates/sqlquack-syntax-snowflake/src/parser/_helpers/ddl/snowflake_streams.rs create mode 100644 crates/sqlquack-syntax-snowflake/tests/parser/test_snowflake_views.rs diff --git a/crates/sqlquack-syntax-snowflake/src/parser/_helpers/ddl/mod.rs b/crates/sqlquack-syntax-snowflake/src/parser/_helpers/ddl/mod.rs index 3572e3324..b29e8b0e5 100644 --- a/crates/sqlquack-syntax-snowflake/src/parser/_helpers/ddl/mod.rs +++ b/crates/sqlquack-syntax-snowflake/src/parser/_helpers/ddl/mod.rs @@ -33,5 +33,6 @@ pub(crate) mod snowflake_routines; pub(crate) mod snowflake_semantic_view; pub(crate) mod snowflake_show; pub(crate) mod snowflake_statements; +pub(crate) mod snowflake_streams; pub(crate) mod table_options; pub(crate) mod transactions; diff --git a/crates/sqlquack-syntax-snowflake/src/parser/_helpers/ddl/snowflake_create_clone.rs b/crates/sqlquack-syntax-snowflake/src/parser/_helpers/ddl/snowflake_create_clone.rs index cc77c67ef..ad53702c4 100644 --- a/crates/sqlquack-syntax-snowflake/src/parser/_helpers/ddl/snowflake_create_clone.rs +++ b/crates/sqlquack-syntax-snowflake/src/parser/_helpers/ddl/snowflake_create_clone.rs @@ -28,12 +28,17 @@ pub(crate) enum CloneOptions { impl Parser { /// A clone's `{ AT | BEFORE } ( { TIMESTAMP | OFFSET | STATEMENT } => )`, if present. pub(crate) fn parse_clone_time_travel(&mut self) -> Result> { + self.parse_time_travel(&[&["TIMESTAMP"], &["OFFSET"], &["STATEMENT"]]) + } + + /// `{ AT | BEFORE } ( => )` for one of `kinds`, if present. + pub(crate) fn parse_time_travel(&mut self, kinds: &[&[&str]]) -> Result> { if !self.check_identifier("AT") && !self.check(TokenType::Before) { return Ok(None); } let this = self.advance_text()?.to_ascii_uppercase(); self.expect_token(TokenType::LParen)?; - let kind = self.expect_alter_words(&[&["TIMESTAMP"], &["OFFSET"], &["STATEMENT"]])?; + let kind = self.expect_alter_words(kinds)?; self.expect_token(TokenType::FArrow)?; let expression = self.parse_expression()?; self.expect_token(TokenType::RParen)?; diff --git a/crates/sqlquack-syntax-snowflake/src/parser/_helpers/ddl/snowflake_create_properties.rs b/crates/sqlquack-syntax-snowflake/src/parser/_helpers/ddl/snowflake_create_properties.rs index 2676ba740..27ff0d313 100644 --- a/crates/sqlquack-syntax-snowflake/src/parser/_helpers/ddl/snowflake_create_properties.rs +++ b/crates/sqlquack-syntax-snowflake/src/parser/_helpers/ddl/snowflake_create_properties.rs @@ -28,6 +28,9 @@ impl Parser { if let Some(table) = self.parse_snowflake_create_event_table(or_replace, or_alter)? { return Ok(Some(table)); } + if let Some(stream) = self.parse_snowflake_create_stream(or_replace, or_alter)? { + return Ok(Some(stream)); + } if self.check_text_seq(&["SEMANTIC", "VIEW"]) { self.check_snowflake_semantic_view()?; } diff --git a/crates/sqlquack-syntax-snowflake/src/parser/_helpers/ddl/snowflake_external_table.rs b/crates/sqlquack-syntax-snowflake/src/parser/_helpers/ddl/snowflake_external_table.rs index de5e2d082..c90754acd 100644 --- a/crates/sqlquack-syntax-snowflake/src/parser/_helpers/ddl/snowflake_external_table.rs +++ b/crates/sqlquack-syntax-snowflake/src/parser/_helpers/ddl/snowflake_external_table.rs @@ -311,7 +311,7 @@ impl Parser { } /// `( = [ , ... ] )`, one TAG property per tag. - fn parse_external_tags(&mut self) -> Result> { + pub(super) fn parse_external_tags(&mut self) -> Result> { self.expect_token(TokenType::LParen)?; let mut tags: Vec = Vec::new(); loop { @@ -390,7 +390,8 @@ impl Parser { Ok(Expression::Tuple(Box::new(Tuple { expressions }))) } - fn parse_external_scalar(&mut self) -> Result { + /// A string, number or boolean literal, or a bare word. + pub(super) fn parse_external_scalar(&mut self) -> Result { let literal = matches!( self.peek().token_type, TokenType::String | TokenType::Number | TokenType::True | TokenType::False diff --git a/crates/sqlquack-syntax-snowflake/src/parser/_helpers/ddl/snowflake_streams.rs b/crates/sqlquack-syntax-snowflake/src/parser/_helpers/ddl/snowflake_streams.rs new file mode 100644 index 000000000..8939791a8 --- /dev/null +++ b/crates/sqlquack-syntax-snowflake/src/parser/_helpers/ddl/snowflake_streams.rs @@ -0,0 +1,164 @@ +//! Parser ddl: Snowflake CREATE STREAM on a table, view, external, dynamic or event table or a stage, checked against its grammar. + +use crate::parser::_helpers::ddl::snowflake_alter::property; +use crate::parser::models::Parser; +use sqlquack_syntax::expressions::names::models::Identifier; +use sqlquack_syntax::expressions::schema_objects::models::{CreateObject, ObjectProperty}; +use sqlquack_syntax::expressions::tree::models::Expression; +use sqlquack_syntax::expressions::types::models::Tuple; +use sqlquack_syntax::tokens::models::TokenType; +use sqlquack_syntax::tokens::types::Result; + +/// A source a stream can read: its words after ON, whether it takes AT or BEFORE, and the properties it takes after. +struct StreamSource { + words: &'static [&'static str], + time_travel: bool, + properties: &'static [&'static [&'static str]], +} + +/// The sources of CREATE STREAM, each with the properties its grammar documents. +const STREAM_SOURCES: [StreamSource; 6] = [ + StreamSource { + words: &["TABLE"], + time_travel: true, + properties: &[ + &["APPEND_ONLY"], + &["SHOW_INITIAL_ROWS"], + &["COMMENT"], + &["COPY"], + ], + }, + StreamSource { + words: &["VIEW"], + time_travel: true, + properties: &[ + &["APPEND_ONLY"], + &["SHOW_INITIAL_ROWS"], + &["COMMENT"], + &["COPY"], + ], + }, + StreamSource { + words: &["EXTERNAL", "TABLE"], + time_travel: true, + properties: &[&["INSERT_ONLY"], &["COMMENT"], &["COPY"]], + }, + StreamSource { + words: &["DYNAMIC", "TABLE"], + time_travel: false, + properties: &[&["COMMENT"], &["COPY"]], + }, + StreamSource { + words: &["EVENT", "TABLE"], + time_travel: false, + properties: &[&["COMMENT"], &["COPY"]], + }, + StreamSource { + words: &["STAGE"], + time_travel: false, + properties: &[&["COMMENT"], &["COPY"]], + }, +]; + +/// CREATE OR ALTER STREAM and DEFINE STREAM: on a table only, with these properties. +const STREAM_OR_ALTER_SOURCE: StreamSource = StreamSource { + words: &["TABLE"], + time_travel: false, + properties: &[&["APPEND_ONLY"], &["SHOW_INITIAL_ROWS"], &["COMMENT"]], +}; + +/// The kinds a stream's AT or BEFORE takes. +const STREAM_TIME_TRAVEL_KINDS: [&[&str]; 4] = + [&["TIMESTAMP"], &["OFFSET"], &["STATEMENT"], &["STREAM"]]; + +impl Parser { + /// A Snowflake CREATE [ OR REPLACE | OR ALTER ] STREAM or DEFINE STREAM other than a clone; None for any other CREATE and outside the faithful Snowflake modes. + pub(crate) fn parse_snowflake_create_stream( + &mut self, + or_replace: bool, + or_alter: bool, + ) -> Result> { + if !self.applies_snowflake_context_rules() || !self.check_identifier("STREAM") { + return Ok(None); + } + let start = self.current; + self.skip(); + let if_not_exists = !or_alter && self.check(TokenType::If); + if if_not_exists + && !self.match_keywords(&[TokenType::If, TokenType::Not, TokenType::Exists]) + { + return Err(self.parse_error("Expected IF NOT EXISTS")); + } + let name = self.parse_iceberg_object_name()?; + if !or_alter && self.check_identifier("CLONE") { + self.current = start; + return Ok(None); + } + let mut properties: Vec = Vec::new(); + if !or_alter { + if self.match_text_seq(&["WITH", "TAG"]) || self.match_text_seq(&["TAG"]) { + properties.extend(self.parse_external_tags()?); + } + if self.match_text_seq(&["COPY", "GRANTS"]) { + properties.push(property("COPY GRANTS", None, None)); + } + } + if !self.match_token(TokenType::On) { + return Err(self.parse_error("Expected ON and the stream's source")); + } + let sources = match or_alter { + true => std::slice::from_ref(&STREAM_OR_ALTER_SOURCE), + false => &STREAM_SOURCES[..], + }; + let Some(source) = sources + .iter() + .find(|source| self.check_text_seq(source.words)) + else { + let words = sources + .iter() + .map(|source| source.words.join(" ")) + .collect::>() + .join(" or "); + return Err(self.parse_error(format!("Expected {words}"))); + }; + self.match_text_seq(source.words); + let mut operands = vec![Expression::Table(Box::new( + self.parse_iceberg_object_name()?, + ))]; + if source.time_travel { + operands.extend(self.parse_time_travel(&STREAM_TIME_TRAVEL_KINDS)?); + } + while !self.is_at_end() && !self.check(TokenType::Semicolon) { + let kind = self.expect_alter_words(source.properties)?; + self.expect_token(TokenType::Eq)?; + let value = match kind.as_str() { + "COPY" => self.parse_stream_copy()?, + _ => self.parse_external_scalar()?, + }; + properties.push(property(&kind, None, Some(value))); + } + Ok(Some(Expression::CreateObject(Box::new(CreateObject { + kind: "STREAM".to_owned(), + modifier: None, + or_replace, + or_alter, + if_not_exists, + name, + source: format!("ON {}", source.words.join(" ")), + operands, + properties, + })))) + } + + /// A stream's `COPY = ( [ GRANTS ] )` value after its `=`. + fn parse_stream_copy(&mut self) -> Result { + self.expect_token(TokenType::LParen)?; + let mut expressions: Vec = Vec::new(); + if !self.check(TokenType::RParen) { + let grants = self.expect_alter_words(&[&["GRANTS"]])?; + expressions.push(Expression::Identifier(Identifier::new(grants))); + } + self.expect_token(TokenType::RParen)?; + Ok(Expression::Tuple(Box::new(Tuple { expressions }))) + } +} diff --git a/crates/sqlquack-syntax-snowflake/tests/parser.rs b/crates/sqlquack-syntax-snowflake/tests/parser.rs index e9bc36e5f..bc2d77a55 100644 --- a/crates/sqlquack-syntax-snowflake/tests/parser.rs +++ b/crates/sqlquack-syntax-snowflake/tests/parser.rs @@ -36,5 +36,7 @@ mod test_snowflake_grant; mod test_snowflake_recorded; #[path = "parser/test_snowflake_tables.rs"] mod test_snowflake_tables; +#[path = "parser/test_snowflake_views.rs"] +mod test_snowflake_views; #[path = "parser/test_types.rs"] mod test_types; diff --git a/crates/sqlquack-syntax-snowflake/tests/parser/test_snowflake_views.rs b/crates/sqlquack-syntax-snowflake/tests/parser/test_snowflake_views.rs new file mode 100644 index 000000000..cc7c21711 --- /dev/null +++ b/crates/sqlquack-syntax-snowflake/tests/parser/test_snowflake_views.rs @@ -0,0 +1,61 @@ +use crate::helpers::snowflake_kind; +use crate::test_types::SnowflakeVerdictTestCase; + +#[test] +fn given_snowflake_streams_when_parsing_then_their_grammar_is_checked() { + let test_cases = [ + SnowflakeVerdictTestCase { + description: "a stream on a table with every option (recorded OK)", + sql: "CREATE OR REPLACE STREAM IF NOT EXISTS v1 WITH TAG ( v1 = 'm' , v1 = 'm' ) COPY GRANTS ON TABLE v1 AT ( STREAM => 'm' ) APPEND_ONLY = TRUE SHOW_INITIAL_ROWS = TRUE COMMENT = 'm' COPY = ( GRANTS ) ;", + expected_kind: Some("create_object"), + }, + SnowflakeVerdictTestCase { + description: "a stream on a stage with a bare comment and an empty copy (recorded OK)", + sql: "CREATE STREAM v1 TAG ( v1 = m1 ) ON STAGE s1 COMMENT = m1 COPY = ( ) ;", + expected_kind: Some("create_object"), + }, + SnowflakeVerdictTestCase { + description: "a stream on an external table reading from a timestamp (recorded OK)", + sql: "CREATE STREAM v1 ON EXTERNAL TABLE v1 BEFORE ( TIMESTAMP => CURRENT_TIMESTAMP ( ) ) INSERT_ONLY = TRUE ;", + expected_kind: Some("create_object"), + }, + SnowflakeVerdictTestCase { + description: "a stream missing its source kind (recorded errno 1003)", + sql: "CREATE STREAM v1 ON v1 ;", + expected_kind: None, + }, + SnowflakeVerdictTestCase { + description: "a time travel point missing its kind (recorded errno 1003)", + sql: "CREATE STREAM v1 ON TABLE v1 AT ( => 'm' ) ;", + expected_kind: None, + }, + SnowflakeVerdictTestCase { + description: "a tag missing its value (recorded errno 1003)", + sql: "CREATE STREAM v1 TAG ( v1 = ) ON VIEW v1 ;", + expected_kind: None, + }, + SnowflakeVerdictTestCase { + description: "an unclosed copy list (recorded errno 1003)", + sql: "CREATE STREAM v1 ON DYNAMIC TABLE v1 COPY = ( GRANTS ;", + expected_kind: None, + }, + SnowflakeVerdictTestCase { + description: "CREATE OR ALTER STREAM reads only a table", + sql: "CREATE OR ALTER STREAM v1 ON VIEW v1 ;", + expected_kind: None, + }, + SnowflakeVerdictTestCase { + description: "INSERT_ONLY belongs to external tables only", + sql: "CREATE STREAM v1 ON TABLE v1 INSERT_ONLY = TRUE ;", + 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 1274d6c9c1f7ec696b7db260f09284fce8585378 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:20:19 +0000 Subject: [PATCH 2/9] feat(syntax): build Snowflake semantic views as nodes CREATE SEMANTIC VIEW was checked against its grammar and then kept as raw text, and DEFINE SEMANTIC VIEW was refused. Both now build a CreateObject whose clauses are properties: tables, relationships, facts, dimensions, metrics and verified queries as lists of their parts, with the item expressions parsed. --- .../ddl/snowflake_create_properties.rs | 4 +- .../_helpers/ddl/snowflake_semantic_view.rs | 361 +++++++++++------- .../tests/parser/test_snowflake_fixes.rs | 9 +- 3 files changed, 235 insertions(+), 139 deletions(-) diff --git a/crates/sqlquack-syntax-snowflake/src/parser/_helpers/ddl/snowflake_create_properties.rs b/crates/sqlquack-syntax-snowflake/src/parser/_helpers/ddl/snowflake_create_properties.rs index 27ff0d313..58e5bd16a 100644 --- a/crates/sqlquack-syntax-snowflake/src/parser/_helpers/ddl/snowflake_create_properties.rs +++ b/crates/sqlquack-syntax-snowflake/src/parser/_helpers/ddl/snowflake_create_properties.rs @@ -31,8 +31,8 @@ impl Parser { if let Some(stream) = self.parse_snowflake_create_stream(or_replace, or_alter)? { return Ok(Some(stream)); } - if self.check_text_seq(&["SEMANTIC", "VIEW"]) { - self.check_snowflake_semantic_view()?; + if let Some(view) = self.parse_snowflake_create_semantic_view(or_replace, or_alter)? { + return Ok(Some(view)); } if self.check(TokenType::Alter) || self.check(TokenType::String) { return Err(self.parse_error("Expected an object kind after CREATE")); diff --git a/crates/sqlquack-syntax-snowflake/src/parser/_helpers/ddl/snowflake_semantic_view.rs b/crates/sqlquack-syntax-snowflake/src/parser/_helpers/ddl/snowflake_semantic_view.rs index d8ab76239..a77a5cccf 100644 --- a/crates/sqlquack-syntax-snowflake/src/parser/_helpers/ddl/snowflake_semantic_view.rs +++ b/crates/sqlquack-syntax-snowflake/src/parser/_helpers/ddl/snowflake_semantic_view.rs @@ -1,251 +1,340 @@ -//! Parser ddl: Snowflake CREATE SEMANTIC VIEW, checked before the statement is kept as written. +//! Parser ddl: Snowflake CREATE SEMANTIC VIEW and DEFINE SEMANTIC VIEW, built against their grammar. +use crate::parser::_helpers::ddl::snowflake_alter::property; use crate::parser::constants::SNOWFLAKE_VERIFIED_QUERY_FIELDS; use crate::parser::models::Parser; +use sqlquack_syntax::expressions::names::models::Identifier; +use sqlquack_syntax::expressions::operators::models::BinaryOp; +use sqlquack_syntax::expressions::schema_objects::models::{CreateObject, ObjectProperty}; +use sqlquack_syntax::expressions::tree::models::Expression; +use sqlquack_syntax::expressions::types::models::Tuple; use sqlquack_syntax::tokens::models::TokenType; use sqlquack_syntax::tokens::types::Result; +/// Parses one item of a semantic view list. +type SemanticItem = fn(&mut Parser) -> Result; + +/// ` = `: one named part of a semantic view entry. +fn part(key: &str, value: Expression) -> Expression { + Expression::PropertyEQ(Box::new(BinaryOp::new( + Expression::Identifier(Identifier::new(key)), + value, + ))) +} + +/// The parts of one entry, in the order they are written. +fn tuple(expressions: Vec) -> Expression { + Expression::Tuple(Box::new(Tuple { expressions })) +} + impl Parser { - /// Refuses a Snowflake CREATE SEMANTIC VIEW that breaks its grammar, leaving the position unchanged. - pub(crate) fn check_snowflake_semantic_view(&mut self) -> Result<()> { - let start = self.current; - let checked = self.check_semantic_view_body(); - self.current = start; + /// A Snowflake CREATE [ OR REPLACE | OR ALTER ] SEMANTIC VIEW or DEFINE SEMANTIC VIEW; None for any other CREATE. + pub(crate) fn parse_snowflake_create_semantic_view( + &mut self, + or_replace: bool, + or_alter: bool, + ) -> Result> { + if !self.check_text_seq(&["SEMANTIC", "VIEW"]) { + return Ok(None); + } + self.config.in_semantic_view = true; + let view = self.parse_semantic_view_definition(or_replace, or_alter); self.config.in_semantic_view = false; - checked + view.map(Some) } - /// Skips EXCLUDING after a window's PARTITION BY while a semantic view is checked. + /// Skips EXCLUDING after a window's PARTITION BY inside a semantic view. pub(crate) fn skip_semantic_view_excluding(&mut self) { if self.config.in_semantic_view { self.match_text_seq(&["EXCLUDING"]); } } - fn check_semantic_view_body(&mut self) -> Result<()> { - self.config.in_semantic_view = true; + fn parse_semantic_view_definition( + &mut self, + or_replace: bool, + or_alter: bool, + ) -> Result { self.match_text_seq(&["SEMANTIC", "VIEW"]); - if self.check(TokenType::If) + let if_not_exists = self.check(TokenType::If); + if if_not_exists && !self.match_keywords(&[TokenType::If, TokenType::Not, TokenType::Exists]) { return Err(self.parse_error("Expected IF NOT EXISTS")); } - self.parse_object_name()?; + let name = self.parse_object_name()?; if !self.match_text_seq(&["TABLES"]) { return Err(self.parse_error("Expected TABLES after the semantic view name")); } - self.semantic_list(Self::check_semantic_table)?; + let mut properties = vec![property( + "TABLES", + None, + Some(self.semantic_list(Self::parse_semantic_table)?), + )]; while !self.is_at_end() && !self.check(TokenType::Semicolon) { - self.check_semantic_view_clause()?; + properties.push(self.parse_semantic_view_clause()?); } - Ok(()) + Ok(Expression::CreateObject(Box::new(CreateObject { + kind: "SEMANTIC VIEW".to_owned(), + modifier: None, + or_replace, + or_alter, + if_not_exists, + name, + source: String::new(), + operands: Vec::new(), + properties, + }))) } - fn check_semantic_view_clause(&mut self) -> Result<()> { - if self.match_text_seq(&["RELATIONSHIPS"]) { - self.semantic_list(Self::check_semantic_relationship) - } else if self.match_text_seq(&["FACTS"]) - || self.match_text_seq(&["DIMENSIONS"]) - || self.match_text_seq(&["METRICS"]) - { - self.semantic_list(Self::check_semantic_item) - } else if self.match_text_seq(&["AI_VERIFIED_QUERIES"]) { - self.semantic_list(Self::check_semantic_verified_query) - } else if self.match_text_seq(&["AI_SQL_GENERATION"]) - || self.match_text_seq(&["AI_QUESTION_CATEGORIZATION"]) - { - self.expect_semantic_string() - } else if self.match_text_seq(&["COPY", "GRANTS"]) { - Ok(()) - } else if self.match_text_seq(&["COMMENT"]) { + fn parse_semantic_view_clause(&mut self) -> Result { + let lists: [(&str, SemanticItem); 5] = [ + ("RELATIONSHIPS", Self::parse_semantic_relationship), + ("FACTS", Self::parse_semantic_item), + ("DIMENSIONS", Self::parse_semantic_item), + ("METRICS", Self::parse_semantic_item), + ("AI_VERIFIED_QUERIES", Self::parse_semantic_verified_query), + ]; + if let Some((kind, item)) = lists.iter().find(|(kind, _)| self.match_text_seq(&[kind])) { + return Ok(property(kind, None, Some(self.semantic_list(*item)?))); + } + for kind in ["AI_SQL_GENERATION", "AI_QUESTION_CATEGORIZATION"] { + if self.match_text_seq(&[kind]) { + return Ok(property(kind, None, Some(self.parse_semantic_string()?))); + } + } + if self.match_text_seq(&["COPY", "GRANTS"]) { + return Ok(property("COPY GRANTS", None, None)); + } + if self.match_text_seq(&["COMMENT"]) { self.expect_token(TokenType::Eq)?; - self.expect_semantic_string() - } else if self.match_text_seq(&["WITH", "TAG"]) || self.match_text_seq(&["TAG"]) { - self.check_semantic_tags() - } else { - Err(self.parse_error("Unexpected clause in CREATE SEMANTIC VIEW")) + return Ok(property( + "COMMENT", + None, + Some(self.parse_semantic_string()?), + )); + } + if self.match_text_seq(&["WITH", "TAG"]) || self.match_text_seq(&["TAG"]) { + return Ok(property("TAG", None, Some(self.parse_semantic_tags()?))); } + Err(self.parse_error("Unexpected clause in CREATE SEMANTIC VIEW")) } - /// Checks `( [ , ... ] )`, which needs at least one item. - fn semantic_list(&mut self, item: fn(&mut Self) -> Result<()>) -> Result<()> { + /// `( [ , ... ] )`, which needs at least one item. + fn semantic_list(&mut self, item: SemanticItem) -> Result { self.expect_token(TokenType::LParen)?; + let mut items: Vec = Vec::new(); loop { - item(self)?; + items.push(item(self)?); if !self.match_token(TokenType::Comma) { break; } } self.expect_token(TokenType::RParen)?; - Ok(()) + Ok(tuple(items)) } - fn expect_semantic_string(&mut self) -> Result<()> { - match self.match_token(TokenType::String) { - true => Ok(()), + fn parse_semantic_string(&mut self) -> Result { + match self.check(TokenType::String) { + true => self.parse_primary(), false => Err(self.parse_error("Expected a string")), } } - /// Checks one unqualified name: an identifier or a keyword Snowflake does not reserve. - fn expect_semantic_name(&mut self) -> Result<()> { - let token_type = self.peek().token_type; - let name_like = matches!( - token_type, - TokenType::Identifier | TokenType::QuotedIdentifier | TokenType::Var - ) || token_type.is_keyword(); - if self.is_at_end() || !name_like || self.is_snowflake_reserved_keyword() { - return Err(self.parse_error("Expected a name")); - } - self.skip(); - Ok(()) + /// One unqualified name: an identifier or a keyword Snowflake does not reserve. + fn parse_semantic_name(&mut self) -> Result { + Ok(Expression::Identifier(self.parse_external_name()?)) } - fn check_semantic_alias(&mut self) -> Result<()> { - if self + /// ` AS`, when the name is followed by AS. + fn parse_semantic_alias(&mut self) -> Result> { + if !self .peek_nth(1) .is_some_and(|token| token.token_type == TokenType::As) { - self.expect_semantic_name()?; - self.skip(); + return Ok(None); } - Ok(()) + let alias = self.parse_semantic_name()?; + self.skip(); + Ok(Some(part("ALIAS", alias))) } - fn check_semantic_table(&mut self) -> Result<()> { - self.check_semantic_alias()?; - self.parse_object_name()?; + /// `[ AS] [ PRIMARY KEY ( ... ) | UNIQUE ( ... ) | CONSTRAINT ... | ... ]`. + fn parse_semantic_table(&mut self) -> Result { + let mut parts: Vec = self.parse_semantic_alias()?.into_iter().collect(); + parts.push(Expression::Table(Box::new(self.parse_object_name()?))); while !self.check(TokenType::Comma) && !self.check(TokenType::RParen) { - if self.match_text_seq(&["PRIMARY", "KEY"]) || self.match_text_seq(&["UNIQUE"]) { - self.semantic_list(Self::expect_semantic_name)?; + if self.match_text_seq(&["PRIMARY", "KEY"]) { + parts.push(part( + "PRIMARY KEY", + self.semantic_list(Self::parse_semantic_name)?, + )); + } else if self.match_text_seq(&["UNIQUE"]) { + parts.push(part( + "UNIQUE", + self.semantic_list(Self::parse_semantic_name)?, + )); } else if self.match_text_seq(&["CONSTRAINT"]) { - self.expect_semantic_name()?; + let constraint = self.parse_semantic_name()?; if !self.match_text_seq(&["DISTINCT", "RANGE"]) { return Err(self.parse_error("Expected DISTINCT RANGE")); } - self.check_semantic_range()?; + let range = self.parse_semantic_range()?; + parts.push(part("CONSTRAINT", tuple(vec![constraint, range]))); } else { - self.check_semantic_property(false)?; + parts.push(self.parse_semantic_property(false)?); } } - Ok(()) + Ok(tuple(parts)) } - fn check_semantic_range(&mut self) -> Result<()> { + /// `BETWEEN AND EXCLUSIVE`. + fn parse_semantic_range(&mut self) -> Result { if !self.match_text_seq(&["BETWEEN"]) { return Err(self.parse_error("Expected BETWEEN")); } - self.expect_semantic_name()?; + let start = self.parse_semantic_name()?; if !self.match_text_seq(&["AND"]) { return Err(self.parse_error("Expected AND")); } - self.expect_semantic_name()?; + let end = self.parse_semantic_name()?; match self.match_text_seq(&["EXCLUSIVE"]) { - true => Ok(()), + true => Ok(part("DISTINCT RANGE", tuple(vec![start, end]))), false => Err(self.parse_error("Expected EXCLUSIVE")), } } - /// Checks COMMENT, SYNONYMS or TAG, and with `item` also a Cortex Search service. - fn check_semantic_property(&mut self, item: bool) -> Result<()> { + /// COMMENT, SYNONYMS or TAG, and with `item` also a Cortex Search service. + fn parse_semantic_property(&mut self, item: bool) -> Result { if self.match_text_seq(&["COMMENT"]) { self.expect_token(TokenType::Eq)?; - self.expect_semantic_string() + Ok(part("COMMENT", self.parse_semantic_string()?)) } else if self.match_text_seq(&["WITH", "SYNONYMS"]) { self.match_token(TokenType::Eq); - self.semantic_list(Self::expect_semantic_string) + Ok(part( + "SYNONYMS", + self.semantic_list(Self::parse_semantic_string)?, + )) } else if item && self.match_text_seq(&["WITH", "CORTEX", "SEARCH", "SERVICE"]) { - self.parse_object_name()?; - match self.match_token(TokenType::Using) { - true => self.expect_semantic_name(), - false => Ok(()), + let mut service = vec![Expression::Table(Box::new(self.parse_object_name()?))]; + if self.match_token(TokenType::Using) { + service.push(self.parse_semantic_name()?); } + Ok(part("CORTEX SEARCH SERVICE", tuple(service))) } else if self.match_text_seq(&["WITH", "TAG"]) || self.match_text_seq(&["TAG"]) { - self.check_semantic_tags() + Ok(part("TAG", self.parse_semantic_tags()?)) } else { Err(self.parse_error("Unexpected property in CREATE SEMANTIC VIEW")) } } - fn check_semantic_tags(&mut self) -> Result<()> { + /// `( = [ , ... ] )`, the value a string or a name. + fn parse_semantic_tags(&mut self) -> Result { self.semantic_list(|parser| { - parser.parse_object_name()?; + let tag = Expression::Table(Box::new(parser.parse_object_name()?)); parser.expect_token(TokenType::Eq)?; - match parser.check(TokenType::String) { - true => parser.expect_semantic_string(), - false => parser.expect_semantic_name(), - } + let value = match parser.check(TokenType::String) { + true => parser.parse_semantic_string()?, + false => parser.parse_semantic_name()?, + }; + Ok(Expression::PropertyEQ(Box::new(BinaryOp::new(tag, value)))) }) } - fn check_semantic_relationship(&mut self) -> Result<()> { - self.check_semantic_alias()?; - self.parse_object_name()?; - self.semantic_list(Self::expect_semantic_name)?; + /// `[ AS]
( ) REFERENCES
[ ( | ) ]`. + fn parse_semantic_relationship(&mut self) -> Result { + let mut parts: Vec = self.parse_semantic_alias()?.into_iter().collect(); + parts.push(Expression::Table(Box::new(self.parse_object_name()?))); + parts.push(self.semantic_list(Self::parse_semantic_name)?); if !self.match_text_seq(&["REFERENCES"]) { return Err(self.parse_error("Expected REFERENCES")); } - self.parse_object_name()?; - if !self.match_token(TokenType::LParen) { - return Ok(()); - } - if self.check_text_seq(&["BETWEEN"]) { - self.check_semantic_range()?; - } else { - loop { - self.match_text_seq(&["ASOF"]); - self.expect_semantic_name()?; - if !self.match_token(TokenType::Comma) { - break; + let mut references = vec![Expression::Table(Box::new(self.parse_object_name()?))]; + if self.match_token(TokenType::LParen) { + if self.check_text_seq(&["BETWEEN"]) { + references.push(self.parse_semantic_range()?); + } else { + loop { + references.push(match self.match_text_seq(&["ASOF"]) { + true => part("ASOF", self.parse_semantic_name()?), + false => self.parse_semantic_name()?, + }); + if !self.match_token(TokenType::Comma) { + break; + } } } + self.expect_token(TokenType::RParen)?; } - self.expect_token(TokenType::RParen)?; - Ok(()) + parts.push(part("REFERENCES", tuple(references))); + Ok(tuple(parts)) } - /// Checks a fact, dimension or metric: `[PUBLIC | PRIVATE]
. [...] AS [ ...]`. - fn check_semantic_item(&mut self) -> Result<()> { + /// A fact, dimension or metric: `[PUBLIC | PRIVATE]
. [...] AS [ ...]`. + fn parse_semantic_item(&mut self) -> Result { + let mut parts: Vec = Vec::new(); if !self.check_next(TokenType::Dot) { - let _ = self.match_text_seq(&["PUBLIC"]) || self.match_text_seq(&["PRIVATE"]); + for visibility in ["PUBLIC", "PRIVATE"] { + if self.match_text_seq(&[visibility]) { + parts.push(Expression::Identifier(Identifier::new(visibility))); + break; + } + } } - self.expect_semantic_name()?; + let table = self.parse_semantic_name()?; self.expect_token(TokenType::Dot)?; - self.expect_semantic_name()?; + parts.push(tuple(vec![table, self.parse_semantic_name()?])); loop { if self.match_text_seq(&["LABELS"]) { self.expect_token(TokenType::Eq)?; - self.semantic_list(Self::expect_semantic_name)?; + parts.push(part( + "LABELS", + self.semantic_list(Self::parse_semantic_name)?, + )); } else if self.match_token(TokenType::Using) { - self.semantic_list(Self::expect_semantic_name)?; + parts.push(part( + "USING", + self.semantic_list(Self::parse_semantic_name)?, + )); } else if self.match_text_seq(&["NON", "ADDITIVE", "BY"]) { - self.semantic_list(Self::check_semantic_ordered_name)?; + parts.push(part( + "NON ADDITIVE BY", + self.semantic_list(Self::parse_semantic_ordered_name)?, + )); } else { break; } } self.expect_token(TokenType::As)?; - self.parse_expression()?; + parts.push(part("AS", self.parse_expression()?)); while !self.check(TokenType::Comma) && !self.check(TokenType::RParen) { - self.check_semantic_property(true)?; + parts.push(self.parse_semantic_property(true)?); } - Ok(()) + Ok(tuple(parts)) } - fn check_semantic_ordered_name(&mut self) -> Result<()> { - self.expect_semantic_name()?; - let _ = self.match_token(TokenType::Asc) || self.match_token(TokenType::Desc); - if self.match_text_seq(&["NULLS"]) - && !(self.match_text_seq(&["FIRST"]) || self.match_text_seq(&["LAST"])) - { - return Err(self.parse_error("Expected FIRST or LAST after NULLS")); + /// ` [ ASC | DESC ] [ NULLS { FIRST | LAST } ]`. + fn parse_semantic_ordered_name(&mut self) -> Result { + let mut parts = vec![self.parse_semantic_name()?]; + for direction in ["ASC", "DESC"] { + if self.match_text_seq(&[direction]) { + parts.push(Expression::Identifier(Identifier::new(direction))); + break; + } } - Ok(()) + if self.match_text_seq(&["NULLS"]) { + let order = self.expect_alter_words(&[&["FIRST"], &["LAST"]])?; + parts.push(part( + "NULLS", + Expression::Identifier(Identifier::new(order)), + )); + } + Ok(tuple(parts)) } - /// Checks ` AS ( ... )`, which needs QUESTION and SQL. - fn check_semantic_verified_query(&mut self) -> Result<()> { - self.expect_semantic_name()?; + /// ` AS ( ... )`, which needs QUESTION and SQL. + fn parse_semantic_verified_query(&mut self) -> Result { + let mut parts = vec![self.parse_semantic_name()?]; self.expect_token(TokenType::As)?; self.expect_token(TokenType::LParen)?; let mut keys: Vec = Vec::new(); @@ -256,15 +345,17 @@ impl Parser { ) { return Err(self.parse_error("Expected a verified query field")); } - keys.push(self.advance_text()?.to_ascii_uppercase()); + let key = self.advance_text()?.to_ascii_uppercase(); let literal = matches!( self.peek().token_type, TokenType::String | TokenType::Number | TokenType::True | TokenType::False ); - match literal { - true => self.skip(), - false => self.expect_semantic_name()?, - } + let value = match literal && !self.is_at_end() { + true => self.parse_primary()?, + false => self.parse_semantic_name()?, + }; + parts.push(part(&key, value)); + keys.push(key); } if !SNOWFLAKE_VERIFIED_QUERY_FIELDS .iter() @@ -273,6 +364,6 @@ impl Parser { return Err(self.parse_error("Expected QUESTION and SQL in a verified query")); } self.expect_token(TokenType::RParen)?; - Ok(()) + Ok(tuple(parts)) } } diff --git a/crates/sqlquack-syntax-snowflake/tests/parser/test_snowflake_fixes.rs b/crates/sqlquack-syntax-snowflake/tests/parser/test_snowflake_fixes.rs index 3525f5a3a..f9672688d 100644 --- a/crates/sqlquack-syntax-snowflake/tests/parser/test_snowflake_fixes.rs +++ b/crates/sqlquack-syntax-snowflake/tests/parser/test_snowflake_fixes.rs @@ -1650,12 +1650,17 @@ fn given_a_snowflake_create_semantic_view_when_parsing_then_recorded_verdicts_ho SnowflakeVerdictTestCase { description: "a metric window PARTITION BY EXCLUDING (recorded OK)", sql: "CREATE OR ALTER SEMANTIC VIEW v1 TABLES ( v1 ) METRICS ( v1 . v1 AS SUM ( v1 ) OVER ( PARTITION BY EXCLUDING v1 ) ) ;", - expected_kind: Some("raw"), + expected_kind: Some("create_object"), }, SnowflakeVerdictTestCase { description: "a dimension with a Cortex Search service (recorded OK)", sql: "CREATE SEMANTIC VIEW v1 TABLES ( v1 ) DIMENSIONS ( v1 . v1 AS 1 WITH CORTEX SEARCH SERVICE v1 USING v1 ) ;", - expected_kind: Some("raw"), + expected_kind: Some("create_object"), + }, + SnowflakeVerdictTestCase { + description: "a DCM semantic view definition (recorded OK)", + sql: "DEFINE SEMANTIC VIEW d1.s1.v1 TABLES ( d1.s1.v1 ) RELATIONSHIPS ( v1 ( v1 ) REFERENCES v1 ( BETWEEN v1 AND v1 EXCLUSIVE ) ) ;", + expected_kind: Some("create_object"), }, SnowflakeVerdictTestCase { description: "PARTITION BY EXCLUDING outside a semantic view stays refused", From 830503f7bebb5d460c8d913a0984c04f61aa4381 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:41:13 +0000 Subject: [PATCH 3/9] feat(syntax): check Snowflake views and materialized views against their grammar Recursive, volatile, secure materialized and interactive materialized views were kept as raw text, and view-level clauses such as COPY = ( ), JOIN POLICY, AGGREGATION POLICY and WITH CONTACT were refused. A Snowflake view now reads its scope, column options, view clauses and query against the grammar of its kind: a materialized view takes CLUSTER BY and no CHANGE_TRACKING, and CREATE OR ALTER VIEW takes only CHANGE_TRACKING and COMMENT. Column policies after the column list, empty column lists and string column names are refused, as Snowflake refuses them. --- .../src/parser/_helpers/ddl/constraints.rs | 6 +- .../src/parser/_helpers/ddl/mod.rs | 1 + .../_helpers/ddl/snowflake_semantic_view.rs | 4 +- .../parser/_helpers/ddl/snowflake_views.rs | 298 ++++++++++++++++++ .../src/parser/_helpers/statements/dml.rs | 3 + .../tests/parser/test_snowflake_views.rs | 64 ++++ 6 files changed, 373 insertions(+), 3 deletions(-) create mode 100644 crates/sqlquack-syntax-snowflake/src/parser/_helpers/ddl/snowflake_views.rs diff --git a/crates/sqlquack-syntax-snowflake/src/parser/_helpers/ddl/constraints.rs b/crates/sqlquack-syntax-snowflake/src/parser/_helpers/ddl/constraints.rs index 4cd5e7689..dd0dd2150 100644 --- a/crates/sqlquack-syntax-snowflake/src/parser/_helpers/ddl/constraints.rs +++ b/crates/sqlquack-syntax-snowflake/src/parser/_helpers/ddl/constraints.rs @@ -1075,7 +1075,11 @@ impl Parser { } } - fn parse_create_view_query(&mut self, query_parenthesized: bool) -> Result { + /// The view's query after AS, in parentheses when `query_parenthesized`. + pub(crate) fn parse_create_view_query( + &mut self, + query_parenthesized: bool, + ) -> Result { Ok(if self.check(TokenType::With) { self.parse_statement()? } else if query_parenthesized { diff --git a/crates/sqlquack-syntax-snowflake/src/parser/_helpers/ddl/mod.rs b/crates/sqlquack-syntax-snowflake/src/parser/_helpers/ddl/mod.rs index b29e8b0e5..f1cdcd294 100644 --- a/crates/sqlquack-syntax-snowflake/src/parser/_helpers/ddl/mod.rs +++ b/crates/sqlquack-syntax-snowflake/src/parser/_helpers/ddl/mod.rs @@ -34,5 +34,6 @@ pub(crate) mod snowflake_semantic_view; pub(crate) mod snowflake_show; pub(crate) mod snowflake_statements; pub(crate) mod snowflake_streams; +pub(crate) mod snowflake_views; pub(crate) mod table_options; pub(crate) mod transactions; diff --git a/crates/sqlquack-syntax-snowflake/src/parser/_helpers/ddl/snowflake_semantic_view.rs b/crates/sqlquack-syntax-snowflake/src/parser/_helpers/ddl/snowflake_semantic_view.rs index a77a5cccf..36bc639e6 100644 --- a/crates/sqlquack-syntax-snowflake/src/parser/_helpers/ddl/snowflake_semantic_view.rs +++ b/crates/sqlquack-syntax-snowflake/src/parser/_helpers/ddl/snowflake_semantic_view.rs @@ -15,7 +15,7 @@ use sqlquack_syntax::tokens::types::Result; type SemanticItem = fn(&mut Parser) -> Result; /// ` = `: one named part of a semantic view entry. -fn part(key: &str, value: Expression) -> Expression { +pub(super) fn part(key: &str, value: Expression) -> Expression { Expression::PropertyEQ(Box::new(BinaryOp::new( Expression::Identifier(Identifier::new(key)), value, @@ -23,7 +23,7 @@ fn part(key: &str, value: Expression) -> Expression { } /// The parts of one entry, in the order they are written. -fn tuple(expressions: Vec) -> Expression { +pub(super) fn tuple(expressions: Vec) -> Expression { Expression::Tuple(Box::new(Tuple { expressions })) } diff --git a/crates/sqlquack-syntax-snowflake/src/parser/_helpers/ddl/snowflake_views.rs b/crates/sqlquack-syntax-snowflake/src/parser/_helpers/ddl/snowflake_views.rs new file mode 100644 index 000000000..d21e8fbe5 --- /dev/null +++ b/crates/sqlquack-syntax-snowflake/src/parser/_helpers/ddl/snowflake_views.rs @@ -0,0 +1,298 @@ +//! Parser ddl: Snowflake CREATE VIEW and CREATE MATERIALIZED VIEW, checked against their grammar. + +use crate::parser::_helpers::ddl::snowflake_semantic_view::{part, tuple}; +use crate::parser::models::Parser; +use sqlquack_syntax::expressions::names::models::Identifier; +use sqlquack_syntax::expressions::operators::models::BinaryOp; +use sqlquack_syntax::expressions::schema_objects::models::{CreateView, ViewColumn}; +use sqlquack_syntax::expressions::tree::models::Expression; +use sqlquack_syntax::tokens::models::TokenType; +use sqlquack_syntax::tokens::types::Result; + +/// The words that make a view temporary, longest first. +const VIEW_SCOPES: [&[&str]; 9] = [ + &["LOCAL", "TEMPORARY"], + &["LOCAL", "TEMP"], + &["LOCAL", "VOLATILE"], + &["GLOBAL", "TEMPORARY"], + &["GLOBAL", "TEMP"], + &["GLOBAL", "VOLATILE"], + &["TEMPORARY"], + &["TEMP"], + &["VOLATILE"], +]; + +/// What a COPY = ( ... ) list may name. +const VIEW_COPY_KINDS: [&[&str]; 3] = [&["COMMENTS"], &["TAGS"], &["GRANTS"]]; + +/// The view kinds whose clauses differ: a view, a materialized view, and CREATE OR ALTER or DEFINE of a view. +#[derive(Clone, Copy, PartialEq, Eq)] +enum ViewKind { + View, + Materialized, + OrAlter, +} + +impl Parser { + /// A Snowflake CREATE [ OR REPLACE | OR ALTER ] [ SECURE ] [ ] [ RECURSIVE ] VIEW, or [ SECURE ] [ INTERACTIVE ] MATERIALIZED VIEW; None for any other CREATE and outside the faithful Snowflake modes. + pub(crate) fn parse_snowflake_create_view( + &mut self, + or_replace: bool, + or_alter: bool, + ) -> Result> { + if !self.applies_snowflake_context_rules() { + return Ok(None); + } + let start = self.current; + let secure = self.match_text_seq(&["SECURE"]); + let scope = VIEW_SCOPES + .iter() + .find(|words| self.match_text_seq(words)) + .map(|words| words.join(" ")); + let interactive = self.match_text_seq(&["INTERACTIVE"]); + let recursive = self.match_text_seq(&["RECURSIVE"]); + let materialized = self.match_token(TokenType::Materialized); + if !self.check(TokenType::View) { + self.current = start; + return Ok(None); + } + if materialized && (scope.is_some() || recursive || or_alter) { + return Err(self.parse_error("Expected VIEW; a materialized view is not temporary, recursive or altered in place")); + } + if interactive && !materialized { + return Err(self.parse_error("Expected MATERIALIZED VIEW after INTERACTIVE")); + } + self.skip(); + let kind = match (materialized, or_alter) { + (true, _) => ViewKind::Materialized, + (false, true) => ViewKind::OrAlter, + (false, false) => ViewKind::View, + }; + let if_not_exists = kind != ViewKind::OrAlter && self.check(TokenType::If); + if if_not_exists + && !self.match_keywords(&[TokenType::If, TokenType::Not, TokenType::Exists]) + { + return Err(self.parse_error("Expected IF NOT EXISTS")); + } + let name = self.parse_iceberg_object_name()?; + let temporary = scope.is_some(); + let mut properties: Vec = [scope, recursive.then(|| "RECURSIVE".to_owned())] + .into_iter() + .chain([interactive.then(|| "INTERACTIVE".to_owned())]) + .flatten() + .map(|word| Expression::Identifier(Identifier::new(word))) + .collect(); + let mut copy_grants = false; + if kind == ViewKind::Materialized { + copy_grants = self.match_text_seq(&["COPY", "GRANTS"]); + if self.check(TokenType::Copy) && self.check_next(TokenType::Eq) { + self.skip(); + self.skip(); + properties.push(part("COPY", self.parse_view_copy_list()?)); + } + } + let columns = match self.check(TokenType::LParen) { + true => self.parse_snowflake_view_columns()?, + false => Vec::new(), + }; + while !self.is_at_end() && !self.check(TokenType::As) && !self.check(TokenType::Semicolon) { + match self.parse_snowflake_view_clause(kind)? { + Some(clause) => properties.push(clause), + None => copy_grants = true, + } + } + if !self.match_token(TokenType::As) { + return Err(self.parse_error("Expected AS and the view's query")); + } + let query_parenthesized = self.check(TokenType::LParen); + let query = self.parse_create_view_query(query_parenthesized)?; + let mut view = CreateView::new(String::new(), query); + view.name = name; + view.columns = columns; + view.or_replace = or_replace; + view.or_alter = or_alter; + view.if_not_exists = if_not_exists; + view.materialized = materialized; + view.temporary = temporary; + view.secure = secure; + view.query_parenthesized = query_parenthesized; + view.copy_grants = copy_grants; + view.table_properties = properties; + Ok(Some(Expression::CreateView(Box::new(view)))) + } + + /// `( [