razeri_plugin_contract/sid.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//! **The one derivation of a surrogate key**, composed from the vocabulary a
6//! dialect states.
7//!
8//! [`SidSpelling`] says what one engine calls `LOWER` and `TRIM`, how it joins
9//! strings, how it casts a value to text and how it spells a 64-bit hash. This
10//! module turns those five answers into the expression a warehouse runs:
11//!
12//! ```text
13//! guard( hash( normalize(part₁) ‖ '|' ‖ … ‖ normalize(partₙ) ) )
14//! ```
15//!
16//! It is published so that a dialect author can render their own key
17//! expression and run it. Without it, *"the same key on every engine"* is a
18//! claim the person making it cannot check — and a key that differs by one
19//! character keys a warehouse to numbers nothing else reproduces.
20//!
21//! # What a dialect decides, and what it does not
22//!
23//! The **vocabulary** is the dialect's, and it is the only thing that varies.
24//! The **derivation** is fixed: there is no table of engine names here and no
25//! way to add one, so a third party spells their own engine's key by stating a
26//! [`SidSpelling`] and nothing else.
27//!
28//! Two expressions built from the same five fields differ **only** in those
29//! fields. That is what makes the claim a proof rather than an assumption, and
30//! it is why a dialect states a vocabulary instead of writing an expression.
31//!
32//! # The parts of the derivation
33//!
34//! - **normalize** is `LOWER(TRIM(x))` as this engine spells it, and it is the
35//! only normalization permitted — the natural value alone, never salted, so
36//! one natural key reaches one number in every context. The canonical reading
37//! is the SQL majority's: trim the ASCII space and nothing else, fold case by
38//! the locale-independent simple mapping, one code point to one;
39//! - the parts are normalized **then** joined, never the other way round. A
40//! `TRIM` over a concatenation reaches only the outer edges, which would
41//! leave interior spaces beside the separator inside the hash input;
42//! - **guard** is the [`NULL_SID`] sentinel, and it fires when **any** part is
43//! NULL: any NULL component keys the whole member to the sentinel;
44//! - **hash** is either this engine's own spelling or a call to the
45//! [`HELPER_FUNCTION_NAME`] function, whose body is that same spelling. The
46//! two sites are one derivation — see [`HashSite`].
47//!
48//! # Why the normalization can appear twice
49//!
50//! Where the hash is reached through the helper, the helper normalizes its own
51//! input as well, so the rendered call reads `generate_sid(LOWER(TRIM(x)))` and
52//! the normalization runs twice. It is the same string both times: `TRIM` of an
53//! already-trimmed value and `LOWER` of an already-lowered one are identities,
54//! so no key moves. Spelling it at the call site anyway is what makes every
55//! engine one shape — a composite must be normalized before it is joined, and
56//! the helper only ever sees the joined result.
57
58/// **The sentinel a member with any NULL key part is keyed to**, `(-2^63) + 2`.
59///
60/// One number, on every engine, for one reason: a NULL natural key is an
61/// absence rather than a value, and two absences are not the same member. It
62/// sits two above the minimum so that a warehouse can still reserve the
63/// minimum itself.
64pub const NULL_SID: i64 = -9_223_372_036_854_775_806_i64;
65
66/// **The name of the surrogate-key function — the only one.**
67///
68/// One public name everywhere, whoever defines it. On engines where the hash is
69/// an ordinary SQL expression, Razeri emits a function of this name whose body
70/// is the derivation below. On engines where the hash lives outside SQL — a
71/// compiled library, an assembly, a registered handler — the dialect's
72/// prerequisites install an artifact under this same name and
73/// [`SidSpelling::hash`] names it directly, so nothing emits a second
74/// definition.
75///
76/// A second name is a second contract. There is no alias and no transitional
77/// spelling.
78pub const HELPER_FUNCTION_NAME: &str = "generate_sid";
79
80/// `İ`, LATIN CAPITAL LETTER I WITH DOT ABOVE (U+0130).
81///
82/// The **only** code point whose simple and full Unicode lowercase mappings
83/// differ with no context at all: the full mapping adds a combining dot the
84/// simple one does not. Named so the character cannot be mistaken for an
85/// ordinary `I` at a glance in the one place it is load-bearing.
86///
87/// It is not the whole story. Full case conversion also carries a *conditional*
88/// mapping — [`CAPITAL_SIGMA`] — which is a different fact with a different
89/// proof. Both are in [`PRE_FOLD_SUBSTITUTIONS`], for the same purpose and for
90/// different reasons.
91pub const DOTTED_CAPITAL_I: char = '\u{0130}';
92
93/// `Σ`, GREEK CAPITAL LETTER SIGMA (U+03A3).
94///
95/// Its **simple** lowercase is [`SMALL_SIGMA`] in every position. Its **full**
96/// lowercase is [`SMALL_SIGMA`] too — *except* word-finally, where Unicode's
97/// final-sigma condition makes it `ς` (U+03C2). It is the one
98/// language-independent conditional mapping there is.
99///
100/// **Word-finally, not string-finally**: an engine returns `ς` for a sigma
101/// before a space and before a hyphen alike, so a substitution that handled
102/// only the last character of a value would be a local approximation. Replacing
103/// every `Σ` is exact, because the simple mapping has no context to lose.
104pub const CAPITAL_SIGMA: char = '\u{03A3}';
105
106/// `σ`, GREEK SMALL LETTER SIGMA (U+03C3) — the **simple** lowercase of
107/// [`CAPITAL_SIGMA`] in every position, and therefore what the canonical
108/// normalization produces.
109///
110/// Note the direction: `ς` is left alone. Its own simple lowercase is itself,
111/// so `ς` and `σ` are two code points and two keys. Folding one into the other
112/// would be a third normalization rather than a repair.
113pub const SMALL_SIGMA: char = '\u{03C3}';
114
115/// **What has to be replaced before an engine's `LOWER` runs**, so that the
116/// composition is exactly the canonical simple case mapping.
117///
118/// Applied in order, each substitution wrapping the previous one, by
119/// [`normalize`] on every engine. The list is a property of the canonical fold
120/// rather than of any engine: every engine whose `LOWER` performs full case
121/// conversion needs this identical pair and differs only in what it calls
122/// `LOWER` and `REPLACE`.
123///
124/// Each entry maps a code point to a spelling whose full and simple lowercase
125/// agree, so the engine's own `LOWER` can then run over the whole value
126/// untouched:
127///
128/// - [`DOTTED_CAPITAL_I`] → `'I'`, whose lowercase is a bare `i` everywhere —
129/// the *unconditional* divergence;
130/// - [`CAPITAL_SIGMA`] → [`SMALL_SIGMA`], which `LOWER` then leaves alone
131/// because it is already lowercase — the *conditional* one.
132pub const PRE_FOLD_SUBSTITUTIONS: &[(char, char)] =
133 &[(DOTTED_CAPITAL_I, 'I'), (CAPITAL_SIGMA, SMALL_SIGMA)];
134
135use crate::sid_spelling::{Concat, Lower, SidSpelling, TextCast, Trim};
136
137/// Where the hash itself is spelled.
138#[derive(Debug, Clone, Copy)]
139pub enum HashSite<'a> {
140 /// Inline this engine's own hash spelling, from [`SidSpelling::hash`].
141 Native,
142 /// Call the [`HELPER_FUNCTION_NAME`] function by this — possibly
143 /// schema-qualified — name. Its body is the [`HashSite::Native`] spelling,
144 /// so the two sites are the same derivation.
145 ///
146 /// **The qualifier is the function's home, not the caller's schema.** One
147 /// function is created per deployment unit, so a call from any other schema
148 /// of that unit has to name the schema that has it. A caller that qualifies
149 /// with its own schema names a function nothing created.
150 Helper(&'a str),
151}
152
153/// **`LOWER(TRIM(expr))` in this engine's spelling** — the canonical
154/// normalization, and the only one permitted: the natural value alone, never
155/// salted.
156///
157/// Public because what an engine's fold and trim return is a separate
158/// measurement from what its hash returns, and a test that can make only the
159/// first one still proves half the claim. A test that re-spelled the
160/// normalization would be testing itself.
161///
162/// Parameters:
163/// - `spelling`: this engine's vocabulary;
164/// - `expr`: an already-rendered value expression.
165#[must_use]
166pub fn normalize(spelling: SidSpelling, expr: &str) -> String {
167 let trimmed = match spelling.trim {
168 Trim::Function(name) => format!("{name}({expr})"),
169 Trim::LtrimRtrim => format!("LTRIM(RTRIM({expr}))"),
170 Trim::FunctionWithSpaceSet(name) => format!("{name}({expr}, ' ')"),
171 };
172 let Lower { function, replace, pattern_literal_prefix: prefix, match_collation } =
173 spelling.lower;
174 // The subject is pinned to a binary collation where the engine would
175 // otherwise match a replacement pattern under the value's own collation —
176 // which, under a case-insensitive one, also replaces the final sigma the
177 // canonical mapping leaves alone.
178 let subject = match match_collation {
179 Some(collation) => format!("{trimmed} COLLATE {collation}"),
180 None => trimmed,
181 };
182 let substituted = PRE_FOLD_SUBSTITUTIONS.iter().fold(subject, |expr, (from, to)| {
183 format!("{replace}({expr}, {prefix}'{from}', {prefix}'{to}')")
184 });
185 format!("{function}({substituted})")
186}
187
188/// **`CAST(expr AS text)` in this engine's spelling**, total over NULL.
189fn cast_to_text(spelling: SidSpelling, expr: &str) -> String {
190 match spelling.cast_type {
191 TextCast::Direct(ty) => format!("CAST({expr} AS {ty})"),
192 TextCast::ClickHouseNullTolerant(ty) => {
193 format!("assumeNotNull(CAST({expr} AS Nullable({ty})))")
194 }
195 }
196}
197
198/// **The join, written for an engine whose `CONCAT` takes exactly two
199/// arguments**: the parts and the separators between them, folded left to
200/// right, one call per pair.
201///
202/// The items are the same items the variadic call lists, in the same order, so
203/// the concatenated string is the same string and the key does not move.
204///
205/// An empty key has nothing to nest, and the bare call is what the variadic
206/// form renders for it — this form answers the same way rather than inventing a
207/// second degenerate spelling.
208fn nest_pairwise(name: &str, normalized: &[String]) -> String {
209 let mut parts = normalized.iter();
210 let Some(first) = parts.next() else { return format!("{name}()") };
211 parts.fold(first.clone(), |left, next| format!("{name}({name}({left}, '|'), {next})"))
212}
213
214/// **The derivation**: normalize each part, join on `'|'`, hash the result.
215///
216/// `text_args` are already-rendered value expressions, already cast to text
217/// where the caller needs it — a column reference inside a load statement, a
218/// parameter name inside the key function's own body. Callers name the value
219/// differently, so composing the name here would mean knowing which caller was
220/// asking.
221///
222/// The result carries **no NULL guard**; [`guard_null_members`] wraps this
223/// rather than each engine's arm spelling a guard of its own.
224///
225/// Parameters:
226/// - `text_args`: the key's parts, already cast to text;
227/// - `spelling`: this engine's vocabulary;
228/// - `site`: whether the hash is inlined or reached through the function.
229#[must_use]
230pub fn sid_derivation(text_args: &[String], spelling: SidSpelling, site: HashSite<'_>) -> String {
231 let normalized: Vec<String> = text_args.iter().map(|arg| normalize(spelling, arg)).collect();
232 let joined = if normalized.len() == 1 {
233 normalized[0].clone()
234 } else {
235 match spelling.concat {
236 Concat::Function(name) => format!("{name}({})", normalized.join(", '|', ")),
237 Concat::FunctionBinary(name) => nest_pairwise(name, &normalized),
238 Concat::Plus => normalized.join(" + '|' + "),
239 }
240 };
241 match site {
242 HashSite::Native => format!("{}{joined}{}", spelling.hash.0, spelling.hash.1),
243 HashSite::Helper(name) => format!("{name}({joined})"),
244 }
245}
246
247/// **The [`NULL_SID`] sentinel, wrapped around the derivation.**
248///
249/// `raw_args` are the arguments **before** any cast — a cast preserves
250/// NULL-ness, and `CAST(x AS TEXT) IS NULL` only makes the guard harder to
251/// read.
252///
253/// **Any NULL component of a composite keys the whole member to the sentinel.**
254/// That closes a collision as well as an omission: on several engines
255/// `CONCAT('x', '|', NULL)` is `'x|'`, byte-identical to
256/// `CONCAT('x', '|', '')`, so `('boston', NULL)` and `('boston', '')` would be
257/// one member. Testing the components before they are joined is the only place
258/// the distinction still exists.
259///
260/// The guard is a `CASE WHEN` rather than a `COALESCE` because it tests the
261/// **input**. A `COALESCE` over the hash's *output* reaches the sentinel only
262/// where the hash propagates NULL, and on at least one engine the hash of NULL
263/// is a perfectly ordinary number.
264///
265/// An empty key renders no guard rather than an empty one.
266///
267/// Parameters:
268/// - `raw_args`: the key's parts as the caller wrote them, before any cast;
269/// - `derivation`: the expression [`sid_derivation`] produced.
270#[must_use]
271pub fn guard_null_members(raw_args: &[String], derivation: String) -> String {
272 if raw_args.is_empty() {
273 return derivation;
274 }
275 let test = raw_args.iter().map(|arg| format!("{arg} IS NULL")).collect::<Vec<_>>().join(" OR ");
276 format!("CASE WHEN {test} THEN {NULL_SID} ELSE {derivation} END")
277}
278
279/// **The surrogate key as one expression**: cast, derive, guard.
280///
281/// This is the whole composition, and it is what every producer of a key
282/// expression emits — the load that keys a dimension, the body of the
283/// per-entity key function, the formula a design can name. Rendering it for
284/// your own engine and running it is how you check that your
285/// [`SidSpelling`] reaches the number every other engine reaches.
286///
287/// ```
288/// use razeri_plugin_contract::{ANSI_SID_SPELLING, sid};
289///
290/// let key = sid::sid_expression(ANSI_SID_SPELLING, None, &["t.city".to_owned()]);
291/// assert!(key.starts_with("CASE WHEN t.city IS NULL THEN "));
292/// assert!(key.contains("generate_sid("));
293/// ```
294///
295/// # `helper_home` is the key function's schema, never the caller's
296///
297/// The only thing a schema does here is qualify the call to
298/// [`HELPER_FUNCTION_NAME`] on the engines whose
299/// [`SidSpelling::through_helper`] is true. Where the key function is an
300/// installed artifact instead, its own name is rendered and this argument is
301/// ignored entirely — on at least one engine a compiled function lives server
302/// globally and a qualified call to it does not resolve at all.
303///
304/// So the schema this wants is the one the function was **created** in, which
305/// is a property of the deployment unit rather than of whichever entity is
306/// being keyed. `None` renders the call unqualified, which is correct wherever
307/// nothing is qualified: an engine without schemas, or a design that names no
308/// schema.
309///
310/// Parameters:
311/// - `spelling`: this engine's vocabulary;
312/// - `helper_home`: the schema the key function was created in, where the call
313/// needs qualifying;
314/// - `parts`: the key's parts as raw value expressions, in order.
315#[must_use]
316pub fn sid_expression(
317 spelling: SidSpelling,
318 helper_home: Option<&str>,
319 parts: &[String],
320) -> String {
321 let cast: Vec<String> = parts.iter().map(|arg| cast_to_text(spelling, arg)).collect();
322 let qualified = helper_home
323 .map_or_else(|| HELPER_FUNCTION_NAME.to_owned(), |s| format!("{s}.{HELPER_FUNCTION_NAME}"));
324 let site =
325 if spelling.through_helper { HashSite::Helper(&qualified) } else { HashSite::Native };
326 guard_null_members(parts, sid_derivation(&cast, spelling, site))
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332 use crate::sid_spelling::ANSI_SID_SPELLING;
333
334 /// A vocabulary for the composition tests. It is a fixture, not an engine:
335 /// what is under test here is the derivation, and every real engine's
336 /// vocabulary comes off its own dialect.
337 const HELPER_ROUTED: SidSpelling =
338 SidSpelling { platform: "test", hash: ("pg_xxhash64(", ")"), ..ANSI_SID_SPELLING };
339
340 /// An empty key cannot be guarded, and must not render `CASE WHEN THEN` —
341 /// an empty test is worse than no test.
342 #[test]
343 fn an_empty_key_renders_no_guard_rather_than_an_empty_one() {
344 assert!(!sid_expression(HELPER_ROUTED, None, &[]).contains("CASE WHEN"));
345 }
346
347 /// **Every spelling renders both substitutions, in order.**
348 ///
349 /// Whether an engine's `LOWER` is the simple mapping or the full one is
350 /// decided by the collation its database was created with rather than by
351 /// the engine, so the substitution is unconditional: it costs nothing where
352 /// the lowercase is already simple, and it is the difference between one
353 /// number and two where it is not.
354 #[test]
355 fn a_spelling_pre_replaces_both_divergent_code_points() {
356 assert_eq!(
357 normalize(HELPER_ROUTED, "x"),
358 "LOWER(REPLACE(REPLACE(TRIM(x), '\u{0130}', 'I'), '\u{03A3}', '\u{03C3}'))"
359 );
360 }
361
362 /// **An engine that matches a replacement pattern under the value's own
363 /// collation gets the subject pinned and the pattern prefixed.**
364 #[test]
365 fn a_collation_sensitive_replacement_pins_its_subject_and_prefixes_its_pattern() {
366 let pinned = SidSpelling {
367 lower: Lower {
368 pattern_literal_prefix: "N",
369 match_collation: Some("Latin1_General_BIN2"),
370 ..ANSI_SID_SPELLING.lower
371 },
372 ..HELPER_ROUTED
373 };
374 assert_eq!(
375 normalize(pinned, "x"),
376 "LOWER(REPLACE(REPLACE(TRIM(x) COLLATE Latin1_General_BIN2, N'\u{0130}', N'I'), \
377 N'\u{03A3}', N'\u{03C3}'))"
378 );
379 }
380
381 /// **A spelling that does not route through the key function ignores the
382 /// schema.**
383 ///
384 /// An engine whose key function is an installed artifact must render a
385 /// **bare** call: the artifact is registered where that engine registers
386 /// artifacts, not in a schema Razeri created.
387 #[test]
388 fn a_spelling_that_inlines_its_artifact_ignores_the_schema_it_is_passed() {
389 let external =
390 SidSpelling { hash: ("generate_sid(", ")"), through_helper: false, ..HELPER_ROUTED };
391 let qualified = sid_expression(external, Some("acme"), &["x".to_owned()]);
392 let bare = sid_expression(external, None, &["x".to_owned()]);
393 assert_eq!(
394 qualified, bare,
395 "passing a schema moved the key function's name — the artifact is not in a schema \
396 Razeri created"
397 );
398 assert!(!qualified.contains("acme."), "the emitted call qualifies an installed artifact");
399 }
400
401 /// The call through the key function and the inlined native spelling are
402 /// the same derivation over the same normalized input — the only difference
403 /// is where the hash is written down.
404 #[test]
405 fn the_helper_site_and_the_native_site_normalize_identically() {
406 let args = ["a".to_owned(), "b".to_owned()];
407 let native = sid_derivation(&args, HELPER_ROUTED, HashSite::Native);
408 let helper = sid_derivation(&args, HELPER_ROUTED, HashSite::Helper(HELPER_FUNCTION_NAME));
409 let a = "LOWER(REPLACE(REPLACE(TRIM(a), '\u{0130}', 'I'), '\u{03A3}', '\u{03C3}'))";
410 let b = "LOWER(REPLACE(REPLACE(TRIM(b), '\u{0130}', 'I'), '\u{03A3}', '\u{03C3}'))";
411 assert_eq!(native, format!("pg_xxhash64(CONCAT({a}, '|', {b}))"));
412 assert_eq!(helper, format!("generate_sid(CONCAT({a}, '|', {b}))"));
413 }
414
415 /// **Any NULL part keys the whole member to the sentinel**, and the guard
416 /// tests the parts rather than the hash's result.
417 #[test]
418 fn any_null_part_reaches_the_sentinel_and_the_guard_tests_the_input() {
419 let composite = sid_expression(HELPER_ROUTED, None, &["a".to_owned(), "b".to_owned()]);
420 assert!(composite.starts_with("CASE WHEN a IS NULL OR b IS NULL THEN "));
421 assert!(composite.contains(&NULL_SID.to_string()));
422 assert!(!composite.contains("COALESCE"));
423 }
424
425 /// **An engine whose `CONCAT` takes exactly two arguments gets one call per
426 /// pair**, nested left to right, over the same items the variadic call
427 /// lists.
428 #[test]
429 fn a_binary_concat_nests_one_call_per_pair() {
430 let spelling = SidSpelling { concat: Concat::FunctionBinary("CONCAT"), ..HELPER_ROUTED };
431 let part = |arg: &str| normalize(spelling, arg);
432 let two = ["a".to_owned(), "b".to_owned()];
433 assert_eq!(
434 sid_derivation(&two, spelling, HashSite::Native),
435 format!("pg_xxhash64(CONCAT(CONCAT({}, '|'), {}))", part("a"), part("b"))
436 );
437 let three = ["a".to_owned(), "b".to_owned(), "c".to_owned()];
438 assert_eq!(
439 sid_derivation(&three, spelling, HashSite::Native),
440 format!(
441 "pg_xxhash64(CONCAT(CONCAT(CONCAT(CONCAT({}, '|'), {}), '|'), {}))",
442 part("a"),
443 part("b"),
444 part("c")
445 )
446 );
447 }
448
449 /// **A one-part key is not joined at all**, on this form as on the other
450 /// two: there is nothing to join, and a one-argument `CONCAT` is a call an
451 /// engine with a two-argument one does not have either.
452 #[test]
453 fn a_binary_concat_leaves_a_one_part_key_unjoined() {
454 let binary = SidSpelling { concat: Concat::FunctionBinary("CONCAT"), ..HELPER_ROUTED };
455 let one = ["a".to_owned()];
456 assert_eq!(
457 sid_derivation(&one, binary, HashSite::Native),
458 sid_derivation(&one, HELPER_ROUTED, HashSite::Native)
459 );
460 assert!(!sid_derivation(&one, binary, HashSite::Native).contains("CONCAT"));
461 }
462
463 /// An empty key renders the bare call — the same degenerate spelling the
464 /// variadic form renders, so the two forms stay comparable where there is
465 /// nothing to join.
466 #[test]
467 fn a_binary_concat_renders_the_bare_call_for_an_empty_key() {
468 let binary = SidSpelling { concat: Concat::FunctionBinary("CONCAT"), ..HELPER_ROUTED };
469 assert_eq!(
470 sid_derivation(&[], binary, HashSite::Native),
471 sid_derivation(&[], HELPER_ROUTED, HashSite::Native)
472 );
473 assert_eq!(sid_derivation(&[], binary, HashSite::Native), "pg_xxhash64(CONCAT())");
474 }
475
476 /// **The three join forms are three spellings of one string.** Read the
477 /// items out of each rendering and they are the same items in the same
478 /// order — which is why a dialect may answer whichever one its engine
479 /// parses without moving a key.
480 #[test]
481 fn the_three_join_forms_concatenate_the_same_items_in_the_same_order() {
482 let parts = ["a".to_owned(), "b".to_owned(), "c".to_owned()];
483 let items = |spelling| {
484 let rendered = sid_derivation(&parts, spelling, HashSite::Native);
485 let mut found = Vec::new();
486 for arg in ["a", "b", "c"] {
487 let normalized = normalize(spelling, arg);
488 let at = rendered.find(&normalized).expect("every part is joined");
489 found.push((at, arg));
490 }
491 found.sort_unstable();
492 (
493 found.into_iter().map(|(_, arg)| arg).collect::<Vec<_>>(),
494 rendered.matches("'|'").count(),
495 )
496 };
497 let variadic = items(HELPER_ROUTED);
498 let binary =
499 items(SidSpelling { concat: Concat::FunctionBinary("CONCAT"), ..HELPER_ROUTED });
500 let operator = items(SidSpelling { concat: Concat::Plus, ..HELPER_ROUTED });
501 assert_eq!(variadic, (vec!["a", "b", "c"], 2));
502 assert_eq!(binary, variadic);
503 assert_eq!(operator, variadic);
504 }
505
506 /// The sentinel is two above the smallest 64-bit integer, and it is spelled
507 /// once.
508 #[test]
509 fn the_sentinel_is_two_above_the_smallest_integer() {
510 assert_eq!(NULL_SID, i64::MIN + 2);
511 }
512}