From 7d3a763001e7a9c71adb30818d22da3fed28e58c Mon Sep 17 00:00:00 2001 From: sabir-akhadov-localstack Date: Thu, 29 Oct 2026 15:08:35 +0100 Subject: [PATCH] Snowflake: parse DESC[RIBE] { VIEW | DATABASE | SCHEMA } Co-Authored-By: Claude Opus 5.5 (cherry picked from commit e7278099a374e7077d10817a291a3d7e14fa87e4) --- AGENTS.md | 6 ++ examples/cli.rs | 4 +- src/ast/ddl.rs | 2 +- src/ast/mod.rs | 42 ++++++++++- src/ast/query.rs | 2 +- src/ast/spans.rs | 1 + src/dialect/generic.rs | 3 + src/dialect/mod.rs | 9 +++ src/dialect/snowflake.rs | 4 + src/parser/mod.rs | 36 ++++++++- tests/sqlparser_common.rs | 146 +++++++++++++++++++++++++++++++++++- tests/sqlparser_postgres.rs | 4 +- 12 files changed, 245 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6f3d044bb6..5bc24790bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,12 @@ execution consumer to validate. Never evaluate properties, normalize account ide consume statement delimiters inside option loops, or infer runtime support from an AST. Run the required fork checks below before committing changes to this syntax. +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 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 e2a3676555..ffa59e3b0a 100644 --- a/src/ast/ddl.rs +++ b/src/ast/ddl.rs @@ -4704,7 +4704,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/mod.rs b/src/ast/mod.rs index 7261fc3338..3fd56ffd44 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -4745,6 +4745,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 { @@ -5252,6 +5258,7 @@ impl fmt::Display for Statement { write!(f, "{id}") } + Statement::DescribeObject(s) => write!(f, "{s}"), Statement::ExplainTable { describe_alias, hive_format, @@ -9086,6 +9093,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))] @@ -12174,7 +12208,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))?; @@ -12874,6 +12908,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 cca8008fc9..83b0fe4a71 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -491,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(), diff --git a/src/dialect/generic.rs b/src/dialect/generic.rs index 25f57e3d18..58c075ec07 100644 --- a/src/dialect/generic.rs +++ b/src/dialect/generic.rs @@ -312,4 +312,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 19b1ea2fb5..4a2bd234f9 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -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 diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index 39c3196456..d85a33e6ff 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -529,6 +529,10 @@ impl Dialect for SnowflakeDialect { true } + fn supports_describe_object(&self) -> bool { + true + } + fn allow_extract_custom(&self) -> bool { true } diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 4a4d2e6af3..6eff8ec3f5 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()) } } @@ -13436,7 +13436,7 @@ impl<'a> Parser<'a> { token => { return Err(ParserError::ParserError(format!( "Unexpected token in identifier: {token}" - )))?; + ))); } } } @@ -14070,6 +14070,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 +14175,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 +16897,7 @@ impl<'a> Parser<'a> { tok.token ), tok.span.start - )?; + ); } } 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_postgres.rs b/tests/sqlparser_postgres.rs index eeba53b566..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,