Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
d266014
feat(ast): add ExclusionConstraint and ExclusionElement types
fmguerreiro Apr 16, 2026
a957bb7
feat(parser): parse EXCLUDE constraints in CREATE TABLE and ALTER TABLE
fmguerreiro Apr 16, 2026
6f47488
test: add EXCLUDE constraint parsing tests
fmguerreiro Apr 16, 2026
17cbba9
refactor: clean up exclusion constraint additions for upstream review
fmguerreiro Apr 16, 2026
a0cacb2
fix: tighten exclusion constraint parsing per upstream review
fmguerreiro Apr 16, 2026
a189d8f
feat(ast): extend ExclusionElement with operator_class and order options
fmguerreiro Apr 16, 2026
dddb2ce
fix(parser): gate EXCLUDE by PG dialect; parse OPERATOR() and element…
fmguerreiro Apr 16, 2026
2d37749
test: realign EXCLUDE tests to current APIs and expand coverage
fmguerreiro Apr 16, 2026
90803e0
refactor: reuse parse_order_by_expr_inner and tighten exclude tests
fmguerreiro Apr 16, 2026
442e196
fix: resolve collapsible_match clippy lints in parser
fmguerreiro Apr 17, 2026
d8e35d9
Merge remote-tracking branch 'origin/feat/exclude-constraint-upstream…
fmguerreiro Apr 17, 2026
e02613e
style: cargo fmt
fmguerreiro Apr 17, 2026
837b5a0
fix: remove accidentally committed worktree dirs
fmguerreiro Apr 17, 2026
228c969
feat(ast): model exclusion operator as an enum
fmguerreiro Apr 17, 2026
abfc1c6
Merge remote-tracking branch 'origin/feat/exclude-constraint-upstream…
fmguerreiro Apr 17, 2026
460c098
fix: address review feedback on exclusion constraint PR
fmguerreiro Apr 17, 2026
f699bea
fix: merge review feedback with upstream EXCLUDE changes
fmguerreiro Apr 17, 2026
fc8b794
Merge remote-tracking branch 'upstream/main' into feat/exclude-constr…
fmguerreiro Apr 24, 2026
60b5fd2
fix(exclude): address upstream review feedback on PR #2307
fmguerreiro Apr 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,4 @@ Cargo.lock

*.swp

.DS_store
.DS_store.worktrees/
5 changes: 3 additions & 2 deletions src/ast/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,9 @@ mod dml;
pub mod helpers;
pub mod table_constraints;
pub use table_constraints::{
CheckConstraint, ConstraintUsingIndex, ForeignKeyConstraint, FullTextOrSpatialConstraint,
IndexConstraint, PrimaryKeyConstraint, TableConstraint, UniqueConstraint,
CheckConstraint, ConstraintUsingIndex, ExcludeConstraint, ExcludeConstraintElement,
ExcludeConstraintOperator, ForeignKeyConstraint, FullTextOrSpatialConstraint, IndexConstraint,
PrimaryKeyConstraint, TableConstraint, UniqueConstraint,
};
mod operator;
mod query;
Expand Down
1 change: 1 addition & 0 deletions src/ast/spans.rs
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,7 @@ impl Spanned for TableConstraint {
TableConstraint::FulltextOrSpatial(constraint) => constraint.span(),
TableConstraint::PrimaryKeyUsingIndex(constraint)
| TableConstraint::UniqueUsingIndex(constraint) => constraint.span(),
TableConstraint::Exclude(constraint) => constraint.span(),
}
}
}
Expand Down
125 changes: 124 additions & 1 deletion src/ast/table_constraints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ use crate::tokenizer::Span;
use core::fmt;

#[cfg(not(feature = "std"))]
use alloc::{boxed::Box, vec::Vec};
use alloc::{boxed::Box, string::String, vec::Vec};

#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
Expand Down Expand Up @@ -117,6 +117,12 @@ pub enum TableConstraint {
///
/// [1]: https://www.postgresql.org/docs/current/sql-altertable.html
UniqueUsingIndex(ConstraintUsingIndex),
/// `EXCLUDE` constraint.
///
/// `[ CONSTRAINT <name> ] EXCLUDE [ USING <index_method> ] ( <element> WITH <operator> [, ...] ) [ INCLUDE (<cols>) ] [ WHERE (<predicate>) ]`
///
/// [PostgreSQL](https://www.postgresql.org/docs/current/sql-createtable.html#SQL-CREATETABLE-EXCLUDE)
Exclude(ExcludeConstraint),
}

impl From<UniqueConstraint> for TableConstraint {
Expand Down Expand Up @@ -155,6 +161,12 @@ impl From<FullTextOrSpatialConstraint> for TableConstraint {
}
}

impl From<ExcludeConstraint> for TableConstraint {
fn from(constraint: ExcludeConstraint) -> Self {
TableConstraint::Exclude(constraint)
}
}

impl fmt::Display for TableConstraint {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
Expand All @@ -166,6 +178,7 @@ impl fmt::Display for TableConstraint {
TableConstraint::FulltextOrSpatial(constraint) => constraint.fmt(f),
TableConstraint::PrimaryKeyUsingIndex(c) => c.fmt_with_keyword(f, "PRIMARY KEY"),
TableConstraint::UniqueUsingIndex(c) => c.fmt_with_keyword(f, "UNIQUE"),
TableConstraint::Exclude(constraint) => constraint.fmt(f),
}
}
}
Expand Down Expand Up @@ -603,3 +616,113 @@ impl crate::ast::Spanned for ConstraintUsingIndex {
start.union(&end)
}
}

/// The operator that follows `WITH` in an `EXCLUDE` constraint element.
///
/// [PostgreSQL](https://www.postgresql.org/docs/current/sql-createtable.html#SQL-CREATETABLE-EXCLUDE)
#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))]
pub enum ExcludeConstraintOperator {
/// A single operator token, e.g. `=`, `&&`, `<->`.
Token(String),
/// Postgres schema-qualified form: `OPERATOR(schema.op)`.
PgCustom(Vec<String>),
}

impl fmt::Display for ExcludeConstraintOperator {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ExcludeConstraintOperator::Token(token) => f.write_str(token),
ExcludeConstraintOperator::PgCustom(parts) => {
write!(f, "OPERATOR({})", display_separated(parts, "."))
}
}
}
}

/// One element in an `EXCLUDE` constraint's element list.
///
/// [PostgreSQL](https://www.postgresql.org/docs/current/sql-createtable.html#SQL-CREATETABLE-EXCLUDE)
#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))]
pub struct ExcludeConstraintElement {
/// The index column (`{ column_name | ( expression ) } [ opclass ] [ ASC | DESC ] [ NULLS { FIRST | LAST } ]`).
pub column: IndexColumn,
/// The exclusion operator.
pub operator: ExcludeConstraintOperator,
}

impl fmt::Display for ExcludeConstraintElement {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{} WITH {}", self.column, self.operator)
}
}

impl crate::ast::Spanned for ExcludeConstraintElement {
fn span(&self) -> Span {
let mut span = self.column.column.expr.span();
if let Some(opclass) = &self.column.operator_class {
span = span.union(&opclass.span());
}
span
}
}

/// An `EXCLUDE` constraint.
///
/// [PostgreSql](https://www.postgresql.org/docs/current/sql-createtable.html#SQL-CREATETABLE-EXCLUDE)
#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))]
pub struct ExcludeConstraint {
/// Optional constraint name.
pub name: Option<Ident>,
/// Optional index method (e.g. `gist`, `spgist`).
pub index_method: Option<Ident>,
/// The list of index expressions with their exclusion operators.
pub elements: Vec<ExcludeConstraintElement>,
/// Optional list of additional columns to include in the index.
pub include: Vec<Ident>,
/// Optional `WHERE` predicate to restrict the constraint to a subset of rows.
pub where_clause: Option<Box<Expr>>,
/// Optional constraint characteristics like `DEFERRABLE`.
pub characteristics: Option<ConstraintCharacteristics>,
}

impl fmt::Display for ExcludeConstraint {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
use crate::ast::ddl::display_constraint_name;
write!(f, "{}EXCLUDE", display_constraint_name(&self.name))?;
if let Some(method) = &self.index_method {
write!(f, " USING {method}")?;
}
write!(f, " ({})", display_comma_separated(&self.elements))?;
if !self.include.is_empty() {
write!(f, " INCLUDE ({})", display_comma_separated(&self.include))?;
}
if let Some(predicate) = &self.where_clause {
write!(f, " WHERE ({predicate})")?;
}
if let Some(characteristics) = &self.characteristics {
write!(f, " {characteristics}")?;
}
Ok(())
}
}

impl crate::ast::Spanned for ExcludeConstraint {
fn span(&self) -> Span {
Span::union_iter(
self.name
.iter()
.map(|i| i.span)
.chain(self.index_method.iter().map(|i| i.span))
.chain(self.elements.iter().map(|e| e.span()))
.chain(self.include.iter().map(|i| i.span))
.chain(self.where_clause.iter().map(|e| e.span()))
.chain(self.characteristics.iter().map(|c| c.span())),
)
}
}
4 changes: 4 additions & 0 deletions src/dialect/generic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,10 @@ impl Dialect for GenericDialect {
true
}

fn supports_exclude_constraint(&self) -> bool {
true
}

fn supports_limit_comma(&self) -> bool {
true
}
Expand Down
7 changes: 7 additions & 0 deletions src/dialect/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://www.postgresql.org/docs/current/sql-createtable.html#SQL-CREATETABLE-EXCLUDE>.
fn supports_exclude_constraint(&self) -> bool {
false
}

/// Returns true if the dialect supports the `LOAD DATA` statement
fn supports_load_data(&self) -> bool {
false
Expand Down
5 changes: 5 additions & 0 deletions src/dialect/postgresql.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,11 @@ impl Dialect for PostgreSqlDialect {
true
}

/// see <https://www.postgresql.org/docs/current/sql-createtable.html#SQL-CREATETABLE-EXCLUDE>
fn supports_exclude_constraint(&self) -> bool {
true
}

/// see <https://www.postgresql.org/docs/13/functions-math.html>
fn supports_factorial_operator(&self) -> bool {
true
Expand Down
132 changes: 117 additions & 15 deletions src/parser/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3873,21 +3873,12 @@ impl<'a> Parser<'a> {
Keyword::XOR => Some(BinaryOperator::Xor),
Keyword::OVERLAPS => Some(BinaryOperator::Overlaps),
Keyword::OPERATOR if dialect_is!(dialect is PostgreSqlDialect | GenericDialect) => {
self.expect_token(&Token::LParen)?;
// there are special rules for operator names in
// postgres so we can not use 'parse_object'
// or similar.
// Postgres has special rules for operator names so we can
// not use `parse_object` or similar.
// See https://www.postgresql.org/docs/current/sql-createoperator.html
let mut idents = vec![];
loop {
self.advance_token();
idents.push(self.get_current_token().to_string());
if !self.consume_token(&Token::Period) {
break;
}
}
self.expect_token(&Token::RParen)?;
Some(BinaryOperator::PGCustomBinaryOperator(idents))
Some(BinaryOperator::PGCustomBinaryOperator(
self.parse_pg_operator_ident_parts()?,
))
}
_ => None,
},
Expand Down Expand Up @@ -9915,9 +9906,14 @@ impl<'a> Parser<'a> {
.into(),
))
}
Token::Word(w)
if w.keyword == Keyword::EXCLUDE && self.dialect.supports_exclude_constraint() =>
{
Ok(Some(self.parse_exclude_constraint(name)?.into()))
}
_ => {
if name.is_some() {
self.expected("PRIMARY, UNIQUE, FOREIGN, or CHECK", next_token)
self.expected("PRIMARY, UNIQUE, FOREIGN, CHECK, or EXCLUDE", next_token)
} else {
self.prev_token();
Ok(None)
Expand All @@ -9926,6 +9922,112 @@ impl<'a> Parser<'a> {
}
}

/// Parse an `EXCLUDE` table constraint, with the leading `EXCLUDE` keyword
/// already consumed.
fn parse_exclude_constraint(
&mut self,
name: Option<Ident>,
) -> Result<ExcludeConstraint, ParserError> {
let index_method = if self.parse_keyword(Keyword::USING) {
Some(self.parse_identifier()?)
} else {
None
};

self.expect_token(&Token::LParen)?;
let elements = self.parse_comma_separated(|p| p.parse_exclude_constraint_element())?;
self.expect_token(&Token::RParen)?;

let include = if self.parse_keyword(Keyword::INCLUDE) {
self.expect_token(&Token::LParen)?;
let cols = self.parse_comma_separated(|p| p.parse_identifier())?;
self.expect_token(&Token::RParen)?;
cols
} else {
vec![]
};

let where_clause = if self.parse_keyword(Keyword::WHERE) {
self.expect_token(&Token::LParen)?;
let predicate = self.parse_expr()?;
self.expect_token(&Token::RParen)?;
Some(Box::new(predicate))
} else {
None
};

let characteristics = self.parse_constraint_characteristics()?;

Ok(ExcludeConstraint {
name,
index_method,
elements,
include,
where_clause,
characteristics,
})
}

fn parse_exclude_constraint_element(
&mut self,
) -> Result<ExcludeConstraintElement, ParserError> {
// `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.
Comment on lines +9983 to +9985
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
///
/// Accepts either a single operator token (e.g. `=`, `&&`, `<->`) or the
/// Postgres `OPERATOR(schema.op)` form for schema-qualified operators.

Copy link
Copy Markdown
Contributor Author

@fmguerreiro fmguerreiro Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done in 60b5fd2.

fn parse_exclude_constraint_operator(
&mut self,
) -> Result<ExcludeConstraintOperator, ParserError> {
if self.parse_keyword(Keyword::OPERATOR) {
return Ok(ExcludeConstraintOperator::PgCustom(
self.parse_pg_operator_ident_parts()?,
));
}

// Reject structural delimiters (`,`, `)`, `;`, EOF) since they signal a
// missing operator between `WITH` and the next element / end of list.
let operator_token = self.next_token();
if matches!(
operator_token.token,
Token::EOF | Token::RParen | Token::Comma | Token::SemiColon
) {
return self.expected("exclusion operator", operator_token);
}
Ok(ExcludeConstraintOperator::Token(
operator_token.token.to_string(),
))
}

/// Parse the body of a Postgres `OPERATOR(schema.op)` form — i.e. the
/// parenthesised `.`-separated path of name parts after the `OPERATOR`
/// keyword. Shared between binary expression parsing and exclusion
/// constraint parsing.
fn parse_pg_operator_ident_parts(&mut self) -> Result<Vec<String>, ParserError> {
self.expect_token(&Token::LParen)?;
if self.peek_token_ref().token == Token::RParen {
let token = self.next_token();
return self.expected("operator name", token);
}
let mut idents = vec![];
loop {
self.advance_token();
idents.push(self.get_current_token().to_string());
if !self.consume_token(&Token::Period) {
break;
}
}
self.expect_token(&Token::RParen)?;
Ok(idents)
}

fn parse_optional_nulls_distinct(&mut self) -> Result<NullsDistinctOption, ParserError> {
Ok(if self.parse_keyword(Keyword::NULLS) {
let not = self.parse_keyword(Keyword::NOT);
Expand Down
Loading
Loading