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}