Skip to main content

razeri_plugin_contract/
merge_shape.rs

1// Copyright (C) 2026 Gabriel Shea
2// Licensed under either of the Apache License, Version 2.0 or the MIT
3// license, at your option.
4
5//! **How this engine performs an upsert** — a shape the dialect describes, not
6//! a switch it flips.
7//!
8//! Razeri's generated load is an upsert: match a row by its key, update it if
9//! it is there, insert it if it is not. Engines spell that four ways, and the
10//! question *"do you support MERGE?"* has no honest answer on most of them:
11//!
12//! - some have `MERGE`, and place restrictions on it;
13//! - some have no `MERGE` and their own upsert statement instead;
14//! - some have no upsert **statement** at all and an upserting TABLE ENGINE
15//!   instead. `ClickHouse` is that, measured: no `MERGE`, no
16//!   `ON DUPLICATE KEY`, no `ON CONFLICT` — every statement-level spelling
17//!   refused on the live engine — and a `ReplacingMergeTree` that collapses
18//!   rows sharing the sorting key in background merges. Razeri composes the
19//!   load in that idiom: plain versioned `INSERT`s, deduplicated by the
20//!   engine, read back through the engine's own read-time guarantee;
21//! - some have none of the above, and the honest answer is a refusal.
22//!
23//! A boolean cannot say this. An engine that merely lacks `MERGE` is not
24//! thereby a `MySQL`-style upsert engine, and defaulting it to another
25//! vendor's grammar emits SQL it does not have — which is why the answer is a
26//! shape, not a flag.
27//!
28//! ## Two refusals, and why they are different values
29//!
30//! [`MergeShape::Unsupported`] is a dialect saying *"I measured, and my engine
31//! has neither — here is what it has instead"*. [`MergeShape::None`] is the
32//! default: nobody has said anything. Both refuse, and the reader is owed a
33//! different sentence by each, which is why they are not one variant.
34
35/// How this engine performs the upsert Razeri's load is.
36///
37/// The default is [`MergeShape::None`]: an engine Razeri has not been told
38/// about gets a refusal rather than another vendor's statement.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
40#[non_exhaustive]
41pub enum MergeShape {
42    /// `MERGE INTO … USING … WHEN MATCHED … WHEN NOT MATCHED …`, with whatever
43    /// this engine's `MERGE` will not do.
44    Merge(MergeRestrictions),
45    /// No `MERGE`; this engine has an upsert statement of its own.
46    Upsert(UpsertForm),
47    /// No upsert **statement** at all — the TABLE ENGINE deduplicates, and the
48    /// composed load leans into that instead of borrowing a grammar.
49    ///
50    /// The load is a plain `INSERT … SELECT`; rows that share the target's
51    /// sorting key collapse in the engine's background merges, the greatest
52    /// value of [`InsertDedup::version_column`] deciding the survivor; and a
53    /// read that needs the collapsed answer *now* says
54    /// [`InsertDedup::final_read_modifier`]. Razeri's deterministic surrogate
55    /// key is what makes this sound: re-inserting a member computes the same
56    /// key every time, so there is no lookup and no identity race — the two
57    /// halves an upsert statement exists to coordinate.
58    ///
59    /// This composes a load ([`MergeShape::composes_a_load`] is `true`), and it
60    /// is still not a `MERGE`: [`MergeShape::merges`] stays `false`, so every
61    /// statement family that needs a real `MERGE` — the single-`WHEN` identity
62    /// sync included — takes its join-based spelling here, never a correlated
63    /// one. The engine that carries this shape refuses correlated subqueries
64    /// outright, which is measured, not assumed.
65    InsertDedup(InsertDedup),
66    /// **Neither, measured — and this engine's own sentence about it.**
67    ///
68    /// Razeri composes no load here, and the string is what the reader gets
69    /// instead: it lands in the generated `loads.sql` where the load would have
70    /// been, so it is the only thing an operator has to act on. Write it
71    /// fix-first — what this engine offers *instead* before what it lacks, and
72    /// name the near form, because a near form the reader will find alone is
73    /// the thing that costs them a day.
74    ///
75    /// It is prose that lands inside a SQL comment, so it must never spell a
76    /// complete statement.
77    Unsupported(&'static str),
78    /// **The dialect has not answered.**
79    ///
80    /// Razeri refuses rather than emitting a statement from a different
81    /// vendor's grammar, and the conformance kit reports the silence. A dialect
82    /// whose engine really has neither says so with
83    /// [`MergeShape::Unsupported`], which is a claim rather than a silence and
84    /// carries the words to prove it.
85    #[default]
86    None,
87}
88
89/// What this engine's `MERGE` will not do.
90///
91/// Everything defaults to *no restriction*, so a dialect names only what it
92/// has measured. Each field exists because an engine answered an error, not
93/// because a manual implied one.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
95#[non_exhaustive]
96pub struct MergeRestrictions {
97    /// **This engine refuses a `MERGE` carrying only one `WHEN` clause.**
98    ///
99    /// `Redshift`'s answer is *"MERGE statement with a single WHEN clause is
100    /// not enabled"* — a whole statement rejected, not a clause ignored, and
101    /// with no session setting that lifts it.
102    ///
103    /// It matters because Razeri composes one merge of each kind: the core
104    /// load, which carries `WHEN MATCHED` and `WHEN NOT MATCHED` and is fine,
105    /// and the identity load, which carries one `WHEN NOT MATCHED` and is not.
106    /// An engine that sets this takes the portable `INSERT … WHERE NOT EXISTS`
107    /// and `DELETE … WHERE NOT EXISTS` pair for the second, which is the same
108    /// two effects in two statements.
109    pub requires_two_when_clauses: bool,
110
111    /// **This engine's planner reads one slice only from a literal in the
112    /// statement text.**
113    ///
114    /// A load scoped to one day filters both sides of its statement by that
115    /// day, and the day reaches the statement as a parameter the caller binds:
116    /// `tgt.submission_date = p_slice`. On an engine that answers `false` the
117    /// planner treats that bound value as the constant it is, and the day's
118    /// partition is the only one read. On an engine that answers `true` the
119    /// parameter is opaque until after the plan is chosen, so every partition
120    /// is read to write one — the price of the whole history, every day.
121    ///
122    /// An engine that sets this takes the day through its own dynamic-execution
123    /// form instead, with the value formatted into the statement as a literal,
124    /// so the planner sees the constant before it chooses.
125    ///
126    /// The default is `false`: a bound parameter is enough. `BigQuery`,
127    /// measured, reads exactly the same bytes from a bound parameter as from a
128    /// literal, and an engine whose planner differs is the one whose own
129    /// dialect can say so.
130    pub slice_pruning_needs_literal: bool,
131}
132
133impl MergeRestrictions {
134    /// **No restriction Razeri has measured** — the ordinary answer for an
135    /// engine whose `MERGE` takes what Razeri composes.
136    #[must_use]
137    pub const fn none() -> Self {
138        Self { requires_two_when_clauses: false, slice_pruning_needs_literal: false }
139    }
140
141    /// **This engine refuses a `MERGE` carrying only one `WHEN` clause.** See
142    /// [`MergeRestrictions::requires_two_when_clauses`].
143    #[must_use]
144    pub const fn requiring_two_when_clauses() -> Self {
145        Self { requires_two_when_clauses: true, slice_pruning_needs_literal: false }
146    }
147
148    /// **This engine's planner reads one slice only from a literal.** See
149    /// [`MergeRestrictions::slice_pruning_needs_literal`].
150    #[must_use]
151    pub const fn requiring_a_literal_slice() -> Self {
152        Self { requires_two_when_clauses: false, slice_pruning_needs_literal: true }
153    }
154
155    /// The same restrictions with the literal-slice one added, so an engine
156    /// that has two of them states both without a constructor per pair.
157    #[must_use]
158    pub const fn and_a_literal_slice(self) -> Self {
159        Self { slice_pruning_needs_literal: true, ..self }
160    }
161}
162
163/// How an engine whose TABLE ENGINE deduplicates takes Razeri's load.
164///
165/// Both fields are spellings the composed load and the generated views embed,
166/// so they live on the shape rather than as separate renderer hooks — one fact,
167/// one place, nothing for a consistency gate to hold together.
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169#[non_exhaustive]
170pub struct InsertDedup {
171    /// **The Razeri-owned audit column that decides the surviving row** when
172    /// two rows share the sorting key. Mandatory: without a version column the
173    /// engine keeps whichever row a background merge saw last, which is
174    /// nondeterministic, and Razeri refuses nondeterminism. The materializer
175    /// verifies the named column is one the versioned table really carries; a
176    /// design that cannot carry it is refused by name, never downgraded.
177    pub version_column: &'static str,
178    /// The modifier a read of the versioned table appends when it needs the
179    /// engine's one-row-per-key guarantee at read time rather than after the
180    /// next background merge (`FINAL` on the engine that carries this shape).
181    /// It is a read COST, which is why only the generated views say it and the
182    /// load itself never does.
183    pub final_read_modifier: &'static str,
184}
185
186impl InsertDedup {
187    /// The shape, with both spellings stated.
188    #[must_use]
189    pub const fn new(version_column: &'static str, final_read_modifier: &'static str) -> Self {
190        Self { version_column, final_read_modifier }
191    }
192}
193
194/// The upsert statement an engine without `MERGE` spells instead.
195///
196/// A form is added here when Razeri has *composed* the load in it, not when an
197/// engine is known to have one: naming a form Razeri cannot write is how a
198/// dialect ends up emitting another vendor's grammar.
199#[derive(Debug, Clone, Copy, PartialEq, Eq)]
200#[non_exhaustive]
201pub enum UpsertForm {
202    /// `INSERT … ON DUPLICATE KEY UPDATE`. It reads the table's own unique keys
203    /// rather than naming a conflict target, so an engine that spells
204    /// `ON CONFLICT` is not this.
205    ///
206    /// **Two engines have it and exactly two of Razeri's dialects name it** —
207    /// `MySQL` and `MariaDB`. Naming the form is a claim about an engine,
208    /// never a default: an engine that merely lacks `MERGE` does not thereby
209    /// speak this grammar. `ClickHouse`, asked, answers *"Syntax error …
210    /// failed at position 68 (`DUPLICATE`)"* and states
211    /// [`MergeShape::Unsupported`] instead.
212    OnDuplicateKeyUpdate,
213}
214
215impl MergeShape {
216    /// Whether Razeri's two-clause core load can be a `MERGE` here.
217    #[must_use]
218    pub const fn merges(&self) -> bool {
219        matches!(self, Self::Merge(_))
220    }
221
222    /// Whether a **one**-clause `MERGE` runs here — the identity load's shape.
223    ///
224    /// An engine with `MERGE` can still answer no, and one does.
225    #[must_use]
226    pub const fn merges_a_single_when_clause(&self) -> bool {
227        match self {
228            Self::Merge(restrictions) => !restrictions.requires_two_when_clauses,
229            _ => false,
230        }
231    }
232
233    /// **Whether the day a scoped load is given has to reach this engine as a
234    /// literal** for it to read that day's partition instead of all of them.
235    ///
236    /// `false` is the ordinary answer: the parameter the caller binds to the
237    /// load procedure is constant enough for the planner. A shape that is not a
238    /// `MERGE` answers `false` as well — it carries no restrictions, and Razeri
239    /// binds the day rather than formatting it in.
240    #[must_use]
241    pub const fn slice_predicate_needs_a_literal(&self) -> bool {
242        match self {
243            Self::Merge(restrictions) => restrictions.slice_pruning_needs_literal,
244            _ => false,
245        }
246    }
247
248    /// The engine's own upsert statement, where it has one instead of `MERGE`.
249    #[must_use]
250    pub const fn upsert_form(&self) -> Option<UpsertForm> {
251        match self {
252            Self::Upsert(form) => Some(*form),
253            _ => None,
254        }
255    }
256
257    /// The insert-and-deduplicate composition, where the table engine is the
258    /// upsert — see [`MergeShape::InsertDedup`].
259    #[must_use]
260    pub const fn insert_dedup(&self) -> Option<InsertDedup> {
261        match self {
262            Self::InsertDedup(form) => Some(*form),
263            _ => None,
264        }
265    }
266
267    /// **Why Razeri cannot compose a load here**, in words, or `None` if it
268    /// can. The sentence is the one a reader acts on.
269    ///
270    /// The one accessor every surface reads — the materializer that declines to
271    /// compose the load, and the `loads.sql` that says so where an operator
272    /// opens it — so neither one holds a second copy of the words.
273    #[must_use]
274    pub const fn refusal(&self) -> Option<&'static str> {
275        match self {
276            Self::Unsupported(orientation) => Some(orientation),
277            Self::None => Some(
278                "this engine states no MERGE and no upsert statement Razeri can compose, so \
279                 there is no generated load for it. A dialect says which it has by returning \
280                 MergeShape::Merge or MergeShape::Upsert; until then Razeri refuses rather than \
281                 emitting another engine's grammar.",
282            ),
283            _ => None,
284        }
285    }
286
287    /// Whether Razeri composes a load for this engine at all.
288    ///
289    /// The one question the materializer asks before it builds the load
290    /// procedures — asked of the shape, never of a dialect's name.
291    #[must_use]
292    pub const fn composes_a_load(&self) -> bool {
293        self.refusal().is_none()
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    /// A dialect that has said nothing gets a refusal, not `MySQL`'s
302    /// statement. That inversion is the whole point of the type.
303    #[test]
304    fn a_dialect_that_states_nothing_refuses_rather_than_borrowing_a_grammar() {
305        let shape = MergeShape::default();
306        assert!(!shape.merges());
307        assert!(!shape.merges_a_single_when_clause());
308        assert_eq!(shape.upsert_form(), None);
309        assert!(shape.refusal().is_some());
310        assert!(!shape.composes_a_load());
311    }
312
313    /// **A measured refusal and a silent one are different values**, and the
314    /// reader gets a different sentence from each. They were one variant, and a
315    /// dialect that had measured could not say so.
316    #[test]
317    fn a_measured_refusal_carries_the_engines_own_sentence_and_a_silent_one_does_not() {
318        let measured = MergeShape::Unsupported("this engine de-duplicates in the table engine");
319        assert_eq!(
320            measured.refusal(),
321            Some("this engine de-duplicates in the table engine"),
322            "a stated refusal answers in its own words"
323        );
324        assert_ne!(
325            MergeShape::None.refusal(),
326            measured.refusal(),
327            "silence and a measured answer must not read the same"
328        );
329        assert!(!measured.composes_a_load());
330        assert!(!measured.merges());
331        assert_eq!(measured.upsert_form(), None);
332    }
333
334    /// The shapes that DO compose a load carry no refusal, so nothing
335    /// downstream has to special-case them.
336    #[test]
337    fn a_shape_that_composes_a_load_carries_no_refusal() {
338        for shape in [
339            MergeShape::Merge(MergeRestrictions::none()),
340            MergeShape::Merge(MergeRestrictions::requiring_two_when_clauses()),
341            MergeShape::Upsert(UpsertForm::OnDuplicateKeyUpdate),
342            MergeShape::InsertDedup(InsertDedup::new("_loaded_at", "FINAL")),
343        ] {
344            assert_eq!(shape.refusal(), None, "{shape:?} composes a load");
345            assert!(shape.composes_a_load(), "{shape:?} composes a load");
346        }
347    }
348
349    /// **Insert-and-dedup composes a load and is still not a `MERGE`.** Every
350    /// family that needs a real `MERGE` — the single-`WHEN` identity sync
351    /// included — must keep taking its non-merge spelling on this shape, and
352    /// the two spellings the composition embeds travel on the shape itself.
353    #[test]
354    fn insert_dedup_composes_a_load_without_claiming_a_merge() {
355        let shape = MergeShape::InsertDedup(InsertDedup::new("_loaded_at", "FINAL"));
356        assert!(shape.composes_a_load());
357        assert!(!shape.merges());
358        assert!(!shape.merges_a_single_when_clause());
359        assert_eq!(shape.upsert_form(), None, "the engine has no upsert statement");
360        let form = shape.insert_dedup().expect("the composition's spellings");
361        assert_eq!(form.version_column, "_loaded_at");
362        assert_eq!(form.final_read_modifier, "FINAL");
363        assert_eq!(
364            MergeShape::Merge(MergeRestrictions::none()).insert_dedup(),
365            None,
366            "a merge engine is not an engine-dedup engine"
367        );
368    }
369
370    /// An engine can have `MERGE` and still refuse the one-clause form. Both
371    /// halves used to be separate booleans and only one of them was asked.
372    #[test]
373    fn a_merge_with_a_clause_minimum_answers_the_two_questions_differently() {
374        let restricted = MergeShape::Merge(MergeRestrictions::requiring_two_when_clauses());
375        assert!(restricted.merges());
376        assert!(!restricted.merges_a_single_when_clause());
377
378        let unrestricted = MergeShape::Merge(MergeRestrictions::none());
379        assert!(unrestricted.merges());
380        assert!(unrestricted.merges_a_single_when_clause());
381    }
382
383    /// **A bound parameter is constant enough until an engine says otherwise.**
384    /// The default matters more than the exception here: a dialect that has
385    /// never been asked the question must not be made to render the day as a
386    /// literal, because formatting a value into statement text is the thing
387    /// every engine's planner reads differently.
388    #[test]
389    fn a_slice_bound_as_a_parameter_prunes_unless_an_engine_says_it_needs_a_literal() {
390        let ordinary = MergeRestrictions::none();
391        assert!(!ordinary.slice_pruning_needs_literal);
392        assert!(!MergeRestrictions::default().slice_pruning_needs_literal);
393        assert!(!MergeShape::Merge(ordinary).slice_predicate_needs_a_literal());
394
395        let literal_only = MergeRestrictions::requiring_a_literal_slice();
396        assert!(literal_only.slice_pruning_needs_literal);
397        assert!(!literal_only.requires_two_when_clauses, "one restriction is not the other");
398        assert!(MergeShape::Merge(literal_only).slice_predicate_needs_a_literal());
399    }
400
401    /// **The two restrictions are independent, and an engine with both states
402    /// both.** They were one constructor apiece, and an engine that had two of
403    /// them could only say one.
404    #[test]
405    fn an_engine_with_two_restrictions_states_both() {
406        let both = MergeRestrictions::requiring_two_when_clauses().and_a_literal_slice();
407        assert!(both.requires_two_when_clauses);
408        assert!(both.slice_pruning_needs_literal);
409
410        let shape = MergeShape::Merge(both);
411        assert!(shape.merges());
412        assert!(!shape.merges_a_single_when_clause());
413        assert!(shape.slice_predicate_needs_a_literal());
414
415        assert_eq!(
416            MergeRestrictions::none().and_a_literal_slice(),
417            MergeRestrictions::requiring_a_literal_slice(),
418            "adding the one restriction to none of them is stating that one"
419        );
420    }
421
422    /// A shape that is not a `MERGE` carries no restrictions, so it never asks
423    /// for a literal: Razeri binds the day there and formats nothing in.
424    #[test]
425    fn a_shape_that_is_not_a_merge_never_asks_for_a_literal_slice() {
426        for shape in [
427            MergeShape::Upsert(UpsertForm::OnDuplicateKeyUpdate),
428            MergeShape::InsertDedup(InsertDedup::new("_loaded_at", "FINAL")),
429            MergeShape::Unsupported("this engine has neither"),
430            MergeShape::None,
431        ] {
432            assert!(!shape.slice_predicate_needs_a_literal(), "{shape:?}");
433        }
434    }
435
436    /// An upsert engine is not a merge engine, and neither is a refusal.
437    #[test]
438    fn an_upsert_engine_is_not_a_merge_engine() {
439        let upsert = MergeShape::Upsert(UpsertForm::OnDuplicateKeyUpdate);
440        assert!(!upsert.merges());
441        assert_eq!(upsert.upsert_form(), Some(UpsertForm::OnDuplicateKeyUpdate));
442        assert!(upsert.refusal().is_none());
443    }
444}