From ab3212cd694b315d3546fb95f76a004facce3de4 Mon Sep 17 00:00:00 2001 From: Denys Tsomenko Date: Sat, 7 Nov 2026 07:39:12 +0200 Subject: [PATCH 1/2] Snowflake: Test typed NULL table comment syntax --- tests/sqlparser_snowflake.rs | 154 +++++++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index 0c933ca868..c653c903fa 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -6585,3 +6585,157 @@ fn snowflake_warehouse_names_are_not_table_relations() { assert_eq!(result, ControlFlow::Continue(())); assert_eq!(numeric_literals, 1); } + +#[test] +fn snowflake_table_comment_null_is_typed_and_roundtrips() { + // Snowflake 10.40.102 TEMP CREATE reference: 01c83e8c-0209-ab5f-0004-d66e05c171ae. + let Statement::CreateTable(table) = + snowflake().verified_stmt("CREATE TABLE t (id INT) COMMENT = NULL") + else { + panic!("expected CREATE TABLE"); + }; + assert!( + table.comment.is_none(), + "Snowflake comments belong to plain options" + ); + let CreateTableOptions::Plain(options) = table.table_options else { + panic!("expected plain table options"); + }; + assert_eq!(options.len(), 1); + assert!(matches!(&options[0], SqlOption::Comment(_))); + assert_eq!(options[0].to_string(), "COMMENT = NULL"); + assert_ne!( + options[0], + SqlOption::Comment(CommentDef::WithEq("NULL".into())) + ); + assert_ne!( + options[0], + SqlOption::Comment(CommentDef::WithEq(String::new())) + ); +} + +#[test] +fn snowflake_table_comment_absent_empty_and_null_string_stay_distinct() { + for (sql, expected) in [ + ("CREATE TABLE t (id INT)", CreateTableOptions::None), + ( + "CREATE TABLE t (id INT) COMMENT = ''", + CreateTableOptions::Plain(vec![SqlOption::Comment(CommentDef::WithEq(String::new()))]), + ), + ( + "CREATE TABLE t (id INT) COMMENT = 'NULL'", + CreateTableOptions::Plain(vec![SqlOption::Comment(CommentDef::WithEq("NULL".into()))]), + ), + ( + "CREATE TABLE t (id INT) COMMENT 'text'", + CreateTableOptions::Plain(vec![SqlOption::Comment(CommentDef::WithoutEq( + "text".into(), + ))]), + ), + ] { + let Statement::CreateTable(table) = snowflake().verified_stmt(sql) else { + panic!("expected CREATE TABLE"); + }; + assert_eq!(table.table_options, expected, "{sql}"); + } +} + +#[test] +fn snowflake_table_comment_dollar_quoted_null_stays_a_string() { + let Statement::CreateTable(table) = snowflake().one_statement_parses_to( + "CREATE TABLE t (id INT) COMMENT = $$NULL$$", + "CREATE TABLE t (id INT) COMMENT = 'NULL'", + ) else { + panic!("expected CREATE TABLE"); + }; + assert_eq!( + table.table_options, + CreateTableOptions::Plain(vec![SqlOption::Comment(CommentDef::WithEq("NULL".into())),]) + ); +} + +#[test] +fn snowflake_table_comment_null_keeps_batch_delimiters() { + use sqlparser::parser::Parser; + + let statements = Parser::parse_sql( + &SnowflakeDialect, + "CREATE TABLE t (id INT) COMMENT = NULL; SELECT 'NULL' AS text_value;", + ) + .expect("table comment followed by a separate statement"); + assert_eq!(statements.len(), 2); + assert_eq!( + statements[0].to_string(), + "CREATE TABLE t (id INT) COMMENT = NULL" + ); + assert_eq!(statements[1].to_string(), "SELECT 'NULL' AS text_value"); +} + +#[test] +fn snowflake_table_comment_null_requires_the_selected_assignment_shape() { + use sqlparser::parser::Parser; + + for sql in [ + "CREATE TABLE t (id INT COMMENT NULL)", + "CREATE TABLE t (id INT COMMENT = NULL)", + "CREATE TABLE t (id INT) COMMENT NULL", + "CREATE TABLE t (id INT) COMMENT = (NULL)", + "CREATE TABLE t (id INT) COMMENT = NULL || ''", + ] { + assert!(Parser::parse_sql(&SnowflakeDialect, sql).is_err(), "{sql}"); + } +} + +#[test] +fn snowflake_table_comment_null_does_not_broaden_other_comment_grammars() { + use sqlparser::parser::Parser; + + for sql in [ + "CREATE STAGE s COMMENT = NULL", + "ALTER STAGE s SET COMMENT = NULL", + "CREATE FILE FORMAT f TYPE = CSV COMMENT = NULL", + "ALTER FILE FORMAT f SET COMMENT = NULL", + "CREATE VIEW v COMMENT = NULL AS SELECT 1", + ] { + assert!(Parser::parse_sql(&SnowflakeDialect, sql).is_err(), "{sql}"); + } + assert!(Parser::parse_sql(&GenericDialect, "CREATE TABLE t (id INT) COMMENT = NULL").is_err()); + let mut parser = Parser::new(&SnowflakeDialect) + .try_with_sql("NULL") + .expect("NULL token"); + assert!( + parser.parse_comment_value().is_err(), + "the shared string parser must stay strict" + ); +} + +#[cfg(all(feature = "serde", feature = "serde_json"))] +#[test] +fn snowflake_table_comment_null_has_a_distinct_serde_variant() { + let Statement::CreateTable(table) = + snowflake().verified_stmt("CREATE TABLE t (id INT) COMMENT = NULL") + else { + panic!("expected CREATE TABLE"); + }; + let CreateTableOptions::Plain(options) = table.table_options else { + panic!("expected plain table options"); + }; + assert_eq!( + serde_json::to_string(&options[0]).expect("serialize typed comment"), + r#"{"Comment":"NullWithEq"}"# + ); + let decoded: SqlOption = + serde_json::from_str(r#"{"Comment":"NullWithEq"}"#).expect("deserialize typed comment"); + assert_eq!(decoded, options[0]); +} + +#[cfg(feature = "visitor")] +#[test] +fn snowflake_table_comment_null_is_metadata_without_expression_nodes() { + use core::ops::ControlFlow; + use sqlparser::ast::visit_expressions; + + let statement = snowflake().verified_stmt("CREATE TABLE t (id INT) COMMENT = NULL"); + let result = visit_expressions(&statement, |_| ControlFlow::Break("unexpected expression")); + assert_eq!(result, ControlFlow::Continue(())); +} From 3b740083360c8f757f2d8fb8029ebeef727194f8 Mon Sep 17 00:00:00 2001 From: Denys Tsomenko Date: Sat, 7 Nov 2026 07:39:12 +0200 Subject: [PATCH 2/2] Snowflake: Preserve typed NULL table comments --- AGENTS.md | 8 ++++++++ README.md | 12 ++++++++++++ src/ast/mod.rs | 4 ++++ src/dialect/snowflake.rs | 33 ++++++++++++++++++++------------- 4 files changed, 44 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7e830cdac3..6f3d044bb6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,14 @@ execution consumer to validate. Never evaluate properties, normalize account ide consume statement delimiters inside option loops, or infer runtime support from an AST. Run the required fork checks below before committing changes to this syntax. +Snowflake CREATE TABLE's dedicated COMMENT option admits explicit `= NULL` as +`SqlOption::Comment(CommentDef::NullWithEq)` in plain table options. Keep existing +string variants and the shared `parse_comment_value` strict: column, stage, file +format, view and connector comments do not gain NULL syntax. Do not collapse NULL +into an empty string or absent clause, infer no-`=` NULL syntax, or rewrite SQL. +The additive public enum variant requires an exhaustive-match/serde consumer audit +and a coherent parser/DataFusion/engine pin qualification before runtime claims. + For Snowflake `PUT`/`GET`, keep opaque location lexing confined to the two operands at actual statement start. Preserve ordinary comments and identifiers/functions named `PUT`/`GET`. Keep stage `ObjectName` quoting separate from the exact payload diff --git a/README.md b/README.md index a968f2db58..235c63d3d9 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,18 @@ operation, evaluate properties, or establish wire metadata/error parity. Tags, CREATE OR ALTER, adaptive/interactive control, ABORT ALL QUERIES and SHOW privilege filters remain outside this selected grammar. +The Snowflake table-comment source preparation adds explicit `COMMENT = NULL` as +`SqlOption::Comment(CommentDef::NullWithEq)` within `CreateTableOptions::Plain`. +An absent clause, `COMMENT = ''`, `COMMENT = 'NULL'` and typed NULL remain distinct. +Existing string and dollar-quoted comments keep their string AST variants. Only +the dedicated CREATE TABLE arm admits NULL; shared column/stage/file-format/view +and connector string parsers remain strict, as does table `COMMENT NULL` without +`=`. The selected Snowflake 10.40.102 reference is a temporary CREATE table +(query `01c83e8c-0209-ab5f-0004-d66e05c171ae`), without a persistence/runtime claim. +This preparation is unqualified until the fork checks run. The additive public +enum variant can break external exhaustive matches or old serde readers; existing +string constructors/representations stay intact, without a binary ABI guarantee. + ## Example To parse a simple `SELECT` statement: diff --git a/src/ast/mod.rs b/src/ast/mod.rs index b4ef845549..7261fc3338 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -9290,6 +9290,7 @@ impl fmt::Display for SqlOption { CommentDef::WithoutEq(comment) => { write!(f, "COMMENT '{comment}'") } + CommentDef::NullWithEq => write!(f, "COMMENT = NULL"), }, SqlOption::NamedParenthesizedList(value) => { write!(f, "{} = ", value.key)?; @@ -11085,12 +11086,15 @@ pub enum CommentDef { WithEq(String), /// Comment variant that omits the `=` when displayed. WithoutEq(String), + /// Explicit `COMMENT = NULL`, admitted only by Snowflake CREATE TABLE syntax. + NullWithEq, } impl Display for CommentDef { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { CommentDef::WithEq(comment) | CommentDef::WithoutEq(comment) => write!(f, "{comment}"), + CommentDef::NullWithEq => write!(f, "NULL"), } } } diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index 2c35e4571c..39c3196456 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -35,14 +35,14 @@ use crate::ast::helpers::stmt_warehouse::{ }; use crate::ast::{ AlterTable, AlterTableOperation, AlterTableType, CatalogSyncNamespaceMode, CloudProviderParams, - ColumnOption, ColumnPolicy, ColumnPolicyProperty, ContactEntry, CopyIntoSnowflakeKind, - CreateTable, CreateTableLikeKind, DescribeAlias, DollarQuotedString, Expr, Ident, - IdentityParameters, IdentityProperty, IdentityPropertyFormatKind, IdentityPropertyKind, - IdentityPropertyOrder, InitializeKind, Insert, MultiTableInsertIntoClause, - MultiTableInsertType, MultiTableInsertValue, MultiTableInsertValues, - MultiTableInsertWhenClause, ObjectName, ObjectNamePart, RefreshModeKind, RowAccessPolicy, - ShowObjects, SqlOption, Statement, StorageLifecyclePolicy, StorageSerializationPolicy, - TableObject, TagsColumnOption, Value, WrappedCollection, + ColumnOption, ColumnPolicy, ColumnPolicyProperty, CommentDef, ContactEntry, + CopyIntoSnowflakeKind, CreateTable, CreateTableLikeKind, DescribeAlias, DollarQuotedString, + Expr, Ident, IdentityParameters, IdentityProperty, IdentityPropertyFormatKind, + IdentityPropertyKind, IdentityPropertyOrder, InitializeKind, Insert, + MultiTableInsertIntoClause, MultiTableInsertType, MultiTableInsertValue, + MultiTableInsertValues, MultiTableInsertWhenClause, ObjectName, ObjectNamePart, + RefreshModeKind, RowAccessPolicy, ShowObjects, SqlOption, Statement, StorageLifecyclePolicy, + StorageSerializationPolicy, TableObject, TagsColumnOption, Value, WrappedCollection, }; use crate::dialect::{Dialect, Precedence}; use crate::keywords::Keyword; @@ -1141,11 +1141,18 @@ pub fn parse_create_table( builder = builder.copy_grants(true); } Keyword::COMMENT => { - // Rewind the COMMENT keyword - parser.prev_token(); - if let Some(comment_def) = parser.parse_optional_inline_comment()? { - plain_options.push(SqlOption::Comment(comment_def)) - } + let has_eq = parser.consume_token(&Token::Eq); + let comment_def = if has_eq && parser.parse_keyword(Keyword::NULL) { + CommentDef::NullWithEq + } else { + let comment = parser.parse_comment_value()?; + if has_eq { + CommentDef::WithEq(comment) + } else { + CommentDef::WithoutEq(comment) + } + }; + plain_options.push(SqlOption::Comment(comment_def)); } Keyword::AS => { let query = parser.parse_query()?;