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}