From d266014626ea2d91343f42460d9efffe2fb94899 Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Thu, 16 Apr 2026 10:01:42 +0900 Subject: [PATCH 01/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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