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//! The key is **XXH64, seed 0, of the UTF-8 bytes of the value as the table
9//! stores it**, read as a signed 64-bit integer. Nothing trims, lowercases or
10//! replaces anything on the way in: a column that wants its value cleaned says
11//! so in the design, the load that writes the table cleans it, and the key is
12//! the hash of what the table then holds.
13//!
14//! [`SidSpelling`] says how one engine joins strings, casts a value to text,
15//! writes a replacement and spells a 64-bit hash. This module turns those
16//! answers into the expression a warehouse runs:
17//!
18//! ```text
19//! guard( hash( part₁ ) )                                  one column
20//! guard( hash( escape(part₁) ‖ '|' ‖ … ‖ escape(partₙ) ) )  two or more
21//! ```
22//!
23//! It is published so that a dialect author can render their own key
24//! expression and run it. Without it, *"the same key on every engine"* is a
25//! claim the person making it cannot check — and a key that differs by one
26//! character keys a warehouse to numbers nothing else reproduces.
27//!
28//! # What a dialect decides, and what it does not
29//!
30//! The **vocabulary** is the dialect's, and it is the only thing that varies.
31//! The **derivation** is fixed: there is no table of engine names here and no
32//! way to add one, so a third party spells their own engine's key by stating a
33//! [`SidSpelling`] and nothing else.
34//!
35//! # The parts of the derivation
36//!
37//! - each **part** is the key column cast to text, and nothing more;
38//! - a key of **one** column is hashed as that text, with nothing done to it;
39//! - a key of **two or more** columns **escapes** each part — every `\`
40//!   doubled, then a `\` put before every `|` ([`escape_part`]) — and joins
41//!   the parts with the literal `|`. Without the escape `('a|b', 'c')` and
42//!   `('a', 'b|c')` are one text and one key. A part holding neither character
43//!   is its own text, so its key is the number it always was;
44//! - **guard** is the [`NULL_SID`] sentinel, and it fires when **any** part is
45//!   NULL: any NULL component keys the whole member to the sentinel;
46//! - **hash** is either this engine's own spelling or a call to the
47//!   [`HELPER_FUNCTION_NAME`] function, whose body is that same spelling over
48//!   its argument as given. The two sites are one derivation — see
49//!   [`HashSite`].
50
51/// **The sentinel a member with any NULL key part is keyed to**, `(-2^63) + 2`.
52///
53/// One number, on every engine, for one reason: a NULL natural key is an
54/// absence rather than a value, and two absences are not the same member. It
55/// sits two above the minimum so that a warehouse can still reserve the
56/// minimum itself.
57pub const NULL_SID: i64 = -9_223_372_036_854_775_806_i64;
58
59/// **The name of the surrogate-key function — the only one.**
60///
61/// One public name everywhere, whoever defines it. On engines where the hash is
62/// an ordinary SQL expression, Razeri emits a function of this name whose body
63/// is the derivation below. On engines where the hash lives outside SQL — a
64/// compiled library, an assembly, a registered handler — the dialect's
65/// prerequisites install an artifact under this same name and
66/// [`SidSpelling::hash`] names it directly, so nothing emits a second
67/// definition.
68///
69/// A second name is a second contract. There is no alias and no transitional
70/// spelling.
71pub const HELPER_FUNCTION_NAME: &str = "generate_sid";
72
73use crate::ExpressionRenderer;
74use crate::sid_spelling::{Concat, SidSpelling, TextCast, Trim};
75
76/// Where the hash itself is spelled.
77#[derive(Debug, Clone, Copy)]
78pub enum HashSite<'a> {
79    /// Inline this engine's own hash spelling, from [`SidSpelling::hash`].
80    Native,
81    /// Inline this engine's own hash spelling, in the schema Razeri installed
82    /// it in — [`SidPrerequisiteHome::Schema`](crate::SidPrerequisiteHome).
83    ///
84    /// The schema, a dot, and then [`SidSpelling::hash`] exactly as [`Native`]
85    /// writes it. So a dialect that names a home owes a `hash` whose opening
86    /// text BEGINS with the function's own name: `razeri_xxh64(` qualifies to
87    /// `razeri.razeri_xxh64(`, while a spelling that opened with a cast —
88    /// `CAST(xxHash64(` — would put the schema in front of the cast and name
89    /// nothing. Every engine that opens with a cast has a server-global
90    /// function and names no home, which is why the two facts have never met.
91    ///
92    /// [`Native`]: Self::Native
93    NativeIn(&'a str),
94    /// Call the [`HELPER_FUNCTION_NAME`] function by this — possibly
95    /// schema-qualified — name. Its body is the [`HashSite::Native`] spelling,
96    /// so the two sites are the same derivation.
97    ///
98    /// **The qualifier is the function's home, not the caller's schema.** One
99    /// function is created per deployment unit, so a call from any other schema
100    /// of that unit has to name the schema that has it. A caller that qualifies
101    /// with its own schema names a function nothing created.
102    Helper(&'a str),
103}
104
105/// **`TRIM(expr)` in this engine's spelling** — the space around a value
106/// taken off, and nothing else.
107///
108/// Public because taking the space off a value the source wrote is asked in
109/// more than one place — reading an amount the source spelled its own way,
110/// and a column's `trim` cleaning step — and two spellings of `TRIM` would be
111/// two answers to one question. The key itself trims nothing.
112///
113/// Parameters:
114/// - `spelling`: this engine's vocabulary;
115/// - `expr`: an already-rendered value expression.
116#[must_use]
117pub fn normalize_trim(spelling: SidSpelling, expr: &str) -> String {
118    match spelling.trim {
119        Trim::Function(name) => format!("{name}({expr})"),
120        Trim::LtrimRtrim => format!("LTRIM(RTRIM({expr}))"),
121        Trim::FunctionWithSpaceSet(name) => format!("{name}({expr}, ' ')"),
122    }
123}
124
125/// **`CAST(expr AS text)` in this engine's spelling**, total over NULL.
126///
127/// Public for the reason [`normalize_trim`] is public: turning a value into
128/// text is not something only the key derivation does. A caller that has to
129/// read a value back out of an engine as text — because the transport it
130/// arrives over encodes the engine's own types in a form the same engine will
131/// not accept back — needs this engine's cast and not a second opinion about
132/// it. Two spellings of one cast are two answers to one question.
133///
134/// Parameters:
135/// - `spelling`: this engine's vocabulary;
136/// - `expr`: an already-rendered value expression.
137#[must_use]
138pub fn cast_to_text(spelling: SidSpelling, expr: &str) -> String {
139    match spelling.cast_type {
140        TextCast::Direct(ty) => format!("CAST({expr} AS {ty})"),
141        TextCast::ClickHouseNullTolerant(ty) => {
142            format!("assumeNotNull(CAST({expr} AS Nullable({ty})))")
143        }
144    }
145}
146
147/// **How an engine writes a backslash between quotes** — the one thing about
148/// its string literals the escape inside a composite key has to know.
149///
150/// The escape writes `'\'`, `'\\'` and `'\|'` as literals, and engines
151/// disagree about what those are. Standard SQL reads a backslash in a literal
152/// as itself; some engines read it as the start of an escape, so one
153/// backslash is written as two there. A literal written the wrong way is a
154/// different string, or no string at all, and the key it makes is a number
155/// no other engine computes.
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157pub enum Backslash {
158    /// `'\'` is one backslash: standard SQL, and the default.
159    AsWritten,
160    /// `'\\'` is one backslash: the engine reads a backslash in a literal
161    /// as an escape.
162    Doubled,
163}
164
165impl Backslash {
166    /// **This engine's answer, read from the engine's own words.**
167    ///
168    /// The one place every dialect already says how it writes a backslash
169    /// between quotes is the list of words it reads as a missing value in a
170    /// file ([`ExpressionRenderer::render_missing_value`]): an engine whose
171    /// literals read a backslash as an escape writes each one twice there, so
172    /// that a word holding one stays that word. So the dialect is asked for a
173    /// word that is one backslash, and the answer is read off what it writes.
174    /// A dialect that writes the word as standard SQL does is
175    /// [`Backslash::AsWritten`].
176    ///
177    /// Parameters:
178    /// - `dialect`: the engine the key is written for.
179    #[must_use]
180    pub fn of(dialect: &dyn ExpressionRenderer) -> Self {
181        if dialect.render_missing_value("v", &["\\"]).contains("'\\\\'") {
182            Self::Doubled
183        } else {
184            Self::AsWritten
185        }
186    }
187
188    /// **`text` as a string literal on this engine**: between single quotes,
189    /// each backslash written the way this engine reads one, behind `prefix`,
190    /// the engine's Unicode-literal prefix
191    /// ([`Lower::pattern_literal_prefix`](crate::Lower::pattern_literal_prefix)).
192    /// A quote in `text` is doubled.
193    ///
194    /// Parameters:
195    /// - `prefix`: what this engine puts in front of a Unicode literal.
196    /// - `text`: the literal's characters.
197    #[must_use]
198    pub fn literal(self, prefix: &str, text: &str) -> String {
199        let quoted = text.replace('\'', "''");
200        match self {
201            Self::AsWritten => format!("{prefix}'{quoted}'"),
202            Self::Doubled => format!("{prefix}'{}'", quoted.replace('\\', "\\\\")),
203        }
204    }
205}
206
207/// **One part of a composite key, escaped in this engine's words**: every
208/// `\` doubled, then a `\` put before every `|` — two replacements, the
209/// backslash first, so the second one's own backslashes are not doubled
210/// again. The subject is pinned to the engine's binary collation where its
211/// key spelling names one
212/// ([`Lower::match_collation`](crate::Lower::match_collation)), so the
213/// replacement matches by the character.
214///
215/// Parameters:
216/// - `part`: the part, already cast to text.
217/// - `spelling`: this engine's vocabulary — its `REPLACE`, its literal prefix
218///   and its binary collation.
219/// - `backslash`: how this engine writes a backslash between quotes.
220#[must_use]
221pub fn escape_part(part: &str, spelling: SidSpelling, backslash: Backslash) -> String {
222    let words = spelling.lower;
223    let literal = |text: &str| backslash.literal(words.pattern_literal_prefix, text);
224    let subject = match words.match_collation {
225        Some(collation) => format!("{part} COLLATE {collation}"),
226        None => part.to_owned(),
227    };
228    let doubled = format!("{}({subject}, {}, {})", words.replace, literal("\\"), literal("\\\\"));
229    format!("{}({doubled}, {}, {})", words.replace, literal("|"), literal("\\|"))
230}
231
232/// **The join, written for an engine whose `CONCAT` takes exactly two
233/// arguments**: the parts and the separators between them, folded left to
234/// right, one call per pair. The same items in the same order as the
235/// variadic call, so the joined string is the same string.
236///
237/// An empty key renders the bare call, as the variadic form does.
238fn nest_pairwise(name: &str, parts: &[String]) -> String {
239    let mut rest = parts.iter();
240    let Some(first) = rest.next() else { return format!("{name}()") };
241    rest.fold(first.clone(), |left, next| format!("{name}({name}({left}, '|'), {next})"))
242}
243
244/// **The derivation**: one part hashed as given; two or more parts each
245/// escaped ([`escape_part`]), joined on `'|'`, and hashed.
246///
247/// `text_args` are already-rendered value expressions, already cast to text
248/// where the caller needs it — a column reference inside a load statement, a
249/// parameter name inside the key function's own body. Callers name the value
250/// differently, so composing the name here would mean knowing which caller was
251/// asking.
252///
253/// The result carries **no NULL guard**; [`guard_null_members`] wraps this.
254///
255/// Parameters:
256/// - `text_args`: the key's parts, already cast to text;
257/// - `spelling`: this engine's vocabulary;
258/// - `backslash`: how this engine writes a backslash between quotes
259///   ([`Backslash::of`]);
260/// - `site`: whether the hash is inlined or reached through the function.
261#[must_use]
262pub fn sid_derivation(
263    text_args: &[String],
264    spelling: SidSpelling,
265    backslash: Backslash,
266    site: HashSite<'_>,
267) -> String {
268    let joined = if text_args.len() == 1 {
269        text_args[0].clone()
270    } else {
271        let parts: Vec<String> =
272            text_args.iter().map(|part| escape_part(part, spelling, backslash)).collect();
273        match spelling.concat {
274            Concat::Function(name) => format!("{name}({})", parts.join(", '|', ")),
275            Concat::FunctionBinary(name) => nest_pairwise(name, &parts),
276            Concat::Plus => parts.join(" + '|' + "),
277            Concat::Operator(operator) => parts.join(&format!(" {operator} '|' {operator} ")),
278        }
279    };
280    match site {
281        HashSite::Native => format!("{}{joined}{}", spelling.hash.0, spelling.hash.1),
282        HashSite::NativeIn(home) => {
283            format!("{home}.{}{joined}{}", spelling.hash.0, spelling.hash.1)
284        }
285        HashSite::Helper(name) => format!("{name}({joined})"),
286    }
287}
288
289/// **The [`NULL_SID`] sentinel, wrapped around the derivation.**
290///
291/// `raw_args` are the arguments **before** any cast — a cast preserves
292/// NULL-ness, and `CAST(x AS TEXT) IS NULL` only makes the guard harder to
293/// read.
294///
295/// **Any NULL component of a composite keys the whole member to the sentinel.**
296/// That closes a collision as well as an omission: on several engines
297/// `CONCAT('x', '|', NULL)` is `'x|'`, byte-identical to
298/// `CONCAT('x', '|', '')`, so `('boston', NULL)` and `('boston', '')` would be
299/// one member. Testing the components before they are joined is the only place
300/// the distinction still exists.
301///
302/// The guard is a `CASE WHEN` rather than a `COALESCE` because it tests the
303/// **input**. A `COALESCE` over the hash's *output* reaches the sentinel only
304/// where the hash propagates NULL, and on at least one engine the hash of NULL
305/// is a perfectly ordinary number.
306///
307/// An empty key renders no guard rather than an empty one.
308///
309/// Parameters:
310/// - `raw_args`: the key's parts as the caller wrote them, before any cast;
311/// - `derivation`: the expression [`sid_derivation`] produced.
312#[must_use]
313pub fn guard_null_members(raw_args: &[String], derivation: String) -> String {
314    if raw_args.is_empty() {
315        return derivation;
316    }
317    let test = raw_args.iter().map(|arg| format!("{arg} IS NULL")).collect::<Vec<_>>().join(" OR ");
318    format!("CASE WHEN {test} THEN {NULL_SID} ELSE {derivation} END")
319}
320
321/// **The surrogate key as one expression**: cast, derive, guard.
322///
323/// This is the whole composition, and it is what every producer of a key
324/// expression emits — the load that keys a dimension, the body of the
325/// per-entity key function, the formula a design can name. Rendering it for
326/// your own engine and running it is how you check that your
327/// [`SidSpelling`] reaches the number every other engine reaches.
328///
329/// ```
330/// use razeri_plugin_contract::{ANSI_SID_SPELLING, sid};
331///
332/// let key = sid::sid_expression(
333///     ANSI_SID_SPELLING,
334///     sid::Backslash::AsWritten,
335///     None,
336///     &["t.city".to_owned()],
337/// );
338/// assert!(key.starts_with("CASE WHEN t.city IS NULL THEN "));
339/// assert!(key.contains("generate_sid(CAST(t.city AS TEXT))"));
340/// ```
341///
342/// # `helper_home` is the key function's schema, never the caller's
343///
344/// The only thing a schema does here is qualify the call to
345/// [`HELPER_FUNCTION_NAME`] on the engines whose
346/// [`SidSpelling::through_helper`] is true. Where the key function is an
347/// installed artifact instead, its own name is rendered and this argument is
348/// ignored entirely — on at least one engine a compiled function lives server
349/// globally and a qualified call to it does not resolve at all.
350///
351/// So the schema this wants is the one the function was **created** in, which
352/// is a property of the deployment unit rather than of whichever entity is
353/// being keyed. `None` renders the call unqualified, which is correct wherever
354/// nothing is qualified: an engine without schemas, or a design that names no
355/// schema.
356///
357/// Parameters:
358/// - `spelling`: this engine's vocabulary;
359/// - `backslash`: how this engine writes a backslash between quotes
360///   ([`Backslash::of`]);
361/// - `helper_home`: the schema the key function was created in, where the call
362///   needs qualifying;
363/// - `parts`: the key's parts as raw value expressions, in order.
364#[must_use]
365pub fn sid_expression(
366    spelling: SidSpelling,
367    backslash: Backslash,
368    helper_home: Option<&str>,
369    parts: &[String],
370) -> String {
371    let qualified = helper_home
372        .map_or_else(|| HELPER_FUNCTION_NAME.to_owned(), |s| format!("{s}.{HELPER_FUNCTION_NAME}"));
373    sid_expression_through(spelling, backslash, &qualified, parts)
374}
375
376/// **The same expression, with the helper's name already spelled by the
377/// target.**
378///
379/// [`sid_expression`] writes the helper's name the way most engines read it —
380/// the schema, a dot, the name, all bare. That is a name on every engine that
381/// leaves an unquoted word alone and a *different* name on one that folds it,
382/// where the function was created under the spelling that engine's own DDL
383/// wrote. A caller holding the dialect spells the name first and hands it in
384/// here, so the call and the `CREATE FUNCTION` say the same word.
385///
386/// Parameters:
387/// - `spelling`: this engine's vocabulary;
388/// - `backslash`: how this engine writes a backslash between quotes;
389/// - `helper`: the key helper's name, qualified and spelled;
390/// - `parts`: the key's parts as raw value expressions, in order.
391#[must_use]
392pub fn sid_expression_through(
393    spelling: SidSpelling,
394    backslash: Backslash,
395    helper: &str,
396    parts: &[String],
397) -> String {
398    let cast: Vec<String> = parts.iter().map(|arg| cast_to_text(spelling, arg)).collect();
399    let site = if spelling.through_helper { HashSite::Helper(helper) } else { HashSite::Native };
400    guard_null_members(parts, sid_derivation(&cast, spelling, backslash, site))
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406    use crate::sid_spelling::{ANSI_SID_SPELLING, Lower};
407
408    /// The methods a test renderer has to answer, and nothing else.
409    macro_rules! for_test_renderer {
410        ($name:ident) => {
411            impl crate::SqlRenderer for $name {
412                fn render_data_type(&self, _: &crate::DataType) -> String {
413                    String::new()
414                }
415                fn quote_identifier(&self, name: &str) -> String {
416                    name.to_owned()
417                }
418                fn qualify_name(&self, _: Option<&str>, name: &str) -> String {
419                    name.to_owned()
420                }
421                fn render_current_timestamp(&self) -> String {
422                    String::new()
423                }
424            }
425        };
426    }
427
428    /// A vocabulary for the composition tests: a fixture, not an engine.
429    const HELPER_ROUTED: SidSpelling =
430        SidSpelling { platform: "test", hash: ("pg_xxhash64(", ")"), ..ANSI_SID_SPELLING };
431
432    /// Standard SQL's backslash, for the composition tests.
433    const STD: Backslash = Backslash::AsWritten;
434
435    /// `a` and `b`, each escaped as standard SQL writes it.
436    const A_B: &str = "REPLACE(REPLACE(a, '\\', '\\\\'), '|', '\\|'), '|', \
437                       REPLACE(REPLACE(b, '\\', '\\\\'), '|', '\\|')";
438
439    /// **Nothing is done to a one-column key before it is hashed**: no
440    /// `TRIM`, no `LOWER`, no `REPLACE` — the cast and the hash, and nothing
441    /// between.
442    #[test]
443    fn a_part_is_hashed_as_it_is_cast() {
444        assert_eq!(
445            sid_expression(HELPER_ROUTED, STD, None, &["x".to_owned()]),
446            format!("CASE WHEN x IS NULL THEN {NULL_SID} ELSE generate_sid(CAST(x AS TEXT)) END")
447        );
448        let native = SidSpelling { through_helper: false, ..HELPER_ROUTED };
449        for spelling in [HELPER_ROUTED, native] {
450            for backslash in [STD, Backslash::Doubled] {
451                let rendered = sid_expression(spelling, backslash, Some("acme"), &["x".to_owned()]);
452                for fold in ["TRIM(", "LOWER(", "REPLACE("] {
453                    assert!(!rendered.contains(fold), "{fold} in {rendered}");
454                }
455            }
456        }
457    }
458
459    /// **Each part of a composite is escaped before the join**: every `\`
460    /// doubled, then a `\` put before every `|`, in this engine's `REPLACE`,
461    /// its literal prefix, its binary collation and its way of writing a
462    /// backslash.
463    #[test]
464    fn a_composite_escapes_each_part_before_the_join() {
465        let native = SidSpelling { through_helper: false, ..HELPER_ROUTED };
466        assert_eq!(
467            sid_derivation(&["a".to_owned(), "b".to_owned()], native, STD, HashSite::Native),
468            format!("pg_xxhash64(CONCAT({A_B}))")
469        );
470        assert_eq!(
471            escape_part("a", ANSI_SID_SPELLING, Backslash::Doubled),
472            "REPLACE(REPLACE(a, '\\\\', '\\\\\\\\'), '|', '\\\\|')"
473        );
474        let tsql_like = SidSpelling {
475            lower: Lower {
476                function: "LOWER",
477                replace: "REPLACE",
478                pattern_literal_prefix: "N",
479                match_collation: Some("Latin1_General_100_BIN2"),
480            },
481            ..ANSI_SID_SPELLING
482        };
483        assert_eq!(
484            escape_part("a", tsql_like, STD),
485            "REPLACE(REPLACE(a COLLATE Latin1_General_100_BIN2, N'\\', N'\\\\'), N'|', N'\\|')"
486        );
487        let clickhouse_like = SidSpelling {
488            lower: Lower { replace: "replaceAll", ..ANSI_SID_SPELLING.lower },
489            ..ANSI_SID_SPELLING
490        };
491        assert!(
492            escape_part("a", clickhouse_like, Backslash::Doubled)
493                .starts_with("replaceAll(replaceAll(a, "),
494            "the engine's own replacement function"
495        );
496    }
497
498    /// **An engine's backslash is read from its own missing-value words.**
499    #[test]
500    fn the_backslash_is_read_from_the_dialects_own_literal() {
501        struct Escaping;
502        struct Standard;
503        for_test_renderer!(Escaping);
504        for_test_renderer!(Standard);
505        impl ExpressionRenderer for Standard {}
506        impl ExpressionRenderer for Escaping {
507            fn render_missing_value(&self, text: &str, words: &[&str]) -> String {
508                let listed: Vec<String> =
509                    words.iter().map(|w| format!("'{}'", w.replace('\\', "\\\\"))).collect();
510                format!("CASE WHEN {text} IN ({}) THEN NULL ELSE {text} END", listed.join(", "))
511            }
512        }
513        assert_eq!(Backslash::of(&Escaping), Backslash::Doubled);
514        assert_eq!(Backslash::of(&Standard), Backslash::AsWritten);
515        assert_eq!(Backslash::Doubled.literal("", "\\|"), "'\\\\|'");
516        assert_eq!(Backslash::AsWritten.literal("N", "\\|"), "N'\\|'");
517        assert_eq!(Backslash::AsWritten.literal("", "O'Hare"), "'O''Hare'");
518    }
519
520    /// **The call through the key function and the inlined spelling are one
521    /// derivation** over the same parts.
522    #[test]
523    fn the_helper_site_and_the_native_site_hash_the_same_text() {
524        let args = ["a".to_owned(), "b".to_owned()];
525        assert_eq!(
526            sid_derivation(&args, HELPER_ROUTED, STD, HashSite::Native),
527            format!("pg_xxhash64(CONCAT({A_B}))")
528        );
529        assert_eq!(
530            sid_derivation(&args, HELPER_ROUTED, STD, HashSite::Helper(HELPER_FUNCTION_NAME)),
531            format!("generate_sid(CONCAT({A_B}))")
532        );
533        assert_eq!(
534            sid_derivation(&args, HELPER_ROUTED, STD, HashSite::NativeIn("razeri")),
535            format!("razeri.pg_xxhash64(CONCAT({A_B}))")
536        );
537    }
538
539    /// **The four join forms are four spellings of one string**: the same
540    /// items, in the same order, with the same separators.
541    #[test]
542    fn the_four_join_forms_join_the_same_items() {
543        let three = ["a".to_owned(), "b".to_owned(), "c".to_owned()];
544        let with = |concat| {
545            sid_derivation(&three, SidSpelling { concat, ..HELPER_ROUTED }, STD, HashSite::Native)
546        };
547        let [a, b, c] = three.clone().map(|part| escape_part(&part, HELPER_ROUTED, STD));
548        assert_eq!(
549            with(Concat::Function("CONCAT")),
550            format!("pg_xxhash64(CONCAT({a}, '|', {b}, '|', {c}))")
551        );
552        assert_eq!(
553            with(Concat::FunctionBinary("CONCAT")),
554            format!("pg_xxhash64(CONCAT(CONCAT(CONCAT(CONCAT({a}, '|'), {b}), '|'), {c}))")
555        );
556        assert_eq!(with(Concat::Plus), format!("pg_xxhash64({a} + '|' + {b} + '|' + {c})"));
557        assert_eq!(
558            with(Concat::Operator("||")),
559            format!("pg_xxhash64({a} || '|' || {b} || '|' || {c})")
560        );
561        let one = ["a".to_owned()];
562        assert_eq!(
563            sid_derivation(
564                &one,
565                SidSpelling { concat: Concat::Plus, ..HELPER_ROUTED },
566                STD,
567                HashSite::Native
568            ),
569            "pg_xxhash64(a)",
570            "a one-part key is neither escaped nor joined"
571        );
572        assert_eq!(
573            sid_derivation(
574                &[],
575                SidSpelling { concat: Concat::FunctionBinary("CONCAT"), ..HELPER_ROUTED },
576                STD,
577                HashSite::Native
578            ),
579            "pg_xxhash64(CONCAT())"
580        );
581    }
582
583    /// **Any NULL part keys the whole member to the sentinel**, the guard
584    /// testing the parts before the cast; an empty key renders no guard.
585    #[test]
586    fn any_null_part_reaches_the_sentinel() {
587        let composite = sid_expression(HELPER_ROUTED, STD, None, &["a".to_owned(), "b".to_owned()]);
588        assert!(composite.starts_with("CASE WHEN a IS NULL OR b IS NULL THEN "), "{composite}");
589        assert!(!sid_expression(HELPER_ROUTED, STD, None, &[]).contains("CASE WHEN"));
590    }
591
592    /// **A spelling that does not route through the key function ignores the
593    /// schema.** An engine whose key function is an installed artifact
594    /// renders a bare call: the artifact is registered where that engine
595    /// registers artifacts, not in a schema Razeri created.
596    #[test]
597    fn a_spelling_that_inlines_its_artifact_ignores_the_schema_it_is_passed() {
598        let external =
599            SidSpelling { hash: ("generate_sid(", ")"), through_helper: false, ..HELPER_ROUTED };
600        let qualified = sid_expression(external, STD, Some("acme"), &["x".to_owned()]);
601        let bare = sid_expression(external, STD, None, &["x".to_owned()]);
602        assert_eq!(qualified, bare);
603        assert!(!qualified.contains("acme."), "the emitted call qualifies an installed artifact");
604    }
605
606    /// **An engine that joins with an operator gets one expression**, the
607    /// operator between each part and the separator, left to right; a
608    /// one-part key is not joined on this form either.
609    #[test]
610    fn an_operator_join_writes_the_operator_between_each_part_and_the_separator() {
611        let spelling = SidSpelling { concat: Concat::Operator("||"), ..HELPER_ROUTED };
612        let three = ["a".to_owned(), "b".to_owned(), "c".to_owned()];
613        let [a, b, c] = three.clone().map(|part| escape_part(&part, spelling, STD));
614        assert_eq!(
615            sid_derivation(&three, spelling, STD, HashSite::Native),
616            format!("pg_xxhash64({a} || '|' || {b} || '|' || {c})")
617        );
618        assert_eq!(
619            sid_derivation(&["a".to_owned()], spelling, STD, HashSite::Native),
620            "pg_xxhash64(a)"
621        );
622    }
623
624    /// The sentinel is two above the smallest 64-bit integer, and it is spelled
625    /// once.
626    #[test]
627    fn the_sentinel_is_two_above_the_smallest_integer() {
628        assert_eq!(NULL_SID, i64::MIN + 2);
629    }
630}