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}