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: that engine is
205    /// [`OnConflictDoUpdate`](Self::OnConflictDoUpdate).
206    ///
207    /// **Two engines have it and exactly two of Razeri's dialects name it** —
208    /// `MySQL` and `MariaDB`. Naming the form is a claim about an engine,
209    /// never a default: an engine that merely lacks `MERGE` does not thereby
210    /// speak this grammar. `ClickHouse`, asked, answers *"Syntax error …
211    /// failed at position 68 (`DUPLICATE`)"* and states
212    /// [`MergeShape::Unsupported`] instead.
213    OnDuplicateKeyUpdate,
214    /// `INSERT … ON CONFLICT (<key>) DO UPDATE SET <column> = EXCLUDED.<column>`.
215    ///
216    /// It names its conflict target, and Razeri names the core table's primary
217    /// key there: the hash key, the version, and the slice where the table has
218    /// one. The row that arrived is read as `EXCLUDED`. The target must be a
219    /// primary key or a unique index the engine holds, column for column, or
220    /// the engine refuses the statement, which is why Razeri names the key its
221    /// own DDL declared and nothing else.
222    ///
223    /// `PostgreSQL`'s grammar, and the upsert of `SQLite`, `DuckDB` and
224    /// `CockroachDB` as well. Naming it is a claim that the engine runs this
225    /// statement, not that it lacks `MERGE`: an engine with a `MERGE` Razeri
226    /// composes says [`MergeShape::Merge`], whatever else it has.
227    OnConflictDoUpdate,
228}
229
230impl MergeShape {
231    /// Whether Razeri's two-clause core load can be a `MERGE` here.
232    #[must_use]
233    pub const fn merges(&self) -> bool {
234        matches!(self, Self::Merge(_))
235    }
236
237    /// Whether a **one**-clause `MERGE` runs here — the identity load's shape.
238    ///
239    /// An engine with `MERGE` can still answer no, and one does.
240    #[must_use]
241    pub const fn merges_a_single_when_clause(&self) -> bool {
242        match self {
243            Self::Merge(restrictions) => !restrictions.requires_two_when_clauses,
244            _ => false,
245        }
246    }
247
248    /// **Whether the day a scoped load is given has to reach this engine as a
249    /// literal** for it to read that day's partition instead of all of them.
250    ///
251    /// `false` is the ordinary answer: the parameter the caller binds to the
252    /// load procedure is constant enough for the planner. A shape that is not a
253    /// `MERGE` answers `false` as well — it carries no restrictions, and Razeri
254    /// binds the day rather than formatting it in.
255    #[must_use]
256    pub const fn slice_predicate_needs_a_literal(&self) -> bool {
257        match self {
258            Self::Merge(restrictions) => restrictions.slice_pruning_needs_literal,
259            _ => false,
260        }
261    }
262
263    /// The engine's own upsert statement, where it has one instead of `MERGE`.
264    #[must_use]
265    pub const fn upsert_form(&self) -> Option<UpsertForm> {
266        match self {
267            Self::Upsert(form) => Some(*form),
268            _ => None,
269        }
270    }
271
272    /// The insert-and-deduplicate composition, where the table engine is the
273    /// upsert — see [`MergeShape::InsertDedup`].
274    #[must_use]
275    pub const fn insert_dedup(&self) -> Option<InsertDedup> {
276        match self {
277            Self::InsertDedup(form) => Some(*form),
278            _ => None,
279        }
280    }
281
282    /// **Why Razeri cannot compose a load here**, in words, or `None` if it
283    /// can. The sentence is the one a reader acts on.
284    ///
285    /// The one accessor every surface reads — the materializer that declines to
286    /// compose the load, and the `loads.sql` that says so where an operator
287    /// opens it — so neither one holds a second copy of the words.
288    #[must_use]
289    pub const fn refusal(&self) -> Option<&'static str> {
290        match self {
291            Self::Unsupported(orientation) => Some(orientation),
292            Self::None => Some(
293                "this engine states no MERGE and no upsert statement Razeri can compose, so \
294                 there is no generated load for it. A dialect says which it has by returning \
295                 MergeShape::Merge or MergeShape::Upsert; until then Razeri refuses rather than \
296                 emitting another engine's grammar.",
297            ),
298            _ => None,
299        }
300    }
301
302    /// Whether Razeri composes a load for this engine at all.
303    ///
304    /// The one question the materializer asks before it builds the load
305    /// procedures — asked of the shape, never of a dialect's name.
306    #[must_use]
307    pub const fn composes_a_load(&self) -> bool {
308        self.refusal().is_none()
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    /// A dialect that has said nothing gets a refusal, not `MySQL`'s
317    /// statement. That inversion is the whole point of the type.
318    #[test]
319    fn a_dialect_that_states_nothing_refuses_rather_than_borrowing_a_grammar() {
320        let shape = MergeShape::default();
321        assert!(!shape.merges());
322        assert!(!shape.merges_a_single_when_clause());
323        assert_eq!(shape.upsert_form(), None);
324        assert!(shape.refusal().is_some());
325        assert!(!shape.composes_a_load());
326    }
327
328    /// **A measured refusal and a silent one are different values**, and the
329    /// reader gets a different sentence from each. They were one variant, and a
330    /// dialect that had measured could not say so.
331    #[test]
332    fn a_measured_refusal_carries_the_engines_own_sentence_and_a_silent_one_does_not() {
333        let measured = MergeShape::Unsupported("this engine de-duplicates in the table engine");
334        assert_eq!(
335            measured.refusal(),
336            Some("this engine de-duplicates in the table engine"),
337            "a stated refusal answers in its own words"
338        );
339        assert_ne!(
340            MergeShape::None.refusal(),
341            measured.refusal(),
342            "silence and a measured answer must not read the same"
343        );
344        assert!(!measured.composes_a_load());
345        assert!(!measured.merges());
346        assert_eq!(measured.upsert_form(), None);
347    }
348
349    /// The shapes that DO compose a load carry no refusal, so nothing
350    /// downstream has to special-case them.
351    #[test]
352    fn a_shape_that_composes_a_load_carries_no_refusal() {
353        for shape in [
354            MergeShape::Merge(MergeRestrictions::none()),
355            MergeShape::Merge(MergeRestrictions::requiring_two_when_clauses()),
356            MergeShape::Upsert(UpsertForm::OnDuplicateKeyUpdate),
357            MergeShape::InsertDedup(InsertDedup::new("_loaded_at", "FINAL")),
358        ] {
359            assert_eq!(shape.refusal(), None, "{shape:?} composes a load");
360            assert!(shape.composes_a_load(), "{shape:?} composes a load");
361        }
362    }
363
364    /// **Insert-and-dedup composes a load and is still not a `MERGE`.** Every
365    /// family that needs a real `MERGE` — the single-`WHEN` identity sync
366    /// included — must keep taking its non-merge spelling on this shape, and
367    /// the two spellings the composition embeds travel on the shape itself.
368    #[test]
369    fn insert_dedup_composes_a_load_without_claiming_a_merge() {
370        let shape = MergeShape::InsertDedup(InsertDedup::new("_loaded_at", "FINAL"));
371        assert!(shape.composes_a_load());
372        assert!(!shape.merges());
373        assert!(!shape.merges_a_single_when_clause());
374        assert_eq!(shape.upsert_form(), None, "the engine has no upsert statement");
375        let form = shape.insert_dedup().expect("the composition's spellings");
376        assert_eq!(form.version_column, "_loaded_at");
377        assert_eq!(form.final_read_modifier, "FINAL");
378        assert_eq!(
379            MergeShape::Merge(MergeRestrictions::none()).insert_dedup(),
380            None,
381            "a merge engine is not an engine-dedup engine"
382        );
383    }
384
385    /// An engine can have `MERGE` and still refuse the one-clause form, so the
386    /// shape answers the two questions separately and both are asked.
387    #[test]
388    fn a_merge_with_a_clause_minimum_answers_the_two_questions_differently() {
389        let restricted = MergeShape::Merge(MergeRestrictions::requiring_two_when_clauses());
390        assert!(restricted.merges());
391        assert!(!restricted.merges_a_single_when_clause());
392
393        let unrestricted = MergeShape::Merge(MergeRestrictions::none());
394        assert!(unrestricted.merges());
395        assert!(unrestricted.merges_a_single_when_clause());
396    }
397
398    /// **A bound parameter is constant enough until an engine says otherwise.**
399    /// The default matters more than the exception here: a dialect that has
400    /// never been asked the question must not be made to render the day as a
401    /// literal, because formatting a value into statement text is the thing
402    /// every engine's planner reads differently.
403    #[test]
404    fn a_slice_bound_as_a_parameter_prunes_unless_an_engine_says_it_needs_a_literal() {
405        let ordinary = MergeRestrictions::none();
406        assert!(!ordinary.slice_pruning_needs_literal);
407        assert!(!MergeRestrictions::default().slice_pruning_needs_literal);
408        assert!(!MergeShape::Merge(ordinary).slice_predicate_needs_a_literal());
409
410        let literal_only = MergeRestrictions::requiring_a_literal_slice();
411        assert!(literal_only.slice_pruning_needs_literal);
412        assert!(!literal_only.requires_two_when_clauses, "one restriction is not the other");
413        assert!(MergeShape::Merge(literal_only).slice_predicate_needs_a_literal());
414    }
415
416    /// **The two restrictions are independent, and an engine with both states
417    /// both.** They were one constructor apiece, and an engine that had two of
418    /// them could only say one.
419    #[test]
420    fn an_engine_with_two_restrictions_states_both() {
421        let both = MergeRestrictions::requiring_two_when_clauses().and_a_literal_slice();
422        assert!(both.requires_two_when_clauses);
423        assert!(both.slice_pruning_needs_literal);
424
425        let shape = MergeShape::Merge(both);
426        assert!(shape.merges());
427        assert!(!shape.merges_a_single_when_clause());
428        assert!(shape.slice_predicate_needs_a_literal());
429
430        assert_eq!(
431            MergeRestrictions::none().and_a_literal_slice(),
432            MergeRestrictions::requiring_a_literal_slice(),
433            "adding the one restriction to none of them is stating that one"
434        );
435    }
436
437    /// A shape that is not a `MERGE` carries no restrictions, so it never asks
438    /// for a literal: Razeri binds the day there and formats nothing in.
439    #[test]
440    fn a_shape_that_is_not_a_merge_never_asks_for_a_literal_slice() {
441        for shape in [
442            MergeShape::Upsert(UpsertForm::OnDuplicateKeyUpdate),
443            MergeShape::InsertDedup(InsertDedup::new("_loaded_at", "FINAL")),
444            MergeShape::Unsupported("this engine has neither"),
445            MergeShape::None,
446        ] {
447            assert!(!shape.slice_predicate_needs_a_literal(), "{shape:?}");
448        }
449    }
450
451    /// An upsert engine is not a merge engine, and neither is a refusal.
452    #[test]
453    fn an_upsert_engine_is_not_a_merge_engine() {
454        let upsert = MergeShape::Upsert(UpsertForm::OnDuplicateKeyUpdate);
455        assert!(!upsert.merges());
456        assert_eq!(upsert.upsert_form(), Some(UpsertForm::OnDuplicateKeyUpdate));
457        assert!(upsert.refusal().is_none());
458    }
459
460    /// **`ON CONFLICT` is its own form, and a claim like the other one.** It
461    /// composes a load, it is not a `MERGE`, and it carries no restriction, so
462    /// every family that needs a real `MERGE` takes its other spelling here.
463    #[test]
464    fn an_on_conflict_engine_composes_a_load_and_is_not_a_merge_engine() {
465        let shape = MergeShape::Upsert(UpsertForm::OnConflictDoUpdate);
466        assert!(shape.composes_a_load());
467        assert_eq!(shape.refusal(), None);
468        assert!(!shape.merges());
469        assert!(!shape.merges_a_single_when_clause());
470        assert!(!shape.slice_predicate_needs_a_literal());
471        assert_eq!(shape.upsert_form(), Some(UpsertForm::OnConflictDoUpdate));
472        assert_ne!(
473            shape.upsert_form(),
474            MergeShape::Upsert(UpsertForm::OnDuplicateKeyUpdate).upsert_form(),
475            "the two upsert statements are two grammars"
476        );
477    }
478}