Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions src/ast/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?;
Expand Down Expand Up @@ -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"),
}
}
}
Expand Down
33 changes: 20 additions & 13 deletions src/dialect/snowflake.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()?;
Expand Down
154 changes: 154 additions & 0 deletions tests/sqlparser_snowflake.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(()));
}
Loading