diff --git a/src/ast/dcl.rs b/src/ast/dcl.rs index 3c50a81c06..fcc15c7da3 100644 --- a/src/ast/dcl.rs +++ b/src/ast/dcl.rs @@ -311,6 +311,8 @@ impl fmt::Display for SecondaryRoles { pub struct CreateRole { /// Role names to create. pub names: Vec, + /// Whether `OR REPLACE` was specified. + pub or_replace: bool, /// Whether `IF NOT EXISTS` was specified. pub if_not_exists: bool, // Postgres @@ -353,7 +355,8 @@ impl fmt::Display for CreateRole { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!( f, - "CREATE ROLE {if_not_exists}{names}{superuser}{create_db}{create_role}{inherit}{login}{replication}{bypassrls}", + "CREATE {or_replace}ROLE {if_not_exists}{names}{superuser}{create_db}{create_role}{inherit}{login}{replication}{bypassrls}", + or_replace = if self.or_replace { "OR REPLACE " } else { "" }, if_not_exists = if self.if_not_exists { "IF NOT EXISTS " } else { "" }, names = display_separated(&self.names, ", "), superuser = match self.superuser { diff --git a/src/ast/ddl.rs b/src/ast/ddl.rs index a8821fc5b2..862ba89710 100644 --- a/src/ast/ddl.rs +++ b/src/ast/ddl.rs @@ -34,6 +34,7 @@ use serde::{Deserialize, Serialize}; #[cfg(feature = "visitor")] use sqlparser_derive::{Visit, VisitMut}; +use crate::ast::helpers::key_value_options::KeyValueOptions; use crate::ast::value::escape_single_quote_string; use crate::ast::{ display_comma_separated, display_separated, @@ -143,6 +144,18 @@ pub enum AlterTableOperation { /// MySQL `ALTER TABLE` only [FIRST | AFTER column_name] column_position: Option, }, + /// `ADD [COLUMN] [IF NOT EXISTS] [, , ...]` + /// + /// Snowflake multi-column add: a single `ADD [COLUMN]` keyword followed by + /// a comma-separated list of column definitions. + AddColumns { + /// `[COLUMN]`. + column_keyword: bool, + /// `[IF NOT EXISTS]` + if_not_exists: bool, + /// The column definitions to add. + column_defs: Vec, + }, /// `ADD PROJECTION [IF NOT EXISTS] name ( SELECT [GROUP BY] [ORDER BY])` /// /// Note: this is a ClickHouse-specific operation. @@ -459,6 +472,13 @@ pub enum AlterTableOperation { }, /// Remove the clustering key from the table. DropClusteringKey, + /// `DROP ROW ACCESS POLICY ` (Snowflake). + DropRowAccessPolicy { + /// The row access policy name being detached. + policy_name: ObjectName, + }, + /// `DROP ALL ROW ACCESS POLICIES` (Snowflake). + DropAllRowAccessPolicies, /// Redshift `ALTER SORTKEY (column_list)` /// AlterSortKey { @@ -746,6 +766,21 @@ impl fmt::Display for AlterTableOperation { Ok(()) } + AlterTableOperation::AddColumns { + column_keyword, + if_not_exists, + column_defs, + } => { + write!(f, "ADD")?; + if *column_keyword { + write!(f, " COLUMN")?; + } + if *if_not_exists { + write!(f, " IF NOT EXISTS")?; + } + write!(f, " {}", display_comma_separated(column_defs))?; + Ok(()) + } AlterTableOperation::AddProjection { if_not_exists, name, @@ -1008,6 +1043,12 @@ impl fmt::Display for AlterTableOperation { write!(f, "DROP CLUSTERING KEY")?; Ok(()) } + AlterTableOperation::DropRowAccessPolicy { policy_name } => { + write!(f, "DROP ROW ACCESS POLICY {policy_name}") + } + AlterTableOperation::DropAllRowAccessPolicies => { + write!(f, "DROP ALL ROW ACCESS POLICIES") + } AlterTableOperation::AlterSortKey { columns } => { write!(f, "ALTER SORTKEY({})", display_comma_separated(columns))?; Ok(()) @@ -1297,6 +1338,15 @@ pub enum AlterColumnOperation { had_set: bool, }, + /// `COMMENT ''` + /// + /// Snowflake: set the column comment + /// (`ALTER TABLE t ALTER COLUMN c COMMENT ''`). + Comment { + /// The comment text. + comment: String, + }, + /// `ADD GENERATED { ALWAYS | BY DEFAULT } AS IDENTITY [ ( sequence_options ) ]` /// /// Note: this is a PostgreSQL-specific operation. @@ -1306,6 +1356,116 @@ pub enum AlterColumnOperation { /// Optional sequence options for identity generation. sequence_options: Option>, }, + + /// `SET MASKING POLICY [USING (, ...)] [FORCE]` + /// + /// Snowflake: attach a masking policy to the column + /// (`ALTER TABLE t MODIFY COLUMN c SET MASKING POLICY p`). + SetMaskingPolicy { + /// The policy to attach. + policy_name: ObjectName, + /// Optional `USING (, ...)` conditional-masking column list. + using_columns: Option>, + /// Whether the `FORCE` keyword was present. + force: bool, + }, + + /// `UNSET MASKING POLICY` + /// + /// Snowflake: detach the masking policy from the column. + UnsetMaskingPolicy, +} + +/// An operation on a masking policy in an `ALTER MASKING POLICY` statement. +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub enum AlterMaskingPolicyOperation { + /// `SET BODY -> ` + SetBody { + /// The replacement body expression. + body: Expr, + }, + /// `RENAME TO ` + RenameTo { + /// The new policy name. + new_name: ObjectName, + }, + /// `SET COMMENT = ''` + SetComment { + /// The replacement comment text. + comment: String, + }, + /// `UNSET COMMENT` + UnsetComment, +} + +impl fmt::Display for AlterMaskingPolicyOperation { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + AlterMaskingPolicyOperation::SetBody { body } => write!(f, "SET BODY -> {body}"), + AlterMaskingPolicyOperation::RenameTo { new_name } => write!(f, "RENAME TO {new_name}"), + AlterMaskingPolicyOperation::SetComment { comment } => { + write!(f, "SET COMMENT = '{}'", escape_single_quote_string(comment)) + } + AlterMaskingPolicyOperation::UnsetComment => write!(f, "UNSET COMMENT"), + } + } +} + +/// An operation in an `ALTER TAG` statement. +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub enum AlterTagOperation { + /// `RENAME TO ` + RenameTo { + /// The new tag name. + new_name: ObjectName, + }, + /// `SET MASKING POLICY

[, MASKING POLICY

...] [FORCE]` + SetMaskingPolicy { + /// The masking policies to attach. + policies: Vec, + /// `FORCE` flag. + force: bool, + }, + /// `UNSET MASKING POLICY

[, MASKING POLICY

...]` + UnsetMaskingPolicy { + /// The masking policies to detach. + policies: Vec, + }, +} + +impl fmt::Display for AlterTagOperation { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + AlterTagOperation::RenameTo { new_name } => write!(f, "RENAME TO {new_name}"), + AlterTagOperation::SetMaskingPolicy { policies, force } => { + write!(f, "SET ")?; + for (i, p) in policies.iter().enumerate() { + if i > 0 { + write!(f, ", ")?; + } + write!(f, "MASKING POLICY {p}")?; + } + if *force { + write!(f, " FORCE")?; + } + Ok(()) + } + AlterTagOperation::UnsetMaskingPolicy { policies } => { + write!(f, "UNSET ")?; + for (i, p) in policies.iter().enumerate() { + if i > 0 { + write!(f, ", ")?; + } + write!(f, "MASKING POLICY {p}")?; + } + Ok(()) + } + } + } } impl fmt::Display for AlterColumnOperation { @@ -1333,6 +1493,9 @@ impl fmt::Display for AlterColumnOperation { } Ok(()) } + AlterColumnOperation::Comment { comment } => { + write!(f, "COMMENT '{}'", escape_single_quote_string(comment)) + } AlterColumnOperation::AddGenerated { generated_as, sequence_options, @@ -1355,6 +1518,21 @@ impl fmt::Display for AlterColumnOperation { } Ok(()) } + AlterColumnOperation::SetMaskingPolicy { + policy_name, + using_columns, + force, + } => { + write!(f, "SET MASKING POLICY {policy_name}")?; + if let Some(columns) = using_columns { + write!(f, " USING ({})", display_comma_separated(columns))?; + } + if *force { + write!(f, " FORCE")?; + } + Ok(()) + } + AlterColumnOperation::UnsetMaskingPolicy => write!(f, "UNSET MASKING POLICY"), } } } @@ -1572,6 +1750,42 @@ pub struct ColumnDef { pub options: Vec, } +impl ColumnDef { + /// Drop any [`ConstraintCharacteristics`] the column's inline constraints + /// carry, so they render as bare constraints. Counterpart to + /// [`TableConstraint::clear_characteristics`] for dialects whose grammar has + /// no equivalent of the characteristics the source dialect accepts. + pub fn clear_constraint_characteristics(&mut self) { + for option in &mut self.options { + match &mut option.option { + ColumnOption::PrimaryKey(constraint) => constraint.characteristics = None, + ColumnOption::Unique(constraint) => constraint.characteristics = None, + ColumnOption::ForeignKey(constraint) => constraint.characteristics = None, + ColumnOption::Null + | ColumnOption::NotNull + | ColumnOption::Default(_) + | ColumnOption::Materialized(_) + | ColumnOption::Ephemeral(_) + | ColumnOption::Alias(_) + | ColumnOption::Check(_) + | ColumnOption::DialectSpecific(_) + | ColumnOption::CharacterSet(_) + | ColumnOption::Collation(_) + | ColumnOption::Comment(_) + | ColumnOption::OnUpdate(_) + | ColumnOption::Generated { .. } + | ColumnOption::Options(_) + | ColumnOption::Identity(_) + | ColumnOption::OnConflict(_) + | ColumnOption::Policy(_) + | ColumnOption::Tags(_) + | ColumnOption::Srid(_) + | ColumnOption::Invisible => {} + } + } + } +} + impl fmt::Display for ColumnDef { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { if self.data_type == DataType::Unspecified { @@ -2257,9 +2471,13 @@ pub(crate) fn display_option_spaced(option: &Option) -> impl display_option(" ", "", option) } -/// ` = [ DEFERRABLE | NOT DEFERRABLE ] [ INITIALLY DEFERRED | INITIALLY IMMEDIATE ] [ ENFORCED | NOT ENFORCED ]` +/// ` = [ DEFERRABLE | NOT DEFERRABLE ] [ INITIALLY DEFERRED | INITIALLY IMMEDIATE ] [ ENFORCED | NOT ENFORCED ] [ ENABLE | DISABLE ] [ VALIDATE | NOVALIDATE ] [ RELY | NORELY ]` /// /// Used in UNIQUE and foreign key constraints. The individual settings may occur in any order. +/// +/// `ENABLE`/`DISABLE`, `VALIDATE`/`NOVALIDATE` and `RELY`/`NORELY` are only +/// parsed for dialects returning true from +/// [`Dialect::supports_informational_constraint_properties`]. #[derive(Debug, Copy, Clone, PartialEq, PartialOrd, Default, Eq, Ord, Hash)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] @@ -2270,6 +2488,12 @@ pub struct ConstraintCharacteristics { pub initially: Option, /// `[ ENFORCED | NOT ENFORCED ]` pub enforced: Option, + /// `[ ENABLE | DISABLE ]` + pub enabled: Option, + /// `[ VALIDATE | NOVALIDATE ]` + pub validated: Option, + /// `[ RELY | NORELY ]` + pub rely: Option, } /// Initial setting for deferrable constraints (`INITIALLY IMMEDIATE` or `INITIALLY DEFERRED`). @@ -2313,26 +2537,35 @@ impl ConstraintCharacteristics { }, ) } + + fn enabled_text(&self) -> Option<&'static str> { + self.enabled + .map(|enabled| if enabled { "ENABLE" } else { "DISABLE" }) + } + + fn validated_text(&self) -> Option<&'static str> { + self.validated + .map(|validated| if validated { "VALIDATE" } else { "NOVALIDATE" }) + } + + fn rely_text(&self) -> Option<&'static str> { + self.rely.map(|rely| if rely { "RELY" } else { "NORELY" }) + } } impl fmt::Display for ConstraintCharacteristics { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - let deferrable = self.deferrable_text(); - let initially_immediate = self.initially_immediate_text(); - let enforced = self.enforced_text(); - - match (deferrable, initially_immediate, enforced) { - (None, None, None) => Ok(()), - (None, None, Some(enforced)) => write!(f, "{enforced}"), - (None, Some(initial), None) => write!(f, "{initial}"), - (None, Some(initial), Some(enforced)) => write!(f, "{initial} {enforced}"), - (Some(deferrable), None, None) => write!(f, "{deferrable}"), - (Some(deferrable), None, Some(enforced)) => write!(f, "{deferrable} {enforced}"), - (Some(deferrable), Some(initial), None) => write!(f, "{deferrable} {initial}"), - (Some(deferrable), Some(initial), Some(enforced)) => { - write!(f, "{deferrable} {initial} {enforced}") - } - } + let properties = [ + self.deferrable_text(), + self.initially_immediate_text(), + self.enforced_text(), + self.enabled_text(), + self.validated_text(), + self.rely_text(), + ]; + + let set: Vec<&str> = properties.into_iter().flatten().collect(); + write!(f, "{}", display_separated(&set, " ")) } } @@ -3030,6 +3263,9 @@ pub struct CreateTable { /// Snowflake "CHANGE_TRACKING" clause /// pub change_tracking: Option, + /// Snowflake table-level "STAGE_FILE_FORMAT" clause + /// + pub stage_file_format: Option, /// Snowflake "DATA_RETENTION_TIME_IN_DAYS" clause /// pub data_retention_time_in_days: Option, @@ -3060,6 +3296,12 @@ pub struct CreateTable { /// Snowflake "CATALOG" clause for Iceberg tables /// pub catalog: Option, + /// Snowflake "CATALOG_TABLE_NAME" clause for externally-managed Iceberg tables + /// + pub catalog_table_name: Option, + /// Snowflake "AUTO_REFRESH" clause for externally-managed Iceberg tables + /// + pub auto_refresh: Option, /// Snowflake "CATALOG_SYNC" clause for Iceberg tables /// pub catalog_sync: Option, @@ -3069,9 +3311,23 @@ pub struct CreateTable { /// Snowflake "TARGET_LAG" clause for dybamic tables /// pub target_lag: Option, - /// Snowflake "WAREHOUSE" clause for dybamic tables + /// Snowflake "WAREHOUSE" clause for dybamic tables. Stored verbatim (not + /// as an `Ident`) so the user's original casing survives — SHOW / GET_DDL + /// echo the warehouse exactly as written. /// - pub warehouse: Option, + pub warehouse: Option, + /// Snowflake "INITIALIZATION_WAREHOUSE" clause for dynamic tables. Stored + /// verbatim for the same case-fidelity reason as `warehouse`. + /// + pub initialization_warehouse: Option, + /// Snowflake "SCHEDULER" clause for dynamic tables (a quoted string or a + /// bare keyword such as `DISABLE`); the value is stored verbatim. + /// + pub scheduler: Option, + /// Snowflake "IMMUTABLE WHERE ()" clause for dynamic tables. + /// Stored as the serialized predicate text so its casing survives. + /// + pub immutable_where: Option, /// Snowflake "REFRESH_MODE" clause for dybamic tables /// pub refresh_mode: Option, @@ -3149,8 +3405,10 @@ impl fmt::Display for CreateTable { && self.like.is_none() && self.clone.is_none() && self.partition_of.is_none() + && !self.iceberg { - // PostgreSQL allows `CREATE TABLE t ();`, but requires empty parens + // PostgreSQL allows `CREATE TABLE t ();`, but requires empty parens. + // Externally-managed Iceberg tables legitimately have no column list. f.write_str(" ()")?; } else if let Some(CreateTableLikeKind::Parenthesized(like_in_columns_list)) = &self.like { write!(f, " ({like_in_columns_list})")?; @@ -3294,6 +3552,18 @@ impl fmt::Display for CreateTable { write!(f, " CATALOG='{catalog}'")?; } + if let Some(catalog_table_name) = self.catalog_table_name.as_ref() { + write!(f, " CATALOG_TABLE_NAME='{catalog_table_name}'")?; + } + + if let Some(auto_refresh) = self.auto_refresh { + write!( + f, + " AUTO_REFRESH={}", + if auto_refresh { "TRUE" } else { "FALSE" } + )?; + } + if self.iceberg { if let Some(base_location) = self.base_location.as_ref() { write!(f, " BASE_LOCATION='{base_location}'")?; @@ -3331,6 +3601,10 @@ impl fmt::Display for CreateTable { )?; } + if let Some(stage_file_format) = &self.stage_file_format { + write!(f, " STAGE_FILE_FORMAT=({stage_file_format})")?; + } + if let Some(data_retention_time_in_days) = self.data_retention_time_in_days { write!( f, @@ -3373,6 +3647,18 @@ impl fmt::Display for CreateTable { write!(f, " WAREHOUSE={warehouse}")?; } + if let Some(initialization_warehouse) = &self.initialization_warehouse { + write!(f, " INITIALIZATION_WAREHOUSE={initialization_warehouse}")?; + } + + if let Some(scheduler) = &self.scheduler { + write!(f, " SCHEDULER='{scheduler}'")?; + } + + if let Some(immutable_where) = &self.immutable_where { + write!(f, " IMMUTABLE WHERE ({immutable_where})")?; + } + if let Some(refresh_mode) = &self.refresh_mode { write!(f, " REFRESH_MODE={refresh_mode}")?; } @@ -4224,6 +4510,9 @@ pub struct Truncate { pub partitions: Option>, /// TABLE - optional keyword pub table: bool, + /// Snowflake-specific: `MATERIALIZED VIEW` target instead of a table. + /// + pub materialized_view: bool, /// Snowflake/Redshift-specific option: [ IF EXISTS ] pub if_exists: bool, /// Postgres-specific option: [ RESTART IDENTITY | CONTINUE IDENTITY ] @@ -4237,7 +4526,13 @@ pub struct Truncate { impl fmt::Display for Truncate { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - let table = if self.table { "TABLE " } else { "" }; + let table = if self.materialized_view { + "MATERIALIZED VIEW " + } else if self.table { + "TABLE " + } else { + "" + }; let if_exists = if self.if_exists { "IF EXISTS " } else { "" }; write!( @@ -4692,6 +4987,9 @@ pub enum AlterTableType { /// External table type /// External, + /// Materialized view type + /// + MaterializedView, } /// ALTER TABLE statement @@ -4726,6 +5024,7 @@ impl fmt::Display for AlterTable { Some(AlterTableType::Iceberg) => write!(f, "ALTER ICEBERG TABLE ")?, Some(AlterTableType::Dynamic) => write!(f, "ALTER DYNAMIC TABLE ")?, Some(AlterTableType::External) => write!(f, "ALTER EXTERNAL TABLE ")?, + Some(AlterTableType::MaterializedView) => write!(f, "ALTER MATERIALIZED VIEW ")?, None => write!(f, "ALTER TABLE ")?, } @@ -5604,6 +5903,80 @@ impl Spanned for AlterFunction { } } +/// Snowflake `ALTER PROCEDURE [IF EXISTS] ( [ [, ...]] ) `. +/// +/// Kept distinct from [`AlterFunction`] because the procedure grammar carries +/// the `EXECUTE AS { CALLER | OWNER }` rights operation, which has no function +/// analog. +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub struct AlterProcedure { + /// `IF EXISTS` flag. + pub if_exists: bool, + /// Procedure name. + pub name: ObjectName, + /// Argument-type signature (`(NUMBER, VARCHAR)`), used to disambiguate + /// overloads. Empty when the parentheses hold no arguments. + pub args: Vec, + /// Operation applied to the procedure. + pub operation: AlterProcedureOperation, +} + +/// Operation for [`AlterProcedure`]. +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub enum AlterProcedureOperation { + /// `RENAME TO ` + RenameTo { + /// New procedure name. + new_name: ObjectName, + }, + /// `SET COMMENT = ` + SetComment { + /// The comment value expression. + comment: Expr, + }, + /// `UNSET COMMENT` + UnsetComment, + /// `EXECUTE AS { CALLER | OWNER }` + ExecuteAs(ProcedureExecuteAs), +} + +impl fmt::Display for AlterProcedure { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "ALTER PROCEDURE ")?; + if self.if_exists { + write!(f, "IF EXISTS ")?; + } + write!( + f, + "{}({}) {}", + self.name, + display_comma_separated(&self.args), + self.operation + ) + } +} + +impl fmt::Display for AlterProcedureOperation { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + AlterProcedureOperation::RenameTo { new_name } => write!(f, "RENAME TO {new_name}"), + AlterProcedureOperation::SetComment { comment } => write!(f, "SET COMMENT = {comment}"), + AlterProcedureOperation::UnsetComment => write!(f, "UNSET COMMENT"), + AlterProcedureOperation::ExecuteAs(execute_as) => write!(f, "EXECUTE AS {execute_as}"), + } + } +} + +impl Spanned for AlterProcedure { + fn span(&self) -> Span { + Span::empty() + } +} + /// CREATE POLICY statement. /// /// See [PostgreSQL](https://www.postgresql.org/docs/current/sql-createpolicy.html) diff --git a/src/ast/helpers/stmt_create_table.rs b/src/ast/helpers/stmt_create_table.rs index ab2feb6930..1cd35a3adb 100644 --- a/src/ast/helpers/stmt_create_table.rs +++ b/src/ast/helpers/stmt_create_table.rs @@ -24,6 +24,7 @@ use serde::{Deserialize, Serialize}; #[cfg(feature = "visitor")] use sqlparser_derive::{Visit, VisitMut}; +use crate::ast::helpers::key_value_options::KeyValueOptions; use crate::ast::{ ClusteredBy, ColumnDef, CommentDef, CreateTable, CreateTableLikeKind, CreateTableOptions, DistStyle, Expr, FileFormat, ForValues, HiveDistributionStyle, HiveFormat, Ident, @@ -139,6 +140,8 @@ pub struct CreateTableBuilder { pub enable_schema_evolution: Option, /// Optional change tracking flag. pub change_tracking: Option, + /// Optional table-level stage file format options. + pub stage_file_format: Option, /// Optional data retention time in days. pub data_retention_time_in_days: Option, /// Optional max data extension time in days. @@ -159,6 +162,10 @@ pub struct CreateTableBuilder { pub external_volume: Option, /// Optional catalog name. pub catalog: Option, + /// Optional externally-managed catalog table name. + pub catalog_table_name: Option, + /// Optional auto-refresh flag for externally-managed Iceberg tables. + pub auto_refresh: Option, /// Optional catalog synchronization option. pub catalog_sync: Option, /// Optional storage serialization policy. @@ -167,8 +174,14 @@ pub struct CreateTableBuilder { pub table_options: CreateTableOptions, /// Optional target lag configuration. pub target_lag: Option, - /// Optional warehouse identifier. - pub warehouse: Option, + /// Optional warehouse name, stored verbatim. + pub warehouse: Option, + /// Optional initialization warehouse name, stored verbatim. + pub initialization_warehouse: Option, + /// Optional scheduler value (quoted string or bare keyword). + pub scheduler: Option, + /// Optional `IMMUTABLE WHERE` predicate text. + pub immutable_where: Option, /// Optional refresh mode for materialized tables. pub refresh_mode: Option, /// Optional initialization kind for the table. @@ -226,6 +239,7 @@ impl CreateTableBuilder { copy_grants: false, enable_schema_evolution: None, change_tracking: None, + stage_file_format: None, data_retention_time_in_days: None, max_data_extension_time_in_days: None, default_ddl_collation: None, @@ -236,11 +250,16 @@ impl CreateTableBuilder { base_location: None, external_volume: None, catalog: None, + catalog_table_name: None, + auto_refresh: None, catalog_sync: None, storage_serialization_policy: None, table_options: CreateTableOptions::None, target_lag: None, warehouse: None, + initialization_warehouse: None, + scheduler: None, + immutable_where: None, refresh_mode: None, initialize: None, require_user: false, @@ -434,6 +453,11 @@ impl CreateTableBuilder { self.change_tracking = change_tracking; self } + /// Set the table-level stage file format options. + pub fn stage_file_format(mut self, stage_file_format: Option) -> Self { + self.stage_file_format = stage_file_format; + self + } /// Set data retention time (in days). pub fn data_retention_time_in_days(mut self, data_retention_time_in_days: Option) -> Self { self.data_retention_time_in_days = data_retention_time_in_days; @@ -516,11 +540,26 @@ impl CreateTableBuilder { self.target_lag = target_lag; self } - /// Associate the table with a warehouse identifier. - pub fn warehouse(mut self, warehouse: Option) -> Self { + /// Associate the table with a warehouse name (stored verbatim). + pub fn warehouse(mut self, warehouse: Option) -> Self { self.warehouse = warehouse; self } + /// Set the initialization warehouse name (stored verbatim). + pub fn initialization_warehouse(mut self, initialization_warehouse: Option) -> Self { + self.initialization_warehouse = initialization_warehouse; + self + } + /// Set the scheduler value (quoted string or bare keyword). + pub fn scheduler(mut self, scheduler: Option) -> Self { + self.scheduler = scheduler; + self + } + /// Set the `IMMUTABLE WHERE` predicate text. + pub fn immutable_where(mut self, immutable_where: Option) -> Self { + self.immutable_where = immutable_where; + self + } /// Set refresh mode for materialized/managed tables. pub fn refresh_mode(mut self, refresh_mode: Option) -> Self { self.refresh_mode = refresh_mode; @@ -596,6 +635,7 @@ impl CreateTableBuilder { copy_grants: self.copy_grants, enable_schema_evolution: self.enable_schema_evolution, change_tracking: self.change_tracking, + stage_file_format: self.stage_file_format, data_retention_time_in_days: self.data_retention_time_in_days, max_data_extension_time_in_days: self.max_data_extension_time_in_days, default_ddl_collation: self.default_ddl_collation, @@ -606,11 +646,16 @@ impl CreateTableBuilder { base_location: self.base_location, external_volume: self.external_volume, catalog: self.catalog, + catalog_table_name: self.catalog_table_name, + auto_refresh: self.auto_refresh, catalog_sync: self.catalog_sync, storage_serialization_policy: self.storage_serialization_policy, table_options: self.table_options, target_lag: self.target_lag, warehouse: self.warehouse, + initialization_warehouse: self.initialization_warehouse, + scheduler: self.scheduler, + immutable_where: self.immutable_where, refresh_mode: self.refresh_mode, initialize: self.initialize, require_user: self.require_user, @@ -677,6 +722,7 @@ impl From for CreateTableBuilder { copy_grants: table.copy_grants, enable_schema_evolution: table.enable_schema_evolution, change_tracking: table.change_tracking, + stage_file_format: table.stage_file_format, data_retention_time_in_days: table.data_retention_time_in_days, max_data_extension_time_in_days: table.max_data_extension_time_in_days, default_ddl_collation: table.default_ddl_collation, @@ -687,11 +733,16 @@ impl From for CreateTableBuilder { base_location: table.base_location, external_volume: table.external_volume, catalog: table.catalog, + catalog_table_name: table.catalog_table_name, + auto_refresh: table.auto_refresh, catalog_sync: table.catalog_sync, storage_serialization_policy: table.storage_serialization_policy, table_options: table.table_options, target_lag: table.target_lag, warehouse: table.warehouse, + initialization_warehouse: table.initialization_warehouse, + scheduler: table.scheduler, + immutable_where: table.immutable_where, refresh_mode: table.refresh_mode, initialize: table.initialize, require_user: table.require_user, diff --git a/src/ast/mod.rs b/src/ast/mod.rs index dbbc9103fc..5d5a9d38c3 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -64,7 +64,9 @@ pub use self::dcl::{ pub use self::ddl::{ Alignment, AlterCollation, AlterCollationOperation, AlterColumnOperation, AlterConnectorOwner, AlterFunction, AlterFunctionAction, AlterFunctionKind, AlterFunctionOperation, - AlterIndexOperation, AlterOperator, AlterOperatorClass, AlterOperatorClassOperation, + AlterMaskingPolicyOperation, AlterTagOperation, + AlterIndexOperation, AlterProcedure, AlterProcedureOperation, + AlterOperator, AlterOperatorClass, AlterOperatorClassOperation, AlterOperatorFamily, AlterOperatorFamilyOperation, AlterOperatorOperation, AlterPolicy, AlterPolicyOperation, AlterSchema, AlterSchemaOperation, AlterTable, AlterTableAlgorithm, AlterTableLock, AlterTableOperation, AlterTableType, AlterType, AlterTypeAddValue, @@ -1030,6 +1032,23 @@ pub enum Expr { /// Optional escape character. escape_char: Option, }, + /// Snowflake ` [NOT] {LIKE|ILIKE} {ANY|ALL} (, ..., ) [ESCAPE ]` + /// matching a subject against a parenthesized list of patterns. + /// + LikeAnyAll { + /// `true` when `NOT` is present. + negated: bool, + /// `true` for `ILIKE`, `false` for `LIKE`. + ilike: bool, + /// `true` for the `ALL` quantifier, `false` for `ANY`. + all: bool, + /// Subject expression to match. + expr: Box, + /// List of pattern expressions. + patterns: Vec, + /// Optional escape character applied to every pattern. + escape_char: Option, + }, /// `SIMILAR TO` regex SimilarTo { /// `true` when `NOT` is present. @@ -1852,6 +1871,28 @@ impl fmt::Display for Expr { pattern ), }, + Expr::LikeAnyAll { + negated, + ilike, + all, + expr, + patterns, + escape_char, + } => { + write!( + f, + "{} {}{} {} ({})", + expr, + if *negated { "NOT " } else { "" }, + if *ilike { "ILIKE" } else { "LIKE" }, + if *all { "ALL" } else { "ANY" }, + display_comma_separated(patterns), + )?; + if let Some(ch) = escape_char { + write!(f, " ESCAPE {ch}")?; + } + Ok(()) + } Expr::RLike { negated, expr, @@ -2502,6 +2543,29 @@ impl fmt::Display for ShowCreateObject { } } +#[derive(Debug, Copy, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +/// Key-constraint catalog selected by a `SHOW ... KEYS` statement. +pub enum ShowKeysKind { + /// `SHOW [TERSE] PRIMARY KEYS` + Primary, + /// `SHOW IMPORTED KEYS` + Imported, + /// `SHOW EXPORTED KEYS` + Exported, +} + +impl fmt::Display for ShowKeysKind { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + ShowKeysKind::Primary => f.write_str("PRIMARY"), + ShowKeysKind::Imported => f.write_str("IMPORTED"), + ShowKeysKind::Exported => f.write_str("EXPORTED"), + } + } +} + #[derive(Debug, Copy, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] @@ -3914,6 +3978,10 @@ pub enum Statement { from_obj: Option, /// Optional alias for the source object. from_obj_alias: Option, + /// Optional inline stage table-function args on the FROM stage + /// (`@stage (FILE_FORMAT => …, PATTERN => …)`), as used in a + /// `COPY INTO FROM (SELECT … FROM @stage (…))` load. + from_obj_args: Option, /// Stage-specific parameters (e.g., credentials, path). stage_params: StageParamsObject, /// Optional list of transformations applied when loading. @@ -3987,6 +4055,21 @@ pub enum Statement { /// See [PostgreSQL](https://www.postgresql.org/docs/current/sql-createrole.html) CreateRole(CreateRole), /// ```sql + /// CREATE [OR REPLACE] DATABASE ROLE [IF NOT EXISTS] [COMMENT = '...'] + /// ``` + /// Snowflake database role (scoped to a database, distinct from an + /// account-level role). + CreateDatabaseRole { + /// `true` when `OR REPLACE` was specified. + or_replace: bool, + /// `true` when `IF NOT EXISTS` was specified. + if_not_exists: bool, + /// The (optionally database-qualified) role name. + name: ObjectName, + /// Optional `COMMENT = '...'` clause. + comment: Option, + }, + /// ```sql /// CREATE SECRET /// ``` /// See [DuckDB](https://duckdb.org/docs/sql/statements/create_secret.html) @@ -4066,6 +4149,20 @@ pub enum Statement { with_options: Vec, }, /// ```sql + /// ALTER VIEW { MODIFY | ALTER } COLUMN + /// { SET MASKING POLICY

[USING (, ...)] [FORCE] | UNSET MASKING POLICY } + /// ``` + /// Snowflake column-level masking-policy operation on a view. + AlterViewColumn { + /// View name. + #[cfg_attr(feature = "visitor", visit(with = "visit_relation"))] + name: ObjectName, + /// Target column. + column_name: Ident, + /// The column operation to apply. + op: AlterColumnOperation, + }, + /// ```sql /// ALTER FUNCTION /// ALTER AGGREGATE /// ``` @@ -4208,6 +4305,18 @@ pub enum Statement { table: Option, }, /// ```sql + /// UNDROP

+ /// ``` + /// Snowflake: restore the most recently dropped object of the given name + /// within the Time Travel retention window. + /// + Undrop { + /// The type of object to restore. + object_type: ObjectType, + /// The name of the object to restore. + name: ObjectName, + }, + /// ```sql /// DROP FUNCTION /// ``` DropFunction(DropFunction), @@ -4325,6 +4434,48 @@ pub enum Statement { /// Optional target table to fetch rows into. into: Option, }, + /// Snowflake scripting `FETCH INTO [, ...]`. + /// + /// Unlike the ISO/PostgreSQL [`Statement::Fetch`], the scripting form has + /// no direction and no `FROM`/`IN`; it binds the current cursor row into + /// one or more local variables. + FetchInto { + /// Cursor name. + cursor: Ident, + /// One or more variable targets. + into: Vec, + }, + /// Snowflake `CALL () INTO [, ...]`. + /// + /// Like [`Statement::Call`] but captures the procedure result into one or + /// more local variables. + CallInto { + /// The procedure call. + function: Function, + /// One or more variable targets. + into: Vec, + }, + /// Snowflake `ALTER PROCEDURE`. + AlterProcedure(AlterProcedure), + /// Snowflake anonymous procedure: + /// `WITH AS PROCEDURE () RETURNS LANGUAGE + /// [EXECUTE AS ...] AS CALL ()`. + WithProcedure { + /// Procedure name introduced by the `WITH` clause. + name: Ident, + /// Optional procedure parameters. + params: Option>, + /// Optional return type. + returns: Option, + /// Optional language identifier. + language: Option, + /// Optional `EXECUTE AS { CALLER | OWNER }` rights clause. + execute_as: Option, + /// Procedure body statements. + body: ConditionalStatements, + /// The trailing `CALL ()` statement. + call: Box, + }, /// ```sql /// FLUSH [NO_WRITE_TO_BINLOG | LOCAL] flush_option [, flush_option] ... | tables_option /// ``` @@ -4359,8 +4510,8 @@ pub enum Statement { /// /// Note: this is a Presto-specific statement. ShowFunctions { - /// Optional filter for which functions to display. - filter: Option, + /// Options controlling the SHOW output (filter, `IN `, etc.). + show_options: ShowStatementOptions, }, /// ```sql /// SHOW @@ -4821,6 +4972,22 @@ pub enum Statement { name: ObjectName, }, /// ```sql + /// CREATE [OR REPLACE] STREAM [IF NOT EXISTS] ON { TABLE | VIEW } + /// ``` + CreateStream { + /// `OR REPLACE` flag. + or_replace: bool, + /// `IF NOT EXISTS` flag. + if_not_exists: bool, + /// Stream name. + name: ObjectName, + /// Whether the source is a table (`ON TABLE`) or a view (`ON VIEW`). + source_kind: StreamSourceKind, + /// The source object the stream tracks (the `` after + /// `ON TABLE`/`ON VIEW`). + source_table: ObjectName, + }, + /// ```sql /// ALTER WAREHOUSE [IF EXISTS] [] /// ``` /// See @@ -4960,6 +5127,29 @@ pub enum Statement { show_options: ShowStatementOptions, }, /// ```sql + /// SHOW [TERSE] STREAMS [LIKE ''] + /// [IN { ACCOUNT | DATABASE | SCHEMA }] + /// ``` + /// See + ShowStreams { + /// `true` when terse output format was requested. + terse: bool, + /// Additional options for `SHOW` statements. + show_options: ShowStatementOptions, + }, + /// ```sql + /// ALTER STREAM [IF EXISTS] { SET COMMENT = '' | UNSET COMMENT } + /// ``` + /// See + AlterStream { + /// `IF EXISTS` flag. + if_exists: bool, + /// Stream name. + name: ObjectName, + /// The alter action. + operation: AlterStreamOperation, + }, + /// ```sql /// CREATE [OR REPLACE] EXTERNAL VOLUME [IF NOT EXISTS] /// ``` /// See @@ -5080,6 +5270,224 @@ pub enum Statement { show_options: ShowStatementOptions, }, /// ```sql + /// CREATE [OR REPLACE] TAG [IF NOT EXISTS] + /// [ ALLOWED_VALUES '' [ , '' ... ] ] [ COMMENT = '' ] + /// ``` + /// See + CreateTag { + /// `OR REPLACE` flag. + or_replace: bool, + /// `IF NOT EXISTS` flag. + if_not_exists: bool, + /// Tag name. + name: ObjectName, + /// Optional `ALLOWED_VALUES` list (stored for SHOW fidelity only). + allowed_values: Vec, + /// Optional comment. + comment: Option, + }, + /// ```sql + /// ALTER TAG [IF EXISTS] RENAME TO + /// ALTER TAG [IF EXISTS] SET MASKING POLICY

[, MASKING POLICY

...] [FORCE] + /// ALTER TAG [IF EXISTS] UNSET MASKING POLICY

[, MASKING POLICY

...] + /// ``` + AlterTag { + /// `IF EXISTS` flag. + if_exists: bool, + /// Tag name. + name: ObjectName, + /// The operation to apply to the tag. + operation: AlterTagOperation, + }, + /// ```sql + /// DROP TAG [IF EXISTS] + /// ``` + DropTag { + /// Tag name. + name: ObjectName, + /// `IF EXISTS` flag. + if_exists: bool, + }, + /// ```sql + /// ALTER SET TAG = '' [, ...] + /// ALTER UNSET TAG [, ...] + /// ``` + /// Object-level tag assignment / removal. + SetTags { + /// The domain of the target object (e.g. `DATABASE`). + object_type: ObjectType, + /// The target object name. + object_name: ObjectName, + /// Whether this is `UNSET TAG` (`true`) or `SET TAG` (`false`). + unset: bool, + /// Tags to set (`SET TAG`); empty for `UNSET TAG`. + set_tags: Vec, + /// Tag names to unset (`UNSET TAG`); empty for `SET TAG`. + unset_tags: Vec, + }, + /// ```sql + /// SHOW TAGS [ LIKE '' ] [ IN ... ] + /// ``` + ShowTags { + /// Whether to show terse output. + terse: bool, + /// Options controlling the SHOW output (`LIKE` / `IN` / ...). + show_options: ShowStatementOptions, + }, + /// ```sql + /// SHOW [TERSE] SEQUENCES [ LIKE '' ] [ IN ... ] ... + /// ``` + ShowSequences { + /// Whether to show terse output. + terse: bool, + /// Options controlling the SHOW output (`LIKE` / `IN` / `LIMIT` / ...). + show_options: ShowStatementOptions, + }, + /// ```sql + /// SHOW [TERSE] PRIMARY KEYS [ IN ... ] + /// SHOW IMPORTED KEYS [ IN ... ] + /// SHOW EXPORTED KEYS [ IN ... ] + /// ``` + ShowKeys { + /// Which key-constraint catalog to show. + kind: ShowKeysKind, + /// Whether to show terse output (only meaningful for `PRIMARY KEYS`). + terse: bool, + /// Options controlling the SHOW output (`LIKE` / `IN` / `LIMIT` / ...). + show_options: ShowStatementOptions, + }, + /// ```sql + /// CREATE [OR REPLACE] ROW ACCESS POLICY [IF NOT EXISTS] + /// AS () RETURNS BOOLEAN -> + /// ``` + /// See + CreateRowAccessPolicy { + /// `OR REPLACE` flag. + or_replace: bool, + /// `IF NOT EXISTS` flag. + if_not_exists: bool, + /// Policy name. + name: ObjectName, + /// Signature arguments (name + type). + args: Vec, + /// The declared return type (`BOOLEAN`). + return_type: DataType, + /// The policy body expression after `->`. + policy_expr: Expr, + }, + /// ```sql + /// ALTER ROW ACCESS POLICY [IF EXISTS] RENAME TO + /// ``` + AlterRowAccessPolicy { + /// `IF EXISTS` flag. + if_exists: bool, + /// Policy name. + name: ObjectName, + /// New policy name. + new_name: ObjectName, + }, + /// ```sql + /// DROP ROW ACCESS POLICY [IF EXISTS] + /// ``` + DropRowAccessPolicy { + /// `IF EXISTS` flag. + if_exists: bool, + /// Policy name. + name: ObjectName, + }, + /// ```sql + /// DESC[RIBE] ROW ACCESS POLICY + /// ``` + DescribeRowAccessPolicy { + /// Policy name. + name: ObjectName, + }, + /// ```sql + /// SHOW ROW ACCESS POLICIES [ LIKE '' ] + /// ``` + ShowRowAccessPolicies { + /// Optional `LIKE` filter. + filter: Option, + }, + /// ```sql + /// CREATE [OR REPLACE] MASKING POLICY [IF NOT EXISTS] + /// AS ( [, ...]) RETURNS -> [COMMENT = ''] + /// ``` + /// See + CreateMaskingPolicy { + /// `OR REPLACE` flag. + or_replace: bool, + /// `IF NOT EXISTS` flag. + if_not_exists: bool, + /// Policy name. + name: ObjectName, + /// Signature arguments (name + type), in declaration order. + args: Vec, + /// The declared return type. + return_type: DataType, + /// The policy body expression after `->`. + policy_expr: Expr, + /// Optional `COMMENT = ''`. + comment: Option, + }, + /// ```sql + /// ALTER MASKING POLICY [IF EXISTS] + /// { SET BODY -> | RENAME TO | SET COMMENT = '' | UNSET COMMENT } + /// ``` + /// See + AlterMaskingPolicy { + /// `IF EXISTS` flag. + if_exists: bool, + /// Policy name. + name: ObjectName, + /// The operation to apply. + operation: AlterMaskingPolicyOperation, + }, + /// ```sql + /// DROP MASKING POLICY [IF EXISTS] + /// ``` + DropMaskingPolicy { + /// `IF EXISTS` flag. + if_exists: bool, + /// Policy name. + name: ObjectName, + }, + /// ```sql + /// DESC[RIBE] MASKING POLICY + /// ``` + DescribeMaskingPolicy { + /// Policy name. + name: ObjectName, + }, + /// ```sql + /// SHOW MASKING POLICIES [ LIKE '' ] [ IN ] + /// ``` + ShowMaskingPolicies { + /// Options controlling the SHOW output (filter, `IN `, etc.). + show_options: ShowStatementOptions, + }, + /// ```sql + /// SHOW PROCEDURES [ LIKE '' ] [ IN ] + /// ``` + ShowProcedures { + /// Options controlling the SHOW output (filter, `IN `, etc.). + show_options: ShowStatementOptions, + }, + /// ```sql + /// SHOW CONNECTIONS [ LIKE '' ] + /// ``` + ShowConnections { + /// Optional `LIKE` filter. + filter: Option, + }, + /// ```sql + /// SHOW SHARES [ LIKE '' ] + /// ``` + ShowShares { + /// Optional `LIKE` filter. + filter: Option, + }, + /// ```sql /// CREATE [OR REPLACE] CATALOG INTEGRATION [IF NOT EXISTS] ... /// ``` /// See @@ -5124,6 +5532,54 @@ pub enum Statement { filter: Option, }, /// ```sql + /// CREATE [OR REPLACE] STORAGE INTEGRATION [IF NOT EXISTS] ... + /// ``` + /// See + CreateStorageIntegration { + /// `OR REPLACE` flag. + or_replace: bool, + /// `IF NOT EXISTS` flag. + if_not_exists: bool, + /// Storage integration name. + name: ObjectName, + /// Configuration parameters (`TYPE`, `ENABLED`, `STORAGE_PROVIDER`, …). + params: KeyValueOptions, + }, + /// ```sql + /// ALTER STORAGE INTEGRATION [IF EXISTS] SET ... + /// ``` + AlterStorageIntegration { + /// Storage integration name. + name: ObjectName, + /// `IF EXISTS` flag. + if_exists: bool, + /// The `SET` options. + set_options: KeyValueOptions, + }, + /// ```sql + /// DROP STORAGE INTEGRATION [IF EXISTS] + /// ``` + DropStorageIntegration { + /// Storage integration name. + name: ObjectName, + /// `IF EXISTS` flag. + if_exists: bool, + }, + /// ```sql + /// DESC[RIBE] STORAGE INTEGRATION + /// ``` + DescribeStorageIntegration { + /// Storage integration name. + name: ObjectName, + }, + /// ```sql + /// SHOW STORAGE INTEGRATIONS [LIKE ''] + /// ``` + ShowStorageIntegrations { + /// Optional filter (e.g. `LIKE`). + filter: Option, + }, + /// ```sql /// ASSERT [AS ] /// ``` Assert { @@ -5239,6 +5695,8 @@ pub enum Statement { /// The object name (may be qualified: db.schema.table) #[cfg_attr(feature = "visitor", visit(with = "visit_relation"))] object_name: ObjectName, + /// Optional `TYPE = { COLUMNS | STAGE }` modifier (Snowflake `DESC TABLE`). + table_type: Option, }, /// ```sql /// DESC | DESCRIBE RESULT @@ -5339,6 +5797,8 @@ pub enum Statement { CreateSequence { /// Whether the sequence is temporary. temporary: bool, + /// `OR REPLACE` flag. + or_replace: bool, /// `IF NOT EXISTS` flag. if_not_exists: bool, /// Sequence name. @@ -5626,6 +6086,23 @@ pub enum Statement { /// branches. /// [Snowflake](https://docs.snowflake.com/en/developer-guide/snowflake-scripting/snowflake-scripting) Null, + /// A `PUT` / `GET` file-transfer statement appearing inside a Snowflake + /// Scripting block (procedure / anonymous-block body). + /// + /// ```sql + /// PUT file:///tmp/x.csv @my_stage AUTO_COMPRESS = FALSE; + /// GET @my_stage file:///tmp/dir; + /// ``` + /// + /// Snowflake recognises these inside a body only so the block parses; it + /// rejects executing them ("Unsupported statement type 'PUT_FILES'"). The + /// operands are not modelled — an unquoted `file://` path triggers the `//` + /// single-line-comment lexer rule, so the tail is not tokenizable — only + /// the `get` discriminant is kept. `get = true` for `GET`. + PutGetFiles { + /// `true` for `GET`, `false` for `PUT`. + get: bool, + }, } impl From for Statement { @@ -5789,8 +6266,13 @@ impl fmt::Display for Statement { describe_alias, object_type, object_name, + table_type, } => { - write!(f, "{describe_alias} {object_type} {object_name}") + write!(f, "{describe_alias} {object_type} {object_name}")?; + if let Some(table_type) = table_type { + write!(f, " TYPE = {table_type}")?; + } + Ok(()) } Statement::DescribeResult { describe_alias, @@ -5853,6 +6335,41 @@ impl fmt::Display for Statement { Ok(()) } + Statement::FetchInto { cursor, into } => { + write!(f, "FETCH {cursor} INTO {}", display_comma_separated(into)) + } + Statement::CallInto { function, into } => { + write!( + f, + "CALL {function} INTO {}", + display_comma_separated(into) + ) + } + Statement::AlterProcedure(alter_procedure) => write!(f, "{alter_procedure}"), + Statement::WithProcedure { + name, + params, + returns, + language, + execute_as, + body, + call, + } => { + write!(f, "WITH {name} AS PROCEDURE")?; + if let Some(p) = params { + write!(f, " ({})", display_comma_separated(p))?; + } + if let Some(ret) = returns { + write!(f, " RETURNS {ret}")?; + } + if let Some(language) = language { + write!(f, " LANGUAGE {language}")?; + } + if let Some(execute_as) = execute_as { + write!(f, " EXECUTE AS {execute_as}")?; + } + write!(f, " AS {body} {call}") + } Statement::Directory { overwrite, local, @@ -6237,6 +6754,27 @@ impl fmt::Display for Statement { write!(f, "{drop_operator_class}") } Statement::CreateRole(create_role) => write!(f, "{create_role}"), + Statement::CreateDatabaseRole { + or_replace, + if_not_exists, + name, + comment, + } => { + write!( + f, + "CREATE {or_replace}DATABASE ROLE {if_not_exists}{name}", + or_replace = if *or_replace { "OR REPLACE " } else { "" }, + if_not_exists = if *if_not_exists { "IF NOT EXISTS " } else { "" }, + )?; + if let Some(comment) = comment { + write!( + f, + " COMMENT = '{}'", + value::escape_single_quote_string(comment) + )?; + } + Ok(()) + } Statement::CreateSecret { or_replace, temporary, @@ -6301,6 +6839,13 @@ impl fmt::Display for Statement { } write!(f, " AS {query}") } + Statement::AlterViewColumn { + name, + column_name, + op, + } => { + write!(f, "ALTER VIEW {name} ALTER COLUMN {column_name} {op}") + } Statement::AlterFunction(alter_function) => write!(f, "{alter_function}"), Statement::AlterType(AlterType { name, operation }) => { write!(f, "ALTER TYPE {name} {operation}") @@ -6388,6 +6933,9 @@ impl fmt::Display for Statement { }; Ok(()) } + Statement::Undrop { object_type, name } => { + write!(f, "UNDROP {object_type} {name}") + } Statement::DropFunction(drop_function) => write!(f, "{drop_function}"), Statement::DropDomain(DropDomain { if_exists, @@ -6570,12 +7118,14 @@ impl fmt::Display for Statement { } Statement::ShowObjects(ShowObjects { terse, + dynamic, show_options, }) => { write!( f, - "SHOW {terse}OBJECTS{show_options}", + "SHOW {terse}{kind}{show_options}", terse = if *terse { "TERSE " } else { "" }, + kind = if *dynamic { "DYNAMIC TABLES" } else { "OBJECTS" }, )?; Ok(()) } @@ -6611,11 +7161,8 @@ impl fmt::Display for Statement { )?; Ok(()) } - Statement::ShowFunctions { filter } => { - write!(f, "SHOW FUNCTIONS")?; - if let Some(filter) = filter { - write!(f, " {filter}")?; - } + Statement::ShowFunctions { show_options } => { + write!(f, "SHOW FUNCTIONS{show_options}")?; Ok(()) } Statement::Use(use_expr) => use_expr.fmt(f), @@ -6876,6 +7423,7 @@ impl fmt::Display for Statement { } Statement::CreateSequence { temporary, + or_replace, if_not_exists, name, data_type, @@ -6891,7 +7439,8 @@ impl fmt::Display for Statement { }; write!( f, - "CREATE {temporary}SEQUENCE {if_not_exists}{name}{as_type}", + "CREATE {or_replace}{temporary}SEQUENCE {if_not_exists}{name}{as_type}", + or_replace = if *or_replace { "OR REPLACE " } else { "" }, if_not_exists = if *if_not_exists { "IF NOT EXISTS " } else { "" }, temporary = if *temporary { "TEMPORARY " } else { "" }, name = name, @@ -6961,6 +7510,20 @@ impl fmt::Display for Statement { if_not_exists = if *if_not_exists { "IF NOT EXISTS " } else { "" }, ) } + Statement::CreateStream { + or_replace, + if_not_exists, + name, + source_kind, + source_table, + } => { + write!( + f, + "CREATE {or_replace}STREAM {if_not_exists}{name} ON {source_kind} {source_table}", + or_replace = if *or_replace { "OR REPLACE " } else { "" }, + if_not_exists = if *if_not_exists { "IF NOT EXISTS " } else { "" }, + ) + } Statement::AlterWarehouse { name, if_exists, @@ -7075,149 +7638,372 @@ impl fmt::Display for Statement { } write!(f, " {name} {action}") } - Statement::ExecuteTask { name } => { - write!(f, "EXECUTE TASK {name}") + Statement::ExecuteTask { name } => { + write!(f, "EXECUTE TASK {name}") + } + Statement::ShowTasks { + terse, + show_options, + } => { + write!( + f, + "SHOW {terse}TASKS{show_options}", + terse = if *terse { "TERSE " } else { "" }, + )?; + Ok(()) + } + Statement::ShowStreams { + terse, + show_options, + } => { + write!( + f, + "SHOW {terse}STREAMS{show_options}", + terse = if *terse { "TERSE " } else { "" }, + )?; + Ok(()) + } + Statement::AlterStream { + if_exists, + name, + operation, + } => { + write!(f, "ALTER STREAM")?; + if *if_exists { + write!(f, " IF EXISTS")?; + } + write!(f, " {name} {operation}") + } + Statement::CreateExternalVolume { + or_replace, + if_not_exists, + name, + storage_locations, + allow_writes, + comment, + } => { + write!( + f, + "CREATE {or_replace}EXTERNAL VOLUME {if_not_exists}{name} STORAGE_LOCATIONS = (", + or_replace = if *or_replace { "OR REPLACE " } else { "" }, + if_not_exists = if *if_not_exists { "IF NOT EXISTS " } else { "" }, + )?; + for (i, loc) in storage_locations.iter().enumerate() { + if i > 0 { + write!(f, ", ")?; + } + write!(f, "({loc})")?; + } + write!(f, ")")?; + if let Some(val) = allow_writes { + write!(f, " ALLOW_WRITES = {}", if *val { "TRUE" } else { "FALSE" })?; + } + if let Some(ref c) = comment { + write!(f, " COMMENT = '{}'", value::escape_single_quote_string(c))?; + } + Ok(()) + } + Statement::AlterExternalVolume { + name, + if_exists, + operation, + } => { + write!( + f, + "ALTER EXTERNAL VOLUME {if_exists}{name} {operation}", + if_exists = if *if_exists { "IF EXISTS " } else { "" }, + ) + } + Statement::DropExternalVolume { name, if_exists } => { + write!( + f, + "DROP EXTERNAL VOLUME {if_exists}{name}", + if_exists = if *if_exists { "IF EXISTS " } else { "" }, + ) + } + Statement::DescribeExternalVolume { name } => { + write!(f, "DESCRIBE EXTERNAL VOLUME {name}") + } + Statement::ShowExternalVolumes { filter } => { + write!(f, "SHOW EXTERNAL VOLUMES")?; + if let Some(ref filter) = filter { + write!(f, " {filter}")?; + } + Ok(()) + } + Statement::CreateFileFormat { + or_replace, + temporary, + if_not_exists, + name, + format_type, + options, + like_source, + comment, + } => { + write!( + f, + "CREATE {or_replace}{temporary}FILE FORMAT {if_not_exists}{name}", + or_replace = if *or_replace { "OR REPLACE " } else { "" }, + temporary = if *temporary { "TEMPORARY " } else { "" }, + if_not_exists = if *if_not_exists { "IF NOT EXISTS " } else { "" }, + )?; + if let Some(ref source) = like_source { + write!(f, " LIKE {source}")?; + } else { + if let Some(ref ft) = format_type { + write!(f, " TYPE = {ft}")?; + } + if !options.options.is_empty() { + write!(f, " {options}")?; + } + } + if let Some(ref c) = comment { + write!(f, " COMMENT = '{}'", value::escape_single_quote_string(c))?; + } + Ok(()) + } + Statement::AlterFileFormat { + name, + if_exists, + operation, + } => { + write!( + f, + "ALTER FILE FORMAT {if_exists}{name} {operation}", + if_exists = if *if_exists { "IF EXISTS " } else { "" }, + ) + } + Statement::DropFileFormat { name, if_exists } => { + write!( + f, + "DROP FILE FORMAT {if_exists}{name}", + if_exists = if *if_exists { "IF EXISTS " } else { "" }, + ) + } + Statement::DescribeFileFormat { name } => { + write!(f, "DESCRIBE FILE FORMAT {name}") + } + Statement::ShowFileFormats { + terse, + show_options, + } => { + write!( + f, + "SHOW {terse}FILE FORMATS{show_options}", + terse = if *terse { "TERSE " } else { "" }, + ) + } + Statement::ShowStages { + terse, + show_options, + } => { + write!( + f, + "SHOW {terse}STAGES{show_options}", + terse = if *terse { "TERSE " } else { "" }, + ) + } + Statement::CreateTag { + or_replace, + if_not_exists, + name, + allowed_values, + comment, + } => { + write!( + f, + "CREATE {or_replace}TAG {if_not_exists}{name}", + or_replace = if *or_replace { "OR REPLACE " } else { "" }, + if_not_exists = if *if_not_exists { "IF NOT EXISTS " } else { "" }, + )?; + if !allowed_values.is_empty() { + write!(f, " ALLOWED_VALUES ")?; + for (i, v) in allowed_values.iter().enumerate() { + if i > 0 { + write!(f, ", ")?; + } + write!(f, "'{}'", value::escape_single_quote_string(v))?; + } + } + if let Some(ref c) = comment { + write!(f, " COMMENT = '{}'", value::escape_single_quote_string(c))?; + } + Ok(()) + } + Statement::AlterTag { + if_exists, + name, + operation, + } => { + write!( + f, + "ALTER TAG {if_exists}{name} {operation}", + if_exists = if *if_exists { "IF EXISTS " } else { "" }, + ) + } + Statement::DropTag { name, if_exists } => { + write!( + f, + "DROP TAG {if_exists}{name}", + if_exists = if *if_exists { "IF EXISTS " } else { "" }, + ) + } + Statement::SetTags { + object_type, + object_name, + unset, + set_tags, + unset_tags, + } => { + write!(f, "ALTER {object_type} {object_name} ")?; + if *unset { + write!(f, "UNSET TAG {}", display_comma_separated(unset_tags)) + } else { + write!(f, "SET TAG {}", display_comma_separated(set_tags)) + } + } + Statement::ShowTags { + terse, + show_options, + } => { + write!( + f, + "SHOW {terse}TAGS{show_options}", + terse = if *terse { "TERSE " } else { "" }, + ) + } + Statement::ShowSequences { + terse, + show_options, + } => { + write!( + f, + "SHOW {terse}SEQUENCES{show_options}", + terse = if *terse { "TERSE " } else { "" }, + ) } - Statement::ShowTasks { + Statement::ShowKeys { + kind, terse, show_options, } => { write!( f, - "SHOW {terse}TASKS{show_options}", + "SHOW {terse}{kind} KEYS{show_options}", terse = if *terse { "TERSE " } else { "" }, - )?; - Ok(()) + ) } - Statement::CreateExternalVolume { + Statement::CreateRowAccessPolicy { or_replace, if_not_exists, name, - storage_locations, - allow_writes, - comment, + args, + return_type, + policy_expr, } => { write!( f, - "CREATE {or_replace}EXTERNAL VOLUME {if_not_exists}{name} STORAGE_LOCATIONS = (", + "CREATE {or_replace}ROW ACCESS POLICY {if_not_exists}{name} AS ({args}) RETURNS {return_type} -> {policy_expr}", or_replace = if *or_replace { "OR REPLACE " } else { "" }, if_not_exists = if *if_not_exists { "IF NOT EXISTS " } else { "" }, - )?; - for (i, loc) in storage_locations.iter().enumerate() { - if i > 0 { - write!(f, ", ")?; - } - write!(f, "({loc})")?; - } - write!(f, ")")?; - if let Some(val) = allow_writes { - write!(f, " ALLOW_WRITES = {}", if *val { "TRUE" } else { "FALSE" })?; - } - if let Some(ref c) = comment { - write!(f, " COMMENT = '{}'", value::escape_single_quote_string(c))?; - } - Ok(()) + args = display_comma_separated(args), + ) } - Statement::AlterExternalVolume { - name, + Statement::AlterRowAccessPolicy { if_exists, - operation, + name, + new_name, } => { write!( f, - "ALTER EXTERNAL VOLUME {if_exists}{name} {operation}", + "ALTER ROW ACCESS POLICY {if_exists}{name} RENAME TO {new_name}", if_exists = if *if_exists { "IF EXISTS " } else { "" }, ) } - Statement::DropExternalVolume { name, if_exists } => { + Statement::DropRowAccessPolicy { if_exists, name } => { write!( f, - "DROP EXTERNAL VOLUME {if_exists}{name}", + "DROP ROW ACCESS POLICY {if_exists}{name}", if_exists = if *if_exists { "IF EXISTS " } else { "" }, ) } - Statement::DescribeExternalVolume { name } => { - write!(f, "DESCRIBE EXTERNAL VOLUME {name}") + Statement::DescribeRowAccessPolicy { name } => { + write!(f, "DESCRIBE ROW ACCESS POLICY {name}") } - Statement::ShowExternalVolumes { filter } => { - write!(f, "SHOW EXTERNAL VOLUMES")?; - if let Some(ref filter) = filter { + Statement::ShowRowAccessPolicies { filter } => { + write!(f, "SHOW ROW ACCESS POLICIES")?; + if let Some(filter) = filter { write!(f, " {filter}")?; } Ok(()) } - Statement::CreateFileFormat { + Statement::CreateMaskingPolicy { or_replace, - temporary, if_not_exists, name, - format_type, - options, - like_source, + args, + return_type, + policy_expr, comment, } => { write!( f, - "CREATE {or_replace}{temporary}FILE FORMAT {if_not_exists}{name}", + "CREATE {or_replace}MASKING POLICY {if_not_exists}{name} AS ({args}) RETURNS {return_type} -> {policy_expr}", or_replace = if *or_replace { "OR REPLACE " } else { "" }, - temporary = if *temporary { "TEMPORARY " } else { "" }, if_not_exists = if *if_not_exists { "IF NOT EXISTS " } else { "" }, + args = display_comma_separated(args), )?; - if let Some(ref source) = like_source { - write!(f, " LIKE {source}")?; - } else { - if let Some(ref ft) = format_type { - write!(f, " TYPE = {ft}")?; - } - if !options.options.is_empty() { - write!(f, " {options}")?; - } - } - if let Some(ref c) = comment { - write!(f, " COMMENT = '{}'", value::escape_single_quote_string(c))?; + if let Some(comment) = comment { + write!( + f, + " COMMENT = '{}'", + value::escape_single_quote_string(comment) + )?; } Ok(()) } - Statement::AlterFileFormat { - name, + Statement::AlterMaskingPolicy { if_exists, + name, operation, } => { write!( f, - "ALTER FILE FORMAT {if_exists}{name} {operation}", + "ALTER MASKING POLICY {if_exists}{name} {operation}", if_exists = if *if_exists { "IF EXISTS " } else { "" }, ) } - Statement::DropFileFormat { name, if_exists } => { + Statement::DropMaskingPolicy { if_exists, name } => { write!( f, - "DROP FILE FORMAT {if_exists}{name}", + "DROP MASKING POLICY {if_exists}{name}", if_exists = if *if_exists { "IF EXISTS " } else { "" }, ) } - Statement::DescribeFileFormat { name } => { - write!(f, "DESCRIBE FILE FORMAT {name}") + Statement::DescribeMaskingPolicy { name } => { + write!(f, "DESCRIBE MASKING POLICY {name}") } - Statement::ShowFileFormats { - terse, - show_options, - } => { - write!( - f, - "SHOW {terse}FILE FORMATS{show_options}", - terse = if *terse { "TERSE " } else { "" }, - ) + Statement::ShowMaskingPolicies { show_options } => { + write!(f, "SHOW MASKING POLICIES{show_options}") } - Statement::ShowStages { - terse, - show_options, - } => { - write!( - f, - "SHOW {terse}STAGES{show_options}", - terse = if *terse { "TERSE " } else { "" }, - ) + Statement::ShowProcedures { show_options } => { + write!(f, "SHOW PROCEDURES{show_options}")?; + Ok(()) + } + Statement::ShowConnections { filter } => { + write!(f, "SHOW CONNECTIONS")?; + if let Some(filter) = filter { + write!(f, " {filter}")?; + } + Ok(()) + } + Statement::ShowShares { filter } => { + write!(f, "SHOW SHARES")?; + if let Some(filter) = filter { + write!(f, " {filter}")?; + } + Ok(()) } Statement::CreateCatalogIntegration { or_replace, @@ -7275,12 +8061,62 @@ impl fmt::Display for Statement { } Ok(()) } + Statement::CreateStorageIntegration { + or_replace, + if_not_exists, + name, + params, + } => { + write!( + f, + "CREATE {or_replace}STORAGE INTEGRATION {if_not_exists}{name}", + or_replace = if *or_replace { "OR REPLACE " } else { "" }, + if_not_exists = if *if_not_exists { "IF NOT EXISTS " } else { "" }, + )?; + if !params.options.is_empty() { + write!(f, " {params}")?; + } + Ok(()) + } + Statement::AlterStorageIntegration { + name, + if_exists, + set_options, + } => { + write!( + f, + "ALTER STORAGE INTEGRATION {if_exists}{name} SET", + if_exists = if *if_exists { "IF EXISTS " } else { "" }, + )?; + if !set_options.options.is_empty() { + write!(f, " {set_options}")?; + } + Ok(()) + } + Statement::DropStorageIntegration { name, if_exists } => { + write!( + f, + "DROP STORAGE INTEGRATION {if_exists}{name}", + if_exists = if *if_exists { "IF EXISTS " } else { "" }, + ) + } + Statement::DescribeStorageIntegration { name } => { + write!(f, "DESCRIBE STORAGE INTEGRATION {name}") + } + Statement::ShowStorageIntegrations { filter } => { + write!(f, "SHOW STORAGE INTEGRATIONS")?; + if let Some(ref filter) = filter { + write!(f, " {filter}")?; + } + Ok(()) + } Statement::CopyIntoSnowflake { kind, into, into_columns, from_obj, from_obj_alias, + from_obj_args, stage_params, from_transformations, from_query, @@ -7305,6 +8141,9 @@ impl fmt::Display for Statement { from_stage, stage_params )?; + if let Some(args) = from_obj_args { + write!(f, " ({})", display_comma_separated(&args.args))?; + } } if let Some(from_obj_alias) = from_obj_alias { write!(f, " AS {from_obj_alias}")?; @@ -7313,6 +8152,9 @@ impl fmt::Display for Statement { } else if let Some(from_obj) = from_obj { // Standard data load write!(f, " FROM {from_obj}{stage_params}")?; + if let Some(args) = from_obj_args { + write!(f, " ({})", display_comma_separated(&args.args))?; + } if let Some(from_obj_alias) = from_obj_alias { write!(f, " AS {from_obj_alias}")?; } @@ -7497,6 +8339,9 @@ impl fmt::Display for Statement { write!(f, " := {value}") } Statement::Null => write!(f, "NULL"), + Statement::PutGetFiles { get } => { + write!(f, "{}", if *get { "GET" } else { "PUT" }) + } } } } @@ -8167,6 +9012,8 @@ pub enum Action { /// Read access. Read, + /// Write access. + Write, /// Read session-level access. ReadSession, /// References with optional column list. @@ -8260,6 +9107,7 @@ impl fmt::Display for Action { Action::Ownership => f.write_str("OWNERSHIP")?, Action::PurchaseDataExchangeListing => f.write_str("PURCHASE DATA EXCHANGE LISTING")?, Action::Read => f.write_str("READ")?, + Action::Write => f.write_str("WRITE")?, Action::ReadSession => f.write_str("READ SESSION")?, Action::References { .. } => f.write_str("REFERENCES")?, Action::Replicate => f.write_str("REPLICATE")?, @@ -8303,8 +9151,13 @@ pub enum ActionCreateObjectType { ComputePool, /// A data exchange listing. DataExchangeListing, + /// A class object identified by a qualified name, e.g. + /// `CREATE SNOWFLAKE.ML.ANOMALY_DETECTION`. + Class(ObjectName), /// A database object. Database, + /// A database role object. + DatabaseRole, /// An external volume object. ExternalVolume, /// A failover group object. @@ -8323,6 +9176,8 @@ pub enum ActionCreateObjectType { Schema, /// A share object. Share, + /// A table object. + Table, /// A user object. User, /// A warehouse object. @@ -8337,7 +9192,9 @@ impl fmt::Display for ActionCreateObjectType { ActionCreateObjectType::ApplicationPackage => write!(f, "APPLICATION PACKAGE"), ActionCreateObjectType::ComputePool => write!(f, "COMPUTE POOL"), ActionCreateObjectType::DataExchangeListing => write!(f, "DATA EXCHANGE LISTING"), + ActionCreateObjectType::Class(name) => write!(f, "{name}"), ActionCreateObjectType::Database => write!(f, "DATABASE"), + ActionCreateObjectType::DatabaseRole => write!(f, "DATABASE ROLE"), ActionCreateObjectType::ExternalVolume => write!(f, "EXTERNAL VOLUME"), ActionCreateObjectType::FailoverGroup => write!(f, "FAILOVER GROUP"), ActionCreateObjectType::Integration => write!(f, "INTEGRATION"), @@ -8347,6 +9204,7 @@ impl fmt::Display for ActionCreateObjectType { ActionCreateObjectType::Role => write!(f, "ROLE"), ActionCreateObjectType::Schema => write!(f, "SCHEMA"), ActionCreateObjectType::Share => write!(f, "SHARE"), + ActionCreateObjectType::Table => write!(f, "TABLE"), ActionCreateObjectType::User => write!(f, "USER"), ActionCreateObjectType::Warehouse => write!(f, "WAREHOUSE"), } @@ -8679,6 +9537,46 @@ pub enum GrantObjects { /// The target schema names. schemas: Vec, }, + /// Grant privileges on `ALL SCHEMAS IN DATABASE [, ...]` + AllSchemasInDatabase { + /// The target database names. + databases: Vec, + }, + /// Grant privileges on `ALL TABLES IN DATABASE [, ...]` + AllTablesInDatabase { + /// The target database names. + databases: Vec, + }, + /// Grant privileges on `ALL STAGES IN SCHEMA [, ...]` + AllStagesInSchema { + /// The target schema names. + schemas: Vec, + }, + /// Grant privileges on `ALL FILE FORMATS IN SCHEMA [, ...]` + AllFileFormatsInSchema { + /// The target schema names. + schemas: Vec, + }, + /// Grant privileges on `FUTURE TABLES IN DATABASE [, ...]` + FutureTablesInDatabase { + /// The target database names. + databases: Vec, + }, + /// Grant privileges on `FUTURE STAGES IN SCHEMA [, ...]` + FutureStagesInSchema { + /// The target schema names. + schemas: Vec, + }, + /// Grant privileges on `FUTURE FILE FORMATS IN SCHEMA [, ...]` + FutureFileFormatsInSchema { + /// The target schema names. + schemas: Vec, + }, + /// Grant privileges on `FUTURE FUNCTIONS IN SCHEMA [, ...]` + FutureFunctionsInSchema { + /// The target schema names. + schemas: Vec, + }, /// Grant privileges on specific databases Databases(Vec), /// Grant privileges on specific schemas @@ -8697,6 +9595,10 @@ pub enum GrantObjects { ResourceMonitors(Vec), /// Grant privileges on users Users(Vec), + /// Grant privileges on specific stages + Stages(Vec), + /// Grant privileges on specific file formats + FileFormats(Vec), /// Grant privileges on compute pools ComputePools(Vec), /// Grant privileges on connections @@ -8840,12 +9742,74 @@ impl fmt::Display for GrantObjects { display_comma_separated(schemas) ) } + GrantObjects::AllSchemasInDatabase { databases } => { + write!( + f, + "ALL SCHEMAS IN DATABASE {}", + display_comma_separated(databases) + ) + } + GrantObjects::AllTablesInDatabase { databases } => { + write!( + f, + "ALL TABLES IN DATABASE {}", + display_comma_separated(databases) + ) + } + GrantObjects::AllStagesInSchema { schemas } => { + write!( + f, + "ALL STAGES IN SCHEMA {}", + display_comma_separated(schemas) + ) + } + GrantObjects::AllFileFormatsInSchema { schemas } => { + write!( + f, + "ALL FILE FORMATS IN SCHEMA {}", + display_comma_separated(schemas) + ) + } + GrantObjects::FutureTablesInDatabase { databases } => { + write!( + f, + "FUTURE TABLES IN DATABASE {}", + display_comma_separated(databases) + ) + } + GrantObjects::FutureStagesInSchema { schemas } => { + write!( + f, + "FUTURE STAGES IN SCHEMA {}", + display_comma_separated(schemas) + ) + } + GrantObjects::FutureFileFormatsInSchema { schemas } => { + write!( + f, + "FUTURE FILE FORMATS IN SCHEMA {}", + display_comma_separated(schemas) + ) + } + GrantObjects::FutureFunctionsInSchema { schemas } => { + write!( + f, + "FUTURE FUNCTIONS IN SCHEMA {}", + display_comma_separated(schemas) + ) + } GrantObjects::ResourceMonitors(objects) => { write!(f, "RESOURCE MONITOR {}", display_comma_separated(objects)) } GrantObjects::Users(objects) => { write!(f, "USER {}", display_comma_separated(objects)) } + GrantObjects::Stages(objects) => { + write!(f, "STAGE {}", display_comma_separated(objects)) + } + GrantObjects::FileFormats(objects) => { + write!(f, "FILE FORMAT {}", display_comma_separated(objects)) + } GrantObjects::ComputePools(objects) => { write!(f, "COMPUTE POOL {}", display_comma_separated(objects)) } @@ -9578,6 +10542,9 @@ pub enum ObjectType { View, /// A materialized view. MaterializedView, + /// A dynamic table (Snowflake). + /// + DynamicTable, /// An index. Index, /// A schema. @@ -9586,6 +10553,8 @@ pub enum ObjectType { Database, /// A role. Role, + /// A database role (Snowflake). + DatabaseRole, /// A sequence. Sequence, /// A stage. @@ -9609,10 +10578,12 @@ impl fmt::Display for ObjectType { ObjectType::Table => "TABLE", ObjectType::View => "VIEW", ObjectType::MaterializedView => "MATERIALIZED VIEW", + ObjectType::DynamicTable => "DYNAMIC TABLE", ObjectType::Index => "INDEX", ObjectType::Schema => "SCHEMA", ObjectType::Database => "DATABASE", ObjectType::Role => "ROLE", + ObjectType::DatabaseRole => "DATABASE ROLE", ObjectType::Sequence => "SEQUENCE", ObjectType::Stage => "STAGE", ObjectType::Type => "TYPE", @@ -9624,6 +10595,28 @@ impl fmt::Display for ObjectType { } } +#[derive(Debug, Copy, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +/// The kind of object a Snowflake stream tracks, i.e. whether it was created +/// with `ON TABLE ` or `ON VIEW `. +/// +pub enum StreamSourceKind { + /// `ON TABLE `. + Table, + /// `ON VIEW `. + View, +} + +impl fmt::Display for StreamSourceKind { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str(match self { + StreamSourceKind::Table => "TABLE", + StreamSourceKind::View => "VIEW", + }) + } +} + #[derive(Debug, Copy, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] @@ -9779,8 +10772,14 @@ impl fmt::Display for HiveDescribeFormat { pub enum DescribeObjectType { /// `TABLE` Table, + /// `DYNAMIC TABLE` (Snowflake) + /// + DynamicTable, /// `VIEW` View, + /// `MATERIALIZED VIEW` (Snowflake) + /// + MaterializedView, /// `DATABASE` Database, /// `SCHEMA` @@ -9789,17 +10788,43 @@ pub enum DescribeObjectType { Task, /// `STAGE` Stage, + /// `STREAM` + Stream, } impl fmt::Display for DescribeObjectType { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(match self { DescribeObjectType::Table => "TABLE", + DescribeObjectType::DynamicTable => "DYNAMIC TABLE", DescribeObjectType::View => "VIEW", + DescribeObjectType::MaterializedView => "MATERIALIZED VIEW", DescribeObjectType::Database => "DATABASE", DescribeObjectType::Schema => "SCHEMA", DescribeObjectType::Task => "TASK", DescribeObjectType::Stage => "STAGE", + DescribeObjectType::Stream => "STREAM", + }) + } +} + +#[derive(Debug, Copy, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +/// `TYPE = { COLUMNS | STAGE }` modifier of a Snowflake `DESC TABLE` statement. +/// +pub enum DescribeTableType { + /// `TYPE = COLUMNS` (the default): describe the table's columns. + Columns, + /// `TYPE = STAGE`: describe the table's implicit internal stage properties. + Stage, +} + +impl fmt::Display for DescribeTableType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + DescribeTableType::Columns => "COLUMNS", + DescribeTableType::Stage => "STAGE", }) } } @@ -12072,6 +13097,9 @@ impl fmt::Display for ShowCharset { pub struct ShowObjects { /// Whether to show terse output. pub terse: bool, + /// Whether this is `SHOW DYNAMIC TABLES` rather than `SHOW OBJECTS` + /// (Snowflake). Both share the option grammar (`LIKE` / `IN` / …). + pub dynamic: bool, /// Additional options controlling the SHOW output. pub show_options: ShowStatementOptions, } @@ -12469,6 +13497,28 @@ impl fmt::Display for AlterTaskAction { } } +/// Action for [`Statement::AlterStream`]. +/// +/// See . +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub enum AlterStreamOperation { + /// `SET COMMENT = ''` + SetComment(String), + /// `UNSET COMMENT` + UnsetComment, +} + +impl fmt::Display for AlterStreamOperation { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + AlterStreamOperation::SetComment(value) => write!(f, "SET COMMENT = '{value}'"), + AlterStreamOperation::UnsetComment => write!(f, "UNSET COMMENT"), + } + } +} + /// The `CATALOG_SOURCE` of a `CATALOG INTEGRATION`. #[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] diff --git a/src/ast/query.rs b/src/ast/query.rs index 6b8b36eb4c..ccd56fe341 100644 --- a/src/ast/query.rs +++ b/src/ast/query.rs @@ -2630,8 +2630,10 @@ pub enum TableVersion { Changes { /// The `CHANGES(INFORMATION => ...)` function-call expression. changes: Expr, - /// The `AT(TIMESTAMP => ...)` function-call expression. - at: Expr, + /// The optional `AT(TIMESTAMP => ...)` / `BEFORE(...)` function-call + /// expression. Absent when `CHANGES` is used without an `AT`/`BEFORE` + /// bound. + at: Option, /// The optional `END(TIMESTAMP => ...)` function-call expression. end: Option, }, @@ -2645,7 +2647,10 @@ impl Display for TableVersion { TableVersion::VersionAsOf(e) => write!(f, "VERSION AS OF {e}")?, TableVersion::Function(func) => write!(f, "{func}")?, TableVersion::Changes { changes, at, end } => { - write!(f, "{changes} {at}")?; + write!(f, "{changes}")?; + if let Some(at) = at { + write!(f, " {at}")?; + } if let Some(end) = end { write!(f, " {end}")?; } diff --git a/src/ast/spans.rs b/src/ast/spans.rs index c442aa519a..ca1bd2484a 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -361,6 +361,7 @@ impl Spanned for Statement { into_columns: _, from_obj: _, from_obj_alias: _, + from_obj_args: _, stage_params: _, from_transformations: _, files: _, @@ -393,6 +394,7 @@ impl Spanned for Statement { ), Statement::CreateIndex(create_index) => create_index.span(), Statement::CreateRole(create_role) => create_role.span(), + Statement::CreateDatabaseRole { name, .. } => name.span(), Statement::CreateExtension(create_extension) => create_extension.span(), Statement::CreateCollation(create_collation) => create_collation.span(), Statement::DropExtension(drop_extension) => drop_extension.span(), @@ -420,6 +422,13 @@ impl Spanned for Statement { .chain(core::iter::once(query.span())) .chain(with_options.iter().map(|i| i.span())), ), + Statement::AlterViewColumn { + name, + column_name, + op, + } => union_spans( + [name.span(), column_name.span, op.span()].into_iter(), + ), // These statements need to be implemented Statement::AlterFunction { .. } => Span::empty(), Statement::AlterType { .. } => Span::empty(), @@ -433,6 +442,7 @@ impl Spanned for Statement { Statement::AttachDuckDBDatabase { .. } => Span::empty(), Statement::DetachDuckDBDatabase { .. } => Span::empty(), Statement::Drop { .. } => Span::empty(), + Statement::Undrop { .. } => Span::empty(), Statement::DropFunction(drop_function) => drop_function.span(), Statement::DropDomain { .. } => Span::empty(), Statement::DropProcedure { .. } => Span::empty(), @@ -531,6 +541,7 @@ impl Spanned for Statement { Statement::AlterUser(..) => Span::empty(), Statement::Reset(..) => Span::empty(), Statement::CreateWarehouse { .. } => Span::empty(), + Statement::CreateStream { .. } => Span::empty(), Statement::AlterWarehouse { .. } => Span::empty(), Statement::DescribeWarehouse { .. } => Span::empty(), Statement::ShowWarehouses { .. } => Span::empty(), @@ -540,8 +551,10 @@ impl Spanned for Statement { Statement::ShowAccounts { .. } => Span::empty(), Statement::CreateTask { .. } => Span::empty(), Statement::AlterTask { .. } => Span::empty(), + Statement::AlterStream { .. } => Span::empty(), Statement::ExecuteTask { .. } => Span::empty(), Statement::ShowTasks { .. } => Span::empty(), + Statement::ShowStreams { .. } => Span::empty(), Statement::CreateExternalVolume { .. } => Span::empty(), Statement::AlterExternalVolume { .. } => Span::empty(), Statement::DropExternalVolume { .. } => Span::empty(), @@ -553,12 +566,42 @@ impl Spanned for Statement { Statement::DescribeFileFormat { .. } => Span::empty(), Statement::ShowFileFormats { .. } => Span::empty(), Statement::ShowStages { .. } => Span::empty(), + Statement::CreateTag { .. } => Span::empty(), + Statement::AlterTag { .. } => Span::empty(), + Statement::DropTag { .. } => Span::empty(), + Statement::SetTags { .. } => Span::empty(), + Statement::ShowTags { .. } => Span::empty(), + Statement::ShowSequences { .. } => Span::empty(), + Statement::ShowKeys { .. } => Span::empty(), + Statement::CreateRowAccessPolicy { .. } => Span::empty(), + Statement::AlterRowAccessPolicy { .. } => Span::empty(), + Statement::DropRowAccessPolicy { .. } => Span::empty(), + Statement::DescribeRowAccessPolicy { .. } => Span::empty(), + Statement::ShowRowAccessPolicies { .. } => Span::empty(), + Statement::CreateMaskingPolicy { .. } => Span::empty(), + Statement::AlterMaskingPolicy { .. } => Span::empty(), + Statement::DropMaskingPolicy { .. } => Span::empty(), + Statement::DescribeMaskingPolicy { .. } => Span::empty(), + Statement::ShowMaskingPolicies { .. } => Span::empty(), + Statement::ShowProcedures { .. } => Span::empty(), + Statement::ShowConnections { .. } => Span::empty(), + Statement::ShowShares { .. } => Span::empty(), Statement::CreateCatalogIntegration { .. } => Span::empty(), Statement::DropCatalogIntegration { .. } => Span::empty(), Statement::ShowCatalogIntegrations { .. } => Span::empty(), + Statement::CreateStorageIntegration { .. } => Span::empty(), + Statement::AlterStorageIntegration { .. } => Span::empty(), + Statement::DropStorageIntegration { .. } => Span::empty(), + Statement::DescribeStorageIntegration { .. } => Span::empty(), + Statement::ShowStorageIntegrations { .. } => Span::empty(), Statement::Assignment { .. } => Span::empty(), Statement::Let { .. } => Span::empty(), Statement::Null => Span::empty(), + Statement::PutGetFiles { .. } => Span::empty(), + Statement::FetchInto { .. } => Span::empty(), + Statement::CallInto { .. } => Span::empty(), + Statement::AlterProcedure { .. } => Span::empty(), + Statement::WithProcedure { .. } => Span::empty(), } } } @@ -622,6 +665,7 @@ impl Spanned for CreateTable { copy_grants: _, // bool enable_schema_evolution: _, // bool change_tracking: _, // bool + stage_file_format: _, // key-value options, no span data_retention_time_in_days: _, // u64, no span max_data_extension_time_in_days: _, // u64, no span default_ddl_collation: _, // string, no span @@ -632,11 +676,16 @@ impl Spanned for CreateTable { external_volume: _, // todo, Snowflake specific base_location: _, // todo, Snowflake specific catalog: _, // todo, Snowflake specific + catalog_table_name: _, // todo, Snowflake specific + auto_refresh: _, // todo, Snowflake specific catalog_sync: _, // todo, Snowflake specific storage_serialization_policy: _, table_options, target_lag: _, warehouse: _, + initialization_warehouse: _, + scheduler: _, + immutable_where: _, version: _, refresh_mode: _, initialize: _, @@ -942,6 +991,9 @@ impl Spanned for ConstraintCharacteristics { deferrable: _, // bool initially: _, // enum enforced: _, // bool + enabled: _, // bool + validated: _, // bool + rely: _, // bool } = self; Span::empty() @@ -983,7 +1035,10 @@ impl Spanned for AlterColumnOperation { using, had_set: _, } => using.as_ref().map_or(Span::empty(), |u| u.span()), + AlterColumnOperation::Comment { .. } => Span::empty(), AlterColumnOperation::AddGenerated { .. } => Span::empty(), + AlterColumnOperation::SetMaskingPolicy { .. } => Span::empty(), + AlterColumnOperation::UnsetMaskingPolicy => Span::empty(), } } } @@ -1189,6 +1244,11 @@ impl Spanned for AlterTableOperation { column_def, column_position: _, } => column_def.span(), + AlterTableOperation::AddColumns { + column_keyword: _, + if_not_exists: _, + column_defs, + } => union_spans(column_defs.iter().map(|c| c.span())), AlterTableOperation::AddProjection { if_not_exists: _, name, @@ -1206,6 +1266,8 @@ impl Spanned for AlterTableOperation { partition, } => name.span.union_opt(&partition.as_ref().map(|i| i.span)), AlterTableOperation::DisableRowLevelSecurity => Span::empty(), + AlterTableOperation::DropRowAccessPolicy { .. } => Span::empty(), + AlterTableOperation::DropAllRowAccessPolicies => Span::empty(), AlterTableOperation::DisableRule { name } => name.span, AlterTableOperation::DisableTrigger { name } => name.span, AlterTableOperation::DropConstraint { @@ -1602,6 +1664,16 @@ impl Spanned for Expr { escape_char: _, any: _, } => expr.span().union(&pattern.span()), + Expr::LikeAnyAll { + negated: _, + ilike: _, + all: _, + expr, + patterns, + escape_char: _, + } => union_spans( + core::iter::once(expr.span()).chain(patterns.iter().map(|p| p.span())), + ), Expr::RLike { .. } => Span::empty(), Expr::IsNormalized { expr, diff --git a/src/ast/table_constraints.rs b/src/ast/table_constraints.rs index 9ba196a81e..25c70f2dd7 100644 --- a/src/ast/table_constraints.rs +++ b/src/ast/table_constraints.rs @@ -155,6 +155,24 @@ impl From for TableConstraint { } } +impl TableConstraint { + /// Drop any [`ConstraintCharacteristics`] the constraint carries, so it + /// renders as the bare constraint. Useful for dialects whose grammar has no + /// counterpart for the characteristics the source dialect accepts. + pub fn clear_characteristics(&mut self) { + match self { + TableConstraint::Unique(constraint) => constraint.characteristics = None, + TableConstraint::PrimaryKey(constraint) => constraint.characteristics = None, + TableConstraint::ForeignKey(constraint) => constraint.characteristics = None, + TableConstraint::PrimaryKeyUsingIndex(constraint) + | TableConstraint::UniqueUsingIndex(constraint) => constraint.characteristics = None, + TableConstraint::Check(_) + | TableConstraint::Index(_) + | TableConstraint::FulltextOrSpatial(_) => {} + } + } +} + impl fmt::Display for TableConstraint { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { diff --git a/src/ast/value.rs b/src/ast/value.rs index 5f069f36cc..780a3a261e 100644 --- a/src/ast/value.rs +++ b/src/ast/value.rs @@ -537,56 +537,21 @@ pub struct EscapeQuotedString<'a> { impl fmt::Display for EscapeQuotedString<'_> { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - // EscapeQuotedString doesn't know which mode of escape was - // chosen by the user. So this code must to correctly display - // strings without knowing if the strings are already escaped - // or not. - // - // If the quote symbol in the string is repeated twice, OR, if - // the quote symbol is after backslash, display all the chars - // without any escape. However, if the quote symbol is used - // just between usual chars, `fmt()` should display it twice." - // - // The following table has examples - // - // | original query | mode | AST Node | serialized | - // | ------------- | --------- | -------------------------------------------------- | ------------ | - // | `"A""B""A"` | no-escape | `DoubleQuotedString(String::from("A\"\"B\"\"A"))` | `"A""B""A"` | - // | `"A""B""A"` | default | `DoubleQuotedString(String::from("A\"B\"A"))` | `"A""B""A"` | - // | `"A\"B\"A"` | no-escape | `DoubleQuotedString(String::from("A\\\"B\\\"A"))` | `"A\"B\"A"` | - // | `"A\"B\"A"` | default | `DoubleQuotedString(String::from("A\"B\"A"))` | `"A""B""A"` | + // The quoted-string AST nodes hold the *decoded* value, so escaping is + // the exact inverse of parsing: double every occurrence of the quote + // character. Doubling unconditionally keeps adjacent quotes — such as an + // embedded empty-string literal `''` — correct. A peek-ahead + // "already escaped" heuristic would mistake such a pair for a + // pre-escaped quote and leave it under-escaped. let quote = self.quote; - let mut previous_char = char::default(); let mut start_idx = 0; - let mut peekable_chars = self.string.char_indices().peekable(); - while let Some(&(idx, ch)) = peekable_chars.peek() { - match ch { - char if char == quote => { - if previous_char == '\\' { - // the quote is already escaped with a backslash, skip - peekable_chars.next(); - continue; - } - peekable_chars.next(); - match peekable_chars.peek() { - Some((_, c)) if *c == quote => { - // the quote is already escaped with another quote, skip - peekable_chars.next(); - } - _ => { - // The quote is not escaped. - // Including idx in the range, so the quote at idx will be printed twice: - // in this call to write_str() and in the next one. - f.write_str(&self.string[start_idx..=idx])?; - start_idx = idx; - } - } - } - _ => { - peekable_chars.next(); - } + for (idx, ch) in self.string.char_indices() { + if ch == quote { + // Including idx in the range emits the quote at idx, then + // start_idx is reset to idx so the next write emits it again. + f.write_str(&self.string[start_idx..=idx])?; + start_idx = idx; } - previous_char = ch; } f.write_str(&self.string[start_idx..])?; Ok(()) diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index 8afe594dc0..2be89f4215 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -283,6 +283,18 @@ pub trait Dialect: Debug + Any { false } + /// Determine whether the dialect decodes Snowflake's extended backslash + /// escape set inside single-quoted string literals: octal `\ooo`, hex + /// `\xhh` and unicode `\uhhhh` (each yielding a Unicode code point), with + /// unrecognized letters such as `\a` / `\Z` collapsing to the bare letter + /// and malformed `\x` / `\u` raising a tokenizer error. This differs from + /// the MySQL/BigQuery-style mapping used by + /// [`Self::supports_string_literal_backslash_escape`], so a dialect that + /// enables this must also enable that one. + fn supports_snowflake_string_literal_escapes(&self) -> bool { + false + } + /// Determine whether the dialect strips the backslash when escaping LIKE wildcards (%, _). /// /// [MySQL] has a special case when escaping single quoted strings which leaves these unescaped @@ -641,6 +653,14 @@ pub trait Dialect: Debug + Any { false } + /// Returns true if the dialect allows a column in a `CREATE TABLE` + /// column list to omit its data type, deferring type inference to an + /// `AS ` clause. + /// Example: `CREATE TABLE t (id) AS SELECT 123` + fn supports_create_table_optional_column_type(&self) -> bool { + false + } + /// Returns true if the dialect supports double dot notation for object names /// /// Example @@ -1107,6 +1127,19 @@ pub trait Dialect: Debug + Any { false } + /// Returns true if this dialect accepts a colon placeholder as a + /// `SELECT ... INTO` target, e.g. `SELECT ... INTO :var` in Snowflake + /// scripting, where the target is a local variable rather than a table. + fn supports_select_into_placeholder_target(&self) -> bool { + false + } + + /// Returns true if this dialect accepts an `INTO [, ...]` tail after + /// a procedure call, e.g. `CALL p(1) INTO :ret` in Snowflake scripting. + fn supports_call_into(&self) -> bool { + false + } + /// Returns true if this dialect supports `$` as a prefix for money literals /// e.g. `SELECT $123.45` (SQL Server) fn supports_dollar_as_money_prefix(&self) -> bool { @@ -1244,6 +1277,21 @@ pub trait Dialect: Debug + Any { false } + /// Returns true if the dialect accepts the informational constraint properties + /// `{ ENABLE | DISABLE }`, `{ VALIDATE | NOVALIDATE }` and `{ RELY | NORELY }` + /// alongside `DEFERRABLE` / `INITIALLY` / `ENFORCED` in constraint + /// characteristics. + /// + /// Example: + /// ```sql + /// CREATE TABLE t (a INT, CONSTRAINT pk PRIMARY KEY (a) NOT ENFORCED RELY) + /// ``` + /// + /// + fn supports_informational_constraint_properties(&self) -> bool { + false + } + /// Returns true if the dialect supports the `CONSTRAINT` keyword without a name /// in table constraint definitions. /// @@ -1453,6 +1501,20 @@ pub trait Dialect: Debug + Any { false } + /// Returns true if the dialect supports + /// `ALTER TABLE tbl ADD [COLUMN] c1 , ..., cn ` + fn supports_comma_separated_add_column_list(&self) -> bool { + false + } + + /// Returns true if the dialect supports setting a column comment via + /// `ALTER TABLE tbl ALTER COLUMN c COMMENT ''`, including the + /// comma-separated multi-column form + /// `ALTER COLUMN c1 COMMENT '', COLUMN c2 COMMENT ''`. + fn supports_alter_column_comment(&self) -> bool { + false + } + /// Returns true if the dialect considers the specified ident as a function /// that returns an identifier. Typically used to generate identifiers /// programmatically. @@ -2016,6 +2078,10 @@ mod tests { self.0.supports_string_literal_backslash_escape() } + fn supports_snowflake_string_literal_escapes(&self) -> bool { + self.0.supports_snowflake_string_literal_escapes() + } + fn supports_filter_during_aggregation(&self) -> bool { self.0.supports_filter_during_aggregation() } diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index 3df12ccc92..fbf08fcafe 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -27,17 +27,20 @@ use crate::ast::helpers::stmt_data_loading::{ FileStagingCommand, StageLoadSelectItem, StageLoadSelectItemKind, StageParamsObject, }; use crate::ast::{ - AlterExternalVolumeOperation, AlterFileFormatOperation, AlterStageOperation, AlterTable, + AlterExternalVolumeOperation, AlterFileFormatOperation, AlterMaskingPolicyOperation, + AlterProcedure, AlterProcedureOperation, AlterStageOperation, AlterTable, AlterTagOperation, AlterTableOperation, AlterTableType, CatalogRestAuthentication, CatalogRestConfig, CatalogSource, CatalogSyncNamespaceMode, CatalogTableFormat, ColumnOption, ColumnPolicy, ColumnPolicyProperty, ContactEntry, CopyIntoSnowflakeKind, CreateTable, CreateTableLikeKind, - DollarQuotedString, ExternalVolumeEncryption, ExternalVolumeStorageLocation, Ident, + DollarQuotedString, Expr, ExternalVolumeEncryption, ExternalVolumeStorageLocation, + Ident, ProcedureExecuteAs, 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, + MultiTableInsertWhenClause, ObjectName, ObjectNamePart, ObjectType, OperateFunctionArg, + RefreshModeKind, RenameTableNameKind, RowAccessPolicy, ShowKeysKind, ShowObjects, SqlOption, + Statement, StorageLifecyclePolicy, StorageSerializationPolicy, TableObject, Tag, + TagsColumnOption, Value, WrappedCollection, }; use crate::dialect::{Dialect, Precedence}; use crate::keywords::Keyword; @@ -151,6 +154,10 @@ impl Dialect for SnowflakeDialect { true } + fn supports_create_table_optional_column_type(&self) -> bool { + true + } + // Snowflake supports double-dot notation when the schema name is not specified // In this case the default PUBLIC schema is used // @@ -172,6 +179,11 @@ impl Dialect for SnowflakeDialect { true } + // See https://docs.snowflake.com/en/sql-reference/data-types-text#escape-sequences-in-single-quoted-string-constants + fn supports_snowflake_string_literal_escapes(&self) -> bool { + true + } + fn supports_within_after_array_aggregation(&self) -> bool { true } @@ -190,6 +202,18 @@ impl Dialect for SnowflakeDialect { true } + /// Snowflake scripting accepts `SELECT ... INTO :var` where the target is a + /// local variable placeholder rather than a table name. + fn supports_select_into_placeholder_target(&self) -> bool { + true + } + + /// Snowflake scripting accepts `CALL p(...) INTO :var` to capture a + /// procedure result into local variables. + fn supports_call_into(&self) -> bool { + true + } + /// See fn supports_for_loop_over_cursor(&self) -> bool { true @@ -279,11 +303,34 @@ impl Dialect for SnowflakeDialect { return Some(parser.parse_begin_exception_end()); } + // Snowflake scripting `FETCH INTO [, ...]` has no + // direction and no FROM/IN, so it can't go through the ISO parser. + // Intercept only that shape; anything else falls through untouched. + if parser.peek_keyword(Keyword::FETCH) { + if let Ok(Some(stmt)) = parser.maybe_parse(parse_fetch_into) { + return Some(Ok(stmt)); + } + } + + // Snowflake anonymous procedure: `WITH AS PROCEDURE ...`. Every + // other `WITH` (ordinary CTE) fails the `AS PROCEDURE` probe and falls + // through to the standard query parser. + if parser.peek_keyword(Keyword::WITH) { + if let Ok(Some(stmt)) = parser.maybe_parse(parse_with_procedure) { + return Some(Ok(stmt)); + } + } + if parser.parse_keywords(&[Keyword::ALTER, Keyword::DYNAMIC, Keyword::TABLE]) { // ALTER DYNAMIC TABLE return Some(parse_alter_dynamic_table(parser)); } + if parser.parse_keywords(&[Keyword::ALTER, Keyword::MATERIALIZED, Keyword::VIEW]) { + // ALTER MATERIALIZED VIEW + return Some(parse_alter_materialized_view(parser)); + } + if parser.parse_keywords(&[Keyword::ALTER, Keyword::EXTERNAL, Keyword::VOLUME]) { // ALTER EXTERNAL VOLUME return Some(parse_alter_external_volume(parser)); @@ -294,16 +341,62 @@ impl Dialect for SnowflakeDialect { return Some(parse_alter_external_table(parser)); } + if parser.parse_keywords(&[Keyword::ALTER, Keyword::STORAGE, Keyword::INTEGRATION]) { + // ALTER STORAGE INTEGRATION + return Some(parse_alter_storage_integration(parser)); + } + + if parser.parse_keywords(&[Keyword::ALTER, Keyword::PROCEDURE]) { + // ALTER PROCEDURE + return Some(parse_alter_procedure(parser)); + } + if parser.parse_keywords(&[Keyword::ALTER, Keyword::FILE, Keyword::FORMAT]) { // ALTER FILE FORMAT return Some(parse_alter_file_format(parser)); } + if parser.parse_keywords(&[Keyword::ALTER, Keyword::TAG]) { + // ALTER TAG + return Some(parse_alter_tag(parser)); + } + + if parser.parse_keywords(&[Keyword::ALTER, Keyword::DATABASE]) { + // ALTER DATABASE { SET TAG | UNSET TAG } + return Some(parse_alter_object_set_tags(parser, ObjectType::Database)); + } + + // ALTER SCHEMA { SET TAG | UNSET TAG } — intercept only the tag + // form; every other ALTER SCHEMA form (RENAME TO, SET OPTIONS, SET + // DEFAULT COLLATE, ADD/DROP REPLICA, OWNER TO) fails the closure and + // falls through to the generic parse_alter_schema. + if let Ok(Some(stmt)) = parser.maybe_parse(|p| { + p.expect_keywords(&[Keyword::ALTER, Keyword::SCHEMA])?; + parse_alter_object_set_tags(p, ObjectType::Schema) + }) { + return Some(Ok(stmt)); + } + if parser.parse_keywords(&[Keyword::ALTER, Keyword::STAGE]) { // ALTER STAGE return Some(parse_alter_stage(parser)); } + if parser.parse_keywords(&[ + Keyword::ALTER, + Keyword::ROW, + Keyword::ACCESS, + Keyword::POLICY, + ]) { + // ALTER ROW ACCESS POLICY + return Some(parse_alter_row_access_policy(parser)); + } + + if parser.parse_keywords(&[Keyword::ALTER, Keyword::MASKING, Keyword::POLICY]) { + // ALTER MASKING POLICY + return Some(parse_alter_masking_policy(parser)); + } + if parser.parse_keywords(&[Keyword::ALTER, Keyword::SESSION]) { // ALTER SESSION let set = match parser.parse_one_of_keywords(&[Keyword::SET, Keyword::UNSET]) { @@ -324,11 +417,36 @@ impl Dialect for SnowflakeDialect { return Some(parse_drop_catalog_integration(parser)); } + if parser.parse_keywords(&[Keyword::DROP, Keyword::STORAGE, Keyword::INTEGRATION]) { + // DROP STORAGE INTEGRATION + return Some(parse_drop_storage_integration(parser)); + } + if parser.parse_keywords(&[Keyword::DROP, Keyword::FILE, Keyword::FORMAT]) { // DROP FILE FORMAT return Some(parse_drop_file_format(parser)); } + if parser.parse_keywords(&[Keyword::DROP, Keyword::TAG]) { + // DROP TAG + return Some(parse_drop_tag(parser)); + } + + if parser.parse_keywords(&[ + Keyword::DROP, + Keyword::ROW, + Keyword::ACCESS, + Keyword::POLICY, + ]) { + // DROP ROW ACCESS POLICY + return Some(parse_drop_row_access_policy(parser)); + } + + if parser.parse_keywords(&[Keyword::DROP, Keyword::MASKING, Keyword::POLICY]) { + // DROP MASKING POLICY + return Some(parse_drop_masking_policy(parser)); + } + if parser .parse_one_of_keywords(&[Keyword::DESC, Keyword::DESCRIBE]) .is_some() @@ -337,6 +455,10 @@ impl Dialect for SnowflakeDialect { // DESC[RIBE] EXTERNAL VOLUME return Some(parse_describe_external_volume(parser)); } + if parser.parse_keywords(&[Keyword::STORAGE, Keyword::INTEGRATION]) { + // DESC[RIBE] STORAGE INTEGRATION + return Some(parse_describe_storage_integration(parser)); + } if parser.parse_keywords(&[Keyword::FILE, Keyword::FORMAT]) { // DESC[RIBE] FILE FORMAT return Some(parse_describe_file_format(parser)); @@ -345,6 +467,14 @@ impl Dialect for SnowflakeDialect { // DESC[RIBE] WAREHOUSE return Some(parse_describe_warehouse(parser)); } + if parser.parse_keywords(&[Keyword::ROW, Keyword::ACCESS, Keyword::POLICY]) { + // DESC[RIBE] ROW ACCESS POLICY + return Some(parse_describe_row_access_policy(parser)); + } + if parser.parse_keywords(&[Keyword::MASKING, Keyword::POLICY]) { + // DESC[RIBE] MASKING POLICY + return Some(parse_describe_masking_policy(parser)); + } // not handled — put back DESC/DESCRIBE parser.prev_token(); } @@ -364,6 +494,21 @@ impl Dialect for SnowflakeDialect { return Some(parse_create_catalog_integration(or_replace, parser)); } + // CREATE [OR REPLACE] STORAGE INTEGRATION + if parser.parse_keywords(&[Keyword::STORAGE, Keyword::INTEGRATION]) { + return Some(parse_create_storage_integration(or_replace, parser)); + } + + // CREATE [OR REPLACE] ROW ACCESS POLICY + if parser.parse_keywords(&[Keyword::ROW, Keyword::ACCESS, Keyword::POLICY]) { + return Some(parse_create_row_access_policy(or_replace, parser)); + } + + // CREATE [OR REPLACE] MASKING POLICY + if parser.parse_keywords(&[Keyword::MASKING, Keyword::POLICY]) { + return Some(parse_create_masking_policy(or_replace, parser)); + } + // LOCAL | GLOBAL let global = match parser.parse_one_of_keywords(&[Keyword::LOCAL, Keyword::GLOBAL]) { Some(Keyword::LOCAL) => Some(false), @@ -371,13 +516,24 @@ impl Dialect for SnowflakeDialect { _ => None, }; - let dynamic = parser.parse_keyword(Keyword::DYNAMIC); - let mut temporary = false; let mut volatile = false; let mut transient = false; let mut iceberg = false; + // Snowflake allows a leading `TRANSIENT` before `DYNAMIC` + // (`CREATE [OR REPLACE] [TRANSIENT] DYNAMIC [ICEBERG] TABLE`); the + // standalone `TRANSIENT` modifier for plain tables is still handled + // by the modifier group below. + let dynamic = if parser.parse_keyword(Keyword::DYNAMIC) { + true + } else if parser.parse_keywords(&[Keyword::TRANSIENT, Keyword::DYNAMIC]) { + transient = true; + true + } else { + false + }; + match parser.parse_one_of_keywords(&[ Keyword::TEMP, Keyword::TEMPORARY, @@ -392,6 +548,11 @@ impl Dialect for SnowflakeDialect { _ => {} } + // CREATE [OR REPLACE] HYBRID TABLE — the "hybrid" property has no + // observable effect here, so the modifier is discarded and the + // statement is parsed as an ordinary table. + let hybrid = parser.parse_keyword(Keyword::HYBRID); + // CREATE [OR REPLACE] [ TEMP | TEMPORARY | VOLATILE ] FILE FORMAT. // For file formats VOLATILE is a synonym of TEMPORARY. if parser.parse_keywords(&[Keyword::FILE, Keyword::FORMAT]) { @@ -402,6 +563,11 @@ impl Dialect for SnowflakeDialect { )); } + // CREATE [OR REPLACE] TAG + if parser.parse_keyword(Keyword::TAG) { + return Some(parse_create_tag(or_replace, parser)); + } + if parser.parse_keyword(Keyword::STAGE) { // OK - this is CREATE STAGE statement return Some(parse_create_stage(or_replace, temporary, parser)); @@ -414,6 +580,9 @@ impl Dialect for SnowflakeDialect { .map(Into::into), ); } else if parser.parse_keyword(Keyword::DATABASE) { + if parser.parse_keyword(Keyword::ROLE) { + return Some(parser.parse_create_database_role(or_replace)); + } return Some(parse_create_database(or_replace, transient, parser)); } else { // Not a Snowflake-specific CREATE form — rewind the consumed @@ -430,6 +599,9 @@ impl Dialect for SnowflakeDialect { if temporary || volatile || transient || iceberg { back += 1 } + if hybrid { + back += 1 + } for _i in 0..back { parser.prev_token(); } @@ -456,6 +628,9 @@ impl Dialect for SnowflakeDialect { if parser.parse_keywords(&[Keyword::CATALOG, Keyword::INTEGRATIONS]) { return Some(parse_show_catalog_integrations(parser)); } + if parser.parse_keywords(&[Keyword::STORAGE, Keyword::INTEGRATIONS]) { + return Some(parse_show_storage_integrations(parser)); + } if parser.parse_keyword(Keyword::WAREHOUSES) { return Some(parse_show_warehouses(parser)); } @@ -463,8 +638,11 @@ impl Dialect for SnowflakeDialect { return Some(parse_show_accounts(parser)); } let terse = parser.parse_keyword(Keyword::TERSE); + if parser.parse_keywords(&[Keyword::DYNAMIC, Keyword::TABLES]) { + return Some(parse_show_objects(terse, true, parser)); + } if parser.parse_keyword(Keyword::OBJECTS) { - return Some(parse_show_objects(terse, parser)); + return Some(parse_show_objects(terse, false, parser)); } if parser.parse_keywords(&[Keyword::FILE, Keyword::FORMATS]) { return Some(parse_show_file_formats(terse, parser)); @@ -472,6 +650,36 @@ impl Dialect for SnowflakeDialect { if parser.parse_keyword(Keyword::STAGES) { return Some(parse_show_stages(terse, parser)); } + if parser.parse_keyword(Keyword::TAGS) { + return Some(parse_show_tags(terse, parser)); + } + if parser.parse_keyword(Keyword::SEQUENCES) { + return Some(parse_show_sequences(terse, parser)); + } + if parser.parse_keyword(Keyword::PRIMARY) { + return Some(parse_show_keys(ShowKeysKind::Primary, terse, parser)); + } + if parser.parse_keyword(Keyword::IMPORTED) { + return Some(parse_show_keys(ShowKeysKind::Imported, terse, parser)); + } + if parser.parse_keyword(Keyword::EXPORTED) { + return Some(parse_show_keys(ShowKeysKind::Exported, terse, parser)); + } + if parser.parse_keywords(&[Keyword::ROW, Keyword::ACCESS, Keyword::POLICIES]) { + return Some(parse_show_row_access_policies(parser)); + } + if parser.parse_keywords(&[Keyword::MASKING, Keyword::POLICIES]) { + return Some(parse_show_masking_policies(parser)); + } + if parser.parse_keyword(Keyword::PROCEDURES) { + return Some(parse_show_procedures(parser)); + } + if parser.parse_keyword(Keyword::CONNECTIONS) { + return Some(parse_show_connections(parser)); + } + if parser.parse_keyword(Keyword::SHARES) { + return Some(parse_show_shares(parser)); + } //Give back Keyword::TERSE if terse { parser.prev_token(); @@ -734,6 +942,11 @@ impl Dialect for SnowflakeDialect { true } + /// See: + fn supports_informational_constraint_properties(&self) -> bool { + true + } + /// See: fn get_reserved_keywords_for_select_item_operator(&self) -> &[Keyword] { &RESERVED_KEYWORDS_FOR_SELECT_ITEM_OPERATOR @@ -747,6 +960,14 @@ impl Dialect for SnowflakeDialect { true } + fn supports_comma_separated_add_column_list(&self) -> bool { + true + } + + fn supports_alter_column_comment(&self) -> bool { + true + } + fn is_identifier_generating_function_name( &self, ident: &Ident, @@ -830,21 +1051,43 @@ fn parse_file_staging_command(kw: Keyword, parser: &mut Parser) -> Result +/// fn parse_alter_dynamic_table(parser: &mut Parser) -> Result { + let if_exists = parser.parse_keywords(&[Keyword::IF, Keyword::EXISTS]); // Use parse_object_name(true) to support IDENTIFIER() function let table_name = parser.parse_object_name(true)?; - // Parse the operation (REFRESH, SUSPEND, or RESUME) let operation = if parser.parse_keyword(Keyword::REFRESH) { AlterTableOperation::Refresh { subpath: None } } else if parser.parse_keyword(Keyword::SUSPEND) { AlterTableOperation::Suspend } else if parser.parse_keyword(Keyword::RESUME) { AlterTableOperation::Resume + } else if parser.parse_keyword(Keyword::RENAME) { + parser.expect_keyword_is(Keyword::TO)?; + let new_name = parser.parse_object_name(false)?; + AlterTableOperation::RenameTable { + table_name: RenameTableNameKind::To(new_name), + } + } else if parser.parse_keywords(&[Keyword::CLUSTER, Keyword::BY]) { + parser.expect_token(&Token::LParen)?; + let exprs = parser.parse_comma_separated(|p| p.parse_expr())?; + parser.expect_token(&Token::RParen)?; + AlterTableOperation::ClusterBy { exprs } + } else if parser.parse_keywords(&[Keyword::DROP, Keyword::CLUSTERING, Keyword::KEY]) { + AlterTableOperation::DropClusteringKey + } else if parser.parse_keyword(Keyword::SET) { + AlterTableOperation::SetOptionsParens { + options: parse_alter_dynamic_table_properties(parser, false)?, + } + } else if parser.parse_keyword(Keyword::UNSET) { + AlterTableOperation::SetOptionsParens { + options: parse_alter_dynamic_table_properties(parser, true)?, + } } else { return parser.expected_ref( - "REFRESH, SUSPEND, or RESUME after ALTER DYNAMIC TABLE", + "REFRESH, SUSPEND, RESUME, RENAME, SET, UNSET, CLUSTER BY, \ + or DROP CLUSTERING KEY after ALTER DYNAMIC TABLE", parser.peek_token_ref(), ); }; @@ -857,7 +1100,7 @@ fn parse_alter_dynamic_table(parser: &mut Parser) -> Result Result` is encoded as ` = NULL`. +fn parse_alter_dynamic_table_properties( + parser: &mut Parser, + unset: bool, +) -> Result, ParserError> { + let mut options = vec![parse_alter_dynamic_table_property(parser, unset)?]; + loop { + let _ = parser.consume_token(&Token::Comma); + if matches!(parser.peek_token().token, Token::EOF | Token::SemiColon) { + break; + } + options.push(parse_alter_dynamic_table_property(parser, unset)?); + } + Ok(options) +} + +/// Parse one `ALTER DYNAMIC TABLE … SET/UNSET` property into an +/// [`SqlOption::KeyValue`]. See [`parse_alter_dynamic_table_properties`]. +fn parse_alter_dynamic_table_property( + parser: &mut Parser, + unset: bool, +) -> Result { + let key_token = parser.next_token(); + let key = match &key_token.token { + Token::Word(w) if w.quote_style.is_none() => w.value.to_uppercase(), + _ => return parser.expected("a dynamic table property name", key_token), + }; + + if key == "IMMUTABLE" { + parser.expect_keyword_is(Keyword::WHERE)?; + let value = if unset { + Value::Null + } else { + parser.expect_token(&Token::LParen)?; + let predicate = parser.parse_expr()?; + parser.expect_token(&Token::RParen)?; + Value::SingleQuotedString(predicate.to_string()) + }; + return Ok(SqlOption::KeyValue { + key: Ident::new("IMMUTABLE_WHERE"), + value: Expr::Value(value.into()), + }); + } + + if unset { + if !matches!( + key.as_str(), + "COMMENT" | "INITIALIZATION_WAREHOUSE" | "SCHEDULER" + ) { + return parser.expected( + "COMMENT, INITIALIZATION_WAREHOUSE, SCHEDULER, or IMMUTABLE WHERE after UNSET", + key_token, + ); + } + return Ok(SqlOption::KeyValue { + key: Ident::new(key), + value: Expr::Value(Value::Null.into()), + }); + } + + if !matches!( + key.as_str(), + "TARGET_LAG" | "WAREHOUSE" | "INITIALIZATION_WAREHOUSE" | "COMMENT" | "SCHEDULER" + ) { + return parser.expected("a dynamic table property name", key_token); + } + parser.expect_token(&Token::Eq)?; + let value_token = parser.next_token(); + let value = match &value_token.token { + Token::SingleQuotedString(s) => s.clone(), + // WAREHOUSE / INITIALIZATION_WAREHOUSE keep the user's verbatim + // spelling; other keyword values (bare DOWNSTREAM) are uppercased. + Token::Word(w) + if w.quote_style.is_none() + && key != "WAREHOUSE" + && key != "INITIALIZATION_WAREHOUSE" => + { + w.value.to_uppercase() + } + Token::Word(w) if w.quote_style.is_none() => w.value.clone(), + _ => return parser.expected("a property value", value_token), + }; + Ok(SqlOption::KeyValue { + key: Ident::new(key), + value: Expr::Value(Value::SingleQuotedString(value).into()), + }) +} + +/// Parse Snowflake scripting `FETCH INTO [, ...]`. +/// +/// The caller has verified the next keyword is `FETCH` and runs this via +/// `maybe_parse`, so a non-scripting `FETCH` simply errors out and rewinds. +fn parse_fetch_into(parser: &mut Parser) -> Result { + parser.expect_keyword(Keyword::FETCH)?; + let cursor = parser.parse_identifier()?; + parser.expect_keyword(Keyword::INTO)?; + let into = parser.parse_scripting_into_targets()?; + Ok(Statement::FetchInto { cursor, into }) +} + +/// Parse `ALTER PROCEDURE [IF EXISTS] ( [ [, ...]] ) +/// { RENAME TO ... | SET COMMENT = ... | UNSET COMMENT | EXECUTE AS CALLER|OWNER }`. +/// +/// The `ALTER PROCEDURE` keywords are already consumed by the caller. +fn parse_alter_procedure(parser: &mut Parser) -> Result { + let if_exists = parser.parse_keywords(&[Keyword::IF, Keyword::EXISTS]); + let name = parser.parse_object_name(false)?; + + parser.expect_token(&Token::LParen)?; + let args = if parser.peek_token_ref().token == Token::RParen { + vec![] + } else { + parser.parse_comma_separated(Parser::parse_data_type)? + }; + parser.expect_token(&Token::RParen)?; + + let operation = if parser.parse_keywords(&[Keyword::RENAME, Keyword::TO]) { + AlterProcedureOperation::RenameTo { + new_name: parser.parse_object_name(false)?, + } + } else if parser.parse_keywords(&[Keyword::EXECUTE, Keyword::AS]) { + let execute_as = if parser.parse_keyword(Keyword::CALLER) { + ProcedureExecuteAs::Caller + } else { + parser.expect_keyword_is(Keyword::OWNER)?; + ProcedureExecuteAs::Owner + }; + AlterProcedureOperation::ExecuteAs(execute_as) + } else if parser.parse_keyword(Keyword::SET) { + parser.expect_keyword_is(Keyword::COMMENT)?; + parser.expect_token(&Token::Eq)?; + AlterProcedureOperation::SetComment { + comment: parser.parse_expr()?, + } + } else if parser.parse_keyword(Keyword::UNSET) { + parser.expect_keyword_is(Keyword::COMMENT)?; + AlterProcedureOperation::UnsetComment + } else { + return parser.expected_ref( + "RENAME TO, SET COMMENT, UNSET COMMENT, or EXECUTE AS after ALTER PROCEDURE", + parser.peek_token_ref(), + ); + }; + + Ok(Statement::AlterProcedure(AlterProcedure { + if_exists, + name, + args, + operation, + })) +} + +/// Parse Snowflake anonymous procedure: +/// `WITH AS PROCEDURE () RETURNS LANGUAGE +/// [EXECUTE AS ...] AS CALL ()`. +/// +/// The caller runs this via `maybe_parse`, so an ordinary CTE fails the +/// `AS PROCEDURE` probe and rewinds. +fn parse_with_procedure(parser: &mut Parser) -> Result { + parser.expect_keyword(Keyword::WITH)?; + let name = parser.parse_identifier()?; + parser.expect_keyword_is(Keyword::AS)?; + parser.expect_keyword_is(Keyword::PROCEDURE)?; + + let params = parser.parse_optional_procedure_parameters()?; + + let returns = if parser.parse_keyword(Keyword::RETURNS) { + Some(parser.parse_data_type()?) + } else { + None + }; + // Snowflake allows a `NOT NULL` return-type annotation; drop it. + let _ = parser.parse_keywords(&[Keyword::NOT, Keyword::NULL]); + + let language = if parser.parse_keyword(Keyword::LANGUAGE) { + Some(parser.parse_identifier()?) + } else { + None + }; + + let execute_as = if parser.parse_keywords(&[Keyword::EXECUTE, Keyword::AS]) { + if parser.parse_keyword(Keyword::CALLER) { + Some(ProcedureExecuteAs::Caller) + } else { + parser.expect_keyword_is(Keyword::OWNER)?; + Some(ProcedureExecuteAs::Owner) + } + } else { + None + }; + + parser.expect_keyword_is(Keyword::AS)?; + let body = parser.parse_procedure_body()?; + + parser.expect_keyword(Keyword::CALL)?; + let call = Box::new(parser.parse_call()?); + + Ok(Statement::WithProcedure { + name, + params, + returns, + language, + execute_as, + body, + call, + }) +} + +/// Parse snowflake alter materialized view. +/// +/// +/// Every clause except `RENAME TO` is an accept-and-no-op downstream; only the +/// object identity and, for `RENAME TO`, the new name carry meaning. The +/// operation is tagged [`AlterTableType::MaterializedView`] so the emulator can +/// route it to the view machinery. +fn parse_alter_materialized_view(parser: &mut Parser) -> Result { + let if_exists = parser.parse_keywords(&[Keyword::IF, Keyword::EXISTS]); + let name = parser.parse_object_name(true)?; + + let operation = if parser.parse_keyword(Keyword::RENAME) { + parser.expect_keyword_is(Keyword::TO)?; + let new_name = parser.parse_object_name(false)?; + AlterTableOperation::RenameTable { + table_name: RenameTableNameKind::To(new_name), + } + } else if parser.parse_keywords(&[Keyword::CLUSTER, Keyword::BY]) { + parser.expect_token(&Token::LParen)?; + let exprs = parser.parse_comma_separated(|p| p.parse_expr())?; + parser.expect_token(&Token::RParen)?; + AlterTableOperation::ClusterBy { exprs } + } else if parser.parse_keywords(&[Keyword::DROP, Keyword::CLUSTERING, Keyword::KEY]) { + AlterTableOperation::DropClusteringKey + } else if parser.parse_keyword(Keyword::SUSPEND) { + let _ = parser.parse_keyword(Keyword::RECLUSTER); + AlterTableOperation::Suspend + } else if parser.parse_keyword(Keyword::RESUME) { + let _ = parser.parse_keyword(Keyword::RECLUSTER); + AlterTableOperation::Resume + } else if parser.parse_keyword(Keyword::SET) { + AlterTableOperation::SetOptionsParens { + options: parse_alter_materialized_view_properties(parser, false)?, + } + } else if parser.parse_keyword(Keyword::UNSET) { + AlterTableOperation::SetOptionsParens { + options: parse_alter_materialized_view_properties(parser, true)?, + } + } else { + return parser.expected_ref( + "RENAME, CLUSTER BY, DROP CLUSTERING KEY, SUSPEND, RESUME, SET, \ + or UNSET after ALTER MATERIALIZED VIEW", + parser.peek_token_ref(), + ); + }; + + let end_token = if parser.peek_token_ref().token == Token::SemiColon { + parser.peek_token_ref().clone() + } else { + parser.get_current_token().clone() + }; + + Ok(Statement::AlterTable(AlterTable { + name, + if_exists, + only: false, + operations: vec![operation], + location: None, + on_cluster: None, + table_type: Some(AlterTableType::MaterializedView), + end_token: AttachedToken(end_token), + })) +} + +/// Parse the comma-separated property list of `ALTER MATERIALIZED VIEW … +/// SET/UNSET { SECURE | COMMENT | CONTACT | DATA_METRIC_SCHEDULE }`. The list is +/// only preserved for round-tripping; the emulator treats every property as a +/// no-op. +fn parse_alter_materialized_view_properties( + parser: &mut Parser, + unset: bool, +) -> Result, ParserError> { + let mut options = vec![parse_alter_materialized_view_property(parser, unset)?]; + while parser.consume_token(&Token::Comma) { + options.push(parse_alter_materialized_view_property(parser, unset)?); + } + Ok(options) +} + +/// Parse one `SET`/`UNSET` property. `SECURE` is a bare flag; `CONTACT` takes a +/// `purpose[= contact]` pair; everything else takes `= ` on SET. +fn parse_alter_materialized_view_property( + parser: &mut Parser, + unset: bool, +) -> Result { + let key_token = parser.next_token(); + let key = match &key_token.token { + Token::Word(w) => w.value.to_uppercase(), + _ => return parser.expected("a materialized view property name", key_token), + }; + + let value = if key == "SECURE" { + Expr::Value(Value::Boolean(!unset).into()) + } else if key == "CONTACT" { + let purpose = parser.parse_identifier()?; + if unset { + Expr::Value(Value::SingleQuotedString(purpose.value).into()) + } else { + parser.expect_token(&Token::Eq)?; + let contact = parser.parse_object_name(false)?; + Expr::Value(Value::SingleQuotedString(format!("{}={contact}", purpose.value)).into()) + } + } else if unset { + Expr::Value(Value::Null.into()) + } else { + parser.expect_token(&Token::Eq)?; + parser.parse_expr()? + }; + + Ok(SqlOption::KeyValue { + key: Ident::new(key), + value, + }) +} + /// Parse snowflake alter external table. /// fn parse_alter_external_table(parser: &mut Parser) -> Result { @@ -1008,6 +1579,11 @@ pub fn parse_create_table( parser.expect_token(&Token::Eq)?; builder = builder.change_tracking(Some(parser.parse_boolean_string()?)); } + Keyword::STAGE_FILE_FORMAT => { + parser.expect_token(&Token::Eq)?; + let options = parser.parse_key_value_options(true, &[], false)?; + builder = builder.stage_file_format(Some(options)); + } Keyword::DATA_RETENTION_TIME_IN_DAYS => { parser.expect_token(&Token::Eq)?; let data_retention_time_in_days = parser.parse_literal_uint()?; @@ -1083,6 +1659,14 @@ pub fn parse_create_table( parser.expect_token(&Token::Eq)?; builder.catalog = Some(parser.parse_literal_string()?); } + Keyword::CATALOG_TABLE_NAME => { + parser.expect_token(&Token::Eq)?; + builder.catalog_table_name = Some(parser.parse_literal_string()?); + } + Keyword::AUTO_REFRESH => { + parser.expect_token(&Token::Eq)?; + builder.auto_refresh = Some(parser.parse_boolean_string()?); + } Keyword::BASE_LOCATION => { parser.expect_token(&Token::Eq)?; builder.base_location = Some(parser.parse_literal_string()?); @@ -1102,13 +1686,43 @@ pub fn parse_create_table( } Keyword::TARGET_LAG => { parser.expect_token(&Token::Eq)?; - let target_lag = parser.parse_literal_string()?; + // TARGET_LAG accepts a quoted duration ('1 minute') or the + // bare keyword DOWNSTREAM. + let target_lag = if parser.parse_keyword(Keyword::DOWNSTREAM) { + "DOWNSTREAM".to_string() + } else { + parser.parse_literal_string()? + }; builder = builder.target_lag(Some(target_lag)); } Keyword::WAREHOUSE => { parser.expect_token(&Token::Eq)?; let warehouse = parser.parse_identifier()?; - builder = builder.warehouse(Some(warehouse)); + builder = builder.warehouse(Some(warehouse.value)); + } + Keyword::INITIALIZATION_WAREHOUSE => { + parser.expect_token(&Token::Eq)?; + let warehouse = parser.parse_identifier()?; + builder = builder.initialization_warehouse(Some(warehouse.value)); + } + Keyword::SCHEDULER => { + parser.expect_token(&Token::Eq)?; + // SCHEDULER accepts a quoted string ('DISABLE') or a bare + // keyword (the GET_DDL spelling, e.g. DISABLE). + let value_token = parser.next_token(); + let scheduler = match &value_token.token { + Token::SingleQuotedString(s) => s.clone(), + Token::Word(w) => w.value.clone(), + _ => return parser.expected("a scheduler value", value_token), + }; + builder = builder.scheduler(Some(scheduler)); + } + Keyword::IMMUTABLE => { + parser.expect_keyword_is(Keyword::WHERE)?; + parser.expect_token(&Token::LParen)?; + let predicate = parser.parse_expr()?; + parser.expect_token(&Token::RParen)?; + builder = builder.immutable_where(Some(predicate.to_string())); } Keyword::AT | Keyword::BEFORE => { parser.prev_token(); @@ -1152,6 +1766,8 @@ pub fn parse_create_table( let (columns, constraints) = parser.parse_columns()?; builder = builder.columns(columns).constraints(constraints); } + // Snowflake accepts Iceberg table options either space- or comma-separated. + Token::Comma => {} Token::EOF => { break; } @@ -1172,7 +1788,18 @@ pub fn parse_create_table( builder = builder.table_options(table_options); - if iceberg && builder.base_location.is_none() { + // Snowflake-managed Iceberg tables require BASE_LOCATION. Tables bound to + // an external catalog integration (an explicit non-SNOWFLAKE CATALOG, or + // CATALOG_TABLE_NAME for externally-managed reads) do not. + let external_catalog = builder + .catalog + .as_deref() + .is_some_and(|c| !c.eq_ignore_ascii_case("SNOWFLAKE")); + if iceberg + && builder.base_location.is_none() + && builder.catalog_table_name.is_none() + && !external_catalog + { return Err(ParserError::ParserError( "BASE_LOCATION is required for ICEBERG tables".to_string(), )); @@ -1385,17 +2012,17 @@ fn parse_stage_properties(parser: &mut Parser) -> Result` // is sugar for `FILE_FORMAT = (FORMAT_NAME = )` — @@ -1415,7 +2042,7 @@ fn parse_stage_properties(parser: &mut Parser) -> Result Result { + parser.prev_token(); + break; + } Token::AtSign => ident.push('@'), Token::Tilde => ident.push('~'), Token::Mod => ident.push('%'), @@ -1528,6 +2159,10 @@ pub fn parse_snowflake_stage_name(parser: &mut Parser) -> Result Ok(ObjectName::from(vec![Ident::new(s)])), _ => { parser.prev_token(); Ok(parser.parse_object_name(false)?) @@ -1550,6 +2185,7 @@ pub fn parse_copy_into(parser: &mut Parser) -> Result { let mut from_transformations: Option> = None; let mut from_stage_alias = None; let mut from_stage = None; + let mut from_stage_args = None; let mut stage_params = StageParamsObject { url: None, encryption: KeyValueOptions { @@ -1589,6 +2225,11 @@ pub fn parse_copy_into(parser: &mut Parser) -> Result { parser.expect_keyword_is(Keyword::FROM)?; from_stage = Some(parse_snowflake_stage_name(parser)?); + // Inline stage table-function args (querying-stage syntax): + // `@stage (FILE_FORMAT => …, PATTERN => …)`. + if parser.consume_token(&Token::LParen) { + from_stage_args = Some(parser.parse_table_function_args()?); + } stage_params = parse_stage_params(parser)?; // Parse an optional alias @@ -1623,7 +2264,40 @@ pub fn parse_copy_into(parser: &mut Parser) -> Result { // FILE_FORMAT if parser.parse_keyword(Keyword::FILE_FORMAT) { parser.expect_token(&Token::Eq)?; - file_format = parser.parse_key_value_options(true, &[])?.options; + if parser.peek_token().token == Token::LParen { + let paren_span = parser.peek_token().span; + file_format = parser.parse_key_value_options(true, &[], false)?.options; + if file_format.is_empty() { + // Snowflake parses an empty `FILE_FORMAT = ()` as a reference + // to its internal empty-constant-list token and then resolves + // that as a format name, so the clause fails with + // "File format 'TOK_CONSTANT_LIST' does not exist". Mirror + // that by lowering `()` to `FORMAT_NAME = TOK_CONSTANT_LIST`. + file_format = vec![KeyValueOption { + option_name: "FORMAT_NAME".to_string(), + option_value: KeyValueOptionKind::Single( + Value::Placeholder("TOK_CONSTANT_LIST".to_string()) + .with_span(paren_span), + ), + }]; + } + } else { + // Shorthand `FILE_FORMAT = ''` / `FILE_FORMAT = ` + // is sugar for `FILE_FORMAT = (FORMAT_NAME = )` — + // normalize it (mirrors CREATE STAGE). + let tok = parser.peek_token(); + let value = match tok.token { + Token::Word(w) => { + parser.next_token(); + Value::Placeholder(w.value.clone()).with_span(tok.span) + } + _ => parser.parse_value()?, + }; + file_format = vec![KeyValueOption { + option_name: "FORMAT_NAME".to_string(), + option_value: KeyValueOptionKind::Single(value), + }]; + } // PARTITION BY } else if parser.parse_keywords(&[Keyword::PARTITION, Keyword::BY]) { partition = Some(Box::new(parser.parse_expr()?)) @@ -1661,14 +2335,21 @@ pub fn parse_copy_into(parser: &mut Parser) -> Result { // COPY OPTIONS } else if parser.parse_keyword(Keyword::COPY_OPTIONS) { parser.expect_token(&Token::Eq)?; - copy_options = parser.parse_key_value_options(true, &[])?.options; + copy_options = parser.parse_key_value_options(true, &[], false)?.options; } else { match parser.next_token().token { - Token::SemiColon | Token::EOF => break, + // Leave the statement terminator for the caller: inside a + // `BEGIN … END` body the surrounding statement list expects to + // consume the `;` itself. + Token::SemiColon => { + parser.prev_token(); + break; + } + Token::EOF => break, Token::Comma => continue, // In `COPY INTO ` the copy options do not have a shared key // like in `COPY INTO

` - Token::Word(key) => copy_options.push(parser.parse_key_value_option(&key)?), + Token::Word(key) => copy_options.push(parser.parse_key_value_option(&key, false)?), _ => { return parser .expected_ref("another copy option, ; or EOF'", parser.peek_token_ref()) @@ -1683,6 +2364,7 @@ pub fn parse_copy_into(parser: &mut Parser) -> Result { into_columns, from_obj: from_stage, from_obj_alias: from_stage_alias, + from_obj_args: from_stage_args, stage_params, from_transformations, from_query, @@ -1784,6 +2466,16 @@ fn parse_select_item_for_data_load( }?); } + // The data-load item grammar only covers [.]$[:] [AS ]. + // If the item was not fully consumed here — e.g. a dotted sub-path (`$1:a.b`) or a + // `::TYPE` cast follows — roll back by failing so the caller's general select-item + // fallback parses the extended shape. Items are separated by `,` and terminated by + // the trailing `FROM @stage`, so anything else means the item continues. + if !matches!(parser.peek_token_ref().token, Token::Comma) && !parser.peek_keyword(Keyword::FROM) + { + return parser.expected_ref("end of data load select item", parser.peek_token_ref()); + } + Ok(StageLoadSelectItem { alias, file_col_num, @@ -1831,7 +2523,7 @@ fn parse_stage_params(parser: &mut Parser) -> Result Result { parser.advance_token(); if set { - let option = parser.parse_key_value_option(&key)?; + let option = parser.parse_key_value_option(&key, false)?; options.push(option); } else { options.push(KeyValueOption { @@ -1981,10 +2673,15 @@ fn parse_column_tags(parser: &mut Parser, with: bool) -> Result -fn parse_show_objects(terse: bool, parser: &mut Parser) -> Result { +fn parse_show_objects( + terse: bool, + dynamic: bool, + parser: &mut Parser, +) -> Result { let show_options = parser.parse_show_stmt_options()?; Ok(Statement::ShowObjects(ShowObjects { terse, + dynamic, show_options, })) } @@ -2411,7 +3108,7 @@ fn parse_create_file_format( if matches!(parser.peek_token().token, Token::EOF | Token::SemiColon) { break; } - let parsed = parser.parse_key_value_options(false, &[Keyword::COMMENT])?; + let parsed = parser.parse_key_value_options(false, &[Keyword::COMMENT], false)?; if parsed.options.is_empty() { break; } @@ -2446,7 +3143,7 @@ fn parse_alter_file_format(parser: &mut Parser) -> Result Result +/// [ ALLOWED_VALUES '' [ , ... ] ] [ COMMENT = '' ]` +fn parse_create_tag(or_replace: bool, parser: &mut Parser) -> Result { + let if_not_exists = parser.parse_keywords(&[Keyword::IF, Keyword::NOT, Keyword::EXISTS]); + let name = parser.parse_object_name(false)?; + + let mut allowed_values = Vec::new(); + if parser.parse_keyword(Keyword::ALLOWED_VALUES) { + loop { + allowed_values.push(parser.parse_literal_string()?); + if !parser.consume_token(&Token::Comma) { + break; + } + } + } + + let comment = if parser.parse_keyword(Keyword::COMMENT) { + parser.expect_token(&Token::Eq)?; + Some(parser.parse_comment_value()?) + } else { + None + }; + + Ok(Statement::CreateTag { + or_replace, + if_not_exists, + name, + allowed_values, + comment, + }) +} + +/// Parse a comma-separated list of `MASKING POLICY ` items, where the +/// `MASKING POLICY` keywords are repeated before each policy name. +fn parse_masking_policy_list(parser: &mut Parser) -> Result, ParserError> { + let mut policies = Vec::new(); + loop { + parser.expect_keywords(&[Keyword::MASKING, Keyword::POLICY])?; + policies.push(parser.parse_object_name(false)?); + if !parser.consume_token(&Token::Comma) { + break; + } + } + Ok(policies) +} + +/// Parse `ALTER TAG [IF EXISTS] { RENAME TO +/// | SET MASKING POLICY

[, MASKING POLICY

...] [FORCE] +/// | UNSET MASKING POLICY

[, MASKING POLICY

...] }` +fn parse_alter_tag(parser: &mut Parser) -> Result { + let if_exists = parser.parse_keywords(&[Keyword::IF, Keyword::EXISTS]); + let name = parser.parse_object_name(false)?; + let operation = if parser.parse_keyword(Keyword::SET) { + let policies = parse_masking_policy_list(parser)?; + let force = parser.parse_keyword(Keyword::FORCE); + AlterTagOperation::SetMaskingPolicy { policies, force } + } else if parser.parse_keyword(Keyword::UNSET) { + let policies = parse_masking_policy_list(parser)?; + AlterTagOperation::UnsetMaskingPolicy { policies } + } else { + parser.expect_keywords(&[Keyword::RENAME, Keyword::TO])?; + let new_name = parser.parse_object_name(false)?; + AlterTagOperation::RenameTo { new_name } + }; + Ok(Statement::AlterTag { + if_exists, + name, + operation, + }) +} + +/// Parse `DROP TAG [IF EXISTS] ` +fn parse_drop_tag(parser: &mut Parser) -> Result { + let if_exists = parser.parse_keywords(&[Keyword::IF, Keyword::EXISTS]); + let name = parser.parse_object_name(false)?; + Ok(Statement::DropTag { name, if_exists }) +} + +/// Parse the tail of `ALTER { SET TAG = '' [, ...] +/// | UNSET TAG [, ...] }` after the object-type keyword has been consumed. +fn parse_alter_object_set_tags( + parser: &mut Parser, + object_type: ObjectType, +) -> Result { + let object_name = parser.parse_object_name(false)?; + let unset = match parser.expect_one_of_keywords(&[Keyword::SET, Keyword::UNSET])? { + Keyword::UNSET => true, + _ => false, + }; + parser.expect_keyword(Keyword::TAG)?; + + let mut set_tags = Vec::new(); + let mut unset_tags = Vec::new(); + if unset { + loop { + unset_tags.push(parser.parse_object_name(false)?); + if !parser.consume_token(&Token::Comma) { + break; + } + } + } else { + loop { + let key = parser.parse_object_name(false)?; + parser.expect_token(&Token::Eq)?; + let value = parser.parse_literal_string()?; + set_tags.push(Tag::new(key, value)); + if !parser.consume_token(&Token::Comma) { + break; + } + } + } + + Ok(Statement::SetTags { + object_type, + object_name, + unset, + set_tags, + unset_tags, + }) +} + +/// Parse `SHOW [TERSE] TAGS [ ... ]` +fn parse_show_tags(terse: bool, parser: &mut Parser) -> Result { + let show_options = parser.parse_show_stmt_options()?; + Ok(Statement::ShowTags { + terse, + show_options, + }) +} + +/// Parse `SHOW [TERSE] SEQUENCES [ ... ]` +fn parse_show_sequences(terse: bool, parser: &mut Parser) -> Result { + let show_options = parser.parse_show_stmt_options()?; + Ok(Statement::ShowSequences { + terse, + show_options, + }) +} + +/// Parse `SHOW [TERSE] { PRIMARY | IMPORTED | EXPORTED } KEYS [ ... ]` +fn parse_show_keys( + kind: ShowKeysKind, + terse: bool, + parser: &mut Parser, +) -> Result { + parser.expect_keyword(Keyword::KEYS)?; + let show_options = parser.parse_show_stmt_options()?; + Ok(Statement::ShowKeys { + kind, + terse, + show_options, + }) +} + /// Parse `DESC[RIBE] WAREHOUSE ` fn parse_describe_warehouse(parser: &mut Parser) -> Result { let name = parser.parse_object_name(false)?; Ok(Statement::DescribeWarehouse { name }) } +/// Parse `CREATE [OR REPLACE] ROW ACCESS POLICY [IF NOT EXISTS] +/// AS ( [, ...]) RETURNS -> ` +fn parse_create_row_access_policy( + or_replace: bool, + parser: &mut Parser, +) -> Result { + let if_not_exists = parser.parse_keywords(&[Keyword::IF, Keyword::NOT, Keyword::EXISTS]); + let name = parser.parse_object_name(false)?; + parser.expect_keyword_is(Keyword::AS)?; + parser.expect_token(&Token::LParen)?; + let args = parser.parse_comma_separated(|p| { + let arg_name = p.parse_identifier()?; + let data_type = p.parse_data_type()?; + Ok(OperateFunctionArg { + mode: None, + name: Some(arg_name), + data_type, + default_expr: None, + }) + })?; + parser.expect_token(&Token::RParen)?; + parser.expect_keyword_is(Keyword::RETURNS)?; + let return_type = parser.parse_data_type()?; + parser.expect_token(&Token::Arrow)?; + let policy_expr = parser.parse_expr()?; + Ok(Statement::CreateRowAccessPolicy { + or_replace, + if_not_exists, + name, + args, + return_type, + policy_expr, + }) +} + +/// Parse `ALTER ROW ACCESS POLICY [IF EXISTS] RENAME TO ` +fn parse_alter_row_access_policy(parser: &mut Parser) -> Result { + let if_exists = parser.parse_keywords(&[Keyword::IF, Keyword::EXISTS]); + let name = parser.parse_object_name(false)?; + parser.expect_keywords(&[Keyword::RENAME, Keyword::TO])?; + let new_name = parser.parse_object_name(false)?; + Ok(Statement::AlterRowAccessPolicy { + if_exists, + name, + new_name, + }) +} + +/// Parse `DROP ROW ACCESS POLICY [IF EXISTS] ` +fn parse_drop_row_access_policy(parser: &mut Parser) -> Result { + let if_exists = parser.parse_keywords(&[Keyword::IF, Keyword::EXISTS]); + let name = parser.parse_object_name(false)?; + Ok(Statement::DropRowAccessPolicy { if_exists, name }) +} + +/// Parse `DESC[RIBE] ROW ACCESS POLICY ` +fn parse_describe_row_access_policy(parser: &mut Parser) -> Result { + let name = parser.parse_object_name(false)?; + Ok(Statement::DescribeRowAccessPolicy { name }) +} + +/// Parse `SHOW ROW ACCESS POLICIES [LIKE '']` +fn parse_show_row_access_policies(parser: &mut Parser) -> Result { + let filter = parser.parse_show_statement_filter()?; + Ok(Statement::ShowRowAccessPolicies { filter }) +} + +/// Parse `CREATE [OR REPLACE] MASKING POLICY [IF NOT EXISTS] +/// AS ( [, ...]) RETURNS -> [COMMENT = '']` +fn parse_create_masking_policy( + or_replace: bool, + parser: &mut Parser, +) -> Result { + let if_not_exists = parser.parse_keywords(&[Keyword::IF, Keyword::NOT, Keyword::EXISTS]); + let name = parser.parse_object_name(false)?; + parser.expect_keyword_is(Keyword::AS)?; + parser.expect_token(&Token::LParen)?; + let args = parser.parse_comma_separated(|p| { + let arg_name = p.parse_identifier()?; + let data_type = p.parse_data_type()?; + Ok(OperateFunctionArg { + mode: None, + name: Some(arg_name), + data_type, + default_expr: None, + }) + })?; + parser.expect_token(&Token::RParen)?; + parser.expect_keyword_is(Keyword::RETURNS)?; + let return_type = parser.parse_data_type()?; + parser.expect_token(&Token::Arrow)?; + let policy_expr = parser.parse_expr()?; + let comment = if parser.parse_keyword(Keyword::COMMENT) { + parser.expect_token(&Token::Eq)?; + Some(parser.parse_comment_value()?) + } else { + None + }; + Ok(Statement::CreateMaskingPolicy { + or_replace, + if_not_exists, + name, + args, + return_type, + policy_expr, + comment, + }) +} + +/// Parse `ALTER MASKING POLICY [IF EXISTS] +/// { SET BODY -> | RENAME TO | SET COMMENT = '' | UNSET COMMENT }` +fn parse_alter_masking_policy(parser: &mut Parser) -> Result { + let if_exists = parser.parse_keywords(&[Keyword::IF, Keyword::EXISTS]); + let name = parser.parse_object_name(false)?; + let operation = if parser.parse_keywords(&[Keyword::SET, Keyword::BODY]) { + parser.expect_token(&Token::Arrow)?; + AlterMaskingPolicyOperation::SetBody { + body: parser.parse_expr()?, + } + } else if parser.parse_keywords(&[Keyword::RENAME, Keyword::TO]) { + AlterMaskingPolicyOperation::RenameTo { + new_name: parser.parse_object_name(false)?, + } + } else if parser.parse_keywords(&[Keyword::SET, Keyword::COMMENT]) { + parser.expect_token(&Token::Eq)?; + AlterMaskingPolicyOperation::SetComment { + comment: parser.parse_comment_value()?, + } + } else if parser.parse_keywords(&[Keyword::UNSET, Keyword::COMMENT]) { + AlterMaskingPolicyOperation::UnsetComment + } else { + return parser.expected_ref( + "SET BODY, RENAME TO, SET COMMENT, or UNSET COMMENT", + parser.peek_token_ref(), + ); + }; + Ok(Statement::AlterMaskingPolicy { + if_exists, + name, + operation, + }) +} + +/// Parse `DROP MASKING POLICY [IF EXISTS] ` +fn parse_drop_masking_policy(parser: &mut Parser) -> Result { + let if_exists = parser.parse_keywords(&[Keyword::IF, Keyword::EXISTS]); + let name = parser.parse_object_name(false)?; + Ok(Statement::DropMaskingPolicy { if_exists, name }) +} + +/// Parse `DESC[RIBE] MASKING POLICY ` +fn parse_describe_masking_policy(parser: &mut Parser) -> Result { + let name = parser.parse_object_name(false)?; + Ok(Statement::DescribeMaskingPolicy { name }) +} + +/// Parse `SHOW MASKING POLICIES [LIKE ''] [IN ]` +fn parse_show_masking_policies(parser: &mut Parser) -> Result { + let show_options = parser.parse_show_stmt_options()?; + Ok(Statement::ShowMaskingPolicies { show_options }) +} + +/// Parse `SHOW PROCEDURES [LIKE ''] [IN ]` +fn parse_show_procedures(parser: &mut Parser) -> Result { + let show_options = parser.parse_show_stmt_options()?; + Ok(Statement::ShowProcedures { show_options }) +} + /// Parse `SHOW WAREHOUSES [LIKE '']` fn parse_show_warehouses(parser: &mut Parser) -> Result { let filter = parser.parse_show_statement_filter()?; Ok(Statement::ShowWarehouses { filter }) } +/// Parse `SHOW CONNECTIONS [LIKE '']` +fn parse_show_connections(parser: &mut Parser) -> Result { + let filter = parser.parse_show_statement_filter()?; + Ok(Statement::ShowConnections { filter }) +} + +/// Parse `SHOW SHARES [LIKE '']` +fn parse_show_shares(parser: &mut Parser) -> Result { + let filter = parser.parse_show_statement_filter()?; + Ok(Statement::ShowShares { filter }) +} + /// Parse `SHOW ACCOUNTS [HISTORY] [LIKE '']` fn parse_show_accounts(parser: &mut Parser) -> Result { let history = parser.parse_keyword(Keyword::HISTORY); @@ -2694,6 +3725,29 @@ fn parse_catalog_rest_config(parser: &mut Parser) -> Result Result { + let mut scope = parse_oauth_scope_segment(parser)?; + while parser.consume_token(&Token::Colon) { + scope.push(':'); + scope.push_str(&parse_oauth_scope_segment(parser)?); + } + Ok(scope) +} + +/// A single segment of an OAuth scope: a bare word (keyword or not) or a +/// quoted string. +fn parse_oauth_scope_segment(parser: &mut Parser) -> Result { + let token = parser.next_token(); + match token.token { + Token::Word(w) => Ok(w.value), + Token::SingleQuotedString(s) | Token::DoubleQuotedString(s) => Ok(s), + _ => parser.expected("OAUTH scope", token), + } +} + /// Parse the body of `REST_AUTHENTICATION = ( … )`. fn parse_catalog_rest_authentication( parser: &mut Parser, @@ -2722,7 +3776,7 @@ fn parse_catalog_rest_authentication( parser.expect_token(&Token::Eq)?; parser.expect_token(&Token::LParen)?; loop { - oauth_allowed_scopes.push(parser.parse_literal_string()?); + oauth_allowed_scopes.push(parse_oauth_scope(parser)?); if !parser.consume_token(&Token::Comma) { break; } @@ -2784,3 +3838,59 @@ fn parse_show_catalog_integrations(parser: &mut Parser) -> Result `. +/// +/// Params (`TYPE`, `ENABLED`, `STORAGE_PROVIDER`, `STORAGE_AWS_ROLE_ARN`, +/// `STORAGE_ALLOWED_LOCATIONS`, `STORAGE_BLOCKED_LOCATIONS`, `COMMENT`, plus +/// GCS/Azure provider variants) are captured generically as key-value options, +/// so the parser stays agnostic to the provider-specific property set. +fn parse_create_storage_integration( + or_replace: bool, + parser: &mut Parser, +) -> Result { + let if_not_exists = parser.parse_keywords(&[Keyword::IF, Keyword::NOT, Keyword::EXISTS]); + let name = parser.parse_object_name(false)?; + let params = parser.parse_key_value_options(false, &[], false)?; + Ok(Statement::CreateStorageIntegration { + or_replace, + if_not_exists, + name, + params, + }) +} + +/// Parse `ALTER STORAGE INTEGRATION [IF EXISTS] SET `. +/// +/// Only the `SET` form is modeled; the emulator consumes it in a follow-up +/// task. The `SET` options are captured generically as key-value options. +fn parse_alter_storage_integration(parser: &mut Parser) -> Result { + let if_exists = parser.parse_keywords(&[Keyword::IF, Keyword::EXISTS]); + let name = parser.parse_object_name(false)?; + parser.expect_keyword(Keyword::SET)?; + let set_options = parser.parse_key_value_options(false, &[], false)?; + Ok(Statement::AlterStorageIntegration { + name, + if_exists, + set_options, + }) +} + +/// Parse `DROP STORAGE INTEGRATION [IF EXISTS] `. +fn parse_drop_storage_integration(parser: &mut Parser) -> Result { + let if_exists = parser.parse_keywords(&[Keyword::IF, Keyword::EXISTS]); + let name = parser.parse_object_name(false)?; + Ok(Statement::DropStorageIntegration { name, if_exists }) +} + +/// Parse `DESC[RIBE] STORAGE INTEGRATION `. +fn parse_describe_storage_integration(parser: &mut Parser) -> Result { + let name = parser.parse_object_name(false)?; + Ok(Statement::DescribeStorageIntegration { name }) +} + +/// Parse `SHOW STORAGE INTEGRATIONS [LIKE '']`. +fn parse_show_storage_integrations(parser: &mut Parser) -> Result { + let filter = parser.parse_show_statement_filter()?; + Ok(Statement::ShowStorageIntegrations { filter }) +} diff --git a/src/keywords.rs b/src/keywords.rs index 743329d971..74fc5d4b6d 100644 --- a/src/keywords.rs +++ b/src/keywords.rs @@ -115,6 +115,7 @@ define_keywords!( ALIGNMENT, ALL, ALLOCATE, + ALLOWED_VALUES, ALLOWOVERWRITE, ALLOW_WRITES, ALTER, @@ -147,6 +148,7 @@ define_keywords!( AUTOEXTEND_SIZE, AUTOINCREMENT, AUTO_INCREMENT, + AUTO_REFRESH, AVG, AVG_ROW_LENGTH, AVRO, @@ -177,6 +179,7 @@ define_keywords!( BLOCK, BLOOM, BLOOMFILTER, + BODY, BOOL, BOOLEAN, BOOST, @@ -213,6 +216,7 @@ define_keywords!( CATALOG_SYNC, CATALOG_SYNC_NAMESPACE_FLATTEN_DELIMITER, CATALOG_SYNC_NAMESPACE_MODE, + CATALOG_TABLE_NAME, CATALOG_URI, CATCH, CATEGORY, @@ -266,6 +270,7 @@ define_keywords!( CONFLICT, CONNECT, CONNECTION, + CONNECTIONS, CONNECTOR, CONNECT_BY_ROOT, CONSTRAINT, @@ -426,6 +431,7 @@ define_keywords!( EXPLAIN, EXPLICIT, EXPORT, + EXPORTED, EXTEND, EXTENDED, EXTENSION, @@ -514,6 +520,7 @@ define_keywords!( HOUR, HOURS, HUGEINT, + HYBRID, IAM_ROLE, ICEBERG, ICEBERG_REST, @@ -539,6 +546,7 @@ define_keywords!( INDICATOR, INHERIT, INHERITS, + INITIALIZATION_WAREHOUSE, INITIALIZE, INITIALLY, INNER, @@ -726,6 +734,7 @@ define_keywords!( NOLOGIN, NONE, NOORDER, + NORELY, NOREPLICATION, NORMALIZE, NORMALIZED, @@ -735,6 +744,7 @@ define_keywords!( NOTHING, NOTIFY, NOTNULL, + NOVALIDATE, NOWAIT, NO_WRITE_TO_BINLOG, NTH_VALUE, @@ -830,6 +840,7 @@ define_keywords!( PLANS, POINT, POLARIS, + POLICIES, POLICY, POLYGON, POOL, @@ -851,6 +862,7 @@ define_keywords!( PRIOR, PRIVILEGES, PROCEDURE, + PROCEDURES, PROCESSLIST, PROFILE, PROGRAM, @@ -900,6 +912,7 @@ define_keywords!( RELAY, RELEASE, RELEASES, + RELY, REMAINDER, REMOTE, REMOVE, @@ -957,6 +970,7 @@ define_keywords!( SAMPLE, SAVEPOINT, SCHEDULE, + SCHEDULER, SCHEMA, SCHEMAS, SCOPE, @@ -993,6 +1007,7 @@ define_keywords!( SETTINGS, SHARE, SHARED, + SHARES, SHARING, SHOW, SIGNED, @@ -1026,6 +1041,7 @@ define_keywords!( STABLE, STAGE, STAGES, + STAGE_FILE_FORMAT, START, STARTS, STATEMENT, @@ -1053,6 +1069,7 @@ define_keywords!( STORED, STRAIGHT_JOIN, STREAM, + STREAMS, STRICT, STRING, STRUCT, @@ -1085,6 +1102,7 @@ define_keywords!( TABLESPACE, TABLE_FORMAT, TAG, + TAGS, TARGET, TARGET_LAG, TASK, @@ -1156,6 +1174,7 @@ define_keywords!( UNCACHE, UNCOMMITTED, UNDEFINED, + UNDROP, UNFREEZE, UNION, UNIQUE, diff --git a/src/parser/alter.rs b/src/parser/alter.rs index 4000eb26ba..9625d871bf 100644 --- a/src/parser/alter.rs +++ b/src/parser/alter.rs @@ -259,7 +259,7 @@ impl Parser<'_> { }; let set_tag = if self.parse_keywords(&[Keyword::SET, Keyword::TAG]) { - self.parse_key_value_options(false, &[])? + self.parse_key_value_options(false, &[], false)? } else { KeyValueOptions { delimiter: KeyValueOptionsDelimiter::Comma, @@ -277,7 +277,7 @@ impl Parser<'_> { }; let set_props = if self.parse_keyword(Keyword::SET) { - self.parse_key_value_options(false, &[])? + self.parse_key_value_options(false, &[], false)? } else { KeyValueOptions { delimiter: KeyValueOptionsDelimiter::Comma, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index b44490b461..3e736d4df4 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -650,6 +650,7 @@ impl<'a> Parser<'a> { Keyword::CREATE => self.parse_create(), Keyword::CACHE => self.parse_cache_table(), Keyword::DROP => self.parse_drop(), + Keyword::UNDROP => self.parse_undrop(), Keyword::DISCARD => self.parse_discard(), Keyword::DECLARE => self.parse_declare(), Keyword::FETCH => self.parse_fetch_statement(), @@ -1063,6 +1064,19 @@ impl<'a> Parser<'a> { _ => {} } + // A `PUT` / `GET` file-transfer statement inside a scripting body. + // These are recognised so the block parses, but never tokenize + // cleanly: an unquoted `file://` path triggers the Snowflake `//` + // single-line-comment rule, which eats the statement's `;` + // terminator and the rest of the line. Scan the raw token stream + // (comments included) to the terminator rather than parse operands, + // and skip the shared `;` expectation below. + if let Some(get) = self.peek_put_get_files() { + self.consume_put_get_files(); + values.push(Statement::PutGetFiles { get }); + continue; + } + let stmt = if self.peek_keyword(Keyword::LET) { self.next_token(); // consume LET let name = self.parse_identifier()?; @@ -1119,6 +1133,54 @@ impl<'a> Parser<'a> { Ok(values) } + /// If the next scripting statement is a `PUT` / `GET` file-transfer + /// statement, return `Some(get)` (`get = true` for `GET`). A `GET` + /// followed by `STACKED` / `DIAGNOSTICS` is `GET DIAGNOSTICS`, not a + /// file transfer, and yields `None`. + fn peek_put_get_files(&self) -> Option { + let Token::Word(w) = &self.peek_nth_token_ref(0).token else { + return None; + }; + if w.quote_style.is_some() { + return None; + } + if w.value.eq_ignore_ascii_case("PUT") { + return Some(false); + } + if w.keyword == Keyword::GET { + if let Token::Word(next) = &self.peek_nth_token_ref(1).token { + if next.value.eq_ignore_ascii_case("STACKED") + || next.value.eq_ignore_ascii_case("DIAGNOSTICS") + { + return None; + } + } + return Some(true); + } + None + } + + /// Consume the raw tokens of a `PUT` / `GET` file-transfer statement up to + /// and including its terminator — either a real `;` (quoted paths keep it) + /// or the `//` single-line comment that swallowed it (unquoted `file://` + /// paths), scanning whitespace/comment tokens directly. + fn consume_put_get_files(&mut self) { + while let Some(tok) = self.tokens.get(self.index) { + match &tok.token { + Token::EOF => break, + Token::SemiColon => { + self.index += 1; + break; + } + Token::Whitespace(Whitespace::SingleLineComment { .. }) => { + self.index += 1; + break; + } + _ => self.index += 1, + } + } + } + /// Parse a `RAISE` statement. /// /// See [Statement::Raise] @@ -1334,7 +1396,8 @@ impl<'a> Parser<'a> { /// Parse `TRUNCATE` statement. pub fn parse_truncate(&mut self) -> Result { - let table = self.parse_keyword(Keyword::TABLE); + let materialized_view = self.parse_keywords(&[Keyword::MATERIALIZED, Keyword::VIEW]); + let table = !materialized_view && self.parse_keyword(Keyword::TABLE); let if_exists = self.parse_keywords(&[Keyword::IF, Keyword::EXISTS]); let table_names = self.parse_comma_separated(|p| { @@ -1376,6 +1439,7 @@ impl<'a> Parser<'a> { table_names, partitions, table, + materialized_view, if_exists, identity, cascade, @@ -1886,16 +1950,21 @@ impl<'a> Parser<'a> { // callee name (an `ObjectNamePart::Function`) and the trailing // `(args)` is the actual call — mirror the object-name grammar // so the resulting `Expr::Function` matches the CALL path shape. - if self.dialect.is_identifier_generating_function_name(&ident, &[]) { + if self + .dialect + .is_identifier_generating_function_name(&ident, &[]) + { let checkpoint = self.index; self.expect_token(&Token::LParen)?; - let args = self - .parse_comma_separated0(Self::parse_function_args, Token::RParen)?; + let args = + self.parse_comma_separated0(Self::parse_function_args, Token::RParen)?; self.expect_token(&Token::RParen)?; if self.peek_token_ref().token == Token::LParen { - let name = ObjectName(vec![ObjectNamePart::Function( - ObjectNamePartFunction { name: ident, args }, - )]); + let name = + ObjectName(vec![ObjectNamePart::Function(ObjectNamePartFunction { + name: ident, + args, + })]); return self.parse_function(name); } self.index = checkpoint; @@ -4275,46 +4344,31 @@ impl<'a> Parser<'a> { let negated = self.parse_keyword(Keyword::NOT); let regexp = self.parse_keyword(Keyword::REGEXP); let rlike = self.parse_keyword(Keyword::RLIKE); - let null = if !self.in_column_definition_state() { - self.parse_keyword(Keyword::NULL) - } else { - false - }; if regexp || rlike { - Ok(Expr::RLike { + return Ok(Expr::RLike { negated, expr: Box::new(expr), pattern: Box::new( self.parse_subexpr(self.dialect.prec_value(Precedence::Like))?, ), regexp, - }) - } else if negated && null { + }); + } + let null = if !self.in_column_definition_state() { + self.parse_keyword(Keyword::NULL) + } else { + false + }; + if negated && null { Ok(Expr::IsNotNull(Box::new(expr))) } else if self.parse_keyword(Keyword::IN) { self.parse_in(expr, negated) } else if self.parse_keyword(Keyword::BETWEEN) { self.parse_between(expr, negated) } else if self.parse_keyword(Keyword::LIKE) { - Ok(Expr::Like { - negated, - any: self.parse_keyword(Keyword::ANY), - expr: Box::new(expr), - pattern: Box::new( - self.parse_subexpr(self.dialect.prec_value(Precedence::Like))?, - ), - escape_char: self.parse_escape_char()?, - }) + self.parse_like_expr(negated, false, expr) } else if self.parse_keyword(Keyword::ILIKE) { - Ok(Expr::ILike { - negated, - any: self.parse_keyword(Keyword::ANY), - expr: Box::new(expr), - pattern: Box::new( - self.parse_subexpr(self.dialect.prec_value(Precedence::Like))?, - ), - escape_char: self.parse_escape_char()?, - }) + self.parse_like_expr(negated, true, expr) } else if self.parse_keywords(&[Keyword::SIMILAR, Keyword::TO]) { Ok(Expr::SimilarTo { negated, @@ -4377,6 +4431,54 @@ impl<'a> Parser<'a> { } } + /// Parse the tail of a `LIKE` / `ILIKE` predicate after the keyword has + /// been consumed. Handles Snowflake's `{ANY|ALL} (, ..., )` + /// multi-pattern list form as well as the ordinary single-pattern form. + fn parse_like_expr( + &mut self, + negated: bool, + ilike: bool, + expr: Expr, + ) -> Result { + let quantifier = self.parse_one_of_keywords(&[Keyword::ANY, Keyword::ALL]); + if let Some(kw) = quantifier { + if self.consume_token(&Token::LParen) { + let patterns = self.parse_comma_separated0(Parser::parse_expr, Token::RParen)?; + self.expect_token(&Token::RParen)?; + return Ok(Expr::LikeAnyAll { + negated, + ilike, + all: kw == Keyword::ALL, + expr: Box::new(expr), + patterns, + escape_char: self.parse_escape_char()?, + }); + } + // `ANY` without a parenthesized list falls back to the legacy + // single-pattern form (`ALL` has no single-pattern meaning here). + } + let any = quantifier == Some(Keyword::ANY); + let pattern = Box::new(self.parse_subexpr(self.dialect.prec_value(Precedence::Like))?); + let escape_char = self.parse_escape_char()?; + if ilike { + Ok(Expr::ILike { + negated, + any, + expr: Box::new(expr), + pattern, + escape_char, + }) + } else { + Ok(Expr::Like { + negated, + any, + expr: Box::new(expr), + pattern, + escape_char, + }) + } + } + /// Parse the `ESCAPE CHAR` portion of `LIKE`, `ILIKE`, and `SIMILAR TO` pub fn parse_escape_char(&mut self) -> Result, ParserError> { if self.parse_keyword(Keyword::ESCAPE) { @@ -5452,9 +5554,15 @@ impl<'a> Parser<'a> { self.parse_create_procedure(or_alter, or_replace) } else if self.parse_keyword(Keyword::SCHEMA) { self.parse_create_schema(or_replace, transient) + } else if self.parse_keyword(Keyword::ROLE) { + self.parse_create_role(or_replace).map(Into::into) + } else if self.parse_keyword(Keyword::SEQUENCE) { + self.parse_create_sequence(or_replace, temporary) + } else if self.parse_keyword(Keyword::STREAM) { + self.parse_create_stream(or_replace) } else if or_replace { self.expected_ref( - "[EXTERNAL] TABLE or [MATERIALIZED] VIEW or FUNCTION or WAREHOUSE or TASK or PROCEDURE or SCHEMA after CREATE OR REPLACE", + "[EXTERNAL] TABLE or [MATERIALIZED] VIEW or FUNCTION or WAREHOUSE or TASK or PROCEDURE or SCHEMA or ROLE or SEQUENCE after CREATE OR REPLACE", self.peek_token_ref(), ) } else if self.parse_keyword(Keyword::EXTENSION) { @@ -5466,11 +5574,11 @@ impl<'a> Parser<'a> { } else if self.parse_keyword(Keyword::VIRTUAL) { self.parse_create_virtual_table() } else if self.parse_keyword(Keyword::DATABASE) { - self.parse_create_database() - } else if self.parse_keyword(Keyword::ROLE) { - self.parse_create_role().map(Into::into) - } else if self.parse_keyword(Keyword::SEQUENCE) { - self.parse_create_sequence(temporary) + if self.parse_keyword(Keyword::ROLE) { + self.parse_create_database_role(or_replace) + } else { + self.parse_create_database() + } } else if self.parse_keyword(Keyword::COLLATION) { self.parse_create_collation().map(Into::into) } else if self.parse_keyword(Keyword::TYPE) { @@ -5497,11 +5605,11 @@ impl<'a> Parser<'a> { let if_not_exists = self.parse_keywords(&[Keyword::IF, Keyword::NOT, Keyword::EXISTS]); let name = self.parse_identifier()?; let options = self - .parse_key_value_options(false, &[Keyword::WITH, Keyword::TAG])? + .parse_key_value_options(false, &[Keyword::WITH, Keyword::TAG], true)? .options; let with_tags = self.parse_keyword(Keyword::WITH); let tags = if self.parse_keyword(Keyword::TAG) { - self.parse_key_value_options(true, &[])?.options + self.parse_key_value_options(true, &[], false)?.options } else { vec![] }; @@ -5521,6 +5629,27 @@ impl<'a> Parser<'a> { }) } + /// `CREATE [OR REPLACE] STREAM [IF NOT EXISTS] ON { TABLE | VIEW } ` + fn parse_create_stream(&mut self, or_replace: bool) -> Result { + let if_not_exists = self.parse_keywords(&[Keyword::IF, Keyword::NOT, Keyword::EXISTS]); + let name = self.parse_object_name(false)?; + self.expect_keyword(Keyword::ON)?; + let source_kind = if self.parse_keyword(Keyword::VIEW) { + StreamSourceKind::View + } else { + self.expect_keyword(Keyword::TABLE)?; + StreamSourceKind::Table + }; + let source_table = self.parse_object_name(false)?; + Ok(Statement::CreateStream { + or_replace, + if_not_exists, + name, + source_kind, + source_table, + }) + } + fn parse_create_warehouse(&mut self, or_replace: bool) -> Result { let if_not_exists = self.parse_keywords(&[Keyword::IF, Keyword::NOT, Keyword::EXISTS]); let name = self.parse_object_name(false)?; @@ -6042,6 +6171,7 @@ impl<'a> Parser<'a> { body.called_on_null = Some(FunctionCalledOnNull::Strict); } let mut set_params: Vec = Vec::new(); + let mut options: Vec = Vec::new(); loop { fn ensure_not_set(field: &Option, name: &str) -> Result<(), ParserError> { if field.is_some() { @@ -6135,6 +6265,10 @@ impl<'a> Parser<'a> { } else if self.parse_keyword(Keyword::RETURN) { ensure_not_set(&body.function_body, "RETURN")?; body.function_body = Some(CreateFunctionBody::Return(self.parse_expr()?)); + } else if dialect_of!(self is SnowflakeDialect | GenericDialect) + && self.peek_snowflake_function_property() + { + options.push(self.parse_snowflake_function_property()?); } else { break; } @@ -6158,11 +6292,44 @@ impl<'a> Parser<'a> { if_not_exists: false, using: None, determinism_specifier: None, - options: None, + options: if options.is_empty() { + None + } else { + Some(options) + }, remote_connection: None, }) } + /// True when the next token is a Snowflake Python-UDF property name + /// (`RUNTIME_VERSION`, `HANDLER`, `IMPORTS`, `PACKAGES`). None of these are + /// reserved keywords, so they are recognised by their bare-word spelling. + fn peek_snowflake_function_property(&self) -> bool { + matches!(&self.peek_token_ref().token, Token::Word(w) + if w.quote_style.is_none() + && matches!( + w.value.to_ascii_uppercase().as_str(), + "RUNTIME_VERSION" | "HANDLER" | "IMPORTS" | "PACKAGES" + )) + } + + /// Parse a Snowflake Python-UDF property clause `key = value` or + /// `key = ('a' [, 'b' …])` into an [`SqlOption::KeyValue`]. Parenthesised + /// lists are stored as an [`Expr::Tuple`]. + fn parse_snowflake_function_property(&mut self) -> Result { + let key = self.parse_identifier()?; + self.expect_token(&Token::Eq)?; + let value = if self.peek_token_ref().token == Token::LParen { + self.expect_token(&Token::LParen)?; + let values = self.parse_comma_separated(Parser::parse_expr)?; + self.expect_token(&Token::RParen)?; + Expr::Tuple(values) + } else { + self.parse_expr()? + }; + Ok(SqlOption::KeyValue { key, value }) + } + /// Parse `CREATE FUNCTION` for [Hive] /// /// [Hive]: https://cwiki.apache.org/confluence/display/hive/languagemanual+ddl#LanguageManualDDL-Create/Drop/ReloadFunction @@ -7071,7 +7238,7 @@ impl<'a> Parser<'a> { } /// Parse a `CREATE ROLE` statement. - pub fn parse_create_role(&mut self) -> Result { + pub fn parse_create_role(&mut self, or_replace: bool) -> Result { let if_not_exists = self.parse_keywords(&[Keyword::IF, Keyword::NOT, Keyword::EXISTS]); let names = self.parse_comma_separated(|p| p.parse_object_name(false))?; @@ -7275,6 +7442,7 @@ impl<'a> Parser<'a> { Ok(CreateRole { names, + or_replace, if_not_exists, login, inherit, @@ -7295,6 +7463,28 @@ impl<'a> Parser<'a> { }) } + /// Parse a Snowflake `CREATE [OR REPLACE] DATABASE ROLE` statement. The + /// leading `DATABASE ROLE` keywords have already been consumed. + pub fn parse_create_database_role( + &mut self, + or_replace: bool, + ) -> Result { + let if_not_exists = self.parse_keywords(&[Keyword::IF, Keyword::NOT, Keyword::EXISTS]); + let name = self.parse_object_name(false)?; + let comment = if self.parse_keyword(Keyword::COMMENT) { + self.expect_token(&Token::Eq)?; + Some(self.parse_literal_string()?) + } else { + None + }; + Ok(Statement::CreateDatabaseRole { + or_replace, + if_not_exists, + name, + comment, + }) + } + /// Parse an `OWNER` clause. pub fn parse_owner(&mut self) -> Result { let owner = match self.parse_one_of_keywords(&[Keyword::CURRENT_USER, Keyword::CURRENT_ROLE, Keyword::SESSION_USER]) { @@ -7761,7 +7951,11 @@ impl<'a> Parser<'a> { let persistent = dialect_of!(self is DuckDbDialect) && self.parse_one_of_keywords(&[Keyword::PERSISTENT]).is_some(); - let object_type = if self.parse_keyword(Keyword::TABLE) { + let object_type = if self.parse_keywords(&[Keyword::DYNAMIC, Keyword::TABLE]) { + ObjectType::DynamicTable + } else if self.parse_keyword(Keyword::TABLE) + || self.parse_keywords(&[Keyword::ICEBERG, Keyword::TABLE]) + { ObjectType::Table } else if self.parse_keyword(Keyword::COLLATION) { ObjectType::Collation @@ -7776,7 +7970,11 @@ impl<'a> Parser<'a> { } else if self.parse_keyword(Keyword::SCHEMA) { ObjectType::Schema } else if self.parse_keyword(Keyword::DATABASE) { - ObjectType::Database + if self.parse_keyword(Keyword::ROLE) { + ObjectType::DatabaseRole + } else { + ObjectType::Database + } } else if self.parse_keyword(Keyword::SEQUENCE) { ObjectType::Sequence } else if self.parse_keyword(Keyword::STAGE) { @@ -7859,6 +8057,33 @@ impl<'a> Parser<'a> { }) } + /// Parse a Snowflake `UNDROP ` statement. + /// + /// Grammar for the whole UNDROP family (`TABLE`, `DYNAMIC TABLE`, + /// `SCHEMA`, `DATABASE`) plus `VIEW`, which parses so it can be rejected + /// downstream with Snowflake's unsupported-feature error rather than a + /// parse error. + pub fn parse_undrop(&mut self) -> Result { + let object_type = if self.parse_keywords(&[Keyword::DYNAMIC, Keyword::TABLE]) { + ObjectType::DynamicTable + } else if self.parse_keyword(Keyword::TABLE) { + ObjectType::Table + } else if self.parse_keyword(Keyword::VIEW) { + ObjectType::View + } else if self.parse_keyword(Keyword::SCHEMA) { + ObjectType::Schema + } else if self.parse_keyword(Keyword::DATABASE) { + ObjectType::Database + } else { + return self.expected_ref( + "DATABASE, DYNAMIC TABLE, SCHEMA, TABLE or VIEW after UNDROP", + self.peek_token_ref(), + ); + }; + let name = self.parse_object_name(false)?; + Ok(Statement::Undrop { object_type, name }) + } + fn parse_optional_drop_behavior(&mut self) -> Option { match self.parse_one_of_keywords(&[Keyword::CASCADE, Keyword::RESTRICT]) { Some(Keyword::CASCADE) => Some(DropBehavior::Cascade), @@ -9609,7 +9834,25 @@ impl<'a> Parser<'a> { if let Some(constraint) = self.parse_optional_table_constraint()? { constraints.push(constraint); } else if let Token::Word(_) = &self.peek_token_ref().token { - columns.push(self.parse_column_def()?); + // A bare column name with no data type (`CREATE TABLE t(id) AS + // SELECT ...`) is only accepted when the dialect opts in and the + // name is immediately followed by a column-list terminator, so a + // genuinely malformed data type still surfaces its own error. + if self.dialect.supports_create_table_optional_column_type() + && matches!( + self.peek_nth_token_ref(1).token, + Token::Comma | Token::RParen + ) + { + let name = self.parse_identifier()?; + columns.push(ColumnDef { + name, + data_type: DataType::Unspecified, + options: vec![], + }); + } else { + columns.push(self.parse_column_def()?); + } } else { return self.expected_ref( "column name or constraint definition", @@ -10188,16 +10431,62 @@ impl<'a> Parser<'a> { && self.parse_keywords(&[Keyword::NOT, Keyword::ENFORCED]) { cc.enforced = Some(false); - } else { + } else if !self.parse_informational_constraint_property(&mut cc) { break; } } - if cc.deferrable.is_some() || cc.initially.is_some() || cc.enforced.is_some() { - Ok(Some(cc)) - } else { + if cc == ConstraintCharacteristics::default() { Ok(None) + } else { + Ok(Some(cc)) + } + } + + /// Parse one of the informational constraint properties `{ ENABLE | DISABLE }`, + /// `{ VALIDATE | NOVALIDATE }` or `{ RELY | NORELY }`, returning whether one was + /// consumed. Only dialects opting in via + /// [`Dialect::supports_informational_constraint_properties`] accept them, as + /// `ENABLE`, `DISABLE` and `VALIDATE` are keywords used elsewhere. + fn parse_informational_constraint_property( + &mut self, + cc: &mut ConstraintCharacteristics, + ) -> bool { + if !self.dialect.supports_informational_constraint_properties() { + return false; + } + + if cc.enabled.is_none() { + if self.parse_keyword(Keyword::ENABLE) { + cc.enabled = Some(true); + return true; + } + if self.parse_keyword(Keyword::DISABLE) { + cc.enabled = Some(false); + return true; + } } + if cc.validated.is_none() { + if self.parse_keyword(Keyword::VALIDATE) { + cc.validated = Some(true); + return true; + } + if self.parse_keyword(Keyword::NOVALIDATE) { + cc.validated = Some(false); + return true; + } + } + if cc.rely.is_none() { + if self.parse_keyword(Keyword::RELY) { + cc.rely = Some(true); + return true; + } + if self.parse_keyword(Keyword::NORELY) { + cc.rely = Some(false); + return true; + } + } + false } /// Parse an optional table constraint (e.g. `PRIMARY KEY`, `UNIQUE`, `FOREIGN KEY`, `CHECK`). @@ -10704,6 +10993,18 @@ impl<'a> Parser<'a> { Ok(AlterTableOperation::AlterSortKey { columns }) } + /// Peek whether the upcoming tokens are a bare ` COMMENT ...` + /// continuation of a comma-separated `ALTER COLUMN ... COMMENT` list, i.e. + /// with the `COLUMN` keyword omitted. This shape is unambiguous against + /// every other `ALTER TABLE` operation, which are all keyword-led. + fn peek_bare_column_comment_continuation(&self) -> bool { + matches!(self.peek_nth_token(0).token, Token::Word(_)) + && matches!( + self.peek_nth_token(1).token, + Token::Word(w) if w.keyword == Keyword::COMMENT + ) + } + /// Parse a single `ALTER TABLE` operation and return an `AlterTableOperation`. pub fn parse_alter_table_operation(&mut self) -> Result { let operation = if self.parse_keyword(Keyword::ADD) { @@ -10744,15 +11045,38 @@ impl<'a> Parser<'a> { false }; - let column_def = self.parse_column_def()?; + if self.dialect.supports_comma_separated_add_column_list() { + let mut column_defs = vec![self.parse_column_def()?]; + while self.consume_token(&Token::Comma) { + column_defs.push(self.parse_column_def()?); + } + if column_defs.len() == 1 { + let column_def = column_defs.swap_remove(0); + let column_position = self.parse_column_position()?; + AlterTableOperation::AddColumn { + column_keyword, + if_not_exists, + column_def, + column_position, + } + } else { + AlterTableOperation::AddColumns { + column_keyword, + if_not_exists, + column_defs, + } + } + } else { + let column_def = self.parse_column_def()?; - let column_position = self.parse_column_position()?; + let column_position = self.parse_column_position()?; - AlterTableOperation::AddColumn { - column_keyword, - if_not_exists, - column_def, - column_position, + AlterTableOperation::AddColumn { + column_keyword, + if_not_exists, + column_def, + column_position, + } } } } @@ -10918,6 +11242,16 @@ impl<'a> Parser<'a> { AlterTableOperation::DropProjection { if_exists, name } } else if self.parse_keywords(&[Keyword::CLUSTERING, Keyword::KEY]) { AlterTableOperation::DropClusteringKey + } else if self.parse_keywords(&[ + Keyword::ALL, + Keyword::ROW, + Keyword::ACCESS, + Keyword::POLICIES, + ]) { + AlterTableOperation::DropAllRowAccessPolicies + } else if self.parse_keywords(&[Keyword::ROW, Keyword::ACCESS, Keyword::POLICY]) { + let policy_name = self.parse_object_name(false)?; + AlterTableOperation::DropRowAccessPolicy { policy_name } } else { let has_column_keyword = self.parse_keyword(Keyword::COLUMN); // [ COLUMN ] let if_exists = self.parse_keywords(&[Keyword::IF, Keyword::EXISTS]); @@ -10969,19 +11303,43 @@ impl<'a> Parser<'a> { } else if self.parse_keyword(Keyword::MODIFY) { let _ = self.parse_keyword(Keyword::COLUMN); // [ COLUMN ] let col_name = self.parse_identifier()?; - let data_type = self.parse_data_type()?; - let mut options = vec![]; - while let Some(option) = self.parse_optional_column_option()? { - options.push(option); - } + if let Some(op) = self.maybe_parse_column_masking_policy()? { + AlterTableOperation::AlterColumn { + column_name: col_name, + op, + } + } else { + let data_type = self.parse_data_type()?; + let mut options = vec![]; + while let Some(option) = self.parse_optional_column_option()? { + options.push(option); + } - let column_position = self.parse_column_position()?; + let column_position = self.parse_column_position()?; - AlterTableOperation::ModifyColumn { - col_name, - data_type, - options, - column_position, + AlterTableOperation::ModifyColumn { + col_name, + data_type, + options, + column_position, + } + } + } else if self.dialect.supports_alter_column_comment() + && (self.parse_keyword(Keyword::COLUMN) + || self.peek_bare_column_comment_continuation()) + { + // Continuation of a comma-separated `ALTER COLUMN ... COMMENT` list, + // e.g. `... ALTER COLUMN c1 COMMENT 's1', COLUMN c2 COMMENT 's2'`. + // The second and later items carry `COLUMN` without a leading `ALTER`, + // and Snowflake also accepts the bare form with `COLUMN` omitted: + // `... ALTER c1 COMMENT 's1', c2 COMMENT 's2'`. + let column_name = self.parse_identifier()?; + self.expect_keyword_is(Keyword::COMMENT)?; + AlterTableOperation::AlterColumn { + column_name, + op: AlterColumnOperation::Comment { + comment: self.parse_literal_string()?, + }, } } else if self.parse_keyword(Keyword::ALTER) { if self.peek_keyword(Keyword::SORTKEY) { @@ -11011,6 +11369,12 @@ impl<'a> Parser<'a> { self.parse_set_data_type(true)? } else if self.parse_keyword(Keyword::TYPE) { self.parse_set_data_type(false)? + } else if self.dialect.supports_alter_column_comment() + && self.parse_keyword(Keyword::COMMENT) + { + AlterColumnOperation::Comment { + comment: self.parse_literal_string()?, + } } else if self.parse_keywords(&[Keyword::ADD, Keyword::GENERATED]) { let generated_as = if self.parse_keyword(Keyword::ALWAYS) { Some(GeneratedAs::Always) @@ -11034,6 +11398,8 @@ impl<'a> Parser<'a> { generated_as, sequence_options, } + } else if let Some(op) = self.maybe_parse_column_masking_policy()? { + op } else { let message = if is_postgresql { "SET/DROP NOT NULL, SET DEFAULT, SET DATA TYPE, or ADD GENERATED after ALTER COLUMN" @@ -11184,6 +11550,35 @@ impl<'a> Parser<'a> { Ok(operation) } + /// Try to parse a Snowflake column masking-policy operation + /// (`SET MASKING POLICY

[USING (, ...)] [FORCE]` or + /// `UNSET MASKING POLICY`) following the column name in an `ALTER TABLE` + /// / `ALTER VIEW` `{MODIFY|ALTER} COLUMN` clause. Returns `None` if the + /// upcoming tokens are not a masking-policy operation, leaving the parser + /// position unchanged. + fn maybe_parse_column_masking_policy( + &mut self, + ) -> Result, ParserError> { + if self.parse_keywords(&[Keyword::SET, Keyword::MASKING, Keyword::POLICY]) { + let policy_name = self.parse_object_name(false)?; + let using_columns = if self.parse_keyword(Keyword::USING) { + Some(self.parse_parenthesized_column_list(Mandatory, false)?) + } else { + None + }; + let force = self.parse_keyword(Keyword::FORCE); + Ok(Some(AlterColumnOperation::SetMaskingPolicy { + policy_name, + using_columns, + force, + })) + } else if self.parse_keywords(&[Keyword::UNSET, Keyword::MASKING, Keyword::POLICY]) { + Ok(Some(AlterColumnOperation::UnsetMaskingPolicy)) + } else { + Ok(None) + } + } + fn parse_set_data_type(&mut self, had_set: bool) -> Result { let data_type = self.parse_data_type()?; let using = if self.dialect.supports_alter_column_type_using() @@ -11232,6 +11627,7 @@ impl<'a> Parser<'a> { Keyword::WAREHOUSE, Keyword::ACCOUNT, Keyword::TASK, + Keyword::STREAM, ])?; match object_type { Keyword::SCHEMA => { @@ -11283,9 +11679,10 @@ impl<'a> Parser<'a> { Keyword::WAREHOUSE => self.parse_alter_warehouse(), Keyword::ACCOUNT => self.parse_alter_account(), Keyword::TASK => self.parse_alter_task(), + Keyword::STREAM => self.parse_alter_stream(), // unreachable because expect_one_of_keywords used above unexpected_keyword => Err(ParserError::ParserError( - format!("Internal parser error: expected any of {{VIEW, TYPE, COLLATION, TABLE, INDEX, FUNCTION, AGGREGATE, ROLE, POLICY, CONNECTOR, ICEBERG, SCHEMA, USER, OPERATOR, WAREHOUSE, ACCOUNT, TASK}}, got {unexpected_keyword:?}"), + format!("Internal parser error: expected any of {{VIEW, TYPE, COLLATION, TABLE, INDEX, FUNCTION, AGGREGATE, ROLE, POLICY, CONNECTOR, ICEBERG, SCHEMA, USER, OPERATOR, WAREHOUSE, ACCOUNT, TASK, STREAM}}, got {unexpected_keyword:?}"), )), } } @@ -11622,6 +12019,27 @@ impl<'a> Parser<'a> { }) } + /// Parse `ALTER STREAM [IF EXISTS] { SET COMMENT = '' | UNSET COMMENT }`. + pub fn parse_alter_stream(&mut self) -> Result { + let if_exists = self.parse_keywords(&[Keyword::IF, Keyword::EXISTS]); + let name = self.parse_object_name(false)?; + let operation = if self.parse_keyword(Keyword::SET) { + self.expect_keyword(Keyword::COMMENT)?; + self.expect_token(&Token::Eq)?; + AlterStreamOperation::SetComment(self.parse_literal_string()?) + } else if self.parse_keyword(Keyword::UNSET) { + self.expect_keyword(Keyword::COMMENT)?; + AlterStreamOperation::UnsetComment + } else { + return self.expected("SET or UNSET after ALTER STREAM", self.peek_token()); + }; + Ok(Statement::AlterStream { + if_exists, + name, + operation, + }) + } + /// Parse a [Statement::AlterTable] pub fn parse_alter_table(&mut self, iceberg: bool) -> Result { let if_exists = self.parse_keywords(&[Keyword::IF, Keyword::EXISTS]); @@ -11669,6 +12087,28 @@ impl<'a> Parser<'a> { /// Parse an `ALTER VIEW` statement. pub fn parse_alter_view(&mut self) -> Result { let name = self.parse_object_name(false)?; + + // Snowflake: `ALTER VIEW v { MODIFY | ALTER } COLUMN c + // { SET MASKING POLICY p [USING (...)] [FORCE] | UNSET MASKING POLICY }` + if self.parse_one_of_keywords(&[Keyword::MODIFY, Keyword::ALTER]).is_some() { + let _ = self.parse_keyword(Keyword::COLUMN); // [ COLUMN ] + let column_name = self.parse_identifier()?; + let op = match self.maybe_parse_column_masking_policy()? { + Some(op) => op, + None => { + return self.expected_ref( + "SET MASKING POLICY or UNSET MASKING POLICY", + self.peek_token_ref(), + ) + } + }; + return Ok(Statement::AlterViewColumn { + name, + column_name, + op, + }); + } + let columns = self.parse_parenthesized_column_list(Optional, false)?; let with_options = self.parse_options(Keyword::WITH)?; @@ -12111,16 +12551,18 @@ impl<'a> Parser<'a> { /// or `CALL procedure_name` statement pub fn parse_call(&mut self) -> Result { let object_name = self.parse_object_name(false)?; - if self.peek_token_ref().token == Token::LParen { + let function = if self.peek_token_ref().token == Token::LParen { match self.parse_function(object_name)? { - Expr::Function(f) => Ok(Statement::Call(f)), - other => parser_err!( - format!("Expected a simple procedure call but found: {other}"), - self.peek_token_ref().span.start - ), + Expr::Function(f) => f, + other => { + return parser_err!( + format!("Expected a simple procedure call but found: {other}"), + self.peek_token_ref().span.start + ) + } } } else { - Ok(Statement::Call(Function { + Function { name: object_name, uses_odbc_syntax: false, parameters: FunctionArguments::None, @@ -12129,7 +12571,38 @@ impl<'a> Parser<'a> { filter: None, null_treatment: None, within_group: vec![], - })) + } + }; + + // Snowflake scripting: `CALL p(...) INTO :var [, ...]` captures the + // procedure result into local variables. + if self.dialect.supports_call_into() && self.parse_keyword(Keyword::INTO) { + let into = self.parse_scripting_into_targets()?; + return Ok(Statement::CallInto { function, into }); + } + + Ok(Statement::Call(function)) + } + + /// Parse a comma-separated list of Snowflake scripting `INTO` targets. + /// + /// Each target is either a `:placeholder` bind variable — kept verbatim as + /// its `:name` text so the round-trip is stable — or a bare local-variable + /// name. + pub(crate) fn parse_scripting_into_targets( + &mut self, + ) -> Result, ParserError> { + self.parse_comma_separated(Parser::parse_scripting_into_target) + } + + fn parse_scripting_into_target(&mut self) -> Result { + if self.peek_token_ref().token == Token::Colon { + let placeholder = self.parse_value()?; + Ok(ObjectName(vec![ObjectNamePart::Identifier(Ident::new( + placeholder.to_string(), + ))])) + } else { + self.parse_object_name(false) } } @@ -12839,6 +13312,7 @@ impl<'a> Parser<'a> { Ok(s) } Token::UnicodeStringLiteral(s) => Ok(s), + Token::DollarQuotedString(s) if dialect_of!(self is SnowflakeDialect) => Ok(s.value), _ => self.expected("literal string", next_token), } } @@ -14644,6 +15118,33 @@ impl<'a> Parser<'a> { query_id: Box::new(query_id), }); } + if self.parse_keywords(&[Keyword::DYNAMIC, Keyword::TABLE]) { + let object_name = self.parse_object_name(false)?; + return Ok(Statement::DescribeObject { + describe_alias, + object_type: DescribeObjectType::DynamicTable, + object_name, + table_type: None, + }); + } + if self.parse_keywords(&[Keyword::MATERIALIZED, Keyword::VIEW]) { + let object_name = self.parse_object_name(false)?; + return Ok(Statement::DescribeObject { + describe_alias, + object_type: DescribeObjectType::MaterializedView, + object_name, + table_type: None, + }); + } + // `DESCRIBE TABLE STREAM ` is not valid Snowflake + // syntax (a client such as sqlglot may emit it). Real + // Snowflake consumes the whole statement and then reports an + // unexpected end-of-input; mirror that by draining the name + // and failing at EOF rather than at the second identifier. + if self.parse_keywords(&[Keyword::TABLE, Keyword::STREAM]) { + let _ = self.parse_object_name(false)?; + return self.expected("end of statement", self.peek_token()); + } if let Some(kw) = self.parse_one_of_keywords(&[ Keyword::TABLE, Keyword::VIEW, @@ -14651,6 +15152,7 @@ impl<'a> Parser<'a> { Keyword::SCHEMA, Keyword::TASK, Keyword::STAGE, + Keyword::STREAM, ]) { let object_type = match kw { Keyword::TABLE => DescribeObjectType::Table, @@ -14659,13 +15161,31 @@ impl<'a> Parser<'a> { Keyword::SCHEMA => DescribeObjectType::Schema, Keyword::TASK => DescribeObjectType::Task, Keyword::STAGE => DescribeObjectType::Stage, + Keyword::STREAM => DescribeObjectType::Stream, _ => return self.expected("a describe object type", self.peek_token()), }; let object_name = self.parse_object_name(false)?; + // Snowflake `DESC TABLE [ TYPE = { COLUMNS | STAGE } ]`. + // The modifier is only valid for TABLE; COLUMNS is the default. + let table_type = if object_type == DescribeObjectType::Table + && self.parse_keyword(Keyword::TYPE) + { + self.expect_token(&Token::Eq)?; + match self.parse_one_of_keywords(&[Keyword::COLUMNS, Keyword::STAGE]) { + Some(Keyword::COLUMNS) => Some(DescribeTableType::Columns), + Some(Keyword::STAGE) => Some(DescribeTableType::Stage), + _ => { + return self.expected("COLUMNS or STAGE", self.peek_token()) + } + } + } else { + None + }; return Ok(Statement::DescribeObject { describe_alias, object_type, object_name, + table_type, }); } } @@ -16167,6 +16687,8 @@ impl<'a> Parser<'a> { Ok(self.parse_show_tables(terse, extended, full, external)?) } else if self.parse_keyword(Keyword::TASKS) { Ok(self.parse_show_tasks(terse)?) + } else if self.parse_keyword(Keyword::STREAMS) { + Ok(self.parse_show_streams(terse)?) } else if self.parse_keywords(&[Keyword::MATERIALIZED, Keyword::VIEWS]) { Ok(self.parse_show_views(terse, true)?) } else if self.parse_keyword(Keyword::VIEWS) { @@ -16321,6 +16843,14 @@ impl<'a> Parser<'a> { }) } + fn parse_show_streams(&mut self, terse: bool) -> Result { + let show_options = self.parse_show_stmt_options()?; + Ok(Statement::ShowStreams { + terse, + show_options, + }) + } + fn parse_show_views( &mut self, terse: bool, @@ -16334,10 +16864,10 @@ impl<'a> Parser<'a> { }) } - /// Parse `SHOW FUNCTIONS` and optional filter. + /// Parse `SHOW FUNCTIONS` with optional filter and `IN ` clause. pub fn parse_show_functions(&mut self) -> Result { - let filter = self.parse_show_statement_filter()?; - Ok(Statement::ShowFunctions { filter }) + let show_options = self.parse_show_stmt_options()?; + Ok(Statement::ShowFunctions { show_options }) } /// Parse `SHOW COLLATION` and optional filter. @@ -17580,8 +18110,12 @@ impl<'a> Parser<'a> { fn parse_table_version_changes(&mut self) -> Result { let changes_name = self.parse_object_name(true)?; let changes = self.parse_function(changes_name)?; - let at_name = self.parse_object_name(true)?; - let at = self.parse_function(at_name)?; + let at = if self.peek_keyword(Keyword::AT) || self.peek_keyword(Keyword::BEFORE) { + let at_name = self.parse_object_name(true)?; + Some(self.parse_function(at_name)?) + } else { + None + }; let end = if self.peek_keyword(Keyword::END) { let end_name = self.parse_object_name(true)?; Some(self.parse_function(end_name)?) @@ -18101,6 +18635,80 @@ impl<'a> Parser<'a> { Some(GrantObjects::FutureSequencesInSchema { schemas: self.parse_comma_separated(|p| p.parse_object_name(false))?, }) + } else if self.parse_keywords(&[ + Keyword::ALL, + Keyword::SCHEMAS, + Keyword::IN, + Keyword::DATABASE, + ]) { + Some(GrantObjects::AllSchemasInDatabase { + databases: self.parse_comma_separated(|p| p.parse_object_name(false))?, + }) + } else if self.parse_keywords(&[ + Keyword::ALL, + Keyword::TABLES, + Keyword::IN, + Keyword::DATABASE, + ]) { + Some(GrantObjects::AllTablesInDatabase { + databases: self.parse_comma_separated(|p| p.parse_object_name(false))?, + }) + } else if self.parse_keywords(&[ + Keyword::ALL, + Keyword::STAGES, + Keyword::IN, + Keyword::SCHEMA, + ]) { + Some(GrantObjects::AllStagesInSchema { + schemas: self.parse_comma_separated(|p| p.parse_object_name(false))?, + }) + } else if self.parse_keywords(&[ + Keyword::ALL, + Keyword::FILE, + Keyword::FORMATS, + Keyword::IN, + Keyword::SCHEMA, + ]) { + Some(GrantObjects::AllFileFormatsInSchema { + schemas: self.parse_comma_separated(|p| p.parse_object_name(false))?, + }) + } else if self.parse_keywords(&[ + Keyword::FUTURE, + Keyword::TABLES, + Keyword::IN, + Keyword::DATABASE, + ]) { + Some(GrantObjects::FutureTablesInDatabase { + databases: self.parse_comma_separated(|p| p.parse_object_name(false))?, + }) + } else if self.parse_keywords(&[ + Keyword::FUTURE, + Keyword::STAGES, + Keyword::IN, + Keyword::SCHEMA, + ]) { + Some(GrantObjects::FutureStagesInSchema { + schemas: self.parse_comma_separated(|p| p.parse_object_name(false))?, + }) + } else if self.parse_keywords(&[ + Keyword::FUTURE, + Keyword::FILE, + Keyword::FORMATS, + Keyword::IN, + Keyword::SCHEMA, + ]) { + Some(GrantObjects::FutureFileFormatsInSchema { + schemas: self.parse_comma_separated(|p| p.parse_object_name(false))?, + }) + } else if self.parse_keywords(&[ + Keyword::FUTURE, + Keyword::FUNCTIONS, + Keyword::IN, + Keyword::SCHEMA, + ]) { + Some(GrantObjects::FutureFunctionsInSchema { + schemas: self.parse_comma_separated(|p| p.parse_object_name(false))?, + }) } else if self.parse_keywords(&[Keyword::RESOURCE, Keyword::MONITOR]) { Some(GrantObjects::ResourceMonitors( self.parse_comma_separated(|p| p.parse_object_name(false))?, @@ -18121,6 +18729,14 @@ impl<'a> Parser<'a> { Some(GrantObjects::ExternalVolumes( self.parse_comma_separated(|p| p.parse_object_name(false))?, )) + } else if self.parse_keywords(&[Keyword::FILE, Keyword::FORMAT]) { + Some(GrantObjects::FileFormats( + self.parse_comma_separated(|p| p.parse_object_name(false))?, + )) + } else if self.parse_keyword(Keyword::STAGE) { + Some(GrantObjects::Stages( + self.parse_comma_separated(|p| p.parse_object_name(false))?, + )) } else { let object_type = self.parse_one_of_keywords(&[ Keyword::SEQUENCE, @@ -18285,6 +18901,8 @@ impl<'a> Parser<'a> { }) } else if self.parse_keyword(Keyword::READ) { Ok(Action::Read) + } else if self.parse_keyword(Keyword::WRITE) { + Ok(Action::Write) } else if self.parse_keyword(Keyword::REPLICATE) { Ok(Action::Replicate) } else if self.parse_keyword(Keyword::ROLE) { @@ -18333,6 +18951,8 @@ impl<'a> Parser<'a> { Some(ActionCreateObjectType::OrganiationListing) } else if self.parse_keywords(&[Keyword::REPLICATION, Keyword::GROUP]) { Some(ActionCreateObjectType::ReplicationGroup) + } else if self.parse_keywords(&[Keyword::DATABASE, Keyword::ROLE]) { + Some(ActionCreateObjectType::DatabaseRole) } // Single-word object types else if self.parse_keyword(Keyword::ACCOUNT) { @@ -18349,10 +18969,20 @@ impl<'a> Parser<'a> { Some(ActionCreateObjectType::Schema) } else if self.parse_keyword(Keyword::SHARE) { Some(ActionCreateObjectType::Share) + } else if self.parse_keyword(Keyword::TABLE) { + Some(ActionCreateObjectType::Table) } else if self.parse_keyword(Keyword::USER) { Some(ActionCreateObjectType::User) } else if self.parse_keyword(Keyword::WAREHOUSE) { Some(ActionCreateObjectType::Warehouse) + } else if matches!(self.peek_token_ref().token, Token::Word(ref w) if w.keyword == Keyword::NoKeyword) + { + // Qualified "class" create privilege, e.g. + // `CREATE SNOWFLAKE.ML.ANOMALY_DETECTION`. + self.maybe_parse(|p| p.parse_object_name(false)) + .ok() + .flatten() + .map(ActionCreateObjectType::Class) } else { None } @@ -19055,7 +19685,10 @@ impl<'a> Parser<'a> { } } - fn parse_table_function_args(&mut self) -> Result { + /// Parse the parenthesized argument list of a table-valued function, + /// assuming the opening `(` has already been consumed, up to and + /// including the closing `)`. + pub fn parse_table_function_args(&mut self) -> Result { if self.consume_token(&Token::RParen) { return Ok(TableFunctionArgs { args: vec![], @@ -20211,7 +20844,20 @@ impl<'a> Parser<'a> { .is_some(); let unlogged = self.parse_keyword(Keyword::UNLOGGED); let table = self.parse_keyword(Keyword::TABLE); - let name = self.parse_object_name(false)?; + let name = if self.dialect.supports_select_into_placeholder_target() + && self.peek_token_ref().token == Token::Colon + { + // Snowflake scripting: `SELECT ... INTO :var` targets a local + // variable. Keep the `:name` placeholder text as the object-name + // part so the round-trip is preserved and downstream can tell it + // apart from a bare table target. + let placeholder = self.parse_value()?; + ObjectName(vec![ObjectNamePart::Identifier(Ident::new( + placeholder.to_string(), + ))]) + } else { + self.parse_object_name(false)? + }; Ok(SelectInto { temporary, @@ -20373,7 +21019,11 @@ impl<'a> Parser<'a> { /// ``` /// /// See [Postgres docs](https://www.postgresql.org/docs/current/sql-createsequence.html) for more details. - pub fn parse_create_sequence(&mut self, temporary: bool) -> Result { + pub fn parse_create_sequence( + &mut self, + or_replace: bool, + temporary: bool, + ) -> Result { //[ IF NOT EXISTS ] let if_not_exists = self.parse_keywords(&[Keyword::IF, Keyword::NOT, Keyword::EXISTS]); //name @@ -20396,6 +21046,7 @@ impl<'a> Parser<'a> { }; Ok(Statement::CreateSequence { temporary, + or_replace, if_not_exists, name, data_type, @@ -20405,46 +21056,47 @@ impl<'a> Parser<'a> { } fn parse_create_sequence_options(&mut self) -> Result, ParserError> { + // Options may appear in any order (Snowflake) and each keyword may use an + // optional `=` assignment form (`START = 1`, `INCREMENT = 1`). Snowflake's + // `ORDER`/`NOORDER` ordering guarantee is accepted and ignored. let mut sequence_options = vec![]; - //[ INCREMENT [ BY ] increment ] - if self.parse_keywords(&[Keyword::INCREMENT]) { - if self.parse_keywords(&[Keyword::BY]) { - sequence_options.push(SequenceOptions::IncrementBy(self.parse_number()?, true)); - } else { - sequence_options.push(SequenceOptions::IncrementBy(self.parse_number()?, false)); - } - } - //[ MINVALUE minvalue | NO MINVALUE ] - if self.parse_keyword(Keyword::MINVALUE) { - sequence_options.push(SequenceOptions::MinValue(Some(self.parse_number()?))); - } else if self.parse_keywords(&[Keyword::NO, Keyword::MINVALUE]) { - sequence_options.push(SequenceOptions::MinValue(None)); - } - //[ MAXVALUE maxvalue | NO MAXVALUE ] - if self.parse_keywords(&[Keyword::MAXVALUE]) { - sequence_options.push(SequenceOptions::MaxValue(Some(self.parse_number()?))); - } else if self.parse_keywords(&[Keyword::NO, Keyword::MAXVALUE]) { - sequence_options.push(SequenceOptions::MaxValue(None)); - } - - //[ START [ WITH ] start ] - if self.parse_keywords(&[Keyword::START]) { - if self.parse_keywords(&[Keyword::WITH]) { - sequence_options.push(SequenceOptions::StartWith(self.parse_number()?, true)); + loop { + if self.parse_keyword(Keyword::INCREMENT) { + //[ INCREMENT [ BY ] [ = ] increment ] + let by = self.parse_keyword(Keyword::BY); + let _ = self.consume_token(&Token::Eq); + sequence_options.push(SequenceOptions::IncrementBy(self.parse_number()?, by)); + } else if self.parse_keywords(&[Keyword::NO, Keyword::MINVALUE]) { + sequence_options.push(SequenceOptions::MinValue(None)); + } else if self.parse_keyword(Keyword::MINVALUE) { + //[ MINVALUE [ = ] minvalue ] + let _ = self.consume_token(&Token::Eq); + sequence_options.push(SequenceOptions::MinValue(Some(self.parse_number()?))); + } else if self.parse_keywords(&[Keyword::NO, Keyword::MAXVALUE]) { + sequence_options.push(SequenceOptions::MaxValue(None)); + } else if self.parse_keyword(Keyword::MAXVALUE) { + //[ MAXVALUE [ = ] maxvalue ] + let _ = self.consume_token(&Token::Eq); + sequence_options.push(SequenceOptions::MaxValue(Some(self.parse_number()?))); + } else if self.parse_keyword(Keyword::START) { + //[ START [ WITH ] [ = ] start ] + let with = self.parse_keyword(Keyword::WITH); + let _ = self.consume_token(&Token::Eq); + sequence_options.push(SequenceOptions::StartWith(self.parse_number()?, with)); + } else if self.parse_keyword(Keyword::CACHE) { + //[ CACHE [ = ] cache ] + let _ = self.consume_token(&Token::Eq); + sequence_options.push(SequenceOptions::Cache(self.parse_number()?)); + } else if self.parse_keywords(&[Keyword::NO, Keyword::CYCLE]) { + sequence_options.push(SequenceOptions::Cycle(true)); + } else if self.parse_keyword(Keyword::CYCLE) { + sequence_options.push(SequenceOptions::Cycle(false)); + } else if self.parse_keyword(Keyword::ORDER) || self.parse_keyword(Keyword::NOORDER) { + // Snowflake ordering guarantee — accepted, no effect on emulation. } else { - sequence_options.push(SequenceOptions::StartWith(self.parse_number()?, false)); + break; } } - //[ CACHE cache ] - if self.parse_keywords(&[Keyword::CACHE]) { - sequence_options.push(SequenceOptions::Cache(self.parse_number()?)); - } - // [ [ NO ] CYCLE ] - if self.parse_keywords(&[Keyword::NO, Keyword::CYCLE]) { - sequence_options.push(SequenceOptions::Cycle(true)); - } else if self.parse_keywords(&[Keyword::CYCLE]) { - sequence_options.push(SequenceOptions::Cycle(false)); - } Ok(sequence_options) } @@ -20557,19 +21209,7 @@ impl<'a> Parser<'a> { self.expect_keyword_is(Keyword::AS)?; - // Snowflake encloses the procedure body in a dollar-quoted string - // (e.g. `AS $$ BEGIN … END $$`). Re-parse its content as a - // conditional-statements block so the body is always typed as - // `ConditionalStatements` regardless of how it was written. - let body = match self.peek_token().token.clone() { - Token::DollarQuotedString(dqs) => { - self.next_token(); // consume the dollar-quoted string token - Parser::new(self.dialect) - .try_with_sql(&dqs.value)? - .parse_conditional_statements(&[Keyword::END])? - } - _ => self.parse_conditional_statements(&[Keyword::END])?, - }; + let body = self.parse_procedure_body()?; Ok(Statement::CreateProcedure { name, @@ -20583,6 +21223,32 @@ impl<'a> Parser<'a> { }) } + /// Parse a stored-procedure body following the `AS` keyword. + /// + /// Snowflake encloses the body in a dollar-quoted string + /// (e.g. `AS $$ BEGIN … END $$`) or, equivalently, a single-quoted string + /// with interior quotes doubled (`AS ' … '`); it may also be written bare + /// (`AS BEGIN … END`). In every case the body is typed as + /// `ConditionalStatements`. The tokenizer already unescapes doubled `''` + /// quotes, so the string value is the plain scripting text. + pub(crate) fn parse_procedure_body(&mut self) -> Result { + match self.peek_token().token.clone() { + Token::DollarQuotedString(dqs) => { + self.next_token(); // consume the dollar-quoted string token + Parser::new(self.dialect) + .try_with_sql(&dqs.value)? + .parse_conditional_statements(&[Keyword::END]) + } + Token::SingleQuotedString(body) => { + self.next_token(); // consume the single-quoted string token + Parser::new(self.dialect) + .try_with_sql(&body)? + .parse_conditional_statements(&[Keyword::END]) + } + _ => self.parse_conditional_statements(&[Keyword::END]), + } + } + /// Parse a window specification. pub fn parse_window_spec(&mut self) -> Result { let window_name = match &self.peek_token_ref().token { @@ -21217,6 +21883,7 @@ impl<'a> Parser<'a> { &mut self, parenthesized: bool, end_words: &[Keyword], + optional_equals: bool, ) -> Result { let mut options: Vec = Vec::new(); let mut delimiter = KeyValueOptionsDelimiter::Space; @@ -21238,7 +21905,7 @@ impl<'a> Parser<'a> { continue; } Token::Word(w) if !end_words.contains(&w.keyword) => { - options.push(self.parse_key_value_option(&w)?) + options.push(self.parse_key_value_option(&w, optional_equals)?) } Token::Word(w) if end_words.contains(&w.keyword) => { self.prev_token(); @@ -21256,12 +21923,20 @@ impl<'a> Parser<'a> { Ok(KeyValueOptions { delimiter, options }) } - /// Parses a `KEY = VALUE` construct based on the specified key + /// Parses a `KEY = VALUE` construct based on the specified key. + /// + /// When `optional_equals` is set the `=` separator may be omitted + /// (`KEY VALUE`), as Snowflake accepts for `CREATE USER` options. pub(crate) fn parse_key_value_option( &mut self, key: &Word, + optional_equals: bool, ) -> Result { - self.expect_token(&Token::Eq)?; + if optional_equals { + let _ = self.consume_token(&Token::Eq); + } else { + self.expect_token(&Token::Eq)?; + } let peeked_token = self.peek_token(); match peeked_token.token { Token::SingleQuotedString(_) => Ok(KeyValueOption { @@ -21306,7 +21981,7 @@ impl<'a> Parser<'a> { None => Ok(KeyValueOption { option_name: key.value.clone(), option_value: KeyValueOptionKind::KeyValueOptions(Box::new( - self.parse_key_value_options(true, &[])?, + self.parse_key_value_options(true, &[], optional_equals)?, )), }), } diff --git a/src/tokenizer.rs b/src/tokenizer.rs index d9f131f8fc..4b6fda3c29 100644 --- a/src/tokenizer.rs +++ b/src/tokenizer.rs @@ -2287,17 +2287,21 @@ impl<'a> Tokenizer<'a> { num_consecutive_quotes = 0; - if let Some(next) = chars.peek() { + if let Some(&next) = chars.peek() { if !self.unescape || (self.dialect.ignores_wildcard_escapes() - && (*next == '%' || *next == '_')) + && (next == '%' || next == '_')) { // In no-escape mode, the given query has to be saved completely // including backslashes. Similarly, with ignore_like_wildcard_escapes, // the backslash is not stripped. s.push(ch); - s.push(*next); + s.push(next); chars.next(); // consume next + } else if self.dialect.supports_snowflake_string_literal_escapes() { + let escape_loc = chars.location(); + chars.next(); // consume the escape indicator + s.push(self.unescape_snowflake_escape(next, chars, escape_loc)?); } else { let n = match next { '0' => '\0', @@ -2308,7 +2312,7 @@ impl<'a> Tokenizer<'a> { 'r' => '\r', 't' => '\t', 'Z' => '\u{1a}', - _ => *next, + _ => next, }; s.push(n); chars.next(); // consume next @@ -2331,6 +2335,172 @@ impl<'a> Tokenizer<'a> { self.tokenizer_error(error_loc, "Unterminated string literal") } + /// Decode a single Snowflake backslash escape (the backslash and the + /// `indicator` char have already been consumed). Octal `\ooo`, hex `\xhh` + /// and unicode `\uhhhh` each yield a Unicode code point; recognized control + /// letters map to their control character; every other letter collapses to + /// itself. Malformed `\x` / `\u` raise a tokenizer error, matching real + /// Snowflake. + fn unescape_snowflake_escape( + &self, + indicator: char, + chars: &mut State, + loc: Location, + ) -> Result { + match indicator { + 'x' => { + let mut digits = String::new(); + while digits.len() < 2 { + match chars.peek() { + Some(c) if c.is_ascii_hexdigit() => { + if let Some(c) = chars.next() { + digits.push(c); + } + } + _ => break, + } + } + if digits.len() < 2 { + return self.tokenizer_error( + loc, + format!( + "Invalid hex escape sequence '\\x{digits}'; should be exactly 2 digits." + ), + ); + } + self.code_point(&digits, 16, loc) + } + 'u' => self.unescape_unicode(chars, loc), + // Octal `\ooo`: up to three octal digits, but a third digit is only + // consumed when the running value stays within a single byte + // (<= 0xFF); otherwise it is left as a literal character. + '0'..='7' => { + let mut value = indicator.to_digit(8).unwrap_or(0); + // Second octal digit always fits in a byte (max 0o77 = 63). + if let Some(d) = chars.peek().and_then(|c| c.to_digit(8)) { + value = value * 8 + d; + chars.next(); + // Third digit only when the byte does not overflow. + if let Some(d) = chars.peek().and_then(|c| c.to_digit(8)) { + if value * 8 + d <= 0xFF { + value = value * 8 + d; + chars.next(); + } + } + } + char::from_u32(value).map_or_else( + || self.tokenizer_error(loc, "Invalid octal escape sequence."), + Ok, + ) + } + 'b' => Ok('\u{8}'), + 'f' => Ok('\u{c}'), + 'n' => Ok('\n'), + 'r' => Ok('\r'), + 't' => Ok('\t'), + other => Ok(other), + } + } + + fn code_point(&self, digits: &str, radix: u32, loc: Location) -> Result { + u32::from_str_radix(digits, radix) + .ok() + .and_then(char::from_u32) + .map_or_else(|| self.tokenizer_error(loc, "Invalid escape sequence."), Ok) + } + + /// Decode a `\uhhhh` escape (the `\u` has already been consumed). A high + /// surrogate must be followed by a `\uhhhh` low surrogate and combines with + /// it into an astral code point; an unpaired high or low surrogate is an + /// error, matching real Snowflake. + fn unescape_unicode(&self, chars: &mut State, loc: Location) -> Result { + let value = self.read_hex4(chars, loc)?; + if (0xD800..=0xDBFF).contains(&value) { + return match self.read_low_surrogate(chars) { + Some(low) => { + let cp = 0x1_0000 + ((value - 0xD800) << 10) + (low - 0xDC00); + char::from_u32(cp).map_or_else( + || self.tokenizer_error(loc, "Invalid escape sequence."), + Ok, + ) + } + None => self.tokenizer_error( + loc, + format!( + "Invalid Unicode string literal; high surrogate '\\u{value:04X}' \ + must be followed by a low surrogate ('\\uDC00'-'\\uDFFF')." + ), + ), + }; + } + if (0xDC00..=0xDFFF).contains(&value) { + return self.tokenizer_error( + loc, + format!( + "Invalid Unicode string literal; low surrogate '\\u{value:04X}' \ + must be preceded by a high surrogate ('\\uD800'-'\\uDBFF')." + ), + ); + } + char::from_u32(value) + .map_or_else(|| self.tokenizer_error(loc, "Invalid escape sequence."), Ok) + } + + /// Read exactly four hex digits as a `u32`, erroring like Snowflake when + /// fewer than four are present. + fn read_hex4(&self, chars: &mut State, loc: Location) -> Result { + let mut digits = String::new(); + while digits.len() < 4 { + match chars.peek() { + Some(c) if c.is_ascii_hexdigit() => { + if let Some(c) = chars.next() { + digits.push(c); + } + } + _ => break, + } + } + if digits.len() < 4 { + return self.tokenizer_error( + loc, + format!("Invalid unicode escape sequence '\\u{digits}'; should be exactly 4 digits."), + ); + } + u32::from_str_radix(&digits, 16) + .map_or_else(|_| self.tokenizer_error(loc, "Invalid unicode escape sequence."), Ok) + } + + /// Consume a trailing `\uhhhh` low surrogate, returning its value when + /// present and in range. Consumes greedily on the error path — the caller + /// aborts the whole token, so partial consumption is irrelevant. + fn read_low_surrogate(&self, chars: &mut State) -> Option { + if chars.peek() != Some(&'\\') { + return None; + } + chars.next(); + if chars.peek() != Some(&'u') { + return None; + } + chars.next(); + let mut digits = String::new(); + while digits.len() < 4 { + match chars.peek() { + Some(c) if c.is_ascii_hexdigit() => { + if let Some(c) = chars.next() { + digits.push(c); + } + } + _ => break, + } + } + if digits.len() < 4 { + return None; + } + u32::from_str_radix(&digits, 16) + .ok() + .filter(|low| (0xDC00..=0xDFFF).contains(low)) + } + fn tokenize_multiline_comment( &self, chars: &mut State, @@ -3878,10 +4048,13 @@ mod tests { (r#"'%a\'%b'"#, r#"%a\'%b"#, r#"%a'%b"#), (r#"'a\'\'b\'c\'d'"#, r#"a\'\'b\'c\'d"#, r#"a''b'c'd"#), (r#"'\\'"#, r#"\\"#, r#"\"#), + // Snowflake keeps `\0` as NUL, maps the control-letter escapes, and + // collapses unrecognized letters (`\a` -> a, `\Z` -> Z) to the bare + // letter rather than a control character. ( r#"'\0\a\b\f\n\r\t\Z'"#, r#"\0\a\b\f\n\r\t\Z"#, - "\0\u{7}\u{8}\u{c}\n\r\t\u{1a}", + "\0a\u{8}\u{c}\n\r\tZ", ), (r#"'\"'"#, r#"\""#, "\""), (r#"'\\a\\b\'c'"#, r#"\\a\\b\'c"#, r#"\a\b'c"#), @@ -3890,6 +4063,20 @@ mod tests { (r#"'\q'"#, r#"\q"#, r#"q"#), (r#"'\%\_'"#, r#"\%\_"#, r#"%_"#), (r#"'\\%\\_'"#, r#"\\%\\_"#, r#"\%\_"#), + // Hex `\xhh` and unicode `\uhhhh` yield a code point; octal `\ooo` + // takes a third digit only when the byte does not overflow. + (r#"'\x41'"#, r#"\x41"#, "A"), + (r#"'\u0041'"#, r#"\u0041"#, "A"), + (r#"'\u00e9'"#, r#"\u00e9"#, "\u{e9}"), + (r#"'\ud83d\ude00'"#, r#"\ud83d\ude00"#, "\u{1f600}"), + (r#"'\xff'"#, r#"\xff"#, "\u{ff}"), + (r#"'A'"#, r#"A"#, "A"), + (r#"'é'"#, r#"é"#, "\u{e9}"), + (r#"'ꯍ'"#, r#"ꯍ"#, "\u{abcd}"), + (r#"'\101'"#, r#"\101"#, "A"), + (r#"'\1012'"#, r#"\1012"#, "A2"), + (r#"'\777'"#, r#"\777"#, "\u{3f}7"), + (r#"'\400'"#, r#"\400"#, "\u{20}0"), ] { let tokens = Tokenizer::new(&dialect, sql) .with_unescape(false) diff --git a/tests/sqlparser_bigquery.rs b/tests/sqlparser_bigquery.rs index bab18b786d..5683204c57 100644 --- a/tests/sqlparser_bigquery.rs +++ b/tests/sqlparser_bigquery.rs @@ -45,11 +45,40 @@ fn parse_literal_string() { r#""""triple-double'unescaped""", "#, // line 1, column 118 r#"'''triple-single"unescaped'''"#, // line 1, column 131 ); + // With the escaper doubling every quote unconditionally, an unescape=false + // value containing a quote re-serializes to the quote-doubled form, so the + // serialization no longer round-trips verbatim. Parsing (the point of this + // test) is unchanged; assert the parsed AST and the canonical doubled form. + let canonical = concat!( + "SELECT ", + "'single', ", + r#""double", "#, + "'''triple-single''', ", + r#""""triple-double""", "#, + r#"'single\''escaped', "#, + r#"'''triple-single\'escaped''', "#, + r#"'''triple-single'unescaped''', "#, + r#""double\""escaped", "#, + r#""""triple-double\"escaped""", "#, + r#""""triple-double"unescaped""", "#, + r#""""triple-double'unescaped""", "#, + r#"'''triple-single"unescaped'''"#, + ); let dialect = TestedDialects::new_with_options( vec![Box::new(BigQueryDialect {})], ParserOptions::new().with_unescape(false), ); - let select = dialect.verified_only_select(sql); + let statements = dialect.parse_sql_statements(sql).unwrap(); + assert_eq!(1, statements.len()); + let stmt = statements.into_iter().next().unwrap(); + assert_eq!(canonical, stmt.to_string()); + let select = match stmt { + Statement::Query(query) => match *query.body { + SetExpr::Select(s) => *s, + _ => panic!("Expected SetExpr::Select"), + }, + _ => panic!("Expected Query"), + }; assert_eq!(12, select.projection.len()); assert_eq!( &Expr::Value(Value::SingleQuotedString("single".into()).with_empty_span()), diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index 9f2311be92..7761701859 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -1558,13 +1558,26 @@ fn parse_escaped_single_quote_string_predicate_with_no_escape() { let sql = "SELECT id, fname, lname FROM customer \ WHERE salary <> 'Jim''s salary'"; - let ast = TestedDialects::new_with_options( + // unescape=false keeps the doubled quote in the value; the escaper now + // re-doubles on display (`'Jim''''s salary'`), so this no longer + // round-trips verbatim. Parsing is the point here — assert the AST. + let stmt = TestedDialects::new_with_options( vec![Box::new(MySqlDialect {})], ParserOptions::new() .with_trailing_commas(true) .with_unescape(false), ) - .verified_only_select(sql); + .parse_sql_statements(sql) + .unwrap() + .pop() + .unwrap(); + let ast = match stmt { + Statement::Query(query) => match *query.body { + SetExpr::Select(s) => *s, + _ => panic!("Expected SetExpr::Select"), + }, + _ => panic!("Expected Query"), + }; assert_eq!( Some(Expr::BinaryOp { @@ -4098,7 +4111,8 @@ fn parse_create_table_with_constraint_characteristics() { characteristics: Some(ConstraintCharacteristics { deferrable: Some(true), initially: Some(DeferrableInitial::Deferred), - enforced: None + enforced: None, + ..Default::default() }), } .into(), @@ -4115,6 +4129,7 @@ fn parse_create_table_with_constraint_characteristics() { deferrable: Some(true), initially: Some(DeferrableInitial::Immediate), enforced: None, + ..Default::default() }), } .into(), @@ -4131,6 +4146,7 @@ fn parse_create_table_with_constraint_characteristics() { deferrable: Some(false), initially: Some(DeferrableInitial::Deferred), enforced: Some(false), + ..Default::default() }), } .into(), @@ -4147,6 +4163,7 @@ fn parse_create_table_with_constraint_characteristics() { deferrable: Some(false), initially: Some(DeferrableInitial::Immediate), enforced: Some(true), + ..Default::default() }), } .into(), @@ -4213,6 +4230,7 @@ fn parse_create_table_column_constraint_characteristics() { deferrable, initially, enforced, + ..Default::default() }) } else { None @@ -10924,10 +10942,21 @@ fn parse_cursor() { #[test] fn parse_show_functions() { + // Dialects that do not support `LIKE` before `IN` place the filter in the + // suffix position. assert_eq!( - verified_stmt("SHOW FUNCTIONS LIKE 'pattern'"), + all_dialects_where(|d| !d.supports_show_like_before_in()) + .verified_stmt("SHOW FUNCTIONS LIKE 'pattern'"), Statement::ShowFunctions { - filter: Some(ShowStatementFilter::Like("pattern".into())), + show_options: ShowStatementOptions { + show_in: None, + starts_with: None, + limit: None, + limit_from: None, + filter_position: Some(ShowStatementFilterPosition::Suffix( + ShowStatementFilter::Like("pattern".into()) + )), + }, } ); } @@ -11254,7 +11283,11 @@ fn parse_escaped_string_with_unescape() { let escaping_dialects = &all_dialects_where(|dialect| dialect.supports_string_literal_backslash_escape()); let no_wildcard_exception = &all_dialects_where(|dialect| { - dialect.supports_string_literal_backslash_escape() && !dialect.ignores_wildcard_escapes() + dialect.supports_string_literal_backslash_escape() + && !dialect.ignores_wildcard_escapes() + // Snowflake decodes `\Z` / `\a` as the bare letter, not a control + // character, so it is exercised separately. + && !dialect.supports_snowflake_string_literal_escapes() }); let with_wildcard_exception = &all_dialects_where(|dialect| { dialect.supports_string_literal_backslash_escape() && dialect.ignores_wildcard_escapes() @@ -17321,6 +17354,7 @@ fn parse_truncate_only() { table_names, partitions: None, table: true, + materialized_view: false, if_exists: false, identity: None, cascade: None, @@ -17773,6 +17807,11 @@ fn parse_create_user() { verified_stmt("CREATE OR REPLACE USER u1"); verified_stmt("CREATE OR REPLACE USER IF NOT EXISTS u1"); verified_stmt("CREATE OR REPLACE USER IF NOT EXISTS u1 PASSWORD='secret'"); + // Snowflake accepts options without the `=` separator. + one_statement_parses_to( + "CREATE USER u1 PASSWORD 'secret'", + "CREATE USER u1 PASSWORD='secret'", + ); let dialects = all_dialects_where(|d| d.supports_boolean_literals()); dialects.one_statement_parses_to( "CREATE OR REPLACE USER IF NOT EXISTS u1 PASSWORD='secret' MUST_CHANGE_PASSWORD=TRUE", diff --git a/tests/sqlparser_duckdb.rs b/tests/sqlparser_duckdb.rs index df62685808..1b5d441a4f 100644 --- a/tests/sqlparser_duckdb.rs +++ b/tests/sqlparser_duckdb.rs @@ -781,6 +781,8 @@ fn test_duckdb_union_datatype() { base_location: Default::default(), external_volume: Default::default(), catalog: Default::default(), + catalog_table_name: Default::default(), + auto_refresh: Default::default(), catalog_sync: Default::default(), storage_serialization_policy: Default::default(), table_options: CreateTableOptions::None, diff --git a/tests/sqlparser_mssql.rs b/tests/sqlparser_mssql.rs index d56f2ac1c3..668bb00c6b 100644 --- a/tests/sqlparser_mssql.rs +++ b/tests/sqlparser_mssql.rs @@ -1996,6 +1996,7 @@ fn parse_create_table_with_valid_options() { copy_grants: false, enable_schema_evolution: None, change_tracking: None, + stage_file_format: None, data_retention_time_in_days: None, max_data_extension_time_in_days: None, default_ddl_collation: None, @@ -2006,6 +2007,8 @@ fn parse_create_table_with_valid_options() { base_location: None, external_volume: None, catalog: None, + catalog_table_name: None, + auto_refresh: None, catalog_sync: None, storage_serialization_policy: None, table_options: CreateTableOptions::With(with_options), @@ -2170,6 +2173,7 @@ fn parse_create_table_with_identity_column() { copy_grants: false, enable_schema_evolution: None, change_tracking: None, + stage_file_format: None, data_retention_time_in_days: None, max_data_extension_time_in_days: None, default_ddl_collation: None, @@ -2180,6 +2184,8 @@ fn parse_create_table_with_identity_column() { base_location: None, external_volume: None, catalog: None, + catalog_table_name: None, + auto_refresh: None, catalog_sync: None, storage_serialization_policy: None, table_options: CreateTableOptions::None, diff --git a/tests/sqlparser_mysql.rs b/tests/sqlparser_mysql.rs index 89ed6a9ca1..8d85acb157 100644 --- a/tests/sqlparser_mysql.rs +++ b/tests/sqlparser_mysql.rs @@ -1508,7 +1508,10 @@ fn parse_escaped_quote_identifiers_with_no_escape() { require_semicolon_stmt_delimiter: true, } ) - .verified_stmt(sql), + .parse_sql_statements(sql) + .unwrap() + .pop() + .unwrap(), Statement::Query(Box::new(Query { with: None, body: Box::new(SetExpr::Select(Box::new(Select { @@ -1610,7 +1613,10 @@ fn parse_escaped_backticks_with_no_escape() { vec![Box::new(MySqlDialect {})], ParserOptions::new().with_unescape(false) ) - .verified_stmt(sql), + .parse_sql_statements(sql) + .unwrap() + .pop() + .unwrap(), Statement::Query(Box::new(Query { with: None, body: Box::new(SetExpr::Select(Box::new(Select { @@ -1668,26 +1674,24 @@ fn parse_unterminated_escape() { #[test] fn check_roundtrip_of_escaped_string() { + // The escaper doubles every quote unconditionally, so an unescape=false + // value that contains the quote character re-serializes to the + // quote-doubled form rather than the original spelling. Each case asserts + // the canonical serialization the parser now produces. let options = ParserOptions::new().with_unescape(false); - - TestedDialects::new_with_options(vec![Box::new(MySqlDialect {})], options.clone()) - .verified_stmt(r"SELECT 'I\'m fine'"); - TestedDialects::new_with_options(vec![Box::new(MySqlDialect {})], options.clone()) - .verified_stmt(r#"SELECT 'I''m fine'"#); - TestedDialects::new_with_options(vec![Box::new(MySqlDialect {})], options.clone()) - .verified_stmt(r"SELECT 'I\\\'m fine'"); - TestedDialects::new_with_options(vec![Box::new(MySqlDialect {})], options.clone()) - .verified_stmt(r"SELECT 'I\\\'m fine'"); - TestedDialects::new_with_options(vec![Box::new(MySqlDialect {})], options.clone()) - .verified_stmt(r#"SELECT "I\"m fine""#); - TestedDialects::new_with_options(vec![Box::new(MySqlDialect {})], options.clone()) - .verified_stmt(r#"SELECT "I""m fine""#); - TestedDialects::new_with_options(vec![Box::new(MySqlDialect {})], options.clone()) - .verified_stmt(r#"SELECT "I\\\"m fine""#); - TestedDialects::new_with_options(vec![Box::new(MySqlDialect {})], options.clone()) - .verified_stmt(r#"SELECT "I\\\"m fine""#); - TestedDialects::new_with_options(vec![Box::new(MySqlDialect {})], options.clone()) - .verified_stmt(r#"SELECT "I'm ''fine''""#); + let dialect = TestedDialects::new_with_options(vec![Box::new(MySqlDialect {})], options); + for (sql, canonical) in [ + (r"SELECT 'I\'m fine'", r"SELECT 'I\''m fine'"), + (r#"SELECT 'I''m fine'"#, r#"SELECT 'I''''m fine'"#), + (r"SELECT 'I\\\'m fine'", r"SELECT 'I\\\''m fine'"), + (r#"SELECT "I\"m fine""#, r#"SELECT "I\""m fine""#), + (r#"SELECT "I""m fine""#, r#"SELECT "I""""m fine""#), + (r#"SELECT "I\\\"m fine""#, r#"SELECT "I\\\""m fine""#), + (r#"SELECT "I'm ''fine''""#, r#"SELECT "I'm ''fine''""#), + ] { + let stmt = dialect.parse_sql_statements(sql).unwrap().pop().unwrap(); + assert_eq!(canonical, stmt.to_string()); + } } #[test] diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index 141ca0bec6..c8ac31066c 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -5457,6 +5457,7 @@ fn parse_truncate() { table_names, partitions: None, table: false, + materialized_view: false, if_exists: false, identity: None, cascade: None, @@ -5483,6 +5484,7 @@ fn parse_truncate_with_options() { table_names, partitions: None, table: true, + materialized_view: false, if_exists: false, identity: Some(TruncateIdentityOption::Restart), cascade: Some(CascadeOption::Cascade), @@ -5519,6 +5521,7 @@ fn parse_truncate_with_table_list() { table_names, partitions: None, table: true, + materialized_view: false, if_exists: false, identity: Some(TruncateIdentityOption::Restart), cascade: Some(CascadeOption::Cascade), @@ -5543,6 +5546,7 @@ fn parse_truncate_with_descendant() { table_names, partitions: None, table: true, + materialized_view: false, if_exists: false, identity: None, cascade: None, @@ -5577,6 +5581,7 @@ fn parse_truncate_with_descendant() { table_names, partitions: None, table: true, + materialized_view: false, if_exists: false, identity: Some(TruncateIdentityOption::Restart), cascade: None, @@ -6439,6 +6444,7 @@ fn parse_create_trigger_with_multiple_events_and_deferrable() { deferrable: Some(true), initially: Some(DeferrableInitial::Deferred), enforced: None, + ..Default::default() }), }); @@ -6700,6 +6706,7 @@ fn parse_trigger_related_functions() { copy_grants: false, enable_schema_evolution: None, change_tracking: None, + stage_file_format: None, data_retention_time_in_days: None, max_data_extension_time_in_days: None, default_ddl_collation: None, @@ -6710,6 +6717,8 @@ fn parse_trigger_related_functions() { base_location: None, external_volume: None, catalog: None, + catalog_table_name: None, + auto_refresh: None, catalog_sync: None, storage_serialization_policy: None, table_options: CreateTableOptions::None, diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index 244d422961..4cea8d2b75 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -23,7 +23,7 @@ use sqlparser::ast::helpers::key_value_options::{KeyValueOption, KeyValueOptionK use sqlparser::ast::helpers::stmt_data_loading::{StageLoadSelectItem, StageLoadSelectItemKind}; use sqlparser::ast::*; use sqlparser::dialect::{Dialect, GenericDialect, SnowflakeDialect}; -use sqlparser::parser::{ParserError, ParserOptions}; +use sqlparser::parser::{Parser, ParserError, ParserOptions}; use sqlparser::tokenizer::*; use test_utils::*; @@ -71,6 +71,103 @@ fn parse_sf_create_secure_view_and_materialized_view() { } } +#[test] +fn parse_sf_create_stream_on_table_and_view() { + for (sql, expected_kind) in [ + ("CREATE STREAM s ON TABLE t", StreamSourceKind::Table), + ("CREATE STREAM s ON VIEW v", StreamSourceKind::View), + ( + "CREATE OR REPLACE STREAM s ON TABLE t", + StreamSourceKind::Table, + ), + ( + "CREATE OR REPLACE STREAM s ON VIEW v", + StreamSourceKind::View, + ), + ( + "CREATE STREAM IF NOT EXISTS s ON TABLE t", + StreamSourceKind::Table, + ), + ( + "CREATE STREAM IF NOT EXISTS s ON VIEW v", + StreamSourceKind::View, + ), + ] { + match snowflake().verified_stmt(sql) { + Statement::CreateStream { source_kind, .. } => { + assert_eq!(source_kind, expected_kind); + } + _ => unreachable!(), + } + assert_eq!(snowflake().verified_stmt(sql).to_string(), sql); + } +} + +#[test] +fn parse_sf_informational_constraint_properties() { + let canonical = "CREATE TABLE t (id INT, CONSTRAINT pk PRIMARY KEY (id) NOT ENFORCED DISABLE NOVALIDATE RELY)"; + match snowflake().verified_stmt(canonical) { + Statement::CreateTable(CreateTable { constraints, .. }) => match &constraints[0] { + TableConstraint::PrimaryKey(pk) => assert_eq!( + pk.characteristics, + Some(ConstraintCharacteristics { + deferrable: None, + initially: None, + enforced: Some(false), + enabled: Some(false), + validated: Some(false), + rely: Some(true), + }) + ), + other => panic!("unexpected constraint: {other}"), + }, + other => panic!("unexpected statement: {other}"), + } + + // The properties are interchangeable in order. + snowflake().one_statement_parses_to( + "CREATE TABLE t (id INT, CONSTRAINT pk PRIMARY KEY (id) RELY DISABLE NOVALIDATE NOT ENFORCED)", + canonical, + ); + + snowflake().verified_stmt("CREATE TABLE t (id INT PRIMARY KEY NOT ENFORCED RELY)"); + snowflake().verified_stmt("CREATE TABLE t (id INT UNIQUE ENABLE VALIDATE NORELY)"); + snowflake() + .verified_stmt("CREATE TABLE t (id INT, CONSTRAINT u UNIQUE (id) NOT ENFORCED NORELY)"); + snowflake().verified_stmt( + "CREATE TABLE t (id INT, CONSTRAINT fk FOREIGN KEY (id) REFERENCES p(id) NOT ENFORCED RELY)", + ); + snowflake().verified_stmt("ALTER TABLE t ADD CONSTRAINT u UNIQUE (id) NOT ENFORCED RELY"); +} + +#[test] +fn parse_sf_clear_constraint_characteristics() { + let sql = "CREATE TABLE t (id INT UNIQUE ENABLE VALIDATE RELY, CONSTRAINT u UNIQUE (id) NOT ENFORCED RELY)"; + match snowflake().verified_stmt(sql) { + Statement::CreateTable(CreateTable { + mut columns, + mut constraints, + .. + }) => { + constraints[0].clear_characteristics(); + assert_eq!(constraints[0].to_string(), "CONSTRAINT u UNIQUE (id)"); + columns[0].clear_constraint_characteristics(); + assert_eq!(columns[0].to_string(), "id INT UNIQUE"); + } + other => panic!("unexpected statement: {other}"), + } +} + +#[test] +fn parse_informational_constraint_properties_are_dialect_gated() { + let sql = "CREATE TABLE t (id INT, CONSTRAINT pk PRIMARY KEY (id) RELY)"; + let err = Parser::parse_sql(&GenericDialect {}, sql).unwrap_err(); + assert_eq!( + err.to_string(), + "sql parser error: Expected: \',\' or \')\' after column definition, found: RELY at Line: 1, Column: 56" + ); +} + #[test] fn test_snowflake_create_or_replace_table() { let sql = "CREATE OR REPLACE TABLE my_table (a number)"; @@ -1051,6 +1148,141 @@ fn test_snowflake_create_iceberg_table_without_location() { ); } +#[test] +fn test_snowflake_create_iceberg_table_comma_separated_options() { + let canonical = "CREATE ICEBERG TABLE my_table (a INT) \ + EXTERNAL_VOLUME='volume' CATALOG='SNOWFLAKE' BASE_LOCATION='relative/path'"; + let with_commas = "CREATE ICEBERG TABLE my_table (a INT) \ + CATALOG='SNOWFLAKE', EXTERNAL_VOLUME='volume', BASE_LOCATION='relative/path'"; + match snowflake().one_statement_parses_to(with_commas, canonical) { + Statement::CreateTable(CreateTable { + iceberg, + columns, + external_volume, + catalog, + base_location, + .. + }) => { + assert!(iceberg); + assert_eq!(1, columns.len()); + assert_eq!("volume", external_volume.unwrap()); + assert_eq!("SNOWFLAKE", catalog.unwrap()); + assert_eq!("relative/path", base_location.unwrap()); + } + _ => unreachable!(), + } +} + +#[test] +fn test_snowflake_create_iceberg_table_externally_managed() { + match snowflake().verified_stmt( + "CREATE OR REPLACE ICEBERG TABLE my_table \ + CATALOG='my_catalog_integration' CATALOG_TABLE_NAME='db.tbl' AUTO_REFRESH=TRUE", + ) { + Statement::CreateTable(CreateTable { + or_replace, + iceberg, + columns, + catalog, + catalog_table_name, + auto_refresh, + base_location, + query, + .. + }) => { + assert!(or_replace); + assert!(iceberg); + assert!(columns.is_empty()); + assert_eq!("my_catalog_integration", catalog.unwrap()); + assert_eq!("db.tbl", catalog_table_name.unwrap()); + assert_eq!(Some(true), auto_refresh); + assert!(base_location.is_none()); + assert!(query.is_none()); + } + _ => unreachable!(), + } +} + +#[test] +fn test_snowflake_create_iceberg_table_external_catalog_no_base_location() { + match snowflake() + .verified_stmt("CREATE ICEBERG TABLE my_table (c1 TEXT) CATALOG='my_catalog_integration'") + { + Statement::CreateTable(CreateTable { + iceberg, + catalog, + catalog_table_name, + base_location, + .. + }) => { + assert!(iceberg); + assert_eq!("my_catalog_integration", catalog.unwrap()); + assert!(catalog_table_name.is_none()); + assert!(base_location.is_none()); + } + _ => unreachable!(), + } +} + +#[test] +fn test_snowflake_create_iceberg_table_ctas() { + match snowflake().verified_stmt( + "CREATE ICEBERG TABLE my_table EXTERNAL_VOLUME='volume' CATALOG='SNOWFLAKE' \ + BASE_LOCATION='relative/path' AS SELECT 1", + ) { + Statement::CreateTable(CreateTable { + iceberg, + columns, + query, + base_location, + .. + }) => { + assert!(iceberg); + assert!(columns.is_empty()); + assert!(query.is_some()); + assert_eq!("relative/path", base_location.unwrap()); + } + _ => unreachable!(), + } +} + +#[test] +fn test_snowflake_drop_iceberg_table() { + match snowflake().one_statement_parses_to( + "DROP ICEBERG TABLE IF EXISTS my_table PURGE", + "DROP TABLE IF EXISTS my_table PURGE", + ) { + Statement::Drop { + object_type, + if_exists, + names, + purge, + .. + } => { + assert_eq!(ObjectType::Table, object_type); + assert!(if_exists); + assert!(purge); + assert_eq!("my_table", names[0].to_string()); + } + _ => unreachable!(), + } + + match snowflake().one_statement_parses_to("DROP ICEBERG TABLE my_table", "DROP TABLE my_table") + { + Statement::Drop { + object_type, + if_exists, + purge, + .. + } => { + assert_eq!(ObjectType::Table, object_type); + assert!(!if_exists); + assert!(!purge); + } + _ => unreachable!(), + } +} + #[test] fn test_snowflake_create_table_trailing_options() { // Serialization to SQL assume that in `CREATE TABLE AS` the options come before the `AS ()` @@ -1139,6 +1371,25 @@ fn parse_sf_create_table_or_view_with_dollar_quoted_comment() { ); } +#[test] +fn parse_sf_dollar_quoted_literal_string() { + // Dollar-quoted text is accepted wherever a literal string is expected. + snowflake().one_statement_parses_to( + "COMMENT ON TABLE my_table IS $$table comment$$", + "COMMENT ON TABLE my_table IS 'table comment'", + ); + snowflake().one_statement_parses_to( + "COMMENT ON COLUMN my_table.col IS $$$$", + "COMMENT ON COLUMN my_table.col IS ''", + ); + + // Other dialects keep rejecting dollar-quoted text in a literal-string + // position. + assert!(TestedDialects::new(vec![Box::new(GenericDialect {})]) + .parse_sql_statements("COMMENT ON TABLE my_table IS $$table comment$$") + .is_err()); +} + #[test] fn parse_create_dynamic_table() { snowflake().verified_stmt(r#"CREATE OR REPLACE DYNAMIC TABLE my_dynamic_table TARGET_LAG='20 minutes' WAREHOUSE=mywh AS SELECT product_id, product_name FROM staging_table"#); @@ -1212,6 +1463,47 @@ fn parse_create_dynamic_table() { )); } +#[test] +fn parse_create_dynamic_table_snowflake_clauses() { + // Leading TRANSIENT, bare TARGET_LAG = DOWNSTREAM, INITIALIZATION_WAREHOUSE, + // SCHEDULER (quoted + bare), and IMMUTABLE WHERE all carry their values in + // the AST. + let sql = concat!( + "CREATE OR REPLACE TRANSIENT DYNAMIC TABLE t", + " TARGET_LAG='1 minute'", + " WAREHOUSE=my_wh", + " INITIALIZATION_WAREHOUSE=init_wh", + " SCHEDULER='DISABLE'", + " IMMUTABLE WHERE (id > 5)", + " AS SELECT id FROM staging_table" + ); + match snowflake().verified_stmt(sql) { + Statement::CreateTable(ct) => { + assert!(ct.transient); + assert!(ct.dynamic); + assert!(ct.or_replace); + assert_eq!(Some("1 minute".to_string()), ct.target_lag); + assert_eq!(Some("my_wh".to_string()), ct.warehouse); + assert_eq!(Some("init_wh".to_string()), ct.initialization_warehouse); + assert_eq!(Some("DISABLE".to_string()), ct.scheduler); + assert_eq!(Some("id > 5".to_string()), ct.immutable_where); + } + other => panic!("expected CreateTable, got {other:?}"), + } + + // Bare TARGET_LAG = DOWNSTREAM and bare SCHEDULER value. + match snowflake().one_statement_parses_to( + "CREATE DYNAMIC TABLE t TARGET_LAG=DOWNSTREAM WAREHOUSE=wh SCHEDULER=disable AS SELECT id FROM s", + "CREATE DYNAMIC TABLE t TARGET_LAG='DOWNSTREAM' WAREHOUSE=wh SCHEDULER='disable' AS SELECT id FROM s", + ) { + Statement::CreateTable(ct) => { + assert_eq!(Some("DOWNSTREAM".to_string()), ct.target_lag); + assert_eq!(Some("disable".to_string()), ct.scheduler); + } + other => panic!("expected CreateTable, got {other:?}"), + } +} + #[test] fn test_sf_derived_table_in_parenthesis() { // Nesting a subquery in an extra set of parentheses is non-standard, @@ -1498,6 +1790,21 @@ fn snowflake() -> TestedDialects { TestedDialects::new(vec![Box::new(SnowflakeDialect {})]) } +#[test] +fn parse_create_sequence_snowflake_options() { + // Snowflake accepts the `=` assignment form, options in any order, and a + // trailing ORDER/NOORDER guarantee. + for sql in [ + "CREATE SEQUENCE seq0 START = 1 INCREMENT = 1 ORDER", + "CREATE SEQUENCE seq0 INCREMENT = 2 START = 5 NOORDER", + "CREATE SEQUENCE seq0 START WITH 1 INCREMENT 1", + ] { + snowflake() + .parse_sql_statements(sql) + .unwrap_or_else(|e| panic!("failed to parse {sql:?}: {e}")); + } +} + fn snowflake_with_recursion_limit(recursion_limit: usize) -> TestedDialects { TestedDialects::new(vec![Box::new(SnowflakeDialect {})]).with_recursion_limit(recursion_limit) } @@ -1778,6 +2085,143 @@ fn test_alter_table_add_column_if_not_exists() { } } +#[test] +fn test_alter_table_add_multiple_columns() { + let sql = "ALTER TABLE tab ADD COLUMN a INT, b TEXT"; + match alter_table_op(snowflake().verified_stmt(sql)) { + AlterTableOperation::AddColumns { + column_keyword, + if_not_exists, + column_defs, + } => { + assert!(column_keyword); + assert!(!if_not_exists); + assert_eq!(column_defs.len(), 2); + assert_eq!(column_defs[0].name.to_string(), "a"); + assert_eq!(column_defs[1].name.to_string(), "b"); + } + _ => unreachable!(), + } + + let sql = "ALTER TABLE tab ADD COLUMN IF NOT EXISTS a INT, b TEXT"; + match alter_table_op(snowflake().verified_stmt(sql)) { + AlterTableOperation::AddColumns { + column_keyword, + if_not_exists, + column_defs, + } => { + assert!(column_keyword); + assert!(if_not_exists); + assert_eq!(column_defs.len(), 2); + } + _ => unreachable!(), + } + + // A single-column list still parses to the AddColumn variant. + let sql = "ALTER TABLE tab ADD COLUMN a INT"; + match alter_table_op(snowflake().verified_stmt(sql)) { + AlterTableOperation::AddColumn { column_def, .. } => { + assert_eq!(column_def.name.to_string(), "a"); + } + _ => unreachable!(), + } +} + +#[test] +fn test_alter_table_alter_column_comment() { + let sql = "ALTER TABLE tab ALTER COLUMN c COMMENT 'hello'"; + match alter_table_op(snowflake().verified_stmt(sql)) { + AlterTableOperation::AlterColumn { + column_name, + op: AlterColumnOperation::Comment { comment }, + } => { + assert_eq!(column_name.to_string(), "c"); + assert_eq!(comment, "hello"); + } + _ => unreachable!(), + } + + // Dollar-quoted comment values are accepted as plain string literals and + // canonicalise to single-quoted form on Display. + let sql = "ALTER TABLE tab ALTER COLUMN c COMMENT $$hi$$"; + let canonical = "ALTER TABLE tab ALTER COLUMN c COMMENT 'hi'"; + match alter_table_op(snowflake().one_statement_parses_to(sql, canonical)) { + AlterTableOperation::AlterColumn { + op: AlterColumnOperation::Comment { comment }, + .. + } => assert_eq!(comment, "hi"), + _ => unreachable!(), + } + + // Multi-column form: later items carry `COLUMN` without `ALTER`; Display + // canonicalises each item to the full `ALTER COLUMN` spelling. + let sql = "ALTER TABLE tab ALTER COLUMN c1 COMMENT 's1', COLUMN c2 COMMENT 's2'"; + let canonical = "ALTER TABLE tab ALTER COLUMN c1 COMMENT 's1', ALTER COLUMN c2 COMMENT 's2'"; + match snowflake().one_statement_parses_to(sql, canonical) { + Statement::AlterTable(AlterTable { operations, .. }) => { + assert_eq!(operations.len(), 2); + match (&operations[0], &operations[1]) { + ( + AlterTableOperation::AlterColumn { + column_name: n1, + op: AlterColumnOperation::Comment { comment: c1 }, + }, + AlterTableOperation::AlterColumn { + column_name: n2, + op: AlterColumnOperation::Comment { comment: c2 }, + }, + ) => { + assert_eq!(n1.to_string(), "c1"); + assert_eq!(c1, "s1"); + assert_eq!(n2.to_string(), "c2"); + assert_eq!(c2, "s2"); + } + _ => unreachable!(), + } + } + _ => unreachable!(), + } + + // Bare multi-column form: `COLUMN` omitted on every clause (the shape dbt + // `persist_docs` emits). Canonicalises to the full `ALTER COLUMN` spelling. + let sql = "ALTER TABLE tab ALTER \"ID\" COMMENT 's1', \"NAME\" COMMENT $$$$"; + let canonical = "ALTER TABLE tab ALTER COLUMN \"ID\" COMMENT 's1', ALTER COLUMN \"NAME\" COMMENT ''"; + match snowflake().one_statement_parses_to(sql, canonical) { + Statement::AlterTable(AlterTable { operations, .. }) => { + assert_eq!(operations.len(), 2); + match (&operations[0], &operations[1]) { + ( + AlterTableOperation::AlterColumn { + column_name: n1, + op: AlterColumnOperation::Comment { comment: c1 }, + }, + AlterTableOperation::AlterColumn { + column_name: n2, + op: AlterColumnOperation::Comment { comment: c2 }, + }, + ) => { + assert_eq!(n1.to_string(), "\"ID\""); + assert_eq!(c1, "s1"); + assert_eq!(n2.to_string(), "\"NAME\""); + assert_eq!(c2, ""); + } + _ => unreachable!(), + } + } + _ => unreachable!(), + } + + // Mixed form: `COLUMN` on the first clause, omitted on the second. + let sql = "ALTER TABLE tab ALTER COLUMN c1 COMMENT 's1', c2 COMMENT 's2'"; + let canonical = "ALTER TABLE tab ALTER COLUMN c1 COMMENT 's1', ALTER COLUMN c2 COMMENT 's2'"; + match snowflake().one_statement_parses_to(sql, canonical) { + Statement::AlterTable(AlterTable { operations, .. }) => { + assert_eq!(operations.len(), 2); + } + _ => unreachable!(), + } +} + #[test] fn test_alter_iceberg_table() { snowflake_and_generic().verified_stmt("ALTER ICEBERG TABLE tbl DROP CLUSTERING KEY"); @@ -2599,40 +3043,162 @@ fn test_copy_into_with_transformations() { } #[test] -fn test_copy_into_file_format() { - let sql = concat!( - "COPY INTO my_company.emp_basic ", - "FROM 'gcs://mybucket/./../a.csv' ", - "FILES = ('file1.json', 'file2.json') ", - "PATTERN = '.*employees0[1-5].csv.gz' ", - r#"FILE_FORMAT=(COMPRESSION=AUTO BINARY_FORMAT=HEX ESCAPE='\\')"# - ); - - match snowflake_without_unescape().verified_stmt(sql) { - Statement::CopyIntoSnowflake { file_format, .. } => { - assert!(file_format.options.contains(&KeyValueOption { - option_name: "COMPRESSION".to_string(), - option_value: KeyValueOptionKind::Single( - Value::Placeholder("AUTO".to_string()).with_empty_span() - ), - })); - assert!(file_format.options.contains(&KeyValueOption { - option_name: "BINARY_FORMAT".to_string(), - option_value: KeyValueOptionKind::Single( - Value::Placeholder("HEX".to_string()).with_empty_span() - ), - })); - assert!(file_format.options.contains(&KeyValueOption { - option_name: "ESCAPE".to_string(), - option_value: KeyValueOptionKind::Single( - Value::SingleQuotedString(r#"\\"#.to_string()).with_empty_span() - ), - })); +fn test_copy_into_data_load_dotted_subpaths_and_casts() { + // Extended data-load projection shapes that the dedicated item parser does not + // cover — dotted sub-paths (`$N:a.b`), `::TYPE` casts (with or without a colon + // element, and bare `$N::TYPE`), and their combinations — must fall back to the + // general select-item parser instead of failing at the stray `.`/`::`. + fn transformations(sql: &str) -> Vec { + match snowflake().verified_stmt(sql) { + Statement::CopyIntoSnowflake { + from_transformations, + .. + } => from_transformations.unwrap(), + _ => unreachable!(), } - _ => unreachable!(), } + + // Dotted sub-path with a cast and alias, mixed with a single-word colon element and + // a bare `$N`. The extended item parses as a general SelectItem; the plain ones stay + // StageLoadSelectItem. + let items = transformations( + "COPY INTO t FROM (SELECT $1:country.name::TEXT AS country_name, $1:id, $2 FROM @stage)", + ); + assert!(matches!(items[0], StageLoadSelectItemKind::SelectItem(_))); assert_eq!( - snowflake_without_unescape().verified_stmt(sql).to_string(), + items[1], + StageLoadSelectItemKind::StageLoadSelectItem(StageLoadSelectItem { + alias: None, + file_col_num: 1, + element: Some(Ident::new("id")), + item_as: None, + }) + ); + assert_eq!( + items[2], + StageLoadSelectItemKind::StageLoadSelectItem(StageLoadSelectItem { + alias: None, + file_col_num: 2, + element: None, + item_as: None, + }) + ); + + // Bare `$N::TYPE` cast, mixed with a plain `$N`. + let items = transformations("COPY INTO t FROM (SELECT $1::INT, $2 FROM @stage)"); + assert!(matches!(items[0], StageLoadSelectItemKind::SelectItem(_))); + assert_eq!( + items[1], + StageLoadSelectItemKind::StageLoadSelectItem(StageLoadSelectItem { + alias: None, + file_col_num: 2, + element: None, + item_as: None, + }) + ); + + // Colon element with a cast, no dotted sub-path. + let items = transformations("COPY INTO t FROM (SELECT $1:id::INT AS id FROM @stage)"); + assert!(matches!(items[0], StageLoadSelectItemKind::SelectItem(_))); + + // Dotted sub-path without a cast. + let items = transformations("COPY INTO t FROM (SELECT $1:a.b FROM @stage)"); + assert!(matches!(items[0], StageLoadSelectItemKind::SelectItem(_))); +} + +#[test] +fn test_copy_into_data_load_accepted_shapes_preserved() { + // The currently-accepted data-load item shapes must keep parsing as dedicated + // StageLoadSelectItems and keep round-tripping. + let sql = concat!( + "COPY INTO t FROM ", + "(SELECT $1, alias.$2, $3:element, $4:element AS a FROM @stage AS alias)" + ); + match snowflake().verified_stmt(sql) { + Statement::CopyIntoSnowflake { + from_transformations, + .. + } => { + let items = from_transformations.unwrap(); + assert_eq!( + items[0], + StageLoadSelectItemKind::StageLoadSelectItem(StageLoadSelectItem { + alias: None, + file_col_num: 1, + element: None, + item_as: None, + }) + ); + assert_eq!( + items[1], + StageLoadSelectItemKind::StageLoadSelectItem(StageLoadSelectItem { + alias: Some(Ident::new("alias")), + file_col_num: 2, + element: None, + item_as: None, + }) + ); + assert_eq!( + items[2], + StageLoadSelectItemKind::StageLoadSelectItem(StageLoadSelectItem { + alias: None, + file_col_num: 3, + element: Some(Ident::new("element")), + item_as: None, + }) + ); + assert_eq!( + items[3], + StageLoadSelectItemKind::StageLoadSelectItem(StageLoadSelectItem { + alias: None, + file_col_num: 4, + element: Some(Ident::new("element")), + item_as: Some(Ident::new("a")), + }) + ); + } + _ => unreachable!(), + } + + // SELECT * remains handled by the general fallback. + snowflake().verified_stmt("COPY INTO t FROM (SELECT * FROM @stage)"); +} + +#[test] +fn test_copy_into_file_format() { + let sql = concat!( + "COPY INTO my_company.emp_basic ", + "FROM 'gcs://mybucket/./../a.csv' ", + "FILES = ('file1.json', 'file2.json') ", + "PATTERN = '.*employees0[1-5].csv.gz' ", + r#"FILE_FORMAT=(COMPRESSION=AUTO BINARY_FORMAT=HEX ESCAPE='\\')"# + ); + + match snowflake_without_unescape().verified_stmt(sql) { + Statement::CopyIntoSnowflake { file_format, .. } => { + assert!(file_format.options.contains(&KeyValueOption { + option_name: "COMPRESSION".to_string(), + option_value: KeyValueOptionKind::Single( + Value::Placeholder("AUTO".to_string()).with_empty_span() + ), + })); + assert!(file_format.options.contains(&KeyValueOption { + option_name: "BINARY_FORMAT".to_string(), + option_value: KeyValueOptionKind::Single( + Value::Placeholder("HEX".to_string()).with_empty_span() + ), + })); + assert!(file_format.options.contains(&KeyValueOption { + option_name: "ESCAPE".to_string(), + option_value: KeyValueOptionKind::Single( + Value::SingleQuotedString(r#"\\"#.to_string()).with_empty_span() + ), + })); + } + _ => unreachable!(), + } + assert_eq!( + snowflake_without_unescape().verified_stmt(sql).to_string(), sql ); @@ -2675,6 +3241,83 @@ fn test_copy_into_file_format() { } } +#[test] +fn test_copy_into_file_format_name_shorthand() { + // Bare `FILE_FORMAT = ` is sugar for + // `FILE_FORMAT = (FORMAT_NAME = )`, for both the location-unload and + // table-load forms. An unquoted identifier parses to a `Placeholder`. + for sql in [ + "COPY INTO @my_stage/out.csv FROM (SELECT * FROM t) FILE_FORMAT = my_format", + "COPY INTO my_table FROM @my_stage FILE_FORMAT = my_format", + ] { + match snowflake() + .parse_sql_statements(sql) + .unwrap() + .pop() + .unwrap() + { + Statement::CopyIntoSnowflake { file_format, .. } => { + assert_eq!( + file_format.options, + vec![KeyValueOption { + option_name: "FORMAT_NAME".to_string(), + option_value: KeyValueOptionKind::Single( + Value::Placeholder("my_format".to_string()).with_empty_span() + ), + }] + ); + } + _ => unreachable!(), + } + } + + // A quoted name parses to a `SingleQuotedString`. + match snowflake() + .parse_sql_statements( + "COPY INTO @my_stage/out.csv FROM (SELECT * FROM t) FILE_FORMAT = 'my_format'", + ) + .unwrap() + .pop() + .unwrap() + { + Statement::CopyIntoSnowflake { file_format, .. } => { + assert_eq!( + file_format.options, + vec![KeyValueOption { + option_name: "FORMAT_NAME".to_string(), + option_value: KeyValueOptionKind::Single( + Value::SingleQuotedString("my_format".to_string()).with_empty_span() + ), + }] + ); + } + _ => unreachable!(), + } + + // The parenthesized form is unaffected. + match snowflake() + .parse_sql_statements( + "COPY INTO @my_stage/out.csv FROM (SELECT * FROM t) FILE_FORMAT = (TYPE = 'CSV')", + ) + .unwrap() + .pop() + .unwrap() + { + Statement::CopyIntoSnowflake { file_format, .. } => { + assert_eq!( + file_format.options, + vec![KeyValueOption { + option_name: "TYPE".to_string(), + option_value: KeyValueOptionKind::Single( + Value::SingleQuotedString("CSV".to_string()).with_empty_span() + ), + }] + ); + } + _ => unreachable!(), + } +} + #[test] fn test_copy_into_copy_options() { let sql = concat!( @@ -3483,6 +4126,7 @@ fn test_parse_show_objects() { Statement::ShowObjects(ShowObjects { terse, show_options, + .. }) => { assert!(terse); let name = match show_options.show_in { @@ -4351,6 +4995,16 @@ fn test_grant_database_role_to() { snowflake_and_generic().verified_stmt("GRANT DATABASE ROLE db1.sc1.r1 TO ROLE db1.sc1.r2"); } +#[test] +fn test_create_database_role() { + snowflake().verified_stmt("CREATE DATABASE ROLE r1"); + snowflake().verified_stmt("CREATE OR REPLACE DATABASE ROLE r1"); + snowflake().verified_stmt("CREATE DATABASE ROLE IF NOT EXISTS db1.r1"); + snowflake().verified_stmt("CREATE DATABASE ROLE db1.\"role123\" COMMENT = 'hi'"); + snowflake().verified_stmt("DROP DATABASE ROLE r1"); + snowflake().verified_stmt("DROP DATABASE ROLE IF EXISTS db1.\"role123\""); +} + #[test] fn test_alter_session() { assert_eq!( @@ -5324,6 +5978,34 @@ fn test_select_dollar_column_from_stage() { snowflake().verified_stmt("SELECT $1, $2 FROM @mystage1(file_format => 'myformat')"); } +#[test] +fn test_select_from_stage_comma_join_lateral() { + // Comma immediately abutting the stage path, followed by LATERAL FLATTEN. + snowflake().one_statement_parses_to( + "SELECT value FROM @stage/test.jsonl, LATERAL FLATTEN(input => $1, path => 'attr1')", + "SELECT value FROM @stage/test.jsonl, LATERAL FLATTEN(input => $1, path => 'attr1')", + ); + // Space before the comma parses to the same canonical form. + snowflake().one_statement_parses_to( + "SELECT value FROM @stage/test.jsonl , LATERAL FLATTEN(input => $1, path => 'attr1')", + "SELECT value FROM @stage/test.jsonl, LATERAL FLATTEN(input => $1, path => 'attr1')", + ); + // Comma abutting the path, with a table alias on the stage factor. + let stmt = snowflake() + .parse_sql_statements("SELECT value FROM @stage/test.jsonl t, LATERAL FLATTEN(input => $1)") + .expect("stage factor with alias and comma join should parse"); + assert_eq!( + stmt[0].to_string(), + "SELECT value FROM @stage/test.jsonl t, LATERAL FLATTEN(input => $1)" + ); + // Explicit `AS` alias round-trips. + snowflake() + .verified_stmt("SELECT value FROM @stage/test.jsonl AS t, LATERAL FLATTEN(input => $1)"); + // Plain comma join of a stage factor with an ordinary table. + snowflake().verified_stmt("SELECT * FROM @stage, other_table"); + snowflake().verified_stmt("SELECT * FROM @stage/test.jsonl, other_table"); +} + /// Bare assignment `var := expr` inside `BEGIN...END` scripting blocks. #[test] fn test_scripting_bare_assignment() { @@ -6179,6 +6861,41 @@ fn test_create_catalog_integration_minimal_iceberg_rest_oauth() { } } +#[test] +fn test_create_catalog_integration_unquoted_oauth_scope_with_colon() { + // Snowflake accepts unquoted scopes that embed a colon; they canonicalize + // to the quoted form on display. + let sql = concat!( + "CREATE CATALOG INTEGRATION my_cat ", + "CATALOG_SOURCE = ICEBERG_REST TABLE_FORMAT = ICEBERG ", + "REST_CONFIG = (CATALOG_URI = 'https://rest.example.com') ", + "REST_AUTHENTICATION = (TYPE = OAUTH OAUTH_CLIENT_ID = 'cid' ", + "OAUTH_CLIENT_SECRET = 'secret' OAUTH_ALLOWED_SCOPES = (PRINCIPAL_ROLE:ALL)) ", + "ENABLED = TRUE", + ); + let canonical = concat!( + "CREATE CATALOG INTEGRATION my_cat ", + "CATALOG_SOURCE = ICEBERG_REST TABLE_FORMAT = ICEBERG ", + "REST_CONFIG = (CATALOG_URI = 'https://rest.example.com') ", + "REST_AUTHENTICATION = (TYPE = OAUTH OAUTH_CLIENT_ID = 'cid' ", + "OAUTH_CLIENT_SECRET = 'secret' OAUTH_ALLOWED_SCOPES = ('PRINCIPAL_ROLE:ALL')) ", + "ENABLED = TRUE", + ); + match snowflake().one_statement_parses_to(sql, canonical) { + Statement::CreateCatalogIntegration { + rest_authentication, + .. + } => { + let auth = rest_authentication.expect("rest_authentication"); + assert_eq!( + vec!["PRINCIPAL_ROLE:ALL".to_string()], + auth.oauth_allowed_scopes + ); + } + _ => unreachable!(), + } +} + #[test] fn test_create_catalog_integration_full_rest_config() { let sql = concat!( @@ -6967,6 +7684,52 @@ fn test_desc_task() { } } +#[test] +fn test_desc_table_type_columns() { + for sql in ["DESC TABLE foo TYPE = COLUMNS", "DESCRIBE TABLE foo TYPE = COLUMNS"] { + match snowflake().verified_stmt(sql) { + Statement::DescribeObject { + object_type, + object_name, + table_type, + .. + } => { + assert_eq!(DescribeObjectType::Table, object_type); + assert_eq!("foo", object_name.to_string()); + assert_eq!(Some(DescribeTableType::Columns), table_type); + } + _ => unreachable!(), + } + } +} + +#[test] +fn test_desc_table_type_stage() { + match snowflake().verified_stmt("DESC TABLE foo TYPE = STAGE") { + Statement::DescribeObject { + object_type, + object_name, + table_type, + .. + } => { + assert_eq!(DescribeObjectType::Table, object_type); + assert_eq!("foo", object_name.to_string()); + assert_eq!(Some(DescribeTableType::Stage), table_type); + } + _ => unreachable!(), + } +} + +#[test] +fn test_desc_table_no_type_clause() { + match snowflake().verified_stmt("DESC TABLE foo") { + Statement::DescribeObject { table_type, .. } => { + assert_eq!(None, table_type); + } + _ => unreachable!(), + } +} + #[test] fn test_show_tasks() { let sql = "SHOW TASKS"; @@ -8005,3 +8768,565 @@ fn test_show_terse_stages() { _ => unreachable!(), } } + +#[test] +fn test_select_into_placeholder_target() { + // Snowflake scripting: a colon placeholder is a valid `SELECT ... INTO` + // target (a local variable), and it round-trips as `:res`. + let stmt = snowflake().verified_stmt("SELECT :res + t.hmy INTO :res FROM tbl AS t"); + let Statement::Query(query) = stmt else { + unreachable!() + }; + let SetExpr::Select(select) = *query.body else { + unreachable!() + }; + let into = select.into.expect("expected SELECT ... INTO clause"); + assert_eq!(into.name.to_string(), ":res"); + assert!(!into.table); + + // Bare-identifier targets are unaffected. + let stmt = snowflake().verified_stmt("SELECT 1 INTO res FROM tbl"); + let Statement::Query(query) = stmt else { + unreachable!() + }; + let SetExpr::Select(select) = *query.body else { + unreachable!() + }; + assert_eq!(select.into.unwrap().name.to_string(), "res"); +} + +#[test] +fn test_fetch_cursor_into_variables() { + // Snowflake scripting FETCH has no direction and no FROM/IN; it binds the + // current cursor row into one or more local variables. + let stmt = snowflake().verified_stmt("FETCH c INTO x, y"); + match stmt { + Statement::FetchInto { cursor, into } => { + assert_eq!(cursor.value, "c"); + let names: Vec = into.iter().map(ToString::to_string).collect(); + assert_eq!(names, vec!["x".to_string(), "y".to_string()]); + } + other => panic!("expected FetchInto, got {other:?}"), + } + + // Single variable and colon-placeholder targets both round-trip. + let stmt = snowflake().verified_stmt("FETCH c INTO :res"); + match stmt { + Statement::FetchInto { cursor, into } => { + assert_eq!(cursor.value, "c"); + assert_eq!(into.len(), 1); + assert_eq!(into[0].to_string(), ":res"); + } + other => panic!("expected FetchInto, got {other:?}"), + } +} + +#[test] +fn test_call_into_variable() { + let stmt = snowflake().verified_stmt("CALL myproc(1, 'a') INTO :ret"); + match stmt { + Statement::CallInto { function, into } => { + assert_eq!(function.name.to_string(), "myproc"); + assert_eq!(into.len(), 1); + assert_eq!(into[0].to_string(), ":ret"); + } + other => panic!("expected CallInto, got {other:?}"), + } + + // Multiple targets. + let stmt = snowflake().verified_stmt("CALL p() INTO a, b"); + match stmt { + Statement::CallInto { into, .. } => { + let names: Vec = into.iter().map(ToString::to_string).collect(); + assert_eq!(names, vec!["a".to_string(), "b".to_string()]); + } + other => panic!("expected CallInto, got {other:?}"), + } + + // A plain CALL is unaffected. + let stmt = snowflake().verified_stmt("CALL myproc(1)"); + assert!(matches!(stmt, Statement::Call(_))); +} + +#[test] +fn test_alter_procedure() { + let stmt = snowflake().verified_stmt("ALTER PROCEDURE myproc(NUMBER, VARCHAR) RENAME TO newproc"); + match stmt { + Statement::AlterProcedure(AlterProcedure { + if_exists, + name, + args, + operation, + }) => { + assert!(!if_exists); + assert_eq!(name.to_string(), "myproc"); + assert_eq!(args.len(), 2); + assert_eq!( + operation, + AlterProcedureOperation::RenameTo { + new_name: ObjectName::from(vec![Ident::new("newproc")]) + } + ); + } + other => panic!("expected AlterProcedure, got {other:?}"), + } + + // IF EXISTS + EXECUTE AS CALLER. + let stmt = snowflake().verified_stmt("ALTER PROCEDURE IF EXISTS p() EXECUTE AS CALLER"); + match stmt { + Statement::AlterProcedure(AlterProcedure { + if_exists, + args, + operation, + .. + }) => { + assert!(if_exists); + assert!(args.is_empty()); + assert_eq!( + operation, + AlterProcedureOperation::ExecuteAs(ProcedureExecuteAs::Caller) + ); + } + other => panic!("expected AlterProcedure, got {other:?}"), + } + + // SET / UNSET COMMENT. + snowflake().verified_stmt("ALTER PROCEDURE p(NUMBER) SET COMMENT = 'hi'"); + snowflake().verified_stmt("ALTER PROCEDURE p(NUMBER) UNSET COMMENT"); + snowflake().verified_stmt("ALTER PROCEDURE p(NUMBER) EXECUTE AS OWNER"); +} + +#[test] +fn test_with_as_procedure() { + let sql = "WITH myproc AS PROCEDURE (arg1 INT) RETURNS INT LANGUAGE SQL AS BEGIN RETURN arg1; END CALL myproc(10)"; + let stmt = snowflake().verified_stmt(sql); + match stmt { + Statement::WithProcedure { + name, + params, + returns, + language, + call, + .. + } => { + assert_eq!(name.value, "myproc"); + assert_eq!(params.as_ref().map(Vec::len), Some(1)); + assert!(returns.is_some()); + assert_eq!(language.map(|l| l.value), Some("SQL".to_string())); + assert!(matches!(*call, Statement::Call(_))); + } + other => panic!("expected WithProcedure, got {other:?}"), + } + + // An ordinary CTE query is unaffected. + let stmt = snowflake().verified_stmt("WITH t AS (SELECT 1) SELECT * FROM t"); + assert!(matches!(stmt, Statement::Query(_))); +} + +#[test] +fn parse_snowflake_create_masking_policy() { + match snowflake().verified_stmt( + "CREATE MASKING POLICY p AS (a VARCHAR, b VARCHAR) RETURNS VARCHAR -> a", + ) { + Statement::CreateMaskingPolicy { + or_replace, + if_not_exists, + name, + args, + return_type, + policy_expr, + comment, + } => { + assert!(!or_replace); + assert!(!if_not_exists); + assert_eq!("p", name.to_string()); + assert_eq!(2, args.len()); + assert_eq!(Some(Ident::new("a")), args[0].name); + assert_eq!(DataType::Varchar(None), args[0].data_type); + assert_eq!(Some(Ident::new("b")), args[1].name); + assert_eq!(DataType::Varchar(None), return_type); + assert_eq!(Expr::Identifier(Ident::new("a")), policy_expr); + assert_eq!(None, comment); + } + other => panic!("expected CreateMaskingPolicy, got {other:?}"), + } + + // OR REPLACE, IF NOT EXISTS, and a COMMENT. + match snowflake().verified_stmt( + "CREATE OR REPLACE MASKING POLICY p AS (a VARCHAR) RETURNS VARCHAR -> a COMMENT = 'hi'", + ) { + Statement::CreateMaskingPolicy { + or_replace, + if_not_exists, + comment, + .. + } => { + assert!(or_replace); + assert!(!if_not_exists); + assert_eq!(Some("hi".to_string()), comment); + } + other => panic!("expected CreateMaskingPolicy, got {other:?}"), + } + + match snowflake().verified_stmt( + "CREATE MASKING POLICY IF NOT EXISTS p AS (a VARCHAR) RETURNS VARCHAR -> a", + ) { + Statement::CreateMaskingPolicy { if_not_exists, .. } => assert!(if_not_exists), + other => panic!("expected CreateMaskingPolicy, got {other:?}"), + } + + // A conditional-masking body with multiple arguments round-trips. + snowflake().verified_stmt( + "CREATE MASKING POLICY p AS (a VARCHAR, b VARCHAR) RETURNS VARCHAR -> \ + CASE WHEN b = 'x' THEN '***' ELSE a END", + ); +} + +#[test] +fn parse_snowflake_alter_masking_policy() { + match snowflake().verified_stmt("ALTER MASKING POLICY p SET BODY -> 'true'") { + Statement::AlterMaskingPolicy { + if_exists, + name, + operation, + } => { + assert!(!if_exists); + assert_eq!("p", name.to_string()); + assert_eq!( + AlterMaskingPolicyOperation::SetBody { + body: Expr::Value( + Value::SingleQuotedString("true".to_string()).with_empty_span() + ), + }, + operation + ); + } + other => panic!("expected AlterMaskingPolicy, got {other:?}"), + } + + match snowflake().verified_stmt("ALTER MASKING POLICY IF EXISTS p RENAME TO q") { + Statement::AlterMaskingPolicy { + if_exists, + operation, + .. + } => { + assert!(if_exists); + assert_eq!( + AlterMaskingPolicyOperation::RenameTo { + new_name: ObjectName::from(vec![Ident::new("q")]), + }, + operation + ); + } + other => panic!("expected AlterMaskingPolicy, got {other:?}"), + } + + match snowflake().verified_stmt("ALTER MASKING POLICY p SET COMMENT = 'c'") { + Statement::AlterMaskingPolicy { operation, .. } => assert_eq!( + AlterMaskingPolicyOperation::SetComment { + comment: "c".to_string(), + }, + operation + ), + other => panic!("expected AlterMaskingPolicy, got {other:?}"), + } + + match snowflake().verified_stmt("ALTER MASKING POLICY p UNSET COMMENT") { + Statement::AlterMaskingPolicy { operation, .. } => { + assert_eq!(AlterMaskingPolicyOperation::UnsetComment, operation) + } + other => panic!("expected AlterMaskingPolicy, got {other:?}"), + } +} + +#[test] +fn parse_snowflake_drop_masking_policy() { + match snowflake().verified_stmt("DROP MASKING POLICY p") { + Statement::DropMaskingPolicy { if_exists, name } => { + assert!(!if_exists); + assert_eq!("p", name.to_string()); + } + other => panic!("expected DropMaskingPolicy, got {other:?}"), + } + + match snowflake().verified_stmt("DROP MASKING POLICY IF EXISTS p") { + Statement::DropMaskingPolicy { if_exists, .. } => assert!(if_exists), + other => panic!("expected DropMaskingPolicy, got {other:?}"), + } +} + +#[test] +fn parse_snowflake_describe_masking_policy() { + for sql in [ + "DESCRIBE MASKING POLICY p", + "DESC MASKING POLICY p", + ] { + match snowflake().one_statement_parses_to(sql, "DESCRIBE MASKING POLICY p") { + Statement::DescribeMaskingPolicy { name } => assert_eq!("p", name.to_string()), + other => panic!("expected DescribeMaskingPolicy, got {other:?}"), + } + } +} + +#[test] +fn parse_snowflake_show_masking_policies() { + match snowflake().verified_stmt("SHOW MASKING POLICIES") { + Statement::ShowMaskingPolicies { show_options } => { + assert!(show_options.filter_position.is_none()); + } + other => panic!("expected ShowMaskingPolicies, got {other:?}"), + } + + match snowflake().verified_stmt("SHOW MASKING POLICIES LIKE '%p%'") { + Statement::ShowMaskingPolicies { .. } => {} + other => panic!("expected ShowMaskingPolicies, got {other:?}"), + } + + snowflake().verified_stmt("SHOW MASKING POLICIES IN SCHEMA s"); +} + +#[test] +fn parse_snowflake_alter_table_column_masking_policy() { + for keyword in ["MODIFY", "ALTER"] { + let sql = format!("ALTER TABLE t {keyword} COLUMN c SET MASKING POLICY p"); + match snowflake().one_statement_parses_to( + &sql, + "ALTER TABLE t ALTER COLUMN c SET MASKING POLICY p", + ) { + Statement::AlterTable(AlterTable { operations, .. }) => match &operations[0] { + AlterTableOperation::AlterColumn { column_name, op } => { + assert_eq!("c", column_name.to_string()); + assert_eq!( + &AlterColumnOperation::SetMaskingPolicy { + policy_name: ObjectName::from(vec![Ident::new("p")]), + using_columns: None, + force: false, + }, + op + ); + } + other => panic!("expected AlterColumn, got {other:?}"), + }, + other => panic!("expected AlterTable, got {other:?}"), + } + } + + // USING and FORCE. + match snowflake() + .verified_stmt("ALTER TABLE t ALTER COLUMN c SET MASKING POLICY p USING (c, d) FORCE") + { + Statement::AlterTable(AlterTable { operations, .. }) => match &operations[0] { + AlterTableOperation::AlterColumn { + op: + AlterColumnOperation::SetMaskingPolicy { + using_columns, + force, + .. + }, + .. + } => { + assert_eq!( + &Some(vec![Ident::new("c"), Ident::new("d")]), + using_columns + ); + assert!(force); + } + other => panic!("expected SetMaskingPolicy, got {other:?}"), + }, + other => panic!("expected AlterTable, got {other:?}"), + } + + // UNSET. + match snowflake().verified_stmt("ALTER TABLE t ALTER COLUMN c UNSET MASKING POLICY") { + Statement::AlterTable(AlterTable { operations, .. }) => assert_eq!( + AlterTableOperation::AlterColumn { + column_name: Ident::new("c"), + op: AlterColumnOperation::UnsetMaskingPolicy, + }, + operations[0] + ), + other => panic!("expected AlterTable, got {other:?}"), + } +} + +#[test] +fn parse_snowflake_alter_view_column_masking_policy() { + for keyword in ["MODIFY", "ALTER"] { + let sql = format!("ALTER VIEW v {keyword} COLUMN c SET MASKING POLICY p"); + match snowflake() + .one_statement_parses_to(&sql, "ALTER VIEW v ALTER COLUMN c SET MASKING POLICY p") + { + Statement::AlterViewColumn { + name, + column_name, + op, + } => { + assert_eq!("v", name.to_string()); + assert_eq!("c", column_name.to_string()); + assert_eq!( + AlterColumnOperation::SetMaskingPolicy { + policy_name: ObjectName::from(vec![Ident::new("p")]), + using_columns: None, + force: false, + }, + op + ); + } + other => panic!("expected AlterViewColumn, got {other:?}"), + } + } + + match snowflake().verified_stmt("ALTER VIEW v ALTER COLUMN c UNSET MASKING POLICY") { + Statement::AlterViewColumn { op, .. } => { + assert_eq!(AlterColumnOperation::UnsetMaskingPolicy, op) + } + other => panic!("expected AlterViewColumn, got {other:?}"), + } +} + +#[test] +fn parse_snowflake_create_view_column_masking_policy() { + match snowflake().verified_stmt( + "CREATE VIEW v (c WITH MASKING POLICY p USING (c, d)) AS SELECT c, d FROM t", + ) { + Statement::CreateView(CreateView { columns, .. }) => { + assert_eq!(1, columns.len()); + assert_eq!("c", columns[0].name.to_string()); + match &columns[0].options { + Some(ColumnOptions::SpaceSeparated(options)) => match &options[0] { + ColumnOption::Policy(ColumnPolicy::MaskingPolicy(property)) => { + assert!(property.with); + assert_eq!("p", property.policy_name.to_string()); + assert_eq!( + Some(vec![Ident::new("c"), Ident::new("d")]), + property.using_columns + ); + } + other => panic!("expected MaskingPolicy, got {other:?}"), + }, + other => panic!("expected column options, got {other:?}"), + } + } + other => panic!("expected CreateView, got {other:?}"), + } +} + +#[test] +fn parse_snowflake_alter_tag_rename() { + match snowflake().verified_stmt("ALTER TAG IF EXISTS t RENAME TO u") { + Statement::AlterTag { + if_exists, + name, + operation, + } => { + assert!(if_exists); + assert_eq!("t", name.to_string()); + assert_eq!( + AlterTagOperation::RenameTo { + new_name: ObjectName::from(vec![Ident::new("u")]), + }, + operation + ); + } + other => panic!("expected AlterTag, got {other:?}"), + } +} + +#[test] +fn parse_snowflake_alter_tag_set_masking_policy() { + match snowflake().verified_stmt("ALTER TAG t SET MASKING POLICY p") { + Statement::AlterTag { + if_exists, + operation, + .. + } => { + assert!(!if_exists); + assert_eq!( + AlterTagOperation::SetMaskingPolicy { + policies: vec![ObjectName::from(vec![Ident::new("p")])], + force: false, + }, + operation + ); + } + other => panic!("expected AlterTag, got {other:?}"), + } + + match snowflake() + .verified_stmt("ALTER TAG IF EXISTS t SET MASKING POLICY p, MASKING POLICY q FORCE") + { + Statement::AlterTag { + if_exists, + operation, + .. + } => { + assert!(if_exists); + assert_eq!( + AlterTagOperation::SetMaskingPolicy { + policies: vec![ + ObjectName::from(vec![Ident::new("p")]), + ObjectName::from(vec![Ident::new("q")]), + ], + force: true, + }, + operation + ); + } + other => panic!("expected AlterTag, got {other:?}"), + } +} + +#[test] +fn parse_snowflake_alter_tag_unset_masking_policy() { + match snowflake().verified_stmt("ALTER TAG t UNSET MASKING POLICY p, MASKING POLICY q") { + Statement::AlterTag { operation, .. } => assert_eq!( + AlterTagOperation::UnsetMaskingPolicy { + policies: vec![ + ObjectName::from(vec![Ident::new("p")]), + ObjectName::from(vec![Ident::new("q")]), + ], + }, + operation + ), + other => panic!("expected AlterTag, got {other:?}"), + } +} + +#[test] +fn parse_snowflake_undrop() { + for (sql, expected_type) in [ + ("UNDROP TABLE t", ObjectType::Table), + ("UNDROP DYNAMIC TABLE t", ObjectType::DynamicTable), + ("UNDROP SCHEMA s", ObjectType::Schema), + ("UNDROP DATABASE d", ObjectType::Database), + ("UNDROP VIEW v", ObjectType::View), + ] { + match snowflake().verified_stmt(sql) { + Statement::Undrop { object_type, name } => { + assert_eq!(object_type, expected_type); + assert_eq!(name, ObjectName::from(vec![Ident::new(sql.rsplit(' ').next().unwrap())])); + } + other => panic!("expected Undrop, got {other:?}"), + } + } +} + +#[test] +fn parse_snowflake_undrop_qualified_name() { + match snowflake().verified_stmt("UNDROP TABLE db.sch.t") { + Statement::Undrop { object_type, name } => { + assert_eq!(object_type, ObjectType::Table); + assert_eq!( + name, + ObjectName::from(vec![Ident::new("db"), Ident::new("sch"), Ident::new("t")]) + ); + } + other => panic!("expected Undrop, got {other:?}"), + } +} + +#[test] +fn parse_snowflake_undrop_missing_object_type() { + let res = snowflake().parse_sql_statements("UNDROP t"); + assert!(res.is_err(), "expected parse error, got {res:?}"); +}