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 886bea26d..f1866f687 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, ExcludeConstraint, ExcludeConstraintElement, + ExcludeConstraintOperator, ForeignKeyConstraint, FullTextOrSpatialConstraint, IndexConstraint, + PrimaryKeyConstraint, TableConstraint, UniqueConstraint, }; mod operator; mod query; diff --git a/src/ast/spans.rs b/src/ast/spans.rs index dc8be4aec..baf5e4669 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -650,6 +650,7 @@ impl Spanned for TableConstraint { TableConstraint::FulltextOrSpatial(constraint) => constraint.span(), TableConstraint::PrimaryKeyUsingIndex(constraint) | TableConstraint::UniqueUsingIndex(constraint) => constraint.span(), + TableConstraint::Exclude(constraint) => constraint.span(), } } } diff --git a/src/ast/table_constraints.rs b/src/ast/table_constraints.rs index 9ba196a81..5f249baee 100644 --- a/src/ast/table_constraints.rs +++ b/src/ast/table_constraints.rs @@ -26,7 +26,7 @@ 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), + /// `EXCLUDE` constraint. + /// + /// `[ CONSTRAINT ] EXCLUDE [ USING ] ( WITH [, ...] ) [ INCLUDE () ] [ WHERE () ]` + /// + /// [PostgreSQL](https://www.postgresql.org/docs/current/sql-createtable.html#SQL-CREATETABLE-EXCLUDE) + Exclude(ExcludeConstraint), } impl From for TableConstraint { @@ -155,6 +161,12 @@ impl From for TableConstraint { } } +impl From for TableConstraint { + fn from(constraint: ExcludeConstraint) -> Self { + TableConstraint::Exclude(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::Exclude(constraint) => constraint.fmt(f), } } } @@ -603,3 +616,113 @@ impl crate::ast::Spanned for ConstraintUsingIndex { start.union(&end) } } + +/// The operator that follows `WITH` in an `EXCLUDE` constraint element. +/// +/// [PostgreSQL](https://www.postgresql.org/docs/current/sql-createtable.html#SQL-CREATETABLE-EXCLUDE) +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub enum ExcludeConstraintOperator { + /// A single operator token, e.g. `=`, `&&`, `<->`. + Token(String), + /// Postgres schema-qualified form: `OPERATOR(schema.op)`. + PgCustom(Vec), +} + +impl fmt::Display for ExcludeConstraintOperator { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + ExcludeConstraintOperator::Token(token) => f.write_str(token), + ExcludeConstraintOperator::PgCustom(parts) => { + write!(f, "OPERATOR({})", display_separated(parts, ".")) + } + } + } +} + +/// One element in an `EXCLUDE` constraint's element list. +/// +/// [PostgreSQL](https://www.postgresql.org/docs/current/sql-createtable.html#SQL-CREATETABLE-EXCLUDE) +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub struct ExcludeConstraintElement { + /// The index column (`{ column_name | ( expression ) } [ opclass ] [ ASC | DESC ] [ NULLS { FIRST | LAST } ]`). + pub column: IndexColumn, + /// The exclusion operator. + pub operator: ExcludeConstraintOperator, +} + +impl fmt::Display for ExcludeConstraintElement { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{} WITH {}", self.column, self.operator) + } +} + +impl crate::ast::Spanned for ExcludeConstraintElement { + fn span(&self) -> Span { + let mut span = self.column.column.expr.span(); + if let Some(opclass) = &self.column.operator_class { + span = span.union(&opclass.span()); + } + span + } +} + +/// An `EXCLUDE` constraint. +/// +/// [PostgreSql](https://www.postgresql.org/docs/current/sql-createtable.html#SQL-CREATETABLE-EXCLUDE) +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub struct ExcludeConstraint { + /// 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 ExcludeConstraint { + 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 ExcludeConstraint { + 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/dialect/generic.rs b/src/dialect/generic.rs index 674311a92..b1da4bc89 100644 --- a/src/dialect/generic.rs +++ b/src/dialect/generic.rs @@ -129,6 +129,10 @@ impl Dialect for GenericDialect { true } + fn supports_exclude_constraint(&self) -> bool { + true + } + fn supports_limit_comma(&self) -> bool { true } diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index 0e2a6158d..5eafa4468 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -1167,6 +1167,13 @@ pub trait Dialect: Debug + Any { false } + /// Returns true if the dialect supports `EXCLUDE` table constraints, e.g. + /// `EXCLUDE USING gist (c WITH &&)` in `CREATE TABLE`/`ALTER TABLE`. + /// See . + fn supports_exclude_constraint(&self) -> bool { + false + } + /// Returns true if the dialect supports the `LOAD DATA` statement fn supports_load_data(&self) -> bool { false diff --git a/src/dialect/postgresql.rs b/src/dialect/postgresql.rs index fda676eb2..f97ed1a61 100644 --- a/src/dialect/postgresql.rs +++ b/src/dialect/postgresql.rs @@ -208,6 +208,11 @@ impl Dialect for PostgreSqlDialect { true } + /// see + fn supports_exclude_constraint(&self) -> bool { + true + } + /// see fn supports_factorial_operator(&self) -> bool { true diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 3d96a1d71..b48f52cba 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -3873,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, }, @@ -9915,9 +9906,14 @@ impl<'a> Parser<'a> { .into(), )) } + Token::Word(w) + if w.keyword == Keyword::EXCLUDE && self.dialect.supports_exclude_constraint() => + { + Ok(Some(self.parse_exclude_constraint(name)?.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 +9922,112 @@ impl<'a> Parser<'a> { } } + /// Parse an `EXCLUDE` table constraint, with the leading `EXCLUDE` keyword + /// already consumed. + fn parse_exclude_constraint( + &mut self, + name: Option, + ) -> Result { + 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_exclude_constraint_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(ExcludeConstraint { + name, + index_method, + elements, + include, + where_clause, + characteristics, + }) + } + + fn parse_exclude_constraint_element( + &mut self, + ) -> Result { + // `index_elem` grammar: { col | (expr) } [ opclass ] [ ASC | DESC ] [ NULLS FIRST | LAST ]. + // Shared with `CREATE INDEX` columns. + let column = self.parse_create_index_expr()?; + self.expect_keyword_is(Keyword::WITH)?; + let operator = self.parse_exclude_constraint_operator()?; + Ok(ExcludeConstraintElement { column, 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_exclude_constraint_operator( + &mut self, + ) -> Result { + if self.parse_keyword(Keyword::OPERATOR) { + return Ok(ExcludeConstraintOperator::PgCustom( + self.parse_pg_operator_ident_parts()?, + )); + } + + // Reject structural delimiters (`,`, `)`, `;`, EOF) since they signal a + // missing operator between `WITH` and the next element / end of list. + let operator_token = self.next_token(); + if matches!( + operator_token.token, + Token::EOF | Token::RParen | Token::Comma | Token::SemiColon + ) { + return self.expected("exclusion operator", operator_token); + } + Ok(ExcludeConstraintOperator::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_common.rs b/tests/sqlparser_common.rs index 00937636b..e3694b942 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -4397,6 +4397,94 @@ fn parse_create_table_with_multiple_on_delete_fails() { .expect_err("should have failed"); } +#[test] +fn parse_exclude_constraint() { + let dialects = all_dialects_where(|d| d.supports_exclude_constraint()); + + // One AST-asserting case to lock the structure of the parsed constraint; + // every other case below relies on `verified_stmt` round-tripping. + let sql = "CREATE TABLE t (room INT, CONSTRAINT no_overlap EXCLUDE USING gist (room WITH =))"; + match dialects.verified_stmt(sql) { + Statement::CreateTable(create_table) => match &create_table.constraints[..] { + [TableConstraint::Exclude(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].column.column.expr, + Expr::Identifier(Ident::new("room")) + ); + assert_eq!(c.elements[0].operator.to_string(), "="); + assert!(c.elements[0].column.operator_class.is_none()); + assert!(c.include.is_empty()); + assert!(c.where_clause.is_none()); + assert!(c.characteristics.is_none()); + } + other => panic!("expected single Exclude constraint, got {other:?}"), + }, + other => panic!("expected CreateTable, got {other:?}"), + } + + // Round-trip a representative range of forms (`USING`/no `USING`, + // `INCLUDE`, `WHERE`, `DEFERRABLE`, multi-element, ordering options, + // operator class, function expression, schema-qualified `OPERATOR(...)`, + // collation, `ALTER TABLE ... ADD CONSTRAINT ... EXCLUDE`). + for sql in [ + "CREATE TABLE t (col INT, EXCLUDE (col WITH =))", + "CREATE TABLE t (room INT, during INT, EXCLUDE USING gist (room WITH =, during WITH &&))", + "CREATE TABLE t (col INT, EXCLUDE USING gist (col WITH =) INCLUDE (col))", + "CREATE TABLE t (col INT, EXCLUDE USING gist (col WITH =) WHERE (col > 0))", + "CREATE TABLE t (col INT, EXCLUDE USING gist (col WITH =) DEFERRABLE INITIALLY DEFERRED)", + "CREATE TABLE t (col INT, EXCLUDE USING gist (col WITH =) NOT DEFERRABLE INITIALLY IMMEDIATE)", + "CREATE TABLE t (col INT, EXCLUDE USING btree (col ASC NULLS LAST WITH =))", + "CREATE TABLE t (col INT, EXCLUDE USING btree (col DESC NULLS FIRST WITH =))", + "CREATE TABLE t (col TEXT, EXCLUDE USING gist (col text_pattern_ops WITH =))", + "CREATE TABLE t (name TEXT, EXCLUDE USING gist ((lower(name)) text_pattern_ops WITH =))", + "CREATE TABLE t (name TEXT, EXCLUDE USING btree (name COLLATE \"C\" WITH =))", + "CREATE TABLE t (col INT, EXCLUDE USING gist (col WITH OPERATOR(pg_catalog.=)))", + "CREATE TABLE t (col INT, CONSTRAINT c EXCLUDE USING gist (col ASC WITH OPERATOR(pg_catalog.=)))", + "CREATE TABLE t (CONSTRAINT no_overlap EXCLUDE USING gist (room WITH =, during WITH &&) INCLUDE (id) WHERE (active = true))", + "ALTER TABLE t ADD CONSTRAINT no_overlap EXCLUDE USING gist (room WITH =)", + ] { + dialects.verified_stmt(sql); + } + + // Error cases: malformed EXCLUDE syntax must be rejected with a useful + // message rather than silently accepted. + for (sql, expected_fragment) in [ + ( + "CREATE TABLE t (CONSTRAINT c EXCLUDE USING gist (col))", + "Expected: WITH", + ), + ( + "CREATE TABLE t (CONSTRAINT c EXCLUDE USING gist ())", + "Expected: an expression", + ), + ( + "CREATE TABLE t (CONSTRAINT c EXCLUDE USING gist (col WITH))", + "exclusion operator", + ), + ] { + let err = dialects.parse_sql_statements(sql).unwrap_err().to_string(); + assert!( + err.contains(expected_fragment), + "expected {expected_fragment:?} in error for {sql:?}, got: {err}" + ); + } + + // Dialects that do not opt in via `supports_exclude_constraint` must + // refuse to parse `EXCLUDE` constraints. + let unsupported = all_dialects_where(|d| !d.supports_exclude_constraint()); + let sql = "CREATE TABLE t (col INT, EXCLUDE USING gist (col WITH =))"; + for dialect in unsupported.dialects { + let parser = TestedDialects::new(vec![dialect]); + assert!( + parser.parse_sql_statements(sql).is_err(), + "dialect unexpectedly accepted EXCLUDE: {sql}" + ); + } +} + #[test] fn parse_assert() { let sql = "ASSERT (SELECT COUNT(*) FROM my_table) > 0"; diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index 86315b1ef..eb634bbbb 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::*; @@ -9221,3 +9221,27 @@ fn parse_lock_table() { } } } + +#[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:?}"), + } + } +}