From 1166605e4c0a2cb5fd70124b5675ef2e01d23c58 Mon Sep 17 00:00:00 2001 From: zvonimir-dd Date: Tue, 13 Oct 2026 14:23:30 +0000 Subject: [PATCH 1/4] Postgres: support WHERE index_predicate in the ON CONFLICT target In PostgreSQL a column-list conflict target may end with `WHERE index_predicate`, which lets a partial unique index be inferred as the arbiter: `ON CONFLICT (a) WHERE b > 0 DO NOTHING`. Parse it into a new `OnConflict::conflict_target_predicate` and print it back after the target. It is only accepted after a column list, as in PostgreSQL's grammar, and it is separate from the action's `DO UPDATE ... WHERE`. Fixes #2625. Environment: Datadog workspace Co-Authored-By: Claude Opus 5.5 --- src/ast/mod.rs | 8 ++++++ src/ast/spans.rs | 2 ++ src/parser/mod.rs | 12 ++++++++ tests/sqlparser_postgres.rs | 55 +++++++++++++++++++++++++++++++++++++ 4 files changed, 77 insertions(+) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 1f2afd7fec..d20a441dea 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -6909,6 +6909,11 @@ pub struct TableAliasWithoutColumns { pub struct OnConflict { /// Optional conflict target specifying columns or constraint. pub conflict_target: Option, + /// PostgreSQL `WHERE index_predicate` after a column-list conflict target, which lets a + /// partial unique index be inferred as the arbiter: `ON CONFLICT (a) WHERE b > 0 DO ...`. + /// + /// See + pub conflict_target_predicate: Option, /// Action to take when a conflict occurs. pub action: OnConflictAction, } @@ -6962,6 +6967,9 @@ impl fmt::Display for OnConflict { if let Some(target) = &self.conflict_target { write!(f, "{target}")?; } + if let Some(predicate) = &self.conflict_target_predicate { + write!(f, " WHERE {predicate}")?; + } write!(f, " {}", self.action) } } diff --git a/src/ast/spans.rs b/src/ast/spans.rs index 34294e4674..df23c6ffe9 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -1416,12 +1416,14 @@ impl Spanned for OnConflict { fn span(&self) -> Span { let OnConflict { conflict_target, + conflict_target_predicate, action, } = self; action .span() .union_opt(&conflict_target.as_ref().map(|i| i.span())) + .union_opt(&conflict_target_predicate.as_ref().map(|i| i.span())) } } diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 188d0a26ea..96eb6fb15d 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -18965,6 +18965,17 @@ impl<'a> Parser<'a> { None }; + // PostgreSQL: `( column [, ...] ) [ WHERE index_predicate ]`. The predicate + // only follows a column list, never `ON CONSTRAINT` or an omitted target. + let conflict_target_predicate = + if matches!(conflict_target, Some(ConflictTarget::Columns(_))) + && self.parse_keyword(Keyword::WHERE) + { + Some(self.parse_expr()?) + } else { + None + }; + self.expect_keyword_is(Keyword::DO)?; let action = if self.parse_keyword(Keyword::NOTHING) { OnConflictAction::DoNothing @@ -18985,6 +18996,7 @@ impl<'a> Parser<'a> { Some(OnInsert::OnConflict(OnConflict { conflict_target, + conflict_target_predicate, action, })) } else { diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index ac6d69f542..6468ae42f3 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -2469,6 +2469,56 @@ fn parse_prepare() { ); } +#[test] +fn parse_pg_on_conflict_target_predicate() { + for (sql, action_selection) in [ + ( + "INSERT INTO t (a, b) VALUES (1, 2) ON CONFLICT(a) WHERE b > 0 DO NOTHING", + None, + ), + ( + "INSERT INTO t (a, b) VALUES (1, 2) ON CONFLICT(a) WHERE b > 0 DO UPDATE SET b = EXCLUDED.b", + None, + ), + // The target's predicate and the action's condition are separate clauses. + ( + "INSERT INTO t (a, b) VALUES (1, 2) ON CONFLICT(a, c) WHERE b > 0 DO UPDATE SET b = EXCLUDED.b WHERE t.b < 10", + Some("t.b < 10"), + ), + ] { + match pg_and_generic().verified_stmt(sql) { + Statement::Insert(Insert { + on: + Some(OnInsert::OnConflict(OnConflict { + conflict_target: Some(ConflictTarget::Columns(_)), + conflict_target_predicate: Some(predicate), + action, + })), + .. + }) => { + assert_eq!(predicate.to_string(), "b > 0"); + let selection = match action { + OnConflictAction::DoUpdate(DoUpdate { selection, .. }) => selection, + OnConflictAction::DoNothing => None, + }; + assert_eq!(selection.map(|e| e.to_string()).as_deref(), action_selection); + } + statement => panic!("Expected INSERT statement, got: {statement:?}"), + } + } + + // A predicate only follows a column list: not `ON CONSTRAINT`, not an omitted target. + for sql in [ + "INSERT INTO t (a) VALUES (1) ON CONFLICT ON CONSTRAINT t_a WHERE a > 0 DO NOTHING", + "INSERT INTO t (a) VALUES (1) ON CONFLICT WHERE a > 0 DO NOTHING", + ] { + assert!( + pg_and_generic().parse_sql_statements(sql).is_err(), + "{sql} should not parse" + ); + } +} + #[test] fn parse_pg_on_conflict() { let stmt = pg_and_generic().verified_stmt( @@ -2482,6 +2532,7 @@ fn parse_pg_on_conflict() { on: Some(OnInsert::OnConflict(OnConflict { conflict_target: Some(ConflictTarget::Columns(cols)), + conflict_target_predicate: None, action, })), .. @@ -2514,6 +2565,7 @@ fn parse_pg_on_conflict() { on: Some(OnInsert::OnConflict(OnConflict { conflict_target: Some(ConflictTarget::Columns(cols)), + conflict_target_predicate: None, action, })), .. @@ -2556,6 +2608,7 @@ fn parse_pg_on_conflict() { on: Some(OnInsert::OnConflict(OnConflict { conflict_target: None, + conflict_target_predicate: None, action, })), .. @@ -2576,6 +2629,7 @@ fn parse_pg_on_conflict() { on: Some(OnInsert::OnConflict(OnConflict { conflict_target: Some(ConflictTarget::Columns(cols)), + conflict_target_predicate: None, action, })), .. @@ -2620,6 +2674,7 @@ fn parse_pg_on_conflict() { on: Some(OnInsert::OnConflict(OnConflict { conflict_target: Some(ConflictTarget::OnConstraint(cname)), + conflict_target_predicate: None, action, })), .. From a6e54d74997c8f365ae1b957f561b50fbafab531 Mon Sep 17 00:00:00 2001 From: Zvonimir Date: Tue, 13 Oct 2026 23:47:11 -0600 Subject: [PATCH 2/4] Test the ON CONFLICT target predicate's span and its parse errors Covers `OnConflict`'s span with and without the predicate, a predicate cut off at the end of the input, and a predicate with no action after it. Environment: Datadog workspace Co-Authored-By: Claude Opus 5.5 --- src/ast/spans.rs | 25 +++++++++++++++++++++++++ tests/sqlparser_postgres.rs | 17 +++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/src/ast/spans.rs b/src/ast/spans.rs index df23c6ffe9..a8c74488e1 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -2878,6 +2878,31 @@ ALTER TABLE users assert_eq!(stmt_span.end, (4, 12).into()); } + #[test] + fn test_on_conflict_target_predicate_span() { + let dialect = crate::dialect::PostgreSqlDialect {}; + let conflict_span = |sql: &str| -> Span { + let stmts = Parser::parse_sql(&dialect, sql).unwrap(); + let Statement::Insert(Insert { + on: Some(OnInsert::OnConflict(on_conflict)), + .. + }) = &stmts[0] + else { + panic!("not an INSERT ... ON CONFLICT statement"); + }; + on_conflict.span() + }; + + // The target's predicate extends the clause's span past the column list. + let sql = "INSERT INTO t (a, b) VALUES (1, 2) ON CONFLICT (a) WHERE b > 0 DO NOTHING"; + let test = SpanTest::new(&dialect, sql); + assert_eq!(test.get_source(conflict_span(sql)), "a) WHERE b > 0"); + + let sql = "INSERT INTO t (a, b) VALUES (1, 2) ON CONFLICT (a) DO NOTHING"; + let test = SpanTest::new(&dialect, sql); + assert_eq!(test.get_source(conflict_span(sql)), "a"); + } + #[test] fn test_replace_statement_span() { let sql = r#" diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index 6468ae42f3..fdd791a399 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -2517,6 +2517,23 @@ fn parse_pg_on_conflict_target_predicate() { "{sql} should not parse" ); } + + // The predicate is an expression, and the action must still follow it. + for (sql, err) in [ + ( + "INSERT INTO t (a) VALUES (1) ON CONFLICT (a) WHERE", + "Expected: an expression, found: EOF", + ), + ( + "INSERT INTO t (a) VALUES (1) ON CONFLICT (a) WHERE a > 0", + "Expected: DO, found: EOF", + ), + ] { + assert_eq!( + pg_and_generic().parse_sql_statements(sql).unwrap_err(), + ParserError::ParserError(err.to_string()), + ); + } } #[test] From 7e23b46350c9667f4460d57b73e923eb3fc2a1fe Mon Sep 17 00:00:00 2001 From: Zvonimir Date: Wed, 14 Oct 2026 11:35:27 -0600 Subject: [PATCH 3/4] Make the target predicate part of ConflictTarget::Columns As suggested in review: `ConflictTarget::Columns` is now a struct variant, `Columns { columns, predicate }`, instead of `OnConflict` carrying a separate `conflict_target_predicate`. The predicate belongs to the column-list target only, and the variant now says so; it also leaves room for the `( index_expression )` form of #1977. Adds the syntax to the fuzz seed corpus. Environment: Datadog workspace Co-Authored-By: Claude Opus 5.5 --- .../postgres_on_conflict_target_predicate.sql | 2 ++ src/ast/mod.rs | 29 +++++++++++------- src/ast/spans.rs | 9 ++++-- src/parser/mod.rs | 24 ++++++--------- tests/sqlparser_postgres.rs | 30 ++++++++++++------- 5 files changed, 55 insertions(+), 39 deletions(-) create mode 100644 fuzz/fuzz_seeds/postgres_on_conflict_target_predicate.sql diff --git a/fuzz/fuzz_seeds/postgres_on_conflict_target_predicate.sql b/fuzz/fuzz_seeds/postgres_on_conflict_target_predicate.sql new file mode 100644 index 0000000000..a63d004f48 --- /dev/null +++ b/fuzz/fuzz_seeds/postgres_on_conflict_target_predicate.sql @@ -0,0 +1,2 @@ +INSERT INTO t (a, b) VALUES (1, 2) ON CONFLICT (a) WHERE b > 0 DO NOTHING; +INSERT INTO t (a, b, c) VALUES (2, 2, 3) ON CONFLICT (a, c) WHERE b > 0 AND c IS NOT NULL DO UPDATE SET b = EXCLUDED.b WHERE t.b < 10; diff --git a/src/ast/mod.rs b/src/ast/mod.rs index d20a441dea..73bc135366 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -6909,11 +6909,6 @@ pub struct TableAliasWithoutColumns { pub struct OnConflict { /// Optional conflict target specifying columns or constraint. pub conflict_target: Option, - /// PostgreSQL `WHERE index_predicate` after a column-list conflict target, which lets a - /// partial unique index be inferred as the arbiter: `ON CONFLICT (a) WHERE b > 0 DO ...`. - /// - /// See - pub conflict_target_predicate: Option, /// Action to take when a conflict occurs. pub action: OnConflictAction, } @@ -6922,8 +6917,17 @@ pub struct OnConflict { #[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] /// Target specification for an `ON CONFLICT` clause. pub enum ConflictTarget { - /// Target specified as a list of columns. - Columns(Vec), + /// Target specified as a list of columns, optionally followed by PostgreSQL's + /// `WHERE index_predicate`, which lets a partial unique index be inferred as the arbiter: + /// `ON CONFLICT (a) WHERE b > 0 DO ...`. + /// + /// See + Columns { + /// The columns of the conflict target. + columns: Vec, + /// The `WHERE index_predicate` after the columns, if any. + predicate: Option, + }, /// Target specified as a named constraint. OnConstraint(ObjectName), } @@ -6967,16 +6971,19 @@ impl fmt::Display for OnConflict { if let Some(target) = &self.conflict_target { write!(f, "{target}")?; } - if let Some(predicate) = &self.conflict_target_predicate { - write!(f, " WHERE {predicate}")?; - } write!(f, " {}", self.action) } } impl fmt::Display for ConflictTarget { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { - ConflictTarget::Columns(cols) => write!(f, "({})", display_comma_separated(cols)), + ConflictTarget::Columns { columns, predicate } => { + write!(f, "({})", display_comma_separated(columns))?; + if let Some(predicate) = predicate { + write!(f, " WHERE {predicate}")?; + } + Ok(()) + } ConflictTarget::OnConstraint(name) => write!(f, " ON CONSTRAINT {name}"), } } diff --git a/src/ast/spans.rs b/src/ast/spans.rs index a8c74488e1..06eed733cf 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -1416,21 +1416,24 @@ impl Spanned for OnConflict { fn span(&self) -> Span { let OnConflict { conflict_target, - conflict_target_predicate, action, } = self; action .span() .union_opt(&conflict_target.as_ref().map(|i| i.span())) - .union_opt(&conflict_target_predicate.as_ref().map(|i| i.span())) } } impl Spanned for ConflictTarget { fn span(&self) -> Span { match self { - ConflictTarget::Columns(vec) => union_spans(vec.iter().map(|i| i.span)), + ConflictTarget::Columns { columns, predicate } => union_spans( + columns + .iter() + .map(|i| i.span) + .chain(predicate.iter().map(|p| p.span())), + ), ConflictTarget::OnConstraint(object_name) => object_name.span(), } } diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 96eb6fb15d..c7f565586e 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -18958,20 +18958,15 @@ impl<'a> Parser<'a> { if self.parse_keywords(&[Keyword::ON, Keyword::CONSTRAINT]) { Some(ConflictTarget::OnConstraint(self.parse_object_name(false)?)) } else if self.peek_token_ref().token == Token::LParen { - Some(ConflictTarget::Columns( - self.parse_parenthesized_column_list(IsOptional::Mandatory, false)?, - )) - } else { - None - }; - - // PostgreSQL: `( column [, ...] ) [ WHERE index_predicate ]`. The predicate - // only follows a column list, never `ON CONSTRAINT` or an omitted target. - let conflict_target_predicate = - if matches!(conflict_target, Some(ConflictTarget::Columns(_))) - && self.parse_keyword(Keyword::WHERE) - { - Some(self.parse_expr()?) + let columns = + self.parse_parenthesized_column_list(IsOptional::Mandatory, false)?; + // PostgreSQL: `( column [, ...] ) [ WHERE index_predicate ]`. + let predicate = if self.parse_keyword(Keyword::WHERE) { + Some(self.parse_expr()?) + } else { + None + }; + Some(ConflictTarget::Columns { columns, predicate }) } else { None }; @@ -18996,7 +18991,6 @@ impl<'a> Parser<'a> { Some(OnInsert::OnConflict(OnConflict { conflict_target, - conflict_target_predicate, action, })) } else { diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index fdd791a399..761d44d060 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -2490,8 +2490,11 @@ fn parse_pg_on_conflict_target_predicate() { Statement::Insert(Insert { on: Some(OnInsert::OnConflict(OnConflict { - conflict_target: Some(ConflictTarget::Columns(_)), - conflict_target_predicate: Some(predicate), + conflict_target: + Some(ConflictTarget::Columns { + predicate: Some(predicate), + .. + }), action, })), .. @@ -2548,8 +2551,11 @@ fn parse_pg_on_conflict() { Statement::Insert(Insert { on: Some(OnInsert::OnConflict(OnConflict { - conflict_target: Some(ConflictTarget::Columns(cols)), - conflict_target_predicate: None, + conflict_target: + Some(ConflictTarget::Columns { + columns: cols, + predicate: None, + }), action, })), .. @@ -2581,8 +2587,11 @@ fn parse_pg_on_conflict() { Statement::Insert(Insert { on: Some(OnInsert::OnConflict(OnConflict { - conflict_target: Some(ConflictTarget::Columns(cols)), - conflict_target_predicate: None, + conflict_target: + Some(ConflictTarget::Columns { + columns: cols, + predicate: None, + }), action, })), .. @@ -2625,7 +2634,6 @@ fn parse_pg_on_conflict() { on: Some(OnInsert::OnConflict(OnConflict { conflict_target: None, - conflict_target_predicate: None, action, })), .. @@ -2645,8 +2653,11 @@ fn parse_pg_on_conflict() { Statement::Insert(Insert { on: Some(OnInsert::OnConflict(OnConflict { - conflict_target: Some(ConflictTarget::Columns(cols)), - conflict_target_predicate: None, + conflict_target: + Some(ConflictTarget::Columns { + columns: cols, + predicate: None, + }), action, })), .. @@ -2691,7 +2702,6 @@ fn parse_pg_on_conflict() { on: Some(OnInsert::OnConflict(OnConflict { conflict_target: Some(ConflictTarget::OnConstraint(cname)), - conflict_target_predicate: None, action, })), .. From a3722e6339dc9700cb4ef5e62e04ae2fa0cc9a9f Mon Sep 17 00:00:00 2001 From: Zvonimir Date: Thu, 15 Oct 2026 07:19:37 -0600 Subject: [PATCH 4/4] Say SQLite accepts the ON CONFLICT target predicate too As suggested in review: SQLite's upsert has the same `( indexed-column, ... ) WHERE expr` target, and the parser reads it in every dialect. The doc on `ConflictTarget::Columns` now names both, and a SQLite test pins the parse, so a change that restricted it to PostgreSQL would fail a test rather than only contradict the doc. Environment: Datadog workspace Co-Authored-By: Claude Opus 5.5 --- src/ast/mod.rs | 5 +++-- tests/sqlparser_sqlite.rs | 25 +++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 73bc135366..1c93c35d4a 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -6917,11 +6917,12 @@ pub struct OnConflict { #[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] /// Target specification for an `ON CONFLICT` clause. pub enum ConflictTarget { - /// Target specified as a list of columns, optionally followed by PostgreSQL's + /// Target specified as a list of columns, optionally followed by /// `WHERE index_predicate`, which lets a partial unique index be inferred as the arbiter: - /// `ON CONFLICT (a) WHERE b > 0 DO ...`. + /// `ON CONFLICT (a) WHERE b > 0 DO ...`. PostgreSQL and SQLite both accept the predicate. /// /// See + /// and Columns { /// The columns of the conflict target. columns: Vec, diff --git a/tests/sqlparser_sqlite.rs b/tests/sqlparser_sqlite.rs index 07a279ae5d..fc2a73bcc8 100644 --- a/tests/sqlparser_sqlite.rs +++ b/tests/sqlparser_sqlite.rs @@ -1362,6 +1362,31 @@ fn parse_over_as_column_alias_after_function() { ); } +#[test] +fn parse_on_conflict_target_predicate() { + // SQLite's upsert target takes the same `( indexed-column, ... ) WHERE expr` as PostgreSQL's. + // See + let sql = "INSERT INTO t VALUES (1, 2, 3) ON CONFLICT(a) WHERE b > 0 DO NOTHING"; + match sqlite().verified_stmt(sql) { + Statement::Insert(Insert { + on: + Some(OnInsert::OnConflict(OnConflict { + conflict_target: + Some(ConflictTarget::Columns { + columns, + predicate: Some(predicate), + }), + action: OnConflictAction::DoNothing, + })), + .. + }) => { + assert_eq!(columns, vec![Ident::new("a")]); + assert_eq!(predicate.to_string(), "b > 0"); + } + other => panic!("unexpected statement: {other:?}"), + } +} + fn sqlite() -> TestedDialects { TestedDialects::new(vec![Box::new(SQLiteDialect {})]) }