Skip to main content

cratestack_sql/values/
conflict.rs

1use cratestack_core::CratestackError;
2
3/// Conflict target for an upsert. Defaults to the model's primary key
4/// (matching the previous PK-only behavior). [`Self::Columns`] /
5/// [`Self::columns`] let callers upsert on an arbitrary unique tuple —
6/// most commonly a natural key that's distinct from the PK (e.g.
7/// `(owner_id, provider)` on a per-owner-and-provider settings row, or
8/// `(pairing_id, slot)` on a per-slot envelope).
9///
10/// The named columns MUST correspond to a `UNIQUE` constraint or
11/// `UNIQUE` index on the target table — the database engine enforces
12/// this and will surface a clear error if not. The upsert builder
13/// additionally requires the input to carry a value for every column
14/// in the target tuple, so the conflict probe (`SELECT … FOR UPDATE`)
15/// has something to filter on. A column that IS present but whose
16/// value is one of the `SqlValue::Null*` variants satisfies this
17/// requirement — the probe then filters on `column = NULL`, which
18/// never matches any row (three-valued SQL logic), so an upsert keyed
19/// on a NULL natural-key column always takes the insert branch. That
20/// is deliberate, not merely convenient: it is the same rule a `WHERE
21/// col IS NOT NULL` partial index encodes, so a NULL key naturally
22/// falls outside such an index's uniqueness domain both in Postgres's
23/// own `ON CONFLICT` inference and in this crate's conflict probe.
24///
25/// Composite-constraint-by-name (`ON CONFLICT ON CONSTRAINT
26/// my_unique_idx_v2`) is not yet exposed; pass the matching column
27/// tuple via [`Self::Columns`] instead.
28///
29/// # Partial unique indexes (cratestack#741)
30///
31/// [`Self::where_index`] attaches an index predicate so an upsert can
32/// target a **partial** unique index (`CREATE UNIQUE INDEX ... WHERE
33/// <predicate>`) — Postgres will not infer a partial index from an
34/// unpredicated `ON CONFLICT (<cols>)`, so without this the statement
35/// fails at runtime with "there is no unique or exclusion constraint
36/// matching the ON CONFLICT specification". The predicate is kept a
37/// `&'static str`, exactly like the column names: a compile-time
38/// constant from the schema/call site, passed through to the database
39/// verbatim, with no runtime-value path into the rendered SQL (the
40/// same precedent `@@index`'s `using`/`opclass` already set).
41///
42/// The predicate is not just appended to the emitted `ON CONFLICT (…)
43/// WHERE …` clause — every conflict probe this crate's runtimes issue
44/// to decide `Inserted` vs. `Existing`/`DO UPDATE` also applies it.
45/// Skipping that half would let the probe match a row the partial
46/// index does not cover, handing the caller a wrong verdict even
47/// though the emitted SQL looks correct.
48///
49/// Declaring a partial index in the schema DDL (`@@unique([...],
50/// where: "...")`) is a separate concern (cratestack#742): this type
51/// only lets an upsert *target* a partial index that already exists.
52///
53/// # Why an enum with four variants, not two plus a predicate field
54///
55/// An earlier draft of this ticket's fix collapsed this type into a
56/// `{ kind, predicate }` struct, which deleted the public `PrimaryKey`
57/// and `Columns(&'static [&'static str])` variants direct construction
58/// / pattern-matching relied on. A repo-wide grep showed every in-repo
59/// call site only ever *constructs* a `ConflictTarget` (never pattern-
60/// matches one), so that break bought nothing — the maintainer ruled
61/// this be reworked additively instead (cratestack#741 finding 3):
62/// [`Self::PrimaryKey`] and [`Self::Columns`] are restored exactly as
63/// they were pre-#741, and the predicate rides along on two new,
64/// purely additive variants ([`Self::ColumnsWithPredicate`],
65/// [`Self::PrimaryKeyWithPredicate`]) reached through
66/// [`Self::where_index`] rather than constructed directly. The invalid
67/// `PrimaryKey` + predicate combination deliberately stays
68/// *representable* (via [`Self::PrimaryKeyWithPredicate`]) rather than
69/// being ruled out at the type level, so [`Self::validate`] can reject
70/// it at runtime with a clear [`CratestackError::Validation`] instead
71/// of the type system silently preventing the chain
72/// `PrimaryKey.where_index(..)` from being written at all.
73///
74/// # `#[non_exhaustive]` (cratestack#741 finding 4 follow-up, maintainer-ruled)
75///
76/// This release already breaks any external crate that pattern-matches
77/// `ConflictTarget` exhaustively without a wildcard arm — the variant
78/// count just grew from two to four (see above), and that alone forces
79/// such a match to stop compiling. `#[non_exhaustive]` costs nothing
80/// *additional* on top of a break those callers must already absorb
81/// this release, and it means every *future* variant addition is
82/// non-breaking for anyone who updates their match now — deferring it
83/// would mean paying a second, separate break later for no extra
84/// benefit, so this is the cheapest moment it will ever be. It affects
85/// matching only: every existing variant, including the two additive
86/// predicate-carrying ones, stays constructible from outside this
87/// crate exactly as before — `#[non_exhaustive]` on an enum blocks
88/// exhaustive `match`es and enum-level struct-update syntax in other
89/// crates, not construction of variants that already exist. (Putting
90/// `#[non_exhaustive]` on an individual *variant* instead would be the
91/// opposite mistake — that blocks construction — and is deliberately
92/// not done here.)
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
94#[non_exhaustive]
95pub enum ConflictTarget {
96    /// The model's `@id` primary key, unpredicated. Default.
97    #[default]
98    PrimaryKey,
99    /// A caller-supplied tuple of columns forming a unique key on the
100    /// target table, unpredicated. Chain [`Self::where_index`] to
101    /// target a partial unique index instead of a plain one.
102    Columns(&'static [&'static str]),
103    /// Additive (cratestack#741): [`Self::Columns`] with an attached
104    /// partial-unique-index predicate. Reached via
105    /// `ConflictTarget::columns(&[...]).where_index(<predicate>)`; not
106    /// normally constructed directly.
107    ColumnsWithPredicate(&'static [&'static str], &'static str),
108    /// Additive (cratestack#741): [`Self::PrimaryKey`] with an
109    /// attached predicate. This combination can never correspond to a
110    /// real index — the primary key index is never partial — and
111    /// [`Self::validate`] always rejects it. It stays representable
112    /// (rather than prevented at the type level) so that rejection is
113    /// a normal runtime [`CratestackError`], not a call that can't
114    /// even be written. Reached via
115    /// `ConflictTarget::PRIMARY_KEY.where_index(<predicate>)`; not
116    /// normally constructed directly.
117    PrimaryKeyWithPredicate(&'static str),
118}
119
120impl ConflictTarget {
121    /// `ConflictTarget::PrimaryKey` as an associated const, matching
122    /// the naming convention [`Self::columns`] sets for `Columns`.
123    /// Kept alongside the `PrimaryKey` variant itself (both spellings
124    /// are used across this codebase's call sites and tests).
125    pub const PRIMARY_KEY: Self = Self::PrimaryKey;
126
127    /// Sugar for `ConflictTarget::Columns(&[...])`.
128    pub const fn columns(cols: &'static [&'static str]) -> Self {
129        Self::Columns(cols)
130    }
131
132    /// Attach a partial-unique-index predicate, e.g.
133    /// `ConflictTarget::columns(&["k"]).where_index("status = 'active'")`
134    /// for an index declared as `UNIQUE (k) WHERE status = 'active'`.
135    ///
136    /// Only valid when chained onto [`Self::Columns`]/[`Self::columns`]
137    /// — the primary key index is never partial, so chaining this onto
138    /// [`Self::PrimaryKey`]/[`Self::PRIMARY_KEY`] is rejected by
139    /// [`Self::validate`] rather than silently dropped. This method
140    /// itself stays infallible (`const fn`, so it can be used in a
141    /// `const` builder chain) — the rejection happens where the target
142    /// is actually consumed, before any SQL is built.
143    pub const fn where_index(self, predicate: &'static str) -> Self {
144        match self {
145            Self::PrimaryKey | Self::PrimaryKeyWithPredicate(_) => {
146                Self::PrimaryKeyWithPredicate(predicate)
147            }
148            Self::Columns(cols) | Self::ColumnsWithPredicate(cols, _) => {
149                Self::ColumnsWithPredicate(cols, predicate)
150            }
151        }
152    }
153
154    /// The attached partial-index predicate, if any.
155    pub const fn predicate(&self) -> Option<&'static str> {
156        match self {
157            Self::ColumnsWithPredicate(_, predicate) | Self::PrimaryKeyWithPredicate(predicate) => {
158                Some(*predicate)
159            }
160            Self::PrimaryKey | Self::Columns(_) => None,
161        }
162    }
163
164    /// `true` when this target is the model's primary key.
165    pub const fn is_primary_key(&self) -> bool {
166        matches!(self, Self::PrimaryKey | Self::PrimaryKeyWithPredicate(_))
167    }
168
169    /// The column tuple, if this target is [`Self::Columns`] /
170    /// [`Self::columns`] (predicated or not); `None` for
171    /// [`Self::PrimaryKey`]/[`Self::PRIMARY_KEY`].
172    pub const fn as_columns(&self) -> Option<&'static [&'static str]> {
173        match self {
174            Self::Columns(cols) | Self::ColumnsWithPredicate(cols, _) => Some(*cols),
175            Self::PrimaryKey | Self::PrimaryKeyWithPredicate(_) => None,
176        }
177    }
178
179    /// Reject a predicate paired with the primary key target — the PK
180    /// index is never partial, so that combination can never
181    /// correspond to a real index. Every runtime entry point that
182    /// consumes a `ConflictTarget` calls this before doing any SQL
183    /// work, so the rejection is a clear
184    /// [`CratestackError::Validation`], not a silently dropped
185    /// predicate or a confusing database-side error.
186    pub fn validate(&self) -> Result<(), CratestackError> {
187        if self.is_primary_key() && self.predicate().is_some() {
188            return Err(CratestackError::Validation(
189                "ConflictTarget predicate requires ConflictTarget::columns(...); the primary \
190                 key index is never partial, so a predicate on ConflictTarget::PRIMARY_KEY \
191                 cannot correspond to any real index"
192                    .to_owned(),
193            ));
194        }
195        Ok(())
196    }
197}