diff --git a/.gitignore b/.gitignore index f705d0b0d..45c4bdae1 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,4 @@ Cargo.lock *.swp -.DS_store \ No newline at end of file +.DS_store.worktrees/ diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 63b3db644..fd3a1f978 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -138,8 +138,9 @@ mod dml; pub mod helpers; pub mod table_constraints; pub use table_constraints::{ - CheckConstraint, ConstraintUsingIndex, ForeignKeyConstraint, FullTextOrSpatialConstraint, - IndexConstraint, PrimaryKeyConstraint, TableConstraint, UniqueConstraint, + CheckConstraint, ConstraintUsingIndex, ExclusionConstraint, ExclusionElement, + ExclusionOperator, ForeignKeyConstraint, FullTextOrSpatialConstraint, IndexConstraint, + PrimaryKeyConstraint, TableConstraint, UniqueConstraint, }; mod operator; mod query; diff --git a/src/ast/spans.rs b/src/ast/spans.rs index e7a8f94f2..3b3da827e 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -643,6 +643,7 @@ impl Spanned for TableConstraint { TableConstraint::FulltextOrSpatial(constraint) => constraint.span(), TableConstraint::PrimaryKeyUsingIndex(constraint) | TableConstraint::UniqueUsingIndex(constraint) => constraint.span(), + TableConstraint::Exclusion(constraint) => constraint.span(), } } } diff --git a/src/ast/table_constraints.rs b/src/ast/table_constraints.rs index 9ba196a81..3331967ef 100644 --- a/src/ast/table_constraints.rs +++ b/src/ast/table_constraints.rs @@ -20,13 +20,13 @@ use crate::ast::{ display_comma_separated, display_separated, ConstraintCharacteristics, ConstraintReferenceMatchKind, Expr, Ident, IndexColumn, IndexOption, IndexType, - KeyOrIndexDisplay, NullsDistinctOption, ObjectName, ReferentialAction, + KeyOrIndexDisplay, NullsDistinctOption, ObjectName, OrderByOptions, ReferentialAction, }; use crate::tokenizer::Span; use core::fmt; #[cfg(not(feature = "std"))] -use alloc::{boxed::Box, vec::Vec}; +use alloc::{boxed::Box, string::String, vec::Vec}; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; @@ -117,6 +117,12 @@ pub enum TableConstraint { /// /// [1]: https://www.postgresql.org/docs/current/sql-altertable.html UniqueUsingIndex(ConstraintUsingIndex), + /// PostgreSQL `EXCLUDE` constraint. + /// + /// `[ CONSTRAINT ] EXCLUDE [ USING ] ( WITH [, ...] ) [ INCLUDE () ] [ WHERE () ]` + /// + /// See + Exclusion(ExclusionConstraint), } impl From for TableConstraint { @@ -155,6 +161,12 @@ impl From for TableConstraint { } } +impl From for TableConstraint { + fn from(constraint: ExclusionConstraint) -> Self { + TableConstraint::Exclusion(constraint) + } +} + impl fmt::Display for TableConstraint { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { @@ -166,6 +178,7 @@ impl fmt::Display for TableConstraint { TableConstraint::FulltextOrSpatial(constraint) => constraint.fmt(f), TableConstraint::PrimaryKeyUsingIndex(c) => c.fmt_with_keyword(f, "PRIMARY KEY"), TableConstraint::UniqueUsingIndex(c) => c.fmt_with_keyword(f, "UNIQUE"), + TableConstraint::Exclusion(constraint) => constraint.fmt(f), } } } @@ -603,3 +616,123 @@ impl crate::ast::Spanned for ConstraintUsingIndex { start.union(&end) } } + +/// The operator that follows `WITH` in an `EXCLUDE` element. +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub enum ExclusionOperator { + /// A single operator token, e.g. `=`, `&&`, `<->`. + Token(String), + /// Postgres schema-qualified form: `OPERATOR(schema.op)`. + PgCustom(Vec), +} + +impl fmt::Display for ExclusionOperator { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + ExclusionOperator::Token(token) => f.write_str(token), + ExclusionOperator::PgCustom(parts) => { + write!(f, "OPERATOR({})", display_separated(parts, ".")) + } + } + } +} + +/// One element in an `EXCLUDE` constraint's element list. +/// +/// `{ column_name | ( expression ) } [ opclass ] [ ASC | DESC ] [ NULLS { FIRST | LAST } ] WITH ` +/// +/// See +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub struct ExclusionElement { + /// The index expression or column name. + pub expr: Expr, + /// Optional operator class (e.g. `gist_geometry_ops_nd`). + pub operator_class: Option, + /// Ordering options (ASC/DESC, NULLS FIRST/LAST). + pub order: OrderByOptions, + /// The exclusion operator. + pub operator: ExclusionOperator, +} + +impl fmt::Display for ExclusionElement { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.expr)?; + if let Some(opclass) = &self.operator_class { + write!(f, " {opclass}")?; + } + write!(f, "{} WITH {}", self.order, self.operator) + } +} + +impl crate::ast::Spanned for ExclusionElement { + fn span(&self) -> Span { + let mut span = self.expr.span(); + if let Some(opclass) = &self.operator_class { + span = span.union(&opclass.span()); + } + span + } +} + +/// A PostgreSQL `EXCLUDE` constraint. +/// +/// `[ CONSTRAINT ] EXCLUDE [ USING ] ( WITH [, ...] ) [ INCLUDE () ] [ WHERE () ]` +/// +/// See +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub struct ExclusionConstraint { + /// Optional constraint name. + pub name: Option, + /// Optional index method (e.g. `gist`, `spgist`). + pub index_method: Option, + /// The list of index expressions with their exclusion operators. + pub elements: Vec, + /// Optional list of additional columns to include in the index. + pub include: Vec, + /// Optional `WHERE` predicate to restrict the constraint to a subset of rows. + pub where_clause: Option>, + /// Optional constraint characteristics like `DEFERRABLE`. + pub characteristics: Option, +} + +impl fmt::Display for ExclusionConstraint { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + use crate::ast::ddl::display_constraint_name; + write!(f, "{}EXCLUDE", display_constraint_name(&self.name))?; + if let Some(method) = &self.index_method { + write!(f, " USING {method}")?; + } + write!(f, " ({})", display_comma_separated(&self.elements))?; + if !self.include.is_empty() { + write!(f, " INCLUDE ({})", display_comma_separated(&self.include))?; + } + if let Some(predicate) = &self.where_clause { + write!(f, " WHERE ({predicate})")?; + } + if let Some(characteristics) = &self.characteristics { + write!(f, " {characteristics}")?; + } + Ok(()) + } +} + +impl crate::ast::Spanned for ExclusionConstraint { + fn span(&self) -> Span { + Span::union_iter( + self.name + .iter() + .map(|i| i.span) + .chain(self.index_method.iter().map(|i| i.span)) + .chain(self.elements.iter().map(|e| e.span())) + .chain(self.include.iter().map(|i| i.span)) + .chain(self.where_clause.iter().map(|e| e.span())) + .chain(self.characteristics.iter().map(|c| c.span())), + ) + } +} diff --git a/src/parser/mod.rs b/src/parser/mod.rs index a5526723b..678fab055 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -508,10 +508,10 @@ impl<'a> Parser<'a> { Token::EOF => break, // end of statement - Token::Word(word) => { - if expecting_statement_delimiter && word.keyword == Keyword::END { - break; - } + Token::Word(word) + if expecting_statement_delimiter && word.keyword == Keyword::END => + { + break; } _ => {} } @@ -1305,41 +1305,40 @@ impl<'a> Parser<'a> { let next_token = self.next_token(); match next_token.token { - t @ (Token::Word(_) | Token::SingleQuotedString(_)) => { - if self.peek_token_ref().token == Token::Period { - let mut id_parts: Vec = vec![match t { - Token::Word(w) => w.into_ident(next_token.span), - Token::SingleQuotedString(s) => Ident::with_quote('\'', s), - _ => { - return Err(ParserError::ParserError( - "Internal parser error: unexpected token type".to_string(), - )) + t @ (Token::Word(_) | Token::SingleQuotedString(_)) + if self.peek_token_ref().token == Token::Period => + { + let mut id_parts: Vec = vec![match t { + Token::Word(w) => w.into_ident(next_token.span), + Token::SingleQuotedString(s) => Ident::with_quote('\'', s), + _ => { + return Err(ParserError::ParserError( + "Internal parser error: unexpected token type".to_string(), + )) + } + }]; + + while self.consume_token(&Token::Period) { + let next_token = self.next_token(); + match next_token.token { + Token::Word(w) => id_parts.push(w.into_ident(next_token.span)), + Token::SingleQuotedString(s) => { + // SQLite has single-quoted identifiers + id_parts.push(Ident::with_quote('\'', s)) } - }]; - - while self.consume_token(&Token::Period) { - let next_token = self.next_token(); - match next_token.token { - Token::Word(w) => id_parts.push(w.into_ident(next_token.span)), - Token::SingleQuotedString(s) => { - // SQLite has single-quoted identifiers - id_parts.push(Ident::with_quote('\'', s)) - } - Token::Placeholder(s) => { - // Snowflake uses $1, $2, etc. for positional column references - // in staged data queries like: SELECT t.$1 FROM @stage t - id_parts.push(Ident::new(s)) - } - Token::Mul => { - return Ok(Expr::QualifiedWildcard( - ObjectName::from(id_parts), - AttachedToken(next_token), - )); - } - _ => { - return self - .expected("an identifier or a '*' after '.'", next_token); - } + Token::Placeholder(s) => { + // Snowflake uses $1, $2, etc. for positional column references + // in staged data queries like: SELECT t.$1 FROM @stage t + id_parts.push(Ident::new(s)) + } + Token::Mul => { + return Ok(Expr::QualifiedWildcard( + ObjectName::from(id_parts), + AttachedToken(next_token), + )); + } + _ => { + return self.expected("an identifier or a '*' after '.'", next_token); } } } @@ -3874,21 +3873,12 @@ impl<'a> Parser<'a> { Keyword::XOR => Some(BinaryOperator::Xor), Keyword::OVERLAPS => Some(BinaryOperator::Overlaps), Keyword::OPERATOR if dialect_is!(dialect is PostgreSqlDialect | GenericDialect) => { - self.expect_token(&Token::LParen)?; - // there are special rules for operator names in - // postgres so we can not use 'parse_object' - // or similar. + // Postgres has special rules for operator names so we can + // not use `parse_object` or similar. // See https://www.postgresql.org/docs/current/sql-createoperator.html - let mut idents = vec![]; - loop { - self.advance_token(); - idents.push(self.get_current_token().to_string()); - if !self.consume_token(&Token::Period) { - break; - } - } - self.expect_token(&Token::RParen)?; - Some(BinaryOperator::PGCustomBinaryOperator(idents)) + Some(BinaryOperator::PGCustomBinaryOperator( + self.parse_pg_operator_ident_parts()?, + )) } _ => None, }, @@ -5031,10 +5021,10 @@ impl<'a> Parser<'a> { loop { match &self.peek_nth_token_ref(0).token { Token::EOF => break, - Token::Word(w) => { - if w.quote_style.is_none() && terminal_keywords.contains(&w.keyword) { - break; - } + Token::Word(w) + if w.quote_style.is_none() && terminal_keywords.contains(&w.keyword) => + { + break; } _ => {} } @@ -8377,70 +8367,60 @@ impl<'a> Parser<'a> { Keyword::LINES, Keyword::NULL, ]) { - Some(Keyword::FIELDS) => { - if self.parse_keywords(&[Keyword::TERMINATED, Keyword::BY]) { + Some(Keyword::FIELDS) + if self.parse_keywords(&[Keyword::TERMINATED, Keyword::BY]) => + { + row_delimiters.push(HiveRowDelimiter { + delimiter: HiveDelimiter::FieldsTerminatedBy, + char: self.parse_identifier()?, + }); + + if self.parse_keywords(&[Keyword::ESCAPED, Keyword::BY]) { row_delimiters.push(HiveRowDelimiter { - delimiter: HiveDelimiter::FieldsTerminatedBy, + delimiter: HiveDelimiter::FieldsEscapedBy, char: self.parse_identifier()?, }); - - if self.parse_keywords(&[Keyword::ESCAPED, Keyword::BY]) { - row_delimiters.push(HiveRowDelimiter { - delimiter: HiveDelimiter::FieldsEscapedBy, - char: self.parse_identifier()?, - }); - } - } else { - break; } } - Some(Keyword::COLLECTION) => { + Some(Keyword::COLLECTION) if self.parse_keywords(&[ Keyword::ITEMS, Keyword::TERMINATED, Keyword::BY, - ]) { - row_delimiters.push(HiveRowDelimiter { - delimiter: HiveDelimiter::CollectionItemsTerminatedBy, - char: self.parse_identifier()?, - }); - } else { - break; - } + ]) => + { + row_delimiters.push(HiveRowDelimiter { + delimiter: HiveDelimiter::CollectionItemsTerminatedBy, + char: self.parse_identifier()?, + }); } - Some(Keyword::MAP) => { + Some(Keyword::MAP) if self.parse_keywords(&[ Keyword::KEYS, Keyword::TERMINATED, Keyword::BY, - ]) { - row_delimiters.push(HiveRowDelimiter { - delimiter: HiveDelimiter::MapKeysTerminatedBy, - char: self.parse_identifier()?, - }); - } else { - break; - } + ]) => + { + row_delimiters.push(HiveRowDelimiter { + delimiter: HiveDelimiter::MapKeysTerminatedBy, + char: self.parse_identifier()?, + }); } - Some(Keyword::LINES) => { - if self.parse_keywords(&[Keyword::TERMINATED, Keyword::BY]) { - row_delimiters.push(HiveRowDelimiter { - delimiter: HiveDelimiter::LinesTerminatedBy, - char: self.parse_identifier()?, - }); - } else { - break; - } + Some(Keyword::LINES) + if self.parse_keywords(&[Keyword::TERMINATED, Keyword::BY]) => + { + row_delimiters.push(HiveRowDelimiter { + delimiter: HiveDelimiter::LinesTerminatedBy, + char: self.parse_identifier()?, + }); } - Some(Keyword::NULL) => { - if self.parse_keywords(&[Keyword::DEFINED, Keyword::AS]) { - row_delimiters.push(HiveRowDelimiter { - delimiter: HiveDelimiter::NullDefinedAs, - char: self.parse_identifier()?, - }); - } else { - break; - } + Some(Keyword::NULL) + if self.parse_keywords(&[Keyword::DEFINED, Keyword::AS]) => + { + row_delimiters.push(HiveRowDelimiter { + delimiter: HiveDelimiter::NullDefinedAs, + char: self.parse_identifier()?, + }); } _ => { break; @@ -9915,9 +9895,55 @@ impl<'a> Parser<'a> { .into(), )) } + Token::Word(w) + if w.keyword == Keyword::EXCLUDE + && dialect_of!(self is PostgreSqlDialect | GenericDialect) => + { + let index_method = if self.parse_keyword(Keyword::USING) { + Some(self.parse_identifier()?) + } else { + None + }; + + self.expect_token(&Token::LParen)?; + let elements = self.parse_comma_separated(|p| p.parse_exclusion_element())?; + self.expect_token(&Token::RParen)?; + + let include = if self.parse_keyword(Keyword::INCLUDE) { + self.expect_token(&Token::LParen)?; + let cols = self.parse_comma_separated(|p| p.parse_identifier())?; + self.expect_token(&Token::RParen)?; + cols + } else { + vec![] + }; + + let where_clause = if self.parse_keyword(Keyword::WHERE) { + self.expect_token(&Token::LParen)?; + let predicate = self.parse_expr()?; + self.expect_token(&Token::RParen)?; + Some(Box::new(predicate)) + } else { + None + }; + + let characteristics = self.parse_constraint_characteristics()?; + + Ok(Some( + ExclusionConstraint { + name, + index_method, + elements, + include, + where_clause, + characteristics, + } + .into(), + )) + } _ => { if name.is_some() { - self.expected("PRIMARY, UNIQUE, FOREIGN, or CHECK", next_token) + self.expected("PRIMARY, UNIQUE, FOREIGN, CHECK, or EXCLUDE", next_token) } else { self.prev_token(); Ok(None) @@ -9926,6 +9952,71 @@ impl<'a> Parser<'a> { } } + fn parse_exclusion_element(&mut self) -> Result { + // `index_elem` grammar: { col | (expr) } [ opclass ] [ ASC | DESC ] [ NULLS FIRST | LAST ]. + // Shared with `CREATE INDEX` columns. + let ( + OrderByExpr { + expr, + options: order, + .. + }, + operator_class, + ) = self.parse_order_by_expr_inner(true)?; + + self.expect_keyword_is(Keyword::WITH)?; + let operator = self.parse_exclusion_operator()?; + + Ok(ExclusionElement { + expr, + operator_class, + order, + operator, + }) + } + + /// Parse the operator that follows `WITH` in an `EXCLUDE` element. + /// + /// Accepts either a single operator token (e.g. `=`, `&&`, `<->`) or the + /// Postgres `OPERATOR(schema.op)` form for schema-qualified operators. + fn parse_exclusion_operator(&mut self) -> Result { + if self.parse_keyword(Keyword::OPERATOR) { + return Ok(ExclusionOperator::PgCustom( + self.parse_pg_operator_ident_parts()?, + )); + } + + let operator_token = self.next_token(); + match &operator_token.token { + Token::EOF | Token::RParen | Token::Comma | Token::SemiColon => { + self.expected("exclusion operator", operator_token) + } + _ => Ok(ExclusionOperator::Token(operator_token.token.to_string())), + } + } + + /// Parse the body of a Postgres `OPERATOR(schema.op)` form — i.e. the + /// parenthesised `.`-separated path of name parts after the `OPERATOR` + /// keyword. Shared between binary expression parsing and exclusion + /// constraint parsing. + fn parse_pg_operator_ident_parts(&mut self) -> Result, ParserError> { + self.expect_token(&Token::LParen)?; + if self.peek_token_ref().token == Token::RParen { + let token = self.next_token(); + return self.expected("operator name", token); + } + let mut idents = vec![]; + loop { + self.advance_token(); + idents.push(self.get_current_token().to_string()); + if !self.consume_token(&Token::Period) { + break; + } + } + self.expect_token(&Token::RParen)?; + Ok(idents) + } + fn parse_optional_nulls_distinct(&mut self) -> Result { Ok(if self.parse_keyword(Keyword::NULLS) { let not = self.parse_keyword(Keyword::NOT); diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index 07b62dd93..c258f2b1f 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -24,7 +24,7 @@ mod test_utils; use helpers::attached_token::AttachedToken; use sqlparser::ast::*; -use sqlparser::dialect::{GenericDialect, PostgreSqlDialect}; +use sqlparser::dialect::{Dialect, GenericDialect, MySqlDialect, PostgreSqlDialect, SQLiteDialect}; use sqlparser::parser::ParserError; use sqlparser::tokenizer::Span; use test_utils::*; @@ -9134,6 +9134,56 @@ fn parse_pg_analyze() { } } +#[test] +fn parse_exclude_constraint_basic() { + let sql = "CREATE TABLE t (room INT, CONSTRAINT no_overlap EXCLUDE USING gist (room WITH =))"; + match pg().verified_stmt(sql) { + Statement::CreateTable(create_table) => { + assert_eq!(1, create_table.constraints.len()); + match &create_table.constraints[0] { + TableConstraint::Exclusion(c) => { + assert_eq!(c.name, Some(Ident::new("no_overlap"))); + assert_eq!(c.index_method, Some(Ident::new("gist"))); + assert_eq!(c.elements.len(), 1); + assert_eq!(c.elements[0].expr, Expr::Identifier(Ident::new("room"))); + assert_eq!(c.elements[0].operator.to_string(), "="); + assert!(c.elements[0].operator_class.is_none()); + assert_eq!(c.elements[0].order, OrderByOptions::default()); + assert_eq!(c.include.len(), 0); + assert!(c.where_clause.is_none()); + assert!(c.characteristics.is_none()); + } + other => panic!("Expected Exclusion, got {other:?}"), + } + } + _ => panic!("Expected CreateTable"), + } +} + +#[test] +fn parse_exclude_constraint_multi_element() { + let sql = + "CREATE TABLE t (room INT, during INT, EXCLUDE USING gist (room WITH =, during WITH &&))"; + match pg().verified_stmt(sql) { + Statement::CreateTable(create_table) => { + assert_eq!(1, create_table.constraints.len()); + match &create_table.constraints[0] { + TableConstraint::Exclusion(c) => { + assert!(c.name.is_none()); + assert_eq!(c.index_method, Some(Ident::new("gist"))); + assert_eq!(c.elements.len(), 2); + assert_eq!(c.elements[0].operator.to_string(), "="); + assert_eq!(c.elements[0].expr, Expr::Identifier(Ident::new("room"))); + assert_eq!(c.elements[1].operator.to_string(), "&&"); + assert_eq!(c.elements[1].expr, Expr::Identifier(Ident::new("during"))); + } + other => panic!("Expected Exclusion, got {other:?}"), + } + } + _ => panic!("Expected CreateTable"), + } +} + #[test] fn parse_lock_table() { pg_and_generic().one_statement_parses_to( @@ -9193,3 +9243,310 @@ fn parse_lock_table() { } } } + +#[test] +fn parse_exclude_constraint_with_where() { + let sql = "CREATE TABLE t (col INT, EXCLUDE USING gist (col WITH =) WHERE (col > 0))"; + match pg().verified_stmt(sql) { + Statement::CreateTable(create_table) => { + assert_eq!(1, create_table.constraints.len()); + match &create_table.constraints[0] { + TableConstraint::Exclusion(c) => { + assert!(c.where_clause.is_some()); + match c.where_clause.as_ref().unwrap().as_ref() { + Expr::BinaryOp { left, op, right } => { + assert_eq!(**left, Expr::Identifier(Ident::new("col"))); + assert_eq!(*op, BinaryOperator::Gt); + assert_eq!(**right, Expr::Value(number("0").with_empty_span())); + } + other => panic!("Expected BinaryOp, got {other:?}"), + } + } + other => panic!("Expected Exclusion, got {other:?}"), + } + } + _ => panic!("Expected CreateTable"), + } +} + +#[test] +fn parse_exclude_constraint_with_include() { + let sql = "CREATE TABLE t (col INT, EXCLUDE USING gist (col WITH =) INCLUDE (col))"; + match pg().verified_stmt(sql) { + Statement::CreateTable(create_table) => { + assert_eq!(1, create_table.constraints.len()); + match &create_table.constraints[0] { + TableConstraint::Exclusion(c) => { + assert_eq!(c.elements.len(), 1); + assert_eq!(c.include, vec![Ident::new("col")]); + assert!(c.where_clause.is_none()); + assert!(c.characteristics.is_none()); + } + other => panic!("Expected Exclusion, got {other:?}"), + } + } + _ => panic!("Expected CreateTable"), + } +} + +#[test] +fn parse_exclude_constraint_no_using() { + let sql = "CREATE TABLE t (col INT, EXCLUDE (col WITH =))"; + match pg().verified_stmt(sql) { + Statement::CreateTable(create_table) => { + assert_eq!(1, create_table.constraints.len()); + match &create_table.constraints[0] { + TableConstraint::Exclusion(c) => { + assert!(c.index_method.is_none()); + } + other => panic!("Expected Exclusion, got {other:?}"), + } + } + _ => panic!("Expected CreateTable"), + } +} + +#[test] +fn parse_exclude_constraint_deferrable() { + let sql = + "CREATE TABLE t (col INT, EXCLUDE USING gist (col WITH =) DEFERRABLE INITIALLY DEFERRED)"; + match pg().verified_stmt(sql) { + Statement::CreateTable(create_table) => { + assert_eq!(1, create_table.constraints.len()); + match &create_table.constraints[0] { + TableConstraint::Exclusion(c) => { + let characteristics = c.characteristics.as_ref().unwrap(); + assert_eq!(characteristics.deferrable, Some(true)); + assert_eq!(characteristics.initially, Some(DeferrableInitial::Deferred)); + } + other => panic!("Expected Exclusion, got {other:?}"), + } + } + _ => panic!("Expected CreateTable"), + } +} + +#[test] +fn parse_exclude_constraint_in_alter_table() { + let sql = "ALTER TABLE t ADD CONSTRAINT no_overlap EXCLUDE USING gist (room WITH =)"; + match pg().verified_stmt(sql) { + Statement::AlterTable(alter_table) => match &alter_table.operations[0] { + AlterTableOperation::AddConstraint { + constraint: TableConstraint::Exclusion(c), + .. + } => { + assert_eq!(c.name, Some(Ident::new("no_overlap"))); + assert_eq!(c.elements[0].operator.to_string(), "="); + } + other => panic!("Expected AddConstraint(Exclusion), got {other:?}"), + }, + _ => panic!("Expected AlterTable"), + } +} + +#[test] +fn roundtrip_exclude_constraint() { + let sql = "CREATE TABLE t (CONSTRAINT no_overlap EXCLUDE USING gist (room WITH =, during WITH &&) INCLUDE (id) WHERE (active = true))"; + pg().verified_stmt(sql); +} + +#[test] +fn parse_exclude_constraint_not_deferrable_initially_immediate() { + let sql = "CREATE TABLE t (col INT, EXCLUDE USING gist (col WITH =) NOT DEFERRABLE INITIALLY IMMEDIATE)"; + match pg().verified_stmt(sql) { + Statement::CreateTable(create_table) => match &create_table.constraints[0] { + TableConstraint::Exclusion(c) => { + let characteristics = c.characteristics.as_ref().unwrap(); + assert_eq!(characteristics.deferrable, Some(false)); + assert_eq!( + characteristics.initially, + Some(DeferrableInitial::Immediate) + ); + } + other => panic!("Expected Exclusion, got {other:?}"), + }, + _ => panic!("Expected CreateTable"), + } +} + +#[test] +fn parse_exclude_constraint_collate() { + // `COLLATE` is consumed by the element expression parser; verify that + // a collated column round-trips inside an EXCLUDE element. + pg().verified_stmt( + "CREATE TABLE t (name TEXT, EXCLUDE USING btree (name COLLATE \"C\" WITH =))", + ); +} + +#[test] +fn parse_exclude_constraint_operator_class() { + let sql = "CREATE TABLE t (col TEXT, EXCLUDE USING gist (col text_pattern_ops WITH =))"; + match pg().verified_stmt(sql) { + Statement::CreateTable(create_table) => match &create_table.constraints[0] { + TableConstraint::Exclusion(c) => { + assert_eq!(c.elements.len(), 1); + assert_eq!(c.elements[0].expr, Expr::Identifier(Ident::new("col"))); + assert_eq!( + c.elements[0].operator_class, + Some(ObjectName::from(vec![Ident::new("text_pattern_ops")])) + ); + assert_eq!(c.elements[0].operator.to_string(), "="); + } + other => panic!("Expected Exclusion, got {other:?}"), + }, + _ => panic!("Expected CreateTable"), + } +} + +#[test] +fn parse_exclude_constraint_asc_nulls_last() { + let sql = "CREATE TABLE t (col INT, EXCLUDE USING btree (col ASC NULLS LAST WITH =))"; + match pg().verified_stmt(sql) { + Statement::CreateTable(create_table) => match &create_table.constraints[0] { + TableConstraint::Exclusion(c) => { + assert_eq!(c.elements[0].order.asc, Some(true)); + assert_eq!(c.elements[0].order.nulls_first, Some(false)); + } + other => panic!("Expected Exclusion, got {other:?}"), + }, + _ => panic!("Expected CreateTable"), + } +} + +#[test] +fn parse_exclude_constraint_desc_nulls_first() { + let sql = "CREATE TABLE t (col INT, EXCLUDE USING btree (col DESC NULLS FIRST WITH =))"; + match pg().verified_stmt(sql) { + Statement::CreateTable(create_table) => match &create_table.constraints[0] { + TableConstraint::Exclusion(c) => { + assert_eq!(c.elements[0].order.asc, Some(false)); + assert_eq!(c.elements[0].order.nulls_first, Some(true)); + } + other => panic!("Expected Exclusion, got {other:?}"), + }, + _ => panic!("Expected CreateTable"), + } +} + +#[test] +fn parse_exclude_constraint_function_expression() { + let sql = + "CREATE TABLE t (name TEXT, EXCLUDE USING gist ((lower(name)) text_pattern_ops WITH =))"; + match pg().verified_stmt(sql) { + Statement::CreateTable(create_table) => match &create_table.constraints[0] { + TableConstraint::Exclusion(c) => { + assert_eq!(c.elements.len(), 1); + match &c.elements[0].expr { + Expr::Nested(inner) => match inner.as_ref() { + Expr::Function(func) => { + assert_eq!(func.name.to_string(), "lower"); + } + other => panic!("Expected Function inside Nested, got {other:?}"), + }, + other => panic!("Expected Nested expr, got {other:?}"), + } + assert_eq!( + c.elements[0].operator_class, + Some(ObjectName::from(vec![Ident::new("text_pattern_ops")])) + ); + assert_eq!(c.elements[0].operator.to_string(), "="); + } + other => panic!("Expected Exclusion, got {other:?}"), + }, + _ => panic!("Expected CreateTable"), + } +} + +#[test] +fn parse_exclude_constraint_pg_custom_operator() { + let sql = "CREATE TABLE t (col INT, EXCLUDE USING gist (col WITH OPERATOR(pg_catalog.=)))"; + match pg().verified_stmt(sql) { + Statement::CreateTable(create_table) => match &create_table.constraints[0] { + TableConstraint::Exclusion(c) => match &c.elements[0].operator { + ExclusionOperator::PgCustom(parts) => { + assert_eq!(parts, &vec!["pg_catalog".to_string(), "=".to_string()]); + } + other => panic!("Expected PgCustom operator, got {other:?}"), + }, + other => panic!("Expected Exclusion, got {other:?}"), + }, + _ => panic!("Expected CreateTable"), + } +} + +#[test] +fn exclude_missing_with_keyword_errors() { + let sql = "CREATE TABLE t (CONSTRAINT c EXCLUDE USING gist (col))"; + let err = pg().parse_sql_statements(sql).unwrap_err(); + assert!( + err.to_string().contains("Expected: WITH"), + "unexpected error: {err}" + ); +} + +#[test] +fn exclude_empty_element_list_errors() { + let sql = "CREATE TABLE t (CONSTRAINT c EXCLUDE USING gist ())"; + let err = pg().parse_sql_statements(sql).unwrap_err(); + assert!( + err.to_string().contains("Expected: an expression"), + "unexpected error: {err}" + ); +} + +#[test] +fn exclude_missing_operator_errors() { + let sql = "CREATE TABLE t (CONSTRAINT c EXCLUDE USING gist (col WITH))"; + let err = pg().parse_sql_statements(sql).unwrap_err(); + assert!( + err.to_string().contains("exclusion operator"), + "unexpected error: {err}" + ); +} + +#[test] +fn parse_exclude_constraint_operator_with_ordering() { + pg().verified_stmt( + "CREATE TABLE t (col INT, CONSTRAINT c EXCLUDE USING gist (col ASC WITH OPERATOR(pg_catalog.=)))", + ); +} + +#[test] +fn exclude_rejected_in_non_postgres_dialects() { + // `GenericDialect` is intentionally excluded — it opts in to the + // Postgres EXCLUDE syntax alongside `PostgreSqlDialect`. + let sql = "CREATE TABLE t (col INT, EXCLUDE USING gist (col WITH =))"; + for dialect in + all_dialects_except(|d| d.is::() || d.is::()).dialects + { + let parser = TestedDialects::new(vec![dialect]); + assert!( + parser.parse_sql_statements(sql).is_err(), + "dialect unexpectedly accepted EXCLUDE: {sql}" + ); + } +} + +#[test] +fn exclude_as_column_name_parses_in_mysql_and_sqlite() { + // `exclude` must remain usable as an identifier where it is not a + // reserved keyword; PG reserves it as a constraint keyword. + let sql = "CREATE TABLE t (exclude INT)"; + for dialect in [ + Box::new(MySqlDialect {}) as Box, + Box::new(SQLiteDialect {}), + ] { + let type_name = format!("{dialect:?}"); + let parser = TestedDialects::new(vec![dialect]); + let stmts = parser + .parse_sql_statements(sql) + .unwrap_or_else(|e| panic!("{type_name} failed to parse {sql}: {e}")); + match &stmts[0] { + Statement::CreateTable(create_table) => { + assert_eq!(create_table.columns.len(), 1); + assert_eq!(create_table.columns[0].name.value, "exclude"); + } + other => panic!("{type_name}: expected CreateTable, got {other:?}"), + } + } +}