diff --git a/.github/workflows/license.yml b/.github/workflows/license.yml index f4524f6b21..e5b7ffd515 100644 --- a/.github/workflows/license.yml +++ b/.github/workflows/license.yml @@ -35,6 +35,6 @@ jobs: - name: Setup Python uses: actions/setup-python@v5 with: - python-version: 3.8 + python-version: "3.12" - name: Audit licenses - run: ./dev/release/run-rat.sh . + run: bash -e ./dev/release/run-rat.sh . diff --git a/AGENTS.md b/AGENTS.md index cb8033c55e..5bc24790bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,35 @@ snowflake().one_statement_parses_to( ``` ## Analyzing Parsing Issues + +Warehouse commands use Snowflake-only typed syntax in +`ast/helpers/stmt_warehouse.rs`, dispatched through the existing Snowflake dialect +override. Keep account-object names distinct from table relations. Preserve ordered +primitive properties, including duplicate or semantically invalid values, for the +execution consumer to validate. Never evaluate properties, normalize account identity, +consume statement delimiters inside option loops, or infer runtime support from an AST. +Run the required fork checks below before committing changes to this syntax. + +Namespace DESCRIBE backports retain this fork's existing VIEW representation +(`ExplainTable::has_view_keyword`), stage/file-format/sequence syntax and statement +boundaries. `DescribeObject` admits DATABASE/SCHEMA only; account namespaces must +not acquire table-relation visitor annotations. Parser tests do not establish +DataFusion or Rustice runtime support for the additive AST variant. + +Snowflake CREATE TABLE's dedicated COMMENT option admits explicit `= NULL` as +`SqlOption::Comment(CommentDef::NullWithEq)` in plain table options. Keep existing +string variants and the shared `parse_comment_value` strict: column, stage, file +format, view and connector comments do not gain NULL syntax. Do not collapse NULL +into an empty string or absent clause, infer no-`=` NULL syntax, or rewrite SQL. +The additive public enum variant requires an exhaustive-match/serde consumer audit +and a coherent parser/DataFusion/engine pin qualification before runtime claims. + +For Snowflake `PUT`/`GET`, keep opaque location lexing confined to the two operands +at actual statement start. Preserve ordinary comments and identifiers/functions +named `PUT`/`GET`. Keep stage `ObjectName` quoting separate from the exact payload +prefix, avoid table-relation visitor annotations for named stages, and retain +bindings as normal expression placeholders. The parser must not resolve paths, +perform storage IO, evaluate options, or enforce an engine's transfer policy. You can try to simplify the SQL statement to identify the root cause of the parsing issue. This may involve removing certain clauses or components of the SQL statement to see if it can be parsed successfully. Additionally, you can compare the problematic SQL statement with similar statements that are parsed correctly to identify any differences that may be causing the issue. ## Pre Commit Checks diff --git a/README.md b/README.md index 775d074915..235c63d3d9 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,26 @@ This crate contains a lexer and parser for SQL that conforms with the is used as a foundation for SQL query engines, vendor-specific parsers, and various SQL analysis. +Snowflake warehouse parsing uses typed CREATE, ALTER, SHOW, +DESCRIBE and DROP nodes in `ast::helpers::stmt_warehouse`; USE keeps its existing +AST. It retains quoted/multipart account names and ordered primitive options for +later semantic validation. Parsing does not implement warehouses, authorize an +operation, evaluate properties, or establish wire metadata/error parity. Tags, +CREATE OR ALTER, adaptive/interactive control, ABORT ALL QUERIES and SHOW privilege +filters remain outside this selected grammar. + +The Snowflake table-comment source preparation adds explicit `COMMENT = NULL` as +`SqlOption::Comment(CommentDef::NullWithEq)` within `CreateTableOptions::Plain`. +An absent clause, `COMMENT = ''`, `COMMENT = 'NULL'` and typed NULL remain distinct. +Existing string and dollar-quoted comments keep their string AST variants. Only +the dedicated CREATE TABLE arm admits NULL; shared column/stage/file-format/view +and connector string parsers remain strict, as does table `COMMENT NULL` without +`=`. The selected Snowflake 10.40.102 reference is a temporary CREATE table +(query `01c83e8c-0209-ab5f-0004-d66e05c171ae`), without a persistence/runtime claim. +This preparation is unqualified until the fork checks run. The additive public +enum variant can break external exhaustive matches or old serde readers; existing +string constructors/representations stay intact, without a binary ABI guarantee. + ## Example To parse a simple `SELECT` statement: @@ -78,7 +98,42 @@ This crate avoids semantic analysis because it varies drastically between dialects and implementations. If you want to do semantic analysis, feel free to use this project as a base. -## Preserves Syntax Round Trip +## Snowflake transfer operands + +`SnowflakeDialect` represents `PUT` and `GET` with dedicated statement nodes and +typed local URI, stage, and binding operands. A named stage retains its real +`ObjectName` identifiers and a separate exact payload prefix. User and table +stages remain distinct AST forms. Stage operands are not annotated as table +relations for generic relation discovery. + +Only the first two operands after a statement-start transfer keyword use opaque +location tokenization. This keeps `file://`, `/*` globs, `--` filenames, `?`, and +`#` intact without changing ordinary SQL comments or identifiers/functions named +`PUT` and `GET`. Single-quoted transfer operands retain literal backslashes and +use doubled quotes for a literal quote; ordinary SQL string escape rules outside +these operands are unchanged. Whole-stage single quotes are a local grammar form, +not independent Snowflake reference evidence. Double-quoted local URIs and +`IDENTIFIER(...)` transfer operands are outside this grammar. + +Options retain their original order, duplicates, and typed `Expr` values, including +signed numeric spelling. Optional numeric spelling metadata retains forms such as +`001` even when the `bigdecimal` feature normalizes the typed number; rendering +ignores stale spelling after a visitor changes that numeric value. Location +bindings use normal placeholder expressions so +expression visitors can apply the owning engine's existing binding accounting. +`dialect::snowflake::parse_file_transfer_stage_location` converts an exact bound +stage value into a typed target without manufacturing a SQL statement. + +The parser performs no storage IO, option evaluation, range checking, async/batch +admission, or transfer execution. The execution layer must validate primitive +option shapes and supported stage kinds before planning/evaluating expressions +or submitting work. Accepting syntax does not claim a transfer transport or a +complete Snowflake workflow. In particular, path traversal/backslash admission, +`PARALLEL` range errors, duplicate/unknown options, and unsupported stage kinds +remain semantic responsibilities. These additions require owning qualification; +source tests alone do not establish runtime or reference parity. + +## Preserves Syntax Round Trip This crate allows users to recover the original SQL text (with comments removed, normalized whitespace and keyword capitalization), which is useful for tools diff --git a/examples/cli.rs b/examples/cli.rs index 3c4299b209..51a63a7ea1 100644 --- a/examples/cli.rs +++ b/examples/cli.rs @@ -71,9 +71,9 @@ $ cargo run --example cli - [--dialectname] .expect("failed to read from stdin"); String::from_utf8(buf).expect("stdin content wasn't valid utf8") } else { - println!("Parsing from file '{}' using {:?}", &filename, dialect); + println!("Parsing from file '{}' using {:?}", filename, dialect); fs::read_to_string(&filename) - .unwrap_or_else(|_| panic!("Unable to read the file {}", &filename)) + .unwrap_or_else(|_| panic!("Unable to read the file {}", filename)) }; let without_bom = if contents.chars().next().unwrap() as u64 != 0xfeff { contents.as_str() diff --git a/src/ast/ddl.rs b/src/ast/ddl.rs index 67aefb3928..a6f73e90cc 100644 --- a/src/ast/ddl.rs +++ b/src/ast/ddl.rs @@ -3024,6 +3024,9 @@ pub struct CreateTable { /// Snowflake "BASE_LOCATION" clause for Iceberg tables /// pub base_location: Option, + /// Snowflake "ICEBERG_VERSION" clause for Iceberg tables. + /// + pub iceberg_version: Option, /// Snowflake "CATALOG" clause for Iceberg tables /// pub catalog: Option, @@ -3265,6 +3268,9 @@ impl fmt::Display for CreateTable { if let Some(base_location) = self.base_location.as_ref() { write!(f, " BASE_LOCATION='{base_location}'")?; } + if let Some(iceberg_version) = self.iceberg_version { + write!(f, " ICEBERG_VERSION={iceberg_version}")?; + } } if let Some(catalog_sync) = self.catalog_sync.as_ref() { @@ -3788,6 +3794,63 @@ impl fmt::Display for CreateConnector { } } +/// A native Snowflake `ALTER DATABASE` property operation. +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub enum AlterDatabaseOperation { + /// Set primitive database properties without an `OPTIONS (...)` wrapper. + SetOptions { + /// Ordered assignments, including duplicates; values are not semantically validated. + options: Vec, + }, + /// Reset database properties to their defaults. + UnsetOptions { + /// Ordered property names, including duplicates. + options: Vec, + }, +} + +impl fmt::Display for AlterDatabaseOperation { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::SetOptions { options } => write!(f, "SET {}", display_separated(options, " ")), + Self::UnsetOptions { options } => { + write!(f, "UNSET {}", display_comma_separated(options)) + } + } + } +} + +/// A Snowflake `ALTER DATABASE` (`Statement::AlterDatabase`) statement. +/// +/// See [Snowflake](https://docs.snowflake.com/en/sql-reference/sql/alter-database). +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub struct AlterDatabase { + /// The account-level database name, not a table relation. + pub name: ObjectName, + /// Whether `IF EXISTS` was specified. + pub if_exists: bool, + /// The property operations to perform on the database. + pub operations: Vec, +} + +impl fmt::Display for AlterDatabase { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "ALTER DATABASE ")?; + if self.if_exists { + write!(f, "IF EXISTS ")?; + } + write!(f, "{}", self.name)?; + for operation in &self.operations { + write!(f, " {operation}")?; + } + Ok(()) + } +} + /// An `ALTER SCHEMA` (`Statement::AlterSchema`) operation. /// /// See [BigQuery](https://cloud.google.com/bigquery/docs/reference/standard-sql/data-definition-language#alter_schema_collate_statement) @@ -3818,6 +3881,16 @@ pub enum AlterSchemaOperation { /// The options to set. options: Vec, }, + /// Set primitive schema properties without an `OPTIONS (...)` wrapper (Snowflake). + SetOptions { + /// Ordered assignments, including duplicates; values are not semantically validated. + options: Vec, + }, + /// Reset schema properties to their defaults (Snowflake). + UnsetOptions { + /// Ordered property names, including duplicates. + options: Vec, + }, /// Rename the schema. Rename { /// The new name for the schema. @@ -3847,6 +3920,12 @@ impl fmt::Display for AlterSchemaOperation { AlterSchemaOperation::SetOptionsParens { options } => { write!(f, "SET OPTIONS ({})", display_comma_separated(options)) } + AlterSchemaOperation::SetOptions { options } => { + write!(f, "SET {}", display_separated(options, " ")) + } + AlterSchemaOperation::UnsetOptions { options } => { + write!(f, "UNSET {}", display_comma_separated(options)) + } AlterSchemaOperation::Rename { name } => write!(f, "RENAME TO {name}"), AlterSchemaOperation::OwnerTo { owner } => write!(f, "OWNER TO {owner}"), } @@ -4698,7 +4777,7 @@ impl fmt::Display for AlterTable { if self.only { write!(f, "ONLY ")?; } - write!(f, "{} ", &self.name)?; + write!(f, "{} ", self.name)?; if let Some(cluster) = &self.on_cluster { write!(f, "ON CLUSTER {cluster} ")?; } diff --git a/src/ast/helpers/mod.rs b/src/ast/helpers/mod.rs index 344895dc15..a390eba861 100644 --- a/src/ast/helpers/mod.rs +++ b/src/ast/helpers/mod.rs @@ -25,3 +25,5 @@ pub mod stmt_create_database; pub mod stmt_create_table; /// Helpers for data loading/unloading related statements (stages, PUT, COPY INTO). pub mod stmt_data_loading; +/// Snowflake warehouse lifecycle syntax, without execution or account semantics. +pub mod stmt_warehouse; diff --git a/src/ast/helpers/stmt_create_table.rs b/src/ast/helpers/stmt_create_table.rs index ab2feb6930..92b343f974 100644 --- a/src/ast/helpers/stmt_create_table.rs +++ b/src/ast/helpers/stmt_create_table.rs @@ -155,6 +155,8 @@ pub struct CreateTableBuilder { pub with_tags: Option>, /// Optional base location for staged data. pub base_location: Option, + /// Optional Iceberg format version. + pub iceberg_version: Option, /// Optional external volume identifier. pub external_volume: Option, /// Optional catalog name. @@ -234,6 +236,7 @@ impl CreateTableBuilder { with_storage_lifecycle_policy: None, with_tags: None, base_location: None, + iceberg_version: None, external_volume: None, catalog: None, catalog_sync: None, @@ -604,6 +607,7 @@ impl CreateTableBuilder { with_storage_lifecycle_policy: self.with_storage_lifecycle_policy, with_tags: self.with_tags, base_location: self.base_location, + iceberg_version: self.iceberg_version, external_volume: self.external_volume, catalog: self.catalog, catalog_sync: self.catalog_sync, @@ -685,6 +689,7 @@ impl From for CreateTableBuilder { with_storage_lifecycle_policy: table.with_storage_lifecycle_policy, with_tags: table.with_tags, base_location: table.base_location, + iceberg_version: table.iceberg_version, external_volume: table.external_volume, catalog: table.catalog, catalog_sync: table.catalog_sync, diff --git a/src/ast/helpers/stmt_data_loading.rs b/src/ast/helpers/stmt_data_loading.rs index 26bbacc5d1..43679bbcd0 100644 --- a/src/ast/helpers/stmt_data_loading.rs +++ b/src/ast/helpers/stmt_data_loading.rs @@ -20,17 +20,242 @@ //! See [this page](https://docs.snowflake.com/en/sql-reference/commands-data-loading) for more details. #[cfg(not(feature = "std"))] -use alloc::string::String; +use alloc::{format, string::String, vec::Vec}; use core::fmt; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; use crate::ast::helpers::key_value_options::KeyValueOptions; -use crate::ast::{Ident, ObjectName, SelectItem}; +use crate::ast::{Expr, Ident, ObjectName, SelectItem, UnaryOperator, Value}; #[cfg(feature = "visitor")] use sqlparser_derive::{Visit, VisitMut}; +/// Snowflake `PUT` upload syntax. Execution and transfer admission are separate concerns. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub struct Put { + /// Local source URI or a binding expression. + pub source: FileTransferLocation, + /// Stage destination or a binding expression. + pub target: FileTransferLocation, + /// Ordered options, including duplicates for owning semantic validation. + pub options: Vec, +} + +/// Snowflake `GET` download syntax. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub struct Get { + /// Stage source or a binding expression. + pub source: FileTransferLocation, + /// Local destination URI or a binding expression. + pub target: FileTransferLocation, + /// Ordered options, including duplicates for owning semantic validation. + pub options: Vec, +} + +/// A transfer operand, distinct from a table relation and from arbitrary SQL text. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub enum FileTransferLocation { + /// A local URI, preserving the `file://` prefix and path bytes. + Local { + /// Exact URI; the parser does not resolve or normalize a filesystem path. + uri: String, + /// Whether the complete operand was enclosed in single quotes. + quoted: bool, + }, + /// A typed stage reference and separate optional payload prefix. + Stage(FileTransferStageLocation), + /// A native SQL placeholder. Visitors must consume it through normal expression binding. + Binding(Expr), +} + +/// A stage reference used by transfer syntax, without table-relation visitor annotations. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub struct FileTransferStageLocation { + /// Named, user, or table stage syntax. Parsing does not imply execution support. + pub kind: FileTransferStage, + /// Exact bytes after the first slash outside quoted identifiers; `Some("")` retains `/`. + pub prefix: Option, + /// Whether the complete operand was enclosed in single quotes. + pub quoted: bool, +} + +/// Stage kinds must remain distinct so an execution layer can explicitly refuse unsupported ones. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub enum FileTransferStage { + /// A real qualified object name, retaining every identifier's quoting. + Named(ObjectName), + /// The current user's `@~` stage. + User, + /// A table's `@[namespace.]%table` stage, retaining its qualified name. + Table(ObjectName), +} + +/// One transfer option. Values use normal expression/value nodes, not enum-like placeholders. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub struct FileTransferOption { + /// Original option name, including its identifier quoting. + pub name: Ident, + /// Typed value traversed by normal expression binding. + pub value: Expr, + /// Original primitive numeric spelling when the numeric AST feature normalizes it. + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub number_spelling: Option, +} + +pub(crate) fn file_transfer_number_matches(value: &Expr, raw: &str) -> bool { + let (value, digits) = match value { + Expr::UnaryOp { + op: UnaryOperator::Plus, + expr, + } => { + let Some(digits) = raw.strip_prefix('+') else { + return false; + }; + (expr.as_ref(), digits) + } + Expr::UnaryOp { + op: UnaryOperator::Minus, + expr, + } => { + let Some(digits) = raw.strip_prefix('-') else { + return false; + }; + (expr.as_ref(), digits) + } + _ if raw.starts_with(['+', '-']) => return false, + _ => (value, raw), + }; + let Expr::Value(value) = value else { + return false; + }; + let Ok(number) = digits.parse() else { + return false; + }; + value.value == Value::Number(number, false) +} + +impl fmt::Display for FileTransferStage { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::Named(name) => write!(f, "{name}"), + Self::User => f.write_str("~"), + Self::Table(name) => { + for (index, part) in name.0.iter().enumerate() { + if index > 0 { + f.write_str(".")?; + } + if index + 1 == name.0.len() { + f.write_str("%")?; + } + write!(f, "{part}")?; + } + Ok(()) + } + } + } +} + +impl fmt::Display for FileTransferStageLocation { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let mut value = format!("@{}", self.kind); + if let Some(prefix) = &self.prefix { + value.push('/'); + value.push_str(prefix); + } + display_transfer_location(f, &value, self.quoted) + } +} + +fn display_transfer_location(f: &mut fmt::Formatter, value: &str, quoted: bool) -> fmt::Result { + if quoted || file_transfer_location_needs_quotes(value) { + // Transfer paths keep backslashes literal; only the enclosing SQL quote is escaped. + write!(f, "'{}'", value.replace('\'', "''")) + } else { + f.write_str(value) + } +} + +pub(crate) fn file_transfer_location_needs_quotes(value: &str) -> bool { + let mut stage_name = value.starts_with('@'); + let mut identifier_quote = false; + let mut chars = value.chars().peekable(); + while let Some(ch) = chars.next() { + if stage_name && ch == '"' { + if identifier_quote && chars.peek() == Some(&'"') { + chars.next(); + } else { + identifier_quote = !identifier_quote; + } + } else if stage_name && ch == '/' && !identifier_quote { + stage_name = false; + } else if !identifier_quote && (ch.is_whitespace() || ch == ';') { + return true; + } + } + false +} + +impl fmt::Display for FileTransferLocation { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::Local { uri, quoted } => display_transfer_location(f, uri, *quoted), + Self::Stage(stage) => write!(f, "{stage}"), + Self::Binding(expr) => write!(f, "{expr}"), + } + } +} + +impl fmt::Display for FileTransferOption { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}=", self.name)?; + if let Some(raw) = self + .number_spelling + .as_deref() + .filter(|raw| file_transfer_number_matches(&self.value, raw)) + { + f.write_str(raw) + } else { + write!(f, "{}", self.value) + } + } +} + +impl fmt::Display for Put { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "PUT {} {}", self.source, self.target)?; + for option in &self.options { + write!(f, " {option}")?; + } + Ok(()) + } +} + +impl fmt::Display for Get { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "GET {} {}", self.source, self.target)?; + for option in &self.options { + write!(f, " {option}")?; + } + Ok(()) + } +} + #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] @@ -135,6 +360,12 @@ pub struct StageLoadSelectItem { pub alias: Option, /// Column number within the staged file (1-based). pub file_col_num: i32, + /// Original spelling when the staged placeholder differs from its numeric form. + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub file_col_num_raw: Option, /// Optional element identifier following the column reference. pub element: Option, /// Optional alias for the item (AS clause). @@ -172,7 +403,15 @@ impl fmt::Display for StageLoadSelectItem { if let Some(alias) = &self.alias { write!(f, "{alias}.")?; } - write!(f, "${}", self.file_col_num)?; + if let Some(raw) = self.file_col_num_raw.as_deref().filter(|raw| { + raw.strip_prefix('$') + .and_then(|digits| digits.parse::().ok()) + == Some(self.file_col_num) + }) { + write!(f, "{raw}")?; + } else { + write!(f, "${}", self.file_col_num)?; + } if let Some(element) = &self.element { write!(f, ":{element}")?; } diff --git a/src/ast/helpers/stmt_warehouse.rs b/src/ast/helpers/stmt_warehouse.rs new file mode 100644 index 0000000000..183cd468e2 --- /dev/null +++ b/src/ast/helpers/stmt_warehouse.rs @@ -0,0 +1,232 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Snowflake warehouse syntax. Parsing grants no execution or account authority. + +#[cfg(not(feature = "std"))] +use alloc::{string::String, vec::Vec}; +use core::fmt; + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; +#[cfg(feature = "visitor")] +use sqlparser_derive::{Visit, VisitMut}; + +use crate::ast::{display_comma_separated, DescribeAlias, Expr, Ident, ObjectName, Value}; + +/// An ordered warehouse property. Duplicate names remain available for semantic validation. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub struct WarehouseOption { + /// Property name, without a parser-owned semantic whitelist. + pub name: Ident, + /// Parsed primitive identifier, literal, or signed numeric literal; never evaluated. + /// Consumers must revalidate shapes after external AST construction or mutation. + pub value: Expr, +} + +impl fmt::Display for WarehouseOption { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} = {}", self.name, self.value) + } +} + +/// `CREATE [OR REPLACE] WAREHOUSE [IF NOT EXISTS] name [[WITH] properties]`. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub struct CreateWarehouse { + /// Whether `OR REPLACE` was present; conflicting clauses are retained for the semantic owner. + pub or_replace: bool, + /// Whether `IF NOT EXISTS` was present. + pub if_not_exists: bool, + /// Account object name. Multipart and generating names are not resolved by parsing. + pub name: ObjectName, + /// Whether properties were introduced by `WITH`. + pub with_keyword: bool, + /// Ordered properties, including duplicates and unsupported semantic values. + pub options: Vec, +} + +impl fmt::Display for CreateWarehouse { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "CREATE")?; + if self.or_replace { + write!(f, " OR REPLACE")?; + } + write!(f, " WAREHOUSE")?; + if self.if_not_exists { + write!(f, " IF NOT EXISTS")?; + } + write!(f, " {}", self.name)?; + if self.with_keyword { + write!(f, " WITH")?; + } + for option in &self.options { + write!(f, " {option}")?; + } + Ok(()) + } +} + +/// A selected `ALTER WAREHOUSE` action, independent of execution support. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub enum AlterWarehouseAction { + /// Suspend the named warehouse or the current warehouse when no name is supplied. + Suspend, + /// Resume a warehouse, optionally succeeding when it is already started. + Resume { + /// Whether `IF SUSPENDED` was present. + if_suspended: bool, + }, + /// Set properties in their original order, including duplicates. + Set(Vec), + /// Unset the listed property names. + Unset(Vec), + /// Rename to an unresolved account object name. + RenameTo(ObjectName), +} + +impl fmt::Display for AlterWarehouseAction { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Suspend => write!(f, "SUSPEND"), + Self::Resume { if_suspended } => { + write!(f, "RESUME")?; + if *if_suspended { + write!(f, " IF SUSPENDED")?; + } + Ok(()) + } + Self::Set(options) => { + write!(f, "SET")?; + for option in options { + write!(f, " {option}")?; + } + Ok(()) + } + Self::Unset(names) => write!(f, "UNSET {}", display_comma_separated(names)), + Self::RenameTo(name) => write!(f, "RENAME TO {name}"), + } + } +} + +/// `ALTER WAREHOUSE [IF EXISTS] [name] action`. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub struct AlterWarehouse { + /// Whether `IF EXISTS` was present. + pub if_exists: bool, + /// Omitted only for the selected SUSPEND/RESUME grammar. + pub name: Option, + /// The parsed action. No statement is executed while constructing the AST. + pub action: AlterWarehouseAction, +} + +impl fmt::Display for AlterWarehouse { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "ALTER WAREHOUSE")?; + if self.if_exists { + write!(f, " IF EXISTS")?; + } + if let Some(name) = &self.name { + write!(f, " {name}")?; + } + write!(f, " {}", self.action) + } +} + +/// `SHOW [TERSE] WAREHOUSES` with selected literal filters. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub struct ShowWarehouses { + /// Whether `TERSE` was present; does not assert a particular result schema. + pub terse: bool, + /// Case-insensitive SQL LIKE pattern, interpreted only by a future execution consumer. + pub like_pattern: Option, + /// Selected reference grammar; filter semantics are not inferred by the parser. + pub starts_with: Option, + /// Numeric LIMIT syntax; bounds are owned by semantic validation. + pub limit: Option, +} + +impl fmt::Display for ShowWarehouses { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "SHOW")?; + if self.terse { + write!(f, " TERSE")?; + } + write!(f, " WAREHOUSES")?; + if let Some(pattern) = &self.like_pattern { + write!(f, " LIKE {}", Value::SingleQuotedString(pattern.clone()))?; + } + if let Some(prefix) = &self.starts_with { + write!( + f, + " STARTS WITH {}", + Value::SingleQuotedString(prefix.clone()) + )?; + } + if let Some(limit) = &self.limit { + write!(f, " LIMIT {limit}")?; + } + Ok(()) + } +} + +/// `DESC[RIBE] WAREHOUSE name`, distinct from table description. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub struct DescribeWarehouse { + /// The original DESC/DESCRIBE spelling. + pub describe_alias: DescribeAlias, + /// Unresolved account object name, without table-relation visitor annotations. + pub name: ObjectName, +} + +impl fmt::Display for DescribeWarehouse { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} WAREHOUSE {}", self.describe_alias, self.name) + } +} + +/// `DROP WAREHOUSE [IF EXISTS] name`, without generic DROP modifiers or multiple targets. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub struct DropWarehouse { + /// Whether `IF EXISTS` was present. + pub if_exists: bool, + /// Unresolved account object name, without table-relation visitor annotations. + pub name: ObjectName, +} + +impl fmt::Display for DropWarehouse { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "DROP WAREHOUSE")?; + if self.if_exists { + write!(f, " IF EXISTS")?; + } + write!(f, " {}", self.name) + } +} diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 5d6fea9bd9..fb2df05474 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -27,7 +27,11 @@ use alloc::{ use helpers::{ attached_token::AttachedToken, stmt_data_loading::{ - AlterFileFormatOperation, AlterStageOperation, FileStagingCommand, StageLoadSelectItemKind, + AlterFileFormatOperation, AlterStageOperation, FileStagingCommand, Get, Put, + StageLoadSelectItemKind, + }, + stmt_warehouse::{ + AlterWarehouse, CreateWarehouse, DescribeWarehouse, DropWarehouse, ShowWarehouses, }, }; @@ -63,27 +67,28 @@ pub use self::dcl::{ }; pub use self::ddl::{ Alignment, AlterCollation, AlterCollationOperation, AlterColumnOperation, AlterConnectorOwner, - AlterFunction, AlterFunctionAction, AlterFunctionKind, AlterFunctionOperation, - AlterIndexOperation, AlterOperator, AlterOperatorClass, AlterOperatorClassOperation, - AlterOperatorFamily, AlterOperatorFamilyOperation, AlterOperatorOperation, AlterPolicy, - AlterPolicyOperation, AlterSchema, AlterSchemaOperation, AlterTable, AlterTableAlgorithm, - AlterTableLock, AlterTableOperation, AlterTableType, AlterType, AlterTypeAddValue, - AlterTypeAddValuePosition, AlterTypeOperation, AlterTypeRename, AlterTypeRenameValue, - ClusteredBy, ColumnDef, ColumnOption, ColumnOptionDef, ColumnOptions, ColumnPolicy, - ColumnPolicyProperty, ConstraintCharacteristics, CreateCollation, CreateCollationDefinition, - CreateConnector, CreateDomain, CreateExtension, CreateFunction, CreateIndex, CreateOperator, - CreateOperatorClass, CreateOperatorFamily, CreatePolicy, CreatePolicyCommand, CreatePolicyType, - CreateTable, CreateTrigger, CreateView, Deduplicate, DeferrableInitial, DistStyle, - DropBehavior, DropExtension, DropFunction, DropOperator, DropOperatorClass, DropOperatorFamily, - DropOperatorSignature, DropPolicy, DropTrigger, ForValues, FunctionReturnType, GeneratedAs, - GeneratedExpressionMode, IdentityParameters, IdentityProperty, IdentityPropertyFormatKind, - IdentityPropertyKind, IdentityPropertyOrder, IndexColumn, IndexOption, IndexType, - KeyOrIndexDisplay, Msck, NullsDistinctOption, OperatorArgTypes, OperatorClassItem, - OperatorFamilyDropItem, OperatorFamilyItem, OperatorOption, OperatorPurpose, Owner, Partition, - PartitionBoundValue, ProcedureParam, ReferentialAction, RenameTableNameKind, ReplicaIdentity, - TagsColumnOption, TriggerObjectKind, Truncate, UserDefinedTypeCompositeAttributeDef, - UserDefinedTypeInternalLength, UserDefinedTypeRangeOption, UserDefinedTypeRepresentation, - UserDefinedTypeSqlDefinitionOption, UserDefinedTypeStorage, ViewColumnDef, + AlterDatabase, AlterDatabaseOperation, AlterFunction, AlterFunctionAction, AlterFunctionKind, + AlterFunctionOperation, AlterIndexOperation, AlterOperator, AlterOperatorClass, + AlterOperatorClassOperation, AlterOperatorFamily, AlterOperatorFamilyOperation, + AlterOperatorOperation, AlterPolicy, AlterPolicyOperation, AlterSchema, AlterSchemaOperation, + AlterTable, AlterTableAlgorithm, AlterTableLock, AlterTableOperation, AlterTableType, + AlterType, AlterTypeAddValue, AlterTypeAddValuePosition, AlterTypeOperation, AlterTypeRename, + AlterTypeRenameValue, ClusteredBy, ColumnDef, ColumnOption, ColumnOptionDef, ColumnOptions, + ColumnPolicy, ColumnPolicyProperty, ConstraintCharacteristics, CreateCollation, + CreateCollationDefinition, CreateConnector, CreateDomain, CreateExtension, CreateFunction, + CreateIndex, CreateOperator, CreateOperatorClass, CreateOperatorFamily, CreatePolicy, + CreatePolicyCommand, CreatePolicyType, CreateTable, CreateTrigger, CreateView, Deduplicate, + DeferrableInitial, DistStyle, DropBehavior, DropExtension, DropFunction, DropOperator, + DropOperatorClass, DropOperatorFamily, DropOperatorSignature, DropPolicy, DropTrigger, + ForValues, FunctionReturnType, GeneratedAs, GeneratedExpressionMode, IdentityParameters, + IdentityProperty, IdentityPropertyFormatKind, IdentityPropertyKind, IdentityPropertyOrder, + IndexColumn, IndexOption, IndexType, KeyOrIndexDisplay, Msck, NullsDistinctOption, + OperatorArgTypes, OperatorClassItem, OperatorFamilyDropItem, OperatorFamilyItem, + OperatorOption, OperatorPurpose, Owner, Partition, PartitionBoundValue, ProcedureParam, + ReferentialAction, RenameTableNameKind, ReplicaIdentity, TagsColumnOption, TriggerObjectKind, + Truncate, UserDefinedTypeCompositeAttributeDef, UserDefinedTypeInternalLength, + UserDefinedTypeRangeOption, UserDefinedTypeRepresentation, UserDefinedTypeSqlDefinitionOption, + UserDefinedTypeStorage, ViewColumnDef, }; pub use self::dml::{ Delete, Insert, Merge, MergeAction, MergeClause, MergeClauseKind, MergeInsertExpr, @@ -3773,6 +3778,11 @@ pub enum Statement { /// See [BigQuery](https://cloud.google.com/bigquery/docs/reference/standard-sql/data-definition-language#alter_schema_collate_statement) AlterSchema(AlterSchema), /// ```sql + /// ALTER DATABASE + /// ``` + /// See [Snowflake](https://docs.snowflake.com/en/sql-reference/sql/alter-database) + AlterDatabase(AlterDatabase), + /// ```sql /// ALTER INDEX /// ``` AlterIndex { @@ -4377,6 +4387,12 @@ pub enum Statement { /// CREATE SCHEMA /// ``` CreateSchema { + /// `true` when `OR REPLACE` was present (Snowflake). + #[cfg_attr(feature = "serde", serde(default))] + or_replace: bool, + /// `true` when `TRANSIENT` was present (Snowflake). + #[cfg_attr(feature = "serde", serde(default))] + transient: bool, /// ` | AUTHORIZATION | AUTHORIZATION ` schema_name: SchemaName, /// `true` when `IF NOT EXISTS` was present. @@ -4413,6 +4429,14 @@ pub enum Statement { /// /// [Snowflake](https://docs.snowflake.com/en/sql-reference/sql/create-clone#databases-schemas) clone: Option, + /// `true` when `WITH MANAGED ACCESS` was present (Snowflake). + #[cfg_attr(feature = "serde", serde(default))] + with_managed_access: bool, + /// Space-separated Snowflake `COMMENT` and `DATA_RETENTION_TIME_IN_DAYS` assignments. + /// Stored as [`SqlOption::KeyValue`] entries in source order, retaining duplicates + /// and literal values for the execution consumer to validate. + #[cfg_attr(feature = "serde", serde(default))] + schema_options: Vec, }, /// ```sql /// CREATE DATABASE @@ -4741,6 +4765,12 @@ pub enum Statement { name: ObjectName, }, /// ```sql + /// DESC | DESCRIBE { DATABASE | SCHEMA } + /// ``` + /// See [DESCRIBE DATABASE](https://docs.snowflake.com/en/sql-reference/sql/desc-database) + /// and [DESCRIBE SCHEMA](https://docs.snowflake.com/en/sql-reference/sql/desc-schema) + DescribeObject(DescribeObject), + /// ```sql /// [EXPLAIN | DESC | DESCRIBE] /// ``` Explain { @@ -5022,6 +5052,10 @@ pub enum Statement { /// Snowflake `REMOVE` /// See: Remove(FileStagingCommand), + /// Snowflake `PUT`: prepare an upload from a local URI to a stage. + Put(Put), + /// Snowflake `GET`: prepare a download from a stage to a local URI. + Get(Get), /// RaiseError (MSSQL) /// RAISERROR ( { msg_id | msg_str | @local_variable } /// { , severity , state } @@ -5092,6 +5126,16 @@ pub enum Statement { /// ``` /// [PostgreSQL](https://www.postgresql.org/docs/current/sql-reset.html) Reset(ResetStatement), + /// Snowflake `CREATE WAREHOUSE` syntax; semantic admission belongs to the consumer. + CreateWarehouse(CreateWarehouse), + /// Snowflake `ALTER WAREHOUSE` syntax. + AlterWarehouse(AlterWarehouse), + /// Snowflake `SHOW WAREHOUSES` syntax, distinct from SHOW session variables. + ShowWarehouses(ShowWarehouses), + /// Snowflake `DESC[RIBE] WAREHOUSE` syntax, distinct from table description. + DescribeWarehouse(DescribeWarehouse), + /// Snowflake single-target `DROP WAREHOUSE` syntax. + DropWarehouse(DropWarehouse), } impl From for Statement { @@ -5234,6 +5278,7 @@ impl fmt::Display for Statement { write!(f, "{id}") } + Statement::DescribeObject(s) => write!(f, "{s}"), Statement::ExplainTable { describe_alias, hive_format, @@ -6186,16 +6231,22 @@ impl fmt::Display for Statement { Ok(()) } Statement::CreateSchema { + or_replace, + transient, schema_name, if_not_exists, with, options, default_collate_spec, clone, + with_managed_access, + schema_options, } => { write!( f, - "CREATE SCHEMA {if_not_exists}{name}", + "CREATE {or_replace}{transient}SCHEMA {if_not_exists}{name}", + or_replace = if *or_replace { "OR REPLACE " } else { "" }, + transient = if *transient { "TRANSIENT " } else { "" }, if_not_exists = if *if_not_exists { "IF NOT EXISTS " } else { "" }, name = schema_name )?; @@ -6215,6 +6266,12 @@ impl fmt::Display for Statement { if let Some(clone) = clone { write!(f, " CLONE {clone}")?; } + if *with_managed_access { + write!(f, " WITH MANAGED ACCESS")?; + } + for option in schema_options { + write!(f, " {option}")?; + } Ok(()) } Statement::Assert { condition, message } => { @@ -6723,9 +6780,17 @@ impl fmt::Display for Statement { Statement::Return(r) => write!(f, "{r}"), Statement::List(command) => write!(f, "LIST {command}"), Statement::Remove(command) => write!(f, "REMOVE {command}"), + Statement::Put(command) => write!(f, "{command}"), + Statement::Get(command) => write!(f, "{command}"), Statement::ExportData(e) => write!(f, "{e}"), Statement::CreateUser(s) => write!(f, "{s}"), + Statement::CreateWarehouse(s) => write!(f, "{s}"), + Statement::AlterWarehouse(s) => write!(f, "{s}"), + Statement::ShowWarehouses(s) => write!(f, "{s}"), + Statement::DescribeWarehouse(s) => write!(f, "{s}"), + Statement::DropWarehouse(s) => write!(f, "{s}"), Statement::AlterSchema(s) => write!(f, "{s}"), + Statement::AlterDatabase(s) => write!(f, "{s}"), Statement::Vacuum(s) => write!(f, "{s}"), Statement::AlterUser(s) => write!(f, "{s}"), Statement::Reset(s) => write!(f, "{s}"), @@ -9061,6 +9126,33 @@ impl fmt::Display for DescribeAlias { } } +/// ```sql +/// DESC | DESCRIBE { DATABASE | SCHEMA } +/// ``` +/// See [DESCRIBE DATABASE](https://docs.snowflake.com/en/sql-reference/sql/desc-database) +/// and [DESCRIBE SCHEMA](https://docs.snowflake.com/en/sql-reference/sql/desc-schema) +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub struct DescribeObject { + /// The keyword used, `DESC` or `DESCRIBE`. + pub describe_alias: DescribeAlias, + /// The kind of object described: `DATABASE` or `SCHEMA`. + pub object_type: ObjectType, + /// Name of the object. + pub name: ObjectName, +} + +impl fmt::Display for DescribeObject { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "{} {} {}", + self.describe_alias, self.object_type, self.name + ) + } +} + #[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] @@ -9265,6 +9357,7 @@ impl fmt::Display for SqlOption { CommentDef::WithoutEq(comment) => { write!(f, "COMMENT '{comment}'") } + CommentDef::NullWithEq => write!(f, "COMMENT = NULL"), }, SqlOption::NamedParenthesizedList(value) => { write!(f, "{} = ", value.key)?; @@ -11060,12 +11153,15 @@ pub enum CommentDef { WithEq(String), /// Comment variant that omits the `=` when displayed. WithoutEq(String), + /// Explicit `COMMENT = NULL`, admitted only by Snowflake CREATE TABLE syntax. + NullWithEq, } impl Display for CommentDef { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { CommentDef::WithEq(comment) | CommentDef::WithoutEq(comment) => write!(f, "{comment}"), + CommentDef::NullWithEq => write!(f, "NULL"), } } } @@ -12145,7 +12241,7 @@ impl fmt::Display for AlterUser { let has_props = !self.set_props.options.is_empty(); if has_props { write!(f, " SET")?; - write!(f, " {}", &self.set_props)?; + write!(f, " {}", self.set_props)?; } if !self.unset_props.is_empty() { write!(f, " UNSET {}", display_comma_separated(&self.unset_props))?; @@ -12689,6 +12785,12 @@ impl From for Statement { } } +impl From for Statement { + fn from(a: AlterDatabase) -> Self { + Self::AlterDatabase(a) + } +} + impl From for Statement { fn from(a: AlterFunction) -> Self { Self::AlterFunction(a) @@ -12845,6 +12947,12 @@ impl From for Statement { } } +impl From for Statement { + fn from(d: DescribeObject) -> Self { + Self::DescribeObject(d) + } +} + impl From for Statement { fn from(v: VacuumStatement) -> Self { Self::Vacuum(v) diff --git a/src/ast/query.rs b/src/ast/query.rs index 9925d77430..0782419d30 100644 --- a/src/ast/query.rs +++ b/src/ast/query.rs @@ -3554,7 +3554,7 @@ pub struct LockClause { impl fmt::Display for LockClause { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "FOR {}", &self.lock_type)?; + write!(f, "FOR {}", self.lock_type)?; if let Some(ref of) = self.of { write!(f, " OF {of}")?; } diff --git a/src/ast/spans.rs b/src/ast/spans.rs index 91f547053a..c0fe578ce8 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -17,9 +17,9 @@ use crate::{ ast::{ - ddl::AlterSchema, query::SelectItemQualifiedWildcardKind, AlterSchemaOperation, AlterTable, - ColumnOptions, CreateOperator, CreateOperatorClass, CreateOperatorFamily, CreateView, - ExportData, Owner, TypedString, + ddl::AlterSchema, query::SelectItemQualifiedWildcardKind, AlterDatabase, + AlterDatabaseOperation, AlterSchemaOperation, AlterTable, ColumnOptions, CreateOperator, + CreateOperatorClass, CreateOperatorFamily, CreateView, ExportData, Owner, TypedString, }, tokenizer::TokenWithSpan, }; @@ -328,6 +328,12 @@ impl Spanned for Values { impl Spanned for Statement { fn span(&self) -> Span { match self { + // These account-level commands do not yet retain complete statement spans. + Statement::CreateWarehouse(_) + | Statement::AlterWarehouse(_) + | Statement::ShowWarehouses(_) + | Statement::DescribeWarehouse(_) + | Statement::DropWarehouse(_) => Span::empty(), Statement::Analyze(analyze) => analyze.span(), Statement::Truncate(truncate) => truncate.span(), Statement::Msck(msck) => msck.span(), @@ -485,6 +491,7 @@ impl Spanned for Statement { Statement::DescribeStage { .. } => Span::empty(), Statement::DescribeFileFormat { .. } => Span::empty(), Statement::DescribeSequence { .. } => Span::empty(), + Statement::DescribeObject(..) => Span::empty(), Statement::Explain { .. } => Span::empty(), Statement::Savepoint { .. } => Span::empty(), Statement::ReleaseSavepoint { .. } => Span::empty(), @@ -520,7 +527,10 @@ impl Spanned for Statement { Statement::Print { .. } => Span::empty(), Statement::WaitFor(_) => Span::empty(), Statement::Return { .. } => Span::empty(), - Statement::List(..) | Statement::Remove(..) => Span::empty(), + Statement::List(..) + | Statement::Remove(..) + | Statement::Put(..) + | Statement::Get(..) => Span::empty(), Statement::ExportData(ExportData { options, query, @@ -534,6 +544,7 @@ impl Spanned for Statement { ), Statement::CreateUser(..) => Span::empty(), Statement::AlterSchema(s) => s.span(), + Statement::AlterDatabase(s) => s.span(), Statement::Vacuum(..) => Span::empty(), Statement::AlterUser(..) => Span::empty(), Statement::Reset(..) => Span::empty(), @@ -609,6 +620,7 @@ impl Spanned for CreateTable { with_tags: _, // todo, Snowflake specific external_volume: _, // todo, Snowflake specific base_location: _, // todo, Snowflake specific + iceberg_version: _, // u64, no span catalog: _, // todo, Snowflake specific catalog_sync: _, // todo, Snowflake specific storage_serialization_policy: _, @@ -2463,6 +2475,23 @@ impl Spanned for OpenStatement { } } +impl Spanned for AlterDatabaseOperation { + fn span(&self) -> Span { + match self { + Self::SetOptions { options } => union_spans(options.iter().map(|i| i.span())), + Self::UnsetOptions { options } => union_spans(options.iter().map(|i| i.span)), + } + } +} + +impl Spanned for AlterDatabase { + fn span(&self) -> Span { + union_spans( + core::iter::once(self.name.span()).chain(self.operations.iter().map(|i| i.span())), + ) + } +} + impl Spanned for AlterSchemaOperation { fn span(&self) -> Span { match self { @@ -2472,9 +2501,13 @@ impl Spanned for AlterSchemaOperation { .chain(options.iter().flat_map(|i| i.iter().map(|i| i.span()))), ), AlterSchemaOperation::DropReplica { replica } => replica.span, - AlterSchemaOperation::SetOptionsParens { options } => { + AlterSchemaOperation::SetOptionsParens { options } + | AlterSchemaOperation::SetOptions { options } => { union_spans(options.iter().map(|i| i.span())) } + AlterSchemaOperation::UnsetOptions { options } => { + union_spans(options.iter().map(|i| i.span)) + } AlterSchemaOperation::Rename { name } => name.span(), AlterSchemaOperation::OwnerTo { owner } => { if let Owner::Ident(ident) = owner { diff --git a/src/dialect/generic.rs b/src/dialect/generic.rs index 25f57e3d18..1ed4584db7 100644 --- a/src/dialect/generic.rs +++ b/src/dialect/generic.rs @@ -121,6 +121,10 @@ impl Dialect for GenericDialect { true } + fn supports_create_schema_options(&self) -> bool { + true + } + fn supports_parens_around_table_factor(&self) -> bool { true } @@ -312,4 +316,7 @@ impl Dialect for GenericDialect { fn supports_xml_expressions(&self) -> bool { true } + fn supports_describe_object(&self) -> bool { + true + } } diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index 85a8bac7ff..94c960d943 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -51,8 +51,8 @@ pub use self::mysql::MySqlDialect; pub use self::oracle::OracleDialect; pub use self::postgresql::PostgreSqlDialect; pub use self::redshift::RedshiftSqlDialect; -pub use self::snowflake::parse_snowflake_stage_name; pub use self::snowflake::SnowflakeDialect; +pub use self::snowflake::{parse_file_transfer_stage_location, parse_snowflake_stage_name}; pub use self::spark::SparkSqlDialect; pub use self::sqlite::SQLiteDialect; pub use self::teradata::TeradataDialect; @@ -1019,6 +1019,15 @@ pub trait Dialect: Debug + Any { false } + /// Returns true if this dialect supports `DESC | DESCRIBE ` + /// for namespace object types: `DESCRIBE DATABASE my_db` or `DESCRIBE SCHEMA my_schema`. + /// Existing `DESCRIBE VIEW` support keeps its `ExplainTable` representation. + /// + /// See + fn supports_describe_object(&self) -> bool { + false + } + /// Returns true if this dialect allows the `EXTRACT` function to words other than [`Keyword`]. fn allow_extract_custom(&self) -> bool { false @@ -1273,6 +1282,14 @@ pub trait Dialect: Debug + Any { false } + /// Supports `PUT`/`GET` with two opaque transfer operands at statement start. + /// + /// URI comment markers, globs, and stage prefixes are payload bytes only in those + /// operands. Normal SQL identifiers and comments retain their ordinary tokenization. + fn supports_file_transfer_commands(&self) -> bool { + false + } + /// Returns true if this dialect supports the `COMMENT` statement fn supports_comment_on(&self) -> bool { false @@ -1484,6 +1501,33 @@ pub trait Dialect: Debug + Any { false } + /// Returns true if `CREATE SCHEMA` supports `OR REPLACE`, `TRANSIENT`, + /// `WITH MANAGED ACCESS`, and space-separated `COMMENT = ` + /// and `DATA_RETENTION_TIME_IN_DAYS = ` suffix options. + /// + /// [Snowflake](https://docs.snowflake.com/en/sql-reference/sql/create-schema) + fn supports_create_schema_options(&self) -> bool { + false + } + + /// Returns true if `ALTER SCHEMA` supports native `SET key = ` + /// assignments separated by whitespace or commas, and `UNSET key, ...`. + /// This does not enable structured properties such as `TAG` or `CONTACT`. + /// + /// [Snowflake](https://docs.snowflake.com/en/sql-reference/sql/alter-schema) + fn supports_alter_schema_options(&self) -> bool { + false + } + + /// Returns true if `ALTER DATABASE` supports native `SET key = ` + /// assignments separated by whitespace or commas, and `UNSET key, ...`. + /// This does not enable structured properties or other database operations. + /// + /// [Snowflake](https://docs.snowflake.com/en/sql-reference/sql/alter-database) + fn supports_alter_database_options(&self) -> bool { + false + } + /// Returns true if the dialect supports the `USING` clause in an `ALTER COLUMN` statement. /// Example: /// ```sql diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index 754bbee640..27cf67994a 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -24,25 +24,31 @@ use crate::ast::helpers::key_value_options::{ use crate::ast::helpers::stmt_create_database::CreateDatabaseBuilder; use crate::ast::helpers::stmt_create_table::CreateTableBuilder; use crate::ast::helpers::stmt_data_loading::{ - AlterFileFormatOperation, AlterStageOperation, FileStagingCommand, StageLoadSelectItem, + file_transfer_location_needs_quotes, file_transfer_number_matches, AlterFileFormatOperation, + AlterStageOperation, FileStagingCommand, FileTransferLocation, FileTransferOption, + FileTransferStage, FileTransferStageLocation, Get, Put, StageLoadSelectItem, StageLoadSelectItemKind, StageParamsObject, }; +use crate::ast::helpers::stmt_warehouse::{ + AlterWarehouse, AlterWarehouseAction, CreateWarehouse, DescribeWarehouse, DropWarehouse, + ShowWarehouses, WarehouseOption, +}; use crate::ast::{ AlterTable, AlterTableOperation, AlterTableType, CatalogSyncNamespaceMode, CloudProviderParams, - ColumnOption, ColumnPolicy, ColumnPolicyProperty, ContactEntry, CopyIntoSnowflakeKind, - CreateTable, CreateTableLikeKind, DollarQuotedString, Expr, Ident, IdentityParameters, - IdentityProperty, IdentityPropertyFormatKind, IdentityPropertyKind, IdentityPropertyOrder, - InitializeKind, Insert, MultiTableInsertIntoClause, MultiTableInsertType, - MultiTableInsertValue, MultiTableInsertValues, MultiTableInsertWhenClause, ObjectName, - ObjectNamePart, RefreshModeKind, RowAccessPolicy, ShowObjects, SqlOption, Statement, - StorageLifecyclePolicy, StorageSerializationPolicy, TableObject, TagsColumnOption, Value, - WrappedCollection, + ColumnOption, ColumnPolicy, ColumnPolicyProperty, CommentDef, ContactEntry, + CopyIntoSnowflakeKind, CreateTable, CreateTableLikeKind, DescribeAlias, DollarQuotedString, + Expr, Ident, IdentityParameters, IdentityProperty, IdentityPropertyFormatKind, + IdentityPropertyKind, IdentityPropertyOrder, InitializeKind, Insert, + MultiTableInsertIntoClause, MultiTableInsertType, MultiTableInsertValue, + MultiTableInsertValues, MultiTableInsertWhenClause, ObjectName, ObjectNamePart, + RefreshModeKind, RowAccessPolicy, ShowObjects, SqlOption, Statement, StorageLifecyclePolicy, + StorageSerializationPolicy, TableObject, TagsColumnOption, Value, WrappedCollection, }; use crate::dialect::{Dialect, Precedence}; use crate::keywords::Keyword; use crate::parser::{IsOptional, Parser, ParserError}; use crate::tokenizer::TokenWithSpan; -use crate::tokenizer::{Span, Token}; +use crate::tokenizer::{Span, Token, Tokenizer}; #[cfg(not(feature = "std"))] use alloc::boxed::Box; #[cfg(not(feature = "std"))] @@ -272,6 +278,27 @@ impl Dialect for SnowflakeDialect { } fn parse_statement(&self, parser: &mut Parser) -> Option> { + if let Some(command) = parser.parse_one_of_keywords(&[Keyword::PUT, Keyword::GET]) { + return Some(parse_file_transfer_command(command, parser)); + } + + if let Some(alias) = parser.parse_one_of_keywords(&[Keyword::DESC, Keyword::DESCRIBE]) { + if parser.parse_keyword(Keyword::WAREHOUSE) { + let describe_alias = if alias == Keyword::DESC { + DescribeAlias::Desc + } else { + DescribeAlias::Describe + }; + return Some(parser.parse_object_name(false).map(|name| { + Statement::DescribeWarehouse(DescribeWarehouse { + describe_alias, + name, + }) + })); + } + parser.prev_token(); + } + if parser.parse_keyword(Keyword::BEGIN) { // Snowflake supports both `BEGIN TRANSACTION` and `BEGIN ... END` blocks. // If the next keyword indicates a transaction statement, let the @@ -315,6 +342,14 @@ impl Dialect for SnowflakeDialect { return Some(parse_alter_file_format(parser)); } + if parser.parse_keywords(&[Keyword::ALTER, Keyword::WAREHOUSE]) { + return Some(parse_alter_warehouse(parser)); + } + + if parser.parse_keywords(&[Keyword::DROP, Keyword::WAREHOUSE]) { + return Some(parse_drop_warehouse(parser)); + } + if parser.parse_keyword(Keyword::CREATE) { // possibly CREATE STAGE //[ OR REPLACE ] @@ -347,7 +382,16 @@ impl Dialect for SnowflakeDialect { _ => {} } - if parser.parse_keyword(Keyword::STAGE) { + if global.is_none() + && !dynamic + && !temporary + && !volatile + && !transient + && !iceberg + && parser.parse_keyword(Keyword::WAREHOUSE) + { + return Some(parse_create_warehouse(or_replace, parser)); + } else if parser.parse_keyword(Keyword::STAGE) { // OK - this is CREATE STAGE statement return Some(parse_create_stage(or_replace, temporary, parser)); } else if parser.parse_keywords(&[Keyword::FILE, Keyword::FORMAT]) { @@ -377,6 +421,14 @@ impl Dialect for SnowflakeDialect { if temporary { back += 1 } + if parser.peek_keyword(Keyword::SCHEMA) { + // The shared schema parser must see every consumed modifier. + back += usize::from(global.is_some()) + + usize::from(dynamic) + + usize::from(volatile) + + usize::from(transient) + + usize::from(iceberg); + } for _i in 0..back { parser.prev_token(); } @@ -401,6 +453,9 @@ impl Dialect for SnowflakeDialect { if parser.parse_keyword(Keyword::OBJECTS) { return Some(parse_show_objects(terse, parser)); } + if parser.parse_keyword(Keyword::WAREHOUSES) { + return Some(parse_show_warehouses(terse, parser)); + } //Give back Keyword::TERSE if terse { parser.prev_token(); @@ -482,6 +537,10 @@ impl Dialect for SnowflakeDialect { true } + fn supports_describe_object(&self) -> bool { + true + } + fn allow_extract_custom(&self) -> bool { true } @@ -508,6 +567,10 @@ impl Dialect for SnowflakeDialect { true } + fn supports_file_transfer_commands(&self) -> bool { + true + } + fn supports_left_associative_joins_without_parens(&self) -> bool { false } @@ -690,6 +753,18 @@ impl Dialect for SnowflakeDialect { true } + fn supports_create_schema_options(&self) -> bool { + true + } + + fn supports_alter_schema_options(&self) -> bool { + true + } + + fn supports_alter_database_options(&self) -> bool { + true + } + fn supports_comma_separated_drop_column_list(&self) -> bool { true } @@ -776,6 +851,182 @@ fn parse_file_staging_command(kw: Keyword, parser: &mut Parser) -> Result Result { + let source = parse_file_transfer_location(parser, command == Keyword::GET)?; + let target = parse_file_transfer_location(parser, command == Keyword::PUT)?; + let mut options = Vec::new(); + while !matches!(parser.peek_token_ref().token, Token::EOF | Token::SemiColon) { + let name = parser.parse_identifier()?; + parser.expect_token(&Token::Eq)?; + let number_spelling = match parser.peek_token().token { + Token::Number(number, false) => Some(number), + sign @ (Token::Plus | Token::Minus) => match parser.peek_nth_token(1).token { + Token::Number(number, false) => Some(format!("{sign}{number}")), + _ => None, + }, + _ => None, + }; + // Keep typed primitives, identifiers, signed numbers, and actual bindings. + // Owning execution must validate option names/value shapes without evaluating them. + let value = parser.parse_expr()?; + let number_spelling = number_spelling + .filter(|raw| raw != &value.to_string() && file_transfer_number_matches(&value, raw)); + options.push(FileTransferOption { + name, + value, + number_spelling, + }); + } + if command == Keyword::PUT { + Ok(Statement::Put(Put { + source, + target, + options, + })) + } else { + Ok(Statement::Get(Get { + source, + target, + options, + })) + } +} + +fn parse_file_transfer_location( + parser: &mut Parser, + stage: bool, +) -> Result { + let token = parser.next_token(); + let (value, quoted) = match token.token { + Token::FileTransferLocation { value, quoted } => (value, quoted), + Token::Placeholder(value) => { + return Ok(FileTransferLocation::Binding(Expr::Value( + Value::Placeholder(value).with_span(token.span), + ))); + } + _ => return parser.expected("file transfer location", token), + }; + let numeric_binding = value + .strip_prefix(':') + .is_some_and(|number| !number.is_empty() && number.chars().all(|ch| ch.is_ascii_digit())); + if !quoted && (value == "?" || numeric_binding) { + return Ok(FileTransferLocation::Binding(Expr::Value( + Value::Placeholder(value).with_span(token.span), + ))); + } + if stage && value.starts_with('@') { + let mut location = parse_file_transfer_stage_location(&value)?; + location.quoted = quoted; + return Ok(FileTransferLocation::Stage(location)); + } + if !stage && value.starts_with("file://") { + return Ok(FileTransferLocation::Local { uri: value, quoted }); + } + Err(ParserError::ParserError(if stage { + "Expected a stage reference or binding in transfer operand".into() + } else { + "Expected a file:// URI or binding in transfer operand".into() + })) +} + +/// Parse an exact bound stage operand into a typed name and separate payload prefix. +/// +/// This parses identifiers rather than manufacturing a `PUT`/`GET` SQL statement. +/// User/table forms remain distinct; parsing does not imply local execution support. +pub fn parse_file_transfer_stage_location( + value: &str, +) -> Result { + let Some(stage) = value.strip_prefix('@') else { + return Err(ParserError::ParserError( + "Expected an @stage reference".into(), + )); + }; + let mut identifier_quote = false; + let mut slash = None; + let mut chars = stage.char_indices().peekable(); + while let Some((index, ch)) = chars.next() { + if ch == '"' { + if identifier_quote && chars.peek().is_some_and(|(_, ch)| *ch == '"') { + chars.next(); + } else { + identifier_quote = !identifier_quote; + } + } else if ch == '/' && !identifier_quote { + slash = Some(index); + break; + } + } + if identifier_quote { + return Err(ParserError::ParserError( + "Unterminated stage identifier".into(), + )); + } + let (name, prefix) = match slash { + Some(index) => (&stage[..index], Some(String::from(&stage[index + 1..]))), + None => (stage, None), + }; + let kind = if name == "~" { + FileTransferStage::User + } else { + let dialect = SnowflakeDialect; + let mut tokens = Tokenizer::new(&dialect, name) + .with_file_transfer_locations(false) + .tokenize_with_location()?; + // A comment cannot silently truncate a bound identifier. Whitespace inside + // a quoted identifier is part of its Word token and is preserved normally. + if tokens + .iter() + .any(|token| matches!(token.token, Token::Whitespace(_))) + { + return Err(ParserError::ParserError( + "Unexpected whitespace or comment in stage name".into(), + )); + } + let markers = tokens + .iter() + .filter(|token| token.token == Token::Mod) + .count(); + let table = markers == 1; + if markers > 1 + || (table && (tokens.len() < 2 || tokens[tokens.len() - 2].token != Token::Mod)) + { + return Err(ParserError::ParserError( + "Expected % immediately before the final table stage identifier".into(), + )); + } + if table { + tokens.remove(tokens.len() - 2); + } + let mut parser = Parser::new(&dialect).with_tokens_with_locations(tokens); + let name = parser.parse_object_name(false)?; + if name + .0 + .iter() + .any(|part| !matches!(part, ObjectNamePart::Identifier(_))) + { + return Err(ParserError::ParserError( + "Expected identifier components in transfer stage name".into(), + )); + } + if !matches!(parser.peek_token_ref().token, Token::EOF) { + return parser.expected_ref("end of stage name", parser.peek_token_ref()); + } + if table { + FileTransferStage::Table(name) + } else { + FileTransferStage::Named(name) + } + }; + Ok(FileTransferStageLocation { + kind, + prefix, + quoted: file_transfer_location_needs_quotes(value), + }) +} + /// Parse snowflake alter dynamic table. /// fn parse_alter_dynamic_table(parser: &mut Parser) -> Result { @@ -914,11 +1165,18 @@ pub fn parse_create_table( builder = builder.copy_grants(true); } Keyword::COMMENT => { - // Rewind the COMMENT keyword - parser.prev_token(); - if let Some(comment_def) = parser.parse_optional_inline_comment()? { - plain_options.push(SqlOption::Comment(comment_def)) - } + let has_eq = parser.consume_token(&Token::Eq); + let comment_def = if has_eq && parser.parse_keyword(Keyword::NULL) { + CommentDef::NullWithEq + } else { + let comment = parser.parse_comment_value()?; + if has_eq { + CommentDef::WithEq(comment) + } else { + CommentDef::WithoutEq(comment) + } + }; + plain_options.push(SqlOption::Comment(comment_def)); } Keyword::AS => { let query = parser.parse_query()?; @@ -1046,6 +1304,10 @@ pub fn parse_create_table( parser.expect_token(&Token::Eq)?; builder.base_location = Some(parser.parse_literal_string()?); } + Keyword::ICEBERG_VERSION if iceberg => { + parser.expect_token(&Token::Eq)?; + builder.iceberg_version = Some(parser.parse_literal_uint()?); + } Keyword::CATALOG_SYNC => { parser.expect_token(&Token::Eq)?; builder.catalog_sync = Some(parser.parse_literal_string()?); @@ -1131,12 +1393,6 @@ pub fn parse_create_table( builder = builder.table_options(table_options); - if iceberg && builder.base_location.is_none() { - return Err(ParserError::ParserError( - "BASE_LOCATION is required for ICEBERG tables".to_string(), - )); - } - Ok(builder.build()) } @@ -1242,7 +1498,11 @@ pub fn parse_create_database( } _ => return parser.expected("end of statement", next_token), }, - Token::SemiColon | Token::EOF => break, + Token::SemiColon => { + parser.prev_token(); + break; + } + Token::EOF => break, _ => return parser.expected("end of statement", next_token), } } @@ -1775,19 +2035,22 @@ fn parse_select_item_for_data_load( ) -> Result { let mut alias: Option = None; let mut file_col_num: i32 = 0; + let mut file_col_num_raw: Option = None; let mut element: Option = None; let mut item_as: Option = None; let next_token = parser.next_token(); match next_token.token { Token::Placeholder(w) => { - file_col_num = w.to_string().split_off(1).parse::().map_err(|e| { - ParserError::ParserError(format!("Could not parse '{w}' as i32: {e}")) - })?; + (file_col_num, file_col_num_raw) = parse_stage_load_column_number(&w)?; Ok(()) } Token::Word(w) => { - alias = Some(Ident::new(w.value)); + alias = Some(Ident { + value: w.value, + quote_style: w.quote_style, + span: next_token.span, + }); Ok(()) } _ => parser.expected("alias or file_col_num", next_token), @@ -1799,9 +2062,7 @@ fn parse_select_item_for_data_load( let col_num_token = parser.next_token(); match col_num_token.token { Token::Placeholder(w) => { - file_col_num = w.to_string().split_off(1).parse::().map_err(|e| { - ParserError::ParserError(format!("Could not parse '{w}' as i32: {e}")) - })?; + (file_col_num, file_col_num_raw) = parse_stage_load_column_number(&w)?; Ok(()) } _ => parser.expected("file_col_num", col_num_token), @@ -1812,10 +2073,17 @@ fn parse_select_item_for_data_load( match parser.next_token().token { Token::Colon => { // parse element - element = Some(Ident::new(match parser.next_token().token { - Token::Word(w) => Ok(w.value), - _ => parser.expected_ref("file_col_num", parser.peek_token_ref()), - }?)); + element = Some(match parser.next_token() { + TokenWithSpan { + token: Token::Word(w), + span, + } => Ident { + value: w.value, + quote_style: w.quote_style, + span, + }, + token => return parser.expected("file_col_num", token), + }); } _ => { // element not present move back @@ -1837,20 +2105,37 @@ fn parse_select_item_for_data_load( // as if parser.parse_keyword(Keyword::AS) { - item_as = Some(match parser.next_token().token { - Token::Word(w) => Ok(Ident::new(w.value)), - _ => parser.expected_ref("column item alias", parser.peek_token_ref()), - }?); + item_as = Some(match parser.next_token() { + TokenWithSpan { + token: Token::Word(w), + span, + } => Ident { + value: w.value, + quote_style: w.quote_style, + span, + }, + token => return parser.expected("column item alias", token), + }); } Ok(StageLoadSelectItem { alias, file_col_num, + file_col_num_raw, element, item_as, }) } +fn parse_stage_load_column_number(placeholder: &str) -> Result<(i32, Option), ParserError> { + let digits = placeholder.strip_prefix('$').unwrap_or(placeholder); + let number = digits.parse::().map_err(|error| { + ParserError::ParserError(format!("Could not parse '{placeholder}' as i32: {error}")) + })?; + let raw = (placeholder != format!("${number}")).then(|| String::from(placeholder)); + Ok((number, raw)) +} + fn parse_stage_params(parser: &mut Parser) -> Result { let (mut url, mut storage_integration, mut endpoint) = (None, None, None); let mut encryption: KeyValueOptions = KeyValueOptions { @@ -1886,6 +2171,12 @@ fn parse_stage_params(parser: &mut Parser) -> Result Result { + let if_not_exists = parser.parse_keywords(&[Keyword::IF, Keyword::NOT, Keyword::EXISTS]); + let name = parser.parse_object_name(false)?; + let with_keyword = parser.parse_keyword(Keyword::WITH); + let options = parse_warehouse_options(parser, with_keyword)?; + Ok(Statement::CreateWarehouse(CreateWarehouse { + or_replace, + if_not_exists, + name, + with_keyword, + options, + })) +} + +fn parse_warehouse_number(parser: &mut Parser) -> Result { + let numeric = matches!(parser.peek_token_ref().token, Token::Number(..)) + || (matches!(parser.peek_token_ref().token, Token::Plus | Token::Minus) + && matches!(parser.peek_nth_token(1).token, Token::Number(..))); + if !numeric { + return parser.expected_ref("numeric warehouse literal", parser.peek_token_ref()); + } + parser.parse_number() +} + +fn parse_warehouse_option_value(parser: &mut Parser) -> Result { + match parser.peek_token().token { + Token::Number(..) | Token::Plus | Token::Minus => parse_warehouse_number(parser), + Token::SingleQuotedString(_) | Token::DollarQuotedString(_) => { + parser.parse_value().map(Expr::Value) + } + Token::Word(word) + if matches!(word.keyword, Keyword::TRUE | Keyword::FALSE | Keyword::NULL) => + { + parser.parse_value().map(Expr::Value) + } + Token::Word(_) => parser.parse_identifier().map(Expr::Identifier), + _ => parser.expected_ref("primitive warehouse option value", parser.peek_token_ref()), + } +} + +fn parse_warehouse_options( + parser: &mut Parser, + required: bool, +) -> Result, ParserError> { + let mut options = Vec::new(); + loop { + match parser.peek_token_ref().token { + Token::EOF | Token::SemiColon => break, + Token::Word(_) => { + let name = parser.parse_identifier()?; + parser.expect_token(&Token::Eq)?; + let value = parse_warehouse_option_value(parser)?; + options.push(WarehouseOption { name, value }); + } + _ => { + return parser + .expected_ref("warehouse option or statement end", parser.peek_token_ref()); + } + } + } + if required && options.is_empty() { + return parser.expected_ref("at least one warehouse option", parser.peek_token_ref()); + } + Ok(options) +} + +fn parse_alter_warehouse(parser: &mut Parser) -> Result { + let if_exists = parser.parse_keywords(&[Keyword::IF, Keyword::EXISTS]); + let name = if parser + .peek_one_of_keywords(&[Keyword::SUSPEND, Keyword::RESUME]) + .is_some() + { + None + } else { + Some(parser.parse_object_name(false)?) + }; + let action = if parser.parse_keyword(Keyword::SUSPEND) { + AlterWarehouseAction::Suspend + } else if parser.parse_keyword(Keyword::RESUME) { + let if_suspended = parser.parse_keywords(&[Keyword::IF, Keyword::SUSPENDED]); + AlterWarehouseAction::Resume { if_suspended } + } else if name.is_some() && parser.parse_keyword(Keyword::SET) { + AlterWarehouseAction::Set(parse_warehouse_options(parser, true)?) + } else if name.is_some() && parser.parse_keyword(Keyword::UNSET) { + AlterWarehouseAction::Unset(parser.parse_comma_separated(|p| p.parse_identifier())?) + } else if name.is_some() && parser.parse_keywords(&[Keyword::RENAME, Keyword::TO]) { + AlterWarehouseAction::RenameTo(parser.parse_object_name(false)?) + } else { + return parser.expected_ref( + "warehouse SUSPEND, RESUME, SET, UNSET or RENAME TO", + parser.peek_token_ref(), + ); + }; + Ok(Statement::AlterWarehouse(AlterWarehouse { + if_exists, + name, + action, + })) +} + +fn parse_drop_warehouse(parser: &mut Parser) -> Result { + let if_exists = parser.parse_keywords(&[Keyword::IF, Keyword::EXISTS]); + let name = parser.parse_object_name(false)?; + Ok(Statement::DropWarehouse(DropWarehouse { if_exists, name })) +} + +fn parse_show_warehouses(terse: bool, parser: &mut Parser) -> Result { + let like_pattern = if parser.parse_keyword(Keyword::LIKE) { + Some(parser.parse_literal_string()?) + } else { + None + }; + let starts_with = if parser.parse_keywords(&[Keyword::STARTS, Keyword::WITH]) { + Some(parser.parse_literal_string()?) + } else { + None + }; + let limit = if parser.parse_keyword(Keyword::LIMIT) { + Some(parse_warehouse_number(parser)?) + } else { + None + }; + Ok(Statement::ShowWarehouses(ShowWarehouses { + terse, + like_pattern, + starts_with, + limit, + })) +} diff --git a/src/keywords.rs b/src/keywords.rs index c10530af1c..8a9cb0a6a8 100644 --- a/src/keywords.rs +++ b/src/keywords.rs @@ -495,6 +495,7 @@ define_keywords!( HUGEINT, IAM_ROLE, ICEBERG, + ICEBERG_VERSION, ID, IDENTIFIED, IDENTITY, @@ -825,6 +826,7 @@ define_keywords!( PUBLIC, PURCHASE, PURGE, + PUT, QUALIFY, QUARTER, QUERIES, @@ -1032,6 +1034,7 @@ define_keywords!( SUPERUSER, SUPPORT, SUSPEND, + SUSPENDED, SWAP, SYMMETRIC, SYNC, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 20216d8862..8d917c4a31 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -4761,7 +4761,7 @@ impl<'a> Parser<'a> { if self.parse_keyword(expected) { Ok(self.get_current_token().clone()) } else { - self.expected_ref(format!("{:?}", &expected).as_str(), self.peek_token_ref()) + self.expected_ref(format!("{:?}", expected).as_str(), self.peek_token_ref()) } } @@ -4774,7 +4774,7 @@ impl<'a> Parser<'a> { if self.parse_keyword(expected) { Ok(()) } else { - self.expected_ref(format!("{:?}", &expected).as_str(), self.peek_token_ref()) + self.expected_ref(format!("{:?}", expected).as_str(), self.peek_token_ref()) } } @@ -5195,6 +5195,22 @@ impl<'a> Parser<'a> { } else { self.parse_create_sequence(or_replace, or_alter, temporary) } + } else if self.peek_keyword(Keyword::SCHEMA) { + if or_alter + || global.is_some() + || temporary + || persistent + || create_view_params.is_some() + || ((or_replace || transient) && !self.dialect.supports_create_schema_options()) + { + self.expected_ref( + "an object type supporting the CREATE modifiers", + self.peek_token_ref(), + ) + } else { + self.expect_keyword_is(Keyword::SCHEMA)?; + self.parse_create_schema_with_modifiers(or_replace, transient) + } } else if or_replace { self.expected_ref( "[EXTERNAL] TABLE or [MATERIALIZED] VIEW or FUNCTION after CREATE OR REPLACE", @@ -5208,8 +5224,6 @@ impl<'a> Parser<'a> { self.parse_create_index(true).map(Into::into) } else if self.parse_keyword(Keyword::VIRTUAL) { self.parse_create_virtual_table() - } else if self.parse_keyword(Keyword::SCHEMA) { - self.parse_create_schema() } else if self.parse_keyword(Keyword::DATABASE) { self.parse_create_database() } else if self.parse_keyword(Keyword::ROLE) { @@ -5448,6 +5462,14 @@ impl<'a> Parser<'a> { /// Parse a `CREATE SCHEMA` statement. pub fn parse_create_schema(&mut self) -> Result { + self.parse_create_schema_with_modifiers(false, false) + } + + fn parse_create_schema_with_modifiers( + &mut self, + or_replace: bool, + transient: bool, + ) -> Result { let if_not_exists = self.parse_keywords(&[Keyword::IF, Keyword::NOT, Keyword::EXISTS]); let schema_name = self.parse_schema_name()?; @@ -5458,7 +5480,10 @@ impl<'a> Parser<'a> { None }; - let with = if self.peek_keyword(Keyword::WITH) { + let with = if self.peek_keyword(Keyword::WITH) + && !(self.dialect.supports_create_schema_options() + && self.peek_keywords(&[Keyword::WITH, Keyword::MANAGED])) + { Some(self.parse_options(Keyword::WITH)?) } else { None @@ -5476,13 +5501,39 @@ impl<'a> Parser<'a> { None }; + let with_managed_access = self.dialect.supports_create_schema_options() + && self.parse_keywords(&[Keyword::WITH, Keyword::MANAGED]); + if with_managed_access { + self.expect_keyword_is(Keyword::ACCESS)?; + } + + let mut schema_options = Vec::new(); + if self.dialect.supports_create_schema_options() { + while self + .peek_one_of_keywords(&[Keyword::COMMENT, Keyword::DATA_RETENTION_TIME_IN_DAYS]) + .is_some() + { + let key = self.parse_identifier()?; + self.expect_token(&Token::Eq)?; + let value = match self.peek_token_ref().token { + Token::Number(..) | Token::Plus | Token::Minus => self.parse_number()?, + _ => Expr::Value(self.parse_value()?), + }; + schema_options.push(SqlOption::KeyValue { key, value }); + } + } + Ok(Statement::CreateSchema { + or_replace, + transient, schema_name, if_not_exists, with, options, default_collate_spec, clone, + with_managed_access, + schema_options, }) } @@ -10265,7 +10316,7 @@ impl<'a> Parser<'a> { } else { let column_keyword = self.parse_keyword(Keyword::COLUMN); - let if_not_exists = if dialect_of!(self is PostgreSqlDialect | BigQueryDialect | DuckDbDialect | GenericDialect) + let if_not_exists = if dialect_of!(self is PostgreSqlDialect | BigQueryDialect | DuckDbDialect | SnowflakeDialect | GenericDialect) { self.parse_keywords(&[Keyword::IF, Keyword::NOT, Keyword::EXISTS]) || if_not_exists @@ -10741,6 +10792,10 @@ impl<'a> Parser<'a> { /// Parse an `ALTER ` statement and dispatch to the appropriate alter handler. pub fn parse_alter(&mut self) -> Result { + if self.peek_keyword(Keyword::DATABASE) && self.dialect.supports_alter_database_options() { + self.prev_token(); + return self.parse_alter_database(); + } let object_type = self.expect_one_of_keywords(&[ Keyword::VIEW, Keyword::TYPE, @@ -11514,6 +11569,34 @@ impl<'a> Parser<'a> { }) } + /// Parse a native `ALTER DATABASE` primitive property statement. + pub fn parse_alter_database(&mut self) -> Result { + if !self.dialect.supports_alter_database_options() { + return self.expected_ref( + "dialect supporting ALTER DATABASE properties", + self.peek_token_ref(), + ); + } + self.expect_keywords(&[Keyword::ALTER, Keyword::DATABASE])?; + 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) { + let options = self.parse_alter_namespace_set_options("database")?; + AlterDatabaseOperation::SetOptions { options } + } else if self.parse_keyword(Keyword::UNSET) { + let options = self.parse_alter_namespace_unset_options("database")?; + AlterDatabaseOperation::UnsetOptions { options } + } else { + return self.expected_ref("ALTER DATABASE property operation", self.peek_token_ref()); + }; + Ok(AlterDatabase { + name, + if_exists, + operations: vec![operation], + } + .into()) + } + /// Parse an `ALTER SCHEMA` statement. /// /// Supports operations such as setting options, renaming, adding/dropping replicas, and changing owner. @@ -11545,6 +11628,13 @@ impl<'a> Parser<'a> { } else if self.parse_keywords(&[Keyword::OWNER, Keyword::TO]) { let owner = self.parse_owner()?; AlterSchemaOperation::OwnerTo { owner } + } else if self.dialect.supports_alter_schema_options() && self.parse_keyword(Keyword::SET) { + let options = self.parse_alter_namespace_set_options("schema")?; + AlterSchemaOperation::SetOptions { options } + } else if self.dialect.supports_alter_schema_options() && self.parse_keyword(Keyword::UNSET) + { + let options = self.parse_alter_namespace_unset_options("schema")?; + AlterSchemaOperation::UnsetOptions { options } } else { return self.expected_ref("ALTER SCHEMA operation", self.peek_token_ref()); }; @@ -11555,6 +11645,88 @@ impl<'a> Parser<'a> { })) } + fn parse_alter_namespace_option_name( + &mut self, + object_type: &str, + ) -> Result { + if !matches!(self.peek_token_ref().token, Token::Word(_)) + || self + .peek_one_of_keywords(&[Keyword::TAG, Keyword::CONTACT]) + .is_some() + { + return self.expected_ref( + &format!("primitive {object_type} property name"), + self.peek_token_ref(), + ); + } + self.parse_identifier() + } + + fn parse_alter_namespace_unset_options( + &mut self, + object_type: &str, + ) -> Result, ParserError> { + let options = self.parse_comma_separated_with_trailing_commas( + |parser| parser.parse_alter_namespace_option_name(object_type), + false, + Self::is_reserved_for_column_alias, + )?; + if !matches!(self.peek_token_ref().token, Token::EOF | Token::SemiColon) { + return self.expected_ref("comma or statement end", self.peek_token_ref()); + } + Ok(options) + } + + fn parse_alter_namespace_set_options( + &mut self, + object_type: &str, + ) -> Result, ParserError> { + let mut options = Vec::new(); + loop { + let key = self.parse_alter_namespace_option_name(object_type)?; + self.expect_token(&Token::Eq)?; + let value = match self.peek_token_ref().token { + Token::Plus | Token::Minus + if !matches!(self.peek_nth_token_ref(1).token, Token::Number(..)) => + { + return self.expected_ref("signed numeric literal", self.peek_token_ref()); + } + Token::Number(..) | Token::Plus | Token::Minus => self.parse_number()?, + Token::EOF + | Token::SemiColon + | Token::Placeholder(_) + | Token::Colon + | Token::AtSign => { + return self.expected_ref( + &format!("primitive {object_type} property literal"), + self.peek_token_ref(), + ); + } + _ => Expr::Value(self.parse_value()?), + }; + options.push(SqlOption::KeyValue { key, value }); + + let space_separated = matches!( + self.peek_nth_token_no_skip_ref(0).token, + Token::Whitespace(_) + ); + if self.consume_token(&Token::Comma) { + continue; + } + match self.peek_token_ref().token { + Token::EOF | Token::SemiColon => break, + Token::Word(_) if space_separated => {} + _ => { + return self.expected_ref( + &format!("{object_type} property separator or statement end"), + self.peek_token_ref(), + ) + } + } + } + Ok(options) + } + /// Parse a `CALL procedure_name(arg1, arg2, ...)` /// or `CALL procedure_name` statement pub fn parse_call(&mut self) -> Result { @@ -13436,7 +13608,7 @@ impl<'a> Parser<'a> { token => { return Err(ParserError::ParserError(format!( "Unexpected token in identifier: {token}" - )))?; + ))); } } } @@ -14070,6 +14242,11 @@ impl<'a> Parser<'a> { &mut self, describe_alias: DescribeAlias, ) -> Result { + if describe_alias != DescribeAlias::Explain && self.dialect.supports_describe_object() { + if let Some(statement) = self.parse_describe_object(describe_alias)? { + return Ok(statement); + } + } let mut analyze = false; let mut verbose = false; let mut query_plan = false; @@ -14170,6 +14347,29 @@ impl<'a> Parser<'a> { } } + /// Parse `{ DATABASE | SCHEMA } ` following `DESC | DESCRIBE`. + /// VIEW retains this fork's existing `ExplainTable::has_view_keyword` representation. + /// Returns `None` if none of the object type keywords is present. + fn parse_describe_object( + &mut self, + describe_alias: DescribeAlias, + ) -> Result, ParserError> { + let object_type = match self.parse_one_of_keywords(&[Keyword::DATABASE, Keyword::SCHEMA]) { + Some(Keyword::DATABASE) => ObjectType::Database, + Some(Keyword::SCHEMA) => ObjectType::Schema, + _ => return Ok(None), + }; + let name = self.parse_object_name(false)?; + Ok(Some( + DescribeObject { + describe_alias, + object_type, + name, + } + .into(), + )) + } + /// Parse a query expression, i.e. a `SELECT` statement optionally /// preceded with some `WITH` CTE declarations and optionally followed /// by `ORDER BY`. Unlike some other parse_... methods, this one doesn't @@ -16869,7 +17069,7 @@ impl<'a> Parser<'a> { tok.token ), tok.span.start - )?; + ); } } diff --git a/src/tokenizer.rs b/src/tokenizer.rs index 60a7e8d9a0..9fee49a743 100644 --- a/src/tokenizer.rs +++ b/src/tokenizer.rs @@ -219,6 +219,13 @@ pub enum Token { PGCubeRoot, /// `?` or `$` , a prepared statement arg placeholder Placeholder(String), + /// One opaque operand of a statement-start Snowflake `PUT` or `GET`. + FileTransferLocation { + /// URI or stage text with comment/glob/path bytes retained. + value: String, + /// Whether the complete operand was enclosed in single quotes. + quoted: bool, + }, /// `->`, used as a operator to extract json field in PostgreSQL Arrow, /// `->>`, used as a operator to extract json field as text in PostgreSQL @@ -383,6 +390,13 @@ impl fmt::Display for Token { Token::ShiftLeftVerticalBar => f.write_str("<<|"), Token::VerticalBarShiftRight => f.write_str("|>>"), Token::Placeholder(ref s) => write!(f, "{s}"), + Token::FileTransferLocation { value, quoted } => { + if *quoted { + write!(f, "'{value}'") + } else { + f.write_str(value) + } + } Token::Arrow => write!(f, "->"), Token::LongArrow => write!(f, "->>"), Token::HashArrow => write!(f, "#>"), @@ -872,6 +886,8 @@ pub struct Tokenizer<'a> { /// If true (the default), the tokenizer will un-escape literal /// SQL strings See [`Tokenizer::with_unescape`] for more details. unescape: bool, + /// Internal identifier-only parsing must not activate statement-start transfer syntax. + file_transfer_locations: bool, } impl<'a> Tokenizer<'a> { @@ -896,6 +912,7 @@ impl<'a> Tokenizer<'a> { dialect, query, unescape: true, + file_transfer_locations: true, } } @@ -934,6 +951,11 @@ impl<'a> Tokenizer<'a> { self } + pub(crate) fn with_file_transfer_locations(mut self, enabled: bool) -> Self { + self.file_transfer_locations = enabled; + self + } + /// Tokenize the statement and produce a vector of tokens pub fn tokenize(&mut self) -> Result, TokenizerError> { let twl = self.tokenize_with_location()?; @@ -970,9 +992,41 @@ impl<'a> Tokenizer<'a> { }; let mut location = state.location(); - while let Some(token) = self.next_token(&mut state, buf.last().map(|t| &t.token))? { + let file_transfer_locations = + self.file_transfer_locations && self.dialect.supports_file_transfer_commands(); + let mut statement_start = true; + let mut transfer_operands = 0; + loop { + let token = if transfer_operands > 0 && Self::is_transfer_operand_start(&state) { + transfer_operands -= 1; + Some(self.tokenize_file_transfer_location(&mut state)?) + } else { + self.next_token(&mut state, buf.last().map(|t| &t.token))? + }; + let Some(token) = token else { + break; + }; let span = location.span_to(state.location()); + if file_transfer_locations { + match &token { + Token::Whitespace(_) => {} + Token::SemiColon => { + statement_start = true; + transfer_operands = 0; + } + Token::Word(word) if statement_start => { + statement_start = false; + if word.quote_style.is_none() + && matches!(word.keyword, Keyword::PUT | Keyword::GET) + { + transfer_operands = 2; + } + } + _ => statement_start = false, + } + } + // Check if this is a multiline comment hint that should be expanded match &token { Token::Whitespace(Whitespace::MultiLineComment(comment)) @@ -992,6 +1046,68 @@ impl<'a> Tokenizer<'a> { Ok(()) } + /// Comments between operands retain SQL meaning. Once an operand starts, + /// its own `//`, `/*`, `--`, `?`, and `#` are opaque path bytes. + fn is_transfer_operand_start(state: &State) -> bool { + let mut chars = state.peekable.clone(); + match chars.next() { + None | Some(';' | '#' | ' ' | '\t' | '\r' | '\n') => false, + Some('-') => chars.next() != Some('-'), + Some('/') => !matches!(chars.next(), Some('/' | '*')), + Some(_) => true, + } + } + + fn tokenize_file_transfer_location(&self, state: &mut State) -> Result { + let location = state.location(); + let quoted = state.peek() == Some(&'\''); + let mut value = String::new(); + if quoted { + state.next(); + while let Some(ch) = state.next() { + if ch == '\'' { + if state.peek() == Some(&'\'') { + state.next(); + if !self.unescape { + value.push('\''); + } + } else { + return Ok(Token::FileTransferLocation { value, quoted }); + } + } + // Unlike an ordinary string literal, a transfer operand cannot + // discard a backslash before the execution layer validates paths. + value.push(ch); + } + return self.tokenizer_error(location, "Unterminated quoted file transfer location"); + } + + let mut identifier_quote = false; + let mut stage_name = state.peek() == Some(&'@'); + while let Some(&ch) = state.peek() { + if !identifier_quote && (ch.is_whitespace() || ch == ';') { + break; + } + state.next(); + value.push(ch); + if stage_name && ch == '/' && !identifier_quote { + stage_name = false; + } + if stage_name && ch == '"' { + if identifier_quote && state.peek() == Some(&'"') { + state.next(); + value.push('"'); + } else { + identifier_quote = !identifier_quote; + } + } + } + if identifier_quote { + return self.tokenizer_error(location, "Unterminated quoted file transfer identifier"); + } + Ok(Token::FileTransferLocation { value, quoted }) + } + /// Re-tokenize optimizer hints from a multiline comment and add them to the buffer. /// For example, `/*!50110 KEY_BLOCK_SIZE = 1024*/` becomes tokens for `KEY_BLOCK_SIZE = 1024` fn tokenize_comment_hints( diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index cb2ab84cf5..f714f4b782 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -1719,7 +1719,7 @@ fn parse_json_ops_without_colon() { ]; for (str_op, op, dialects) in binary_ops { - let select = dialects.verified_only_select(&format!("SELECT a {} b", &str_op)); + let select = dialects.verified_only_select(&format!("SELECT a {} b", str_op)); assert_eq!( SelectItem::UnnamedExpr(Expr::BinaryOp { left: Box::new(Expr::Identifier(Ident::new("a"))), @@ -2415,7 +2415,7 @@ fn parse_bitwise_ops() { ]; for (str_op, op, dialects) in bitwise_ops { - let select = dialects.verified_only_select(&format!("SELECT a {} b", &str_op)); + let select = dialects.verified_only_select(&format!("SELECT a {} b", str_op)); assert_eq!( SelectItem::UnnamedExpr(Expr::BinaryOp { left: Box::new(Expr::Identifier(Ident::new("a"))), @@ -18622,7 +18622,7 @@ fn parse_generic_unary_ops() { ("+", UnaryOperator::Plus), ]; for (str_op, op) in unary_ops { - let select = verified_only_select(&format!("SELECT {}expr", &str_op)); + let select = verified_only_select(&format!("SELECT {}expr", str_op)); assert_eq!( UnnamedExpr(UnaryOp { op: *op, @@ -18913,3 +18913,143 @@ fn parse_non_pg_dialects_keep_xml_names_as_regular_identifiers() { let dialects = all_dialects_except(|d| d.supports_xml_expressions()); dialects.verified_only_select("SELECT xml FROM t"); } + +#[test] +fn parse_describe_object() { + let dialects = all_dialects_where(|d| d.supports_describe_object()); + dialects.verified_stmt("DESCRIBE DATABASE d"); + dialects.verified_stmt("DESC SCHEMA db.s"); + + let dialects = all_dialects_where(|d| !d.supports_describe_object()); + assert!(matches!( + dialects.verified_stmt("DESC schema"), + Statement::ExplainTable { .. } + )); +} + +#[test] +fn parse_describe_object_keeps_kind_alias_and_quoted_names() { + let dialects = all_dialects_where(|d| d.supports_describe_object()); + for (alias, expected_alias) in [ + ("DESC", DescribeAlias::Desc), + ("DESCRIBE", DescribeAlias::Describe), + ] { + for (kind, expected_type, name, parts) in [ + ("DATABASE", ObjectType::Database, r#""Db.With.Dots""#, 1), + ( + "SCHEMA", + ObjectType::Schema, + r#""Db.With.Dots"."Sch ""quoted""""#, + 2, + ), + ] { + let sql = format!("{alias} {kind} {name}"); + let statement = dialects.verified_stmt(&sql); + assert_eq!(statement.span(), Span::empty()); + let Statement::DescribeObject(description) = statement else { + panic!("expected DescribeObject for {sql}"); + }; + assert_eq!(description.describe_alias, expected_alias); + assert_eq!(description.object_type, expected_type); + assert_eq!(description.name.to_string(), name); + assert_eq!(description.name.0.len(), parts); + assert_eq!(Statement::from(description).to_string(), sql); + } + } +} + +#[test] +fn parse_describe_object_preserves_batch_boundaries() { + let dialects = all_dialects_where(|d| d.supports_describe_object()); + let statements = dialects + .parse_sql_statements("DESC DATABASE d; /* next */ DESCRIBE SCHEMA d.s; SELECT 1;") + .unwrap(); + assert_eq!(statements.len(), 3); + assert!(matches!(&statements[0], Statement::DescribeObject(value) + if value.object_type == ObjectType::Database)); + assert!(matches!(&statements[1], Statement::DescribeObject(value) + if value.object_type == ObjectType::Schema)); + assert!(matches!(&statements[2], Statement::Query(_))); +} + +#[test] +fn parse_describe_object_rejects_incomplete_or_trailing_syntax() { + let dialects = all_dialects_where(|d| d.supports_describe_object()); + for sql in [ + "DESC DATABASE", + "DESCRIBE SCHEMA", + "DESC SCHEMA db.", + "DESCRIBE DATABASE d WHERE TRUE", + "DESC SCHEMA d.s extra", + ] { + assert!(dialects.parse_sql_statements(sql).is_err(), "{sql}"); + } + assert!(matches!( + dialects.verified_stmt(r#"DESC "SCHEMA""#), + Statement::ExplainTable { .. } + )); + assert!(matches!( + dialects.verified_stmt("EXPLAIN SELECT 1"), + Statement::Explain { .. } + )); +} + +#[test] +fn parse_describe_object_rejects_query_modifiers() { + let dialects = all_dialects_where(|d| d.supports_describe_object()); + for alias in ["DESC", "DESCRIBE"] { + for modifier in [ + "ANALYZE", + "VERBOSE", + "QUERY PLAN", + "ESTIMATE", + "FORMAT JSON", + ] { + for kind in ["DATABASE", "SCHEMA"] { + let sql = format!("{alias} {modifier} {kind} d"); + assert!(dialects.parse_sql_statements(&sql).is_err(), "{sql}"); + } + } + } +} + +#[cfg(feature = "visitor")] +#[test] +fn describe_object_does_not_visit_namespaces_as_table_relations() { + use core::ops::ControlFlow; + + #[derive(Default)] + struct Relations(Vec); + impl Visitor for Relations { + type Break = (); + + fn pre_visit_relation(&mut self, name: &ObjectName) -> ControlFlow { + self.0.push(name.to_string()); + ControlFlow::Continue(()) + } + } + + let dialects = all_dialects_where(|d| d.supports_describe_object()); + let mut relations = Relations::default(); + for sql in ["DESC DATABASE d", "DESCRIBE SCHEMA d.s"] { + assert!(dialects + .verified_stmt(sql) + .visit(&mut relations) + .is_continue()); + assert!(relations.0.is_empty()); + } + assert!(dialects + .verified_stmt("SELECT * FROM t") + .visit(&mut relations) + .is_continue()); + assert_eq!(relations.0, vec!["t"]); +} + +#[cfg(all(feature = "serde", feature = "serde_json"))] +#[test] +fn describe_object_serde_round_trip() { + let statement = all_dialects_where(|d| d.supports_describe_object()) + .verified_stmt(r#"DESCRIBE SCHEMA "Db"."Sch.With.Dot""#); + let json = serde_json::to_string(&statement).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), statement); +} diff --git a/tests/sqlparser_duckdb.rs b/tests/sqlparser_duckdb.rs index df62685808..d36e907305 100644 --- a/tests/sqlparser_duckdb.rs +++ b/tests/sqlparser_duckdb.rs @@ -779,6 +779,7 @@ fn test_duckdb_union_datatype() { with_storage_lifecycle_policy: Default::default(), with_tags: Default::default(), base_location: Default::default(), + iceberg_version: Default::default(), external_volume: Default::default(), catalog: Default::default(), catalog_sync: Default::default(), diff --git a/tests/sqlparser_mssql.rs b/tests/sqlparser_mssql.rs index ceac1b60a9..53b96e8657 100644 --- a/tests/sqlparser_mssql.rs +++ b/tests/sqlparser_mssql.rs @@ -2003,6 +2003,7 @@ fn parse_create_table_with_valid_options() { with_storage_lifecycle_policy: None, with_tags: None, base_location: None, + iceberg_version: None, external_volume: None, catalog: None, catalog_sync: None, @@ -2177,6 +2178,7 @@ fn parse_create_table_with_identity_column() { with_storage_lifecycle_policy: None, with_tags: None, base_location: None, + iceberg_version: None, external_volume: None, catalog: None, catalog_sync: None, diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index 9a51264e0f..a020b10a8d 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -2461,7 +2461,7 @@ fn parse_pg_unary_ops() { ("@", UnaryOperator::PGAbs), ]; for (str_op, op) in pg_unary_ops { - let select = pg().verified_only_select(&format!("SELECT {}a", &str_op)); + let select = pg().verified_only_select(&format!("SELECT {}a", str_op)); assert_eq!( SelectItem::UnnamedExpr(Expr::UnaryOp { op: *op, @@ -2477,7 +2477,7 @@ fn parse_pg_postfix_factorial() { let postfix_factorial = &[("!", UnaryOperator::PGPostfixFactorial)]; for (str_op, op) in postfix_factorial { - let select = pg().verified_only_select(&format!("SELECT a{}", &str_op)); + let select = pg().verified_only_select(&format!("SELECT a{}", str_op)); assert_eq!( SelectItem::UnnamedExpr(Expr::UnaryOp { op: *op, @@ -6702,6 +6702,7 @@ fn parse_trigger_related_functions() { with_storage_lifecycle_policy: None, with_tags: None, base_location: None, + iceberg_version: None, external_volume: None, catalog: None, catalog_sync: None, diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index b3cc8b6335..c1f8285ca7 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -35,6 +35,26 @@ mod test_utils; #[cfg(test)] use pretty_assertions::assert_eq; +#[test] +fn snowflake_alter_table_add_column_if_not_exists() { + for sql in [ + "ALTER TABLE t ADD IF NOT EXISTS value NUMBER", + "ALTER TABLE t ADD COLUMN IF NOT EXISTS value NUMBER", + ] { + let Statement::AlterTable(AlterTable { operations, .. }) = snowflake().verified_stmt(sql) + else { + panic!("expected ALTER TABLE"); + }; + assert!(matches!( + operations.as_slice(), + [AlterTableOperation::AddColumn { + if_not_exists: true, + .. + }] + )); + } +} + #[test] fn test_snowflake_create_table() { let sql = "CREATE TABLE _my_$table (am00unt number)"; @@ -1070,11 +1090,29 @@ fn test_snowflake_create_iceberg_table() { #[test] fn test_snowflake_create_iceberg_table_without_location() { - let res = snowflake().parse_sql_statements("CREATE ICEBERG TABLE my_table (a INT)"); - assert_eq!( - ParserError::ParserError("BASE_LOCATION is required for ICEBERG tables".to_string()), - res.unwrap_err() - ); + match snowflake().verified_stmt("CREATE ICEBERG TABLE my_table (a INT)") { + Statement::CreateTable(table) => assert_eq!(table.base_location, None), + _ => unreachable!(), + } +} + +#[test] +fn test_snowflake_create_iceberg_table_version() { + for version in [2, 3] { + let sql = format!( + "CREATE ICEBERG TABLE my_table (a INT) EXTERNAL_VOLUME='SNOWFLAKE_MANAGED' CATALOG='SNOWFLAKE' ICEBERG_VERSION={version}" + ); + match snowflake().verified_stmt(&sql) { + Statement::CreateTable(table) => { + assert_eq!(table.iceberg_version, Some(version)); + assert_eq!(table.base_location, None); + } + _ => unreachable!(), + } + } + assert!(snowflake() + .parse_sql_statements("CREATE TABLE my_table (a INT) ICEBERG_VERSION=3") + .is_err()); } #[test] @@ -2685,6 +2723,7 @@ fn test_copy_into_with_transformations() { StageLoadSelectItemKind::StageLoadSelectItem(StageLoadSelectItem { alias: Some(Ident::new("t1")), file_col_num: 1, + file_col_num_raw: None, element: Some(Ident::new("st")), item_as: Some(Ident::new("st")) }) @@ -2694,6 +2733,7 @@ fn test_copy_into_with_transformations() { StageLoadSelectItemKind::StageLoadSelectItem(StageLoadSelectItem { alias: None, file_col_num: 1, + file_col_num_raw: None, element: Some(Ident::new("index")), item_as: None }) @@ -2703,6 +2743,7 @@ fn test_copy_into_with_transformations() { StageLoadSelectItemKind::StageLoadSelectItem(StageLoadSelectItem { alias: Some(Ident::new("t2")), file_col_num: 1, + file_col_num_raw: None, element: None, item_as: None }) @@ -2733,6 +2774,66 @@ fn test_copy_into_with_transformations() { snowflake().parse_sql_statements(sql1).unwrap(); } +#[test] +fn test_copy_into_staged_identifiers_preserve_quotes_and_spans() { + let sql = r#"COPY INTO t FROM (SELECT "src".$1:"field" AS "Mixed" FROM @stage AS "src")"#; + snowflake().verified_stmt(sql); + let Statement::CopyIntoSnowflake { + from_transformations: Some(items), + .. + } = sqlparser::parser::Parser::parse_sql(&SnowflakeDialect {}, sql) + .unwrap() + .remove(0) + else { + panic!("expected COPY INTO with transformations"); + }; + let StageLoadSelectItemKind::StageLoadSelectItem(item) = &items[0] else { + panic!("expected a staged field"); + }; + for (ident, source) in [ + (item.alias.as_ref().unwrap(), "\"src\""), + (item.element.as_ref().unwrap(), "\"field\""), + (item.item_as.as_ref().unwrap(), "\"Mixed\""), + ] { + assert_eq!(ident.quote_style, Some('"')); + assert_eq!(ident.span.start.line, 1); + assert_eq!( + usize::try_from(ident.span.start.column).unwrap(), + sql.find(source).unwrap() + 1 + ); + } + assert_eq!(items[0].to_string(), r#""src".$1:"field" AS "Mixed""#); +} + +#[test] +fn test_copy_into_staged_placeholders_preserve_zero_padding() { + let sql = r#"COPY INTO t FROM (SELECT $1 AS "$00", $00, src.$01 AS "One" FROM @stage AS src)"#; + let statement = snowflake().verified_stmt(sql); + let Statement::CopyIntoSnowflake { + from_transformations: Some(items), + .. + } = statement + else { + panic!("expected COPY INTO with transformations"); + }; + for (item, number, raw) in [(&items[1], 0, "$00"), (&items[2], 1, "$01")] { + let StageLoadSelectItemKind::StageLoadSelectItem(item) = item else { + panic!("expected a staged field"); + }; + assert_eq!(item.file_col_num, number); + assert_eq!(item.file_col_num_raw.as_deref(), Some(raw)); + } + assert_eq!(items[0].to_string(), "$1 AS \"$00\""); + assert_eq!(items[1].to_string(), "$00"); + assert_eq!(items[2].to_string(), "src.$01 AS \"One\""); + + let StageLoadSelectItemKind::StageLoadSelectItem(mut changed) = items[2].clone() else { + panic!("expected a staged field"); + }; + changed.file_col_num = 2; + assert_eq!(changed.to_string(), "src.$2 AS \"One\""); +} + #[test] fn test_copy_into_with_nested_transformation_path() { let sql = concat!( @@ -5790,3 +5891,2402 @@ fn test_nested_structured_array_map_nullability() { ); } } + +#[test] +fn snowflake_create_database_preserves_following_statement_delimiters() { + use sqlparser::parser::Parser; + + for sql in [ + "CREATE DATABASE demo; USE DATABASE demo; SELECT 1", + "CREATE DATABASE IF NOT EXISTS demo; USE DATABASE demo; SELECT 1", + "CREATE DATABASE demo DATA_RETENTION_TIME_IN_DAYS=1 COMMENT='kept'; USE DATABASE demo; SELECT 1", + ] { + let statements = Parser::parse_sql(&SnowflakeDialect, sql).unwrap(); + assert_eq!(statements.len(), 3, "{sql}"); + assert!(matches!(statements[0], Statement::CreateDatabase { .. })); + assert!(matches!(statements[1], Statement::Use(Use::Database(_)))); + assert_eq!(statements[2].to_string(), "SELECT 1"); + } +} + +#[test] +fn snowflake_create_database_multiple_databases_and_schema_batch() { + use sqlparser::parser::Parser; + + let sql = "CREATE DATABASE first_db; CREATE DATABASE second_db; CREATE SCHEMA second_db.app; USE SCHEMA second_db.app; SELECT 1;"; + let statements = Parser::parse_sql(&SnowflakeDialect, sql).unwrap(); + assert_eq!(statements.len(), 5); + assert!(matches!(statements[0], Statement::CreateDatabase { .. })); + assert!(matches!(statements[1], Statement::CreateDatabase { .. })); + assert!(matches!(statements[2], Statement::CreateSchema { .. })); + assert!(matches!(statements[3], Statement::Use(Use::Schema(_)))); + assert_eq!(statements[4].to_string(), "SELECT 1"); +} + +#[test] +fn snowflake_create_database_semicolons_in_identifiers_strings_and_comments() { + use sqlparser::parser::Parser; + + let sql = r#"CREATE DATABASE "demo;name" COMMENT='keep; USE DATABASE other'; + /* ignored ; CREATE DATABASE hidden; */ SELECT ';not a separator';"#; + let statements = Parser::parse_sql(&SnowflakeDialect, sql).unwrap(); + assert_eq!(statements.len(), 2); + let Statement::CreateDatabase { + db_name, comment, .. + } = &statements[0] + else { + panic!("expected CREATE DATABASE"); + }; + assert_eq!( + db_name, + &ObjectName::from(Ident::with_quote('"', "demo;name")) + ); + assert_eq!(comment.as_deref(), Some("keep; USE DATABASE other")); + assert_eq!(statements[1].to_string(), "SELECT ';not a separator'"); +} + +#[test] +fn snowflake_create_database_still_requires_statement_delimiters() { + for sql in [ + "CREATE DATABASE demo USE DATABASE demo", + "CREATE DATABASE IF NOT EXISTS demo SELECT 1", + "CREATE DATABASE first_db CREATE DATABASE second_db", + ] { + assert!(snowflake().parse_sql_statements(sql).is_err(), "{sql}"); + } + for sql in [ + "CREATE DATABASE demo", + "CREATE DATABASE IF NOT EXISTS demo", + "CREATE DATABASE demo COMMENT = 'still one statement'", + ] { + snowflake().verified_stmt(sql); + assert_eq!( + snowflake() + .parse_sql_statements(&format!("{sql};")) + .unwrap() + .len(), + 1 + ); + } +} + +#[test] +fn snowflake_put_get_typed_locations_and_options() { + use sqlparser::ast::helpers::stmt_data_loading::{FileTransferLocation, FileTransferStage}; + + let sql = r#"PUT file:///tmp/a--b/*?[0-9]#part.csv @"db.with.dot"."schema/part"."stage""name"/inbox/a--b/*?x=1#part AUTO_COMPRESS=FALSE SOURCE_COMPRESSION=AUTO_DETECT OVERWRITE=TRUE PARALLEL=001"#; + let Statement::Put(command) = snowflake().one_statement_parses_to( + sql, + r#"PUT file:///tmp/a--b/*?[0-9]#part.csv @"db.with.dot"."schema/part"."stage""name"/inbox/a--b/*?x=1#part AUTO_COMPRESS=false SOURCE_COMPRESSION=AUTO_DETECT OVERWRITE=true PARALLEL=001"#, + ) else { + panic!("expected typed PUT"); + }; + assert!(matches!( + command.source, + FileTransferLocation::Local { ref uri, quoted: false } + if uri == "file:///tmp/a--b/*?[0-9]#part.csv" + )); + let FileTransferLocation::Stage(location) = command.target else { + panic!("expected typed stage target"); + }; + let FileTransferStage::Named(name) = location.kind else { + panic!("expected named stage"); + }; + assert_eq!( + name, + ObjectName::from(vec![ + Ident::with_quote('"', "db.with.dot"), + Ident::with_quote('"', "schema/part"), + Ident::with_quote('"', "stage\"name"), + ]) + ); + assert_eq!(location.prefix.as_deref(), Some("inbox/a--b/*?x=1#part")); + assert!(!location.quoted); + assert_eq!(command.options.len(), 4); + assert_eq!(command.options[0].name, Ident::new("AUTO_COMPRESS")); + assert!(matches!( + command.options[0].value, + Expr::Value(ValueWithSpan { + value: Value::Boolean(false), + .. + }) + )); + assert_eq!( + command.options[1].value, + Expr::Identifier(Ident::new("AUTO_DETECT")) + ); + assert!(matches!( + &command.options[3].value, + Expr::Value(ValueWithSpan { + value: Value::Number(_, false), + .. + }) + )); + assert_eq!(command.options[3].to_string(), "PARALLEL=001"); + let mut changed = command.options[3].clone(); + changed.value = Expr::Value(Value::Number("2".parse().unwrap(), false).with_empty_span()); + assert_eq!(changed.to_string(), "PARALLEL=2"); +} + +#[test] +fn snowflake_put_get_quoted_paths_round_trip_without_path_normalization() { + for sql in [ + r#"PUT 'file:///tmp/a b/it''s--file/*.csv?x#part' @stage/path AUTO_COMPRESS=false"#, + r#"GET '@"db.with.dot"."stage/slash"/a b/it''s--key/*?x#part' 'file:///tmp/download here' PATTERN='.*[.]csv' PARALLEL=4"#, + r#"PUT file://relative/../input.csv @stage/a/../b OVERWRITE=true"#, + r#"PUT file:///tmp/input.csv @stage/ PARALLEL=-1"#, + r#"GET @stage file:///tmp/download PARALLEL=100"#, + r#"PUT 'file:///C:\input\a.csv' @stage/a\b AUTO_COMPRESS=false"#, + r#"PUT file:///tmp/a"b.csv @stage/a"b?x#part AUTO_COMPRESS=false"#, + ] { + snowflake().verified_stmt(sql); + } +} + +#[test] +fn snowflake_put_get_preserves_option_order_duplicates_and_numeric_spelling() { + let Statement::Put(command) = snowflake().one_statement_parses_to( + "PUT file:///tmp/a.csv @stage AUTO_COMPRESS=FALSE PARALLEL=-001 AUTO_COMPRESS=TRUE UNSUPPORTED_OPTION=7", + "PUT file:///tmp/a.csv @stage AUTO_COMPRESS=false PARALLEL=-001 AUTO_COMPRESS=true UNSUPPORTED_OPTION=7", + ) else { + panic!("expected typed PUT"); + }; + assert_eq!(command.options.len(), 4); + assert_eq!(command.options[0].name, command.options[2].name); + assert_eq!(command.options[1].to_string(), "PARALLEL=-001"); + assert_eq!(command.options[3].name, Ident::new("UNSUPPORTED_OPTION")); + // Semantic option admission/range errors belong to the owning execution layer. + for value in ["0", "100", "-1", "+001", "1.5", "NULL", "'invalid'"] { + assert!(snowflake() + .parse_sql_statements(&format!("GET @stage file:///tmp/download PARALLEL={value}")) + .is_ok()); + } +} + +#[test] +fn snowflake_put_get_represents_user_and_table_stages_without_named_aliases() { + use sqlparser::ast::helpers::stmt_data_loading::{FileTransferLocation, FileTransferStage}; + for (sql, user) in [ + ("PUT file:///tmp/a.csv @~/prefix", true), + ( + r#"GET @"db"."schema".%"table/name"/prefix file:///tmp/download"#, + false, + ), + ] { + let statement = snowflake().verified_stmt(sql); + let stage = match statement { + Statement::Put(command) => command.target, + Statement::Get(command) => command.source, + _ => panic!("expected transfer statement"), + }; + let FileTransferLocation::Stage(location) = stage else { + panic!("expected stage location"); + }; + assert_eq!(location.prefix.as_deref(), Some("prefix")); + if user { + assert!(matches!(location.kind, FileTransferStage::User)); + } else { + assert!(matches!(location.kind, FileTransferStage::Table(_))); + } + } +} + +#[test] +fn snowflake_put_get_statement_boundaries_and_ordinary_sql_comments() { + use sqlparser::parser::Parser; + + let statements = Parser::parse_sql( + &SnowflakeDialect, + "SELECT 1; PUT file:///tmp/a--b/*.csv?x#part @stage/prefix; GET @stage/prefix file:///tmp/out; SELECT put, get FROM source", + ) + .unwrap(); + assert_eq!(statements.len(), 4); + assert!(matches!(statements[1], Statement::Put(_))); + assert!(matches!(statements[2], Statement::Get(_))); + assert!(matches!(statements[3], Statement::Query(_))); + snowflake().one_statement_parses_to( + "SELECT put, get FROM source // ordinary PUT file:///tmp/a.csv @stage\n", + "SELECT put, get FROM source", + ); + snowflake().one_statement_parses_to( + "SELECT put(1), get(2) /* ordinary file:///tmp/ -- text */", + "SELECT put(1), get(2)", + ); + snowflake().one_statement_parses_to( + "PUT /* before source */ file:///tmp/a.csv -- before target\n @stage/path # after target\n PARALLEL=4", + "PUT file:///tmp/a.csv @stage/path PARALLEL=4", + ); +} + +#[test] +fn snowflake_put_get_malformed_operands_and_options() { + for sql in [ + "PUT", + "PUT file:///tmp/a.csv", + "GET @stage", + "PUT file:///tmp/a.csv @", + "GET @% file:///tmp/out", + "GET @~.name file:///tmp/out", + "GET @%db.schema.table file:///tmp/out", + "GET @IDENTIFIER('stage') file:///tmp/out", + "PUT file:///tmp/a.csv @db.IDENTIFIER(:1)", + "PUT file:///tmp/a.csv @stage PARALLEL", + "GET @stage file:///tmp/out PARALLEL=", + "PUT file:///tmp/a.csv @stage AUTO_COMPRESS=TRUE, OVERWRITE=FALSE", + "PUT 'file:///tmp/unterminated @stage", + "GET @\"unterminated/file file:///tmp/out", + "PUT file:///tmp/a.csv @stage--hidden-suffix", + ] { + assert!(snowflake().parse_sql_statements(sql).is_err(), "{sql}"); + } +} + +#[test] +fn snowflake_put_get_stage_binding_parser_preserves_components_and_prefix() { + use sqlparser::ast::helpers::stmt_data_loading::FileTransferStage; + use sqlparser::dialect::parse_file_transfer_stage_location; + + let location = parse_file_transfer_stage_location( + r#"@"db.with/slash"."schema.dot"."stage""name"/prefix/../a--b/*?x#part"#, + ) + .unwrap(); + let FileTransferStage::Named(name) = location.kind else { + panic!("expected named stage"); + }; + assert_eq!(name.0.len(), 3); + assert_eq!(location.prefix.as_deref(), Some("prefix/../a--b/*?x#part")); + assert_eq!( + parse_file_transfer_stage_location("@get.schema.put") + .unwrap() + .kind, + FileTransferStage::Named(ObjectName::from(vec![ + Ident::new("get"), + Ident::new("schema"), + Ident::new("put"), + ])) + ); + assert_eq!( + parse_file_transfer_stage_location("@stage/*literal*/") + .unwrap() + .prefix + .as_deref(), + Some("*literal*/") + ); + for invalid in [ + "stage", + "@", + "@stage;SELECT 1", + "@stage--hidden", + "@IDENTIFIER('stage')", + "@db.IDENTIFIER(:1)", + ] { + assert!( + parse_file_transfer_stage_location(invalid).is_err(), + "{invalid}" + ); + } +} + +#[test] +fn snowflake_put_get_opaque_tokens_are_operand_local_and_dialect_scoped() { + let sql = r#"PUT file:///tmp/a--b/*.csv?x#part @"stage/slash"/prefix; SELECT put, get"#; + let tokens = Tokenizer::new(&SnowflakeDialect, sql) + .tokenize_with_location() + .unwrap(); + let operands = tokens + .iter() + .filter(|token| matches!(token.token, Token::FileTransferLocation { .. })) + .collect::>(); + assert_eq!(operands.len(), 2); + assert_eq!(operands[0].span.start, Location::new(1, 5)); + assert_eq!( + operands[1].span.start, + Location::new(1, u64::try_from(sql.find('@').unwrap() + 1).unwrap()) + ); + assert!(tokens.iter().any(|token| token.token == Token::SemiColon)); + let generic = Tokenizer::new(&GenericDialect, "PUT file:///tmp/a.csv @stage") + .tokenize() + .unwrap(); + assert!(!generic + .iter() + .any(|token| matches!(token, Token::FileTransferLocation { .. }))); + snowflake().one_statement_parses_to( + "// PUT file:///ignored/* @hidden\nPUT file:///tmp/a.csv @stage", + "PUT file:///tmp/a.csv @stage", + ); +} + +#[cfg(feature = "visitor")] +#[test] +fn snowflake_put_get_bindings_use_expression_visitors_not_table_relations() { + use core::ops::ControlFlow; + use sqlparser::ast::helpers::stmt_data_loading::FileTransferLocation; + use sqlparser::ast::{visit_expressions, visit_expressions_mut, visit_relations}; + + let mut statement = snowflake().verified_stmt("PUT ? :1 PARALLEL=:2"); + let mut bindings = Vec::new(); + let result = visit_expressions(&statement, |expr| { + if let Expr::Value(ValueWithSpan { + value: Value::Placeholder(value), + .. + }) = expr + { + bindings.push(value.clone()); + } + ControlFlow::<()>::Continue(()) + }); + assert_eq!(result, ControlFlow::Continue(())); + assert_eq!(bindings, vec!["?", ":1", ":2"]); + let result = visit_expressions_mut(&mut statement, |expr| { + if matches!( + expr, + Expr::Value(ValueWithSpan { + value: Value::Placeholder(_), + .. + }) + ) { + *expr = + Expr::Value(Value::SingleQuotedString("@stage/prefix".into()).with_empty_span()); + } + ControlFlow::<()>::Continue(()) + }); + assert_eq!(result, ControlFlow::Continue(())); + assert!(matches!(statement, Statement::Put(ref command) + if matches!(command.target, FileTransferLocation::Binding(Expr::Value(_))))); + + let statement = snowflake().verified_stmt("GET @db.schema.stage/prefix file:///tmp/out"); + let result = visit_relations(&statement, |_| { + ControlFlow::Break("unexpected table relation") + }); + assert_eq!(result, ControlFlow::Continue(())); +} + +#[test] +fn snowflake_bound_stage_prefix_is_data_and_display_remains_one_operand() { + use sqlparser::ast::helpers::stmt_data_loading::FileTransferLocation; + use sqlparser::dialect::parse_file_transfer_stage_location; + + let bound = + parse_file_transfer_stage_location("@stage/prefix with space;a--b/*?x#part").unwrap(); + assert!(bound.quoted); + assert_eq!( + bound.to_string(), + "'@stage/prefix with space;a--b/*?x#part'" + ); + let Statement::Get(command) = + snowflake().verified_stmt(&format!("GET {bound} file:///tmp/download")) + else { + panic!("expected typed GET"); + }; + let FileTransferLocation::Stage(reparsed) = command.source else { + panic!("expected typed stage source"); + }; + assert_eq!(reparsed, bound); +} + +#[test] +fn snowflake_create_stage_storage_integration_can_follow_endpoint() { + let canonical = concat!( + "CREATE STAGE reference_stage ", + "URL='s3://example-bucket/raw%40path/' ", + "STORAGE_INTEGRATION=LOCAL_REFERENCE ", + "ENDPOINT='https://example.invalid/raw%3Fpath/' ", + "FILE_FORMAT=(TYPE=CSV FIELD_DELIMITER='|') COMMENT='metadata only'" + ); + let endpoint_first = concat!( + "CREATE STAGE reference_stage ", + "URL='s3://example-bucket/raw%40path/' ", + "ENDPOINT='https://example.invalid/raw%3Fpath/' ", + "STORAGE_INTEGRATION=LOCAL_REFERENCE ", + "FILE_FORMAT=(TYPE=CSV FIELD_DELIMITER='|') COMMENT='metadata only'" + ); + let expected = snowflake().verified_stmt(canonical); + let observed = snowflake().one_statement_parses_to(endpoint_first, canonical); + let ( + Statement::CreateStage { + stage_params: expected, + .. + }, + Statement::CreateStage { + stage_params: observed, + .. + }, + ) = (expected, observed) + else { + panic!("expected CREATE STAGE"); + }; + assert_eq!(observed, expected); + assert_eq!( + observed.storage_integration.as_deref(), + Some("LOCAL_REFERENCE") + ); + assert_eq!( + observed.endpoint.as_deref(), + Some("https://example.invalid/raw%3Fpath/") + ); +} + +#[test] +fn snowflake_create_stage_duplicate_integration_or_endpoint_stays_unsupported() { + for parameters in [ + "STORAGE_INTEGRATION=first ENDPOINT='https://example.invalid/' STORAGE_INTEGRATION=second", + "ENDPOINT='https://example.invalid/' STORAGE_INTEGRATION=first STORAGE_INTEGRATION=second", + "STORAGE_INTEGRATION=first STORAGE_INTEGRATION=second ENDPOINT='https://example.invalid/'", + "ENDPOINT='https://first.invalid/' STORAGE_INTEGRATION=first ENDPOINT='https://second.invalid/'", + ] { + let sql = format!("CREATE STAGE reference_stage URL='s3://example-bucket/' {parameters}"); + assert!(snowflake().parse_sql_statements(&sql).is_err(), "{sql}"); + } +} + +// Logical-warehouse syntax only. These controls use existing parser/Display APIs so +// the tests-first patch compiles before the new AST is added. Runtime effects and +// Snowflake result/error metadata are not established by parsing these statements. +#[test] +fn snowflake_warehouse_create_roundtrips() { + for sql in [ + "CREATE WAREHOUSE demo", + "CREATE WAREHOUSE IF NOT EXISTS demo", + "CREATE OR REPLACE WAREHOUSE demo", + "CREATE OR REPLACE WAREHOUSE IF NOT EXISTS demo", + "CREATE WAREHOUSE \"Mixed\"\"Quote\" WITH WAREHOUSE_TYPE = STANDARD WAREHOUSE_SIZE = XSMALL COMMENT = 'a ''quoted'' comment'", + "CREATE WAREHOUSE account.warehouse WAREHOUSE_SIZE = 'SMALL' AUTO_SUSPEND = NULL", + "CREATE WAREHOUSE demo AUTO_SUSPEND = -1 MAX_CLUSTER_COUNT = 2", + "CREATE WAREHOUSE demo COMMENT = 'first' COMMENT = 'second'", + ] { + snowflake().verified_stmt(sql); + } + snowflake().one_statement_parses_to( + "CREATE WAREHOUSE demo AUTO_RESUME = TRUE INITIALLY_SUSPENDED = FALSE", + "CREATE WAREHOUSE demo AUTO_RESUME = true INITIALLY_SUSPENDED = false", + ); +} + +#[test] +fn snowflake_warehouse_alter_roundtrips() { + for sql in [ + "ALTER WAREHOUSE demo SUSPEND", + "ALTER WAREHOUSE SUSPEND", + "ALTER WAREHOUSE IF EXISTS demo RESUME IF SUSPENDED", + "ALTER WAREHOUSE IF EXISTS RESUME IF SUSPENDED", + "ALTER WAREHOUSE \"SUSPEND\" RESUME", + "ALTER WAREHOUSE demo SET WAREHOUSE_SIZE = SMALL COMMENT = 'x' AUTO_SUSPEND = 0", + "ALTER WAREHOUSE IF EXISTS demo UNSET COMMENT, AUTO_SUSPEND", + "ALTER WAREHOUSE demo RENAME TO \"New.Name\"", + "ALTER WAREHOUSE a.b RENAME TO c.d", + ] { + snowflake().verified_stmt(sql); + } +} + +#[test] +fn snowflake_warehouse_drop_and_describe_roundtrip() { + for sql in [ + "DROP WAREHOUSE demo", + "DROP WAREHOUSE IF EXISTS \"Mixed.Name\"", + "DROP WAREHOUSE IF EXISTS a.b", + "DESC WAREHOUSE demo", + "DESCRIBE WAREHOUSE \"Mixed\"\"Quote\"", + "DESCRIBE WAREHOUSE a.b", + ] { + snowflake().verified_stmt(sql); + } +} + +#[test] +fn snowflake_warehouse_show_has_a_native_statement() { + // Existing selected reference accepts these individual forms. It does not + // establish populated ordering or the effect of STARTS WITH / LIMIT. + for sql in [ + "SHOW WAREHOUSES", + "SHOW WAREHOUSES LIKE 'absent%'", + "SHOW WAREHOUSES LIKE 'x'' OR ''1''=''1'", + "SHOW TERSE WAREHOUSES LIKE 'absent%'", + "SHOW WAREHOUSES STARTS WITH 'WH'", + "SHOW WAREHOUSES LIMIT 1", + ] { + let statement = snowflake().verified_stmt(sql); + assert!(!matches!(statement, Statement::ShowVariable { .. })); + } +} + +#[test] +fn snowflake_warehouse_batches_keep_the_statement_delimiter() { + let statements = snowflake() + .parse_sql_statements( + "CREATE WAREHOUSE demo COMMENT = 'one;two'; SELECT 1; ALTER WAREHOUSE demo SET COMMENT = 'three'; DROP WAREHOUSE demo; SELECT 2", + ) + .expect("five complete statements"); + assert_eq!(statements.len(), 5); + assert_eq!( + statements[0].to_string(), + "CREATE WAREHOUSE demo COMMENT = 'one;two'" + ); + assert_eq!(statements[1].to_string(), "SELECT 1"); + assert_eq!(statements[3].to_string(), "DROP WAREHOUSE demo"); + assert_eq!(statements[4].to_string(), "SELECT 2"); +} + +#[test] +fn snowflake_warehouse_malformed_or_unselected_syntax_is_not_discarded() { + for sql in [ + "CREATE WAREHOUSE", + "CREATE TEMPORARY WAREHOUSE demo", + "CREATE WAREHOUSE demo WITH", + "CREATE WAREHOUSE demo WAREHOUSE_SIZE =", + "CREATE WAREHOUSE demo WAREHOUSE_SIZE = ABS(1)", + "CREATE WAREHOUSE demo AUTO_SUSPEND = (SELECT 1)", + "CREATE WAREHOUSE demo WAREHOUSE_SIZE = SMALL, COMMENT = 'x'", + "ALTER WAREHOUSE demo SET", + "ALTER WAREHOUSE demo UNSET", + "ALTER WAREHOUSE demo RENAME TO", + "DROP WAREHOUSE demo, peer", + "DROP WAREHOUSE demo CASCADE", + "DESCRIBE WAREHOUSE", + "SHOW WAREHOUSES IN SCHEMA app", + "SHOW WAREHOUSES LIKE 'x' unexpected", + ] { + assert!(snowflake().parse_sql_statements(sql).is_err(), "{sql}"); + } +} + +#[test] +fn snowflake_warehouse_native_and_other_dialect_paths_are_preserved() { + for sql in [ + "USE WAREHOUSE demo", + "USE WAREHOUSE IDENTIFIER('demo')", + "DROP TABLE warehouse", + "SHOW TABLES", + "DESCRIBE TABLE warehouse", + "CREATE TABLE warehouse (id INT)", + "EXPLAIN SELECT 1", + "PUT file:///tmp/warehouse.csv @warehouse AUTO_COMPRESS=false", + ] { + snowflake().verified_stmt(sql); + } + let generic = GenericDialect {}; + assert!(sqlparser::parser::Parser::parse_sql(&generic, "CREATE WAREHOUSE demo").is_err()); + let ordinary = sqlparser::parser::Parser::parse_sql(&generic, "DROP TABLE warehouse") + .expect("unchanged generic DROP"); + assert_eq!(ordinary[0].to_string(), "DROP TABLE warehouse"); +} + +#[test] +fn snowflake_warehouse_typed_options_keep_identity_order_and_semantic_inputs() { + let Statement::CreateWarehouse(command) = snowflake().verified_stmt( + "CREATE OR REPLACE WAREHOUSE IF NOT EXISTS \"Mixed.Name\" WITH WAREHOUSE_SIZE = SMALL AUTO_SUSPEND = -1 AUTO_RESUME = false COMMENT = 'first' COMMENT = 'second'", + ) else { + panic!("expected typed CREATE WAREHOUSE"); + }; + assert!(command.or_replace); + assert!(command.if_not_exists); + assert!(command.with_keyword); + assert_eq!(command.name.to_string(), "\"Mixed.Name\""); + assert_eq!(command.options.len(), 5); + assert_eq!(command.options[3].name.value, "COMMENT"); + assert_eq!(command.options[4].name.value, "COMMENT"); + assert!(matches!(&command.options[0].value, Expr::Identifier(size) if size.value == "SMALL")); + assert!(matches!( + &command.options[1].value, + Expr::UnaryOp { + op: UnaryOperator::Minus, + .. + } + )); + assert!( + matches!(&command.options[2].value, Expr::Value(value) if value.value == Value::Boolean(false)) + ); + assert!( + matches!(&command.options[3].value, Expr::Value(value) if value.value == Value::SingleQuotedString("first".into())) + ); + assert!( + matches!(&command.options[4].value, Expr::Value(value) if value.value == Value::SingleQuotedString("second".into())) + ); +} + +#[test] +fn snowflake_warehouse_typed_targets_and_actions_stay_distinct() { + use sqlparser::ast::helpers::stmt_warehouse::AlterWarehouseAction; + + let Statement::AlterWarehouse(command) = + snowflake().verified_stmt("ALTER WAREHOUSE IF EXISTS RESUME IF SUSPENDED") + else { + panic!("expected typed ALTER WAREHOUSE"); + }; + assert!(command.if_exists); + assert!(command.name.is_none()); + assert_eq!( + command.action, + AlterWarehouseAction::Resume { if_suspended: true } + ); + + let Statement::AlterWarehouse(command) = + snowflake().verified_stmt("ALTER WAREHOUSE \"SUSPEND\" UNSET COMMENT, AUTO_SUSPEND") + else { + panic!("expected typed ALTER WAREHOUSE"); + }; + assert_eq!( + command.name.expect("explicit target").to_string(), + "\"SUSPEND\"" + ); + assert!(matches!(command.action, AlterWarehouseAction::Unset(names) if names.len() == 2)); + + let Statement::DescribeWarehouse(command) = snowflake().verified_stmt("DESC WAREHOUSE a.b") + else { + panic!("expected typed DESCRIBE WAREHOUSE"); + }; + assert_eq!(command.describe_alias, DescribeAlias::Desc); + assert_eq!(command.name.to_string(), "a.b"); + + let Statement::DropWarehouse(command) = + snowflake().verified_stmt("DROP WAREHOUSE IF EXISTS \"One.Name\"") + else { + panic!("expected typed DROP WAREHOUSE"); + }; + assert!(command.if_exists); + assert_eq!(command.name.to_string(), "\"One.Name\""); + + let Statement::ShowWarehouses(command) = + snowflake().verified_stmt("SHOW TERSE WAREHOUSES LIKE 'absent%'") + else { + panic!("expected typed SHOW WAREHOUSES"); + }; + assert!(command.terse); + assert_eq!(command.like_pattern.as_deref(), Some("absent%")); + assert!(command.starts_with.is_none()); + assert!(command.limit.is_none()); +} + +#[cfg(feature = "visitor")] +#[test] +fn snowflake_warehouse_names_are_not_table_relations() { + use core::ops::ControlFlow; + use sqlparser::ast::{visit_expressions, visit_relations}; + + for sql in [ + "CREATE WAREHOUSE wh COMMENT = 'text'", + "ALTER WAREHOUSE wh RENAME TO peer", + "SHOW WAREHOUSES LIMIT 1", + "DESCRIBE WAREHOUSE wh", + "DROP WAREHOUSE wh", + ] { + let statement = snowflake().verified_stmt(sql); + let result = visit_relations(&statement, |_| ControlFlow::Break("unexpected relation")); + assert_eq!(result, ControlFlow::Continue(()), "{sql}"); + } + let statement = snowflake().verified_stmt("CREATE WAREHOUSE wh AUTO_SUSPEND = -1"); + let mut numeric_literals = 0; + let result = visit_expressions(&statement, |expr| { + if matches!(expr, Expr::Value(value) if matches!(&value.value, Value::Number(..))) { + numeric_literals += 1; + } + ControlFlow::<()>::Continue(()) + }); + assert_eq!(result, ControlFlow::Continue(())); + assert_eq!(numeric_literals, 1); +} + +#[test] +fn snowflake_table_comment_null_is_typed_and_roundtrips() { + // Snowflake 10.40.102 TEMP CREATE reference: 01c83e8c-0209-ab5f-0004-d66e05c171ae. + let Statement::CreateTable(table) = + snowflake().verified_stmt("CREATE TABLE t (id INT) COMMENT = NULL") + else { + panic!("expected CREATE TABLE"); + }; + assert!( + table.comment.is_none(), + "Snowflake comments belong to plain options" + ); + let CreateTableOptions::Plain(options) = table.table_options else { + panic!("expected plain table options"); + }; + assert_eq!(options.len(), 1); + assert!(matches!(&options[0], SqlOption::Comment(_))); + assert_eq!(options[0].to_string(), "COMMENT = NULL"); + assert_ne!( + options[0], + SqlOption::Comment(CommentDef::WithEq("NULL".into())) + ); + assert_ne!( + options[0], + SqlOption::Comment(CommentDef::WithEq(String::new())) + ); +} + +#[test] +fn snowflake_table_comment_absent_empty_and_null_string_stay_distinct() { + for (sql, expected) in [ + ("CREATE TABLE t (id INT)", CreateTableOptions::None), + ( + "CREATE TABLE t (id INT) COMMENT = ''", + CreateTableOptions::Plain(vec![SqlOption::Comment(CommentDef::WithEq(String::new()))]), + ), + ( + "CREATE TABLE t (id INT) COMMENT = 'NULL'", + CreateTableOptions::Plain(vec![SqlOption::Comment(CommentDef::WithEq("NULL".into()))]), + ), + ( + "CREATE TABLE t (id INT) COMMENT 'text'", + CreateTableOptions::Plain(vec![SqlOption::Comment(CommentDef::WithoutEq( + "text".into(), + ))]), + ), + ] { + let Statement::CreateTable(table) = snowflake().verified_stmt(sql) else { + panic!("expected CREATE TABLE"); + }; + assert_eq!(table.table_options, expected, "{sql}"); + } +} + +#[test] +fn snowflake_table_comment_dollar_quoted_null_stays_a_string() { + let Statement::CreateTable(table) = snowflake().one_statement_parses_to( + "CREATE TABLE t (id INT) COMMENT = $$NULL$$", + "CREATE TABLE t (id INT) COMMENT = 'NULL'", + ) else { + panic!("expected CREATE TABLE"); + }; + assert_eq!( + table.table_options, + CreateTableOptions::Plain(vec![SqlOption::Comment(CommentDef::WithEq("NULL".into())),]) + ); +} + +#[test] +fn snowflake_table_comment_null_keeps_batch_delimiters() { + use sqlparser::parser::Parser; + + let statements = Parser::parse_sql( + &SnowflakeDialect, + "CREATE TABLE t (id INT) COMMENT = NULL; SELECT 'NULL' AS text_value;", + ) + .expect("table comment followed by a separate statement"); + assert_eq!(statements.len(), 2); + assert_eq!( + statements[0].to_string(), + "CREATE TABLE t (id INT) COMMENT = NULL" + ); + assert_eq!(statements[1].to_string(), "SELECT 'NULL' AS text_value"); +} + +#[test] +fn snowflake_table_comment_null_requires_the_selected_assignment_shape() { + use sqlparser::parser::Parser; + + for sql in [ + "CREATE TABLE t (id INT COMMENT NULL)", + "CREATE TABLE t (id INT COMMENT = NULL)", + "CREATE TABLE t (id INT) COMMENT NULL", + "CREATE TABLE t (id INT) COMMENT = (NULL)", + "CREATE TABLE t (id INT) COMMENT = NULL || ''", + ] { + assert!(Parser::parse_sql(&SnowflakeDialect, sql).is_err(), "{sql}"); + } +} + +#[test] +fn snowflake_table_comment_null_does_not_broaden_other_comment_grammars() { + use sqlparser::parser::Parser; + + for sql in [ + "CREATE STAGE s COMMENT = NULL", + "ALTER STAGE s SET COMMENT = NULL", + "CREATE FILE FORMAT f TYPE = CSV COMMENT = NULL", + "ALTER FILE FORMAT f SET COMMENT = NULL", + "CREATE VIEW v COMMENT = NULL AS SELECT 1", + ] { + assert!(Parser::parse_sql(&SnowflakeDialect, sql).is_err(), "{sql}"); + } + assert!(Parser::parse_sql(&GenericDialect, "CREATE TABLE t (id INT) COMMENT = NULL").is_err()); + let mut parser = Parser::new(&SnowflakeDialect) + .try_with_sql("NULL") + .expect("NULL token"); + assert!( + parser.parse_comment_value().is_err(), + "the shared string parser must stay strict" + ); +} + +#[cfg(all(feature = "serde", feature = "serde_json"))] +#[test] +fn snowflake_table_comment_null_has_a_distinct_serde_variant() { + let Statement::CreateTable(table) = + snowflake().verified_stmt("CREATE TABLE t (id INT) COMMENT = NULL") + else { + panic!("expected CREATE TABLE"); + }; + let CreateTableOptions::Plain(options) = table.table_options else { + panic!("expected plain table options"); + }; + assert_eq!( + serde_json::to_string(&options[0]).expect("serialize typed comment"), + r#"{"Comment":"NullWithEq"}"# + ); + let decoded: SqlOption = + serde_json::from_str(r#"{"Comment":"NullWithEq"}"#).expect("deserialize typed comment"); + assert_eq!(decoded, options[0]); +} + +#[cfg(feature = "visitor")] +#[test] +fn snowflake_table_comment_null_is_metadata_without_expression_nodes() { + use core::ops::ControlFlow; + use sqlparser::ast::visit_expressions; + + let statement = snowflake().verified_stmt("CREATE TABLE t (id INT) COMMENT = NULL"); + let result = visit_expressions(&statement, |_| ControlFlow::Break("unexpected expression")); + assert_eq!(result, ControlFlow::Continue(())); +} + +#[test] +fn snowflake_create_schema_options() { + for sql in [ + "CREATE SCHEMA demo", + "CREATE SCHEMA demo COMMENT = 'description'", + "CREATE SCHEMA demo COMMENT = ''", + "CREATE SCHEMA demo DATA_RETENTION_TIME_IN_DAYS = 0", + "CREATE SCHEMA IF NOT EXISTS db.demo DATA_RETENTION_TIME_IN_DAYS = 1 COMMENT = 'description'", + "CREATE SCHEMA db.demo COMMENT = 'description' DATA_RETENTION_TIME_IN_DAYS = 90", + "CREATE SCHEMA db.demo CLONE source.demo DATA_RETENTION_TIME_IN_DAYS = 1 COMMENT = 'clone'", + "CREATE SCHEMA IDENTIFIER('db.demo') COMMENT = 'description'", + ] { + snowflake().verified_stmt(sql); + } + + assert_eq!( + snowflake().verified_stmt( + "CREATE SCHEMA IF NOT EXISTS db.demo COMMENT = 'description' DATA_RETENTION_TIME_IN_DAYS = 7" + ), + Statement::CreateSchema { + or_replace: false, + transient: false, + schema_name: SchemaName::Simple(ObjectName::from(vec![ + Ident::new("db"), + Ident::new("demo"), + ])), + if_not_exists: true, + with: None, + options: None, + default_collate_spec: None, + clone: None, + with_managed_access: false, + schema_options: vec![ + SqlOption::KeyValue { + key: Ident::new("COMMENT"), + value: Expr::Value(Value::SingleQuotedString("description".into()).into()), + }, + SqlOption::KeyValue { + key: Ident::new("DATA_RETENTION_TIME_IN_DAYS"), + value: Expr::Value(Value::Number("7".parse().unwrap(), false).into()), + }, + ], + } + ); +} + +#[test] +fn snowflake_create_schema_options_preserve_duplicates_and_invalid_literals() { + let Statement::CreateSchema { schema_options, .. } = snowflake().verified_stmt( + "CREATE SCHEMA demo COMMENT = NULL DATA_RETENTION_TIME_IN_DAYS = -1 COMMENT = 42 DATA_RETENTION_TIME_IN_DAYS = 'invalid'", + ) else { + panic!("expected CREATE SCHEMA"); + }; + assert_eq!( + schema_options, + vec![ + SqlOption::KeyValue { + key: Ident::new("COMMENT"), + value: Expr::Value(Value::Null.into()), + }, + SqlOption::KeyValue { + key: Ident::new("DATA_RETENTION_TIME_IN_DAYS"), + value: Expr::UnaryOp { + op: UnaryOperator::Minus, + expr: Box::new(Expr::Value( + Value::Number("1".parse().unwrap(), false).into() + )), + }, + }, + SqlOption::KeyValue { + key: Ident::new("COMMENT"), + value: Expr::Value(Value::Number("42".parse().unwrap(), false).into()), + }, + SqlOption::KeyValue { + key: Ident::new("DATA_RETENTION_TIME_IN_DAYS"), + value: Expr::Value(Value::SingleQuotedString("invalid".into()).into()), + }, + ] + ); + + for value in [ + "-1", + "+1", + "1.5", + "999999999999999999999999999999", + "'1'", + "true", + "false", + "NULL", + ] { + snowflake().verified_stmt(&format!( + "CREATE SCHEMA demo DATA_RETENTION_TIME_IN_DAYS = {value}" + )); + } + snowflake().one_statement_parses_to( + "CREATE SCHEMA demo COMMENT = TRUE", + "CREATE SCHEMA demo COMMENT = true", + ); + + let exponent = number("1e3"); + let Statement::CreateSchema { schema_options, .. } = snowflake().one_statement_parses_to( + "CREATE SCHEMA demo DATA_RETENTION_TIME_IN_DAYS = 1e3", + &format!("CREATE SCHEMA demo DATA_RETENTION_TIME_IN_DAYS = {exponent}"), + ) else { + panic!("expected CREATE SCHEMA"); + }; + assert_eq!( + schema_options, + vec![SqlOption::KeyValue { + key: Ident::new("DATA_RETENTION_TIME_IN_DAYS"), + value: Expr::Value(exponent.into()), + }] + ); +} + +#[test] +fn snowflake_create_schema_options_comments_and_whitespace() { + snowflake().one_statement_parses_to( + r#" + CREATE /* schema */ SCHEMA IF NOT EXISTS "db"."demo" + -- option order is retained + comment /* key */ = /* value */ 'owner''s schema' + data_retention_time_in_days + = -- retention + 7 /* trailing comment */; + "#, + r#"CREATE SCHEMA IF NOT EXISTS "db"."demo" comment = 'owner''s schema' data_retention_time_in_days = 7"#, + ); +} + +#[test] +fn snowflake_create_schema_options_keep_quoted_sql_and_statement_boundaries() { + for sql in [ + r#"CREATE SCHEMA "db;DROP DATABASE prod"."demo" COMMENT = 'x''; DROP SCHEMA prod; -- /* */ DATA_RETENTION_TIME_IN_DAYS = 0' DATA_RETENTION_TIME_IN_DAYS = 7; /* ; ignored */ SELECT 1;"#, + r#"CREATE SCHEMA "db;DROP DATABASE prod"."demo" COMMENT = $$x'; DROP SCHEMA prod; -- /* */ DATA_RETENTION_TIME_IN_DAYS = 0$$ DATA_RETENTION_TIME_IN_DAYS = 7; SELECT 1;"#, + ] { + let statements = snowflake().parse_sql_statements(sql).unwrap(); + assert_eq!(statements.len(), 2); + let Statement::CreateSchema { + schema_name, + schema_options, + .. + } = &statements[0] + else { + panic!("expected CREATE SCHEMA"); + }; + assert_eq!( + *schema_name, + SchemaName::Simple(ObjectName::from(vec![ + Ident::with_quote('"', "db;DROP DATABASE prod"), + Ident::with_quote('"', "demo"), + ])) + ); + let SqlOption::KeyValue { + value: Expr::Value(value), + .. + } = &schema_options[0] + else { + panic!("expected literal COMMENT"); + }; + match &value.value { + Value::SingleQuotedString(value) => assert_eq!( + value, + "x'; DROP SCHEMA prod; -- /* */ DATA_RETENTION_TIME_IN_DAYS = 0" + ), + Value::DollarQuotedString(value) => assert_eq!( + value.value, + "x'; DROP SCHEMA prod; -- /* */ DATA_RETENTION_TIME_IN_DAYS = 0" + ), + value => panic!("unexpected comment: {value:?}"), + } + assert_eq!(schema_options.len(), 2); + assert_eq!(statements[1].to_string(), "SELECT 1"); + snowflake().verified_stmt(&statements[0].to_string()); + } + + for sql in [ + "CREATE SCHEMA demo; SELECT 1", + "CREATE SCHEMA demo COMMENT = 'kept'; SELECT 1", + "CREATE SCHEMA demo DATA_RETENTION_TIME_IN_DAYS = 1; SELECT 1", + "CREATE SCHEMA demo COMMENT = 'kept' DATA_RETENTION_TIME_IN_DAYS = 1; SELECT 1", + ] { + let statements = snowflake().parse_sql_statements(sql).unwrap(); + assert_eq!(statements.len(), 2, "{sql}"); + assert_eq!(statements[1].to_string(), "SELECT 1"); + } +} + +#[test] +fn snowflake_create_schema_options_reject_malformed_suffixes() { + for sql in [ + "CREATE SCHEMA demo COMMENT", + "CREATE SCHEMA demo COMMENT 'missing equals'", + "CREATE SCHEMA demo COMMENT =", + "CREATE SCHEMA demo COMMENT =; SELECT 1", + "CREATE SCHEMA demo COMMENT = 'unterminated", + "CREATE SCHEMA demo COMMENT = = 'extra equals'", + "CREATE SCHEMA demo COMMENT = 'x' COMMENT =", + "CREATE SCHEMA demo COMMENT = DATA_RETENTION_TIME_IN_DAYS = 1", + "CREATE SCHEMA demo DATA_RETENTION_TIME_IN_DAYS", + "CREATE SCHEMA demo DATA_RETENTION_TIME_IN_DAYS 1", + "CREATE SCHEMA demo DATA_RETENTION_TIME_IN_DAYS =", + "CREATE SCHEMA demo DATA_RETENTION_TIME_IN_DAYS = +", + "CREATE SCHEMA demo DATA_RETENTION_TIME_IN_DAYS = -'1'", + "CREATE SCHEMA demo DATA_RETENTION_TIME_IN_DAYS = 1.5.2", + "CREATE SCHEMA demo DATA_RETENTION_TIME_IN_DAYS = 1, COMMENT = 'x'", + "CREATE SCHEMA demo DATA_RETENTION_TIME_IN_DAYS = (1)", + "CREATE SCHEMA demo DATA_RETENTION_TIME_IN_DAYS = 1 + 1", + "CREATE SCHEMA demo COMMENT = 'a' || 'b'", + "CREATE SCHEMA demo COMMENT = concat('a', 'b')", + "CREATE SCHEMA demo COMMENT = (SELECT 'x')", + "CREATE SCHEMA demo UNKNOWN_OPTION = 1", + "CREATE SCHEMA demo COMMENT = 'x' UNKNOWN_OPTION = 1", + "CREATE SCHEMA demo COMMENT = 'x' SELECT 1", + "CREATE SCHEMA demo DATA_RETENTION_TIME_IN_DAYS = 1 CREATE SCHEMA other", + ] { + assert!(snowflake().parse_sql_statements(sql).is_err(), "{sql}"); + } +} + +#[test] +fn snowflake_create_schema_options_do_not_add_other_schema_modifiers() { + use sqlparser::dialect::DuckDbDialect; + use sqlparser::keywords::Keyword; + use sqlparser::parser::Parser; + + for sql in [ + "CREATE OR ALTER SCHEMA demo COMMENT = 'x'", + "CREATE TEMPORARY SCHEMA demo DATA_RETENTION_TIME_IN_DAYS = 1", + ] { + assert!(snowflake().parse_sql_statements(sql).is_err(), "{sql}"); + } + + for dialect in [ + &SnowflakeDialect as &dyn Dialect, + &GenericDialect, + &DuckDbDialect, + ] { + for modifier in [ + "OR ALTER", + "TEMP", + "TEMPORARY", + "LOCAL", + "GLOBAL", + "PERSISTENT", + "SECURE", + "LOCAL TEMPORARY", + "GLOBAL TEMP", + "OR ALTER TEMPORARY", + "ALGORITHM = UNDEFINED", + "DEFINER = owner", + "SQL SECURITY DEFINER", + "SQL SECURITY INVOKER", + "ALGORITHM = MERGE DEFINER = owner SQL SECURITY INVOKER", + ] { + for suffix in ["", " COMMENT = 'x'", " DATA_RETENTION_TIME_IN_DAYS = 1"] { + let sql = format!("CREATE {modifier} SCHEMA demo{suffix}"); + assert!( + Parser::parse_sql(dialect, &sql).is_err(), + "{dialect:?}: {sql}" + ); + + // Exercise consumed modifiers without relying on dialect fallback rejection. + let mut parser = Parser::new(dialect).try_with_sql(&sql).unwrap(); + parser.expect_keyword_is(Keyword::CREATE).unwrap(); + assert!( + parser.parse_create().is_err(), + "shared CREATE: {dialect:?}: {sql}" + ); + } + } + } +} + +#[test] +fn snowflake_create_schema_options_are_dialect_gated() { + use sqlparser::parser::Parser; + + for dialect in all_dialects().dialects { + if dialect.is::() || dialect.is::() { + assert!(dialect.supports_create_schema_options()); + } else { + assert!(!dialect.supports_create_schema_options()); + for sql in [ + "CREATE SCHEMA demo COMMENT = 'x'", + "CREATE SCHEMA demo DATA_RETENTION_TIME_IN_DAYS = 1", + ] { + assert!( + Parser::parse_sql(&*dialect, sql).is_err(), + "{dialect:?}: {sql}" + ); + } + } + let statements = Parser::parse_sql(&*dialect, "CREATE SCHEMA demo").unwrap(); + let Statement::CreateSchema { schema_options, .. } = &statements[0] else { + panic!("expected CREATE SCHEMA"); + }; + assert!(schema_options.is_empty()); + } + + all_dialects().verified_stmt("CREATE SCHEMA demo AUTHORIZATION owner"); + all_dialects().verified_stmt("CREATE SCHEMA demo WITH (location = '/tmp/demo')"); + all_dialects() + .verified_stmt("CREATE SCHEMA demo DEFAULT COLLATE 'und:ci' OPTIONS(location = 'US')"); +} + +#[cfg(all(feature = "serde", feature = "serde_json"))] +#[test] +fn snowflake_create_schema_options_serde_round_trip_and_default() { + let statement = snowflake().verified_stmt( + "CREATE SCHEMA demo COMMENT = NULL DATA_RETENTION_TIME_IN_DAYS = -1 COMMENT = 'kept'", + ); + let encoded = serde_json::to_string(&statement).unwrap(); + assert_eq!( + serde_json::from_str::(&encoded).unwrap(), + statement + ); + + let plain = snowflake().verified_stmt("CREATE SCHEMA demo"); + let mut old = serde_json::to_value(&plain).unwrap(); + assert!(old["CreateSchema"] + .as_object_mut() + .unwrap() + .remove("schema_options") + .is_some()); + assert_eq!(serde_json::from_value::(old).unwrap(), plain); +} + +#[cfg(feature = "visitor")] +#[test] +fn snowflake_create_schema_options_expose_literal_expressions_to_visitors() { + use core::ops::ControlFlow; + use sqlparser::ast::visit_expressions; + + let statement = snowflake() + .verified_stmt("CREATE SCHEMA demo COMMENT = 'kept' DATA_RETENTION_TIME_IN_DAYS = 7"); + let mut expressions = Vec::new(); + let result = visit_expressions(&statement, |expr| { + expressions.push(expr.to_string()); + ControlFlow::<()>::Continue(()) + }); + assert_eq!(result, ControlFlow::Continue(())); + assert_eq!(expressions, vec!["'kept'", "7"]); +} + +#[test] +fn snowflake_alter_schema_options_set_and_unset() { + for sql in [ + "ALTER SCHEMA demo SET COMMENT = 'description'", + "ALTER SCHEMA IF EXISTS db.demo SET DATA_RETENTION_TIME_IN_DAYS = 7 COMMENT = ''", + "ALTER SCHEMA demo SET COMMENT = NULL DATA_RETENTION_TIME_IN_DAYS = -1 COMMENT = 42", + "ALTER SCHEMA demo SET FUTURE_PROPERTY = false DATA_RETENTION_TIME_IN_DAYS = 'invalid'", + r#"ALTER SCHEMA demo SET "COMMENT" = 'x' "FLAG" = true"#, + "ALTER SCHEMA demo UNSET COMMENT", + "ALTER SCHEMA IF EXISTS db.demo UNSET COMMENT, DATA_RETENTION_TIME_IN_DAYS, COMMENT", + ] { + snowflake().verified_stmt(sql); + } + snowflake().one_statement_parses_to( + r#" + ALTER /* schema */ SCHEMA IF EXISTS "db"."demo" SET + comment /* key */ = /* value */ 'owner''s schema', + data_retention_time_in_days = -- retention + +7 /* trailing comment */; + "#, + r#"ALTER SCHEMA IF EXISTS "db"."demo" SET comment = 'owner''s schema' data_retention_time_in_days = +7"#, + ); + snowflake().one_statement_parses_to( + "ALTER SCHEMA demo SET COMMENT = '', DATA_RETENTION_TIME_IN_DAYS = 1, COMMENT = NULL", + "ALTER SCHEMA demo SET COMMENT = '' DATA_RETENTION_TIME_IN_DAYS = 1 COMMENT = NULL", + ); + snowflake().one_statement_parses_to( + "ALTER SCHEMA demo SET COMMENT='x'/* separator */COMMENT='y'", + "ALTER SCHEMA demo SET COMMENT = 'x' COMMENT = 'y'", + ); + snowflake().one_statement_parses_to( + r#"ALTER SCHEMA demo UNSET comment /* comma */, -- name + data_retention_time_in_days, comment;"#, + "ALTER SCHEMA demo UNSET comment, data_retention_time_in_days, comment", + ); +} + +#[test] +fn snowflake_alter_schema_options_literal_round_trip() { + for value in [ + "-1", + "+1", + "1.5", + "999999999999999999999999999999", + "'1'", + "true", + "false", + "NULL", + "$$opaque; -- /* text */$$", + ] { + snowflake().verified_stmt(&format!("ALTER SCHEMA demo SET COMMENT = {value}")); + } + snowflake().one_statement_parses_to( + "ALTER SCHEMA demo SET COMMENT = TRUE DATA_RETENTION_TIME_IN_DAYS = 1e3", + &format!( + "ALTER SCHEMA demo SET COMMENT = true DATA_RETENTION_TIME_IN_DAYS = {}", + number("1e3") + ), + ); +} + +#[test] +fn snowflake_alter_schema_options_keep_statement_boundaries() { + for sql in [ + r#"ALTER SCHEMA "db;DROP DATABASE prod"."demo" SET COMMENT = 'x''; DROP SCHEMA prod; -- /* */ DATA_RETENTION_TIME_IN_DAYS = 0' DATA_RETENTION_TIME_IN_DAYS = 7; /* ; ignored */ SELECT 1;"#, + r#"ALTER SCHEMA demo SET COMMENT = $$x'; DROP SCHEMA prod; -- /* */$$, DATA_RETENTION_TIME_IN_DAYS = 7; SELECT 1;"#, + "ALTER SCHEMA demo SET COMMENT = 'kept'; SELECT 1", + "ALTER SCHEMA demo SET DATA_RETENTION_TIME_IN_DAYS = -1; SELECT 1", + "ALTER SCHEMA demo UNSET COMMENT, DATA_RETENTION_TIME_IN_DAYS; SELECT 1", + r#"ALTER SCHEMA "db;DROP DATABASE prod"."demo" UNSET "COMMENT; --", COMMENT; SELECT 1"#, + ] { + let statements = snowflake().parse_sql_statements(sql).unwrap(); + assert_eq!(statements.len(), 2, "{sql}"); + assert_eq!(statements[1].to_string(), "SELECT 1"); + assert_eq!( + snowflake().verified_stmt(&statements[0].to_string()), + statements[0] + ); + } +} + +#[test] +fn snowflake_alter_schema_options_reject_malformed_and_complex_forms() { + use sqlparser::parser::Parser; + + for suffix in [ + "SET", + "SET; SELECT 1", + "SET ()", + "SET (COMMENT = 'x')", + "SET , COMMENT = 'x'", + "SET COMMENT", + "SET COMMENT 'missing equals'", + "SET COMMENT =", + "SET COMMENT =; SELECT 1", + "SET COMMENT = = 'extra equals'", + "SET COMMENT = 'unterminated", + "SET COMMENT = 'x',", + "SET COMMENT = 'x',; SELECT 1", + "SET COMMENT = 'x',, COMMENT = 'y'", + "SET COMMENT = 'x' COMMENT =", + "SET COMMENT = DATA_RETENTION_TIME_IN_DAYS = 1", + "SET COMMENT = 'x'COMMENT = 'y'", + "SET COMMENT = 1COMMENT = 'y'", + "SET COMMENT = 'x' SELECT 1", + "SET COMMENT = 'x' CLONE other", + "SET COMMENT = 'x' CREATE SCHEMA other", + "SET COMMENT = 'x', UNSET COMMENT", + "SET COMMENT = ?", + "SET COMMENT = :value", + "SET COMMENT = @value", + "SET COMMENT = +?", + "SET 'COMMENT' = 'x'", + "SET COMMENT = DEFAULT", + "SET COMMENT = NOT true", + "SET COMMENT = [1]", + "SET COMMENT = {'a': 1}", + "SET COMMENT = CAST(1 AS INT)", + "SET DATA_RETENTION_TIME_IN_DAYS = +", + "SET DATA_RETENTION_TIME_IN_DAYS = ++1", + "SET DATA_RETENTION_TIME_IN_DAYS = -NULL", + "SET DATA_RETENTION_TIME_IN_DAYS = -'1'", + "SET DATA_RETENTION_TIME_IN_DAYS = 1.5.2", + "SET DATA_RETENTION_TIME_IN_DAYS = (1)", + "SET DATA_RETENTION_TIME_IN_DAYS = 1 + 1", + "SET COMMENT = 'a' || 'b'", + "SET COMMENT = concat('a', 'b')", + "SET COMMENT = (SELECT 'x')", + "SET TAG t = 'v'", + "SET TAG = 'v'", + "SET COMMENT = 'x', TAG t = 'v'", + "SET CONTACT STEWARD = person", + "SET CONTACT = 'person'", + "SET COMMENT = 'x' CONTACT STEWARD = person", + "UNSET", + "UNSET; SELECT 1", + "UNSET ()", + "UNSET (COMMENT)", + "UNSET , COMMENT", + "UNSET COMMENT,", + "UNSET COMMENT,; SELECT 1", + "UNSET COMMENT,, DATA_RETENTION_TIME_IN_DAYS", + "UNSET COMMENT DATA_RETENTION_TIME_IN_DAYS", + "UNSET COMMENT = 'x'", + "UNSET COMMENT, DATA_RETENTION_TIME_IN_DAYS = 1", + "UNSET COMMENT SET COMMENT = 'x'", + "UNSET 'COMMENT'", + "UNSET 1", + "UNSET TAG", + "UNSET TAG t", + "UNSET COMMENT, TAG", + "UNSET COMMENT, TAG t", + "UNSET CONTACT", + "UNSET COMMENT, CONTACT", + "UNSET CONTACT STEWARD", + "UNSET db.demo.COMMENT", + "SWAP WITH other", + "ENABLE MANAGED ACCESS", + "DISABLE MANAGED ACCESS", + "CLONE other", + ] { + let sql = format!("ALTER SCHEMA demo {suffix}"); + for trailing_commas in [false, true] { + let result = Parser::new(&SnowflakeDialect) + .with_options(ParserOptions::new().with_trailing_commas(trailing_commas)) + .try_with_sql(&sql) + .and_then(|mut parser| parser.parse_statements()); + assert!(result.is_err(), "trailing_commas={trailing_commas}: {sql}"); + } + } +} + +#[test] +fn snowflake_alter_schema_options_are_dialect_gated_and_preserve_existing_forms() { + use sqlparser::parser::Parser; + + for dialect in all_dialects().dialects { + assert_eq!( + dialect.supports_alter_schema_options(), + dialect.is::(), + "{dialect:?}" + ); + for sql in [ + "ALTER SCHEMA demo SET COMMENT = 'x'", + "ALTER SCHEMA demo SET DATA_RETENTION_TIME_IN_DAYS = 1", + "ALTER SCHEMA demo UNSET COMMENT", + ] { + let result = Parser::parse_sql(&*dialect, sql); + assert_eq!( + result.is_ok(), + dialect.is::(), + "{dialect:?}: {sql}" + ); + } + } + for sql in [ + "ALTER SCHEMA demo SET OPTIONS (description = 'kept')", + "ALTER SCHEMA IF EXISTS db.demo SET DEFAULT COLLATE 'und:ci'", + "ALTER SCHEMA demo ADD REPLICA replica OPTIONS (location = 'US')", + "ALTER SCHEMA demo DROP REPLICA replica", + "ALTER SCHEMA demo RENAME TO other", + "ALTER SCHEMA demo OWNER TO owner", + ] { + all_dialects().verified_stmt(sql); + } +} + +#[test] +fn snowflake_alter_schema_options_preserve_typed_ast_and_clone() { + let statement = snowflake().verified_stmt( + "ALTER SCHEMA IF EXISTS db.demo SET COMMENT = NULL DATA_RETENTION_TIME_IN_DAYS = -1 COMMENT = 42 FLAG = true COMMENT = 'invalid'", + ); + assert_eq!( + statement, + Statement::AlterSchema(AlterSchema { + name: ObjectName::from(vec![Ident::new("db"), Ident::new("demo")]), + if_exists: true, + operations: vec![AlterSchemaOperation::SetOptions { + options: vec![ + SqlOption::KeyValue { + key: Ident::new("COMMENT"), + value: Expr::Value(Value::Null.into()), + }, + SqlOption::KeyValue { + key: Ident::new("DATA_RETENTION_TIME_IN_DAYS"), + value: Expr::UnaryOp { + op: UnaryOperator::Minus, + expr: Box::new(Expr::Value(number("1").into())), + }, + }, + SqlOption::KeyValue { + key: Ident::new("COMMENT"), + value: Expr::Value(number("42").into()), + }, + SqlOption::KeyValue { + key: Ident::new("FLAG"), + value: Expr::Value(Value::Boolean(true).into()), + }, + SqlOption::KeyValue { + key: Ident::new("COMMENT"), + value: Expr::Value(Value::SingleQuotedString("invalid".into()).into()), + }, + ], + }], + }) + ); + assert_eq!(statement.clone(), statement); + + assert_eq!( + snowflake() + .verified_stmt("ALTER SCHEMA demo UNSET COMMENT, COMMENT, DATA_RETENTION_TIME_IN_DAYS"), + Statement::AlterSchema(AlterSchema { + name: ObjectName::from(vec![Ident::new("demo")]), + if_exists: false, + operations: vec![AlterSchemaOperation::UnsetOptions { + options: vec![ + Ident::new("COMMENT"), + Ident::new("COMMENT"), + Ident::new("DATA_RETENTION_TIME_IN_DAYS"), + ], + }], + }) + ); +} + +#[test] +fn snowflake_alter_schema_options_capability_can_be_enabled_independently() { + use sqlparser::parser::Parser; + + #[derive(Debug)] + struct SchemaOptionsDialect; + impl Dialect for SchemaOptionsDialect { + fn is_identifier_start(&self, ch: char) -> bool { + ch.is_ascii_alphabetic() || ch == '_' + } + + fn is_identifier_part(&self, ch: char) -> bool { + self.is_identifier_start(ch) || ch.is_ascii_digit() + } + + fn supports_alter_schema_options(&self) -> bool { + true + } + } + + for sql in [ + "ALTER SCHEMA demo SET COMMENT = 'x', DATA_RETENTION_TIME_IN_DAYS = -1", + "ALTER SCHEMA demo UNSET COMMENT, DATA_RETENTION_TIME_IN_DAYS", + ] { + assert_eq!( + Parser::parse_sql(&SchemaOptionsDialect, sql).unwrap(), + Parser::parse_sql(&SnowflakeDialect, sql).unwrap() + ); + } +} + +#[test] +fn snowflake_alter_schema_options_spans_and_unconsumed_delimiters() { + use sqlparser::parser::Parser; + + for (sql, operation_span, statement_span) in [ + ( + r#"ALTER SCHEMA db.demo SET + COMMENT = 'kept', + FLAG = true"#, + Span::new(Location::new(2, 3), Location::new(3, 14)), + Span::new(Location::new(1, 14), Location::new(3, 14)), + ), + ( + r#"ALTER SCHEMA db.demo UNSET + COMMENT, + FLAG"#, + Span::new(Location::new(2, 2), Location::new(3, 6)), + Span::new(Location::new(1, 14), Location::new(3, 6)), + ), + ] { + let statements = Parser::parse_sql(&SnowflakeDialect, sql).unwrap(); + let Statement::AlterSchema(schema) = &statements[0] else { + panic!("expected ALTER SCHEMA"); + }; + assert_eq!(schema.operations[0].span(), operation_span); + assert_eq!(schema.span(), statement_span); + } + + for suffix in [ + "SET COMMENT = 'x'", + "UNSET COMMENT", + "SET", + "SET COMMENT", + "SET COMMENT =", + "SET COMMENT = 'x',", + "SET COMMENT = 'x' COMMENT", + "UNSET", + "UNSET COMMENT,", + ] { + let sql = format!("ALTER SCHEMA demo {suffix}; SELECT 1"); + let mut parser = Parser::new(&SnowflakeDialect).try_with_sql(&sql).unwrap(); + let result = parser.parse_alter_schema(); + assert_eq!( + result.is_ok(), + matches!(suffix, "SET COMMENT = 'x'" | "UNSET COMMENT") + ); + assert_eq!(parser.peek_token_ref().token, Token::SemiColon, "{sql}"); + } +} + +#[test] +fn snowflake_alter_schema_options_injection_text_is_literal_data() { + let sql = r#"ALTER SCHEMA "db;DROP DATABASE prod"."demo" SET COMMENT = 'x''; DROP SCHEMA prod; -- /* */', COMMENT = $$x'; DROP SCHEMA prod; -- /* */$$; SELECT 1"#; + let statements = snowflake().parse_sql_statements(sql).unwrap(); + assert_eq!(statements.len(), 2); + let Statement::AlterSchema(schema) = &statements[0] else { + panic!("expected ALTER SCHEMA"); + }; + assert_eq!( + schema.name, + ObjectName::from(vec![ + Ident::with_quote('"', "db;DROP DATABASE prod"), + Ident::with_quote('"', "demo"), + ]) + ); + let [AlterSchemaOperation::SetOptions { options }] = schema.operations.as_slice() else { + panic!("expected native SET"); + }; + let [SqlOption::KeyValue { + value: Expr::Value(first), + .. + }, SqlOption::KeyValue { + value: Expr::Value(second), + .. + }] = options.as_slice() + else { + panic!("expected two literal assignments"); + }; + assert_eq!( + first.value, + Value::SingleQuotedString("x'; DROP SCHEMA prod; -- /* */".into()) + ); + let Value::DollarQuotedString(value) = &second.value else { + panic!("expected dollar-quoted literal"); + }; + assert_eq!(value.value, "x'; DROP SCHEMA prod; -- /* */"); + assert_eq!(statements[1].to_string(), "SELECT 1"); +} + +#[cfg(all(feature = "serde", feature = "serde_json"))] +#[test] +fn snowflake_alter_schema_options_serde_round_trip() { + for (sql, variant) in [ + ( + "ALTER SCHEMA demo SET COMMENT = NULL DATA_RETENTION_TIME_IN_DAYS = -1 COMMENT = 'kept'", + "SetOptions", + ), + ("ALTER SCHEMA demo UNSET COMMENT, COMMENT", "UnsetOptions"), + ( + "ALTER SCHEMA demo SET OPTIONS (description = 'kept')", + "SetOptionsParens", + ), + ] { + let statement = snowflake().verified_stmt(sql); + let encoded = serde_json::to_value(&statement).unwrap(); + assert!(encoded["AlterSchema"]["operations"][0][variant]["options"].is_array()); + assert_eq!(serde_json::from_value::(encoded).unwrap(), statement); + } +} + +#[cfg(feature = "visitor")] +#[test] +fn snowflake_alter_schema_options_visitors_do_not_treat_names_as_relations() { + use core::ops::ControlFlow; + use sqlparser::ast::{visit_expressions, visit_expressions_mut, visit_relations}; + + let mut statement = snowflake().verified_stmt( + "ALTER SCHEMA db.demo SET COMMENT = 'kept' DATA_RETENTION_TIME_IN_DAYS = -1", + ); + let mut expressions = Vec::new(); + let result = visit_expressions(&statement, |expr| { + expressions.push(expr.to_string()); + ControlFlow::<()>::Continue(()) + }); + assert_eq!(result, ControlFlow::Continue(())); + assert_eq!(expressions, vec!["'kept'", "-1", "1"]); + let result = visit_expressions_mut(&mut statement, |expr| { + if let Expr::Value(ValueWithSpan { + value: Value::SingleQuotedString(value), + .. + }) = expr + { + *value = "changed".into(); + } + ControlFlow::<()>::Continue(()) + }); + assert_eq!(result, ControlFlow::Continue(())); + assert_eq!( + statement.to_string(), + "ALTER SCHEMA db.demo SET COMMENT = 'changed' DATA_RETENTION_TIME_IN_DAYS = -1" + ); + + let unset = snowflake().verified_stmt("ALTER SCHEMA db.demo UNSET COMMENT, COMMENT"); + let result = visit_expressions(&unset, |_| ControlFlow::Break("unexpected expression")); + assert_eq!(result, ControlFlow::Continue(())); + for statement in [statement, unset] { + let result = visit_relations(&statement, |_| ControlFlow::Break("unexpected relation")); + assert_eq!(result, ControlFlow::Continue(())); + } +} + +#[test] +fn snowflake_alter_database_comment_fixtures() { + for (sql, name, if_exists, operation) in [ + ( + "ALTER DATABASE DB_META SET COMMENT = 'database metadata'", + "DB_META", + false, + AlterDatabaseOperation::SetOptions { + options: vec![SqlOption::KeyValue { + key: Ident::new("COMMENT"), + value: Expr::Value( + Value::SingleQuotedString("database metadata".into()).into(), + ), + }], + }, + ), + ( + "ALTER DATABASE DB_META UNSET COMMENT", + "DB_META", + false, + AlterDatabaseOperation::UnsetOptions { + options: vec![Ident::new("COMMENT")], + }, + ), + ( + "ALTER DATABASE IF EXISTS DB_META_MISSING SET COMMENT = 'missing'", + "DB_META_MISSING", + true, + AlterDatabaseOperation::SetOptions { + options: vec![SqlOption::KeyValue { + key: Ident::new("COMMENT"), + value: Expr::Value(Value::SingleQuotedString("missing".into()).into()), + }], + }, + ), + ( + "ALTER DATABASE IF EXISTS DB_META_MISSING UNSET COMMENT", + "DB_META_MISSING", + true, + AlterDatabaseOperation::UnsetOptions { + options: vec![Ident::new("COMMENT")], + }, + ), + ] { + let statement = snowflake().verified_stmt(sql); + let expected = AlterDatabase { + name: ObjectName::from(vec![Ident::new(name)]), + if_exists, + operations: vec![operation], + }; + assert_eq!(statement, Statement::AlterDatabase(expected.clone())); + assert_eq!(Statement::from(expected), statement.clone()); + } +} + +#[test] +fn snowflake_alter_database_options_preserve_primitive_literals_and_duplicates() { + for value in [ + "NULL", + "true", + "false", + "-1", + "+1", + "1.5", + "999999999999999999999999999999", + "''", + "'invalid'", + "$$opaque; -- /* */$$", + ] { + snowflake().verified_stmt(&format!("ALTER DATABASE DB_META SET COMMENT = {value}")); + } + snowflake().one_statement_parses_to( + "ALTER DATABASE DB_META SET COMMENT = TRUE DATA_RETENTION_TIME_IN_DAYS = 1e3", + &format!( + "ALTER DATABASE DB_META SET COMMENT = true DATA_RETENTION_TIME_IN_DAYS = {}", + number("1e3") + ), + ); + let Statement::AlterDatabase(database) = snowflake().one_statement_parses_to( + r#"ALTER /* object */ DATABASE IF EXISTS "DB_META" SET + COMMENT /* key */ = NULL, + DATA_RETENTION_TIME_IN_DAYS = -- value + -1 COMMENT='x'/* separator */COMMENT = 42;"#, + r#"ALTER DATABASE IF EXISTS "DB_META" SET COMMENT = NULL DATA_RETENTION_TIME_IN_DAYS = -1 COMMENT = 'x' COMMENT = 42"#, + ) else { + panic!("expected ALTER DATABASE"); + }; + assert_eq!( + database.operations, + vec![AlterDatabaseOperation::SetOptions { + options: vec![ + SqlOption::KeyValue { + key: Ident::new("COMMENT"), + value: Expr::Value(Value::Null.into()) + }, + SqlOption::KeyValue { + key: Ident::new("DATA_RETENTION_TIME_IN_DAYS"), + value: Expr::UnaryOp { + op: UnaryOperator::Minus, + expr: Box::new(Expr::Value(number("1").into())), + } + }, + SqlOption::KeyValue { + key: Ident::new("COMMENT"), + value: Expr::Value(Value::SingleQuotedString("x".into()).into()) + }, + SqlOption::KeyValue { + key: Ident::new("COMMENT"), + value: Expr::Value(number("42").into()) + }, + ], + }] + ); + snowflake() + .verified_stmt(r#"ALTER DATABASE "DB_META" SET "COMMENT" = 'x' FUTURE_PROPERTY = false"#); + let Statement::AlterDatabase(database) = snowflake().one_statement_parses_to( + r#"ALTER DATABASE DB_META UNSET COMMENT /* comma */, -- property + DATA_RETENTION_TIME_IN_DAYS, COMMENT;"#, + "ALTER DATABASE DB_META UNSET COMMENT, DATA_RETENTION_TIME_IN_DAYS, COMMENT", + ) else { + panic!("expected ALTER DATABASE"); + }; + assert_eq!( + database.operations, + vec![AlterDatabaseOperation::UnsetOptions { + options: vec![ + Ident::new("COMMENT"), + Ident::new("DATA_RETENTION_TIME_IN_DAYS"), + Ident::new("COMMENT") + ], + }] + ); +} + +#[test] +fn snowflake_alter_database_options_reject_malformed_and_complex_forms() { + use sqlparser::parser::Parser; + + for suffix in [ + "", + "SET", + "SET; SELECT 1", + "SET ()", + "SET (COMMENT = 'x')", + "SET OPTIONS (COMMENT = 'x')", + "SET , COMMENT = 'x'", + "SET COMMENT", + "SET COMMENT 'missing equals'", + "SET COMMENT =", + "SET COMMENT =; SELECT 1", + "SET COMMENT = = 'x'", + "SET COMMENT = 'unterminated", + "SET COMMENT = 'x',", + "SET COMMENT = 'x',; SELECT 1", + "SET COMMENT = 'x',, COMMENT = 'y'", + "SET COMMENT = 'x'COMMENT = 'y'", + "SET COMMENT = 1COMMENT = 'y'", + "SET COMMENT = 'x' SELECT 1", + "SET COMMENT = 'x' UNSET COMMENT", + "SET COMMENT = 'x', UNSET COMMENT", + "SET COMMENT = concat('a', 'b')", + "SET COMMENT = (SELECT 'x')", + "SET COMMENT = 'a' || 'b'", + "SET COMMENT = 1 + 1", + "SET COMMENT = (1)", + "SET COMMENT = [1]", + "SET COMMENT = DEFAULT", + "SET COMMENT = ?", + "SET COMMENT = :value", + "SET COMMENT = @value", + "SET COMMENT = +?", + "SET COMMENT = +", + "SET COMMENT = -'1'", + "SET COMMENT = -NULL", + "SET 'COMMENT' = 'x'", + "SET TAG t = 'v'", + "SET TAG = 'v'", + "SET COMMENT = 'x', TAG t = 'v'", + "SET CONTACT STEWARD = person", + "SET COMMENT = 'x' CONTACT STEWARD = person", + "SET FEATURE POLICY policy_name", + "UNSET", + "UNSET; SELECT 1", + "UNSET ()", + "UNSET (COMMENT)", + "UNSET , COMMENT", + "UNSET COMMENT,", + "UNSET COMMENT,; SELECT 1", + "UNSET COMMENT,, COMMENT", + "UNSET COMMENT DATA_RETENTION_TIME_IN_DAYS", + "UNSET COMMENT = 'x'", + "UNSET COMMENT, DATA_RETENTION_TIME_IN_DAYS = 1", + "UNSET 'COMMENT'", + "UNSET 1", + "UNSET TAG", + "UNSET COMMENT, TAG t", + "UNSET CONTACT", + "UNSET CONTACT STEWARD", + "UNSET FEATURE POLICY", + "SWAP WITH other", + "RENAME TO other", + "REFRESH", + "ENABLE REPLICATION TO ACCOUNTS other", + "DISABLE REPLICATION", + "PRIMARY", + "SET COMMENT = 'x' CLONE other", + "SET COMMENT = 'x' RENAME TO other", + ] { + let sql = format!("ALTER DATABASE DB_META {suffix}"); + for trailing_commas in [false, true] { + let result = Parser::new(&SnowflakeDialect) + .with_options(ParserOptions::new().with_trailing_commas(trailing_commas)) + .try_with_sql(&sql) + .and_then(|mut parser| parser.parse_statements()); + assert!(result.is_err(), "trailing_commas={trailing_commas}: {sql}"); + } + } + for sql in [ + "ALTER DATABASE IF EXISTS SET COMMENT = 'x'", + "ALTER DATABASE IF NOT EXISTS DB_META UNSET COMMENT", + ] { + assert!(snowflake().parse_sql_statements(sql).is_err(), "{sql}"); + } +} + +#[test] +fn snowflake_alter_database_options_are_dialect_gated() { + use sqlparser::parser::Parser; + + for dialect in all_dialects().dialects { + assert_eq!( + dialect.supports_alter_database_options(), + dialect.is::() + ); + for sql in [ + "ALTER DATABASE DB_META SET COMMENT = 'x'", + "ALTER DATABASE IF EXISTS DB_META_MISSING UNSET COMMENT", + ] { + assert_eq!( + Parser::parse_sql(&*dialect, sql).is_ok(), + dialect.is::(), + "{dialect:?}: {sql}" + ); + let mut parser = Parser::new(&*dialect).try_with_sql(sql).unwrap(); + assert_eq!( + parser.parse_alter_database().is_ok(), + dialect.is::(), + "direct parser: {dialect:?}: {sql}" + ); + } + } + + #[derive(Debug)] + struct DatabaseOptionsDialect; + impl Dialect for DatabaseOptionsDialect { + fn is_identifier_start(&self, ch: char) -> bool { + ch.is_ascii_alphabetic() || ch == '_' + } + fn is_identifier_part(&self, ch: char) -> bool { + self.is_identifier_start(ch) || ch.is_ascii_digit() + } + fn supports_alter_database_options(&self) -> bool { + true + } + } + assert!(!DatabaseOptionsDialect.supports_alter_schema_options()); + let sql = "ALTER DATABASE DB_META SET COMMENT = 'x', DATA_RETENTION_TIME_IN_DAYS = -1"; + assert_eq!( + Parser::parse_sql(&DatabaseOptionsDialect, sql).unwrap(), + Parser::parse_sql(&SnowflakeDialect, sql).unwrap() + ); +} + +#[test] +fn snowflake_alter_database_options_preserve_database_and_schema_boundaries() { + let statements = snowflake().parse_sql_statements( + r#"ALTER DATABASE "DB_META; DROP DATABASE prod" SET COMMENT = 'x''; DROP DATABASE prod; -- /* */'; + ALTER SCHEMA DB_META.PUBLIC SET COMMENT = $$x'; -- /* */$$; + ALTER DATABASE DB_META UNSET COMMENT; + ALTER SCHEMA DB_META.PUBLIC UNSET COMMENT; + SELECT status FROM status_log;"#, + ).unwrap(); + assert_eq!(statements.len(), 5); + let Statement::AlterDatabase(database) = &statements[0] else { + panic!("expected ALTER DATABASE"); + }; + assert_eq!( + database.name, + ObjectName::from(vec![Ident::with_quote('"', "DB_META; DROP DATABASE prod")]) + ); + assert_eq!( + database.operations, + vec![AlterDatabaseOperation::SetOptions { + options: vec![SqlOption::KeyValue { + key: Ident::new("COMMENT"), + value: Expr::Value( + Value::SingleQuotedString("x'; DROP DATABASE prod; -- /* */".into()).into() + ) + }], + }] + ); + assert!(matches!(&statements[1], Statement::AlterSchema(_))); + assert!(matches!(&statements[2], Statement::AlterDatabase(_))); + assert!(matches!(&statements[3], Statement::AlterSchema(_))); + assert_eq!(statements[4].to_string(), "SELECT status FROM status_log"); + for statement in statements { + assert_eq!(snowflake().verified_stmt(&statement.to_string()), statement); + } + all_dialects().verified_stmt("ALTER SCHEMA DB_META.PUBLIC SET OPTIONS (description = 'kept')"); +} + +#[test] +fn snowflake_alter_database_options_spans_and_unconsumed_delimiters() { + use sqlparser::parser::Parser; + + for (sql, operation_span, statement_span) in [ + ( + r#"ALTER DATABASE DB_META SET + COMMENT = 'kept', + FLAG = true"#, + Span::new(Location::new(2, 2), Location::new(3, 13)), + Span::new(Location::new(1, 16), Location::new(3, 13)), + ), + ( + r#"ALTER DATABASE DB_META UNSET + COMMENT, + FLAG"#, + Span::new(Location::new(2, 2), Location::new(3, 6)), + Span::new(Location::new(1, 16), Location::new(3, 6)), + ), + ] { + let statements = Parser::parse_sql(&SnowflakeDialect, sql).unwrap(); + let Statement::AlterDatabase(database) = &statements[0] else { + panic!("expected ALTER DATABASE"); + }; + assert_eq!(database.operations[0].span(), operation_span); + assert_eq!(database.span(), statement_span); + assert_eq!(statements[0].span(), statement_span); + } + for suffix in [ + "SET COMMENT = 'x'", + "UNSET COMMENT", + "SET", + "SET COMMENT", + "SET COMMENT =", + "SET COMMENT = 'x',", + "UNSET", + "UNSET COMMENT,", + ] { + let sql = format!("ALTER DATABASE DB_META {suffix}; SELECT 1"); + let mut parser = Parser::new(&SnowflakeDialect).try_with_sql(&sql).unwrap(); + assert_eq!( + parser.parse_alter_database().is_ok(), + matches!(suffix, "SET COMMENT = 'x'" | "UNSET COMMENT") + ); + assert_eq!(parser.peek_token_ref().token, Token::SemiColon, "{sql}"); + } +} + +#[cfg(all(feature = "serde", feature = "serde_json"))] +#[test] +fn snowflake_alter_database_options_serde_round_trip() { + for (sql, variant) in [ + ( + "ALTER DATABASE IF EXISTS DB_META SET COMMENT = NULL COMMENT = 'kept'", + "SetOptions", + ), + ( + "ALTER DATABASE DB_META UNSET COMMENT, COMMENT", + "UnsetOptions", + ), + ] { + let statement = snowflake().verified_stmt(sql); + let encoded = serde_json::to_value(&statement).unwrap(); + assert!(encoded["AlterDatabase"]["operations"][0][variant]["options"].is_array()); + assert_eq!( + serde_json::from_value::(encoded).unwrap(), + statement + ); + } +} + +#[cfg(feature = "visitor")] +#[test] +fn snowflake_alter_database_options_visitors_do_not_treat_names_as_relations() { + use core::ops::ControlFlow; + use sqlparser::ast::{visit_expressions, visit_expressions_mut, visit_relations}; + + let mut statement = snowflake().verified_stmt( + "ALTER DATABASE DB_META SET COMMENT = 'kept' DATA_RETENTION_TIME_IN_DAYS = -1", + ); + let mut expressions = Vec::new(); + let result = visit_expressions(&statement, |expr| { + expressions.push(expr.to_string()); + ControlFlow::<()>::Continue(()) + }); + assert_eq!(result, ControlFlow::Continue(())); + assert_eq!(expressions, vec!["'kept'", "-1", "1"]); + let result = visit_expressions_mut(&mut statement, |expr| { + if let Expr::Value(ValueWithSpan { + value: Value::SingleQuotedString(value), + .. + }) = expr + { + *value = "changed".into(); + } + ControlFlow::<()>::Continue(()) + }); + assert_eq!(result, ControlFlow::Continue(())); + assert_eq!( + statement.to_string(), + "ALTER DATABASE DB_META SET COMMENT = 'changed' DATA_RETENTION_TIME_IN_DAYS = -1" + ); + let unset = snowflake().verified_stmt("ALTER DATABASE DB_META UNSET COMMENT, COMMENT"); + assert_eq!( + visit_expressions(&unset, |_| ControlFlow::Break("unexpected expression")), + ControlFlow::Continue(()) + ); + for statement in [statement, unset] { + assert_eq!( + visit_relations(&statement, |_| ControlFlow::Break("unexpected relation")), + ControlFlow::Continue(()) + ); + } +} + +#[test] +fn snowflake_create_schema_modifiers_round_trip_and_ast() { + use sqlparser::keywords::Keyword; + use sqlparser::parser::Parser; + + for or_replace in [false, true] { + for transient in [false, true] { + for with_managed_access in [false, true] { + for if_not_exists in [false, true] { + for clone in [false, true] { + let sql = format!( + "CREATE {}{}SCHEMA {}db.demo{}{} COMMENT = 'kept' DATA_RETENTION_TIME_IN_DAYS = 0 COMMENT = NULL", + if or_replace { "OR REPLACE " } else { "" }, + if transient { "TRANSIENT " } else { "" }, + if if_not_exists { "IF NOT EXISTS " } else { "" }, + if clone { " CLONE source.demo" } else { "" }, + if with_managed_access { " WITH MANAGED ACCESS" } else { "" }, + ); + let statement = snowflake_and_generic().verified_stmt(&sql); + assert_eq!( + statement, + Statement::CreateSchema { + or_replace, + transient, + schema_name: SchemaName::Simple(ObjectName::from(vec![ + Ident::new("db"), + Ident::new("demo"), + ])), + if_not_exists, + with: None, + options: None, + default_collate_spec: None, + clone: clone.then(|| ObjectName::from(vec![ + Ident::new("source"), + Ident::new("demo"), + ])), + with_managed_access, + schema_options: vec![ + SqlOption::KeyValue { + key: Ident::new("COMMENT"), + value: Expr::Value( + Value::SingleQuotedString("kept".into()).into() + ), + }, + SqlOption::KeyValue { + key: Ident::new("DATA_RETENTION_TIME_IN_DAYS"), + value: Expr::Value( + Value::Number("0".parse().unwrap(), false).into() + ), + }, + SqlOption::KeyValue { + key: Ident::new("COMMENT"), + value: Expr::Value(Value::Null.into()), + }, + ], + } + ); + assert_eq!( + snowflake_and_generic().verified_stmt(&statement.to_string()), + statement + ); + for dialect in [&SnowflakeDialect as &dyn Dialect, &GenericDialect] { + let mut parser = Parser::new(dialect).try_with_sql(&sql).unwrap(); + parser.expect_keyword_is(Keyword::CREATE).unwrap(); + assert_eq!( + parser.parse_create().unwrap(), + statement, + "{dialect:?}: {sql}" + ); + assert_eq!(parser.peek_token_ref().token, Token::EOF); + } + } + } + } + } + } +} + +#[test] +fn snowflake_create_schema_modifiers_tokens_and_quoted_names() { + use sqlparser::parser::Parser; + + let sql = r#"CREATE OR REPLACE TRANSIENT SCHEMA IF NOT EXISTS "WITH MANAGED ACCESS" WITH MANAGED ACCESS"#; + let expected = vec![ + Token::make_keyword("CREATE"), + Token::make_keyword("OR"), + Token::make_keyword("REPLACE"), + Token::make_keyword("TRANSIENT"), + Token::make_keyword("SCHEMA"), + Token::make_keyword("IF"), + Token::make_keyword("NOT"), + Token::make_keyword("EXISTS"), + Token::make_word("WITH MANAGED ACCESS", Some('"')), + Token::make_keyword("WITH"), + Token::make_keyword("MANAGED"), + Token::make_keyword("ACCESS"), + ]; + for dialect in [&SnowflakeDialect as &dyn Dialect, &GenericDialect] { + let tokens = Tokenizer::new(dialect, sql).tokenize().unwrap(); + let tokens: Vec<_> = tokens + .into_iter() + .filter(|token| !matches!(token, Token::Whitespace(_))) + .collect(); + assert_eq!(tokens, expected); + let from_tokens = Parser::new(dialect) + .with_tokens(tokens) + .parse_statements() + .unwrap(); + assert_eq!(from_tokens, Parser::parse_sql(dialect, sql).unwrap()); + assert_eq!(from_tokens[0].to_string(), sql); + assert_eq!( + Parser::parse_sql(dialect, &from_tokens[0].to_string()).unwrap(), + from_tokens + ); + } + + for name in [ + r#""TRANSIENT""#, + r#""OR REPLACE""#, + r#""WITH MANAGED ACCESS""#, + ] { + let Statement::CreateSchema { + or_replace, + transient, + with_managed_access, + .. + } = snowflake_and_generic().verified_stmt(&format!("CREATE SCHEMA {name}")) + else { + panic!("expected CREATE SCHEMA"); + }; + assert!(!or_replace && !transient && !with_managed_access); + } +} + +#[test] +fn snowflake_create_schema_modifiers_comments_and_boundaries() { + snowflake_and_generic().one_statement_parses_to( + r#" + CREATE /* prefix */ OR REPLACE TRANSIENT SCHEMA IF NOT EXISTS "db"."demo" + CLONE "source"."demo" WITH /* access */ MANAGED -- access mode + ACCESS comment = 'owner''s schema' data_retention_time_in_days = +7; + "#, + r#"CREATE OR REPLACE TRANSIENT SCHEMA IF NOT EXISTS "db"."demo" CLONE "source"."demo" WITH MANAGED ACCESS comment = 'owner''s schema' data_retention_time_in_days = +7"#, + ); + for sql in [ + "CREATE OR REPLACE SCHEMA demo; SELECT 1", + "CREATE TRANSIENT SCHEMA demo; SELECT 1", + "CREATE SCHEMA demo WITH MANAGED ACCESS; SELECT 1", + r#"CREATE OR REPLACE TRANSIENT SCHEMA "db;DROP DATABASE prod"."demo" CLONE "source"."demo" WITH MANAGED ACCESS COMMENT = 'x''; DROP SCHEMA prod; -- /* WITH MANAGED ACCESS */' DATA_RETENTION_TIME_IN_DAYS = 0; /* ; */ SELECT 1"#, + ] { + let statements = snowflake_and_generic().parse_sql_statements(sql).unwrap(); + assert_eq!(statements.len(), 2, "{sql}"); + assert_eq!(statements[1].to_string(), "SELECT 1"); + assert_eq!( + snowflake_and_generic().verified_stmt(&statements[0].to_string()), + statements[0] + ); + } +} + +#[test] +fn snowflake_create_schema_modifiers_reject_incomplete_and_trailing_syntax() { + use sqlparser::parser::Parser; + + for sql in [ + "CREATE OR", + "CREATE OR REPLACE", + "CREATE OR REPLACE TRANSIENT", + "CREATE TRANSIENT", + "CREATE TRANSIENT SCHEMA", + "CREATE OR REPLACE SCHEMA IF NOT EXISTS", + "CREATE OR REPLACE OR REPLACE SCHEMA demo", + "CREATE TRANSIENT TRANSIENT SCHEMA demo", + "CREATE TRANSIENT OR REPLACE SCHEMA demo", + "CREATE OR REPLACE OR ALTER TRANSIENT SCHEMA demo", + "CREATE SCHEMA demo WITH", + "CREATE SCHEMA demo WITH MANAGED", + "CREATE SCHEMA demo WITH ACCESS", + "CREATE SCHEMA demo MANAGED ACCESS", + "CREATE SCHEMA demo WITH MANAGED ACCESS ACCESS", + "CREATE SCHEMA demo WITH MANAGED ACCESS WITH MANAGED ACCESS", + "CREATE SCHEMA demo WITH MANAGED ACCESS CLONE source.demo", + "CREATE SCHEMA demo CLONE", + "CREATE SCHEMA demo CLONE source.demo WITH MANAGED", + "CREATE SCHEMA demo COMMENT = 'x' WITH MANAGED ACCESS", + "CREATE SCHEMA demo DATA_RETENTION_TIME_IN_DAYS = 0 WITH MANAGED ACCESS", + "CREATE TRANSIENT SCHEMA demo WITH MANAGED ACCESS COMMENT =", + "CREATE TRANSIENT SCHEMA demo WITH MANAGED ACCESS DATA_RETENTION_TIME_IN_DAYS =", + "CREATE OR REPLACE TRANSIENT SCHEMA demo WITH MANAGED ACCESS garbage", + "CREATE OR REPLACE TRANSIENT SCHEMA demo WITH MANAGED ACCESS SELECT 1", + r#"CREATE "TRANSIENT" SCHEMA demo"#, + r#"CREATE OR "REPLACE" SCHEMA demo"#, + r#"CREATE SCHEMA demo WITH "MANAGED" ACCESS"#, + r#"CREATE SCHEMA demo WITH MANAGED "ACCESS""#, + ] { + // The dialect override and shared parser may report different errors. + for dialect in [&SnowflakeDialect as &dyn Dialect, &GenericDialect] { + assert!( + Parser::parse_sql(dialect, sql).is_err(), + "{dialect:?}: {sql}" + ); + assert!( + Parser::parse_sql(dialect, &format!("{sql}; SELECT 1")).is_err(), + "{dialect:?}: {sql}" + ); + } + } +} + +#[test] +fn snowflake_create_schema_modifiers_keep_other_dialects_and_modifiers_strict() { + use sqlparser::keywords::Keyword; + use sqlparser::parser::Parser; + + for dialect in all_dialects().dialects { + if !dialect.supports_create_schema_options() { + for sql in [ + "CREATE OR REPLACE SCHEMA demo", + "CREATE TRANSIENT SCHEMA demo", + "CREATE OR REPLACE TRANSIENT SCHEMA demo", + "CREATE SCHEMA demo WITH MANAGED ACCESS", + ] { + assert!( + Parser::parse_sql(&*dialect, sql).is_err(), + "{dialect:?}: {sql}" + ); + let mut parser = Parser::new(&*dialect).try_with_sql(sql).unwrap(); + parser.expect_keyword_is(Keyword::CREATE).unwrap(); + assert!(parser.parse_create().is_err(), "{dialect:?}: {sql}"); + } + } + for modifier in [ + "OR ALTER", + "TEMP", + "TEMPORARY", + "PERSISTENT", + "SECURE", + "GLOBAL", + "LOCAL", + "GLOBAL TEMP", + "LOCAL TEMPORARY", + "DYNAMIC", + "VOLATILE", + "ICEBERG", + "OR ALTER TRANSIENT", + "TRANSIENT TEMPORARY", + "GLOBAL TRANSIENT", + "LOCAL TRANSIENT", + "ALGORITHM = UNDEFINED", + "DEFINER = owner", + "SQL SECURITY DEFINER", + ] { + let sql = format!("CREATE {modifier} SCHEMA demo"); + assert!( + Parser::parse_sql(&*dialect, &sql).is_err(), + "{dialect:?}: {sql}" + ); + let mut parser = Parser::new(&*dialect).try_with_sql(&sql).unwrap(); + parser.expect_keyword_is(Keyword::CREATE).unwrap(); + assert!(parser.parse_create().is_err(), "{dialect:?}: {sql}"); + } + } +} + +#[test] +fn snowflake_create_schema_modifiers_preserve_public_schema_parser_and_with_options() { + use sqlparser::parser::Parser; + + for body in [ + "demo", + "IF NOT EXISTS demo WITH MANAGED ACCESS COMMENT = 'kept'", + "demo CLONE source.demo WITH MANAGED ACCESS DATA_RETENTION_TIME_IN_DAYS = 0", + "demo WITH (location = '/tmp/demo')", + "demo DEFAULT COLLATE 'und:ci' OPTIONS(location = 'US')", + "demo AUTHORIZATION owner", + ] { + let sql = format!("{body}; SELECT 1"); + for dialect in [&SnowflakeDialect as &dyn Dialect, &GenericDialect] { + let mut parser = Parser::new(dialect).try_with_sql(&sql).unwrap(); + let statement = parser.parse_create_schema().unwrap(); + assert_eq!(statement.to_string(), format!("CREATE SCHEMA {body}")); + assert!(matches!( + statement, + Statement::CreateSchema { + or_replace: false, + transient: false, + .. + } + )); + assert_eq!(parser.peek_token_ref().token, Token::SemiColon); + } + snowflake_and_generic().verified_stmt(&format!("CREATE SCHEMA {body}")); + } +} + +#[cfg(all(feature = "serde", feature = "serde_json"))] +#[test] +fn snowflake_create_schema_modifiers_serde_round_trip_and_old_ast_defaults() { + let statement = snowflake_and_generic().verified_stmt( + "CREATE OR REPLACE TRANSIENT SCHEMA db.demo CLONE source.demo WITH MANAGED ACCESS COMMENT = 'kept' DATA_RETENTION_TIME_IN_DAYS = 0", + ); + let encoded = serde_json::to_value(&statement).unwrap(); + for field in ["or_replace", "transient", "with_managed_access"] { + assert_eq!(encoded["CreateSchema"][field], true); + } + assert_eq!( + serde_json::from_value::(encoded).unwrap(), + statement + ); + + let plain = snowflake_and_generic().verified_stmt("CREATE SCHEMA demo COMMENT = 'kept'"); + let mut old = serde_json::to_value(&plain).unwrap(); + for field in ["or_replace", "transient", "with_managed_access"] { + let mut missing_one = serde_json::to_value(&plain).unwrap(); + assert_eq!( + missing_one["CreateSchema"] + .as_object_mut() + .unwrap() + .remove(field), + Some(false.into()) + ); + assert_eq!( + serde_json::from_value::(missing_one).unwrap(), + plain + ); + assert_eq!( + old["CreateSchema"].as_object_mut().unwrap().remove(field), + Some(false.into()) + ); + } + assert_eq!(serde_json::from_value::(old).unwrap(), plain); +}