Skip to main content

razeri_plugin_contract/
sid_spelling.rs

1// Copyright (C) 2026 Gabriel Shea <gshea@ideapivot.com> - All Rights Reserved
2
3//! **How this engine spells a surrogate key** — the one thing about the key a
4//! dialect decides.
5//!
6//! Razeri derives every surrogate key one way:
7//!
8//! ```text
9//! guard( hash( normalize(part₁) ‖ '|' ‖ … ‖ normalize(partₙ) ) )
10//! ```
11//!
12//! That derivation is Razeri's and is composed in one place. What varies
13//! between engines is only the **vocabulary** — what this engine calls `LOWER`,
14//! what it calls `TRIM`, how it joins strings, how it casts a value to text,
15//! and how it spells a 64-bit hash. [`SidSpelling`] is that vocabulary, and a
16//! dialect states it by overriding
17//! [`SqlRenderer::sid_spelling`](crate::SqlRenderer::sid_spelling).
18//!
19//! Two expressions built from this struct differ **only** in these fields,
20//! which is what makes *"provably the same key on every engine"* a proof
21//! rather than an assumption.
22//!
23//! # If your engine has no 64-bit hash
24//!
25//! Say so rather than reaching for the nearest-looking function. A hash that is
26//! deterministic *within* one warehouse and equal to nothing outside it is the
27//! exact lock-in a portable key exists to end — a warehouse keyed with it
28//! reconciles against nothing. The honest answers are an artifact your dialect
29//! installs (name it in [`SidSpelling::hash`] and emit its definition from your
30//! prerequisites), or a refusal that says what the reader should do instead.
31
32/// The [`SidSpelling::platform`] of a dialect that has not stated its spelling.
33///
34/// The default [`SidSpelling`] is ANSI's vocabulary, which is a **guess** and
35/// not a claim about any platform. It has to exist — a dialect must render
36/// *something* — and it has to be distinguishable from the agnostic renderer's
37/// answer, which is ANSI on purpose. Otherwise an unspelled platform gets ANSI
38/// everywhere, every producer agrees, and Razeri's census goes green over a
39/// platform nobody has spelled: a gate that cannot see its own blind spot.
40///
41/// **A dialect that leaves this value in place is telling Razeri it has not
42/// decided**, and the conformance kit reports it as an unanswered obligation.
43pub const UNKNOWN_PLATFORM: &str = "unknown";
44
45/// How a dialect spells `LOWER`.
46///
47/// The canonical lowercase is fixed as **locale-independent simple case
48/// mapping, one code point to one code point, with no combining mark
49/// introduced by the operation**. Some engines' `LOWER` is exactly that;
50/// [`Lower::SimpleFoldViaReplace`] is for the ones whose `LOWER` is the
51/// **full** mapping instead, which is measurably a different function.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum Lower {
54    /// `LOWER(x)`, in the platform's own casing. Measured against the canonical
55    /// reference on `DuckDB`, `PostgreSQL`, Citus and `MariaDB`: all four fold
56    /// `İ` (U+0130) to a bare `i` and lower the rest of the BMP, which is the
57    /// canonical mapping.
58    Function(&'static str),
59    /// `<lower>(<replace>(<replace>(x, 'İ', 'I'), 'Σ', 'σ'))` — the
60    /// **full**-fold engines, whose Unicode-aware lowercase is a different
61    /// function from the canonical simple case mapping on two code points, for
62    /// **two different reasons**. The reasons do not merge, and stating them as
63    /// one would round the evidence up:
64    ///
65    /// - **`İ` (U+0130) — the unconditional divergence.** Its full lowercase is
66    ///   `i` + U+0307, a combining mark the canonical mapping excludes by name,
67    ///   and it is the **only** code point whose simple and full mappings
68    ///   differ with no context at all. Replacing it with `I` first makes the
69    ///   composition exactly the simple mapping;
70    /// - **`Σ` (U+03A3) — the conditional divergence.** Its full lowercase is
71    ///   `σ` *except* word-finally, where Unicode's `Final_Sigma` condition
72    ///   makes it `ς` (U+03C2). No enumeration of unconditional mappings can
73    ///   see this, which is exactly why the İ fix's exhaustive proof did not
74    ///   cover it: a different quantifier hides a different letter. Replacing
75    ///   every `Σ` with an already-lowercase `σ` first removes the context the
76    ///   rule reads, and `LOWER` then leaves it alone.
77    ///
78    /// With both applied the composition is the simple mapping over the whole
79    /// scalar space — an exact rendering of the canonical clause in the
80    /// platform's own vocabulary, never a local approximation. The reason this
81    /// is one variant rather than a per-platform arm is that the *reasoning* is
82    /// one reasoning; only the two function names differ.
83    ///
84    /// **Three engines have been measured needing it** — `ClickHouse`
85    /// (`lowerUTF8` / `replaceAll`), Snowflake (`LOWER` / `REPLACE`) and
86    /// Databricks (`LOWER` / `REPLACE`) — and every one of the three was found
87    /// by *executing* a Turkish and a Greek probe, never by reading a manual.
88    /// If you have not run those two probes on your engine, you do not yet know
89    /// which variant is yours.
90    SimpleFoldViaReplace {
91        /// The platform's full-fold lowercase function.
92        lower: &'static str,
93        /// The platform's three-argument string-replacement function.
94        replace: &'static str,
95    },
96}
97
98/// How a dialect casts one call-site argument to text.
99///
100/// The hash is a hash **of a string** and a call-site argument is a raw column
101/// of its own declared type, so the cast is not optional.
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum TextCast {
104    /// `CAST(x AS <type>)` — the cast propagates NULL, so Razeri's NULL
105    /// sentinel is reached with the statement intact.
106    Direct(&'static str),
107    /// `assumeNotNull(CAST(x AS Nullable(<type>)))` — `ClickHouse`, whose
108    /// `CAST` to a non-`Nullable` type **raises** instead of propagating
109    /// (*"Cannot convert NULL to a non-nullable type"*).
110    ///
111    /// `ClickHouse` evaluates both arms of a `CASE`, so the sentinel cannot
112    /// save the statement: the emitted key expression failed outright on any
113    /// NULL natural-key part rather than returning the sentinel. That is not a
114    /// wrong key — it is a dimension load that does not run. The wrapper makes
115    /// the unreachable arm total; which value it would produce is irrelevant,
116    /// because the sentinel has already answered.
117    ClickHouseNullTolerant(&'static str),
118}
119
120/// How a dialect spells `TRIM`.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum Trim {
123    /// `TRIM(x)`, in the platform's own casing.
124    Function(&'static str),
125    /// `LTRIM(RTRIM(x))` — T-SQL, whose `TRIM` requires SQL Server 2017. The
126    /// pair removes the same leading and trailing spaces.
127    LtrimRtrim,
128    /// `TRIM(x, ' ')` — the engines whose **bare** `TRIM` removes every
129    /// whitespace character rather than the ASCII space alone.
130    ///
131    /// The canonical trim is the ASCII space (`U+0020`) and nothing else, which
132    /// is why a tab-padded and a newline-padded probe are in the corpus and are
133    /// *not* expected to normalize away. `BigQuery` is the measured member:
134    /// `LENGTH(TRIM('\tBoston\t'))` is 6 there — the tab gone and the key moved
135    /// — while `LENGTH(TRIM('\tBoston\t', ' '))` is 8, and a non-breaking space
136    /// survives the second form and not the first.
137    ///
138    /// The explicit set is not a repair of the engine; it is the canonical
139    /// clause written in an engine whose default set is wider. `TRIM(x, ' ')`
140    /// and `TRIM(x)` are the same function on every platform whose bare form
141    /// already strips the space alone, so the variant states a difference
142    /// rather than introducing one.
143    ///
144    /// **If you have not run a tab-padded probe on your engine, you do not yet
145    /// know which variant is yours** — this one was found by executing the
146    /// corpus on the service, and by nothing else. The community emulator this
147    /// dialect was built against strips the space alone, which is the opposite
148    /// answer.
149    FunctionWithSpaceSet(&'static str),
150}
151
152/// How a dialect joins the normalized parts of a composite key.
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub enum Concat {
155    /// `CONCAT(a, '|', b)`, in the platform's own casing.
156    Function(&'static str),
157    /// `a + '|' + b` — T-SQL's string operator. Equal to `CONCAT` for non-NULL
158    /// operands, which Razeri's NULL sentinel is what guarantees: `+`
159    /// propagates NULL where `CONCAT` swallows it, and a swallowed NULL
160    /// silently collapses `('boston', NULL)` and `('boston', '')` into one
161    /// member.
162    Plus,
163}
164
165/// One platform's vocabulary for the surrogate-key derivation — the only thing
166/// that varies between engines.
167#[derive(Debug, Clone, Copy)]
168pub struct SidSpelling {
169    /// The name this dialect answers under. [`UNKNOWN_PLATFORM`] means the
170    /// dialect has not stated a spelling and is taking ANSI's guess.
171    pub platform: &'static str,
172    /// The `LOWER` function, or the composition that stands in for it.
173    pub lower: Lower,
174    /// The `TRIM` function, or the pair that stands in for it.
175    pub trim: Trim,
176    /// The join used for composite keys.
177    pub concat: Concat,
178    /// The platform's hash of an already-normalized text expression, as the
179    /// text before and after it. `("CAST(xxHash64(", ") AS Int64)")` reads
180    /// `CAST(xxHash64(x) AS Int64)`.
181    pub hash: (&'static str, &'static str),
182    /// The CAST that turns one argument into text. The hash is a hash OF A
183    /// STRING, and at the inline call sites the argument is a raw column of its
184    /// own declared type — `TRIM(DATE)` is a binder error, not a value.
185    pub cast_type: TextCast,
186    /// Whether the load path reaches the hash through Razeri's emitted helper
187    /// function instead of inlining it. Inlining is forced where a UDF cannot
188    /// call another UDF (`DuckDB` macros, `ClickHouse` lambdas) and chosen
189    /// where the platform's own function is already the derivation.
190    pub through_helper: bool,
191}
192
193/// **ANSI's vocabulary** — the spelling a dialect inherits until it states its
194/// own, and the agnostic renderer's answer on purpose.
195///
196/// A dialect building its own arm should start from this and change only the
197/// fields its engine actually spells differently:
198///
199/// ```
200/// use razeri_plugin_contract::{ANSI_SID_SPELLING, SidSpelling, TextCast};
201///
202/// const MY_ENGINE: SidSpelling = SidSpelling {
203///     platform: "my-engine",
204///     hash: ("my_xxhash64(", ")"),
205///     cast_type: TextCast::Direct("STRING"),
206///     through_helper: false,
207///     ..ANSI_SID_SPELLING
208/// };
209/// ```
210pub const ANSI_SID_SPELLING: SidSpelling = SidSpelling {
211    platform: "ansi",
212    lower: Lower::Function("LOWER"),
213    trim: Trim::Function("TRIM"),
214    concat: Concat::Function("CONCAT"),
215    hash: ("xxhash64(", ")"),
216    cast_type: TextCast::Direct("TEXT"),
217    through_helper: true,
218};
219
220/// The spelling a dialect that has decided nothing renders — ANSI's vocabulary
221/// under the name that says it is a guess.
222pub const UNSPELLED_SID: SidSpelling =
223    SidSpelling { platform: UNKNOWN_PLATFORM, ..ANSI_SID_SPELLING };
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn the_unspelled_default_differs_from_ansi_only_in_its_name() {
231        assert_eq!(UNSPELLED_SID.platform, UNKNOWN_PLATFORM);
232        assert_eq!(UNSPELLED_SID.hash, ANSI_SID_SPELLING.hash);
233        assert_eq!(UNSPELLED_SID.lower, ANSI_SID_SPELLING.lower);
234        assert_eq!(UNSPELLED_SID.trim, ANSI_SID_SPELLING.trim);
235        assert_eq!(UNSPELLED_SID.concat, ANSI_SID_SPELLING.concat);
236        assert_eq!(UNSPELLED_SID.cast_type, ANSI_SID_SPELLING.cast_type);
237        assert_eq!(UNSPELLED_SID.through_helper, ANSI_SID_SPELLING.through_helper);
238    }
239}