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