From d266014626ea2d91343f42460d9efffe2fb94899 Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Thu, 16 Apr 2026 10:01:42 +0900 Subject: [PATCH 01/15] feat(ast): add ExclusionConstraint and ExclusionElement types --- src/ast/mod.rs | 5 +- src/ast/spans.rs | 1 + src/ast/table_constraints.rs | 96 ++++++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 2 deletions(-) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 63b3db644..357b24f9c 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, + 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..c196865f8 100644 --- a/src/ast/table_constraints.rs +++ b/src/ast/table_constraints.rs @@ -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,86 @@ impl crate::ast::Spanned for ConstraintUsingIndex { start.union(&end) } } + +/// One element in an `EXCLUDE` constraint's element list. +/// +/// ` 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, + /// The exclusion operator (e.g. `&&`, `<->`, `=`). + pub operator: String, +} + +impl fmt::Display for ExclusionElement { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{} WITH {}", self.expr, self.operator) + } +} + +/// 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 { + fn union_spans>(iter: I) -> Span { + Span::union_iter(iter) + } + + union_spans( + self.name + .iter() + .map(|i| i.span) + .chain(self.index_method.iter().map(|i| i.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())), + ) + } +} From a957bb721c33b8281f5f43521717fb870c1bc03e Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Thu, 16 Apr 2026 10:01:46 +0900 Subject: [PATCH 02/15] feat(parser): parse EXCLUDE constraints in CREATE TABLE and ALTER TABLE --- src/parser/mod.rs | 52 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/src/parser/mod.rs b/src/parser/mod.rs index a5526723b..b5a18a93c 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -9915,6 +9915,50 @@ impl<'a> Parser<'a> { .into(), )) } + Token::Word(w) if w.keyword == Keyword::EXCLUDE => { + 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) @@ -9926,6 +9970,14 @@ impl<'a> Parser<'a> { } } + fn parse_exclusion_element(&mut self) -> Result { + let expr = self.parse_expr()?; + self.expect_keyword_is(Keyword::WITH)?; + let operator_token = self.next_token(); + let operator = operator_token.token.to_string(); + Ok(ExclusionElement { expr, operator }) + } + fn parse_optional_nulls_distinct(&mut self) -> Result { Ok(if self.parse_keyword(Keyword::NULLS) { let not = self.parse_keyword(Keyword::NOT); From 6f47488ea1cdc4315ffa058067c23fface8bad17 Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Thu, 16 Apr 2026 10:01:51 +0900 Subject: [PATCH 03/15] test: add EXCLUDE constraint parsing tests --- tests/sqlparser_postgres.rs | 134 ++++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index 07b62dd93..66458f124 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -9134,6 +9134,51 @@ 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].operator, "="); + assert_eq!(c.include.len(), 0); + assert!(c.where_clause.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, "="); + assert_eq!(c.elements[1].operator, "&&"); + } + other => panic!("Expected Exclusion, got {other:?}"), + } + } + _ => panic!("Expected CreateTable"), + } +} + #[test] fn parse_lock_table() { pg_and_generic().one_statement_parses_to( @@ -9193,3 +9238,92 @@ 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()); + } + 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.include, vec![Ident::new("col")]); + } + 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 =)"; + pg().verified_stmt(sql); +} + +#[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); +} From 17cbba9cf2ab0f2c135668bf716255ed9404d377 Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Fri, 17 Apr 2026 06:58:38 +0900 Subject: [PATCH 04/15] refactor: clean up exclusion constraint additions for upstream review --- src/ast/table_constraints.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/ast/table_constraints.rs b/src/ast/table_constraints.rs index c196865f8..22ff1e77c 100644 --- a/src/ast/table_constraints.rs +++ b/src/ast/table_constraints.rs @@ -638,6 +638,12 @@ impl fmt::Display for ExclusionElement { } } +impl crate::ast::Spanned for ExclusionElement { + fn span(&self) -> Span { + self.expr.span() + } +} + /// A PostgreSQL `EXCLUDE` constraint. /// /// `[ CONSTRAINT ] EXCLUDE [ USING ] ( WITH [, ...] ) [ INCLUDE () ] [ WHERE () ]` @@ -693,6 +699,7 @@ impl crate::ast::Spanned for ExclusionConstraint { .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())), From a0cacb2124a96f0dfece7f44a3c58b88d1940cee Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Fri, 17 Apr 2026 07:07:59 +0900 Subject: [PATCH 05/15] fix: tighten exclusion constraint parsing per upstream review --- src/ast/table_constraints.rs | 1 + src/parser/mod.rs | 9 +++++++ tests/sqlparser_postgres.rs | 50 +++++++++++++++++++++++++++++++++++- 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/ast/table_constraints.rs b/src/ast/table_constraints.rs index 22ff1e77c..d0637c319 100644 --- a/src/ast/table_constraints.rs +++ b/src/ast/table_constraints.rs @@ -640,6 +640,7 @@ impl fmt::Display for ExclusionElement { impl crate::ast::Spanned for ExclusionElement { fn span(&self) -> Span { + // Operator is stored as a plain String with no source span; only expr contributes. self.expr.span() } } diff --git a/src/parser/mod.rs b/src/parser/mod.rs index b5a18a93c..f8d064917 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -9974,6 +9974,15 @@ impl<'a> Parser<'a> { let expr = self.parse_expr()?; self.expect_keyword_is(Keyword::WITH)?; let operator_token = self.next_token(); + match &operator_token.token { + Token::EOF + | Token::RParen + | Token::Comma + | Token::SemiColon => { + return self.expected("exclusion operator", operator_token); + } + _ => {} + } let operator = operator_token.token.to_string(); Ok(ExclusionElement { expr, operator }) } diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index 66458f124..4c86c7583 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -9170,7 +9170,15 @@ fn parse_exclude_constraint_multi_element() { assert_eq!(c.index_method, Some(Ident::new("gist"))); assert_eq!(c.elements.len(), 2); assert_eq!(c.elements[0].operator, "="); + assert_eq!( + c.elements[0].expr, + Expr::Identifier(Ident::new("room")) + ); assert_eq!(c.elements[1].operator, "&&"); + assert_eq!( + c.elements[1].expr, + Expr::Identifier(Ident::new("during")) + ); } other => panic!("Expected Exclusion, got {other:?}"), } @@ -9249,6 +9257,23 @@ fn parse_exclude_constraint_with_where() { 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( + (Value::Number("0".to_string(), false)) + .with_empty_span() + ) + ); + } + other => panic!("Expected BinaryOp, got {other:?}"), + } } other => panic!("Expected Exclusion, got {other:?}"), } @@ -9319,7 +9344,18 @@ fn parse_exclude_constraint_deferrable() { fn parse_exclude_constraint_in_alter_table() { let sql = "ALTER TABLE t ADD CONSTRAINT no_overlap EXCLUDE USING gist (room WITH =)"; - pg().verified_stmt(sql); + match pg().verified_stmt(sql) { + Statement::AlterTable { operations, .. } => { + match &operations[0] { + AlterTableOperation::AddConstraint(TableConstraint::Exclusion(c)) => { + assert_eq!(c.name, Some(Ident::new("no_overlap"))); + assert_eq!(c.elements[0].operator, "="); + } + other => panic!("Expected AddConstraint(Exclusion), got {other:?}"), + } + } + _ => panic!("Expected AlterTable"), + } } #[test] @@ -9327,3 +9363,15 @@ 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 exclude_missing_with_keyword_errors() { + let sql = "CREATE TABLE t (CONSTRAINT c EXCLUDE USING gist (col))"; + assert!(pg().parse_sql_statements(sql).is_err()); +} + +#[test] +fn exclude_empty_element_list_errors() { + let sql = "CREATE TABLE t (CONSTRAINT c EXCLUDE USING gist ())"; + assert!(pg().parse_sql_statements(sql).is_err()); +} From a189d8f51692dce22920fe19574620a6e310ac0e Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Fri, 17 Apr 2026 08:34:14 +0900 Subject: [PATCH 06/15] feat(ast): extend ExclusionElement with operator_class and order options Per the Postgres `index_elem` grammar, an exclusion element may carry an operator class and ASC/DESC/NULLS FIRST|LAST qualifiers between the expression and the `WITH ` tail. Add the two missing fields and route their display to the canonical position. Also simplify `ExclusionConstraint::span` by calling `Span::union_iter` directly and add `String` to the `no_std` import set so the crate continues to build with `--no-default-features`. --- src/ast/table_constraints.rs | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/src/ast/table_constraints.rs b/src/ast/table_constraints.rs index d0637c319..6b1bdec0e 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}; @@ -619,7 +619,7 @@ impl crate::ast::Spanned for ConstraintUsingIndex { /// One element in an `EXCLUDE` constraint's element list. /// -/// ` WITH ` +/// `{ column_name | ( expression ) } [ opclass ] [ ASC | DESC ] [ NULLS { FIRST | LAST } ] WITH ` /// /// See #[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] @@ -628,20 +628,32 @@ impl crate::ast::Spanned for ConstraintUsingIndex { pub struct ExclusionElement { /// The index expression or column name. pub expr: Expr, - /// The exclusion operator (e.g. `&&`, `<->`, `=`). + /// 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. Either a simple token (`&&`, `=`, `<->`) or the + /// Postgres schema-qualified form `OPERATOR(schema.op)`. pub operator: String, } impl fmt::Display for ExclusionElement { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "{} WITH {}", self.expr, self.operator) + 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 { - // Operator is stored as a plain String with no source span; only expr contributes. - self.expr.span() + let mut span = self.expr.span(); + if let Some(opclass) = &self.operator_class { + span = span.union(&opclass.span()); + } + span } } @@ -691,11 +703,7 @@ impl fmt::Display for ExclusionConstraint { impl crate::ast::Spanned for ExclusionConstraint { fn span(&self) -> Span { - fn union_spans>(iter: I) -> Span { - Span::union_iter(iter) - } - - union_spans( + Span::union_iter( self.name .iter() .map(|i| i.span) From dddb2ce72a4d152b35d05d0f5e0aad962794ccb5 Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Fri, 17 Apr 2026 08:35:01 +0900 Subject: [PATCH 07/15] fix(parser): gate EXCLUDE by PG dialect; parse OPERATOR() and element ordering Three related fixes to the `EXCLUDE` table-constraint arm: - Guard the match on `PostgreSqlDialect | GenericDialect` so MySQL, SQLite, and others can continue to use `exclude` as a column name. Previously the arm fired on any dialect and hard-errored once the expected continuation was missing, instead of falling through to `parse_column_def`. - Extend `parse_exclusion_element` to parse the optional `opclass`, `ASC`/`DESC`, and `NULLS FIRST`/`LAST` qualifiers that precede `WITH `, matching the PG `index_elem` grammar. - Add `parse_exclusion_operator` so the schema-qualified `OPERATOR(schema.op)` form is consumed as one unit. The previous single-token lookahead silently stopped at `OPERATOR` and left the parenthesised path to corrupt the surrounding parse. --- src/parser/mod.rs | 60 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 49 insertions(+), 11 deletions(-) diff --git a/src/parser/mod.rs b/src/parser/mod.rs index f8d064917..5f4e200d1 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -9915,7 +9915,10 @@ impl<'a> Parser<'a> { .into(), )) } - Token::Word(w) if w.keyword == Keyword::EXCLUDE => { + 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 { @@ -9923,8 +9926,7 @@ impl<'a> Parser<'a> { }; self.expect_token(&Token::LParen)?; - let elements = - self.parse_comma_separated(|p| p.parse_exclusion_element())?; + let elements = self.parse_comma_separated(|p| p.parse_exclusion_element())?; self.expect_token(&Token::RParen)?; let include = if self.parse_keyword(Keyword::INCLUDE) { @@ -9972,19 +9974,55 @@ impl<'a> Parser<'a> { fn parse_exclusion_element(&mut self) -> Result { let expr = self.parse_expr()?; + + // `index_elem` grammar: [ opclass ] [ ASC | DESC ] [ NULLS FIRST | LAST ] + let operator_class: Option = if self + .peek_one_of_keywords(&[Keyword::ASC, Keyword::DESC, Keyword::NULLS, Keyword::WITH]) + .is_some() + { + None + } else { + self.maybe_parse(|p| p.parse_object_name(false))? + }; + let order = self.parse_order_by_options()?; + 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) { + self.expect_token(&Token::LParen)?; + let mut parts = vec![]; + loop { + self.advance_token(); + parts.push(self.get_current_token().to_string()); + if !self.consume_token(&Token::Period) { + break; + } + } + self.expect_token(&Token::RParen)?; + return Ok(format!("OPERATOR({})", parts.join("."))); + } + let operator_token = self.next_token(); match &operator_token.token { - Token::EOF - | Token::RParen - | Token::Comma - | Token::SemiColon => { - return self.expected("exclusion operator", operator_token); + Token::EOF | Token::RParen | Token::Comma | Token::SemiColon => { + self.expected("exclusion operator", operator_token) } - _ => {} + _ => Ok(operator_token.token.to_string()), } - let operator = operator_token.token.to_string(); - Ok(ExclusionElement { expr, operator }) } fn parse_optional_nulls_distinct(&mut self) -> Result { From 2d3774998365c007c479fb036759019270e99d0d Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Fri, 17 Apr 2026 08:40:35 +0900 Subject: [PATCH 08/15] test: realign EXCLUDE tests to current APIs and expand coverage Update the existing EXCLUDE tests to the current upstream APIs: - `Statement::AlterTable` is a tuple variant wrapping `AlterTable` - `AlterTableOperation::AddConstraint` is a struct variant with `{ constraint, not_valid }` - `Value::Number` takes `BigDecimal` under `--all-features`; use the `number()` helper so the tests compile in CI's feature matrix Expand coverage following upstream review: - `NOT DEFERRABLE INITIALLY IMMEDIATE` complement to the existing `DEFERRABLE INITIALLY DEFERRED` case - Operator class: `col text_pattern_ops WITH =` - Ordering qualifiers: `ASC NULLS LAST`, `DESC NULLS FIRST` - Parenthesised function expression as element: `(lower(name))` - Schema-qualified operator: `OPERATOR(pg_catalog.=)` - Tighter error assertions on missing `WITH` and missing operator - Negative test for non-PostgreSQL dialects (and smoke test that `exclude` remains a legal column name in MySQL and SQLite) --- tests/sqlparser_postgres.rs | 208 +++++++++++++++++++++++++++++------- 1 file changed, 167 insertions(+), 41 deletions(-) diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index 4c86c7583..d21035f4c 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::*; @@ -9136,8 +9136,7 @@ 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 =))"; + 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()); @@ -9146,9 +9145,13 @@ fn parse_exclude_constraint_basic() { 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, "="); + 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:?}"), } @@ -9170,15 +9173,9 @@ fn parse_exclude_constraint_multi_element() { assert_eq!(c.index_method, Some(Ident::new("gist"))); assert_eq!(c.elements.len(), 2); assert_eq!(c.elements[0].operator, "="); - assert_eq!( - c.elements[0].expr, - Expr::Identifier(Ident::new("room")) - ); + assert_eq!(c.elements[0].expr, Expr::Identifier(Ident::new("room"))); assert_eq!(c.elements[1].operator, "&&"); - assert_eq!( - c.elements[1].expr, - Expr::Identifier(Ident::new("during")) - ); + assert_eq!(c.elements[1].expr, Expr::Identifier(Ident::new("during"))); } other => panic!("Expected Exclusion, got {other:?}"), } @@ -9249,8 +9246,7 @@ 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))"; + 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()); @@ -9259,18 +9255,9 @@ fn parse_exclude_constraint_with_where() { 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!(**left, Expr::Identifier(Ident::new("col"))); assert_eq!(*op, BinaryOperator::Gt); - assert_eq!( - **right, - Expr::Value( - (Value::Number("0".to_string(), false)) - .with_empty_span() - ) - ); + assert_eq!(**right, Expr::Value(number("0").with_empty_span())); } other => panic!("Expected BinaryOp, got {other:?}"), } @@ -9284,14 +9271,16 @@ fn parse_exclude_constraint_with_where() { #[test] fn parse_exclude_constraint_with_include() { - let sql = - "CREATE TABLE t (col INT, EXCLUDE USING gist (col WITH =) INCLUDE (col))"; + 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:?}"), } @@ -9328,10 +9317,7 @@ fn parse_exclude_constraint_deferrable() { TableConstraint::Exclusion(c) => { let characteristics = c.characteristics.as_ref().unwrap(); assert_eq!(characteristics.deferrable, Some(true)); - assert_eq!( - characteristics.initially, - Some(DeferrableInitial::Deferred) - ); + assert_eq!(characteristics.initially, Some(DeferrableInitial::Deferred)); } other => panic!("Expected Exclusion, got {other:?}"), } @@ -9342,18 +9328,18 @@ fn parse_exclude_constraint_deferrable() { #[test] fn parse_exclude_constraint_in_alter_table() { - let sql = - "ALTER TABLE t ADD CONSTRAINT no_overlap EXCLUDE USING gist (room WITH =)"; + let sql = "ALTER TABLE t ADD CONSTRAINT no_overlap EXCLUDE USING gist (room WITH =)"; match pg().verified_stmt(sql) { - Statement::AlterTable { operations, .. } => { - match &operations[0] { - AlterTableOperation::AddConstraint(TableConstraint::Exclusion(c)) => { - assert_eq!(c.name, Some(Ident::new("no_overlap"))); - assert_eq!(c.elements[0].operator, "="); - } - other => panic!("Expected AddConstraint(Exclusion), got {other:?}"), + 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, "="); } - } + other => panic!("Expected AddConstraint(Exclusion), got {other:?}"), + }, _ => panic!("Expected AlterTable"), } } @@ -9364,10 +9350,108 @@ fn roundtrip_exclude_constraint() { 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_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].operator_class, + Some(ObjectName::from(vec![Ident::new("text_pattern_ops")])) + ); + assert_eq!(c.elements[0].operator, "="); + } + 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() { + pg().verified_stmt( + "CREATE TABLE t (col INT, EXCLUDE USING btree (col DESC NULLS FIRST WITH =))", + ); +} + +#[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); + assert!(matches!(c.elements[0].expr, Expr::Nested(_))); + assert_eq!( + c.elements[0].operator_class, + Some(ObjectName::from(vec![Ident::new("text_pattern_ops")])) + ); + } + 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) => { + assert_eq!(c.elements[0].operator, "OPERATOR(pg_catalog.=)"); + } + 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))"; - assert!(pg().parse_sql_statements(sql).is_err()); + let err = pg().parse_sql_statements(sql).unwrap_err(); + assert!( + err.to_string().contains("Expected: WITH"), + "unexpected error: {err}" + ); } #[test] @@ -9375,3 +9459,45 @@ fn exclude_empty_element_list_errors() { let sql = "CREATE TABLE t (CONSTRAINT c EXCLUDE USING gist ())"; assert!(pg().parse_sql_statements(sql).is_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 exclude_rejected_in_non_postgres_dialects() { + 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]); + assert!( + parser.parse_sql_statements(sql).is_ok(), + "dialect {type_name} failed to parse `exclude` as column name" + ); + } +} From 90803e045da78472949bda9a1edab5fa5ff37441 Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Fri, 17 Apr 2026 08:45:17 +0900 Subject: [PATCH 09/15] refactor: reuse parse_order_by_expr_inner and tighten exclude tests Follow-up to the review feedback on the EXCLUDE constraint changes: - Replace the hand-rolled `{ expr [opclass] [ASC|DESC] [NULLS ...] }` lookahead inside `parse_exclusion_element` with a direct call to `parse_order_by_expr_inner(true)` so the `index_elem` grammar lives in a single place. `WITH FILL` is gated on a separate dialect capability, so EXCLUDE (PG-only) cannot accidentally consume it. - Add structural assertions to `parse_exclude_constraint_desc_nulls_first` to mirror the ascending-order test instead of relying on the round-trip alone. - Assert that `exclude` survives as a column name in MySQL/SQLite by checking the parsed AST rather than `is_ok()`. - Tighten `exclude_empty_element_list_errors` and strengthen the operator-class and function-expression tests with explicit `expr` assertions for completeness. - Document why `GenericDialect` is intentionally excluded from the rejection sweep (it opts into PG-style EXCLUDE). --- src/parser/mod.rs | 22 ++++++++--------- tests/sqlparser_postgres.rs | 48 ++++++++++++++++++++++++++++++------- 2 files changed, 49 insertions(+), 21 deletions(-) diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 5f4e200d1..da7df1912 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -9973,18 +9973,16 @@ impl<'a> Parser<'a> { } fn parse_exclusion_element(&mut self) -> Result { - let expr = self.parse_expr()?; - - // `index_elem` grammar: [ opclass ] [ ASC | DESC ] [ NULLS FIRST | LAST ] - let operator_class: Option = if self - .peek_one_of_keywords(&[Keyword::ASC, Keyword::DESC, Keyword::NULLS, Keyword::WITH]) - .is_some() - { - None - } else { - self.maybe_parse(|p| p.parse_object_name(false))? - }; - let order = self.parse_order_by_options()?; + // `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()?; diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index d21035f4c..12909955b 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -9376,6 +9376,7 @@ fn parse_exclude_constraint_operator_class() { 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")])) @@ -9405,9 +9406,17 @@ fn parse_exclude_constraint_asc_nulls_last() { #[test] fn parse_exclude_constraint_desc_nulls_first() { - pg().verified_stmt( - "CREATE TABLE t (col INT, EXCLUDE USING btree (col DESC NULLS FIRST WITH =))", - ); + 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] @@ -9418,11 +9427,20 @@ fn parse_exclude_constraint_function_expression() { Statement::CreateTable(create_table) => match &create_table.constraints[0] { TableConstraint::Exclusion(c) => { assert_eq!(c.elements.len(), 1); - assert!(matches!(c.elements[0].expr, Expr::Nested(_))); + 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, "="); } other => panic!("Expected Exclusion, got {other:?}"), }, @@ -9457,7 +9475,11 @@ fn exclude_missing_with_keyword_errors() { #[test] fn exclude_empty_element_list_errors() { let sql = "CREATE TABLE t (CONSTRAINT c EXCLUDE USING gist ())"; - assert!(pg().parse_sql_statements(sql).is_err()); + let err = pg().parse_sql_statements(sql).unwrap_err(); + assert!( + err.to_string().contains("Expected"), + "unexpected error: {err}" + ); } #[test] @@ -9472,6 +9494,8 @@ fn exclude_missing_operator_errors() { #[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 @@ -9495,9 +9519,15 @@ fn exclude_as_column_name_parses_in_mysql_and_sqlite() { ] { let type_name = format!("{dialect:?}"); let parser = TestedDialects::new(vec![dialect]); - assert!( - parser.parse_sql_statements(sql).is_ok(), - "dialect {type_name} failed to parse `exclude` as column name" - ); + 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:?}"), + } } } From 442e196cf5b098a7e302bd3d79bda7bc7173d2b5 Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Fri, 17 Apr 2026 14:55:11 +0900 Subject: [PATCH 10/15] fix: resolve collapsible_match clippy lints in parser Eight pre-existing upstream lint violations in src/parser/mod.rs flagged by clippy::collapsible_match on the CI toolchain (rust 1.95.0). Each fix collapses an if block inside a match arm into a match guard. Locations fixed: - Line 512: Token::Word arm in parse_statements loop - Line 1309: Token::Word/SingleQuotedString arm in parse_wildcard_expr - Line 5035: Token::Word arm in parse_body_statements - Lines 8381/8398/8412/8426/8436: Hive row format delimiter arms --- src/parser/mod.rs | 170 ++++++++++++++++++++++------------------------ 1 file changed, 80 insertions(+), 90 deletions(-) diff --git a/src/parser/mod.rs b/src/parser/mod.rs index f8d064917..af3dcc1f4 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,41 @@ 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); } } } @@ -5031,10 +5031,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 +8377,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; From e02613e3471f0b55b93a9a12842ea41216e2cbc7 Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Fri, 17 Apr 2026 17:24:01 +0900 Subject: [PATCH 11/15] style: cargo fmt --- .worktrees/create-aggregate | 1 + .worktrees/fix-pr-2307-ci | 1 + .worktrees/foreign-table-fdw | 1 + .worktrees/text-search | 1 + src/parser/mod.rs | 3 +-- 5 files changed, 5 insertions(+), 2 deletions(-) create mode 160000 .worktrees/create-aggregate create mode 160000 .worktrees/fix-pr-2307-ci create mode 160000 .worktrees/foreign-table-fdw create mode 160000 .worktrees/text-search diff --git a/.worktrees/create-aggregate b/.worktrees/create-aggregate new file mode 160000 index 000000000..aff2e8157 --- /dev/null +++ b/.worktrees/create-aggregate @@ -0,0 +1 @@ +Subproject commit aff2e815721990a0c3a447e7de6f2b2ac6ede69b diff --git a/.worktrees/fix-pr-2307-ci b/.worktrees/fix-pr-2307-ci new file mode 160000 index 000000000..90803e045 --- /dev/null +++ b/.worktrees/fix-pr-2307-ci @@ -0,0 +1 @@ +Subproject commit 90803e045da78472949bda9a1edab5fa5ff37441 diff --git a/.worktrees/foreign-table-fdw b/.worktrees/foreign-table-fdw new file mode 160000 index 000000000..ef2ce4146 --- /dev/null +++ b/.worktrees/foreign-table-fdw @@ -0,0 +1 @@ +Subproject commit ef2ce4146cfb111e9f569697367f038be3fab72c diff --git a/.worktrees/text-search b/.worktrees/text-search new file mode 160000 index 000000000..7688828a9 --- /dev/null +++ b/.worktrees/text-search @@ -0,0 +1 @@ +Subproject commit 7688828a9d0698d7aacd601543d818cb6425e82d diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 6cb1789a2..342d80790 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -1338,8 +1338,7 @@ impl<'a> Parser<'a> { )); } _ => { - return self - .expected("an identifier or a '*' after '.'", next_token); + return self.expected("an identifier or a '*' after '.'", next_token); } } } From 837b5a04265a8df58ce47def903a0259aaa0c44c Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Fri, 17 Apr 2026 17:24:33 +0900 Subject: [PATCH 12/15] fix: remove accidentally committed worktree dirs --- .gitignore | 2 +- .worktrees/create-aggregate | 1 - .worktrees/fix-pr-2307-ci | 1 - .worktrees/foreign-table-fdw | 1 - .worktrees/text-search | 1 - 5 files changed, 1 insertion(+), 5 deletions(-) delete mode 160000 .worktrees/create-aggregate delete mode 160000 .worktrees/fix-pr-2307-ci delete mode 160000 .worktrees/foreign-table-fdw delete mode 160000 .worktrees/text-search 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/.worktrees/create-aggregate b/.worktrees/create-aggregate deleted file mode 160000 index aff2e8157..000000000 --- a/.worktrees/create-aggregate +++ /dev/null @@ -1 +0,0 @@ -Subproject commit aff2e815721990a0c3a447e7de6f2b2ac6ede69b diff --git a/.worktrees/fix-pr-2307-ci b/.worktrees/fix-pr-2307-ci deleted file mode 160000 index 90803e045..000000000 --- a/.worktrees/fix-pr-2307-ci +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 90803e045da78472949bda9a1edab5fa5ff37441 diff --git a/.worktrees/foreign-table-fdw b/.worktrees/foreign-table-fdw deleted file mode 160000 index ef2ce4146..000000000 --- a/.worktrees/foreign-table-fdw +++ /dev/null @@ -1 +0,0 @@ -Subproject commit ef2ce4146cfb111e9f569697367f038be3fab72c diff --git a/.worktrees/text-search b/.worktrees/text-search deleted file mode 160000 index 7688828a9..000000000 --- a/.worktrees/text-search +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 7688828a9d0698d7aacd601543d818cb6425e82d From 228c96943856bd40964ef8a671b9b20cd2650835 Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Fri, 17 Apr 2026 17:32:56 +0900 Subject: [PATCH 13/15] feat(ast): model exclusion operator as an enum Replace the free-form `operator: String` on `ExclusionElement` with an `ExclusionOperator` enum distinguishing a single operator token from the Postgres schema-qualified `OPERATOR(schema.op)` form. Downstream visitors and rewriters can now pattern-match on the two cases instead of re-parsing a string. Factor the `OPERATOR(schema.op)` body out of the binary-operator path into a shared `parse_pg_operator_ident_parts` helper and reuse it from `parse_exclusion_operator` so the two call sites stay in lockstep. Add a `COLLATE` round-trip test for exclusion elements; `COLLATE` is consumed by the shared expression parser, so the new type exercises that flow end-to-end. --- src/ast/mod.rs | 4 +-- src/ast/table_constraints.rs | 27 ++++++++++++++++-- src/parser/mod.rs | 55 ++++++++++++++++++------------------ tests/sqlparser_postgres.rs | 30 ++++++++++++++------ 4 files changed, 75 insertions(+), 41 deletions(-) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 357b24f9c..fd3a1f978 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -139,8 +139,8 @@ pub mod helpers; pub mod table_constraints; pub use table_constraints::{ CheckConstraint, ConstraintUsingIndex, ExclusionConstraint, ExclusionElement, - ForeignKeyConstraint, FullTextOrSpatialConstraint, IndexConstraint, PrimaryKeyConstraint, - TableConstraint, UniqueConstraint, + ExclusionOperator, ForeignKeyConstraint, FullTextOrSpatialConstraint, IndexConstraint, + PrimaryKeyConstraint, TableConstraint, UniqueConstraint, }; mod operator; mod query; diff --git a/src/ast/table_constraints.rs b/src/ast/table_constraints.rs index 6b1bdec0e..3331967ef 100644 --- a/src/ast/table_constraints.rs +++ b/src/ast/table_constraints.rs @@ -617,6 +617,28 @@ impl crate::ast::Spanned for ConstraintUsingIndex { } } +/// 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 ` @@ -632,9 +654,8 @@ pub struct ExclusionElement { pub operator_class: Option, /// Ordering options (ASC/DESC, NULLS FIRST/LAST). pub order: OrderByOptions, - /// The exclusion operator. Either a simple token (`&&`, `=`, `<->`) or the - /// Postgres schema-qualified form `OPERATOR(schema.op)`. - pub operator: String, + /// The exclusion operator. + pub operator: ExclusionOperator, } impl fmt::Display for ExclusionElement { diff --git a/src/parser/mod.rs b/src/parser/mod.rs index da7df1912..4c138940d 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -3874,21 +3874,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, }, @@ -9999,19 +9990,11 @@ impl<'a> Parser<'a> { /// /// 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 { + fn parse_exclusion_operator(&mut self) -> Result { if self.parse_keyword(Keyword::OPERATOR) { - self.expect_token(&Token::LParen)?; - let mut parts = vec![]; - loop { - self.advance_token(); - parts.push(self.get_current_token().to_string()); - if !self.consume_token(&Token::Period) { - break; - } - } - self.expect_token(&Token::RParen)?; - return Ok(format!("OPERATOR({})", parts.join("."))); + return Ok(ExclusionOperator::PgCustom( + self.parse_pg_operator_ident_parts()?, + )); } let operator_token = self.next_token(); @@ -10019,8 +10002,26 @@ impl<'a> Parser<'a> { Token::EOF | Token::RParen | Token::Comma | Token::SemiColon => { self.expected("exclusion operator", operator_token) } - _ => Ok(operator_token.token.to_string()), + _ => 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)?; + 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 { diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index 12909955b..8533a54fc 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -9146,7 +9146,7 @@ fn parse_exclude_constraint_basic() { 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, "="); + 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); @@ -9172,9 +9172,9 @@ fn parse_exclude_constraint_multi_element() { 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, "="); + 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, "&&"); + 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:?}"), @@ -9336,7 +9336,7 @@ fn parse_exclude_constraint_in_alter_table() { .. } => { assert_eq!(c.name, Some(Ident::new("no_overlap"))); - assert_eq!(c.elements[0].operator, "="); + assert_eq!(c.elements[0].operator.to_string(), "="); } other => panic!("Expected AddConstraint(Exclusion), got {other:?}"), }, @@ -9369,6 +9369,15 @@ fn parse_exclude_constraint_not_deferrable_initially_immediate() { } } +#[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 =))"; @@ -9381,7 +9390,7 @@ fn parse_exclude_constraint_operator_class() { c.elements[0].operator_class, Some(ObjectName::from(vec![Ident::new("text_pattern_ops")])) ); - assert_eq!(c.elements[0].operator, "="); + assert_eq!(c.elements[0].operator.to_string(), "="); } other => panic!("Expected Exclusion, got {other:?}"), }, @@ -9440,7 +9449,7 @@ fn parse_exclude_constraint_function_expression() { c.elements[0].operator_class, Some(ObjectName::from(vec![Ident::new("text_pattern_ops")])) ); - assert_eq!(c.elements[0].operator, "="); + assert_eq!(c.elements[0].operator.to_string(), "="); } other => panic!("Expected Exclusion, got {other:?}"), }, @@ -9453,9 +9462,12 @@ 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) => { - assert_eq!(c.elements[0].operator, "OPERATOR(pg_catalog.=)"); - } + 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"), From 460c098edb845c48d202636099bcfd849e5c4cb2 Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Fri, 17 Apr 2026 18:47:05 +0900 Subject: [PATCH 14/15] fix: address review feedback on exclusion constraint PR --- src/parser/mod.rs | 7 ++++++- tests/sqlparser_postgres.rs | 9 ++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 342d80790..f04f3f1ad 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -9952,7 +9952,7 @@ impl<'a> Parser<'a> { } _ => { 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) @@ -9991,6 +9991,11 @@ impl<'a> Parser<'a> { fn parse_exclusion_operator(&mut self) -> Result { if self.parse_keyword(Keyword::OPERATOR) { self.expect_token(&Token::LParen)?; + let peek = self.peek_token_ref(); + if peek.token == Token::RParen { + let token = self.next_token(); + return self.expected("operator name", token); + } let mut parts = vec![]; loop { self.advance_token(); diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index 12909955b..fa7896d24 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -9477,7 +9477,7 @@ 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"), + err.to_string().contains("Expected: an expression"), "unexpected error: {err}" ); } @@ -9492,6 +9492,13 @@ fn exclude_missing_operator_errors() { ); } +#[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 From 60b5fd253a525c7f5e16f6f3bbd203d0cf81bf88 Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Sat, 25 Apr 2026 08:17:29 +0900 Subject: [PATCH 15/15] fix(exclude): address upstream review feedback on PR #2307 - Rename `ExclusionConstraint`/`ExclusionElement`/`ExclusionOperator` (and `TableConstraint::Exclusion`) to `ExcludeConstraint`/ `ExcludeConstraintElement`/`ExcludeConstraintOperator`/ `TableConstraint::Exclude` to match the SQL keyword. - Tighten doc comments on the new types: add the canonical `[PostgreSQL](...)` doc link to each and drop redundant grammar sketches where the variant doc already carries it. - Introduce `Dialect::supports_exclude_constraint()` (default `false`, enabled for `PostgreSqlDialect` and `GenericDialect`) and replace the inline `dialect_of!` macro guard with the new method. - Extract the `EXCLUDE` parsing into `parse_exclude_constraint`, `parse_exclude_constraint_element`, and `parse_exclude_constraint_operator` instead of inlining the body of the constraint match arm. - Switch `ExcludeConstraintElement` to embed `IndexColumn` directly, parsed via `parse_create_index_expr`, instead of destructuring an `OrderByExpr`. - Replace the structural-delimiter `match` in `parse_exclude_constraint_operator` with an explicit `if matches!` guard plus a comment explaining the missing-operator case. - Move the `EXCLUDE` constraint tests out of `sqlparser_postgres.rs` into a single `parse_exclude_constraint` test in `sqlparser_common.rs` driven by `all_dialects_where(|d| d.supports_exclude_constraint())`, leaning on `verified_stmt` round-trips for breadth. --- src/ast/mod.rs | 4 +- src/ast/spans.rs | 2 +- src/ast/table_constraints.rs | 72 ++++---- src/dialect/generic.rs | 4 + src/dialect/mod.rs | 7 + src/dialect/postgresql.rs | 5 + src/parser/mod.rs | 136 +++++++------- tests/sqlparser_common.rs | 88 +++++++++ tests/sqlparser_postgres.rs | 333 ----------------------------------- 9 files changed, 206 insertions(+), 445 deletions(-) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 1b174bef8..f1866f687 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -138,8 +138,8 @@ mod dml; pub mod helpers; pub mod table_constraints; pub use table_constraints::{ - CheckConstraint, ConstraintUsingIndex, ExclusionConstraint, ExclusionElement, - ExclusionOperator, ForeignKeyConstraint, FullTextOrSpatialConstraint, IndexConstraint, + CheckConstraint, ConstraintUsingIndex, ExcludeConstraint, ExcludeConstraintElement, + ExcludeConstraintOperator, ForeignKeyConstraint, FullTextOrSpatialConstraint, IndexConstraint, PrimaryKeyConstraint, TableConstraint, UniqueConstraint, }; mod operator; diff --git a/src/ast/spans.rs b/src/ast/spans.rs index 4d938d619..baf5e4669 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -650,7 +650,7 @@ impl Spanned for TableConstraint { TableConstraint::FulltextOrSpatial(constraint) => constraint.span(), TableConstraint::PrimaryKeyUsingIndex(constraint) | TableConstraint::UniqueUsingIndex(constraint) => constraint.span(), - TableConstraint::Exclusion(constraint) => constraint.span(), + TableConstraint::Exclude(constraint) => constraint.span(), } } } diff --git a/src/ast/table_constraints.rs b/src/ast/table_constraints.rs index 3331967ef..5f249baee 100644 --- a/src/ast/table_constraints.rs +++ b/src/ast/table_constraints.rs @@ -20,7 +20,7 @@ use crate::ast::{ display_comma_separated, display_separated, ConstraintCharacteristics, ConstraintReferenceMatchKind, Expr, Ident, IndexColumn, IndexOption, IndexType, - KeyOrIndexDisplay, NullsDistinctOption, ObjectName, OrderByOptions, ReferentialAction, + KeyOrIndexDisplay, NullsDistinctOption, ObjectName, ReferentialAction, }; use crate::tokenizer::Span; use core::fmt; @@ -117,12 +117,12 @@ pub enum TableConstraint { /// /// [1]: https://www.postgresql.org/docs/current/sql-altertable.html UniqueUsingIndex(ConstraintUsingIndex), - /// PostgreSQL `EXCLUDE` constraint. + /// `EXCLUDE` constraint. /// /// `[ CONSTRAINT ] EXCLUDE [ USING ] ( WITH [, ...] ) [ INCLUDE () ] [ WHERE () ]` /// - /// See - Exclusion(ExclusionConstraint), + /// [PostgreSQL](https://www.postgresql.org/docs/current/sql-createtable.html#SQL-CREATETABLE-EXCLUDE) + Exclude(ExcludeConstraint), } impl From for TableConstraint { @@ -161,9 +161,9 @@ impl From for TableConstraint { } } -impl From for TableConstraint { - fn from(constraint: ExclusionConstraint) -> Self { - TableConstraint::Exclusion(constraint) +impl From for TableConstraint { + fn from(constraint: ExcludeConstraint) -> Self { + TableConstraint::Exclude(constraint) } } @@ -178,7 +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), + TableConstraint::Exclude(constraint) => constraint.fmt(f), } } } @@ -617,22 +617,24 @@ impl crate::ast::Spanned for ConstraintUsingIndex { } } -/// The operator that follows `WITH` in an `EXCLUDE` element. +/// 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 ExclusionOperator { +pub enum ExcludeConstraintOperator { /// A single operator token, e.g. `=`, `&&`, `<->`. Token(String), /// Postgres schema-qualified form: `OPERATOR(schema.op)`. PgCustom(Vec), } -impl fmt::Display for ExclusionOperator { +impl fmt::Display for ExcludeConstraintOperator { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { - ExclusionOperator::Token(token) => f.write_str(token), - ExclusionOperator::PgCustom(parts) => { + ExcludeConstraintOperator::Token(token) => f.write_str(token), + ExcludeConstraintOperator::PgCustom(parts) => { write!(f, "OPERATOR({})", display_separated(parts, ".")) } } @@ -641,58 +643,46 @@ impl fmt::Display for ExclusionOperator { /// One element in an `EXCLUDE` constraint's element list. /// -/// `{ column_name | ( expression ) } [ opclass ] [ ASC | DESC ] [ NULLS { FIRST | LAST } ] WITH ` -/// -/// See +/// [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 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, +pub struct ExcludeConstraintElement { + /// The index column (`{ column_name | ( expression ) } [ opclass ] [ ASC | DESC ] [ NULLS { FIRST | LAST } ]`). + pub column: IndexColumn, /// The exclusion operator. - pub operator: ExclusionOperator, + pub operator: ExcludeConstraintOperator, } -impl fmt::Display for ExclusionElement { +impl fmt::Display for ExcludeConstraintElement { 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) + write!(f, "{} WITH {}", self.column, self.operator) } } -impl crate::ast::Spanned for ExclusionElement { +impl crate::ast::Spanned for ExcludeConstraintElement { fn span(&self) -> Span { - let mut span = self.expr.span(); - if let Some(opclass) = &self.operator_class { + let mut span = self.column.column.expr.span(); + if let Some(opclass) = &self.column.operator_class { span = span.union(&opclass.span()); } span } } -/// A PostgreSQL `EXCLUDE` constraint. -/// -/// `[ CONSTRAINT ] EXCLUDE [ USING ] ( WITH [, ...] ) [ INCLUDE () ] [ WHERE () ]` +/// An `EXCLUDE` constraint. /// -/// See +/// [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 ExclusionConstraint { +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, + 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. @@ -701,7 +691,7 @@ pub struct ExclusionConstraint { pub characteristics: Option, } -impl fmt::Display for ExclusionConstraint { +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))?; @@ -722,7 +712,7 @@ impl fmt::Display for ExclusionConstraint { } } -impl crate::ast::Spanned for ExclusionConstraint { +impl crate::ast::Spanned for ExcludeConstraint { fn span(&self) -> Span { Span::union_iter( self.name 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 b3422c034..b48f52cba 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -9907,50 +9907,9 @@ impl<'a> Parser<'a> { )) } Token::Word(w) - if w.keyword == Keyword::EXCLUDE - && dialect_of!(self is PostgreSqlDialect | GenericDialect) => + if w.keyword == Keyword::EXCLUDE && self.dialect.supports_exclude_constraint() => { - 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(), - )) + Ok(Some(self.parse_exclude_constraint(name)?.into())) } _ => { if name.is_some() { @@ -9963,47 +9922,88 @@ 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)?; + /// 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_keyword_is(Keyword::WITH)?; - let operator = self.parse_exclusion_operator()?; + self.expect_token(&Token::LParen)?; + let elements = self.parse_comma_separated(|p| p.parse_exclude_constraint_element())?; + self.expect_token(&Token::RParen)?; - Ok(ExclusionElement { - expr, - operator_class, - order, - operator, + 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_exclusion_operator(&mut self) -> Result { + fn parse_exclude_constraint_operator( + &mut self, + ) -> Result { if self.parse_keyword(Keyword::OPERATOR) { - return Ok(ExclusionOperator::PgCustom( + 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(); - 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())), + 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 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 ab7dc30da..eb634bbbb 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -9162,56 +9162,6 @@ 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( @@ -9272,289 +9222,6 @@ 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