diff --git a/crates/eql-domains/src/fixture.rs b/crates/eql-domains/src/fixture.rs deleted file mode 100644 index 3bb07730b..000000000 --- a/crates/eql-domains/src/fixture.rs +++ /dev/null @@ -1,48 +0,0 @@ -//! Inherent impls for [`Fixture`] — resolving a fixture to its integer value -//! (`numeric_value`). Definition lives in `lib.rs`. - -use crate::{Fixture, ScalarKind}; - -impl Fixture { - /// The integer value for this fixture (`Min`/`Max` -> kind bounds, `Zero` -> - /// 0, `Int(n)` -> n), or `None` for the string-backed kinds. Does not - /// range-check; `every_fixture_value_is_within_kind_bounds` guards the bounds. - /// - /// `const fn` so the `int_values!` materialiser can resolve a whole fixture - /// list into a typed `&'static` array at compile time. - pub const fn numeric_value(self, kind: ScalarKind) -> Option { - match self { - // `?` is not allowed in `const fn`, so match `as_bounded_int()` - // explicitly. A pivot on a non-integer kind resolves to `None`; the - // `pivot_sentinels_only_appear_with_integer_kinds` catalog test - // guarantees that combination never reaches a real `CATALOG` row. - Fixture::Min => match kind.as_bounded_int() { - Some(k) => Some(k.min_value()), - None => None, - }, - Fixture::Max => match kind.as_bounded_int() { - Some(k) => Some(k.max_value()), - None => None, - }, - Fixture::Zero => match kind.as_bounded_int() { - Some(_) => Some(0), - None => None, - }, - // Gate the literal on the integer kinds too, mirroring the sentinels - // above: a hand-built `Int(n)` on a non-integer kind resolves to - // `None` rather than fabricating a number for a `Text`/`Date`/`Bool` - // kind that has no integer projection. - Fixture::Int(n) => match kind.as_bounded_int() { - Some(_) => Some(n), - None => None, - }, - Fixture::Numeric(_) - | Fixture::Text(_) - | Fixture::Jsonb(_) - | Fixture::Date(_) - | Fixture::Timestamptz(_) - | Fixture::Float(_) - | Fixture::Bool(_) => None, - } - } -} diff --git a/crates/eql-domains/src/fixtures/fixture.rs b/crates/eql-domains/src/fixtures/fixture.rs new file mode 100644 index 000000000..46a66aed4 --- /dev/null +++ b/crates/eql-domains/src/fixtures/fixture.rs @@ -0,0 +1,111 @@ +//! [`Fixture`] — the value-kind-tagged plaintext fixture value, its +//! `numeric_value` impl, and the `fixtures!` builder macro. Def + impl + macro +//! co-located here (the fixture-layer vocabulary). + +use super::kind::ScalarKind; + +/// Builds a `&[Fixture]`. The `int ;` arm (a tt-muncher over `Min`/`Max`/ +/// `Zero` and `N()`) range-checks each literal against `` at compile +/// time via `const _RANGE_CHECK`, so out-of-range literals do not compile; +/// `text;`/`numeric;`/`jsonb;` wrap string literals. The reject case has no +/// in-crate test (macro isn't exported, no `trybuild` under zero-deps) — verify +/// by hand with a bad `N(..)`. +macro_rules! fixtures { + (int $t:ty; $($body:tt)*) => { fixtures!(@int $t; [] $($body)*) }; + (@int $t:ty; [$($acc:expr),*]) => { &[$($acc),*] }; + (@int $t:ty; [$($acc:expr),*] , $($r:tt)*) => { fixtures!(@int $t; [$($acc),*] $($r)*) }; + (@int $t:ty; [$($acc:expr),*] Min $($r:tt)*) => { fixtures!(@int $t; [$($acc,)* Fixture::Min ] $($r)*) }; + (@int $t:ty; [$($acc:expr),*] Max $($r:tt)*) => { fixtures!(@int $t; [$($acc,)* Fixture::Max ] $($r)*) }; + (@int $t:ty; [$($acc:expr),*] Zero $($r:tt)*) => { fixtures!(@int $t; [$($acc,)* Fixture::Zero] $($r)*) }; + (@int $t:ty; [$($acc:expr),*] N($v:literal) $($r:tt)*) => { + fixtures!(@int $t; [$($acc,)* Fixture::Int({ const _RANGE_CHECK: $t = $v; $v as i128 })] $($r)*) + }; + (text; $($s:literal),* $(,)?) => { &[$(Fixture::Text($s)),*] }; + (numeric; $($s:literal),* $(,)?) => { &[$(Fixture::Numeric($s)),*] }; + (jsonb; $($s:literal),* $(,)?) => { &[$(Fixture::Jsonb($s)),*] }; + (date; $($s:literal),* $(,)?) => { &[$(Fixture::Date($s)),*] }; + (timestamptz; $($s:literal),* $(,)?) => { &[$(Fixture::Timestamptz($s)),*] }; + (bool; $($b:literal),* $(,)?) => { &[$(Fixture::Bool($b)),*] }; + (float; $($s:literal),* $(,)?) => { &[$(Fixture::Float($s)),*] }; +} + +/// A single fixture plaintext value, value-kind tagged: `Min`/`Max`/`Zero` are +/// the integer matrix pivots (resolved per-kind); `Int` is an integer literal; +/// `Numeric`/`Text`/`Jsonb` carry rendered string literals. +/// +/// `fixtures!` range-checks `Int` literals at compile time, but a hand-built +/// `Fixture::Int(n)` is not — hence the runtime invariant tests. `Int(MIN)` and +/// `Min` resolve to the same numeric value via `numeric_value`. +/// (`numeric_value` is impl'd below.) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Fixture { + Min, + Max, + Zero, + Int(i128), + Numeric(&'static str), + Text(&'static str), + Jsonb(&'static str), + /// An ISO-8601 date string (`"1970-01-01"`). The catalog stays zero-dep, so + /// the string is parsed into a `chrono::NaiveDate` in the SQLx harness, not + /// here. Distinct by literal, like the other string-backed fixtures. + Date(&'static str), + /// An RFC3339 UTC timestamp string (`"1970-01-01T00:00:00Z"`). The catalog + /// stays zero-dep, so the string is parsed into a `chrono::DateTime` in + /// the SQLx harness, not here. Distinct by literal, like `Date`. + Timestamptz(&'static str), + /// A boolean plaintext (`true` / `false`). The `bool` scalar is + /// storage-only, so this fixture is encrypted (ciphertext only, no index + /// term) and never participates in a comparison pivot. Distinct by value. + Bool(bool), + /// An IEEE-754 float plaintext rendered as a string (`"0.5"`, `"-inf"`). + /// The catalog stays zero-dep, so the string is parsed into `f32`/`f64` in + /// the SQLx harness, not here. Distinct by parsed value (the harness + /// `float_fixtures_are_distinct_by_value` guard enforces this). NaN and + /// `-0.0` are deliberately excluded; `±Inf` (`"inf"`/`"-inf"`) ARE fixtures. + Float(&'static str), +} + +impl Fixture { + /// The integer value for this fixture (`Min`/`Max` -> kind bounds, `Zero` -> + /// 0, `Int(n)` -> n), or `None` for the string-backed kinds. Does not + /// range-check; `every_fixture_value_is_within_kind_bounds` guards the bounds. + /// + /// `const fn` so the `int_values!` materialiser can resolve a whole fixture + /// list into a typed `&'static` array at compile time. + pub const fn numeric_value(self, kind: ScalarKind) -> Option { + match self { + // `?` is not allowed in `const fn`, so match `as_bounded_int()` + // explicitly. A pivot on a non-integer kind resolves to `None`; the + // `pivot_sentinels_only_appear_with_integer_kinds` catalog test + // guarantees that combination never reaches a real `CATALOG` row. + Fixture::Min => match kind.as_bounded_int() { + Some(k) => Some(k.min_value()), + None => None, + }, + Fixture::Max => match kind.as_bounded_int() { + Some(k) => Some(k.max_value()), + None => None, + }, + Fixture::Zero => match kind.as_bounded_int() { + Some(_) => Some(0), + None => None, + }, + // Gate the literal on the integer kinds too, mirroring the sentinels + // above: a hand-built `Int(n)` on a non-integer kind resolves to + // `None` rather than fabricating a number for a `Text`/`Date`/`Bool` + // kind that has no integer projection. + Fixture::Int(n) => match kind.as_bounded_int() { + Some(_) => Some(n), + None => None, + }, + Fixture::Numeric(_) + | Fixture::Text(_) + | Fixture::Jsonb(_) + | Fixture::Date(_) + | Fixture::Timestamptz(_) + | Fixture::Float(_) + | Fixture::Bool(_) => None, + } + } +} diff --git a/crates/eql-domains/src/kind.rs b/crates/eql-domains/src/fixtures/kind.rs similarity index 55% rename from crates/eql-domains/src/kind.rs rename to crates/eql-domains/src/fixtures/kind.rs index 0b697331e..5472cabc7 100644 --- a/crates/eql-domains/src/kind.rs +++ b/crates/eql-domains/src/fixtures/kind.rs @@ -1,8 +1,77 @@ -//! Inherent impls for the scalar-kind vocabulary: [`BoundedIntKind`] (the total -//! accessors for fixed-width integer kinds) and [`ScalarKind`] (the native -//! scalar a domain maps onto). Definitions live in `lib.rs`. +//! [`ScalarKind`] / [`BoundedIntKind`] — the native scalar a domain maps onto +//! plus the total fixed-width-integer accessors. Defs and impls co-located here +//! (the fixture-layer vocabulary). -use crate::{BoundedIntKind, ScalarKind}; +/// The fixed-width integer kinds — exactly those scalar kinds with an `i128` +/// range and `MIN`/`MAX`/`Zero` sentinels. These accessors are **total**: every +/// variant answers every method. The non-integer kinds (`Numeric`/`Text`/ +/// `Jsonb`/`Date`/`Timestamptz`/`Bool`/`F32`/`F64`) are simply not representable +/// here, so there is no partial function to panic — `ScalarKind::Date` cannot +/// call `min_symbol()` because `Date` is not a `BoundedIntKind`. Reach this type +/// from a `ScalarKind` via [`ScalarKind::as_bounded_int`]. (Accessors are impl'd +/// below.) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BoundedIntKind { + I16, + I32, + I64, +} + +/// The native scalar a domain type maps onto. The integer kinds (`I16`/`I32`/ +/// `I64`) carry i128 bounds; the non-integer kinds (`Numeric`/`Text`/`Jsonb`/ +/// `Date`/`Timestamptz`/`Bool`/`F32`/`F64`) have no i128 range and string- or +/// bool-backed fixtures. All but `Jsonb` and `Bool` are still ORE-orderable — +/// `Jsonb` has no order, and `Bool` is storage-only (no comparison surface). +/// Capability layer only: `CATALOG` declares which kinds actually exist. +/// +/// The bounded-numeric accessors live on the total [`BoundedIntKind`], reached +/// via [`ScalarKind::as_bounded_int`]; non-integer kinds have no such accessor, +/// so misuse is a compile error rather than a runtime panic. (Accessors are +/// impl'd below.) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ScalarKind { + I16, + I32, + I64, + Numeric, + Text, + Jsonb, + /// Calendar date (`chrono::NaiveDate`). Ordered like the integer kinds via + /// ORE, but string-backed (ISO-8601) at the catalog layer and with no i128 + /// range — so it is *not* `is_int()` and `as_bounded_int()` returns `None` + /// for it, like the other non-integer kinds. The bounded-numeric accessors + /// live on `BoundedIntKind`, which `Date` cannot be, so they are + /// unreachable for it by construction rather than by a runtime panic. + Date, + /// UTC timestamp (`chrono::DateTime`). Ordered like the integer kinds + /// via ORE, but string-backed (RFC3339) at the catalog layer and with no + /// i128 range — so it is *not* `is_int()` and `as_bounded_int()` returns + /// `None` for it, like the other non-integer kinds. The bounded-numeric + /// accessors live on `BoundedIntKind`, which `Timestamptz` cannot be, so they + /// are unreachable for it by construction rather than by a runtime panic. + /// UTC-normalized: cipherstash has no tz-preserving type, so it maps to the + /// `timestamp` cast and the SQL `timestamp with time zone` plaintext type. + Timestamptz, + /// Boolean (`bool`). **Encryption-only / storage-only**: it carries no index + /// term and is *not* `is_int()`/`is_temporal()`/`is_text()`. A two-value + /// column has such low cardinality that any searchable index (even HMAC + /// equality) would trivially leak the plaintext distribution, so the catalog + /// gives `bool` a single term-less storage domain and no `_eq`/`_ord` — the + /// value is encrypted at rest and decrypted by the proxy, never searched + /// server-side. Like the other non-integer kinds, the bounded-numeric + /// accessors are unreachable for it by construction. + Bool, + /// 32-bit IEEE-754 binary float (`f32`, Postgres `real`/`float4`). + /// Ordered like the integer kinds via ORE, but with no i128 range + /// (`as_bounded_int()` returns `None`) and string-backed at the catalog + /// layer. Encrypts through the single f64 float crypto path + /// (`Plaintext::Float`) — the f32→f64 widening is exact and monotonic. + F32, + /// 64-bit IEEE-754 binary float (`f64`, Postgres `double precision`/ + /// `float8`). The native width of the float crypto path (`F32` widens into + /// it); otherwise classified exactly like [`ScalarKind::F32`]. + F64, +} impl BoundedIntKind { /// The Rust type name as it appears in generated source (e.g. `"i32"`). diff --git a/crates/eql-domains/src/fixtures/mod.rs b/crates/eql-domains/src/fixtures/mod.rs new file mode 100644 index 000000000..1beba5c1a --- /dev/null +++ b/crates/eql-domains/src/fixtures/mod.rs @@ -0,0 +1,23 @@ +//! The fixture/test layer of the catalog: the native-scalar vocabulary +//! (`ScalarKind` / `BoundedIntKind`), the `Fixture` value tag + `fixtures!` +//! builder, the per-type `TypeFixtures` records + `FIXTURES` table, and the +//! materialised `*_VALUES` slices. One-way dependency: this module references +//! catalog rows (`crate::INT4` …); the catalog never references this module. +//! +//! `#[macro_use]` order matters: `fixture` (which defines `fixtures!`) must be +//! declared before `record` (which invokes it), without `#[macro_export]`. + +#[macro_use] +pub(crate) mod fixture; +pub(crate) mod kind; +pub(crate) mod record; +pub(crate) mod values; + +pub use fixture::Fixture; +pub use kind::{BoundedIntKind, ScalarKind}; +pub use record::{ + TypeFixtures, BOOL_FIXTURES, DATE_FIXTURES, FIXTURES, FLOAT4_FIXTURES, FLOAT8_FIXTURES, + INT2_FIXTURES, INT4_FIXTURES, INT8_FIXTURES, NUMERIC_FIXTURES, TEXT_FIXTURES, + TIMESTAMPTZ_FIXTURES, +}; +pub use values::{INT2_VALUES, INT4_VALUES, INT8_VALUES, TEXT_VALUES}; diff --git a/crates/eql-domains/src/fixtures/record.rs b/crates/eql-domains/src/fixtures/record.rs new file mode 100644 index 000000000..e8a367b6f --- /dev/null +++ b/crates/eql-domains/src/fixtures/record.rs @@ -0,0 +1,270 @@ +//! The fixture-layer record: a `TypeFixtures` per scalar type, pairing a +//! structural catalog row (`&DomainFamily`) with its `ScalarKind` and its +//! plaintext fixture `values`. This is where `kind`/`fixtures` live now that +//! they are off `DomainFamily` — a fixture/test concern, not structural catalog +//! data. The `FIXTURES` table mirrors `CATALOG` order; the `const _` parity +//! block at the bottom of this file replaces the struct's old compiler-enforced +//! 1:1 — a build-time `assert!` over `CATALOG`/`FIXTURES`, not a runtime test. + +use super::fixture::Fixture; +use super::kind::ScalarKind; +use crate::DomainFamily; + +/// One scalar type's fixture-layer data: the structural catalog row it belongs +/// to (`family`), the native scalar it maps onto (`kind`), and its distinct +/// plaintext fixture `values`. `family` is a reference to the same +/// `DomainFamily` const that `CATALOG` carries, so `family.name` is the join key +/// back to the catalog. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TypeFixtures { + pub family: &'static DomainFamily, + pub kind: ScalarKind, + pub values: &'static [Fixture], +} + +/// int4 fixtures. `N(..)` literals are range-checked against `i32` at compile +/// time by `fixtures!`. +pub const INT4_FIXTURES: TypeFixtures = TypeFixtures { + family: &crate::INT4, + kind: ScalarKind::I32, + values: fixtures!(int i32; + Min, N(-100), N(-1), Zero, N(1), N(2), N(5), N(10), N(17), N(25), + N(42), N(50), N(100), N(250), N(1000), N(9999), Max), +}; + +/// int2 fixtures (`i16`-range-checked). +pub const INT2_FIXTURES: TypeFixtures = TypeFixtures { + family: &crate::INT2, + kind: ScalarKind::I16, + values: fixtures!(int i16; + Min, N(-30000), N(-100), N(-1), Zero, N(1), N(2), N(5), N(10), N(17), + N(25), N(42), N(50), N(100), N(250), N(1000), N(9999), N(30000), Max), +}; + +/// int8 fixtures (`i64`-range-checked) — the int4 set plus two values beyond the +/// i32 range (`±5_000_000_000`) so the matrix exercises the full 64-bit width. +pub const INT8_FIXTURES: TypeFixtures = TypeFixtures { + family: &crate::INT8, + kind: ScalarKind::I64, + values: fixtures!(int i64; + Min, N(-5000000000), N(-100), N(-1), Zero, N(1), N(2), N(5), N(10), N(17), + N(25), N(42), N(50), N(100), N(250), N(1000), N(9999), N(5000000000), Max), +}; + +/// date fixtures — ISO-8601 strings; the three temporal pivots +/// (`1900-01-01`, `1970-01-01`, `2099-12-31`) MUST be present verbatim. +pub const DATE_FIXTURES: TypeFixtures = TypeFixtures { + family: &crate::DATE, + kind: ScalarKind::Date, + values: fixtures!(date; + "1900-01-01", "1950-07-15", "1969-12-31", "1970-01-01", "1970-01-02", + "1980-02-29", "1991-11-09", "1999-12-31", "2000-01-01", "2004-02-29", + "2012-06-30", "2016-03-15", "2020-10-21", "2024-02-29", "2038-01-19", + "2099-12-31"), +}; + +/// timestamptz fixtures — RFC3339 UTC strings; the three temporal pivots +/// (`1900-01-01T00:00:00Z`, `1970-01-01T00:00:00Z`, `2099-12-31T23:59:59Z`) +/// MUST be present verbatim. +pub const TIMESTAMPTZ_FIXTURES: TypeFixtures = TypeFixtures { + family: &crate::TIMESTAMPTZ, + kind: ScalarKind::Timestamptz, + values: fixtures!(timestamptz; + "1900-01-01T00:00:00Z", "1950-07-15T06:30:00Z", "1969-12-31T23:59:59Z", + "1970-01-01T00:00:00Z", "1970-01-01T00:00:01Z", "1985-04-12T23:20:50Z", + "1999-12-31T23:59:59Z", "2000-01-01T00:00:00Z", "2004-02-29T12:00:00Z", + "2012-06-30T11:59:59Z", "2016-03-15T08:15:30Z", "2020-10-21T14:45:00Z", + "2024-02-29T17:30:45Z", "2038-01-19T03:14:07Z", "2099-12-31T23:59:59Z"), +}; + +/// numeric fixtures — distinct by `Decimal` value, mirroring `ore-rs`'s order +/// vectors; includes 0 and the min/max pivots (`±1000000000000`). +pub const NUMERIC_FIXTURES: TypeFixtures = TypeFixtures { + family: &crate::NUMERIC, + kind: ScalarKind::Numeric, + values: fixtures!(numeric; + "-1000000000000", "-1000000", "-1.001", "-1", "-0.5", "-0.001", + "0", "0.001", "0.5", "0.999999999", "1", "1.001", "1000000", "1000000000000"), +}; + +/// text fixtures — lexicographic spread (`aard` min, `frank` mid, `zzzz` max +/// pivots, present verbatim), a known substring pair, and the G3-4b divergence +/// pair (`qabcqbcaqcabqabd` / `abcabd`). The empty string is deliberately absent +/// (issue #262). +pub const TEXT_FIXTURES: TypeFixtures = TypeFixtures { + family: &crate::TEXT, + kind: ScalarKind::Text, + values: fixtures!(text; + "aard", "aardvark", "alice", "bob", "carol", + "dave", "erin", "frank", "mallory", "trent", "zzzz", + "qabcqbcaqcabqabd", "abcabd"), +}; + +/// bool fixtures — both values. Storage-only: encrypted (ciphertext only), never +/// a comparison pivot. +pub const BOOL_FIXTURES: TypeFixtures = TypeFixtures { + family: &crate::BOOL, + kind: ScalarKind::Bool, + values: fixtures!(bool; false, true), +}; + +/// float4 fixtures — IEEE-754 strings, every value dyadic (f32-exact); pivots +/// `-inf` / `0` / `inf` present verbatim. NaN and `-0.0` excluded. +pub const FLOAT4_FIXTURES: TypeFixtures = TypeFixtures { + family: &crate::FLOAT4, + kind: ScalarKind::F32, + values: fixtures!(float; + "-inf", "-1024", "-2.25", "-1", "-0.5", "-0.25", + "0", "0.25", "0.5", "1", "2.25", "1024", "inf"), +}; + +/// float8 fixtures — IEEE-754 strings; pivots `-inf` / `0` / `inf` present +/// verbatim. NaN and `-0.0` excluded. +pub const FLOAT8_FIXTURES: TypeFixtures = TypeFixtures { + family: &crate::FLOAT8, + kind: ScalarKind::F64, + values: fixtures!(float; + "-inf", "-1e300", "-1000000", "-1.5", "-1", "-0.001", + "0", "0.001", "1", "1.5", "1000000", "1e300", "inf"), +}; + +/// The fixture table — one record per scalar type, in `CATALOG` order. The +/// fixture-layer mirror of `CATALOG`; the `const _` parity block below pins the +/// parity at build time. +pub const FIXTURES: &[TypeFixtures] = &[ + INT4_FIXTURES, + INT2_FIXTURES, + INT8_FIXTURES, + DATE_FIXTURES, + TIMESTAMPTZ_FIXTURES, + NUMERIC_FIXTURES, + TEXT_FIXTURES, + BOOL_FIXTURES, + FLOAT4_FIXTURES, + FLOAT8_FIXTURES, +]; + +/// Compile-time `&str` equality, usable in `const` context. `str::eq` / +/// `PartialEq` are not `const fn` on stable, so the parity block below needs its +/// own byte-wise comparison. +const fn str_eq(a: &str, b: &str) -> bool { + let (a, b) = (a.as_bytes(), b.as_bytes()); + if a.len() != b.len() { + return false; + } + let mut i = 0; + while i < a.len() { + if a[i] != b[i] { + return false; + } + i += 1; + } + true +} + +/// A stable `u8` tag per `ScalarKind`, so two kinds can be compared in `const` +/// context (`PartialEq` is not `const fn` on stable). Only the parity block's +/// `family.name` ↔ `kind` binding consumes this. +const fn kind_tag(kind: ScalarKind) -> u8 { + match kind { + ScalarKind::I16 => 0, + ScalarKind::I32 => 1, + ScalarKind::I64 => 2, + ScalarKind::Numeric => 3, + ScalarKind::Text => 4, + ScalarKind::Jsonb => 5, + ScalarKind::Date => 6, + ScalarKind::Timestamptz => 7, + ScalarKind::Bool => 8, + ScalarKind::F32 => 9, + ScalarKind::F64 => 10, + } +} + +/// The native scalar each catalog family is supposed to map onto, keyed by +/// `family.name`. The single source the parity block uses to bind every +/// `TypeFixtures.kind` to its family at build time. An unmapped name is a +/// const-eval panic, so a new scalar type cannot be added without naming its +/// expected kind here. +const fn expected_kind(name: &str) -> ScalarKind { + if str_eq(name, "int2") { + ScalarKind::I16 + } else if str_eq(name, "int4") { + ScalarKind::I32 + } else if str_eq(name, "int8") { + ScalarKind::I64 + } else if str_eq(name, "date") { + ScalarKind::Date + } else if str_eq(name, "timestamptz") { + ScalarKind::Timestamptz + } else if str_eq(name, "numeric") { + ScalarKind::Numeric + } else if str_eq(name, "text") { + ScalarKind::Text + } else if str_eq(name, "bool") { + ScalarKind::Bool + } else if str_eq(name, "float4") { + ScalarKind::F32 + } else if str_eq(name, "float8") { + ScalarKind::F64 + } else { + panic!("unmapped scalar token in expected_kind — name its kind here") + } +} + +/// Compile-time parity guard: `FIXTURES` must mirror `CATALOG` exactly, in +/// order, AND every record's `kind` must match the kind its family maps onto. +/// This is the build-time invariant that REPLACES `DomainFamily`'s old +/// compiler-enforced `kind`/`fixtures` fields — every catalog row has exactly +/// one fixture record and vice-versa, same order, with the right `kind`. As a +/// `const` item it is const-evaluated on every `cargo build`: a missing, extra, +/// or misaligned `TypeFixtures`, or one carrying the wrong `kind` (e.g. +/// `TypeFixtures { family: &INT8, kind: I16, .. }`), fails the build with +/// `error[E0080]: evaluation panicked` carrying the message below — it cannot be +/// `#[cfg]`-gated away or skipped by a test filter, so the `kind` mismatch is +/// caught before any consumer (including `eql-tests-macros` expansion) sees +/// `FIXTURES`. It proves NAME + ORDERING + KIND coverage; fixture-VALUE +/// correctness is gated by the in-crate value/invariant tests, not here. +const _: () = { + assert!( + FIXTURES.len() == crate::CATALOG.len(), + "every CATALOG family needs exactly one TypeFixtures (FIXTURES.len() != CATALOG.len())" + ); + let mut i = 0; + while i < crate::CATALOG.len() { + assert!( + str_eq(crate::CATALOG[i].name, FIXTURES[i].family.name), + "FIXTURES must mirror CATALOG in order: name mismatch at this index" + ); + assert!( + kind_tag(FIXTURES[i].kind) == kind_tag(expected_kind(FIXTURES[i].family.name)), + "TypeFixtures.kind does not match the kind its family maps onto" + ); + i += 1; + } +}; + +#[cfg(test)] +mod str_eq_tests { + use super::str_eq; + + /// `str_eq` is the sole new logic the compile-time parity guard relies on, + /// and the guard only ever exercises the *matching* path against the real + /// (aligned) `CATALOG`/`FIXTURES`. A bug in `str_eq` that returned `true` for + /// differing bytes would silently neuter the guard, so pin its behaviour + /// directly: equal strings match; any length or byte difference does not. + #[test] + fn str_eq_matches_iff_byte_identical() { + assert!(str_eq("", "")); + assert!(str_eq("ab", "ab")); + assert!(str_eq("int4", "int4")); + // Differing length. + assert!(!str_eq("a", "ab")); + assert!(!str_eq("ab", "a")); + assert!(!str_eq("", "a")); + // Same length, one byte differs (the path that would neuter the guard). + assert!(!str_eq("a", "b")); + assert!(!str_eq("int4", "int8")); + assert!(!str_eq("date", "bate")); + } +} diff --git a/crates/eql-domains/src/fixtures/values.rs b/crates/eql-domains/src/fixtures/values.rs new file mode 100644 index 000000000..af35d9306 --- /dev/null +++ b/crates/eql-domains/src/fixtures/values.rs @@ -0,0 +1,75 @@ +//! Compile-time materialisers: each `*_VALUES` const is a typed `&'static` +//! slice derived from its `TypeFixtures` record (`int_values!` / `text_values!`), +//! the single-sourced plaintext list the SQLx matrix reads and the fixture +//! generator encrypts. No committed generated `.rs` round-trip. + +use super::fixture::Fixture; +use super::record::{TypeFixtures, INT2_FIXTURES, INT4_FIXTURES, INT8_FIXTURES, TEXT_FIXTURES}; + +/// Materialise an integer record's fixtures into a typed `&'static` slice at +/// compile time. Integer kinds only: a non-numeric fixture is a const-eval +/// error, mirroring `numeric_value`'s `None`. +macro_rules! int_values { + ($name:ident, $ty:ty, $rec:expr) => { + #[doc = concat!("Distinct plaintext fixture values for `", stringify!($rec), "`, ")] + #[doc = "materialised from its `TypeFixtures` record (see `int_values!`)."] + pub const $name: &[$ty] = { + const REC: TypeFixtures = $rec; + const N: usize = REC.values.len(); + const ARR: [$ty; N] = { + let mut out = [0 as $ty; N]; + let mut i = 0; + while i < N { + out[i] = match REC.values[i].numeric_value(REC.kind) { + Some(v) => { + if v < <$ty>::MIN as i128 || v > <$ty>::MAX as i128 { + panic!(concat!( + "integer scalar fixture value out of range for `", + stringify!($ty), + "`" + )); + } + v as $ty + } + None => panic!("integer scalar fixture must resolve to a number"), + }; + i += 1; + } + out + }; + &ARR + }; + }; +} + +int_values!(INT4_VALUES, i32, INT4_FIXTURES); +int_values!(INT2_VALUES, i16, INT2_FIXTURES); +int_values!(INT8_VALUES, i64, INT8_FIXTURES); + +/// Materialise a `text` record's fixtures into a `&'static [&'static str]` at +/// compile time. A non-text fixture is a const-eval panic. +macro_rules! text_values { + ($name:ident, $rec:expr) => { + #[doc = concat!("Distinct plaintext fixture values for `", stringify!($rec), "`, ")] + #[doc = "materialised from its `TypeFixtures` record (see `text_values!`)."] + pub const $name: &[&'static str] = { + const REC: TypeFixtures = $rec; + const N: usize = REC.values.len(); + const ARR: [&'static str; N] = { + let mut out = [""; N]; + let mut i = 0; + while i < N { + out[i] = match REC.values[i] { + Fixture::Text(s) => s, + _ => panic!("text scalar fixture must be Fixture::Text"), + }; + i += 1; + } + out + }; + &ARR + }; + }; +} + +text_values!(TEXT_VALUES, TEXT_FIXTURES); diff --git a/crates/eql-domains/src/lib.rs b/crates/eql-domains/src/lib.rs index f49e05943..09aa99e03 100644 --- a/crates/eql-domains/src/lib.rs +++ b/crates/eql-domains/src/lib.rs @@ -1,11 +1,5 @@ //! Scalar/term catalog for EQL encrypted-domain codegen — the single Rust -//! source of truth for every scalar type, term, and fixture. Std-only, no -//! dependencies. -//! -//! `Fixture` is value-kind tagged (one non-generic enum, variant = value kind), -//! so a single `CATALOG` spans every scalar kind. Integer literals are -//! range-checked at their definition site by `fixtures!` (`N(-40000)` for `i16` -//! does not compile). +//! source of truth for every scalar type and term. Std-only, no dependencies. //! //! Capability axes are independent: equality covers every kind; order covers //! every kind except `jsonb` (ORE compares ciphertext, so it is @@ -15,82 +9,28 @@ //! //! Public names are consumed verbatim by the later codegen plans — do not rename. //! -//! **Layout.** This file holds the *definitions* — the type vocabulary and the -//! catalog data — so the whole catalog reads top-to-bottom. The inherent `impl` -//! blocks live in sibling modules (`kind`, `term`, `fixture`, `spec`); the unit -//! tests live in `tests`. The methods travel with their types, so nothing here -//! re-exports them. - -mod fixture; -mod kind; +//! **Layout.** This file holds the *structural* catalog: the `DomainFamily`/ +//! `Domain`/`Term`/`Role` definitions, the per-type `DomainFamily` rows, and +//! `CATALOG` — so the structural surface reads top-to-bottom. `DomainFamily` is +//! purely `{ name, domains }`; the native-scalar `kind` and the plaintext +//! `fixtures` are a fixture-layer concern that lives in the `fixtures` module +//! (the `ScalarKind`/`Fixture` vocabulary, the per-type `TypeFixtures` records + +//! `FIXTURES` table, and the materialised `*_VALUES` slices), joined back to a +//! catalog row by `name`. The inherent `impl` blocks for the structural types +//! live in sibling modules (`term`, `spec`); the unit tests live in `tests`. The +//! crate-root `pub use fixtures::{…}` below preserves the public fixture-layer +//! paths. + +#[macro_use] +mod fixtures; mod spec; mod term; -/// The fixed-width integer kinds — exactly those scalar kinds with an `i128` -/// range and `MIN`/`MAX`/`Zero` sentinels. These accessors are **total**: every -/// variant answers every method. Non-integer kinds (`Numeric`/`Text`/`Jsonb`/ -/// `Date`) are simply not representable here, so there is no partial function to -/// panic — `ScalarKind::Date` cannot call `min_symbol()` because `Date` is not a -/// `BoundedIntKind`. Reach this type from a `ScalarKind` via -/// [`ScalarKind::as_bounded_int`]. (Accessors are impl'd in `kind`.) -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BoundedIntKind { - I16, - I32, - I64, -} - -/// The native scalar a domain type maps onto. Integer kinds carry i128 bounds; -/// the others (`Numeric`/`Text`/`Jsonb`) have string fixtures and no numeric -/// range — though `Numeric`/`Text` are still ORE-orderable, only `Jsonb` is not. -/// Capability layer only: `CATALOG` declares which kinds actually exist. -/// -/// The bounded-numeric accessors live on the total [`BoundedIntKind`], reached -/// via [`ScalarKind::as_bounded_int`]; non-integer kinds have no such accessor, -/// so misuse is a compile error rather than a runtime panic. (Accessors are -/// impl'd in `kind`.) -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ScalarKind { - I16, - I32, - I64, - Numeric, - Text, - Jsonb, - /// Calendar date (`chrono::NaiveDate`). Ordered like the integer kinds via - /// ORE, but string-backed (ISO-8601) at the catalog layer and with no i128 - /// range — so it is *not* `is_int()` and `as_bounded_int()` returns `None` - /// for it, like the other non-integer kinds. The bounded-numeric accessors - /// live on `BoundedIntKind`, which `Date` cannot be, so they are - /// unreachable for it by construction rather than by a runtime panic. - Date, - /// UTC timestamp (`chrono::DateTime`). Ordered like the integer kinds - /// via ORE, but string-backed (RFC3339) at the catalog layer and with no - /// i128 range — so it is *not* `is_int()` and the bounded-numeric accessors - /// panic for it, exactly like the other non-integer kinds. UTC-normalized: - /// cipherstash has no tz-preserving type, so it maps to the `timestamp` - /// cast and the SQL `timestamp with time zone` plaintext type. - Timestamptz, - /// Boolean (`bool`). **Encryption-only / storage-only**: it carries no index - /// term and is *not* `is_int()`/`is_temporal()`/`is_text()`. A two-value - /// column has such low cardinality that any searchable index (even HMAC - /// equality) would trivially leak the plaintext distribution, so the catalog - /// gives `bool` a single term-less storage domain and no `_eq`/`_ord` — the - /// value is encrypted at rest and decrypted by the proxy, never searched - /// server-side. Like the other non-integer kinds, the bounded-numeric - /// accessors are unreachable for it by construction. - Bool, - /// 32-bit IEEE-754 binary float (`f32`, Postgres `real`/`float4`). - /// Ordered like the integer kinds via ORE, but with no i128 range - /// (`as_bounded_int()` returns `None`) and string-backed at the catalog - /// layer. Encrypts through the single f64 float crypto path - /// (`Plaintext::Float`) — the f32→f64 widening is exact and monotonic. - F32, - /// 64-bit IEEE-754 binary float (`f64`, Postgres `double precision`/ - /// `float8`). The native width of the float crypto path (`F32` widens into - /// it); otherwise classified exactly like [`ScalarKind::F32`]. - F64, -} +pub use fixtures::{ + BoundedIntKind, Fixture, ScalarKind, TypeFixtures, BOOL_FIXTURES, DATE_FIXTURES, FIXTURES, + FLOAT4_FIXTURES, FLOAT8_FIXTURES, INT2_FIXTURES, INT2_VALUES, INT4_FIXTURES, INT4_VALUES, + INT8_FIXTURES, INT8_VALUES, NUMERIC_FIXTURES, TEXT_FIXTURES, TEXT_VALUES, TIMESTAMPTZ_FIXTURES, +}; /// Always-present payload keys required by every generated domain CHECK, /// before the domain's term keys, in order: envelope version (`v`), ident @@ -159,43 +99,6 @@ impl Role { } } -/// A single fixture plaintext value, value-kind tagged: `Min`/`Max`/`Zero` are -/// the integer matrix pivots (resolved per-kind); `Int` is an integer literal; -/// `Numeric`/`Text`/`Jsonb` carry rendered string literals. -/// -/// `fixtures!` range-checks `Int` literals at compile time, but a hand-built -/// `Fixture::Int(n)` is not — hence the runtime invariant tests. `Int(MIN)` and -/// `Min` resolve to the same numeric value via `numeric_value`. -/// (`numeric_value` is impl'd in `fixture`.) -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Fixture { - Min, - Max, - Zero, - Int(i128), - Numeric(&'static str), - Text(&'static str), - Jsonb(&'static str), - /// An ISO-8601 date string (`"1970-01-01"`). The catalog stays zero-dep, so - /// the string is parsed into a `chrono::NaiveDate` in the SQLx harness, not - /// here. Distinct by literal, like the other string-backed fixtures. - Date(&'static str), - /// An RFC3339 UTC timestamp string (`"1970-01-01T00:00:00Z"`). The catalog - /// stays zero-dep, so the string is parsed into a `chrono::DateTime` in - /// the SQLx harness, not here. Distinct by literal, like `Date`. - Timestamptz(&'static str), - /// A boolean plaintext (`true` / `false`). The `bool` scalar is - /// storage-only, so this fixture is encrypted (ciphertext only, no index - /// term) and never participates in a comparison pivot. Distinct by value. - Bool(bool), - /// An IEEE-754 float plaintext rendered as a string (`"0.5"`, `"-inf"`). - /// The catalog stays zero-dep, so the string is parsed into `f32`/`f64` in - /// the SQLx harness, not here. Distinct by parsed value (the harness - /// `float_fixtures_are_distinct_by_value` guard enforces this). NaN and - /// `-0.0` are deliberately excluded; `±Inf` (`"inf"`/`"-inf"`) ARE fixtures. - Float(&'static str), -} - /// One generated public domain: a bare domain name joined under the family /// name (codegen owns the `_` separator) plus the fixed index terms it /// carries. Name `""` is the storage-only domain. @@ -205,41 +108,15 @@ pub struct Domain { pub terms: &'static [Term], } -/// A scalar encrypted-domain type: its SQL `name`, native Rust type, generated -/// domains, and fixture plaintext list. One row of the Rust `CATALOG` — the -/// source of truth for the type (there is no TOML manifest). -/// (`domain_name`/`is_eq_only` are impl'd in `spec`.) +/// A scalar encrypted-domain type's structural surface: its SQL `name` and the +/// generated domains. One row of the Rust `CATALOG`. The native-scalar `kind` +/// and the plaintext `fixtures` are a fixture-layer concern and live in the +/// `fixtures` module's `TypeFixtures` records, joined back by `name`. +/// (`domain_name`/`is_eq_only`/… are impl'd in `spec`.) #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct DomainFamily { pub name: &'static str, - pub kind: ScalarKind, pub domains: &'static [Domain], - pub fixtures: &'static [Fixture], -} - -/// Builds a `&[Fixture]`. The `int ;` arm (a tt-muncher over `Min`/`Max`/ -/// `Zero` and `N()`) range-checks each literal against `` at compile -/// time via `const _RANGE_CHECK`, so out-of-range literals do not compile; -/// `text;`/`numeric;`/`jsonb;` wrap string literals. The reject case has no -/// in-crate test (macro isn't exported, no `trybuild` under zero-deps) — verify -/// by hand with a bad `N(..)`. -macro_rules! fixtures { - (int $t:ty; $($body:tt)*) => { fixtures!(@int $t; [] $($body)*) }; - (@int $t:ty; [$($acc:expr),*]) => { &[$($acc),*] }; - (@int $t:ty; [$($acc:expr),*] , $($r:tt)*) => { fixtures!(@int $t; [$($acc),*] $($r)*) }; - (@int $t:ty; [$($acc:expr),*] Min $($r:tt)*) => { fixtures!(@int $t; [$($acc,)* Fixture::Min ] $($r)*) }; - (@int $t:ty; [$($acc:expr),*] Max $($r:tt)*) => { fixtures!(@int $t; [$($acc,)* Fixture::Max ] $($r)*) }; - (@int $t:ty; [$($acc:expr),*] Zero $($r:tt)*) => { fixtures!(@int $t; [$($acc,)* Fixture::Zero] $($r)*) }; - (@int $t:ty; [$($acc:expr),*] N($v:literal) $($r:tt)*) => { - fixtures!(@int $t; [$($acc,)* Fixture::Int({ const _RANGE_CHECK: $t = $v; $v as i128 })] $($r)*) - }; - (text; $($s:literal),* $(,)?) => { &[$(Fixture::Text($s)),*] }; - (numeric; $($s:literal),* $(,)?) => { &[$(Fixture::Numeric($s)),*] }; - (jsonb; $($s:literal),* $(,)?) => { &[$(Fixture::Jsonb($s)),*] }; - (date; $($s:literal),* $(,)?) => { &[$(Fixture::Date($s)),*] }; - (timestamptz; $($s:literal),* $(,)?) => { &[$(Fixture::Timestamptz($s)),*] }; - (bool; $($b:literal),* $(,)?) => { &[$(Fixture::Bool($b)),*] }; - (float; $($s:literal),* $(,)?) => { &[$(Fixture::Float($s)),*] }; } /// Domains shared by every ordered-integer scalar, in manifest file order: @@ -283,99 +160,33 @@ const EQ_ONLY_DOMAINS: &[Domain] = &[ }, ]; -/// int4 fixture plaintexts. -/// `N(..)` literals are range-checked against `i32` at compile time. -const INT4_FIXTURES: &[Fixture] = fixtures!(int i32; - Min, N(-100), N(-1), Zero, N(1), N(2), N(5), N(10), N(17), N(25), - N(42), N(50), N(100), N(250), N(1000), N(9999), Max); - -/// int2 fixture plaintexts. -/// `N(..)` literals are range-checked against `i16` at compile time. -const INT2_FIXTURES: &[Fixture] = fixtures!(int i16; - Min, N(-30000), N(-100), N(-1), Zero, N(1), N(2), N(5), N(10), N(17), - N(25), N(42), N(50), N(100), N(250), N(1000), N(9999), N(30000), Max); - -/// int8 fixture plaintexts — the int4 set plus two values beyond the i32 range -/// (`±5_000_000_000`) so the matrix exercises the full 64-bit width. `N(..)` -/// literals are range-checked against `i64` at compile time. -const INT8_FIXTURES: &[Fixture] = fixtures!(int i64; - Min, N(-5000000000), N(-100), N(-1), Zero, N(1), N(2), N(5), N(10), N(17), - N(25), N(42), N(50), N(100), N(250), N(1000), N(9999), N(5000000000), Max); - -/// date fixture plaintexts — ISO-8601 (`YYYY-MM-DD`) strings, parsed into -/// `chrono::NaiveDate` in the SQLx harness (the catalog stays zero-dep). The -/// three temporal pivots MUST be present verbatim: `"1900-01-01"` (min_pivot), -/// `"1970-01-01"` (zero = `NaiveDate::default()`), and `"2099-12-31"` -/// (max_pivot) — the matrix fetches each one's ciphertext via -/// `fetch_fixture_payload`, which fails loudly if a row is absent. The interior -/// dates span varied years/months so range operators yield distinguishable -/// counts. All distinct. -const DATE_FIXTURES: &[Fixture] = fixtures!(date; - "1900-01-01", "1950-07-15", "1969-12-31", "1970-01-01", "1970-01-02", - "1980-02-29", "1991-11-09", "1999-12-31", "2000-01-01", "2004-02-29", - "2012-06-30", "2016-03-15", "2020-10-21", "2024-02-29", "2038-01-19", - "2099-12-31"); - -/// timestamptz fixture plaintexts — RFC3339 UTC strings, parsed into -/// `chrono::DateTime` in the SQLx harness (the catalog stays zero-dep). -/// The three temporal pivots MUST be present verbatim: `"1900-01-01T00:00:00Z"` -/// (min_pivot), `"1970-01-01T00:00:00Z"` (zero = `DateTime::::default()`, -/// the Unix epoch), and `"2099-12-31T23:59:59Z"` (max_pivot) — the matrix -/// fetches each one's ciphertext via `fetch_fixture_payload`, which fails loudly -/// if a row is absent. The interior timestamps span varied dates AND times of -/// day so range operators yield distinguishable counts. All distinct. -const TIMESTAMPTZ_FIXTURES: &[Fixture] = fixtures!(timestamptz; - "1900-01-01T00:00:00Z", "1950-07-15T06:30:00Z", "1969-12-31T23:59:59Z", - "1970-01-01T00:00:00Z", "1970-01-01T00:00:01Z", "1985-04-12T23:20:50Z", - "1999-12-31T23:59:59Z", "2000-01-01T00:00:00Z", "2004-02-29T12:00:00Z", - "2012-06-30T11:59:59Z", "2016-03-15T08:15:30Z", "2020-10-21T14:45:00Z", - "2024-02-29T17:30:45Z", "2038-01-19T03:14:07Z", "2099-12-31T23:59:59Z"); - -/// `numeric` fixture plaintexts — distinct by `Decimal` value, spanning sign, -/// magnitude, and scale, and including `0` plus the min/max pivots -/// (`-1000000000000` / `1000000000000`). They mirror `ore-rs`'s own -/// order-pinning vectors so the 14-block ORE edges (sign + high/low blocks) are -/// exercised. Each literal is distinct by parsed value (no `"1"`/`"1.0"` -/// aliasing) — the harness `numeric_fixtures_distinct_by_value` guard enforces -/// this, since the zero-dep catalog only dedupes by literal string. -const NUMERIC_FIXTURES: &[Fixture] = fixtures!(numeric; - "-1000000000000", "-1000000", "-1.001", "-1", "-0.5", "-0.001", - "0", "0.001", "0.5", "0.999999999", "1", "1.001", "1000000", "1000000000000"); - const INT4: DomainFamily = DomainFamily { name: "int4", - kind: ScalarKind::I32, domains: ORDERED_INT_DOMAINS, - fixtures: INT4_FIXTURES, }; const INT2: DomainFamily = DomainFamily { name: "int2", - kind: ScalarKind::I16, domains: ORDERED_INT_DOMAINS, - fixtures: INT2_FIXTURES, }; const INT8: DomainFamily = DomainFamily { name: "int8", - kind: ScalarKind::I64, domains: ORDERED_INT_DOMAINS, - fixtures: INT8_FIXTURES, }; /// `date` — an ordered, non-integer scalar. Reuses `ORDERED_INT_DOMAINS` (the /// four-domain ordered shape is identical to the integer scalars); only the -/// kind and fixtures differ. +/// kind and fixtures (in `DATE_FIXTURES`) differ. /// /// Public (unlike the integer specs) because the SQLx harness reads -/// `DATE.fixtures` directly to parse the ISO strings into `chrono::NaiveDate` -/// at runtime — there is no `DATE_VALUES` const (chrono is not `const`-friendly -/// and `eql-domains` stays zero-dep, so no typed slice is materialised here). +/// `DATE_FIXTURES.values` directly to parse the ISO strings into +/// `chrono::NaiveDate` at runtime — there is no `DATE_VALUES` const (chrono is +/// not `const`-friendly and `eql-domains` stays zero-dep, so no typed slice is +/// materialised here). pub const DATE: DomainFamily = DomainFamily { name: "date", - kind: ScalarKind::Date, domains: ORDERED_INT_DOMAINS, - fixtures: DATE_FIXTURES, }; /// `timestamptz` — an **ordered**, UTC-normalized non-integer scalar. Uses the @@ -385,14 +196,13 @@ pub const DATE: DomainFamily = DomainFamily { /// Values are UTC-normalized (cipherstash has no tz-preserving type) and encrypt /// under the `timestamp` cast. /// -/// Public (like `DATE`) because the SQLx harness reads `TIMESTAMPTZ.fixtures` -/// directly to parse the RFC3339 strings into `chrono::DateTime` at runtime -/// (no `TIMESTAMPTZ_VALUES` const; `eql-domains` stays zero-dep). +/// Public (like `DATE`) because the SQLx harness reads +/// `TIMESTAMPTZ_FIXTURES.values` directly to parse the RFC3339 strings into +/// `chrono::DateTime` at runtime (no `TIMESTAMPTZ_VALUES` const; +/// `eql-domains` stays zero-dep). pub const TIMESTAMPTZ: DomainFamily = DomainFamily { name: "timestamptz", - kind: ScalarKind::Timestamptz, domains: ORDERED_INT_DOMAINS, - fixtures: TIMESTAMPTZ_FIXTURES, }; /// `numeric` — an **ordered** non-integer scalar backed by @@ -404,14 +214,12 @@ pub const TIMESTAMPTZ: DomainFamily = DomainFamily { /// order (equivalent scales collide, like `Decimal`'s own `Ord`). /// /// Public (like `DATE` / `TIMESTAMPTZ`) so the SQLx harness reads -/// `NUMERIC.fixtures` directly to parse the decimal strings into +/// `NUMERIC_FIXTURES.values` directly to parse the decimal strings into /// `rust_decimal::Decimal` at runtime (the catalog stays zero-dep: no /// `rust_decimal`). pub const NUMERIC: DomainFamily = DomainFamily { name: "numeric", - kind: ScalarKind::Numeric, domains: ORDERED_INT_DOMAINS, - fixtures: NUMERIC_FIXTURES, }; /// Domains for `text`: the ordered shape (with exact `hm` equality on the @@ -462,107 +270,44 @@ const STORAGE_ONLY_DOMAINS: &[Domain] = &[Domain { terms: &[], }]; -/// `bool` fixture plaintexts — both values. `bool` is storage-only, so these are -/// encrypted (ciphertext only) and never used as comparison pivots; they exist -/// so the SQLx matrix can prove the storage domain accepts a real bool ciphertext -/// and rejects every operator. Distinct by value. -const BOOL_FIXTURES: &[Fixture] = fixtures!(bool; false, true); - /// `bool` — an **encryption-only / storage-only** scalar (`ScalarKind::Bool`). /// One term-less storage domain (`eql_v3.bool`), no `_eq`/`_ord`: a two-value /// column has too little cardinality for any searchable index without leaking the /// plaintext, so the value is encrypted at rest and decrypted by the proxy, -/// never searched server-side. Public so the SQLx harness reads `BOOL.fixtures` -/// directly (there is no `BOOL_VALUES` materializer — the two values are read -/// straight from the catalog). +/// never searched server-side. Public so the SQLx harness reads +/// `BOOL_FIXTURES.values` directly (there is no `BOOL_VALUES` materializer — the +/// two values are read straight from the record). pub const BOOL: DomainFamily = DomainFamily { name: "bool", - kind: ScalarKind::Bool, domains: STORAGE_ONLY_DOMAINS, - fixtures: BOOL_FIXTURES, }; -/// `text` fixture plaintexts — curated so eq/ord give a lexicographic spread -/// and the match suite has a known substring pair (`"aardvark"`/`"aard"`, -/// sharing 3-grams) and a disjoint value (`"zzzz"`, no shared 3-grams). -/// `"aard"` is the lexicographic `min_pivot`, `"zzzz"` the `max_pivot`, and -/// `"frank"` the interior `mid_pivot`; all three must be present verbatim so the -/// matrix can fetch their ciphertext. All distinct. -/// -/// The empty string is deliberately **not** a fixture: text is an ordered, not -/// signed, scalar (no numeric origin), and `""` encrypts to an empty ORE term -/// whose comparison is undefined (see issue #262). The interior pivot is a real -/// median value, not `String::default()`. -const TEXT_FIXTURES: &[Fixture] = fixtures!(text; - "aard", "aardvark", "alice", "bob", "carol", - "dave", "erin", "frank", "mallory", "trent", "zzzz", - // Divergence pair (G3 4b): every contiguous 3-gram of NEEDLE (`abcabd` → - // {abc, bca, cab, abd}) is present in HAY (`qabcqbcaqcabqabd`), yet NEEDLE is - // NOT a contiguous substring of HAY (the `q` separators break the run). So - // bloom `@>` is true while `HAY LIKE '%NEEDLE%'` is false — the deterministic - // bloom-vs-LIKE divergence locked in by `bloom_matches_where_like_would_not`. - // Verified against the real cipherstash bf term sets (contiguous 3-grams, - // k=6 hashing): bf(NEEDLE) ⊆ bf(HAY). Both are 3-gram-disjoint from the - // `aard`/`zzzz` disjoint pair and sort interior to the min/mid/max pivots, so - // they perturb no eq/ord oracle. Keep them diverging if edited (the pure-Rust - // guard `divergence_pair_is_contiguity_diverging` in src/tests.rs enforces it). - "qabcqbcaqcabqabd", "abcabd"); - /// `text` — an ordered, non-integer, unbounded scalar. Adds a `_match` domain /// (the `Bloom` term) on top of the ordered shape. Public because the SQLx -/// harness reads `TEXT_VALUES` (materialised below). +/// harness reads `TEXT_VALUES` (materialised in the `fixtures` module). pub const TEXT: DomainFamily = DomainFamily { name: "text", - kind: ScalarKind::Text, domains: TEXT_DOMAINS, - fixtures: TEXT_FIXTURES, }; -/// `float4` fixture plaintexts — IEEE-754 strings parsed into `f32` in the SQLx -/// harness (the catalog stays zero-dep). EVERY value is exactly representable in -/// f32 — each is a dyadic rational `n/2^k` (e.g. `2.25 = 9/4`, `0.25 = 1/4`, -/// `1024 = 2^10`), the value class `real` stores losslessly — so the `real` -/// round-trip is lossless and the f32→f64 widening before encryption is exact. -/// Keep new fixtures dyadic: a value like `0.1` is NOT f32-exact, and the -/// oracle's expected order (parsed `f32`) would then disagree with the value the -/// `real` column actually rounds to. The three pivots MUST be present -/// verbatim: `"-inf"` (min_pivot), `"0"` (origin/mid), `"inf"` (max_pivot). -/// NaN and `-0.0` are deliberately excluded (see the `float_special` suite). -/// Distinctness is enforced by `Fixture::Float` (above) and its guard test. -const FLOAT4_FIXTURES: &[Fixture] = fixtures!(float; - "-inf", "-1024", "-2.25", "-1", "-0.5", "-0.25", - "0", "0.25", "0.5", "1", "2.25", "1024", "inf"); - -/// `float8` fixture plaintexts — IEEE-754 strings parsed into `f64` in the SQLx -/// harness. The native width of the float crypto path; values span sign and -/// magnitude including subnormal-free interior points. The three pivots MUST be -/// present verbatim: `"-inf"` (min_pivot), `"0"` (origin/mid), `"inf"` -/// (max_pivot). NaN and `-0.0` are deliberately excluded. -const FLOAT8_FIXTURES: &[Fixture] = fixtures!(float; - "-inf", "-1e300", "-1000000", "-1.5", "-1", "-0.001", - "0", "0.001", "1", "1.5", "1000000", "1e300", "inf"); - /// `float4` — an **ordered**, non-integer scalar (Postgres `real`). Reuses the /// four-domain ordered shape (`ORDERED_INT_DOMAINS`); only kind and fixtures /// differ. Both float widths encrypt through the SAME f64 crypto path /// (`Plaintext::Float`), so `float4` vs `float8` is purely a Postgres-surface /// distinction. Public (like `DATE`/`NUMERIC`) so the SQLx harness reads -/// `FLOAT4.fixtures` directly to parse the strings into `f32`. +/// `FLOAT4_FIXTURES.values` directly to parse the strings into `f32`. pub const FLOAT4: DomainFamily = DomainFamily { name: "float4", - kind: ScalarKind::F32, domains: ORDERED_INT_DOMAINS, - fixtures: FLOAT4_FIXTURES, }; /// `float8` — an **ordered**, non-integer scalar (Postgres `double precision`), /// the native width of the float crypto path. Reuses the ordered shape. Public -/// so the SQLx harness reads `FLOAT8.fixtures` directly to parse into `f64`. +/// so the SQLx harness reads `FLOAT8_FIXTURES.values` directly to parse into +/// `f64`. pub const FLOAT8: DomainFamily = DomainFamily { name: "float8", - kind: ScalarKind::F64, domains: ORDERED_INT_DOMAINS, - fixtures: FLOAT8_FIXTURES, }; /// The scalar catalog — the single source of truth. Order is significant (it @@ -580,89 +325,6 @@ pub const CATALOG: &[DomainFamily] = &[ FLOAT8, ]; -/// Materialise an integer scalar's fixtures into a typed `&'static` slice at -/// compile time. This is the **single-sourced** plaintext list the SQLx test -/// matrix reads via `ScalarType::fixture_values()` and the fixture generator -/// encrypts — derived from the same `CATALOG` row that drives SQL generation, -/// so the oracle cannot drift from the fixture. (It replaces the old generated, -/// committed `tests/sqlx/src/fixtures/_values.rs` — a Rust source of truth no -/// longer needs to round-trip through generated Rust.) -/// -/// Integer kinds only: a non-numeric fixture (`Text`/`Numeric`/`Jsonb`) is a -/// const-eval error, mirroring `numeric_value`'s `None`. -macro_rules! int_values { - ($name:ident, $ty:ty, $spec:expr) => { - #[doc = concat!("Distinct plaintext fixture values for `", stringify!($spec), "`, ")] - #[doc = "materialised from its `CATALOG` row (see `int_values!`)."] - pub const $name: &[$ty] = { - const SPEC: DomainFamily = $spec; - const N: usize = SPEC.fixtures.len(); - const ARR: [$ty; N] = { - let mut out = [0 as $ty; N]; - let mut i = 0; - while i < N { - out[i] = match SPEC.fixtures[i].numeric_value(SPEC.kind) { - Some(v) => { - // Const-eval bounds check: a fixture value that does - // not fit the narrowed target type would otherwise be - // silently truncated/wrapped by `as`. Make it a - // compile-time error instead. - if v < <$ty>::MIN as i128 || v > <$ty>::MAX as i128 { - panic!(concat!( - "integer scalar fixture value out of range for `", - stringify!($ty), - "`" - )); - } - v as $ty - } - None => panic!("integer scalar fixture must resolve to a number"), - }; - i += 1; - } - out - }; - &ARR - }; - }; -} - -int_values!(INT4_VALUES, i32, INT4); -int_values!(INT2_VALUES, i16, INT2); -int_values!(INT8_VALUES, i64, INT8); - -/// Materialise a `text` scalar's fixtures into a `&'static [&'static str]` at -/// compile time — the single-sourced plaintext list the SQLx matrix reads via -/// `ScalarType::fixture_values()` and the fixture generator encrypts. Unlike -/// `date` (chrono is not `const`-friendly), a `Fixture::Text(&'static str)` is -/// already const, so text materialises a typed slice like the integer kinds. -/// A non-text fixture is a const-eval panic (compile-time guard). -macro_rules! text_values { - ($name:ident, $spec:expr) => { - #[doc = concat!("Distinct plaintext fixture values for `", stringify!($spec), "`, ")] - #[doc = "materialised from its `CATALOG` row (see `text_values!`)."] - pub const $name: &[&'static str] = { - const SPEC: DomainFamily = $spec; - const N: usize = SPEC.fixtures.len(); - const ARR: [&'static str; N] = { - let mut out = [""; N]; - let mut i = 0; - while i < N { - out[i] = match SPEC.fixtures[i] { - Fixture::Text(s) => s, - _ => panic!("text scalar fixture must be Fixture::Text"), - }; - i += 1; - } - out - }; - &ARR - }; - }; -} - -text_values!(TEXT_VALUES, TEXT); - #[cfg(test)] mod tests; diff --git a/crates/eql-domains/src/tests.rs b/crates/eql-domains/src/tests.rs index 70b53e429..8b41ac733 100644 --- a/crates/eql-domains/src/tests.rs +++ b/crates/eql-domains/src/tests.rs @@ -2,8 +2,9 @@ //! (declared from `lib.rs`) rather than co-located with each impl file because //! `rust_tests` spans `BoundedIntKind` + `ScalarKind` + `Fixture` + //! `DomainFamily`. Each inner module imports the crate-root catalog with -//! `use crate::*;`; the crate-local `fixtures!` macro is in scope here by textual -//! scoping (this module is declared after the macro definition in `lib.rs`). +//! `use crate::*;`; the crate-local `fixtures!` macro is in scope here via the +//! `#[macro_use] mod fixtures;` chain in `lib.rs` (it is defined in +//! `fixtures/fixture.rs`). mod rust_tests { use crate::*; @@ -167,14 +168,14 @@ mod rust_tests { /// the source of truth. #[test] fn pivot_sentinels_only_appear_with_integer_kinds() { - for spec in CATALOG { - for fixture in spec.fixtures { + for rec in FIXTURES { + for fixture in rec.values { if matches!(fixture, Fixture::Min | Fixture::Max | Fixture::Zero) { assert!( - spec.kind.is_int(), + rec.kind.is_int(), "pivot sentinel {fixture:?} on non-integer kind {:?} (token `{}`)", - spec.kind, - spec.name, + rec.kind, + rec.family.name, ); } } @@ -209,9 +210,7 @@ mod rust_tests { // `EQ_ONLY_DOMAINS` shape (storage + `_eq`, no `_ord`). let eq_only = DomainFamily { name: "synthetic_eq_only", - kind: ScalarKind::Timestamptz, domains: EQ_ONLY_DOMAINS, - fixtures: &[], }; assert!( eq_only.is_eq_only(), @@ -573,6 +572,13 @@ mod catalog_tests { .unwrap_or_else(|| panic!("{token} missing from CATALOG")) } + fn fixtures(token: &str) -> &'static TypeFixtures { + FIXTURES + .iter() + .find(|f| f.family.name == token) + .unwrap_or_else(|| panic!("{token} missing from FIXTURES")) + } + #[test] fn catalog_has_all_tokens_in_order() { let tokens: Vec<&str> = CATALOG.iter().map(|s| s.name).collect(); @@ -596,17 +602,18 @@ mod catalog_tests { #[test] fn bool_spec_is_storage_only_encryption_only() { let b = scalar("bool"); - assert_eq!(b.kind, ScalarKind::Bool); - assert_eq!(b.kind.rust_type(), "bool"); + let bf = fixtures("bool"); + assert_eq!(bf.kind, ScalarKind::Bool); + assert_eq!(bf.kind.rust_type(), "bool"); // Storage-only: exactly one term-less domain, no `_eq`/`_ord` — no SEM // index term, no comparison surface. let shape: Vec<(&str, &[Term])> = b.domains.iter().map(|d| (d.name, d.terms)).collect(); assert_eq!(shape, vec![("", &[] as &[Term])]); // bool is none of the comparison-capable kinds. - assert!(!b.kind.is_int()); - assert!(!b.kind.is_temporal()); - assert!(!b.kind.is_text()); - assert_eq!(b.kind.as_bounded_int(), None); + assert!(!bf.kind.is_int()); + assert!(!bf.kind.is_temporal()); + assert!(!bf.kind.is_text()); + assert_eq!(bf.kind.as_bounded_int(), None); // is_eq_only() is true (no `_ord` domain), but the shape is strictly // smaller than eq-only — there is no `_eq` domain either, so it is // storage-only. @@ -614,7 +621,7 @@ mod catalog_tests { assert!(b.is_storage_only()); assert!(b.domain_by_name("eq").is_none()); // Both boolean plaintexts are present as fixtures. - assert_eq!(b.fixtures, &[Fixture::Bool(false), Fixture::Bool(true)]); + assert_eq!(bf.values, &[Fixture::Bool(false), Fixture::Bool(true)]); } #[test] @@ -634,7 +641,7 @@ mod catalog_tests { #[test] fn text_spec_is_in_catalog() { let text = scalar("text"); - assert_eq!(text.kind, ScalarKind::Text); + assert_eq!(fixtures("text").kind, ScalarKind::Text); let names: Vec<_> = text.domains.iter().map(|d| d.name).collect(); assert_eq!(names, vec!["", "eq", "match", "ord_ore", "ord", "search"]); } @@ -739,9 +746,8 @@ mod catalog_tests { /// analogue. #[test] fn temporal_fixtures_include_pivot_plaintexts() { - let date = scalar("date"); - let strings: Vec<&str> = date - .fixtures + let strings: Vec<&str> = fixtures("date") + .values .iter() .filter_map(|f| match f { Fixture::Date(s) => Some(*s), @@ -761,9 +767,8 @@ mod catalog_tests { /// `temporal_fixtures_include_pivot_plaintexts`. #[test] fn timestamptz_fixtures_include_pivot_plaintexts() { - let ts = scalar("timestamptz"); - let strings: Vec<&str> = ts - .fixtures + let strings: Vec<&str> = fixtures("timestamptz") + .values .iter() .filter_map(|f| match f { Fixture::Timestamptz(s) => Some(*s), @@ -856,14 +861,49 @@ mod catalog_tests { // The kind↔rust-type pairing for every integer scalar, generic over // CATALOG. Replaces the per-type `_maps_to_iNN` / `_rust_type` // restatements. - for s in CATALOG.iter().filter(|s| s.kind.is_int()) { - let expected = match s.name { + for rec in FIXTURES.iter().filter(|r| r.kind.is_int()) { + let expected = match rec.family.name { "int2" => ScalarKind::I16, "int4" => ScalarKind::I32, "int8" => ScalarKind::I64, other => panic!("unmapped integer scalar token {other}"), }; - assert_eq!(s.kind, expected, "{} maps to the wrong kind", s.name); + assert_eq!( + rec.kind, expected, + "{} maps to the wrong kind", + rec.family.name + ); + } + } + + /// Catalog-wide `family.name` ↔ `kind` guard over EVERY record, not just the + /// integer ones. The primary binding is now the compile-time parity block in + /// `fixtures/record.rs` (`kind_tag(FIXTURES[i].kind) == expected_kind(..)`), + /// which fails the build before any consumer sees a record carrying the wrong + /// `kind` (e.g. `TypeFixtures { family: &INT8, kind: I16, .. }`). This test is + /// the secondary safety net: an independent restatement of the same mapping, + /// so a regression in the const guard's helpers is still caught here. + #[test] + fn every_record_kind_matches_its_family() { + for rec in FIXTURES { + let expected = match rec.family.name { + "int2" => ScalarKind::I16, + "int4" => ScalarKind::I32, + "int8" => ScalarKind::I64, + "date" => ScalarKind::Date, + "timestamptz" => ScalarKind::Timestamptz, + "numeric" => ScalarKind::Numeric, + "text" => ScalarKind::Text, + "bool" => ScalarKind::Bool, + "float4" => ScalarKind::F32, + "float8" => ScalarKind::F64, + other => panic!("unmapped scalar token {other} in FIXTURES"), + }; + assert_eq!( + rec.kind, expected, + "{} record carries the wrong kind", + rec.family.name + ); } } @@ -885,29 +925,29 @@ mod values_tests { /// `check(&INTx, INTx_VALUES)` line, not a duplicated reference list. Subsumes /// the old per-type `_values_materialise_to_typed_array` references and /// `materialised_values_track_their_fixture_lists`. - fn check>(spec: &DomainFamily, values: &[T]) { + fn check>(rec: &TypeFixtures, values: &[T]) { assert_eq!( values.len(), - spec.fixtures.len(), + rec.values.len(), "{}: value count != fixture count", - spec.name + rec.family.name ); - for (i, (v, f)) in values.iter().zip(spec.fixtures).enumerate() { + for (i, (v, f)) in values.iter().zip(rec.values).enumerate() { assert_eq!( (*v).into(), - f.numeric_value(spec.kind) + f.numeric_value(rec.kind) .expect("integer scalar fixture resolves to a number"), "{}: value[{i}] does not match resolved fixture {f:?}", - spec.name + rec.family.name ); } } #[test] fn materialised_values_match_resolved_fixtures() { - check(&INT4, INT4_VALUES); - check(&INT2, INT2_VALUES); - check(&INT8, INT8_VALUES); + check(&INT4_FIXTURES, INT4_VALUES); + check(&INT2_FIXTURES, INT2_VALUES); + check(&INT8_FIXTURES, INT8_VALUES); } #[test] @@ -935,6 +975,7 @@ mod values_tests { #[test] fn text_values_match_fixtures_in_order() { let from_fixtures: Vec<&str> = TEXT_FIXTURES + .values .iter() .map(|f| match f { Fixture::Text(s) => *s, @@ -1002,6 +1043,13 @@ mod float_tests { .unwrap_or_else(|| panic!("{token} missing from CATALOG")) } + fn fixtures(token: &str) -> &'static TypeFixtures { + FIXTURES + .iter() + .find(|f| f.family.name == token) + .unwrap_or_else(|| panic!("{token} missing from FIXTURES")) + } + #[test] fn float_specs_are_in_catalog_with_ordered_shape() { for family_name in ["float4", "float8"] { @@ -1009,8 +1057,8 @@ mod float_tests { let names: Vec<_> = s.domains.iter().map(|d| d.name).collect(); assert_eq!(names, vec!["", "eq", "ord_ore", "ord"]); } - assert_eq!(scalar("float4").kind, ScalarKind::F32); - assert_eq!(scalar("float8").kind, ScalarKind::F64); + assert_eq!(fixtures("float4").kind, ScalarKind::F32); + assert_eq!(fixtures("float8").kind, ScalarKind::F64); } #[test] @@ -1036,9 +1084,8 @@ mod float_tests { #[test] fn float_fixtures_exclude_nan_and_negative_zero_and_include_infinities() { for family_name in ["float4", "float8"] { - let s = scalar(family_name); - let strings: Vec<&str> = s - .fixtures + let strings: Vec<&str> = fixtures(family_name) + .values .iter() .map(|f| match f { Fixture::Float(v) => *v, @@ -1076,9 +1123,8 @@ mod float_tests { #[test] fn float_fixtures_are_distinct_by_value() { for family_name in ["float4", "float8"] { - let s = scalar(family_name); - let parsed: Vec = s - .fixtures + let parsed: Vec = fixtures(family_name) + .values .iter() .map(|f| match f { Fixture::Float(v) => { @@ -1160,37 +1206,41 @@ mod invariant_tests { fn fixtures_include_min_max_and_zero() { // The MIN/MAX/ZERO pivots are an integer-kind invariant; non-integer // kinds (text/numeric/jsonb) have no such pivots. - for s in CATALOG.iter().filter(|s| s.kind.is_int()) { - let bk = s + for rec in FIXTURES.iter().filter(|r| r.kind.is_int()) { + let bk = rec .kind .as_bounded_int() .expect("loop is filtered to integer kinds"); - let resolved: Vec = s - .fixtures + let resolved: Vec = rec + .values .iter() - .filter_map(|f| f.numeric_value(s.kind)) + .filter_map(|f| f.numeric_value(rec.kind)) .collect(); assert!( resolved.contains(&bk.min_value()), "{} fixtures missing MIN", - s.name + rec.family.name ); assert!( resolved.contains(&bk.max_value()), "{} fixtures missing MAX", - s.name + rec.family.name + ); + assert!( + resolved.contains(&0), + "{} fixtures missing zero", + rec.family.name ); - assert!(resolved.contains(&0), "{} fixtures missing zero", s.name); } } #[test] fn fixture_values_are_distinct_by_resolved_number() { - for s in CATALOG { + for rec in FIXTURES { let mut seen: HashMap = HashMap::new(); - for f in s.fixtures { - if let Some(prev) = seen.insert(distinct_key(*f, s.kind), *f) { - panic!("{}: {f:?} duplicates {prev:?}", s.name); + for f in rec.values { + if let Some(prev) = seen.insert(distinct_key(*f, rec.kind), *f) { + panic!("{}: {f:?} duplicates {prev:?}", rec.family.name); } } } @@ -1221,20 +1271,20 @@ mod invariant_tests { #[test] fn every_fixture_value_is_within_kind_bounds() { // Asserts the resolved sentinels stay within bounds (integer kinds only). - for s in CATALOG.iter().filter(|s| s.kind.is_int()) { - let bk = s + for rec in FIXTURES.iter().filter(|r| r.kind.is_int()) { + let bk = rec .kind .as_bounded_int() .expect("loop is filtered to integer kinds"); let (lo, hi) = (bk.min_value(), bk.max_value()); - for f in s.fixtures { - let Some(n) = f.numeric_value(s.kind) else { + for f in rec.values { + let Some(n) = f.numeric_value(rec.kind) else { continue; }; assert!( n >= lo && n <= hi, "{}: fixture {f:?} resolves to {n}, out of range [{lo}, {hi}]", - s.name + rec.family.name ); } } diff --git a/crates/eql-tests-macros/src/lib.rs b/crates/eql-tests-macros/src/lib.rs index c072a4609..22dc38bbe 100644 --- a/crates/eql-tests-macros/src/lib.rs +++ b/crates/eql-tests-macros/src/lib.rs @@ -58,6 +58,10 @@ impl Parse for ScalarEntry { /// The `eql-domains::CATALOG` row for `token`, or a hard panic at macro-expansion /// time if the token is unknown — a dispatch-list entry must name a catalog type. +/// Now used only by the structural predicates (`is_eq_only_token`, +/// `is_storage_only_token`, `has_search_token`); the kind-predicates read the +/// native scalar kind from `fixtures_for_token` (it is no longer a `DomainFamily` +/// field). fn spec_for_token(token: &str) -> &'static eql_domains::DomainFamily { eql_domains::CATALOG .iter() @@ -65,12 +69,24 @@ fn spec_for_token(token: &str) -> &'static eql_domains::DomainFamily { .unwrap_or_else(|| panic!("scalar token `{token}` not in eql-domains::CATALOG")) } +/// The `eql-domains::FIXTURES` record for `token`, or a hard panic at +/// macro-expansion time if the token is unknown. The kind-predicates read the +/// native scalar kind from the fixture layer (it is no longer a `DomainFamily` +/// field); the structural predicates (`is_eq_only`, `is_storage_only`, +/// `has_search`) keep reading `spec_for_token`. +fn fixtures_for_token(token: &str) -> &'static eql_domains::TypeFixtures { + eql_domains::FIXTURES + .iter() + .find(|f| f.family.name == token) + .unwrap_or_else(|| panic!("scalar token `{token}` not in eql-domains::FIXTURES")) +} + /// True when `token`'s catalog kind is temporal (chrono-backed). Replaces the /// `[temporal]` marker: temporal scalars hand off their `impl ScalarType` to /// `temporal_values!` (so `emit_scalar_type_impls` skips them) and stamp the /// `temporal` fixture variant. fn is_temporal_token(token: &str) -> bool { - spec_for_token(token).kind.is_temporal() + fixtures_for_token(token).kind.is_temporal() } /// True when `token`'s catalog kind is a fixed-width integer (`int2`/`int4`/ @@ -80,7 +96,7 @@ fn is_temporal_token(token: &str) -> bool { /// non-integer kind (`date`, `text`) is hand-written in `scalar_domains.rs` and /// skipped by `scalar_type_impls_tokens`. fn is_int_token(token: &str) -> bool { - spec_for_token(token).kind.is_int() + fixtures_for_token(token).kind.is_int() } /// True when `token`'s catalog kind is `text` — an unbounded, owned-`String` @@ -90,7 +106,7 @@ fn is_int_token(token: &str) -> bool { /// generated payloads carry `bf`) and draws its values from the harness accessor /// (`text_values()`). Replaces the `[text]` marker. fn is_text_token(token: &str) -> bool { - spec_for_token(token).kind.is_text() + fixtures_for_token(token).kind.is_text() } /// True when `token`'s catalog row is the `numeric` kind (owned @@ -98,7 +114,10 @@ fn is_text_token(token: &str) -> bool { /// non-chrono, so it stamps the `numeric` fixture discriminator and draws its /// values from the harness accessor (`numeric_values()`). fn is_numeric_token(token: &str) -> bool { - matches!(spec_for_token(token).kind, eql_domains::ScalarKind::Numeric) + matches!( + fixtures_for_token(token).kind, + eql_domains::ScalarKind::Numeric + ) } /// True when `token`'s catalog row is an IEEE-754 float kind (`F32`/`F64`). @@ -107,7 +126,7 @@ fn is_numeric_token(token: &str) -> bool { /// (`float4_values()` / `float8_values()`). fn is_float_token(token: &str) -> bool { matches!( - spec_for_token(token).kind, + fixtures_for_token(token).kind, eql_domains::ScalarKind::F32 | eql_domains::ScalarKind::F64 ) } @@ -709,8 +728,11 @@ mod tests { } #[test] - #[should_panic(expected = "not in eql-domains::CATALOG")] + #[should_panic(expected = "not in eql-domains::FIXTURES")] fn unknown_token_fails_loudly() { + // `is_temporal_token` reads the native scalar kind from the fixture + // layer, so an unknown token now fails loudly via the `FIXTURES` lookup + // (the structural predicates still fail via `CATALOG` / `spec_for_token`). is_temporal_token("nonesuch"); } diff --git a/tests/sqlx/src/scalar_domains.rs b/tests/sqlx/src/scalar_domains.rs index c1dcf1915..19d017f9e 100644 --- a/tests/sqlx/src/scalar_domains.rs +++ b/tests/sqlx/src/scalar_domains.rs @@ -258,7 +258,7 @@ macro_rules! temporal_values { static $cell: std::sync::LazyLock> = std::sync::LazyLock::new(|| { let parse: fn(&str) -> $ty = $parse; $spec - .fixtures + .values .iter() .map(|f| match f { ::eql_domains::Fixture::$variant(s) => parse(s), @@ -308,7 +308,7 @@ macro_rules! temporal_values { #[test] fn values_match_catalog_fixtures() { let parse: fn(&str) -> $ty = $parse; - let want: Vec<$ty> = $spec.fixtures.iter().map(|f| match f { + let want: Vec<$ty> = $spec.values.iter().map(|f| match f { ::eql_domains::Fixture::$variant(s) => parse(s), other => panic!("non-{} fixture: {:?}", $pg, other), }).collect(); @@ -357,7 +357,7 @@ macro_rules! lazy_values { ) => { static $cell: std::sync::LazyLock> = std::sync::LazyLock::new(|| { let parse: fn(&::eql_domains::Fixture) -> $ty = $parse; - $spec.fixtures.iter().map(parse).collect() + $spec.values.iter().map(parse).collect() }); #[doc = concat!("Typed `", stringify!($ty), "` fixtures for `", $pg, "`, materialised once from the catalog.")] @@ -377,7 +377,7 @@ temporal_values! { cell = DATE_VALUES_CELL, accessor = date_values, rust_type = chrono::NaiveDate, - spec = eql_domains::DATE, + spec = eql_domains::DATE_FIXTURES, variant = Date, pg_type = "date", parse = |s| chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") @@ -395,7 +395,7 @@ temporal_values! { cell = TIMESTAMPTZ_VALUES_CELL, accessor = timestamptz_values, rust_type = chrono::DateTime, - spec = eql_domains::TIMESTAMPTZ, + spec = eql_domains::TIMESTAMPTZ_FIXTURES, variant = Timestamptz, pg_type = "timestamptz", parse = |s| chrono::DateTime::parse_from_rfc3339(s) @@ -475,7 +475,7 @@ lazy_values! { cell = TEXT_VALUES_CELL, accessor = text_values, rust_type = String, - spec = eql_domains::TEXT, + spec = eql_domains::TEXT_FIXTURES, variant = Text, pg_type = "text", parse = |f| match f { @@ -553,7 +553,7 @@ lazy_values! { cell = NUMERIC_VALUES_CELL, accessor = numeric_values, rust_type = rust_decimal::Decimal, - spec = eql_domains::NUMERIC, + spec = eql_domains::NUMERIC_FIXTURES, variant = Numeric, pg_type = "numeric", parse = |f| match f { @@ -641,8 +641,8 @@ mod numeric_value_guards { /// order (`[false, true]`). Public so the `eql_v3_bool` fixture module (emitted /// by `scalar_types!(fixture_modules)`) can hand the slice to `scalar_fixture!`. static BOOL_VALUES_CELL: std::sync::LazyLock> = std::sync::LazyLock::new(|| { - eql_domains::BOOL - .fixtures + eql_domains::BOOL_FIXTURES + .values .iter() .map(|f| match f { eql_domains::Fixture::Bool(b) => *b, @@ -685,7 +685,7 @@ impl ScalarType for bool { mod bool_value_tests { use super::*; - /// The harness value list matches the catalog `BOOL.fixtures` and carries + /// The harness value list matches the catalog `BOOL_FIXTURES.values` and carries /// both boolean values — the oracle cannot drift from the catalog the fixture /// generator encrypts. #[test] @@ -881,7 +881,7 @@ lazy_values! { cell = FLOAT4_VALUES_CELL, accessor = float4_values, rust_type = F4, - spec = eql_domains::FLOAT4, + spec = eql_domains::FLOAT4_FIXTURES, variant = Float, pg_type = "float4", parse = |f| match f { @@ -896,7 +896,7 @@ lazy_values! { cell = FLOAT8_VALUES_CELL, accessor = float8_values, rust_type = F8, - spec = eql_domains::FLOAT8, + spec = eql_domains::FLOAT8_FIXTURES, variant = Float, pg_type = "float8", parse = |f| match f { @@ -989,8 +989,8 @@ mod float_value_guards { fn float4_values_match_catalog_and_are_finite_non_negative_zero() { let vals = float4_values(); // Parsed from the catalog, in order. - let want: Vec = eql_domains::FLOAT4 - .fixtures + let want: Vec = eql_domains::FLOAT4_FIXTURES + .values .iter() .map(|f| match f { eql_domains::Fixture::Float(s) => F4(s.parse().unwrap()), @@ -1009,8 +1009,8 @@ mod float_value_guards { #[test] fn float8_values_match_catalog_and_are_finite_non_negative_zero() { let vals = float8_values(); - let want: Vec = eql_domains::FLOAT8 - .fixtures + let want: Vec = eql_domains::FLOAT8_FIXTURES + .values .iter() .map(|f| match f { eql_domains::Fixture::Float(s) => F8(s.parse().unwrap()),