Skip to main content

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/// **`TRIM(expr)` in this engine's spelling** — the space around a value
154/// taken off, and nothing else.
155///
156/// Public because the canonical normalization is not the only thing that has
157/// to take the space off a value the source wrote: reading an amount the
158/// source spelled its own way starts the same way, and two spellings of
159/// `TRIM` would be two answers to one question.
160///
161/// Parameters:
162/// - `spelling`: this engine's vocabulary;
163/// - `expr`: an already-rendered value expression.
164#[must_use]
165pub fn normalize_trim(spelling: SidSpelling, expr: &str) -> String {
166    match spelling.trim {
167        Trim::Function(name) => format!("{name}({expr})"),
168        Trim::LtrimRtrim => format!("LTRIM(RTRIM({expr}))"),
169        Trim::FunctionWithSpaceSet(name) => format!("{name}({expr}, ' ')"),
170    }
171}
172
173/// **`LOWER(TRIM(expr))` in this engine's spelling** — the canonical
174/// normalization, and the only one permitted: the natural value alone, never
175/// salted.
176///
177/// Public because what an engine's fold and trim return is a separate
178/// measurement from what its hash returns, and a test that can make only the
179/// first one still proves half the claim. A test that re-spelled the
180/// normalization would be testing itself.
181///
182/// Parameters:
183/// - `spelling`: this engine's vocabulary;
184/// - `expr`: an already-rendered value expression.
185#[must_use]
186pub fn normalize(spelling: SidSpelling, expr: &str) -> String {
187    let trimmed = normalize_trim(spelling, expr);
188    let Lower { function, replace, pattern_literal_prefix: prefix, match_collation } =
189        spelling.lower;
190    // The subject is pinned to a binary collation where the engine would
191    // otherwise match a replacement pattern under the value's own collation —
192    // which, under a case-insensitive one, also replaces the final sigma the
193    // canonical mapping leaves alone.
194    let subject = match match_collation {
195        Some(collation) => format!("{trimmed} COLLATE {collation}"),
196        None => trimmed,
197    };
198    let substituted = PRE_FOLD_SUBSTITUTIONS.iter().fold(subject, |expr, (from, to)| {
199        format!("{replace}({expr}, {prefix}'{from}', {prefix}'{to}')")
200    });
201    format!("{function}({substituted})")
202}
203
204/// **`CAST(expr AS text)` in this engine's spelling**, total over NULL.
205fn cast_to_text(spelling: SidSpelling, expr: &str) -> String {
206    match spelling.cast_type {
207        TextCast::Direct(ty) => format!("CAST({expr} AS {ty})"),
208        TextCast::ClickHouseNullTolerant(ty) => {
209            format!("assumeNotNull(CAST({expr} AS Nullable({ty})))")
210        }
211    }
212}
213
214/// **The join, written for an engine whose `CONCAT` takes exactly two
215/// arguments**: the parts and the separators between them, folded left to
216/// right, one call per pair.
217///
218/// The items are the same items the variadic call lists, in the same order, so
219/// the concatenated string is the same string and the key does not move.
220///
221/// An empty key has nothing to nest, and the bare call is what the variadic
222/// form renders for it — this form answers the same way rather than inventing a
223/// second degenerate spelling.
224fn nest_pairwise(name: &str, normalized: &[String]) -> String {
225    let mut parts = normalized.iter();
226    let Some(first) = parts.next() else { return format!("{name}()") };
227    parts.fold(first.clone(), |left, next| format!("{name}({name}({left}, '|'), {next})"))
228}
229
230/// **The derivation**: normalize each part, join on `'|'`, hash the result.
231///
232/// `text_args` are already-rendered value expressions, already cast to text
233/// where the caller needs it — a column reference inside a load statement, a
234/// parameter name inside the key function's own body. Callers name the value
235/// differently, so composing the name here would mean knowing which caller was
236/// asking.
237///
238/// The result carries **no NULL guard**; [`guard_null_members`] wraps this
239/// rather than each engine's arm spelling a guard of its own.
240///
241/// Parameters:
242/// - `text_args`: the key's parts, already cast to text;
243/// - `spelling`: this engine's vocabulary;
244/// - `site`: whether the hash is inlined or reached through the function.
245#[must_use]
246pub fn sid_derivation(text_args: &[String], spelling: SidSpelling, site: HashSite<'_>) -> String {
247    let normalized: Vec<String> = text_args.iter().map(|arg| normalize(spelling, arg)).collect();
248    let joined = if normalized.len() == 1 {
249        normalized[0].clone()
250    } else {
251        match spelling.concat {
252            Concat::Function(name) => format!("{name}({})", normalized.join(", '|', ")),
253            Concat::FunctionBinary(name) => nest_pairwise(name, &normalized),
254            Concat::Plus => normalized.join(" + '|' + "),
255        }
256    };
257    match site {
258        HashSite::Native => format!("{}{joined}{}", spelling.hash.0, spelling.hash.1),
259        HashSite::Helper(name) => format!("{name}({joined})"),
260    }
261}
262
263/// **The [`NULL_SID`] sentinel, wrapped around the derivation.**
264///
265/// `raw_args` are the arguments **before** any cast — a cast preserves
266/// NULL-ness, and `CAST(x AS TEXT) IS NULL` only makes the guard harder to
267/// read.
268///
269/// **Any NULL component of a composite keys the whole member to the sentinel.**
270/// That closes a collision as well as an omission: on several engines
271/// `CONCAT('x', '|', NULL)` is `'x|'`, byte-identical to
272/// `CONCAT('x', '|', '')`, so `('boston', NULL)` and `('boston', '')` would be
273/// one member. Testing the components before they are joined is the only place
274/// the distinction still exists.
275///
276/// The guard is a `CASE WHEN` rather than a `COALESCE` because it tests the
277/// **input**. A `COALESCE` over the hash's *output* reaches the sentinel only
278/// where the hash propagates NULL, and on at least one engine the hash of NULL
279/// is a perfectly ordinary number.
280///
281/// An empty key renders no guard rather than an empty one.
282///
283/// Parameters:
284/// - `raw_args`: the key's parts as the caller wrote them, before any cast;
285/// - `derivation`: the expression [`sid_derivation`] produced.
286#[must_use]
287pub fn guard_null_members(raw_args: &[String], derivation: String) -> String {
288    if raw_args.is_empty() {
289        return derivation;
290    }
291    let test = raw_args.iter().map(|arg| format!("{arg} IS NULL")).collect::<Vec<_>>().join(" OR ");
292    format!("CASE WHEN {test} THEN {NULL_SID} ELSE {derivation} END")
293}
294
295/// **The surrogate key as one expression**: cast, derive, guard.
296///
297/// This is the whole composition, and it is what every producer of a key
298/// expression emits — the load that keys a dimension, the body of the
299/// per-entity key function, the formula a design can name. Rendering it for
300/// your own engine and running it is how you check that your
301/// [`SidSpelling`] reaches the number every other engine reaches.
302///
303/// ```
304/// use razeri_plugin_contract::{ANSI_SID_SPELLING, sid};
305///
306/// let key = sid::sid_expression(ANSI_SID_SPELLING, None, &["t.city".to_owned()]);
307/// assert!(key.starts_with("CASE WHEN t.city IS NULL THEN "));
308/// assert!(key.contains("generate_sid("));
309/// ```
310///
311/// # `helper_home` is the key function's schema, never the caller's
312///
313/// The only thing a schema does here is qualify the call to
314/// [`HELPER_FUNCTION_NAME`] on the engines whose
315/// [`SidSpelling::through_helper`] is true. Where the key function is an
316/// installed artifact instead, its own name is rendered and this argument is
317/// ignored entirely — on at least one engine a compiled function lives server
318/// globally and a qualified call to it does not resolve at all.
319///
320/// So the schema this wants is the one the function was **created** in, which
321/// is a property of the deployment unit rather than of whichever entity is
322/// being keyed. `None` renders the call unqualified, which is correct wherever
323/// nothing is qualified: an engine without schemas, or a design that names no
324/// schema.
325///
326/// Parameters:
327/// - `spelling`: this engine's vocabulary;
328/// - `helper_home`: the schema the key function was created in, where the call
329///   needs qualifying;
330/// - `parts`: the key's parts as raw value expressions, in order.
331#[must_use]
332pub fn sid_expression(
333    spelling: SidSpelling,
334    helper_home: Option<&str>,
335    parts: &[String],
336) -> String {
337    let cast: Vec<String> = parts.iter().map(|arg| cast_to_text(spelling, arg)).collect();
338    let qualified = helper_home
339        .map_or_else(|| HELPER_FUNCTION_NAME.to_owned(), |s| format!("{s}.{HELPER_FUNCTION_NAME}"));
340    let site =
341        if spelling.through_helper { HashSite::Helper(&qualified) } else { HashSite::Native };
342    guard_null_members(parts, sid_derivation(&cast, spelling, site))
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348    use crate::sid_spelling::ANSI_SID_SPELLING;
349
350    /// A vocabulary for the composition tests. It is a fixture, not an engine:
351    /// what is under test here is the derivation, and every real engine's
352    /// vocabulary comes off its own dialect.
353    const HELPER_ROUTED: SidSpelling =
354        SidSpelling { platform: "test", hash: ("pg_xxhash64(", ")"), ..ANSI_SID_SPELLING };
355
356    /// An empty key cannot be guarded, and must not render `CASE WHEN  THEN` —
357    /// an empty test is worse than no test.
358    #[test]
359    fn an_empty_key_renders_no_guard_rather_than_an_empty_one() {
360        assert!(!sid_expression(HELPER_ROUTED, None, &[]).contains("CASE WHEN"));
361    }
362
363    /// **Every spelling renders both substitutions, in order.**
364    ///
365    /// Whether an engine's `LOWER` is the simple mapping or the full one is
366    /// decided by the collation its database was created with rather than by
367    /// the engine, so the substitution is unconditional: it costs nothing where
368    /// the lowercase is already simple, and it is the difference between one
369    /// number and two where it is not.
370    #[test]
371    fn a_spelling_pre_replaces_both_divergent_code_points() {
372        assert_eq!(
373            normalize(HELPER_ROUTED, "x"),
374            "LOWER(REPLACE(REPLACE(TRIM(x), '\u{0130}', 'I'), '\u{03A3}', '\u{03C3}'))"
375        );
376    }
377
378    /// **An engine that matches a replacement pattern under the value's own
379    /// collation gets the subject pinned and the pattern prefixed.**
380    #[test]
381    fn a_collation_sensitive_replacement_pins_its_subject_and_prefixes_its_pattern() {
382        let pinned = SidSpelling {
383            lower: Lower {
384                pattern_literal_prefix: "N",
385                match_collation: Some("Latin1_General_BIN2"),
386                ..ANSI_SID_SPELLING.lower
387            },
388            ..HELPER_ROUTED
389        };
390        assert_eq!(
391            normalize(pinned, "x"),
392            "LOWER(REPLACE(REPLACE(TRIM(x) COLLATE Latin1_General_BIN2, N'\u{0130}', N'I'), \
393             N'\u{03A3}', N'\u{03C3}'))"
394        );
395    }
396
397    /// **A spelling that does not route through the key function ignores the
398    /// schema.**
399    ///
400    /// An engine whose key function is an installed artifact must render a
401    /// **bare** call: the artifact is registered where that engine registers
402    /// artifacts, not in a schema Razeri created.
403    #[test]
404    fn a_spelling_that_inlines_its_artifact_ignores_the_schema_it_is_passed() {
405        let external =
406            SidSpelling { hash: ("generate_sid(", ")"), through_helper: false, ..HELPER_ROUTED };
407        let qualified = sid_expression(external, Some("acme"), &["x".to_owned()]);
408        let bare = sid_expression(external, None, &["x".to_owned()]);
409        assert_eq!(
410            qualified, bare,
411            "passing a schema moved the key function's name — the artifact is not in a schema \
412             Razeri created"
413        );
414        assert!(!qualified.contains("acme."), "the emitted call qualifies an installed artifact");
415    }
416
417    /// The call through the key function and the inlined native spelling are
418    /// the same derivation over the same normalized input — the only difference
419    /// is where the hash is written down.
420    #[test]
421    fn the_helper_site_and_the_native_site_normalize_identically() {
422        let args = ["a".to_owned(), "b".to_owned()];
423        let native = sid_derivation(&args, HELPER_ROUTED, HashSite::Native);
424        let helper = sid_derivation(&args, HELPER_ROUTED, HashSite::Helper(HELPER_FUNCTION_NAME));
425        let a = "LOWER(REPLACE(REPLACE(TRIM(a), '\u{0130}', 'I'), '\u{03A3}', '\u{03C3}'))";
426        let b = "LOWER(REPLACE(REPLACE(TRIM(b), '\u{0130}', 'I'), '\u{03A3}', '\u{03C3}'))";
427        assert_eq!(native, format!("pg_xxhash64(CONCAT({a}, '|', {b}))"));
428        assert_eq!(helper, format!("generate_sid(CONCAT({a}, '|', {b}))"));
429    }
430
431    /// **Any NULL part keys the whole member to the sentinel**, and the guard
432    /// tests the parts rather than the hash's result.
433    #[test]
434    fn any_null_part_reaches_the_sentinel_and_the_guard_tests_the_input() {
435        let composite = sid_expression(HELPER_ROUTED, None, &["a".to_owned(), "b".to_owned()]);
436        assert!(composite.starts_with("CASE WHEN a IS NULL OR b IS NULL THEN "));
437        assert!(composite.contains(&NULL_SID.to_string()));
438        assert!(!composite.contains("COALESCE"));
439    }
440
441    /// **An engine whose `CONCAT` takes exactly two arguments gets one call per
442    /// pair**, nested left to right, over the same items the variadic call
443    /// lists.
444    #[test]
445    fn a_binary_concat_nests_one_call_per_pair() {
446        let spelling = SidSpelling { concat: Concat::FunctionBinary("CONCAT"), ..HELPER_ROUTED };
447        let part = |arg: &str| normalize(spelling, arg);
448        let two = ["a".to_owned(), "b".to_owned()];
449        assert_eq!(
450            sid_derivation(&two, spelling, HashSite::Native),
451            format!("pg_xxhash64(CONCAT(CONCAT({}, '|'), {}))", part("a"), part("b"))
452        );
453        let three = ["a".to_owned(), "b".to_owned(), "c".to_owned()];
454        assert_eq!(
455            sid_derivation(&three, spelling, HashSite::Native),
456            format!(
457                "pg_xxhash64(CONCAT(CONCAT(CONCAT(CONCAT({}, '|'), {}), '|'), {}))",
458                part("a"),
459                part("b"),
460                part("c")
461            )
462        );
463    }
464
465    /// **A one-part key is not joined at all**, on this form as on the other
466    /// two: there is nothing to join, and a one-argument `CONCAT` is a call an
467    /// engine with a two-argument one does not have either.
468    #[test]
469    fn a_binary_concat_leaves_a_one_part_key_unjoined() {
470        let binary = SidSpelling { concat: Concat::FunctionBinary("CONCAT"), ..HELPER_ROUTED };
471        let one = ["a".to_owned()];
472        assert_eq!(
473            sid_derivation(&one, binary, HashSite::Native),
474            sid_derivation(&one, HELPER_ROUTED, HashSite::Native)
475        );
476        assert!(!sid_derivation(&one, binary, HashSite::Native).contains("CONCAT"));
477    }
478
479    /// An empty key renders the bare call — the same degenerate spelling the
480    /// variadic form renders, so the two forms stay comparable where there is
481    /// nothing to join.
482    #[test]
483    fn a_binary_concat_renders_the_bare_call_for_an_empty_key() {
484        let binary = SidSpelling { concat: Concat::FunctionBinary("CONCAT"), ..HELPER_ROUTED };
485        assert_eq!(
486            sid_derivation(&[], binary, HashSite::Native),
487            sid_derivation(&[], HELPER_ROUTED, HashSite::Native)
488        );
489        assert_eq!(sid_derivation(&[], binary, HashSite::Native), "pg_xxhash64(CONCAT())");
490    }
491
492    /// **The three join forms are three spellings of one string.** Read the
493    /// items out of each rendering and they are the same items in the same
494    /// order — which is why a dialect may answer whichever one its engine
495    /// parses without moving a key.
496    #[test]
497    fn the_three_join_forms_concatenate_the_same_items_in_the_same_order() {
498        let parts = ["a".to_owned(), "b".to_owned(), "c".to_owned()];
499        let items = |spelling| {
500            let rendered = sid_derivation(&parts, spelling, HashSite::Native);
501            let mut found = Vec::new();
502            for arg in ["a", "b", "c"] {
503                let normalized = normalize(spelling, arg);
504                let at = rendered.find(&normalized).expect("every part is joined");
505                found.push((at, arg));
506            }
507            found.sort_unstable();
508            (
509                found.into_iter().map(|(_, arg)| arg).collect::<Vec<_>>(),
510                rendered.matches("'|'").count(),
511            )
512        };
513        let variadic = items(HELPER_ROUTED);
514        let binary =
515            items(SidSpelling { concat: Concat::FunctionBinary("CONCAT"), ..HELPER_ROUTED });
516        let operator = items(SidSpelling { concat: Concat::Plus, ..HELPER_ROUTED });
517        assert_eq!(variadic, (vec!["a", "b", "c"], 2));
518        assert_eq!(binary, variadic);
519        assert_eq!(operator, variadic);
520    }
521
522    /// The sentinel is two above the smallest 64-bit integer, and it is spelled
523    /// once.
524    #[test]
525    fn the_sentinel_is_two_above_the_smallest_integer() {
526        assert_eq!(NULL_SID, i64::MIN + 2);
527    }
528}