razeri_plugin_contract/sid_spelling.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 spells a surrogate key** — the one thing about the key a
6//! dialect decides.
7//!
8//! Razeri derives every surrogate key one way:
9//!
10//! ```text
11//! guard( hash( normalize(part₁) ‖ '|' ‖ … ‖ normalize(partₙ) ) )
12//! ```
13//!
14//! That derivation is Razeri's and is composed in one place. What varies
15//! between engines is only the **vocabulary** — what this engine calls `LOWER`,
16//! what it calls `TRIM`, how it joins strings, how it casts a value to text,
17//! and how it spells a 64-bit hash. [`SidSpelling`] is that vocabulary, and a
18//! dialect states it by overriding
19//! [`SqlRenderer::sid_spelling`](crate::SqlRenderer::sid_spelling).
20//!
21//! Two expressions built from this struct differ **only** in these fields,
22//! which is what makes *"provably the same key on every engine"* a proof
23//! rather than an assumption.
24//!
25//! # If your engine has no 64-bit hash
26//!
27//! Say so rather than reaching for the nearest-looking function. A hash that is
28//! deterministic *within* one warehouse and equal to nothing outside it is the
29//! exact lock-in a portable key exists to end — a warehouse keyed with it
30//! reconciles against nothing. The honest answers are an artifact your dialect
31//! installs (name it in [`SidSpelling::hash`] and emit its definition from your
32//! prerequisites), or a refusal that says what the reader should do instead.
33
34/// The [`SidSpelling::platform`] of a dialect that has not stated its spelling.
35///
36/// The default [`SidSpelling`] is ANSI's vocabulary, which is a **guess** and
37/// not a claim about any platform. It has to exist — a dialect must render
38/// *something* — and it has to be distinguishable from the agnostic renderer's
39/// answer, which is ANSI on purpose. Otherwise an unspelled platform gets ANSI
40/// everywhere, every producer agrees, and Razeri's cross-checks pass over a
41/// platform nobody has spelled: a check that cannot see its own blind spot.
42///
43/// **A dialect that leaves this value in place is telling Razeri it has not
44/// decided**, and the conformance kit reports it as an unanswered obligation.
45pub const UNKNOWN_PLATFORM: &str = "unknown";
46
47/// How a dialect spells the canonical lowercase.
48///
49/// The canonical lowercase is fixed as **locale-independent simple case
50/// mapping, one code point to one code point, with no combining mark introduced
51/// by the operation**, and it renders as the engine's own `LOWER` over a value
52/// whose two divergent code points have already been substituted away:
53///
54/// ```text
55/// <function>(<replace>(<replace>(x, 'İ', 'I'), 'Σ', 'σ'))
56/// ```
57///
58/// # Why the substitution is unconditional
59///
60/// Whether an engine's `LOWER` is the simple mapping or the **full** one is not
61/// a property of the engine. It is a property of the engine **and its collation
62/// provider**, and the provider is chosen when a database is created — so the
63/// same server answers both ways. Measured on one `PostgreSQL` 17.9 instance:
64/// under a libc collation `lower('İ')` is a bare `i`, and in a database created
65/// with the ICU provider it is `i` + U+0307 and `ΟΔΟΣ` lowercases with a final
66/// `ς`. Classifying the engine therefore classifies the wrong thing, and the
67/// engine that is classified wrong keys one value to two different numbers.
68///
69/// So the substitution is applied **everywhere**, which costs nothing because
70/// it is a no-op wherever the lowercase is already simple: `'İ'` → `'I'`
71/// lowercases to `i` either way, and `'Σ'` → `'σ'` is already lowercase and is
72/// left alone. That makes the composed key **collation-independent** rather
73/// than correct-on-the-configurations-somebody-tried.
74///
75/// # The two code points, and why they are two facts
76///
77/// - **`İ` (U+0130) — the unconditional divergence.** Its full lowercase adds a
78/// combining mark the canonical mapping excludes by name (`i` followed by
79/// U+0307), and it is the **only** code point whose simple and full mappings
80/// differ with no context at all. Replacing it with `I` first makes the
81/// composition exactly the simple mapping;
82/// - **`Σ` (U+03A3) — the conditional divergence.** Its full lowercase is `σ`
83/// *except* word-finally, where Unicode's `Final_Sigma` condition makes it
84/// `ς` (U+03C2). No enumeration of unconditional mappings can see this: a
85/// different quantifier hides a different letter. Replacing every `Σ` with an
86/// already-lowercase `σ` first removes the context the rule reads, and
87/// `LOWER` then leaves it alone.
88///
89/// Note the direction: `ς` is never folded into `σ`. Its own simple lowercase
90/// is itself, so a substitution that merged the two would be a *third*
91/// normalization rather than a repair.
92///
93/// # What a dialect has to answer
94///
95/// Two function names, and — where the engine matches a replacement pattern by
96/// something other than the code point — how to pin that match. An engine whose
97/// replacement is collation-sensitive is the one to watch: under a
98/// case-insensitive collation `REPLACE(x, 'Σ', 'σ')` matches `ς` as well, and
99/// silently performs the third normalization. Measured on SQL Server 2022 with
100/// its default collation, cured by [`Lower::match_collation`].
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub struct Lower {
103 /// The platform's lowercase function, in its own casing — `LOWER` on most
104 /// engines, `lowerUTF8` on one.
105 pub function: &'static str,
106 /// The platform's three-argument string-replacement function — `REPLACE` on
107 /// most engines, `replaceAll` on one.
108 pub replace: &'static str,
109 /// **How this engine spells a Unicode string literal** — what goes in front
110 /// of the opening quote, where that differs from an ordinary literal. Empty
111 /// on every engine whose ordinary literal is already Unicode; `"N"` on
112 /// T-SQL, where a bare literal is narrowed to the database's code page and
113 /// the two Greek letters do not survive it.
114 ///
115 /// The substitution's two patterns are the reason the field exists, and they
116 /// are not the only literal that has to survive: the equality gate
117 /// substitutes probe values where a column reference stands, and a bare
118 /// `'ΟΔΟΣ'` reaches a T-SQL hash as `???S`. Anything that writes one of the
119 /// corpus's values into this engine as a literal reads this field.
120 pub pattern_literal_prefix: &'static str,
121 /// The collation the substitution's subject is pinned to, where this engine
122 /// matches a replacement pattern **under the subject's collation** rather
123 /// than by code point.
124 ///
125 /// `None` on every engine whose replacement is already by code point.
126 /// Name a **binary** collation where it is not: under an accent- or
127 /// case-insensitive one, replacing `Σ` also replaces `ς`, which moves a
128 /// Greek key that was already correct.
129 pub match_collation: Option<&'static str>,
130}
131
132/// How a dialect casts one call-site argument to text.
133///
134/// The hash is a hash **of a string** and a call-site argument is a raw column
135/// of its own declared type, so the cast is not optional.
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub enum TextCast {
138 /// `CAST(x AS <type>)` — the cast propagates NULL, so Razeri's NULL
139 /// sentinel is reached with the statement intact.
140 Direct(&'static str),
141 /// `assumeNotNull(CAST(x AS Nullable(<type>)))` — `ClickHouse`, whose
142 /// `CAST` to a non-`Nullable` type **raises** instead of propagating
143 /// (*"Cannot convert NULL to a non-nullable type"*).
144 ///
145 /// `ClickHouse` evaluates both arms of a `CASE`, so the sentinel cannot
146 /// save the statement: the emitted key expression failed outright on any
147 /// NULL natural-key part rather than returning the sentinel. That is not a
148 /// wrong key — it is a dimension load that does not run. The wrapper makes
149 /// the unreachable arm total; which value it would produce is irrelevant,
150 /// because the sentinel has already answered.
151 ClickHouseNullTolerant(&'static str),
152}
153
154/// How a dialect spells `TRIM`.
155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156pub enum Trim {
157 /// `TRIM(x)`, in the platform's own casing.
158 Function(&'static str),
159 /// `LTRIM(RTRIM(x))` — T-SQL, whose `TRIM` requires SQL Server 2017. The
160 /// pair removes the same leading and trailing spaces.
161 LtrimRtrim,
162 /// `TRIM(x, ' ')` — the engines whose **bare** `TRIM` removes every
163 /// whitespace character rather than the ASCII space alone.
164 ///
165 /// The canonical trim is the ASCII space (`U+0020`) and nothing else, which
166 /// is why a tab-padded and a newline-padded probe are in the corpus and are
167 /// *not* expected to normalize away. `BigQuery` is the measured member:
168 /// `LENGTH(TRIM('\tBoston\t'))` is 6 there — the tab gone and the key moved
169 /// — while `LENGTH(TRIM('\tBoston\t', ' '))` is 8, and a non-breaking space
170 /// survives the second form and not the first.
171 ///
172 /// The explicit set is not a repair of the engine; it is the canonical
173 /// clause written in an engine whose default set is wider. `TRIM(x, ' ')`
174 /// and `TRIM(x)` are the same function on every platform whose bare form
175 /// already strips the space alone, so the variant states a difference
176 /// rather than introducing one.
177 ///
178 /// **Only a tab-padded probe on your engine tells you which variant is
179 /// yours** — run the corpus on the engine itself. An emulator can give the
180 /// opposite answer: the community `BigQuery` emulator strips the space
181 /// alone.
182 FunctionWithSpaceSet(&'static str),
183}
184
185/// How a dialect joins the normalized parts of a composite key.
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub enum Concat {
188 /// `CONCAT(a, '|', b)`, in the platform's own casing.
189 Function(&'static str),
190 /// `CONCAT(CONCAT(a, '|'), b)` — the engines whose `CONCAT` takes
191 /// **exactly two arguments**, in the platform's own casing.
192 ///
193 /// Rendered by nesting pairwise, left to right, one call per pair: three
194 /// parts read `CONCAT(CONCAT(a, b), c)` where a variadic engine reads
195 /// `CONCAT(a, b, c)`. Db2 is the engine that needs it, and older Oracle
196 /// releases are the same shape.
197 ///
198 /// The distinction is not cosmetic. On such an engine the variadic
199 /// spelling is not a wider call to the same function, it is a call to a
200 /// function that is not there, so the first composite key stops the load
201 /// rather than reading oddly in review.
202 ///
203 /// The nesting joins the same parts with the same separator in the same
204 /// order, so the string that reaches the hash — and therefore the key — is
205 /// the one every other engine hashes. What differs is only how many calls
206 /// it takes to spell it.
207 FunctionBinary(&'static str),
208 /// `a + '|' + b` — T-SQL's string operator. Equal to `CONCAT` for non-NULL
209 /// operands, which Razeri's NULL sentinel is what guarantees: `+`
210 /// propagates NULL where `CONCAT` swallows it, and a swallowed NULL
211 /// silently collapses `('boston', NULL)` and `('boston', '')` into one
212 /// member.
213 Plus,
214 /// `a || '|' || b` — the engines that join strings with an operator, in
215 /// the operator's own spelling. `||` is the standard's, and the one an
216 /// engine with no `CONCAT` function usually takes.
217 ///
218 /// Rendered left to right, the operator between each part and the
219 /// separator: `Operator("||")` over three parts reads
220 /// `a || '|' || b || '|' || c`. One expression, whatever the number of
221 /// parts, and no function the warehouse has to hold.
222 ///
223 /// Like [`Plus`](Self::Plus), the operator propagates NULL where `CONCAT`
224 /// swallows it, and Razeri's NULL sentinel is what makes that safe: a key
225 /// with a NULL part is answered before the join is reached.
226 Operator(&'static str),
227}
228
229/// **How a column's `clean:` step changes the case of a letter** — on this
230/// engine, for the twenty-six ASCII letters and nothing else.
231///
232/// A fold is never the engine's `LOWER` or `UPPER`: what they do to a letter
233/// outside `a` to `z` belongs to the database's collation, so the same value
234/// would be stored two ways on two databases. The default spells the fold in
235/// plain SQL that no collation reads, twenty-six nested replacements. An engine
236/// that holds a function doing exactly that fold names it here, and the step is
237/// one call.
238#[derive(Debug, Clone, Copy, PartialEq, Eq)]
239pub enum AsciiFold {
240 /// `REPLACE(… REPLACE(x, 'a', 'A') …, 'z', 'Z')`: one replacement per
241 /// letter, written with [`Lower::replace`] over the value pinned to
242 /// [`Lower::match_collation`]. Every engine with a three-argument replace
243 /// can run it, which is why it is the default.
244 ///
245 /// The nesting is twenty-six calls deep, and a parser can refuse that depth
246 /// inside some statements. `SQLite` 3.45 is the measured case: a
247 /// `parser stack overflow` inside an `UPDATE` or an `INSERT … SELECT`,
248 /// while the same expression runs in a plain `SELECT`.
249 Replacements,
250 /// One call to a function the engine holds: `to_upper(x)` changes `a` to
251 /// `z` into `A` to `Z`, `to_lower(x)` changes `A` to `Z` into `a` to `z`,
252 /// and both leave every other character as it is.
253 ///
254 /// The function is the dialect's to install, from its prerequisites or an
255 /// extension it ships. It must answer exactly what the replacements
256 /// answer for every value, including a letter outside ASCII, which it
257 /// leaves alone; measure it against them before you ship it.
258 Functions {
259 /// The function that folds to capitals.
260 to_upper: &'static str,
261 /// The function that folds to small letters.
262 to_lower: &'static str,
263 },
264}
265
266/// One platform's vocabulary for the surrogate-key derivation — the only thing
267/// that varies between engines.
268#[derive(Debug, Clone, Copy)]
269pub struct SidSpelling {
270 /// The name this dialect answers under. [`UNKNOWN_PLATFORM`] means the
271 /// dialect has not stated a spelling and is taking ANSI's guess.
272 pub platform: &'static str,
273 /// The canonical lowercase — this engine's own `LOWER` and the
274 /// substitution that runs in front of it.
275 pub lower: Lower,
276 /// The `TRIM` function, or the pair that stands in for it.
277 pub trim: Trim,
278 /// The join used for composite keys.
279 pub concat: Concat,
280 /// The platform's hash of an already-normalized text expression, as the
281 /// text before and after it. `("CAST(xxHash64(", ") AS Int64)")` reads
282 /// `CAST(xxHash64(x) AS Int64)`.
283 pub hash: (&'static str, &'static str),
284 /// The CAST that turns one argument into text. The hash is a hash OF A
285 /// STRING, and at the inline call sites the argument is a raw column of its
286 /// own declared type — `TRIM(DATE)` is a binder error, not a value.
287 pub cast_type: TextCast,
288 /// Whether the load path reaches the hash through Razeri's emitted helper
289 /// function instead of inlining it. Inlining is forced where a UDF cannot
290 /// call another UDF (`DuckDB` macros, `ClickHouse` lambdas) and chosen
291 /// where the platform's own function is already the derivation.
292 pub through_helper: bool,
293 /// How a column's `clean:` step changes the case of a letter here — the
294 /// twenty-six replacements, or one function the engine holds.
295 pub ascii_fold: AsciiFold,
296}
297
298/// **ANSI's vocabulary** — the spelling a dialect inherits until it states its
299/// own, and the agnostic renderer's answer on purpose.
300///
301/// A dialect building its own arm should start from this and change only the
302/// fields its engine actually spells differently:
303///
304/// ```
305/// use razeri_plugin_contract::{ANSI_SID_SPELLING, SidSpelling, TextCast};
306///
307/// const MY_ENGINE: SidSpelling = SidSpelling {
308/// platform: "my-engine",
309/// hash: ("my_xxhash64(", ")"),
310/// cast_type: TextCast::Direct("STRING"),
311/// through_helper: false,
312/// ..ANSI_SID_SPELLING
313/// };
314/// ```
315pub const ANSI_SID_SPELLING: SidSpelling = SidSpelling {
316 platform: "ansi",
317 lower: Lower {
318 function: "LOWER",
319 replace: "REPLACE",
320 pattern_literal_prefix: "",
321 match_collation: None,
322 },
323 trim: Trim::Function("TRIM"),
324 concat: Concat::Function("CONCAT"),
325 hash: ("xxhash64(", ")"),
326 cast_type: TextCast::Direct("TEXT"),
327 through_helper: true,
328 ascii_fold: AsciiFold::Replacements,
329};
330
331/// **The language a surrogate-key helper's body is written in**, where that
332/// body is not SQL.
333///
334/// The two named languages are the ones Razeri's function renderers already
335/// spell a `LANGUAGE` clause for. Anything else is [`Named`](Self::Named),
336/// which carries the keyword through unchanged.
337#[derive(Debug, Clone, Copy, PartialEq, Eq)]
338pub enum SidHelperLanguage {
339 /// JavaScript.
340 JavaScript,
341 /// Python.
342 Python,
343 /// A language keyword this contract does not name, spelled exactly as the
344 /// engine's `LANGUAGE` clause takes it.
345 Named(&'static str),
346}
347
348/// **The whole body of the surrogate-key helper, supplied by the dialect.**
349///
350/// Razeri normally builds the helper's body itself: it is the one derivation,
351/// composed from this engine's [`SidSpelling`] in this engine's own SQL. A
352/// dialect answers with this type only when that is impossible — the engine has
353/// no 64-bit hash in any SQL spelling, so there is no vocabulary to compose
354/// from and the definition has to be written in another language.
355///
356/// Answering here does **not** change the key. The body must compute XXH64 with
357/// seed 0 over the UTF-8 bytes of its single already-normalized argument, and
358/// return it as a signed 64-bit integer — the same number every other engine
359/// returns for the same input. A body that computes something else keys a
360/// warehouse to numbers nothing else reproduces, which is the lock-in a
361/// portable key exists to end. Measure it against the canonical number before
362/// you ship it.
363///
364/// The body is the function's inner text only: Razeri writes the `CREATE
365/// FUNCTION` envelope, the parameter, the return type and the `LANGUAGE`
366/// clause around it.
367#[derive(Debug, Clone, Copy, PartialEq, Eq)]
368pub struct SidHelperBody {
369 /// What the body is written in.
370 pub language: SidHelperLanguage,
371 /// The body itself.
372 pub body: &'static str,
373}
374
375/// The spelling a dialect that has decided nothing renders — ANSI's vocabulary
376/// under the name that says it is a guess.
377pub const UNSPELLED_SID: SidSpelling =
378 SidSpelling { platform: UNKNOWN_PLATFORM, ..ANSI_SID_SPELLING };
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383
384 #[test]
385 fn the_unspelled_default_differs_from_ansi_only_in_its_name() {
386 assert_eq!(UNSPELLED_SID.platform, UNKNOWN_PLATFORM);
387 assert_eq!(UNSPELLED_SID.hash, ANSI_SID_SPELLING.hash);
388 assert_eq!(UNSPELLED_SID.lower, ANSI_SID_SPELLING.lower);
389 assert_eq!(UNSPELLED_SID.trim, ANSI_SID_SPELLING.trim);
390 assert_eq!(UNSPELLED_SID.concat, ANSI_SID_SPELLING.concat);
391 assert_eq!(UNSPELLED_SID.cast_type, ANSI_SID_SPELLING.cast_type);
392 assert_eq!(UNSPELLED_SID.through_helper, ANSI_SID_SPELLING.through_helper);
393 assert_eq!(UNSPELLED_SID.ascii_fold, ANSI_SID_SPELLING.ascii_fold);
394 }
395}