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}