Skip to main content

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