diff --git a/src/ast/data_type.rs b/src/ast/data_type.rs index be1acd1a7c..f8927b9c96 100644 --- a/src/ast/data_type.rs +++ b/src/ast/data_type.rs @@ -413,6 +413,8 @@ pub enum DataType { LongText, /// String with optional length. String(Option), + /// A data type with an explicit collation, as supported by Databricks. + Collate(Box, ObjectName), /// A fixed-length string e.g [ClickHouse][1]. /// /// [1]: https://clickhouse.com/docs/en/sql-reference/data-types/fixedstring @@ -708,6 +710,9 @@ impl fmt::Display for DataType { DataType::MediumText => write!(f, "MEDIUMTEXT"), DataType::LongText => write!(f, "LONGTEXT"), DataType::String(size) => format_type_with_optional_length(f, "STRING", size, false), + DataType::Collate(data_type, collation) => { + write!(f, "{data_type} COLLATE {collation}") + } DataType::Bytea => write!(f, "BYTEA"), DataType::Bit(size) => format_type_with_optional_length(f, "BIT", size, false), DataType::BitVarying(size) => { diff --git a/src/ast/ddl.rs b/src/ast/ddl.rs index 66f2cad3eb..e73355e464 100644 --- a/src/ast/ddl.rs +++ b/src/ast/ddl.rs @@ -56,6 +56,29 @@ use crate::display_utils::{DisplayCommaSeparated, Indent, NewLine, SpaceOrNewlin use crate::keywords::Keyword; use crate::tokenizer::{Span, Token}; +/// Databricks view schema adaptation mode. +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub enum ViewSchemaMode { + /// Reject queries whose schema no longer matches the view definition. + Binding, + /// Apply safe casts to preserve the view schema. + Compensation, + /// Adapt the view schema to changes in the query result. + Evolution, +} + +impl fmt::Display for ViewSchemaMode { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str(match self { + Self::Binding => "BINDING", + Self::Compensation => "COMPENSATION", + Self::Evolution => "EVOLUTION", + }) + } +} + /// Index column type. #[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] @@ -4380,6 +4403,10 @@ pub struct CreateView { pub or_alter: bool, /// The `OR REPLACE` clause is used to re-create the view if it already exists. pub or_replace: bool, + /// Databricks `OR REFRESH` clause for materialized views. + pub or_refresh: bool, + /// Databricks view schema adaptation mode. + pub schema_mode: Option, /// if true, has MATERIALIZED view modifier pub materialized: bool, /// Snowflake: SECURE view modifier @@ -4434,6 +4461,9 @@ impl fmt::Display for CreateView { or_alter = if self.or_alter { "OR ALTER " } else { "" }, or_replace = if self.or_replace { "OR REPLACE " } else { "" }, )?; + if self.or_refresh { + f.write_str("OR REFRESH ")?; + } if let Some(ref params) = self.params { params.fmt(f)?; } @@ -4474,6 +4504,12 @@ impl fmt::Display for CreateView { if let Some(ref comment) = self.comment { write!(f, " COMMENT = '{}'", escape_single_quote_string(comment))?; } + if matches!(self.options, CreateTableOptions::TableProperties(_)) { + write!(f, " {}", self.options)?; + } + if let Some(schema_mode) = &self.schema_mode { + write!(f, " WITH SCHEMA {schema_mode}")?; + } if !self.cluster_by.is_empty() { write!( f, diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 9077bed25f..20e12773f7 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -85,7 +85,7 @@ pub use self::ddl::{ ReplicaIdentity, TagsColumnOption, TextSearchObjectType, TriggerObjectKind, Truncate, UserDefinedTypeCompositeAttributeDef, UserDefinedTypeInternalLength, UserDefinedTypeRangeOption, UserDefinedTypeRepresentation, UserDefinedTypeSqlDefinitionOption, - UserDefinedTypeStorage, ViewColumnDef, WithData, + UserDefinedTypeStorage, ViewColumnDef, ViewSchemaMode, WithData, }; pub use self::dml::{ Delete, Insert, Merge, MergeAction, MergeClause, MergeClauseKind, MergeInsertExpr, diff --git a/src/dialect/databricks.rs b/src/dialect/databricks.rs index 00ab6e9414..50ec35c9e2 100644 --- a/src/dialect/databricks.rs +++ b/src/dialect/databricks.rs @@ -114,6 +114,22 @@ impl Dialect for DatabricksDialect { true } + fn supports_typed_view_columns(&self) -> bool { + true + } + + fn supports_create_view_comment_without_equals(&self) -> bool { + true + } + + fn supports_create_view_comment_syntax(&self) -> bool { + true + } + + fn supports_data_type_collation(&self) -> bool { + true + } + fn supports_map_literal_with_angle_brackets(&self) -> bool { true } @@ -122,6 +138,22 @@ impl Dialect for DatabricksDialect { true } + fn supports_create_view_table_properties(&self) -> bool { + true + } + + fn supports_create_or_refresh(&self) -> bool { + true + } + + fn supports_multipart_table_query_name(&self) -> bool { + true + } + + fn supports_create_view_schema_mode(&self) -> bool { + true + } + fn supports_select_wildcard_replace(&self) -> bool { true } diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index ff83a4da61..bd038b006d 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -1070,6 +1070,51 @@ pub trait Dialect: Debug + Any { false } + /// Returns true if `CREATE VIEW` accepts `COMMENT ''` without an + /// equals sign. + fn supports_create_view_comment_without_equals(&self) -> bool { + false + } + + /// Returns true if this dialect supports typed column definitions in + /// `CREATE VIEW`, followed by ordinary column options. + /// + /// Example: + /// ```sql + /// CREATE MATERIALIZED VIEW v (id BIGINT COMMENT 'identifier') AS SELECT 1; + /// ``` + fn supports_typed_view_columns(&self) -> bool { + false + } + + /// Returns true if `CREATE VIEW` accepts a `TBLPROPERTIES` clause. + fn supports_create_view_table_properties(&self) -> bool { + false + } + + /// Returns true if the dialect supports `CREATE OR REFRESH` for + /// materialized views and streaming tables. + fn supports_create_or_refresh(&self) -> bool { + false + } + + /// Returns true if `TABLE` queries accept names with more than two parts. + fn supports_multipart_table_query_name(&self) -> bool { + false + } + + /// Returns true if views accept `WITH SCHEMA BINDING`, `WITH SCHEMA + /// COMPENSATION`, or `WITH SCHEMA EVOLUTION` before `AS`. + fn supports_create_view_schema_mode(&self) -> bool { + false + } + + /// Returns true if a data type can carry a collation, including inside a + /// nested type such as `MAP`. + fn supports_data_type_collation(&self) -> bool { + false + } + /// Returns true if this dialect supports the `ARRAY` type without /// specifying an element type. /// diff --git a/src/keywords.rs b/src/keywords.rs index 0c50703c3f..20a684a622 100644 --- a/src/keywords.rs +++ b/src/keywords.rs @@ -241,6 +241,7 @@ define_keywords!( COMMITTED, COMMUTATOR, COMPATIBLE, + COMPENSATION, COMPRESS, COMPRESSION, COMPUPDATE, @@ -392,6 +393,7 @@ define_keywords!( EVEN, EVENT, EVERY, + EVOLUTION, EVOLVE, EXCEPT, EXCEPTION, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 5edc437145..0607dbb709 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -3644,7 +3644,7 @@ impl<'a> Parser<'a> { None }; - let (field_type, trailing_bracket) = self.parse_data_type_helper()?; + let (field_type, trailing_bracket) = self.parse_data_type_with_optional_collation()?; let options = self.maybe_parse_options(Keyword::OPTIONS)?; Ok(( @@ -5239,6 +5239,9 @@ impl<'a> Parser<'a> { /// Parse a SQL CREATE statement pub fn parse_create(&mut self) -> Result { let or_replace = self.parse_keywords(&[Keyword::OR, Keyword::REPLACE]); + let or_refresh = !or_replace + && self.dialect.supports_create_or_refresh() + && self.parse_keywords(&[Keyword::OR, Keyword::REFRESH]); let or_alter = self.parse_keywords(&[Keyword::OR, Keyword::ALTER]); let multiset = self.maybe_parse_multiset(); let local = self.parse_one_of_keywords(&[Keyword::LOCAL]).is_some(); @@ -5266,6 +5269,11 @@ impl<'a> Parser<'a> { self.parse_create_snapshot_table().map(Into::into) } else if self.peek_keywords(&[Keyword::TEXT, Keyword::SEARCH]) { self.parse_create_text_search().map(Into::into) + } else if or_refresh && self.peek_keyword(Keyword::TABLE) { + self.expected_ref( + "MATERIALIZED VIEW after CREATE OR REFRESH", + self.peek_token_ref(), + ) } else if self.parse_keyword(Keyword::TABLE) { self.parse_create_table( or_replace, temporary, unlogged, global, transient, volatile, multiset, @@ -5276,8 +5284,14 @@ impl<'a> Parser<'a> { || self.peek_keywords(&[Keyword::SECURE, Keyword::MATERIALIZED, Keyword::VIEW]) || self.peek_keywords(&[Keyword::SECURE, Keyword::VIEW]) { - self.parse_create_view(or_alter, or_replace, temporary, create_view_params) - .map(Into::into) + self.parse_create_view( + or_alter, + or_replace, + or_refresh, + temporary, + create_view_params, + ) + .map(Into::into) } else if self.parse_keyword(Keyword::POLICY) { self.parse_create_policy().map(Into::into) } else if self.parse_keyword(Keyword::EXTERNAL) { @@ -6744,11 +6758,18 @@ impl<'a> Parser<'a> { &mut self, or_alter: bool, or_replace: bool, + or_refresh: bool, temporary: bool, create_view_params: Option, ) -> Result { let secure = self.parse_keyword(Keyword::SECURE); let materialized = self.parse_keyword(Keyword::MATERIALIZED); + if or_refresh && !materialized { + return self.expected_ref( + "MATERIALIZED VIEW after CREATE OR REFRESH", + self.peek_token_ref(), + ); + } self.expect_keyword_is(Keyword::VIEW)?; let allow_unquoted_hyphen = dialect_of!(self is BigQueryDialect); // Tries to parse IF NOT EXISTS either before name or after name @@ -6762,7 +6783,7 @@ impl<'a> Parser<'a> { let mut copy_grants = self.parse_keywords(&[Keyword::COPY, Keyword::GRANTS]); // Many dialects support `OR ALTER` right after `CREATE`, but we don't (yet). // ANSI SQL and Postgres support RECURSIVE here, but we don't support it either. - let columns = self.parse_view_columns()?; + let columns = self.parse_view_columns(materialized)?; // Snowflake also documents `COPY GRANTS` *after* the column list; accept // either position, but not both. // @@ -6770,7 +6791,11 @@ impl<'a> Parser<'a> { copy_grants = self.parse_keywords(&[Keyword::COPY, Keyword::GRANTS]); } let mut options = CreateTableOptions::None; - let with_options = self.parse_options(Keyword::WITH)?; + let with_options = if self.peek_keywords(&[Keyword::WITH, Keyword::SCHEMA]) { + Vec::new() + } else { + self.parse_options(Keyword::WITH)? + }; if !with_options.is_empty() { options = CreateTableOptions::With(with_options); } @@ -6801,12 +6826,46 @@ impl<'a> Parser<'a> { let comment = if self.dialect.supports_create_view_comment_syntax() && self.parse_keyword(Keyword::COMMENT) { - self.expect_token(&Token::Eq)?; + if !self.dialect.supports_create_view_comment_without_equals() { + self.expect_token(&Token::Eq)?; + } else { + let _ = self.consume_token(&Token::Eq); + } Some(self.parse_comment_value()?) } else { None }; + if self.dialect.supports_create_view_table_properties() { + let table_properties = self.parse_options(Keyword::TBLPROPERTIES)?; + if !table_properties.is_empty() { + options = CreateTableOptions::TableProperties(table_properties); + } + } + + let schema_mode = if self.dialect.supports_create_view_schema_mode() + && self.parse_keywords(&[Keyword::WITH, Keyword::SCHEMA]) + { + Some( + match self.expect_one_of_keywords(&[ + Keyword::BINDING, + Keyword::COMPENSATION, + Keyword::EVOLUTION, + ])? { + Keyword::BINDING => ViewSchemaMode::Binding, + Keyword::COMPENSATION => ViewSchemaMode::Compensation, + Keyword::EVOLUTION => ViewSchemaMode::Evolution, + unexpected_keyword => { + return Err(ParserError::ParserError(format!( + "Internal parser error: expected a view schema mode, got {unexpected_keyword:?}" + ))) + } + }, + ) + } else { + None + }; + self.expect_keyword_is(Keyword::AS)?; let query = self.parse_query()?; // Optional `WITH [ CASCADED | LOCAL ] CHECK OPTION` is widely supported here. @@ -6827,6 +6886,8 @@ impl<'a> Parser<'a> { materialized, secure, or_replace, + or_refresh, + schema_mode, options, cluster_by, comment, @@ -13109,7 +13170,8 @@ impl<'a> Parser<'a> { })?) } else { self.expect_token(&Token::Lt)?; - let (inside_type, _trailing_bracket) = self.parse_data_type_helper()?; + let (inside_type, _trailing_bracket) = + self.parse_data_type_with_optional_collation()?; trailing_bracket = self.expect_closing_angle_bracket(_trailing_bracket)?; Ok(DataType::Array(ArrayElemTypeDef::AngleBracket(Box::new( inside_type, @@ -13144,9 +13206,17 @@ impl<'a> Parser<'a> { } Keyword::MAP if self.dialect.supports_map_literal_with_angle_brackets() => { self.expect_token(&Token::Lt)?; - let key_data_type = self.parse_data_type()?; + let (key_data_type, key_trailing_bracket) = + self.parse_data_type_with_optional_collation()?; + if key_trailing_bracket.0 { + return parser_err!( + format!("unmatched > after parsing data type {key_data_type}"), + self.peek_token_ref() + ); + } self.expect_token(&Token::Comma)?; - let (value_data_type, _trailing_bracket) = self.parse_data_type_helper()?; + let (value_data_type, _trailing_bracket) = + self.parse_data_type_with_optional_collation()?; trailing_bracket = self.expect_closing_angle_bracket(_trailing_bracket)?; Ok(DataType::Map( Box::new(key_data_type), @@ -13247,6 +13317,16 @@ impl<'a> Parser<'a> { Ok((data, trailing_bracket)) } + fn parse_data_type_with_optional_collation( + &mut self, + ) -> Result<(DataType, MatchedTrailingBracket), ParserError> { + let (mut data_type, trailing_bracket) = self.parse_data_type_helper()?; + if self.dialect.supports_data_type_collation() && self.parse_keyword(Keyword::COLLATE) { + data_type = DataType::Collate(Box::new(data_type), self.parse_object_name(false)?); + } + Ok((data_type, trailing_bracket)) + } + fn parse_returns_table_column(&mut self) -> Result { self.parse_column_def() } @@ -13938,17 +14018,28 @@ impl<'a> Parser<'a> { } /// Parses a parenthesized, comma-separated list of column definitions within a view. - fn parse_view_columns(&mut self) -> Result, ParserError> { + fn parse_view_columns( + &mut self, + materialized: bool, + ) -> Result, ParserError> { if self.consume_token(&Token::LParen) { if self.peek_token_ref().token == Token::RParen { self.next_token(); Ok(vec![]) } else { - let cols = self.parse_comma_separated_with_trailing_commas( - Parser::parse_view_column, - self.dialect.supports_column_definition_trailing_commas(), - Self::is_reserved_for_column_alias, - )?; + let cols = if materialized && self.dialect.supports_typed_view_columns() { + self.parse_comma_separated_with_trailing_commas( + Parser::parse_typed_view_column, + self.dialect.supports_column_definition_trailing_commas(), + Self::is_reserved_for_column_alias, + )? + } else { + self.parse_comma_separated_with_trailing_commas( + Parser::parse_view_column, + self.dialect.supports_column_definition_trailing_commas(), + Self::is_reserved_for_column_alias, + )? + }; self.expect_token(&Token::RParen)?; Ok(cols) } @@ -13973,6 +14064,17 @@ impl<'a> Parser<'a> { }) } + fn parse_typed_view_column(&mut self) -> Result { + let name = self.parse_identifier()?; + let data_type = Some(self.parse_data_type()?); + let options = self.parse_view_column_options()?; + Ok(ViewColumnDef { + name, + data_type, + options, + }) + } + fn parse_view_column_options(&mut self) -> Result, ParserError> { let mut options = Vec::new(); loop { @@ -15606,6 +15708,29 @@ impl<'a> Parser<'a> { /// Parse `CREATE TABLE x AS TABLE y` pub fn parse_as_table(&mut self) -> Result { + if self.dialect.supports_multipart_table_query_name() { + let mut parts = self.parse_object_name(false)?.0; + let table_name = parts + .pop() + .expect("object names always contain at least one part") + .to_string(); + let schema_name = if parts.is_empty() { + None + } else { + Some( + parts + .iter() + .map(ToString::to_string) + .collect::>() + .join("."), + ) + }; + return Ok(Table { + table_name: Some(table_name), + schema_name, + }); + } + let token1 = self.next_token(); let token2 = self.next_token(); let token3 = self.next_token(); diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index 29b060a82d..5f44bceff3 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -8707,6 +8707,8 @@ fn parse_create_view() { name_before_not_exists: _, secure: _, copy_grants: _, + or_refresh: _, + schema_mode: _, }) => { assert_eq!(or_alter, false); assert_eq!("myschema.myview", name.to_string()); @@ -8826,6 +8828,8 @@ fn parse_create_view_temporary() { name_before_not_exists: _, secure: _, copy_grants: _, + or_refresh: _, + schema_mode: _, }) => { assert_eq!(or_alter, false); assert_eq!("myschema.myview", name.to_string()); @@ -8868,6 +8872,8 @@ fn parse_create_or_replace_view() { name_before_not_exists: _, secure: _, copy_grants: _, + or_refresh: _, + schema_mode: _, }) => { assert_eq!(or_alter, false); assert_eq!("v", name.to_string()); @@ -8914,6 +8920,8 @@ fn parse_create_or_replace_materialized_view() { name_before_not_exists: _, secure: _, copy_grants: _, + or_refresh: _, + schema_mode: _, }) => { assert_eq!(or_alter, false); assert_eq!("v", name.to_string()); @@ -8956,6 +8964,8 @@ fn parse_create_materialized_view() { name_before_not_exists: _, secure: _, copy_grants: _, + or_refresh: _, + schema_mode: _, }) => { assert_eq!(or_alter, false); assert_eq!("myschema.myview", name.to_string()); @@ -8998,6 +9008,8 @@ fn parse_create_materialized_view_with_cluster_by() { name_before_not_exists: _, secure: _, copy_grants: _, + or_refresh: _, + schema_mode: _, }) => { assert_eq!(or_alter, false); assert_eq!("myschema.myview", name.to_string()); diff --git a/tests/sqlparser_databricks.rs b/tests/sqlparser_databricks.rs index 62068ef51e..c2f7fca5d7 100644 --- a/tests/sqlparser_databricks.rs +++ b/tests/sqlparser_databricks.rs @@ -749,3 +749,45 @@ fn parse_databricks_query_entry_points() { databricks() .verified_stmt("CREATE VIEW filtered AS FROM main.raw.source |> WHERE id > 0 |> SELECT id"); } + +#[test] +fn parse_materialized_view_typed_columns() { + databricks().one_statement_parses_to( + "CREATE MATERIALIZED VIEW v (id BIGINT COMMENT 'identifier', payload MAP) COMMENT 'view comment' TBLPROPERTIES ('delta.feature.variantType-preview' = 'supported') AS SELECT 1, map()", + "CREATE MATERIALIZED VIEW v (id BIGINT COMMENT 'identifier', payload MAP) COMMENT = 'view comment' TBLPROPERTIES ('delta.feature.variantType-preview' = 'supported') AS SELECT 1, map()", + ); + databricks().one_statement_parses_to( + r#"CREATE MATERIALIZED VIEW v (id STRING COMMENT 'value is \'1\'') AS SELECT '1'"#, + "CREATE MATERIALIZED VIEW v (id STRING COMMENT 'value is ''1''') AS SELECT '1'", + ); + + databricks().verified_stmt( + "CREATE MATERIALIZED VIEW v (id STRING COLLATE UTF8_LCASE, values ARRAY) AS SELECT 'id', array()", + ); + databricks().verified_stmt("CREATE TABLE t (name STRING COLLATE UTF8_BINARY)"); +} + +#[test] +fn parse_databricks_refreshable_views() { + databricks().verified_stmt( + "CREATE OR REFRESH MATERIALIZED VIEW main.models.rollup AS SELECT id FROM main.raw.source", + ); + databricks().verified_stmt("CREATE VIEW copy AS TABLE main.raw.source"); + + for sql in [ + "CREATE VIEW t WITH SCHEMA BINDING AS SELECT id FROM source", + "CREATE VIEW t WITH SCHEMA COMPENSATION AS SELECT id FROM source", + "CREATE VIEW t WITH SCHEMA EVOLUTION AS SELECT id FROM source", + ] { + databricks().verified_stmt(sql); + } + + databricks().one_statement_parses_to( + "CREATE VIEW t COMMENT 'view' TBLPROPERTIES ('quality' = 'gold') WITH SCHEMA BINDING AS SELECT id FROM source", + "CREATE VIEW t COMMENT = 'view' TBLPROPERTIES ('quality' = 'gold') WITH SCHEMA BINDING AS SELECT id FROM source", + ); + + assert!(databricks() + .parse_sql_statements("CREATE OR REFRESH VIEW t AS SELECT 1") + .is_err()); +}