Skip to main content

keelson_core/clause/
conflict.rs

1use std::borrow::Cow;
2
3use crate::error::Error;
4use crate::expr::{Expr, IntoExpr, IntoExprList};
5use crate::writer::{Expression, SqlWriter};
6
7use super::set::{HasSet, Set};
8use super::where_::{HasWhere, Where};
9
10/// The slot an `INSERT` keeps for its conflict handling.
11///
12/// It holds an erased expression rather than a [`ConflictClause`] because the three
13/// dialects do not spell this the same way: PostgreSQL and SQLite have
14/// `ON CONFLICT`, MySQL has `ON DUPLICATE KEY UPDATE` and SQLite also has
15/// `INSERT OR REPLACE`. Each dialect puts its own expression here — usually a
16/// `ConflictClause`, reaching core through
17/// [`Expr::Custom`](crate::expr::Expr::Custom).
18#[derive(Debug, Clone, Default)]
19pub struct Conflict {
20    /// The whole conflict clause, whatever this dialect's shape for one is.
21    pub expression: Option<Expr>,
22}
23
24impl Conflict {
25    /// Set the conflict clause.
26    pub fn set_conflict(&mut self, conflict: impl IntoExpr) {
27        self.expression = Some(conflict.into_expr());
28    }
29
30    /// Whether the clause is absent.
31    pub fn is_empty(&self) -> bool {
32        self.expression.is_none()
33    }
34}
35
36impl Expression for Conflict {
37    fn write_sql(&self, w: &mut SqlWriter<'_>) {
38        w.write_if_some(self.expression.as_ref(), "", "");
39    }
40}
41
42/// An `INSERT` with conflict handling.
43pub trait HasConflict {
44    /// The conflict slot to modify.
45    fn conflict_mut(&mut self) -> &mut Conflict;
46}
47
48impl HasConflict for Conflict {
49    fn conflict_mut(&mut self) -> &mut Conflict {
50        self
51    }
52}
53
54/// `ON CONFLICT [<target>] DO NOTHING | DO UPDATE SET … [WHERE …]`
55///
56/// From PostgreSQL 17, <https://www.postgresql.org/docs/17/sql-insert.html>:
57///
58/// ```text
59/// ON CONFLICT [ conflict_target ] conflict_action
60///
61/// conflict_target: ( { index_column_name | ( index_expression ) } [ COLLATE … ] [ opclass ] [, ...] )
62///                    [ WHERE index_predicate ]
63///                | ON CONSTRAINT constraint_name
64/// conflict_action: DO NOTHING
65///                | DO UPDATE SET { … } [, ...] [ WHERE condition ]
66/// ```
67///
68/// The two halves are easy to conflate and behave nothing alike. The **target** is
69/// an index inference — which unique index the conflict is detected on — and its
70/// `WHERE` is the *index's* predicate, matched against the index rather than
71/// evaluated per row. The **action**'s `WHERE` filters which conflicting rows get
72/// updated. Both are `WHERE`s in the same clause, and both are reachable through
73/// [`HasWhere`] here: on [`ConflictTarget`] for the first, on `ConflictClause` for
74/// the second.
75///
76/// `DO UPDATE` also requires at least one assignment, which is checked rather than
77/// rendered into a syntax error.
78#[derive(Debug, Clone, Default)]
79pub struct ConflictClause {
80    /// Which conflicts this handles. Absent means any.
81    pub target: ConflictTarget,
82    /// What to do. `None` is how a default-constructed clause stays absent: there
83    /// is no `ON CONFLICT` without an action.
84    pub action: Option<ConflictAction>,
85    /// The assignments of `DO UPDATE`.
86    pub set: Set,
87    /// Which conflicting rows `DO UPDATE` applies to.
88    pub where_: Where,
89}
90
91impl ConflictClause {
92    /// `ON CONFLICT … DO NOTHING`.
93    pub fn do_nothing() -> Self {
94        ConflictClause {
95            action: Some(ConflictAction::Nothing),
96            ..ConflictClause::default()
97        }
98    }
99
100    /// `ON CONFLICT … DO UPDATE SET …`. The assignments still have to be added.
101    pub fn do_update() -> Self {
102        ConflictClause {
103            action: Some(ConflictAction::Update),
104            ..ConflictClause::default()
105        }
106    }
107
108    /// Whether the clause is absent.
109    pub fn is_empty(&self) -> bool {
110        self.action.is_none()
111    }
112}
113
114impl Expression for ConflictClause {
115    fn write_sql(&self, w: &mut SqlWriter<'_>) {
116        let Some(action) = &self.action else {
117            return;
118        };
119        if matches!(action, ConflictAction::Update) && self.set.is_empty() {
120            w.record_error(Error::Incomplete(
121                "the assignments of ON CONFLICT DO UPDATE",
122            ));
123            return;
124        }
125
126        w.push_str("ON CONFLICT");
127        w.write_if(!self.target.is_empty(), " ", &self.target, "");
128        w.push_str(" DO ");
129        w.push_str(action.as_str());
130
131        // The keyword belongs here rather than to `Set`: MySQL's
132        // `ON DUPLICATE KEY UPDATE` takes the same list without one.
133        w.write_if(!self.set.is_empty(), " SET ", &self.set, "");
134        w.write_if(!self.where_.is_empty(), " ", &self.where_, "");
135    }
136}
137
138impl HasSet for ConflictClause {
139    fn set_mut(&mut self) -> &mut Set {
140        &mut self.set
141    }
142}
143
144impl HasWhere for ConflictClause {
145    fn where_mut(&mut self) -> &mut Where {
146        &mut self.where_
147    }
148}
149
150/// Anything with a conflict clause of the `ON CONFLICT` shape, so that the target
151/// and action mods can be written once.
152pub trait HasConflictClause {
153    /// The conflict clause to modify.
154    fn conflict_clause_mut(&mut self) -> &mut ConflictClause;
155}
156
157impl HasConflictClause for ConflictClause {
158    fn conflict_clause_mut(&mut self) -> &mut ConflictClause {
159        self
160    }
161}
162
163/// What the conflict is detected on: a named constraint, or an index inferred from
164/// a column list and an optional predicate.
165///
166/// A constraint name wins outright, because PostgreSQL forbids combining the two —
167/// `ON CONSTRAINT` names an index directly and leaves nothing to infer.
168#[derive(Debug, Clone, Default)]
169pub struct ConflictTarget {
170    /// `ON CONSTRAINT <name>`. Quoted on output.
171    pub constraint: Option<Cow<'static, str>>,
172    /// The columns or expressions the unique index is over.
173    pub columns: Vec<Expr>,
174    /// The partial index's predicate — matched against the index definition, not
175    /// evaluated against rows.
176    pub where_: Where,
177}
178
179impl ConflictTarget {
180    /// Infer the index from `columns`.
181    pub fn on_columns(columns: impl IntoExprList) -> Self {
182        ConflictTarget {
183            columns: columns.into_expr_list(),
184            ..ConflictTarget::default()
185        }
186    }
187
188    /// Name the constraint directly.
189    pub fn on_constraint(name: impl Into<Cow<'static, str>>) -> Self {
190        ConflictTarget {
191            constraint: Some(name.into()),
192            ..ConflictTarget::default()
193        }
194    }
195
196    /// Whether the target is absent, so that any conflict is handled.
197    pub fn is_empty(&self) -> bool {
198        self.constraint.is_none() && self.columns.is_empty() && self.where_.is_empty()
199    }
200}
201
202impl Expression for ConflictTarget {
203    fn write_sql(&self, w: &mut SqlWriter<'_>) {
204        if let Some(constraint) = &self.constraint {
205            w.push_str("ON CONSTRAINT ");
206            w.push_quoted(&[constraint]);
207            return;
208        }
209
210        if self.columns.is_empty() {
211            // PostgreSQL's gram.y:
212            //   opt_conf_expr: '(' index_params ')' where_clause
213            //                | ON CONSTRAINT name | /*EMPTY*/
214            // The predicate hangs off the parenthesised column list and cannot
215            // stand without it — `ON CONFLICT WHERE …` is a syntax error, verified
216            // against libpg_query — so a predicate on its own is refused rather
217            // than written or silently dropped.
218            if !self.where_.is_empty() {
219                w.record_error(Error::Incomplete(
220                    "the column list an ON CONFLICT index predicate belongs to",
221                ));
222            }
223            return;
224        }
225
226        w.write_slice(&self.columns, "(", ", ", ")");
227        w.write_if(!self.where_.is_empty(), " ", &self.where_, "");
228    }
229}
230
231impl HasWhere for ConflictTarget {
232    fn where_mut(&mut self) -> &mut Where {
233        &mut self.where_
234    }
235}
236
237/// What to do about a conflicting row.
238#[derive(Debug, Clone, Copy, PartialEq, Eq)]
239pub enum ConflictAction {
240    /// `DO NOTHING` — skip the row.
241    Nothing,
242    /// `DO UPDATE` — the upsert. Requires assignments.
243    Update,
244}
245
246impl ConflictAction {
247    /// The keyword, as written after `DO`.
248    pub fn as_str(self) -> &'static str {
249        match self {
250            ConflictAction::Nothing => "NOTHING",
251            ConflictAction::Update => "UPDATE",
252        }
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use keelson_sqlcheck::testing::assert_frag_sql;
259
260    use super::*;
261    use crate::dialect::testing::Numbered;
262    use crate::expr::{Chain, arg, quote};
263    use crate::value::Value;
264    use crate::writer::build;
265
266    /// `ON CONFLICT` only exists on an `INSERT`, so that is the frame. `users`'
267    /// primary key is what the column targets below infer, because a target that
268    /// matches no unique index is a semantic error rather than a syntactic one —
269    /// the class of mistake only the engine tier sees.
270    const FRAME: &str = r#"INSERT INTO users ("id", "name") VALUES (1, 'kubo') {}"#;
271    /// For a bare [`ConflictTarget`], which is the part between the keyword and the
272    /// action.
273    const TARGET_FRAME: &str =
274        r#"INSERT INTO tags ("id", "name") VALUES (1, 'rust') ON CONFLICT {} DO NOTHING"#;
275
276    fn sql(e: &impl Expression) -> String {
277        build(&Numbered, e).expect("render").0
278    }
279
280    #[test]
281    fn an_actionless_clause_writes_nothing() {
282        assert_frag_sql(FRAME, &sql(&ConflictClause::default()), "");
283        assert!(ConflictClause::default().is_empty());
284        assert_frag_sql(FRAME, &sql(&Conflict::default()), "");
285        assert!(Conflict::default().is_empty());
286    }
287
288    #[test]
289    fn do_nothing_needs_no_target() {
290        // PostgreSQL 17: `ON CONFLICT [ conflict_target ] conflict_action`, and
291        // DO NOTHING is the one action that works with no target at all.
292        assert_frag_sql(
293            FRAME,
294            &sql(&ConflictClause::do_nothing()),
295            "ON CONFLICT DO NOTHING",
296        );
297    }
298
299    #[test]
300    fn a_column_target_precedes_the_action() {
301        let c = ConflictClause {
302            target: ConflictTarget::on_columns(quote("id")),
303            ..ConflictClause::do_nothing()
304        };
305        assert_frag_sql(FRAME, &sql(&c), r#"ON CONFLICT ("id") DO NOTHING"#);
306    }
307
308    #[test]
309    fn a_constraint_name_beats_the_column_list() {
310        // The two forms of conflict_target are alternatives, so a target holding
311        // both renders the one PostgreSQL would accept. `tags_name_key` is the
312        // constraint the shared schema's `name text NOT NULL UNIQUE` creates.
313        let mut t = ConflictTarget::on_constraint("tags_name_key");
314        t.columns = vec![quote("name")];
315        t.where_.append_where("id IS NOT NULL");
316        assert_frag_sql(TARGET_FRAME, &sql(&t), r#"ON CONSTRAINT "tags_name_key""#);
317    }
318
319    #[test]
320    fn a_partial_index_target_carries_the_indexs_own_predicate() {
321        // This WHERE belongs to the *index*: it is how PostgreSQL is told which
322        // partial unique index to infer.
323        //
324        // Not framed. The engine tier resolves a conflict target against the
325        // indexes that exist, and the shared schema has no partial unique index
326        // for this to match — inventing one there would change a fixture five
327        // other test binaries share, to check a rendering rule. What the grammar
328        // says (`'(' index_params ')' where_clause`) is pinned by the psql crate,
329        // which owns that syntax; here it is only the order of the two parts.
330        let mut t = ConflictTarget::on_columns((quote("email"), quote("tenant_id")));
331        t.where_.append_where("deleted_at IS NULL");
332        assert_eq!(
333            build(&Numbered, &t).unwrap().0,
334            r#"("email", "tenant_id") WHERE deleted_at IS NULL"#
335        );
336        assert!(!t.is_empty());
337    }
338
339    #[test]
340    fn an_empty_target_writes_nothing() {
341        assert_frag_sql(TARGET_FRAME, &sql(&ConflictTarget::default()), "");
342        assert!(ConflictTarget::default().is_empty());
343    }
344
345    #[test]
346    fn an_index_predicate_without_a_column_list_is_a_recorded_failure() {
347        // `ON CONFLICT WHERE …` does not parse: in gram.y the where_clause follows
348        // `'(' index_params ')'`, so there is nothing for the predicate to qualify.
349        let mut t = ConflictTarget::default();
350        t.where_mut().append_where("deleted_at IS NULL");
351        assert!(!t.is_empty());
352        let err = build(&Numbered, &t).unwrap_err();
353        // The substring names the SQL concept (the missing column list), not
354        // the message wording.
355        assert!(
356            matches!(&err, Error::Incomplete(what) if what.contains("column list")),
357            "got: {err}"
358        );
359    }
360
361    #[test]
362    fn do_update_carries_the_set_keyword_and_its_own_where() {
363        // The action's WHERE filters rows; the target's matched an index. Both
364        // appear here, in that order, which is the shape most easily got wrong.
365        let mut c = ConflictClause {
366            target: ConflictTarget::on_columns(quote("id")),
367            ..ConflictClause::do_update()
368        };
369        c.set_mut()
370            .append_set(Expr::raw(r#""name" = EXCLUDED."name""#));
371        c.where_mut()
372            .append_where(quote(("users", "id")).gt(arg(0i32)));
373
374        let (rendered, args) = build(&Numbered, &c).unwrap();
375        assert_frag_sql(
376            FRAME,
377            &rendered,
378            r#"ON CONFLICT ("id") DO UPDATE SET "name" = EXCLUDED."name" WHERE ("users"."id" > $1)"#,
379        );
380        assert_eq!(args, vec![Value::I32(0)]);
381    }
382
383    #[test]
384    fn do_update_without_assignments_is_a_recorded_failure() {
385        // `DO UPDATE` with no SET does not parse, so it is refused rather than
386        // written.
387        let err = build(&Numbered, &ConflictClause::do_update()).unwrap_err();
388        // The substring names the SQL concept (the missing assignments), not
389        // the message wording.
390        assert!(
391            matches!(&err, Error::Incomplete(what) if what.contains("assignments")),
392            "got: {err}"
393        );
394    }
395
396    #[test]
397    fn the_two_nested_wheres_are_independent() {
398        let mut c = ConflictClause::do_update();
399        c.set_mut().append_set(Expr::raw("a = 1"));
400        c.target.where_mut().append_where("index_pred");
401        c.where_mut().append_where("row_pred");
402        c.target.columns = vec![quote("id")];
403
404        // Not framed, for the reason given in
405        // `a_partial_index_target_carries_the_indexs_own_predicate`: the index
406        // predicate has no matching index in the shared schema. What is asserted
407        // is that the two WHEREs land on opposite sides of the action and neither
408        // borrows the other's conditions.
409        assert_eq!(
410            build(&Numbered, &c).unwrap().0,
411            r#"ON CONFLICT ("id") WHERE index_pred DO UPDATE SET a = 1 WHERE row_pred"#
412        );
413    }
414
415    #[test]
416    fn the_slot_is_transparent_to_whatever_a_dialect_puts_in_it() {
417        // MySQL's spelling has no ON CONFLICT and no SET, which is exactly why the
418        // slot holds an expression rather than a ConflictClause. Not framed: the
419        // psql judge would reject it, and rightly. MySQL's own crate checks it
420        // against MySQL.
421        let mut slot = Conflict::default();
422        slot.set_conflict(Expr::raw("ON DUPLICATE KEY UPDATE `a` = 1"));
423        assert_eq!(
424            build(&Numbered, &slot).unwrap().0,
425            "ON DUPLICATE KEY UPDATE `a` = 1"
426        );
427
428        let mut slot = Conflict::default();
429        slot.set_conflict(Expr::custom(ConflictClause::do_nothing()));
430        assert_frag_sql(FRAME, &sql(&slot), "ON CONFLICT DO NOTHING");
431    }
432}