From 06aec21b0926861b9a0e6e0b3b083e440af25ecf Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Fri, 5 Jun 2026 13:08:42 +1000 Subject: [PATCH 01/12] test(scalars): add temporal_values! macro for chrono-backed ScalarType wiring --- tests/sqlx/src/scalar_domains.rs | 75 ++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/tests/sqlx/src/scalar_domains.rs b/tests/sqlx/src/scalar_domains.rs index 69453f59..58c3883c 100644 --- a/tests/sqlx/src/scalar_domains.rs +++ b/tests/sqlx/src/scalar_domains.rs @@ -111,6 +111,81 @@ pub trait ScalarType: // integer impls. crate::scalar_types!(scalar_type_impls); +/// Generate the test wiring for one chrono-backed (temporal) scalar from its +/// catalog row: a `LazyLock>` parsing the catalog fixture strings, a +/// public `()` returning a borrow of it, `impl ScalarType for T`, and +/// a `#[cfg(test)]` module asserting the parsed values track the catalog and +/// include the pivots. The chrono analogue of `eql_scalars::int_values!` +/// (integers materialise a `const` slice; temporals can't, so values live in a +/// `LazyLock`). `parse`/`min_pivot`/`max_pivot`/`sql_lit` are expressions so each +/// type supplies its own chrono parsing, sentinel pivots, and SQL literal form. +macro_rules! temporal_values { + ( + cell = $cell:ident, + accessor = $accessor:ident, + rust_type = $ty:ty, + spec = $spec:path, + variant = $variant:ident, + pg_type = $pg:literal, + parse = $parse:expr, + min_pivot = $min:expr, + max_pivot = $max:expr, + sql_lit = $sql_lit:expr $(,)? + ) => { + static $cell: std::sync::LazyLock> = std::sync::LazyLock::new(|| { + let parse: fn(&str) -> $ty = $parse; + $spec + .fixtures + .iter() + .map(|f| match f { + ::eql_scalars::Fixture::$variant(s) => parse(s), + other => panic!(concat!("non-", $pg, " fixture in ", $pg, " catalog row: {:?}"), other), + }) + .collect() + }); + + #[doc = concat!("Typed `", stringify!($ty), "` fixtures for `", $pg, "`, parsed once from the catalog.")] + pub fn $accessor() -> &'static [$ty] { + &$cell + } + + impl ScalarType for $ty { + const PG_TYPE: &'static str = $pg; + fn fixture_values() -> &'static [$ty] { $accessor() } + fn min_pivot() -> $ty { $min } + fn max_pivot() -> $ty { $max } + fn to_sql_literal(value: $ty) -> String { + let f: fn(&$ty) -> String = $sql_lit; + f(&value) + } + } + + #[cfg(test)] + mod $accessor { + use super::*; + #[test] + fn values_match_catalog_fixtures() { + let parse: fn(&str) -> $ty = $parse; + let want: Vec<$ty> = $spec.fixtures.iter().map(|f| match f { + ::eql_scalars::Fixture::$variant(s) => parse(s), + other => panic!("non-{} fixture: {:?}", $pg, other), + }).collect(); + assert_eq!($accessor(), want.as_slice()); + } + #[test] + fn pivots_present_in_fixtures() { + let vals = $accessor(); + assert!(vals.contains(&<$ty as ScalarType>::min_pivot()), "min pivot missing"); + assert!(vals.contains(&<$ty as ScalarType>::max_pivot()), "max pivot missing"); + // The matrix sweeps a zero pivot (`Default::default()`) on every + // ordered/eq-only suite and fetches its ciphertext via + // `fetch_fixture_payload`, so it must be present verbatim too. + assert!(vals.contains(&<$ty as Default>::default()), "zero/default pivot missing"); + } + } + }; +} + /// Typed `chrono::NaiveDate` fixture values, parsed once from `date`'s catalog /// row. The catalog stores ISO strings (zero-dep); parsing into `NaiveDate` /// lives here. `from_ymd_opt` is not `const`, so this cannot be a const slice — From c34b2c36bafab08f56df04d942587dc51bed42a7 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Fri, 5 Jun 2026 13:25:28 +1000 Subject: [PATCH 02/12] test(scalars): generate date ScalarType via temporal_values! --- tests/sqlx/src/scalar_domains.rs | 120 +++++-------------------------- 1 file changed, 18 insertions(+), 102 deletions(-) diff --git a/tests/sqlx/src/scalar_domains.rs b/tests/sqlx/src/scalar_domains.rs index 58c3883c..a4eabdf2 100644 --- a/tests/sqlx/src/scalar_domains.rs +++ b/tests/sqlx/src/scalar_domains.rs @@ -12,7 +12,6 @@ use anyhow::{bail, Context, Result}; use sqlx::PgPool; use std::fmt::{Debug, Display}; -use std::sync::LazyLock; /// One impl per scalar type. Two `const`s and the rest defaults. pub trait ScalarType: @@ -186,107 +185,24 @@ macro_rules! temporal_values { }; } -/// Typed `chrono::NaiveDate` fixture values, parsed once from `date`'s catalog -/// row. The catalog stores ISO strings (zero-dep); parsing into `NaiveDate` -/// lives here. `from_ymd_opt` is not `const`, so this cannot be a const slice — -/// hence the `LazyLock>` + `fixture_values()`-returns-a-borrow shape that -/// the const→fn trait change exists to allow. -static DATE_VALUES_CELL: LazyLock> = LazyLock::new(|| { - eql_scalars::DATE - .fixtures - .iter() - .map(|f| match f { - eql_scalars::Fixture::Date(s) => chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") - .unwrap_or_else(|e| panic!("invalid date fixture {s:?}: {e}")), - other => panic!("date catalog fixture must be Fixture::Date, got {other:?}"), - }) - .collect() -}); - -/// The parsed `chrono::NaiveDate` fixture values, in catalog order. Mirrors the -/// `eql_scalars::_VALUES` accessor pattern for the integer scalars; the -/// stacked timestamptz PR adds a sibling `timestamptz_values()`. Public so the -/// `eql_v2_date` fixture module (emitted by `scalar_types!(fixture_modules)`) -/// can hand the slice to `scalar_fixture!` — temporal scalars have no -/// `eql_scalars::_VALUES` const to point at. -pub fn date_values() -> &'static [chrono::NaiveDate] { - &DATE_VALUES_CELL -} - -impl ScalarType for chrono::NaiveDate { - const PG_TYPE: &'static str = "date"; - - fn fixture_values() -> &'static [Self] { - date_values() - } - - /// Temporal min pivot — `1900-01-01`, present verbatim in the catalog - /// fixtures (not `Self::MIN`, which would be far outside the fixture set). - fn min_pivot() -> Self { - chrono::NaiveDate::from_ymd_opt(1900, 1, 1).expect("1900-01-01 is a valid date") - } - - /// Temporal max pivot — `2099-12-31`, present verbatim in the catalog - /// fixtures. - fn max_pivot() -> Self { - chrono::NaiveDate::from_ymd_opt(2099, 12, 31).expect("2099-12-31 is a valid date") - } - - /// `Display` renders a `NaiveDate` as `2099-12-31` (unquoted), which is not - /// a valid SQL literal on its own — wrap it in single quotes. - fn to_sql_literal(value: Self) -> String { - format!("'{value}'") - } -} - -#[cfg(test)] -mod date_value_tests { - use super::*; - - /// The parsed `NaiveDate` values match the catalog fixture strings in - /// order and count — the harness oracle cannot drift from the catalog the - /// fixture generator encrypts. - #[test] - fn date_values_match_catalog_fixtures() { - let catalog: Vec<&str> = eql_scalars::DATE - .fixtures - .iter() - .map(|f| match f { - eql_scalars::Fixture::Date(s) => *s, - other => panic!("unexpected non-date fixture {other:?}"), - }) - .collect(); - let parsed = ::fixture_values(); - assert_eq!( - parsed.len(), - catalog.len(), - "parsed date count must match catalog fixture count" - ); - for (date, iso) in parsed.iter().zip(&catalog) { - assert_eq!(&date.format("%Y-%m-%d").to_string(), iso); - } - } - - /// The three temporal pivots resolve to fixture rows present verbatim. - #[test] - fn date_pivots_are_in_fixture_values() { - let values = ::fixture_values(); - let min = ::min_pivot(); - let max = ::max_pivot(); - let zero = chrono::NaiveDate::default(); - assert!(values.contains(&min), "min_pivot {min} must be a fixture"); - assert!(values.contains(&max), "max_pivot {max} must be a fixture"); - assert!( - values.contains(&zero), - "zero pivot {zero} must be a fixture" - ); - // Default is 1970-01-01, the documented zero pivot. - assert_eq!( - zero, - chrono::NaiveDate::from_ymd_opt(1970, 1, 1).unwrap(), - "NaiveDate::default() must be 1970-01-01" - ); - } +// `date`'s `ScalarType` wiring is generated from its catalog row by +// `temporal_values!` — the chrono analogue of the integer `int_values!` path. +// Values can't be a `const` slice (`from_ymd_opt` is not `const`), so they live +// in a `LazyLock>` behind `date_values()`. `date_values()` is public so +// the `eql_v2_date` fixture module (emitted by `scalar_types!(fixture_modules)`) +// can hand the slice to `scalar_fixture!`. +temporal_values! { + cell = DATE_VALUES_CELL, + accessor = date_values, + rust_type = chrono::NaiveDate, + spec = eql_scalars::DATE, + variant = Date, + pg_type = "date", + parse = |s| chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") + .expect("catalog date fixture must be YYYY-MM-DD"), + min_pivot = chrono::NaiveDate::from_ymd_opt(1900, 1, 1).expect("1900-01-01 valid"), + max_pivot = chrono::NaiveDate::from_ymd_opt(2099, 12, 31).expect("2099-12-31 valid"), + sql_lit = |v| format!("'{v}'"), } /// Per-domain capability + payload shape. Storage carries no terms, `Eq` From 4026f3aa581656c822a9294368b4e09e19da6f34 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Fri, 5 Jun 2026 13:27:20 +1000 Subject: [PATCH 03/12] feat(eql-scalars): add is_temporal()/is_eq_only() capability accessors --- crates/eql-scalars/src/lib.rs | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/eql-scalars/src/lib.rs b/crates/eql-scalars/src/lib.rs index b5a362c7..d8d3d7fb 100644 --- a/crates/eql-scalars/src/lib.rs +++ b/crates/eql-scalars/src/lib.rs @@ -128,6 +128,13 @@ impl ScalarKind { self.as_bounded_int().is_some() } + /// True for chrono-backed temporal kinds (`Date`; `Timestamptz` once added) — + /// the kinds whose test `ScalarType` impl is generated by `temporal_values!` + /// rather than the integer proc-macro path. Replaces the `[temporal]` marker. + pub const fn is_temporal(self) -> bool { + matches!(self, ScalarKind::Date) + } + /// The Rust type name as it appears in generated source (e.g. `"i32"`). pub const fn rust_type(self) -> &'static str { match self { @@ -360,6 +367,14 @@ impl ScalarSpec { pub fn domain_name(&self, domain: &DomainSpec) -> String { format!("{}{}", self.token, domain.suffix) } + + /// True when this type declares no ordered (`_ord`) domain — i.e. equality-only + /// (storage + `_eq`). Replaces the future `[eq_only]` marker: the domain set + /// already carries this. The `_ord_ore` twin only appears alongside `_ord`, so + /// testing `_ord` suffices. + pub fn is_eq_only(&self) -> bool { + !self.domains.iter().any(|d| d.suffix == "_ord") + } } /// Domains shared by every ordered-integer scalar, in manifest file order: @@ -650,6 +665,23 @@ mod rust_tests { } } } + + #[test] + fn is_temporal_classifies_chrono_kinds() { + assert!(ScalarKind::Date.is_temporal()); + assert!(!ScalarKind::I16.is_temporal()); + assert!(!ScalarKind::I32.is_temporal()); + assert!(!ScalarKind::I64.is_temporal()); + // Timestamptz arrives in Phase 5; assert it here once present. + } + + #[test] + fn is_eq_only_detects_absence_of_ord_domains() { + let int4 = CATALOG.iter().find(|s| s.token == "int4").unwrap(); + assert!(!int4.is_eq_only(), "int4 is ordered"); + let date = CATALOG.iter().find(|s| s.token == "date").unwrap(); + assert!(!date.is_eq_only(), "date is ordered"); + } } #[cfg(test)] From dc72afb74e2536442e56c7be7a891593a3af72c1 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Fri, 5 Jun 2026 13:28:34 +1000 Subject: [PATCH 04/12] build(eql-tests-macros): depend on eql-scalars catalog --- Cargo.lock | 1 + crates/eql-tests-macros/Cargo.toml | 1 + 2 files changed, 2 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index bfecae6b..20adc322 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1172,6 +1172,7 @@ version = "0.1.0" name = "eql-tests-macros" version = "0.1.0" dependencies = [ + "eql-scalars", "proc-macro2", "quote", "syn 2.0.108", diff --git a/crates/eql-tests-macros/Cargo.toml b/crates/eql-tests-macros/Cargo.toml index 8c352a9a..cc92e04c 100644 --- a/crates/eql-tests-macros/Cargo.toml +++ b/crates/eql-tests-macros/Cargo.toml @@ -11,3 +11,4 @@ proc-macro = true syn = { version = "2", features = ["full"] } quote = "1" proc-macro2 = "1" +eql-scalars = { path = "../eql-scalars" } From 4eb40cd753bd3b545d55df0475666bcf5ebad649 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Fri, 5 Jun 2026 13:31:23 +1000 Subject: [PATCH 05/12] refactor(eql-tests-macros): derive temporal/eq_only from catalog, drop [temporal] marker --- crates/eql-tests-macros/src/lib.rs | 200 ++++++++--------------------- 1 file changed, 53 insertions(+), 147 deletions(-) diff --git a/crates/eql-tests-macros/src/lib.rs b/crates/eql-tests-macros/src/lib.rs index e252bca0..504dbf03 100644 --- a/crates/eql-tests-macros/src/lib.rs +++ b/crates/eql-tests-macros/src/lib.rs @@ -32,74 +32,19 @@ use proc_macro2::TokenStream as TokenStream2; use quote::{format_ident, quote}; use syn::parse::{Parse, ParseStream}; use syn::punctuated::Punctuated; -use syn::{bracketed, Ident, Token, Type}; +use syn::{Ident, Token, Type}; -/// One `token => rust_type` entry, with an optional trailing `[temporal]` flag. +/// One `token => rust_type` entry. The type's *shape* (temporal vs integer, +/// equality-only vs ordered) is **not** declared here — it is read from the +/// `eql-scalars::CATALOG` row for `token` via [`is_temporal_token`] / +/// [`is_eq_only_token`]. The catalog is the single source of truth; this list +/// only maps a token to the Rust plaintext type the harness compiles against. struct ScalarEntry { /// Postgres type token (`int4`); also the fixture/domain suffix and the - /// matrix `suite` ident. + /// matrix `suite` ident. Must name a row in `eql-scalars::CATALOG`. token: Ident, /// Rust plaintext type (`i32`). rust_type: Type, - /// Whether this entry is a **temporal** (chrono-backed) scalar rather than a - /// fixed-width integer. Declared explicitly via a trailing `[temporal]` - /// marker in the dispatch list (`date => chrono::NaiveDate [temporal]`) - /// rather than sniffed from the Rust type path — so a temporal type that - /// isn't chrono-spelled, or a non-temporal type whose path happens to - /// contain `DateTime`, cannot be misclassified. - temporal: bool, -} - -/// The recognised optional entry markers, written in `[brackets]` after the -/// rust type (`date => chrono::NaiveDate [temporal]`). `temporal` is the only -/// one today; a new marker is added here so the accepted set stays a single -/// source of truth — `parse_optional_marker` validates against this slice and -/// the rejection message lists it verbatim. -const SUPPORTED_MARKERS: &[&str] = &["temporal"]; - -/// Parse the optional trailing `[marker]` on a scalar entry, returning whether -/// the `temporal` marker was present. -/// -/// Absent brackets → `false` (an ordinary integer scalar). When brackets are -/// present they must hold exactly one recognised identifier: an unknown marker -/// (`[temporial]`), empty brackets (`[]`), or trailing junk (`[temporal foo]`) -/// are all hard parse errors. The whole point of the explicit marker is that a -/// typo fails loudly rather than silently defaulting an entry to integer, so -/// the parse is strict on every malformed shape, not just unknown names. -fn parse_optional_marker(input: ParseStream) -> syn::Result { - if !input.peek(syn::token::Bracket) { - return Ok(false); - } - let content; - bracketed!(content in input); - - let marker: Ident = content.parse()?; - // Reject anything after the single marker ident (`[temporal foo]`, - // `[temporal, bar]`) — otherwise `bracketed!` would silently drop it. - if !content.is_empty() { - let rest: TokenStream2 = content.parse()?; - return Err(syn::Error::new_spanned( - rest, - "expected a single marker identifier, e.g. `[temporal]`", - )); - } - - let name = marker.to_string(); - if !SUPPORTED_MARKERS.contains(&name.as_str()) { - let supported = SUPPORTED_MARKERS - .iter() - .map(|m| format!("`{m}`")) - .collect::>() - .join(", "); - return Err(syn::Error::new( - marker.span(), - format!("unknown scalar marker `{name}`; supported markers: {supported}"), - )); - } - - // Only `temporal` flips the temporal flag; a future non-temporal marker - // would pass validation above but leave this `false`. - Ok(name == "temporal") } impl Parse for ScalarEntry { @@ -107,29 +52,31 @@ impl Parse for ScalarEntry { let token: Ident = input.parse()?; input.parse::]>()?; let rust_type: Type = input.parse()?; - let temporal = parse_optional_marker(input)?; - Ok(ScalarEntry { - token, - rust_type, - temporal, - }) + Ok(ScalarEntry { token, rust_type }) } } -impl ScalarEntry { - /// Whether this entry is a **temporal** (chrono-backed) scalar rather than a - /// fixed-width integer, as declared by the `[temporal]` marker. It drives - /// two divergences: - /// - /// 1. The `impl ScalarType` for a temporal scalar is **hand-written** in - /// `scalar_domains.rs` (chrono values can't be a `const` slice and the - /// pivots are explicit sentinels), so `emit_scalar_type_impls` skips it. - /// 2. The integer-only fixture asserts (`::MIN`, `contains(&0)`, - /// `any(|v| v < 0)`) don't typecheck for a date, so `scalar_fixture!` - /// stamps a temporal (pivot-presence) variant instead. - fn is_temporal(&self) -> bool { - self.temporal - } +/// The `eql-scalars::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. +fn spec_for_token(token: &str) -> &'static eql_scalars::ScalarSpec { + eql_scalars::CATALOG + .iter() + .find(|s| s.token == token) + .unwrap_or_else(|| panic!("scalar token `{token}` not in eql-scalars::CATALOG")) +} + +/// 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() +} + +/// True when `token`'s catalog row declares no ordered domain — equality-only. +/// Replaces the `[eq_only]` marker. +fn is_eq_only_token(token: &str) -> bool { + spec_for_token(token).is_eq_only() } /// The comma-separated list (optional trailing comma). @@ -158,9 +105,13 @@ fn values_const_ident(token: &Ident) -> Ident { /// Emit one `impl ScalarType for ` per entry. See /// [`emit_scalar_type_impls`]. fn scalar_type_impls_tokens(list: &ScalarList) -> TokenStream2 { - // Temporal scalars hand-write their `impl ScalarType` (see `is_temporal`); - // only integer scalars get a macro-generated impl. - let impls = list.entries.iter().filter(|e| !e.is_temporal()).map(|e| { + // Temporal scalars hand off their `impl ScalarType` to `temporal_values!` + // (catalog-driven); only integer scalars get a macro-generated impl here. + let impls = list + .entries + .iter() + .filter(|e| !is_temporal_token(&e.token.to_string())) + .map(|e| { let token_str = e.token.to_string(); let rust_type = &e.rust_type; let values = values_const_ident(&e.token); @@ -198,7 +149,7 @@ fn scalar_fixture_modules_tokens(list: &ScalarList) -> TokenStream2 { let rust_type = &e.rust_type; let mod_ident = format_ident!("eql_v2_{}", e.token); let fixture_name = format!("eql_v2_{}", token_str); - if e.is_temporal() { + if is_temporal_token(&e.token.to_string()) { // Temporal scalars have no `eql_scalars::_VALUES` const (chrono // is not `const`-friendly). The values come from the harness // accessor (`_values()`), and the fixture stamps the @@ -386,10 +337,10 @@ mod tests { #[test] fn temporal_entry_skips_impl_and_stamps_temporal_fixture() { + // No marker: `date`'s temporal shape is read from eql-scalars::CATALOG. let list = - syn::parse_str::("int4 => i32, date => chrono::NaiveDate [temporal],") - .unwrap(); - // Impl emitter skips the temporal entry (hand-written impl). + syn::parse_str::("int4 => i32, date => chrono::NaiveDate").unwrap(); + // Impl emitter skips the temporal entry (handed to `temporal_values!`). let impls = norm(&scalar_type_impls_tokens(&list)); assert!(impls.contains("impl ScalarType for i32")); assert!(!impls.contains("NaiveDate")); @@ -406,74 +357,29 @@ mod tests { assert!(dispatch.contains(r#""date" =>"#)); } - /// Parse a single entry, asserting it parses, and return whether it is - /// temporal. Keeps the per-shape assertions below to one line each. - fn parse_entry_is_temporal(src: &str) -> bool { - syn::parse_str::(src) - .unwrap_or_else(|e| panic!("`{src}` should parse: {e}")) - .is_temporal() - } - - /// Parse a single entry expecting a parse error, returning the message. - fn parse_entry_err(src: &str) -> String { - match syn::parse_str::(src) { - Ok(_) => panic!("`{src}` should have failed to parse"), - Err(e) => e.to_string(), - } - } - - #[test] - fn no_marker_is_integer() { - // No brackets → integer, even when the type path mentions chrono: - // temporal-ness is declared, never inferred from the rust type. - assert!(!parse_entry_is_temporal("int4 => i32")); - assert!(!parse_entry_is_temporal("date => chrono::NaiveDate")); - } - #[test] - fn temporal_marker_sets_the_flag() { - assert!(parse_entry_is_temporal( - "date => chrono::NaiveDate [temporal]" - )); - // Marker binds to its own entry, not the next one, across a list. - let list = - syn::parse_str::("date => chrono::NaiveDate [temporal], int4 => i32,") - .unwrap(); - assert!(list.entries[0].is_temporal()); - assert!(!list.entries[1].is_temporal()); + fn entry_parses_without_markers() { + let list = syn::parse_str::("int4 => i32, date => chrono::NaiveDate") + .expect("bare token => rust_type must parse"); + assert_eq!(list.entries.len(), 2); } #[test] - fn unknown_marker_errors_and_lists_the_supported_set() { - let msg = parse_entry_err("date => chrono::NaiveDate [temporial]"); - // Names the offending marker and the supported set, so the message is - // actionable rather than just "parse error". - assert!(msg.contains("unknown scalar marker"), "got: {msg}"); - assert!( - msg.contains("temporial"), - "should name the bad marker: {msg}" - ); - assert!( - msg.contains("`temporal`"), - "should list supported markers: {msg}" - ); + fn temporal_is_read_from_catalog_not_a_marker() { + assert!(!is_temporal_token("int4")); + assert!(is_temporal_token("date")); } #[test] - fn empty_marker_brackets_error() { - // `[]` has no marker ident to parse — a malformed entry, not a no-op. - let msg = parse_entry_err("date => chrono::NaiveDate []"); - assert!(!msg.is_empty()); + fn eq_only_is_read_from_catalog_not_a_marker() { + assert!(!is_eq_only_token("int4")); + assert!(!is_eq_only_token("date")); } #[test] - fn trailing_junk_in_marker_brackets_errors() { - // Regression guard: `[temporal foo]` / `[temporal, bar]` must NOT be - // silently accepted as `temporal` with the extra tokens dropped. - let msg = parse_entry_err("date => chrono::NaiveDate [temporal foo]"); - assert!(msg.contains("single marker identifier"), "got: {msg}"); - let msg = parse_entry_err("date => chrono::NaiveDate [temporal, bar]"); - assert!(msg.contains("single marker identifier"), "got: {msg}"); + #[should_panic(expected = "not in eql-scalars::CATALOG")] + fn unknown_token_fails_loudly() { + is_temporal_token("nonesuch"); } #[test] From 0c4a55b5af05382a74a76a19599321f3ba761c87 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Fri, 5 Jun 2026 15:57:10 +1000 Subject: [PATCH 06/12] test(scalars): drop [temporal] marker from dispatch list (catalog-derived) --- tests/sqlx/src/scalar_types.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/sqlx/src/scalar_types.rs b/tests/sqlx/src/scalar_types.rs index 6747900a..45173d05 100644 --- a/tests/sqlx/src/scalar_types.rs +++ b/tests/sqlx/src/scalar_types.rs @@ -4,11 +4,13 @@ //! To add a scalar encrypted-domain type to the SQLx matrix, add one //! `token => rust_type` line below (plus the catalog row in `eql-scalars` and //! the `EqlPlaintext` impl, owned separately — see -//! `docs/reference/adding-a-scalar-encrypted-domain-type.md` §3). A temporal -//! (chrono-backed) scalar adds a trailing `[temporal]` marker -//! (`date => chrono::NaiveDate [temporal]`): it hand-writes its `impl -//! ScalarType` in `scalar_domains.rs` and gets pivot-presence fixture asserts -//! instead of the integer signed-extreme ones. +//! `docs/reference/adding-a-scalar-encrypted-domain-type.md` §3). The entry +//! carries no shape marker: whether a type is temporal (chrono-backed) or +//! equality-only is read from its `eql-scalars::CATALOG` row +//! (`ScalarKind::is_temporal()` / `ScalarSpec::is_eq_only()`). A temporal +//! scalar generates its `impl ScalarType` via `temporal_values!` in +//! `scalar_domains.rs` and gets pivot-presence fixture asserts instead of the +//! integer signed-extreme ones. //! //! The harness pieces live in three separate compilation contexts (the //! `eql-tests` lib, the `encrypted_domain` integration-test binary, and the @@ -52,7 +54,7 @@ macro_rules! scalar_types { int4 => i32, int2 => i16, int8 => i64, - date => chrono::NaiveDate [temporal], + date => chrono::NaiveDate, } }; } From aa2d86a5290b104f7b898ade1a9821afa72810f3 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Fri, 5 Jun 2026 16:21:29 +1000 Subject: [PATCH 07/12] feat(eql-tests-macros): guard ordered matrix against eq-only types via is_eq_only_token The ordered_numeric_matrix! suite exercises /min/max; an equality-only scalar (no _ord domain in eql-scalars::CATALOG) does not support those. Route the matrix emitter through matrix_suite_for_entry, which reads eq-only-ness from the catalog (is_eq_only_token) and emits a compile_error! for an eq-only token instead of silently generating ordering tests it cannot pass. Makes the catalog-derived is_eq_only accessor load-bearing and leaves a clean seam for an equality-only matrix path. Both arms unit-tested. --- crates/eql-tests-macros/src/lib.rs | 74 +++++++++++++++++++++++------- 1 file changed, 58 insertions(+), 16 deletions(-) diff --git a/crates/eql-tests-macros/src/lib.rs b/crates/eql-tests-macros/src/lib.rs index 504dbf03..43dada55 100644 --- a/crates/eql-tests-macros/src/lib.rs +++ b/crates/eql-tests-macros/src/lib.rs @@ -74,7 +74,9 @@ fn is_temporal_token(token: &str) -> bool { } /// True when `token`'s catalog row declares no ordered domain — equality-only. -/// Replaces the `[eq_only]` marker. +/// Replaces the `[eq_only]` marker. Consumed by [`matrix_suite_for_entry`] to +/// keep an eq-only type out of the ordered matrix (which exercises ordering +/// operators it does not support). fn is_eq_only_token(token: &str) -> bool { spec_for_token(token).is_eq_only() } @@ -206,24 +208,42 @@ fn fixture_dispatch_tokens(list: &ScalarList) -> TokenStream2 { } } +/// Build the matrix suite for one entry. Ordered types get the +/// `ordered_numeric_matrix!` suite (`=`/`<>`/`<`/`>`/`min`/`max`). An eq-only +/// type has no `_ord` domain, so the ordered matrix would exercise ordering +/// operators the type does not support — emit a `compile_error!` directing the +/// author to wire an equality-only matrix instead. The shape is read from the +/// catalog (`eq_only` = [`is_eq_only_token`]), not a marker; `eq_only` is passed +/// in so this stays a pure function of its inputs and both arms are unit-testable +/// without an eq-only row in the live catalog. +fn matrix_suite_for_entry(token: &Ident, rust_type: &Type, eq_only: bool) -> TokenStream2 { + let token_str = token.to_string(); + if eq_only { + let msg = format!( + "scalar `{token_str}` is equality-only (no `_ord` domain in eql-scalars::CATALOG); \ + the ordered matrix exercises ordering operators it does not support. \ + Wire an equality-only matrix for it instead of routing it through the ordered suite." + ); + return quote! { compile_error!(#msg); }; + } + let eql_type = format!("eql_v2_{}", token_str); + quote! { + #[doc = concat!("`eql_v2_", #token_str, "` matrix suite — generated by `scalar_types!`.")] + pub mod #token { + ::eql_tests::ordered_numeric_matrix! { + suite = #token, + scalar = #rust_type, + eql_type = #eql_type, + } + } + } +} + /// Emit one `pub mod { ordered_numeric_matrix! { ... } }` per entry. -/// See [`emit_scalar_matrix_suites`]. +/// See [`emit_scalar_matrix_suites`] and [`matrix_suite_for_entry`]. fn scalar_matrix_suites_tokens(list: &ScalarList) -> TokenStream2 { let mods = list.entries.iter().map(|e| { - let token = &e.token; - let token_str = e.token.to_string(); - let rust_type = &e.rust_type; - let eql_type = format!("eql_v2_{}", token_str); - quote! { - #[doc = concat!("`eql_v2_", #token_str, "` matrix suite — generated by `scalar_types!`.")] - pub mod #token { - ::eql_tests::ordered_numeric_matrix! { - suite = #token, - scalar = #rust_type, - eql_type = #eql_type, - } - } - } + matrix_suite_for_entry(&e.token, &e.rust_type, is_eq_only_token(&e.token.to_string())) }); quote! { #(#mods)* } } @@ -376,6 +396,28 @@ mod tests { assert!(!is_eq_only_token("date")); } + #[test] + fn ordered_entry_emits_ordered_matrix_suite() { + let token: Ident = syn::parse_str("int4").unwrap(); + let rust_type: Type = syn::parse_str("i32").unwrap(); + let out = norm(&matrix_suite_for_entry(&token, &rust_type, false)); + assert!(out.contains(":: eql_tests :: ordered_numeric_matrix !")); + assert!(out.contains("suite = int4")); + assert!(!out.contains("compile_error")); + } + + #[test] + fn eq_only_entry_emits_compile_error_not_ordered_matrix() { + // No eq-only row exists in the live catalog yet, so pass the shape + // directly: an eq-only token must never reach the ordered matrix. + let token: Ident = syn::parse_str("timestamptz").unwrap(); + let rust_type: Type = syn::parse_str("chrono::DateTime").unwrap(); + let out = norm(&matrix_suite_for_entry(&token, &rust_type, true)); + assert!(out.contains("compile_error !")); + assert!(out.contains("equality-only")); + assert!(!out.contains("ordered_numeric_matrix")); + } + #[test] #[should_panic(expected = "not in eql-scalars::CATALOG")] fn unknown_token_fails_loudly() { From 4e82b47da7b147dfc4277c0d1a2c6ec69d66edbd Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Fri, 5 Jun 2026 16:44:41 +1000 Subject: [PATCH 08/12] style(eql-tests-macros): apply rustfmt to scalar matrix emitters --- crates/eql-tests-macros/src/lib.rs | 57 ++++++++++++++++-------------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/crates/eql-tests-macros/src/lib.rs b/crates/eql-tests-macros/src/lib.rs index 43dada55..74dfe11c 100644 --- a/crates/eql-tests-macros/src/lib.rs +++ b/crates/eql-tests-macros/src/lib.rs @@ -114,32 +114,32 @@ fn scalar_type_impls_tokens(list: &ScalarList) -> TokenStream2 { .iter() .filter(|e| !is_temporal_token(&e.token.to_string())) .map(|e| { - let token_str = e.token.to_string(); - let rust_type = &e.rust_type; - let values = values_const_ident(&e.token); - quote! { - impl ScalarType for #rust_type { - const PG_TYPE: &'static str = #token_str; - - /// The catalog `eql_scalars::*_VALUES` list — the same values - /// the fixture generator encrypts, so the oracle can't drift - /// from the fixture. - fn fixture_values() -> &'static [#rust_type] { - ::eql_scalars::#values - } - - /// Integer scalars pivot on their inherent `MIN`/`MAX` consts; - /// the fixture lists include both (`fixtures!(int …; Min, …, Max)`). - fn min_pivot() -> #rust_type { - <#rust_type>::MIN - } - - fn max_pivot() -> #rust_type { - <#rust_type>::MAX + let token_str = e.token.to_string(); + let rust_type = &e.rust_type; + let values = values_const_ident(&e.token); + quote! { + impl ScalarType for #rust_type { + const PG_TYPE: &'static str = #token_str; + + /// The catalog `eql_scalars::*_VALUES` list — the same values + /// the fixture generator encrypts, so the oracle can't drift + /// from the fixture. + fn fixture_values() -> &'static [#rust_type] { + ::eql_scalars::#values + } + + /// Integer scalars pivot on their inherent `MIN`/`MAX` consts; + /// the fixture lists include both (`fixtures!(int …; Min, …, Max)`). + fn min_pivot() -> #rust_type { + <#rust_type>::MIN + } + + fn max_pivot() -> #rust_type { + <#rust_type>::MAX + } } } - } - }); + }); quote! { #(#impls)* } } @@ -243,7 +243,11 @@ fn matrix_suite_for_entry(token: &Ident, rust_type: &Type, eq_only: bool) -> Tok /// See [`emit_scalar_matrix_suites`] and [`matrix_suite_for_entry`]. fn scalar_matrix_suites_tokens(list: &ScalarList) -> TokenStream2 { let mods = list.entries.iter().map(|e| { - matrix_suite_for_entry(&e.token, &e.rust_type, is_eq_only_token(&e.token.to_string())) + matrix_suite_for_entry( + &e.token, + &e.rust_type, + is_eq_only_token(&e.token.to_string()), + ) }); quote! { #(#mods)* } } @@ -358,8 +362,7 @@ mod tests { #[test] fn temporal_entry_skips_impl_and_stamps_temporal_fixture() { // No marker: `date`'s temporal shape is read from eql-scalars::CATALOG. - let list = - syn::parse_str::("int4 => i32, date => chrono::NaiveDate").unwrap(); + let list = syn::parse_str::("int4 => i32, date => chrono::NaiveDate").unwrap(); // Impl emitter skips the temporal entry (handed to `temporal_values!`). let impls = norm(&scalar_type_impls_tokens(&list)); assert!(impls.contains("impl ScalarType for i32")); From a0e5d308e7faaee12f59d1d984002498cd5f872f Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Fri, 5 Jun 2026 17:52:00 +1000 Subject: [PATCH 09/12] test(scalars): add unified scalar_matrix! wrapper (caps = [eq] | [eq, ord]) Single entry point selected by a capability marker, replacing the two parallel wrappers. caps = [eq, ord] is the ordered-numeric body (verbatim from ordered_numeric_matrix!); caps = [eq] is the equality-only body with pivots derived from the ScalarType impl (min/max/Default), matching the proven timestamptz-era eq-only form so the eq-only name set stays a clean subset of the ordered snapshot. Old wrappers still present; removed next. --- tests/sqlx/src/matrix.rs | 129 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 127 insertions(+), 2 deletions(-) diff --git a/tests/sqlx/src/matrix.rs b/tests/sqlx/src/matrix.rs index 2fdb9e5b..b22dfa4b 100644 --- a/tests/sqlx/src/matrix.rs +++ b/tests/sqlx/src/matrix.rs @@ -269,8 +269,133 @@ macro_rules! eq_only_scalar_matrix { }; } -/// Low-level entry point. Use `ordered_numeric_matrix!` instead unless -/// your type's surface deviates from the standard ordered-numeric shape. +/// Unified convention wrapper for scalar encrypted-domain suites. Replaces the +/// two parallel wrappers (`ordered_numeric_matrix!` + `eq_only_scalar_matrix!`) +/// with one entry point selected by a `caps` capability marker: +/// +/// - `caps = [eq, ord]` — the ordered-numeric shape (all four variants; +/// `=`/`<>`/`<`/`<=`/`>`/`>=`; ORDER BY / ORDER BY USING; ORE injectivity; +/// the ordered functional index). Consumers: `int2`/`int4`/`int8`/`date`. +/// - `caps = [eq]` — equality-only (storage + `_eq` only; `=`/`<>` meaningful, +/// the four ord operators are deliberate blockers). The empty `ord_domains` +/// make the order-by / ORE arms emit zero tests. First consumer: +/// `timestamptz`. +/// +/// Both arms take the identical `(suite, scalar, eql_type)` signature and derive +/// the three comparison pivots from the `ScalarType` impl +/// (`min_pivot()`/`max_pivot()`/`Default`), so the invocation shape is the same +/// regardless of capability — only the `caps` marker differs. The emitted test +/// names for an ordered type are byte-identical to the old +/// `ordered_numeric_matrix!`; the eq-only name set is exactly that set minus the +/// `_ord` / `order_by` / `routes_through_ob` lines. +#[macro_export] +macro_rules! scalar_matrix { + ( + suite = $suite:ident, + scalar = $scalar:ty, + eql_type = $eql_type:literal, + caps = [eq, ord] $(,)? + ) => { + $crate::scalar_domain_matrix! { + suite = $suite, + scalar = $scalar, + eql_type = $eql_type, + // Relative to the suite source file at + // tests/sqlx/tests/encrypted_domain/scalars/.rs; sqlx's + // include_str! resolves it against that file. Every scalar + // suite lives at this depth, so the path is fixed here rather + // than repeated per invocation. + fixture_path = "../../../fixtures", + all_domains = [(storage, Storage), (eq, Eq), (ord, Ord), (ord_ore, OrdOre)], + eq_domains = [(eq, Eq), (ord, Ord), (ord_ore, OrdOre)], + ord_domains = [(ord, Ord), (ord_ore, OrdOre)], + ord_ore_domains = [(ord_ore, OrdOre)], + pivots = [ + (min, <$scalar as $crate::scalar_domains::ScalarType>::min_pivot()), + (max, <$scalar as $crate::scalar_domains::ScalarType>::max_pivot()), + (zero, <$scalar as ::core::default::Default>::default()), + ], + eq_ops = [(eq, "="), (neq, "<>")], + ord_ops = [(lt, "<"), (lte, "<="), (gt, ">"), (gte, ">=")], + index_combos = [ + (eq, Eq, "eql_v3.eq_term", "btree", [(eq, "=")]), + (eq, Eq, "eql_v3.eq_term", "hash", [(eq, "=")]), + (ord, Ord, "eql_v3.ord_term", "btree", + [(eq, "="), (lt, "<"), (lte, "<="), (gt, ">"), (gte, ">=")]), + (ord_ore, OrdOre, "eql_v3.ord_term", "btree", + [(eq, "="), (lt, "<"), (lte, "<="), (gt, ">"), (gte, ">=")]), + ], + blocker_combos = [ + (storage, Storage, [ + (eq, "="), (neq, "<>"), + (lt, "<"), (lte, "<="), (gt, ">"), (gte, ">="), + (contains, "@>"), (contained_by, "<@"), + ]), + (eq, Eq, [ + (lt, "<"), (lte, "<="), (gt, ">"), (gte, ">="), + (contains, "@>"), (contained_by, "<@"), + ]), + (ord, Ord, [(contains, "@>"), (contained_by, "<@")]), + (ord_ore, OrdOre, [(contains, "@>"), (contained_by, "<@")]), + ], + // Always-on cost-preference proof (#239 thread 17): the recommended + // converged ordered domain, ord_term btree. One curated combo keeps + // PR CI cost bounded. + scale_default_combos = [ + (ord, Ord, "eql_v3.ord_term", "btree"), + ], + } + }; + ( + suite = $suite:ident, + scalar = $scalar:ty, + eql_type = $eql_type:literal, + caps = [eq] $(,)? + ) => { + $crate::scalar_domain_matrix! { + suite = $suite, + scalar = $scalar, + eql_type = $eql_type, + // Fixed path; see the `caps = [eq, ord]` arm for the rationale. + fixture_path = "../../../fixtures", + all_domains = [(storage, Storage), (eq, Eq)], + eq_domains = [(eq, Eq)], + ord_domains = [], + ord_ore_domains = [], + // Pivots derived from the scalar type exactly like the ordered arm + // (`min_pivot()`/`max_pivot()`/`Default`), so the equality + // correctness / cross-shape arms sweep the same three anchors and + // the eq-only name set stays a clean subset of the ordered one. + pivots = [ + (min, <$scalar as $crate::scalar_domains::ScalarType>::min_pivot()), + (max, <$scalar as $crate::scalar_domains::ScalarType>::max_pivot()), + (zero, <$scalar as ::core::default::Default>::default()), + ], + eq_ops = [(eq, "="), (neq, "<>")], + ord_ops = [(lt, "<"), (lte, "<="), (gt, ">"), (gte, ">=")], + index_combos = [ + (eq, Eq, "eql_v3.eq_term", "btree", [(eq, "=")]), + (eq, Eq, "eql_v3.eq_term", "hash", [(eq, "=")]), + ], + blocker_combos = [ + (storage, Storage, [ + (eq, "="), (neq, "<>"), + (lt, "<"), (lte, "<="), (gt, ">"), (gte, ">="), + (contains, "@>"), (contained_by, "<@"), + ]), + (eq, Eq, [ + (lt, "<"), (lte, "<="), (gt, ">"), (gte, ">="), + (contains, "@>"), (contained_by, "<@"), + ]), + ], + // Equality-only scalars have no ordered functional index to prefer. + scale_default_combos = [], + } + }; +} + +/// Low-level entry point. Use `scalar_matrix!` instead unless +/// your type's surface deviates from the standard scalar shapes. #[macro_export] macro_rules! scalar_domain_matrix { ( From ba4cb0c1c8c5488b1ed70ada4f07720dd02afab5 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Fri, 5 Jun 2026 18:41:03 +1000 Subject: [PATCH 10/12] test(scalars): emit unified scalar_matrix!; remove the two parallel wrappers The matrix-suite emitter now routes every type through scalar_matrix! with a caps marker derived from the catalog (is_eq_only_token): ordered types get caps = [eq, ord], equality-only types caps = [eq]. This replaces the prior ordered-only emission + eq-only compile_error! seam with a real eq-only path, and deletes ordered_numeric_matrix! / eq_only_scalar_matrix! from matrix.rs. Generated test names are byte-identical (inventory OK, 4 types match the canonical snapshot) and the scalar matrix is green at the baseline (844 passed, 0 failed). release/*.sql unchanged. Doc comments naming the old wrappers updated to scalar_matrix!. --- crates/eql-tests-macros/src/lib.rs | 70 ++++---- tests/sqlx/src/matrix.rs | 151 ++---------------- tests/sqlx/src/scalar_domains.rs | 2 +- tests/sqlx/src/scalar_types.rs | 2 +- .../tests/encrypted_domain/scalars/mod.rs | 2 +- 5 files changed, 56 insertions(+), 171 deletions(-) diff --git a/crates/eql-tests-macros/src/lib.rs b/crates/eql-tests-macros/src/lib.rs index 74dfe11c..01b1d1e7 100644 --- a/crates/eql-tests-macros/src/lib.rs +++ b/crates/eql-tests-macros/src/lib.rs @@ -208,38 +208,37 @@ fn fixture_dispatch_tokens(list: &ScalarList) -> TokenStream2 { } } -/// Build the matrix suite for one entry. Ordered types get the -/// `ordered_numeric_matrix!` suite (`=`/`<>`/`<`/`>`/`min`/`max`). An eq-only -/// type has no `_ord` domain, so the ordered matrix would exercise ordering -/// operators the type does not support — emit a `compile_error!` directing the -/// author to wire an equality-only matrix instead. The shape is read from the -/// catalog (`eq_only` = [`is_eq_only_token`]), not a marker; `eq_only` is passed -/// in so this stays a pure function of its inputs and both arms are unit-testable -/// without an eq-only row in the live catalog. +/// Build the matrix suite for one entry. Both shapes route through the unified +/// `scalar_matrix!` wrapper, selected by a `caps` capability marker derived from +/// the catalog (`eq_only` = [`is_eq_only_token`]): an ordered type emits +/// `caps = [eq, ord]` (`=`/`<>`/`<`/`>`/`min`/`max`); an equality-only type (no +/// `_ord` domain) emits `caps = [eq]`, whose empty `ord_domains` make the +/// ordering arms emit zero tests rather than exercising operators the type does +/// not support. `eq_only` is passed in so this stays a pure function of its +/// inputs and both arms are unit-testable without an eq-only row in the live +/// catalog. fn matrix_suite_for_entry(token: &Ident, rust_type: &Type, eq_only: bool) -> TokenStream2 { let token_str = token.to_string(); - if eq_only { - let msg = format!( - "scalar `{token_str}` is equality-only (no `_ord` domain in eql-scalars::CATALOG); \ - the ordered matrix exercises ordering operators it does not support. \ - Wire an equality-only matrix for it instead of routing it through the ordered suite." - ); - return quote! { compile_error!(#msg); }; - } let eql_type = format!("eql_v2_{}", token_str); + let caps = if eq_only { + quote! { caps = [eq] } + } else { + quote! { caps = [eq, ord] } + }; quote! { #[doc = concat!("`eql_v2_", #token_str, "` matrix suite — generated by `scalar_types!`.")] pub mod #token { - ::eql_tests::ordered_numeric_matrix! { + ::eql_tests::scalar_matrix! { suite = #token, scalar = #rust_type, eql_type = #eql_type, + #caps } } } } -/// Emit one `pub mod { ordered_numeric_matrix! { ... } }` per entry. +/// Emit one `pub mod { scalar_matrix! { ... } }` per entry. /// See [`emit_scalar_matrix_suites`] and [`matrix_suite_for_entry`]. fn scalar_matrix_suites_tokens(list: &ScalarList) -> TokenStream2 { let mods = list.entries.iter().map(|e| { @@ -292,7 +291,7 @@ pub fn emit_fixture_dispatch(input: TokenStream) -> TokenStream { fixture_dispatch_tokens(&list).into() } -/// Emit one `pub mod { ordered_numeric_matrix! { ... } }` per entry. +/// Emit one `pub mod { scalar_matrix! { ... } }` per entry. /// /// Invoked via `scalar_types!` in /// `tests/sqlx/tests/encrypted_domain/scalars/mod.rs`, so the matrix suites land @@ -400,25 +399,27 @@ mod tests { } #[test] - fn ordered_entry_emits_ordered_matrix_suite() { + fn ordered_entry_emits_scalar_matrix_with_eq_ord_caps() { let token: Ident = syn::parse_str("int4").unwrap(); let rust_type: Type = syn::parse_str("i32").unwrap(); let out = norm(&matrix_suite_for_entry(&token, &rust_type, false)); - assert!(out.contains(":: eql_tests :: ordered_numeric_matrix !")); + assert!(out.contains(":: eql_tests :: scalar_matrix !")); + assert!(out.contains("caps = [eq , ord]")); assert!(out.contains("suite = int4")); - assert!(!out.contains("compile_error")); } #[test] - fn eq_only_entry_emits_compile_error_not_ordered_matrix() { + fn eq_only_entry_emits_scalar_matrix_with_eq_caps_only() { // No eq-only row exists in the live catalog yet, so pass the shape - // directly: an eq-only token must never reach the ordered matrix. + // directly: an eq-only token routes to the `caps = [eq]` arm (empty + // ord_domains), never the ordered `caps = [eq, ord]` arm. let token: Ident = syn::parse_str("timestamptz").unwrap(); let rust_type: Type = syn::parse_str("chrono::DateTime").unwrap(); let out = norm(&matrix_suite_for_entry(&token, &rust_type, true)); - assert!(out.contains("compile_error !")); - assert!(out.contains("equality-only")); - assert!(!out.contains("ordered_numeric_matrix")); + assert!(out.contains(":: eql_tests :: scalar_matrix !")); + assert!(out.contains("caps = [eq]")); + assert!(!out.contains("caps = [eq , ord]")); + assert!(!out.contains("compile_error")); } #[test] @@ -444,7 +445,7 @@ mod tests { let out = norm(&scalar_matrix_suites_tokens(&sample())); assert!(out.contains("pub mod int4")); assert!(out.contains("pub mod int8")); - assert!(out.contains(":: eql_tests :: ordered_numeric_matrix !")); + assert!(out.contains(":: eql_tests :: scalar_matrix !")); // suite/scalar/eql_type must match the old per-type files so test names // (and the snapshot) are unchanged. assert!(out.contains("suite = int4")); @@ -454,4 +455,17 @@ mod tests { assert!(out.contains("scalar = i64")); assert!(out.contains(r#"eql_type = "eql_v2_int8""#)); } + + #[test] + fn matrix_suites_emit_unified_macro_with_caps() { + // Both base types are ordered, so the emitter routes them through the + // unified wrapper with the ordered capability marker and never names + // either of the now-deleted parallel wrappers. + let list = syn::parse_str::("int4 => i32, date => chrono::NaiveDate").unwrap(); + let out = norm(&scalar_matrix_suites_tokens(&list)); + assert!(out.contains(":: eql_tests :: scalar_matrix !")); + assert!(out.contains("caps = [eq , ord]")); + assert!(!out.contains("ordered_numeric_matrix")); + assert!(!out.contains("eq_only_scalar_matrix")); + } } diff --git a/tests/sqlx/src/matrix.rs b/tests/sqlx/src/matrix.rs index b22dfa4b..9b79103b 100644 --- a/tests/sqlx/src/matrix.rs +++ b/tests/sqlx/src/matrix.rs @@ -2,17 +2,20 @@ //! //! Two entry points: //! -//! - **`ordered_numeric_matrix!`** — the recommended wrapper. For an -//! ordered numeric scalar (i32, i64, f64, date, numeric, timestamp, -//! ...) all four variants are present, the operator surface is -//! identical, and the only inputs that change per type are the scalar -//! itself, the suite token (used to derive domain + test names), the -//! EQL type name (the fixture `scripts(...)` ref), and the pivot -//! values. Invocation is ~5 lines. +//! - **`scalar_matrix!`** — the recommended wrapper. One invocation per type +//! (~5 lines), with a `caps` capability marker selecting the shape: +//! `caps = [eq, ord]` for an ordered scalar (i32, i64, date, ...) where all +//! four variants are present and the full `=`/`<>`/`<`/`>`/`min`/`max` +//! surface applies; `caps = [eq]` for an equality-only scalar (timestamptz, +//! bool, ...) where only storage + `_eq` materialise and the ord operators +//! are blockers. The only other inputs that change per type are the scalar +//! itself, the suite token (used to derive domain + test names), and the EQL +//! type name (the fixture `scripts(...)` ref); pivots are derived from the +//! `ScalarType` impl. //! //! - **`scalar_domain_matrix!`** — the lower-level macro the wrapper //! expands to. Use directly only for types with a non-standard surface -//! (e.g. equality-only scalars like bool). +//! that neither `caps` shape covers. //! //! Each invocation emits one `#[sqlx::test]` per (category, domain, //! operator, pivot) tuple. Categories: sanity, correctness, cross-shape, @@ -137,138 +140,6 @@ fn collect_index_scan_nodes(value: &serde_json::Value, found: &mut Vec<(String, } } -/// Convention wrapper for ordered numeric scalars. Expands to a -/// `scalar_domain_matrix!` invocation with the standard 4 variants, 6 -/// supported comparison operators, 2 path operators, and the standard -/// blocker / index partitions. -/// -/// `eql_type` is the fixture/table name (e.g. `"eql_v2_int4"`), used as the -/// SQLx fixture `scripts(...)` ref — sqlx parses it as a token-level string -/// literal, so it must be a literal, not derived. It is NOT a domain type -/// name: the `eql_v3.*` domains exercised here are derived from the scalar -/// type (see `scalar_domains.rs`, `format!("eql_v3.{}…", T::PG_TYPE)`). -/// -/// Pivots — the comparison anchors swept by the correctness / cross-shape -/// arms — are derived from the scalar type: `min_pivot()`, `max_pivot()`, and -/// zero (`Default::default()`). Integer scalars resolve `min_pivot`/`max_pivot` -/// to `Self::MIN`/`Self::MAX`; temporal scalars use explicit sentinel dates. The -/// fixture must contain those three plaintext rows, since each pivot's -/// ciphertext is fetched at test time via `fetch_fixture_payload`. -#[macro_export] -macro_rules! ordered_numeric_matrix { - ( - suite = $suite:ident, - scalar = $scalar:ty, - eql_type = $eql_type:literal $(,)? - ) => { - $crate::scalar_domain_matrix! { - suite = $suite, - scalar = $scalar, - eql_type = $eql_type, - // Relative to the suite source file at - // tests/sqlx/tests/encrypted_domain/scalars/.rs; sqlx's - // include_str! resolves it against that file. Every scalar - // suite lives at this depth, so the path is fixed here rather - // than repeated per invocation. - fixture_path = "../../../fixtures", - all_domains = [(storage, Storage), (eq, Eq), (ord, Ord), (ord_ore, OrdOre)], - eq_domains = [(eq, Eq), (ord, Ord), (ord_ore, OrdOre)], - ord_domains = [(ord, Ord), (ord_ore, OrdOre)], - ord_ore_domains = [(ord_ore, OrdOre)], - pivots = [ - (min, <$scalar as $crate::scalar_domains::ScalarType>::min_pivot()), - (max, <$scalar as $crate::scalar_domains::ScalarType>::max_pivot()), - (zero, <$scalar as ::core::default::Default>::default()), - ], - eq_ops = [(eq, "="), (neq, "<>")], - ord_ops = [(lt, "<"), (lte, "<="), (gt, ">"), (gte, ">=")], - index_combos = [ - (eq, Eq, "eql_v3.eq_term", "btree", [(eq, "=")]), - (eq, Eq, "eql_v3.eq_term", "hash", [(eq, "=")]), - (ord, Ord, "eql_v3.ord_term", "btree", - [(eq, "="), (lt, "<"), (lte, "<="), (gt, ">"), (gte, ">=")]), - (ord_ore, OrdOre, "eql_v3.ord_term", "btree", - [(eq, "="), (lt, "<"), (lte, "<="), (gt, ">"), (gte, ">=")]), - ], - blocker_combos = [ - (storage, Storage, [ - (eq, "="), (neq, "<>"), - (lt, "<"), (lte, "<="), (gt, ">"), (gte, ">="), - (contains, "@>"), (contained_by, "<@"), - ]), - (eq, Eq, [ - (lt, "<"), (lte, "<="), (gt, ">"), (gte, ">="), - (contains, "@>"), (contained_by, "<@"), - ]), - (ord, Ord, [(contains, "@>"), (contained_by, "<@")]), - (ord_ore, OrdOre, [(contains, "@>"), (contained_by, "<@")]), - ], - // Always-on cost-preference proof (#239 thread 17): the recommended - // converged ordered domain, ord_term btree. One curated combo keeps - // PR CI cost bounded. - scale_default_combos = [ - (ord, Ord, "eql_v3.ord_term", "btree"), - ], - } - }; -} - -/// Convention wrapper for equality-only scalars (no ord variants). Bool -/// is the canonical consumer: `=` / `<>` are meaningful; the four ord -/// operators are deliberate blockers. -/// -/// Expands to `scalar_domain_matrix!` with `ord_domains = []`, -/// `ord_ore_domains = []`, no btree-ord index combo, and blocker_combos -/// covering the ord operators on every materialised variant. Order-by / -/// order-by-using arms emit zero tests because they iterate empty -/// ord_domains. -/// -/// **Status:** this umbrella has no in-tree consumer yet. It exists so -/// that adding `bool` (or any other equality-only scalar) is one -/// `impl ScalarType` + fixture + one-line macro invocation, with no -/// macro authoring required. Runtime validation lands with bool. -#[macro_export] -macro_rules! eq_only_scalar_matrix { - ( - suite = $suite:ident, - scalar = $scalar:ty, - eql_type = $eql_type:literal, - pivots = [$($pivot:tt),+ $(,)?] $(,)? - ) => { - $crate::scalar_domain_matrix! { - suite = $suite, - scalar = $scalar, - eql_type = $eql_type, - // Fixed path; see `ordered_numeric_matrix!` for the rationale. - fixture_path = "../../../fixtures", - all_domains = [(storage, Storage), (eq, Eq)], - eq_domains = [(eq, Eq)], - ord_domains = [], - ord_ore_domains = [], - pivots = [$($pivot),+], - eq_ops = [(eq, "="), (neq, "<>")], - ord_ops = [(lt, "<"), (lte, "<="), (gt, ">"), (gte, ">=")], - index_combos = [ - (eq, Eq, "eql_v3.eq_term", "btree", [(eq, "=")]), - (eq, Eq, "eql_v3.eq_term", "hash", [(eq, "=")]), - ], - blocker_combos = [ - (storage, Storage, [ - (eq, "="), (neq, "<>"), - (lt, "<"), (lte, "<="), (gt, ">"), (gte, ">="), - (contains, "@>"), (contained_by, "<@"), - ]), - (eq, Eq, [ - (lt, "<"), (lte, "<="), (gt, ">"), (gte, ">="), - (contains, "@>"), (contained_by, "<@"), - ]), - ], - // Equality-only scalars have no ordered functional index to prefer. - scale_default_combos = [], - } - }; -} - /// Unified convention wrapper for scalar encrypted-domain suites. Replaces the /// two parallel wrappers (`ordered_numeric_matrix!` + `eq_only_scalar_matrix!`) /// with one entry point selected by a `caps` capability marker: diff --git a/tests/sqlx/src/scalar_domains.rs b/tests/sqlx/src/scalar_domains.rs index a4eabdf2..5b304a37 100644 --- a/tests/sqlx/src/scalar_domains.rs +++ b/tests/sqlx/src/scalar_domains.rs @@ -45,7 +45,7 @@ pub trait ScalarType: /// `LazyLock>` and returns a borrow of it (see `date_values`). /// Integer scalars return their `eql_scalars::_VALUES` const directly. /// - /// For types driven by `ordered_numeric_matrix!`, the values MUST + /// For types driven by `scalar_matrix!`, the values MUST /// include the three pivots (`min_pivot()`, `max_pivot()`, and zero /// `Default::default()`): the matrix uses those as comparison pivots and /// fetches each one's ciphertext via `fetch_fixture_payload`, which fails diff --git a/tests/sqlx/src/scalar_types.rs b/tests/sqlx/src/scalar_types.rs index 45173d05..feeca605 100644 --- a/tests/sqlx/src/scalar_types.rs +++ b/tests/sqlx/src/scalar_types.rs @@ -22,7 +22,7 @@ //! - `scalar_type_impls` — `scalar_domains.rs` (lib): the `impl ScalarType` block. //! - `fixture_modules` — `fixtures/mod.rs` (lib): the `pub mod eql_v3_` modules. //! - `matrix_suites` — `tests/encrypted_domain/scalars/mod.rs` (test binary): -//! the `ordered_numeric_matrix!` suites. +//! the `scalar_matrix!` suites. //! - `fixture_dispatch` — `tests/generate_all_fixtures.rs` (test binary): the //! `generate_for_token` dispatch fn. //! diff --git a/tests/sqlx/tests/encrypted_domain/scalars/mod.rs b/tests/sqlx/tests/encrypted_domain/scalars/mod.rs index 72c64f87..8995492f 100644 --- a/tests/sqlx/tests/encrypted_domain/scalars/mod.rs +++ b/tests/sqlx/tests/encrypted_domain/scalars/mod.rs @@ -1,6 +1,6 @@ //! Per-scalar matrix suites, generated by the `scalar_types!(matrix_suites)` //! invocation below — one module per scalar type, each holding its -//! `ordered_numeric_matrix!` suite. +//! `scalar_matrix!` suite. //! //! The modules are generated from the single harness list in //! `tests/sqlx/src/scalar_types.rs` — adding a type there adds its suite here From dc30c071d9d7f2070ffc420a0462e0cdff04bc31 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Fri, 5 Jun 2026 18:59:56 +1000 Subject: [PATCH 11/12] test(matrix): single-snapshot inventory accepts derived eq-only subset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each discovered scalar type now matches EITHER the full canonical snapshot (ordered shape) OR a subset derived from it on the fly — the ordered names minus the ord-only lines (_ord / order_by / routes_through_ob). An equality-only scalar (timestamptz, next) is validated against that derivation, so it needs no second committed snapshot. The per-type shape (ordered/eq_only) is now printed. All four current types are ordered; the eq-only branch is dormant but unit-proven (51-line strict subset of the 211-line baseline). --- mise.toml | 41 +++++++++++++++++++++++++++++++---------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/mise.toml b/mise.toml index 8baafecb..fadad50b 100644 --- a/mise.toml +++ b/mise.toml @@ -136,21 +136,26 @@ cargo test -p eql-scalars -p eql-codegen -p eql-tests-macros """ [tasks."test:matrix:inventory"] -description = "Verify the matrix test-name set against the single canonical snapshot, catalog-cross-checked (no database required)" +description = "Verify the matrix test-name set against the single canonical snapshot (or its derived eq-only subset), catalog-cross-checked (no database required)" dir = "{{config_root}}/tests/sqlx" run = """ #!/usr/bin/env bash # ONE canonical, token-normalized snapshot (snapshots/matrix_tests.txt) pins the -# set of macro-emitted matrix test names. The two per-type snapshots are gone: -# they were byte-identical modulo the type token, so one canonical set plus a -# per-type normalize+compare carries the same signal at 1/N the committed surface. +# set of macro-emitted matrix test names for the ORDERED scalar shape. There is +# no second committed file for the equality-only shape: an eq-only type's name +# set is exactly the ordered set MINUS the ord-only lines, so the inventory +# DERIVES it from the one baseline (ordered minus `_ord`/`order_by`/ +# `routes_through_ob`). Each discovered type must match either the full baseline +# (ordered) or that derived subset (eq-only). This keeps one committed snapshot +# however many ordered/eq-only types exist. # # Steps: # 1. List the encrypted_domain binary ONCE (deterministic; reused below). # 2. Discover the set of scalar types present FROM THE BINARY'S OWN OUTPUT # (scalars:::: prefixes) — never a directory glob. # 3. For each discovered type, normalize its token to and assert its set -# equals the canonical snapshot. Assert at least one type is present. +# equals EITHER the canonical snapshot (ordered) OR the derived eq-only +# subset. Assert at least one type is present. # 4. Completeness cross-check: assert the discovered type set equals # `eql-codegen list-types`. A catalog type added without its matrix wiring # (no scalars:::: tests in the binary) fails here. @@ -170,20 +175,36 @@ discovered=$(printf '%s\\n' "$listing" \ | LC_ALL=C sort -u) [ -n "$discovered" ] || { echo "No scalars:::: tests found in the encrypted_domain binary." >&2; exit 1; } -# Per-type normalize + compare against the canonical snapshot. +# An equality-only type (no `_ord` domain) emits a strict SUBSET of the +# canonical (ordered) snapshot: the same names minus every ord-only line +# (`_ord` / `order_by` / `routes_through_ob`). Derive that subset once here, so +# an equality-only scalar (e.g. timestamptz) needs NO second committed snapshot +# — it is validated against this derivation from the single baseline. +eq_only_expected=$(grep -vE '_ord|order_by|routes_through_ob' snapshots/matrix_tests.txt | LC_ALL=C sort -u) + +# Per-type normalize + compare: each type must match EITHER the full canonical +# snapshot (ordered shape) OR the derived eq-only subset (equality-only shape). checked=0 while IFS= read -r t; do [ -n "$t" ] || continue printf '%s\\n' "$listing" | grep "^scalars::${t}::" \ | sed -e "s/^scalars::${t}::/scalars::::/" -e "s/_${t}_/__/g" | LC_ALL=C sort > "/tmp/matrix-norm-${t}.txt" - if ! cmp -s "/tmp/matrix-norm-${t}.txt" snapshots/matrix_tests.txt; then - echo "Matrix test-name set for '${t}' differs from snapshots/matrix_tests.txt:" >&2 + if cmp -s "/tmp/matrix-norm-${t}.txt" snapshots/matrix_tests.txt; then + shape="ordered" + elif [ "$(cat "/tmp/matrix-norm-${t}.txt")" = "$eq_only_expected" ]; then + shape="eq_only" + else + echo "Matrix test-name set for '${t}' matches NEITHER the canonical snapshot nor its derived eq-only subset." >&2 + echo " vs ordered (snapshots/matrix_tests.txt):" >&2 diff snapshots/matrix_tests.txt "/tmp/matrix-norm-${t}.txt" >&2 || true + echo " vs derived eq-only (ordered minus _ord/order_by/routes_through_ob):" >&2 + diff <(printf '%s\\n' "$eq_only_expected") "/tmp/matrix-norm-${t}.txt" >&2 || true exit 1 fi + echo " ${t}: ${shape}" checked=$((checked + 1)) done <<< "$discovered" -[ "$checked" -gt 0 ] || { echo "No scalar type matched the canonical snapshot." >&2; exit 1; } +[ "$checked" -gt 0 ] || { echo "No scalar type matched the canonical snapshot or its derived eq-only subset." >&2; exit 1; } # Completeness cross-check against the catalog (the single source of truth). catalog=$(cd "{{config_root}}" && cargo run -p eql-codegen -- list-types | LC_ALL=C sort -u) @@ -195,7 +216,7 @@ if [ "$discovered" != "$catalog" ]; then exit 1 fi -echo "Matrix inventory OK: ${checked} type(s) match the canonical snapshot; catalog reconciled." +echo "Matrix inventory OK: ${checked} type(s) match the canonical snapshot or its derived eq-only subset; catalog reconciled." """ [tasks."test:matrix:expand"] From 8affba2dc73e906e880727d35ccdedc8cc41e9f6 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Fri, 5 Jun 2026 19:00:54 +1000 Subject: [PATCH 12/12] docs(snapshots): single baseline + derived eq-only check Document that there is ONE committed snapshot (the ordered shape) and that equality-only types are validated against a subset derived from it on the fly (baseline minus _ord/order_by/routes_through_ob), so an eq-only scalar needs no second snapshot. Update the stale ordered_numeric_matrix! references to scalar_matrix! and describe the printed per-type shape. --- tests/sqlx/snapshots/README.md | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/tests/sqlx/snapshots/README.md b/tests/sqlx/snapshots/README.md index ed363608..8b01c384 100644 --- a/tests/sqlx/snapshots/README.md +++ b/tests/sqlx/snapshots/README.md @@ -8,10 +8,17 @@ it in version control. The per-type `_matrix_tests.txt` files are gone. They were byte-identical modulo the type token (the matrix tests are macro-generated from one -`ordered_numeric_matrix!` invocation per type with no per-type variation), so a +`scalar_matrix!` invocation per type with no per-type variation), so a single canonical set plus a per-type normalize-and-compare carries the same signal at a fraction of the committed surface. +There is also **no** separate snapshot for equality-only types. An eq-only +scalar (`scalar_matrix! { caps = [eq] }`, e.g. `timestamptz`) emits exactly the +ordered name set MINUS the ord-only lines, so the inventory **derives** its +expected set from this one baseline — `matrix_tests.txt` minus every line +matching `_ord` / `order_by` / `routes_through_ob`. The baseline file itself is +always the ordered (`caps = [eq, ord]`) shape. + ## What it guards The SQLx assertions verify that the tests which run produce the right results. @@ -34,8 +41,11 @@ The task (`mise.toml`, `[tasks."test:matrix:inventory"]`): `cargo test --no-default-features --test encrypted_domain -- --list`. 2. Discovers the set of scalar types present **from the binary's own output** (the `scalars::::` prefixes) — never a directory glob. -3. Normalizes each type's token to `` and asserts that type's set equals the - canonical `matrix_tests.txt`. Asserts at least one type is present. +3. Normalizes each type's token to `` and asserts that type's set equals + **either** the canonical `matrix_tests.txt` (ordered shape) **or** the derived + eq-only subset (`matrix_tests.txt` minus `_ord`/`order_by`/`routes_through_ob`). + Prints each type's resolved shape (`ordered` / `eq_only`). Asserts at least + one type is present. 4. **Completeness cross-check:** asserts the discovered type set equals `cargo run -p eql-codegen -- list-types` (the catalog is the single source). A catalog type added without its matrix wiring — no `scalars::::` tests in @@ -62,10 +72,10 @@ catalog cross-check) fails the job. - **Adding a new scalar type** → add the catalog row in `eql-scalars::CATALOG`, wire the SQLx matrix oracle (see `docs/reference/adding-a-scalar-encrypted-domain-type.md` §3), then run - `mise run test:matrix:inventory`. If the new type's - normalized name set matches the canonical snapshot (it will, for a standard - `ordered_numeric_matrix!` type), no snapshot edit is needed — the cross-check - just confirms the type is wired. + `mise run test:matrix:inventory`. No snapshot edit is needed: an ordered + (`caps = [eq, ord]`) type matches the canonical baseline, and an equality-only + (`caps = [eq]`) type matches the derived eq-only subset — both are checked + against this one file. The cross-check just confirms the type is wired. - **Removing a scalar type** → remove the catalog row and its matrix wiring; the cross-check then sees the type gone from both sides. - **Changing which matrix tests the macro emits** → regenerate and commit