From 993df0061a4a1f200ef21ab7c46c2c42b16ca35a Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Mon, 29 Jun 2026 10:00:37 +1000 Subject: [PATCH 1/2] refactor: clarify catalog vocabulary (DomainFamily/Domain/name) PR 2 of unified-catalog-codegen refactor. Behavior-preserving rename: ScalarSpec->DomainFamily, DomainSpec->Domain, token/suffix->name (bare), codegen owns the '_' join (Domain::full_name), domain_by_suffix-> domain_by_name. kind stays on DomainFamily (moves in PR 3). Generated SQL/TS/JSON and dump-catalog output are byte-identical (codegen:parity, types:check, test:matrix:inventory all green). --- CLAUDE.md | 2 +- DEVELOPMENT.md | 10 +- crates/eql-bindings/src/v3/mod.rs | 2 +- crates/eql-codegen/src/context.rs | 8 +- crates/eql-codegen/src/dump.rs | 24 ++-- crates/eql-codegen/src/generate.rs | 94 ++++++------- crates/eql-codegen/src/main.rs | 2 +- crates/eql-codegen/tests/parity.rs | 2 +- crates/eql-domains/src/lib.rs | 123 +++++++++--------- crates/eql-domains/src/proptest_invariants.rs | 2 +- crates/eql-domains/src/spec.rs | 72 +++++----- crates/eql-domains/src/tests.rs | 119 +++++++++-------- crates/eql-tests-macros/src/lib.rs | 6 +- .../adding-a-scalar-encrypted-domain-type.md | 42 +++--- tests/sqlx/src/scalar_domains.rs | 32 ++--- tests/sqlx/src/scalar_types.rs | 2 +- .../tests/encrypted_domain/family/support.rs | 2 +- tests/sqlx/tests/generate_all_fixtures.rs | 4 +- 18 files changed, 281 insertions(+), 267 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 383497255..2b90eba7c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,7 +72,7 @@ This is the **Encrypt Query Language (EQL)** - a PostgreSQL extension for search `src/v3/scalars/` holds the generated **encrypted-domain type families** — jsonb-backed PostgreSQL domains in the **`eql_v3` schema**, one domain per operator/index capability (`eql_v3.` storage-only, `eql_v3._eq`, `eql_v3._ord`). The schema qualifier replaces the old version-prefixed name, so the domains are `eql_v3.int4`, `eql_v3.int4_eq`, `eql_v3.int4_ord`, `eql_v3.int4_ord_ore` — created in `eql_v3`, not `public`. Their extractors/wrappers/aggregates (`eql_v3.eq_term`, `eql_v3.ord_term`, `eql_v3.eq`/`lt`/…, `eql_v3.min`/`max`) also live in `eql_v3`, and the SEM index-term types they return and construct (`eql_v3.hmac_256`, `eql_v3.ore_block_256`) are **also `eql_v3`** — hand-written under `src/v3/sem/` so the whole v3 surface is self-contained (no `eql_v2.` appears anywhere in v3 SQL; CI gates this via `mise run test:self_contained_v3` and the self-contained `release/cipherstash-encrypt.sql` installer). `eql_v3.int4` (PR #239, supersedes #225) is the reference scalar implementation; the catalog now generates a full surface for `int4`, `int2`, `int8`, `date`, `timestamptz`, `numeric`, `text`, `bool`, `float4`, and `float8`, all following this materializer pattern. `jsonb` is the only catalog scalar with no generated SQL surface yet — it needs a separate SQL design beyond the ordered-scalar materializer, and the `eql-domains` fixture catalog (`crates/eql-domains`) models its fixture values ahead of that surface. -Adding a scalar encrypted-domain type is one row in the Rust catalog `eql-domains::CATALOG` (`crates/eql-domains/src/lib.rs`): a `ScalarSpec` giving the type `token` (e.g. `int8`), its `ScalarKind` (the `kind` field), the `DomainSpec`s mapping each generated domain suffix to its fixed index `Term`s (`_eq => [Hm]`, `_ord`/`_ord_ore => [Ore]`), and the `Fixture` value list. Term capabilities are fixed in the `Term` enum's `impl` methods (with unit tests): `Hm` provides equality, and `Ore` provides equality plus ordering. There is no TOML manifest and no Python — the catalog is the source of truth, validated by the compiler (an undefined term or unknown scalar is a compile error) plus catalog `#[test]`s. `mise run build` runs `cargo run -p eql-codegen`, which regenerates the scalar SQL surface into `src/v3/scalars//` from `CATALOG` at the start of every build; that surface includes supported comparison wrappers plus blockers for native `jsonb` operators that would otherwise be reachable through domain fallback. `cargo run -p eql-codegen` regenerates every type at once (the same call `mise run build` uses; there is no per-type codegen task). The generated `*_types.sql` / `*_functions.sql` / `*_operators.sql` / `*_aggregates.sql` files are gitignored and never committed. The per-type plaintext fixture lists the SQLx matrix consumes are **not** a generated file — they are materialised from each `CATALOG` row at compile time as `eql_domains::INT4_VALUES` / `INT2_VALUES` (the `int_values!` macro) and read directly by `ScalarType::FIXTURE_VALUES`; a Rust source of truth no longer round-trips through a committed generated `.rs`. Generated SQL carries a `-- AUTOMATICALLY GENERATED FILE` header (the project-wide marker `docs:validate` greps on); change the catalog and rebuild, never hand-edit. Hand-written SQL beyond the fixed surface goes in `src/v3/scalars//_extensions.sql` with no auto-generated header and explicit `-- REQUIRE:` edges — that file IS committed. `jsonb` is out of scope for this scalar materializer. +Adding a scalar encrypted-domain type is one row in the Rust catalog `eql-domains::CATALOG` (`crates/eql-domains/src/lib.rs`): a `DomainFamily` giving the type `name` (e.g. `int8`), its `ScalarKind` (the `kind` field), the `Domain`s mapping each generated (bare) domain name to its fixed index `Term`s (`eq => [Hm]`, `ord`/`ord_ore => [Ore]`), and the `Fixture` value list. Term capabilities are fixed in the `Term` enum's `impl` methods (with unit tests): `Hm` provides equality, and `Ore` provides equality plus ordering. There is no TOML manifest and no Python — the catalog is the source of truth, validated by the compiler (an undefined term or unknown scalar is a compile error) plus catalog `#[test]`s. `mise run build` runs `cargo run -p eql-codegen`, which regenerates the scalar SQL surface into `src/v3/scalars//` from `CATALOG` at the start of every build; that surface includes supported comparison wrappers plus blockers for native `jsonb` operators that would otherwise be reachable through domain fallback. `cargo run -p eql-codegen` regenerates every type at once (the same call `mise run build` uses; there is no per-type codegen task). The generated `*_types.sql` / `*_functions.sql` / `*_operators.sql` / `*_aggregates.sql` files are gitignored and never committed. The per-type plaintext fixture lists the SQLx matrix consumes are **not** a generated file — they are materialised from each `CATALOG` row at compile time as `eql_domains::INT4_VALUES` / `INT2_VALUES` (the `int_values!` macro) and read directly by `ScalarType::FIXTURE_VALUES`; a Rust source of truth no longer round-trips through a committed generated `.rs`. Generated SQL carries a `-- AUTOMATICALLY GENERATED FILE` header (the project-wide marker `docs:validate` greps on); change the catalog and rebuild, never hand-edit. Hand-written SQL beyond the fixed surface goes in `src/v3/scalars//_extensions.sql` with no auto-generated header and explicit `-- REQUIRE:` edges — that file IS committed. `jsonb` is out of scope for this scalar materializer. **Adding a new encrypted-domain type: follow `docs/reference/adding-a-scalar-encrypted-domain-type.md`.** The mechanics are fixed for ordered scalar domains; the catalog row only declares the token, kind, domain suffixes, and terms. New term behavior belongs in the `Term` enum's `impl` methods in `crates/eql-domains/src` with tests, not in free-form catalog data. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 308aa34c7..ced04c4aa 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -181,12 +181,12 @@ source of truth — the `CATALOG` const in [`crates/eql-domains/src/lib.rs`](./crates/eql-domains/src/lib.rs). There is no TOML manifest and no Python. -Each scalar type is one `ScalarSpec` row in `CATALOG`, declaring: +Each scalar type is one `DomainFamily` row in `CATALOG`, declaring: -- the type `token` (e.g. `int8`), +- the type `name` (e.g. `int8`), - its `ScalarKind` (the `kind` field), -- the `DomainSpec`s mapping each generated domain suffix to its fixed index - `Term`s (`_eq => [Hm]`, `_ord` / `_ord_ore => [Ore]`), and +- the `Domain`s mapping each generated (bare) domain name to its fixed index + `Term`s (`eq => [Hm]`, `ord` / `ord_ore => [Ore]`), and - the plaintext `Fixture` value list the SQLx test matrix consumes. `mise run build` invokes `cargo run -p eql-codegen`, which regenerates the SQL @@ -329,7 +329,7 @@ without a database at all. ### Adding a scalar encrypted-domain type Adding a scalar encrypted-domain type (e.g. a new ordered numeric scalar) is one -`ScalarSpec` row in `eql-domains::CATALOG` +`DomainFamily` row in `eql-domains::CATALOG` ([`crates/eql-domains/src/lib.rs`](./crates/eql-domains/src/lib.rs)). New term behaviour belongs in the `Term` enum's `impl` methods (with tests), not in free-form catalog data. After editing the catalog, run `mise run build` to diff --git a/crates/eql-bindings/src/v3/mod.rs b/crates/eql-bindings/src/v3/mod.rs index 2d70c2777..53998b457 100644 --- a/crates/eql-bindings/src/v3/mod.rs +++ b/crates/eql-bindings/src/v3/mod.rs @@ -93,7 +93,7 @@ pub trait DomainType { /// Unqualified SQL domain name (e.g. `"int4_eq"`) — [`Self::sql_domain`] /// minus the schema qualifier; matches `eql-domains` - /// `ScalarSpec::domain_name`. + /// `DomainFamily::domain_name`. fn domain(&self) -> &'static str { self.sql_domain() .strip_prefix("eql_v3.") diff --git a/crates/eql-codegen/src/context.rs b/crates/eql-codegen/src/context.rs index 300a78cdf..2df23d725 100644 --- a/crates/eql-codegen/src/context.rs +++ b/crates/eql-codegen/src/context.rs @@ -2,7 +2,7 @@ use crate::consts::*; use crate::operator_surface::Operator; -use eql_domains::{DomainSpec, Term}; +use eql_domains::{Domain, Term}; /// Build the minijinja environment with the embedded templates: one whole-file /// template per output file (`types`/`functions`/`operators`/`aggregates`) plus @@ -74,8 +74,8 @@ pub struct TypesContext { /// Build the per-domain block data (port of `render_domain_block`'s value logic, /// minus comment prose and the CHECK skeleton — those are template-resident). -pub fn domain_block(token: &str, domain: &DomainSpec) -> DomainBlock { - let name = domain.name_with_token(token); +pub fn domain_block(token: &str, domain: &Domain) -> DomainBlock { + let name = domain.full_name(token); let mut keys: Vec = ENVELOPE_KEYS.iter().map(|k| sql_str(k)).collect(); for k in Term::term_json_keys(domain.terms) { @@ -135,7 +135,7 @@ pub enum FnEntry { pub struct FunctionsContext { pub requires: Vec, // dependency paths only; template emits "-- REQUIRE:" pub token: String, - pub name: String, // full domain name (token+suffix) + pub name: String, // full domain name (family-name + "_" + domain-name) pub dom: String, // schema-qualified domain, e.g. eql_v3.int4_eq pub domain_lit: String, // sql_str(dom), defensively escaped for the RAISE literal pub entries: Vec, diff --git a/crates/eql-codegen/src/dump.rs b/crates/eql-codegen/src/dump.rs index 5cddb056d..a68e80682 100644 --- a/crates/eql-codegen/src/dump.rs +++ b/crates/eql-codegen/src/dump.rs @@ -25,12 +25,14 @@ pub struct TypeEntry { #[derive(Serialize)] pub struct DomainEntry { - /// Test-name segment: the base domain (`suffix == ""`) is `storage`; - /// otherwise the suffix without its leading underscore (`_eq` → `eq`, - /// `_ord_ore` → `ord_ore`). + /// Test-name segment: the base domain (`name == ""`) is `storage`; + /// otherwise the bare domain name (`eq`, `ord`, …). pub segment: String, - /// Raw catalog suffix (`""`, `_eq`, `_ord`, `_ord_ore`, `_match`). - pub suffix: &'static str, + /// The `suffix` wire field (`""`, `_eq`, `_ord`, `_ord_ore`, `_match`), + /// reconstructed by re-prefixing the bare domain name with `_` so the + /// emitted JSON stays byte-stable after the catalog dropped the leading + /// underscore from its stored domain names. + pub suffix: String, /// SQL operators the domain's terms support, in catalog order. Empty for /// the storage domain (no terms). pub supported_ops: Vec<&'static str>, @@ -45,17 +47,21 @@ pub fn dump_catalog() -> CatalogDump { .domains .iter() .map(|d| DomainEntry { - segment: if d.suffix.is_empty() { + segment: if d.name.is_empty() { "storage".to_string() } else { - d.suffix.trim_start_matches('_').to_string() + d.name.to_string() + }, + suffix: if d.name.is_empty() { + String::new() + } else { + format!("_{}", d.name) }, - suffix: d.suffix, supported_ops: Term::operators_for_terms(d.terms), }) .collect(); TypeEntry { - token: spec.token, + token: spec.name, is_eq_only: spec.is_eq_only(), domains, } diff --git a/crates/eql-codegen/src/generate.rs b/crates/eql-codegen/src/generate.rs index c400e871a..58d97464a 100644 --- a/crates/eql-codegen/src/generate.rs +++ b/crates/eql-codegen/src/generate.rs @@ -2,7 +2,7 @@ use std::path::{Path, PathBuf}; -use eql_domains::{DomainSpec, ScalarSpec, Term}; +use eql_domains::{Domain, DomainFamily, Term}; use crate::context::{domain_name, is_ord_capable}; use crate::operator_surface::OPERATORS; @@ -39,14 +39,14 @@ fn types_path(token: &str) -> String { /// Body for _types.sql: every domain in one idempotent DO block. /// Port of `render_types_file`. -pub fn render_types_file(spec: &ScalarSpec) -> String { +pub fn render_types_file(spec: &DomainFamily) -> String { use crate::context::{domain_block, environment, TypesContext}; let ctx = TypesContext { - token: spec.token.to_string(), + token: spec.name.to_string(), domains: spec .domains .iter() - .map(|d| domain_block(spec.token, d)) + .map(|d| domain_block(spec.name, d)) .collect(), }; environment() @@ -72,12 +72,12 @@ fn functions_requires(token: &str, terms: &[Term]) -> Vec { } /// Body for a domain's _functions.sql. Port of `render_functions_file`. -pub fn render_functions_file(token: &str, domain: &DomainSpec) -> String { +pub fn render_functions_file(token: &str, domain: &Domain) -> String { use crate::consts::sql_str; use crate::context::{ environment, extractor_entry, unsupported_entry, wrapper_entry, FunctionsContext, SqlParam, }; - let name = domain.name_with_token(token); + let name = domain.full_name(token); let dom = domain_name(&name); let domain_lit = sql_str(&dom); let supported = Term::operators_for_terms(domain.terms); @@ -127,9 +127,9 @@ pub fn render_functions_file(token: &str, domain: &DomainSpec) -> String { } /// Body for a domain's _operators.sql. Port of `render_operators_file`. -pub fn render_operators_file(token: &str, domain: &DomainSpec) -> String { +pub fn render_operators_file(token: &str, domain: &Domain) -> String { use crate::context::{environment, operator_entry, OperatorsContext}; - let name = domain.name_with_token(token); + let name = domain.full_name(token); let dom = domain_name(&name); let supported = Term::operators_for_terms(domain.terms); let is_supported = |op: &str| supported.contains(&op); @@ -169,12 +169,12 @@ pub fn render_operators_file(token: &str, domain: &DomainSpec) -> String { /// Body for a domain's _aggregates.sql, or None if not ord-capable. /// Port of `render_aggregates_file`. -pub fn render_aggregates_file(token: &str, domain: &DomainSpec) -> Option { +pub fn render_aggregates_file(token: &str, domain: &Domain) -> Option { use crate::context::{environment, AggregatesContext, AGGREGATE_OPS}; if !is_ord_capable(domain.terms) { return None; } - let name = domain.name_with_token(token); + let name = domain.full_name(token); let dom = domain_name(&name); let ctx = AggregatesContext { requires: vec![ @@ -203,11 +203,11 @@ use crate::writer::{ /// Regenerate every generated file for one type into `out_dir`. /// Port of `generate_type`. Returns the written paths. -pub fn generate_type(spec: &ScalarSpec, out_dir: &Path) -> Result, WriteError> { - let token = spec.token; +pub fn generate_type(spec: &DomainFamily, out_dir: &Path) -> Result, WriteError> { + let token = spec.name; let mut targets = vec![out_dir.join(format!("{token}_types.sql"))]; for d in spec.domains { - let name = d.name_with_token(token); + let name = d.full_name(token); targets.push(out_dir.join(format!("{name}_functions.sql"))); targets.push(out_dir.join(format!("{name}_operators.sql"))); if is_ord_capable(d.terms) { @@ -224,7 +224,7 @@ pub fn generate_type(spec: &ScalarSpec, out_dir: &Path) -> Result, written.push(types_path); for d in spec.domains { - let name = d.name_with_token(token); + let name = d.full_name(token); let fn_path = out_dir.join(format!("{name}_functions.sql")); write_generated_file(&fn_path, &render_functions_file(token, d))?; written.push(fn_path); @@ -248,7 +248,7 @@ pub fn generate_type(spec: &ScalarSpec, out_dir: &Path) -> Result, /// (`eql_domains::INT4_VALUES` / `INT2_VALUES`), read directly by the SQLx tests. pub fn generate_all(out_root: &Path) -> Result { for spec in eql_domains::CATALOG { - let token = spec.token; + let token = spec.name; let out_dir = out_root.join(V3_SCALARS_DIR).join(token); let written = generate_type(spec, &out_dir)?; @@ -258,7 +258,7 @@ pub fn generate_all(out_root: &Path) -> Result { } println!("generated {} files for {token}", written.len()); } - let tokens: Vec<&str> = eql_domains::CATALOG.iter().map(|s| s.token).collect(); + let tokens: Vec<&str> = eql_domains::CATALOG.iter().map(|s| s.name).collect(); println!( "codegen: ok ({} types: {})", tokens.len(), @@ -272,18 +272,18 @@ mod tests { use super::*; use eql_domains::CATALOG; - fn spec(token: &str) -> &'static ScalarSpec { + fn spec(token: &str) -> &'static DomainFamily { CATALOG .iter() - .find(|s| s.token == token) + .find(|s| s.name == token) .expect("catalog token") } - fn domain<'a>(spec: &'a ScalarSpec, suffix: &str) -> &'a DomainSpec { + fn domain<'a>(spec: &'a DomainFamily, name: &str) -> &'a Domain { spec.domains .iter() - .find(|d| d.suffix == suffix) - .expect("domain suffix") + .find(|d| d.name == name) + .expect("domain name") } use crate::repo_root; @@ -305,12 +305,12 @@ mod tests { out } - fn rendered_for(token: &str, name: &str, spec: &ScalarSpec) -> String { + fn rendered_for(token: &str, name: &str, spec: &DomainFamily) -> String { if name == format!("{token}_types.sql") { return render_types_file(spec); } for d in spec.domains { - let full = d.name_with_token(token); + let full = d.full_name(token); if name == format!("{full}_functions.sql") { return render_functions_file(token, d); } @@ -337,7 +337,7 @@ mod tests { #[test] fn functions_render_supported_wrappers_and_unsupported_entries_from_catalog() { let s = spec("int4"); - let d = domain(s, "_eq"); + let d = domain(s, "eq"); let sql = render_functions_file("int4", d); assert!(sql.contains("CREATE FUNCTION eql_v3.eq(")); assert!(sql.contains("AS $$ SELECT")); @@ -466,7 +466,7 @@ mod tests { #[test] fn storage_functions_file_is_all_blockers() { let s = spec("int4"); - let sql = render_functions_file(s.token, domain(s, "")); + let sql = render_functions_file(s.name, domain(s, "")); assert_eq!(sql.matches("CREATE FUNCTION").count(), 44); assert!(!sql.contains("SET search_path")); assert_eq!(sql.matches("LANGUAGE plpgsql").count(), 44); @@ -480,7 +480,7 @@ mod tests { #[test] fn eq_functions_file_counts() { let s = spec("int4"); - let sql = render_functions_file(s.token, domain(s, "_eq")); + let sql = render_functions_file(s.name, domain(s, "eq")); assert_eq!(sql.matches("CREATE FUNCTION").count(), 45); assert!(sql.contains("CREATE FUNCTION eql_v3.eq_term(a eql_v3.int4_eq)")); assert!(sql.contains("RETURNS eql_v3.hmac_256")); @@ -496,7 +496,7 @@ mod tests { #[test] fn ore_functions_file_counts() { let s = spec("int4"); - let sql = render_functions_file(s.token, domain(s, "_ord")); + let sql = render_functions_file(s.name, domain(s, "ord")); assert_eq!(sql.matches("CREATE FUNCTION").count(), 45); assert!(sql.contains("CREATE FUNCTION eql_v3.ord_term(a eql_v3.int4_ord)")); assert!(sql.contains("RETURNS eql_v3.ore_block_256")); @@ -511,23 +511,23 @@ mod tests { #[test] fn operators_file_has_forty_four() { let s = spec("int4"); - let sql = render_operators_file(s.token, domain(s, "_eq")); + let sql = render_operators_file(s.name, domain(s, "eq")); assert_eq!(sql.matches("CREATE OPERATOR").count(), 44); } #[test] fn aggregates_file_only_for_ord_variants() { let s = spec("int4"); - assert!(render_aggregates_file(s.token, domain(s, "")).is_none()); - assert!(render_aggregates_file(s.token, domain(s, "_eq")).is_none()); - assert!(render_aggregates_file(s.token, domain(s, "_ord")).is_some()); - assert!(render_aggregates_file(s.token, domain(s, "_ord_ore")).is_some()); + assert!(render_aggregates_file(s.name, domain(s, "")).is_none()); + assert!(render_aggregates_file(s.name, domain(s, "eq")).is_none()); + assert!(render_aggregates_file(s.name, domain(s, "ord")).is_some()); + assert!(render_aggregates_file(s.name, domain(s, "ord_ore")).is_some()); } #[test] fn aggregates_file_carries_min_and_max_and_requires() { let s = spec("int4"); - let sql = render_aggregates_file(s.token, domain(s, "_ord")).unwrap(); + let sql = render_aggregates_file(s.name, domain(s, "ord")).unwrap(); assert_eq!(sql.matches("CREATE FUNCTION").count(), 2); assert_eq!(sql.matches("CREATE AGGREGATE").count(), 2); assert!(sql.contains("eql_v3.min_sfunc")); @@ -540,20 +540,20 @@ mod tests { #[test] fn ordered_files_byte_identical_modulo_typename() { let s = spec("int4"); - let ord = domain(s, "_ord"); - let ore = domain(s, "_ord_ore"); + let ord = domain(s, "ord"); + let ore = domain(s, "ord_ore"); let norm = |sql: String| sql.replace("int4_ord_ore", "T").replace("int4_ord", "T"); assert_eq!( - norm(render_functions_file(s.token, ord)), - norm(render_functions_file(s.token, ore)) + norm(render_functions_file(s.name, ord)), + norm(render_functions_file(s.name, ore)) ); assert_eq!( - norm(render_operators_file(s.token, ord)), - norm(render_operators_file(s.token, ore)) + norm(render_operators_file(s.name, ord)), + norm(render_operators_file(s.name, ore)) ); assert_eq!( - norm(render_aggregates_file(s.token, ord).unwrap()), - norm(render_aggregates_file(s.token, ore).unwrap()) + norm(render_aggregates_file(s.name, ord).unwrap()), + norm(render_aggregates_file(s.name, ore).unwrap()) ); } @@ -577,7 +577,7 @@ mod tests { fn inlinable_functions_have_no_set_search_path() { let s = spec("int4"); // Extractors and wrappers (eq/ord functions files) are inlinable SQL. - for suffix in ["_eq", "_ord"] { + for suffix in ["eq", "ord"] { let sql = render_functions_file("int4", domain(s, suffix)); // Inlinable rows are the LANGUAGE sql ones; none may pin search_path. for block in sql.split("CREATE FUNCTION").skip(1) { @@ -594,7 +594,7 @@ mod tests { #[test] fn aggregate_state_functions_are_plpgsql_not_inlinable() { let s = spec("int4"); - let sql = render_aggregates_file("int4", domain(s, "_ord")).unwrap(); + let sql = render_aggregates_file("int4", domain(s, "ord")).unwrap(); assert_eq!(sql.matches("CREATE FUNCTION").count(), 2); assert_eq!( sql.matches("LANGUAGE plpgsql IMMUTABLE STRICT PARALLEL SAFE") @@ -625,7 +625,7 @@ mod tests { ); } - let sql = render_aggregates_file("int4", domain(s, "_ord")).unwrap(); + let sql = render_aggregates_file("int4", domain(s, "ord")).unwrap(); let function_like = sql.matches("CREATE FUNCTION").count() + sql.matches("CREATE AGGREGATE").count(); assert_eq!(sql.matches("--! @return").count(), function_like); @@ -668,11 +668,11 @@ mod tests { #[test] fn domain_block_escapes_quote_bearing_name() { use crate::context::domain_block; - use eql_domains::DomainSpec; + use eql_domains::Domain; let block = domain_block( "int4", - &DomainSpec { - suffix: "_q", + &Domain { + name: "q", terms: &[], }, ); diff --git a/crates/eql-codegen/src/main.rs b/crates/eql-codegen/src/main.rs index a02e43fff..336b0ac04 100644 --- a/crates/eql-codegen/src/main.rs +++ b/crates/eql-codegen/src/main.rs @@ -10,7 +10,7 @@ fn main() -> ExitCode { // fixtures-all and matrix-inventory enumeration. if args.len() == 2 && args[1] == "list-types" { for spec in eql_domains::CATALOG { - println!("{}", spec.token); + println!("{}", spec.name); } return ExitCode::SUCCESS; } diff --git a/crates/eql-codegen/tests/parity.rs b/crates/eql-codegen/tests/parity.rs index 7fde8bcac..13c2eb895 100644 --- a/crates/eql-codegen/tests/parity.rs +++ b/crates/eql-codegen/tests/parity.rs @@ -81,7 +81,7 @@ fn reference_dirs_match_catalog_tokens() { let refs = reference_tokens(&root); let catalog: BTreeSet = eql_domains::CATALOG .iter() - .map(|s| s.token.to_string()) + .map(|s| s.name.to_string()) .collect(); assert_eq!( refs, catalog, diff --git a/crates/eql-domains/src/lib.rs b/crates/eql-domains/src/lib.rs index a5d872d19..6c3494868 100644 --- a/crates/eql-domains/src/lib.rs +++ b/crates/eql-domains/src/lib.rs @@ -196,22 +196,23 @@ pub enum Fixture { Float(&'static str), } -/// One generated public domain: a suffix appended to the type token and the -/// fixed index terms it carries. Suffix `""` is the storage-only domain. +/// 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. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct DomainSpec { - pub suffix: &'static str, +pub struct Domain { + pub name: &'static str, pub terms: &'static [Term], } -/// A scalar encrypted-domain type: its SQL token, native Rust type, generated +/// A scalar encrypted-domain type: its SQL `name`, native Rust type, generated /// domains, and fixture plaintext list. The Rust analogue of one `*.toml`. /// (`domain_name`/`is_eq_only` are impl'd in `spec`.) #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ScalarSpec { - pub token: &'static str, +pub struct DomainFamily { + pub name: &'static str, pub kind: ScalarKind, - pub domains: &'static [DomainSpec], + pub domains: &'static [Domain], pub fixtures: &'static [Fixture], } @@ -242,21 +243,21 @@ macro_rules! fixtures { /// Domains shared by every ordered-integer scalar, in manifest file order: /// storage (no terms), `_eq` (hm), `_ord_ore` (ore), `_ord` (ore). -const ORDERED_INT_DOMAINS: &[DomainSpec] = &[ - DomainSpec { - suffix: "", +const ORDERED_INT_DOMAINS: &[Domain] = &[ + Domain { + name: "", terms: &[], }, - DomainSpec { - suffix: "_eq", + Domain { + name: "eq", terms: &[Term::Hm], }, - DomainSpec { - suffix: "_ord_ore", + Domain { + name: "ord_ore", terms: &[Term::Ore], }, - DomainSpec { - suffix: "_ord", + Domain { + name: "ord", terms: &[Term::Ore], }, ]; @@ -270,13 +271,13 @@ const ORDERED_INT_DOMAINS: &[DomainSpec] = &[ /// so a future non-orderable scalar (e.g. a hash-only type) can reuse it without /// reconstructing the shape. #[allow(dead_code)] -const EQ_ONLY_DOMAINS: &[DomainSpec] = &[ - DomainSpec { - suffix: "", +const EQ_ONLY_DOMAINS: &[Domain] = &[ + Domain { + name: "", terms: &[], }, - DomainSpec { - suffix: "_eq", + Domain { + name: "eq", terms: &[Term::Hm], }, ]; @@ -340,22 +341,22 @@ 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: ScalarSpec = ScalarSpec { - token: "int4", +const INT4: DomainFamily = DomainFamily { + name: "int4", kind: ScalarKind::I32, domains: ORDERED_INT_DOMAINS, fixtures: INT4_FIXTURES, }; -const INT2: ScalarSpec = ScalarSpec { - token: "int2", +const INT2: DomainFamily = DomainFamily { + name: "int2", kind: ScalarKind::I16, domains: ORDERED_INT_DOMAINS, fixtures: INT2_FIXTURES, }; -const INT8: ScalarSpec = ScalarSpec { - token: "int8", +const INT8: DomainFamily = DomainFamily { + name: "int8", kind: ScalarKind::I64, domains: ORDERED_INT_DOMAINS, fixtures: INT8_FIXTURES, @@ -369,8 +370,8 @@ const INT8: ScalarSpec = ScalarSpec { /// `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). -pub const DATE: ScalarSpec = ScalarSpec { - token: "date", +pub const DATE: DomainFamily = DomainFamily { + name: "date", kind: ScalarKind::Date, domains: ORDERED_INT_DOMAINS, fixtures: DATE_FIXTURES, @@ -386,8 +387,8 @@ pub const DATE: ScalarSpec = ScalarSpec { /// 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). -pub const TIMESTAMPTZ: ScalarSpec = ScalarSpec { - token: "timestamptz", +pub const TIMESTAMPTZ: DomainFamily = DomainFamily { + name: "timestamptz", kind: ScalarKind::Timestamptz, domains: ORDERED_INT_DOMAINS, fixtures: TIMESTAMPTZ_FIXTURES, @@ -405,8 +406,8 @@ pub const TIMESTAMPTZ: ScalarSpec = ScalarSpec { /// `NUMERIC.fixtures` directly to parse the decimal strings into /// `rust_decimal::Decimal` at runtime (the catalog stays zero-dep: no /// `rust_decimal`). -pub const NUMERIC: ScalarSpec = ScalarSpec { - token: "numeric", +pub const NUMERIC: DomainFamily = DomainFamily { + name: "numeric", kind: ScalarKind::Numeric, domains: ORDERED_INT_DOMAINS, fixtures: NUMERIC_FIXTURES, @@ -422,41 +423,41 @@ pub const NUMERIC: ScalarSpec = ScalarSpec { /// claim; it simply never wins because `Hm` precedes it (Option 1, catalog /// ordering). Integer kinds keep `[Ore]`-only `_ord` domains — ORE equality is /// lossless for them. -const TEXT_DOMAINS: &[DomainSpec] = &[ - DomainSpec { - suffix: "", +const TEXT_DOMAINS: &[Domain] = &[ + Domain { + name: "", terms: &[], }, - DomainSpec { - suffix: "_eq", + Domain { + name: "eq", terms: &[Term::Hm], }, - DomainSpec { - suffix: "_match", + Domain { + name: "match", terms: &[Term::Bloom], }, - DomainSpec { - suffix: "_ord_ore", + Domain { + name: "ord_ore", terms: &[Term::Hm, Term::Ore], }, - DomainSpec { - suffix: "_ord", + Domain { + name: "ord", terms: &[Term::Hm, Term::Ore], }, - DomainSpec { - suffix: "_search", + Domain { + name: "search", terms: &[Term::Hm, Term::Ore, Term::Bloom], }, ]; -/// Storage-only domains: a single term-less domain (suffix `""`). The canonical +/// Storage-only domains: a single term-less domain (name `""`). The canonical /// shape for an **encryption-only** scalar — encrypted at rest, decrypted by the /// proxy, never searched server-side. No `_eq`/`_ord`, so no SEM index term and /// no comparison surface (every operator on the domain is a blocker). Used by /// `bool`, whose two-value cardinality makes any searchable index a plaintext /// leak. Validated as a known-valid shape by `every_type_uses_a_known_domain_shape`. -const STORAGE_ONLY_DOMAINS: &[DomainSpec] = &[DomainSpec { - suffix: "", +const STORAGE_ONLY_DOMAINS: &[Domain] = &[Domain { + name: "", terms: &[], }]; @@ -473,8 +474,8 @@ const BOOL_FIXTURES: &[Fixture] = fixtures!(bool; false, true); /// 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). -pub const BOOL: ScalarSpec = ScalarSpec { - token: "bool", +pub const BOOL: DomainFamily = DomainFamily { + name: "bool", kind: ScalarKind::Bool, domains: STORAGE_ONLY_DOMAINS, fixtures: BOOL_FIXTURES, @@ -509,8 +510,8 @@ const TEXT_FIXTURES: &[Fixture] = fixtures!(text; /// `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). -pub const TEXT: ScalarSpec = ScalarSpec { - token: "text", +pub const TEXT: DomainFamily = DomainFamily { + name: "text", kind: ScalarKind::Text, domains: TEXT_DOMAINS, fixtures: TEXT_FIXTURES, @@ -546,8 +547,8 @@ const FLOAT8_FIXTURES: &[Fixture] = fixtures!(float; /// (`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`. -pub const FLOAT4: ScalarSpec = ScalarSpec { - token: "float4", +pub const FLOAT4: DomainFamily = DomainFamily { + name: "float4", kind: ScalarKind::F32, domains: ORDERED_INT_DOMAINS, fixtures: FLOAT4_FIXTURES, @@ -556,8 +557,8 @@ pub const FLOAT4: ScalarSpec = ScalarSpec { /// `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`. -pub const FLOAT8: ScalarSpec = ScalarSpec { - token: "float8", +pub const FLOAT8: DomainFamily = DomainFamily { + name: "float8", kind: ScalarKind::F64, domains: ORDERED_INT_DOMAINS, fixtures: FLOAT8_FIXTURES, @@ -565,7 +566,7 @@ pub const FLOAT8: ScalarSpec = ScalarSpec { /// The scalar catalog — the single source of truth. Order is significant (it /// drives generation order). New types are appended as their SQL surface lands. -pub const CATALOG: &[ScalarSpec] = &[ +pub const CATALOG: &[DomainFamily] = &[ INT4, INT2, INT8, @@ -593,7 +594,7 @@ macro_rules! int_values { #[doc = concat!("Distinct plaintext fixture values for `", stringify!($spec), "`, ")] #[doc = "materialised from its `CATALOG` row (see `int_values!`)."] pub const $name: &[$ty] = { - const SPEC: ScalarSpec = $spec; + const SPEC: DomainFamily = $spec; const N: usize = SPEC.fixtures.len(); const ARR: [$ty; N] = { let mut out = [0 as $ty; N]; @@ -640,7 +641,7 @@ macro_rules! text_values { #[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: ScalarSpec = $spec; + const SPEC: DomainFamily = $spec; const N: usize = SPEC.fixtures.len(); const ARR: [&'static str; N] = { let mut out = [""; N]; diff --git a/crates/eql-domains/src/proptest_invariants.rs b/crates/eql-domains/src/proptest_invariants.rs index 8c0e689bd..401b45214 100644 --- a/crates/eql-domains/src/proptest_invariants.rs +++ b/crates/eql-domains/src/proptest_invariants.rs @@ -123,7 +123,7 @@ fn eq_only_specs_have_no_ordering_operators() { assert!( !ops.iter().any(|o| matches!(*o, "<" | "<=" | ">" | ">=")), "eq-only spec {} exposes an ordering operator on {}", - spec.token, + spec.name, spec.domain_name(dom) ); } diff --git a/crates/eql-domains/src/spec.rs b/crates/eql-domains/src/spec.rs index 8d97dc453..39b766348 100644 --- a/crates/eql-domains/src/spec.rs +++ b/crates/eql-domains/src/spec.rs @@ -1,51 +1,57 @@ -//! Inherent impls for [`ScalarSpec`] — the per-type helpers `domain_name` -//! (token + suffix) and `is_eq_only` (no `_ord` domain). Definitions for -//! [`ScalarSpec`] and [`DomainSpec`] live in `lib.rs`. +//! Inherent impls for [`DomainFamily`] — the per-type helpers `domain_name` +//! (family-name + `_` + domain-name) and `is_eq_only` (no `ord` domain). +//! Definitions for [`DomainFamily`] and [`Domain`] live in `lib.rs`. -use crate::{DomainSpec, ScalarSpec}; +use crate::{Domain, DomainFamily}; -impl DomainSpec { - /// The full (unqualified) domain name for this domain under `token`: - /// `token` + `suffix` (suffix `""` => bare token). The **single** source for - /// the token+suffix concatenation — codegen builds every domain name through - /// this, so the "domain name starts with the token" rule is structural. - pub fn name_with_token(&self, token: &str) -> String { - format!("{token}{}", self.suffix) +impl Domain { + /// The full (unqualified) domain name for this domain under `family_name`: + /// the family name joined to the bare domain name with a `_` separator (an + /// empty domain name => the bare family name). The **single** site that owns + /// the `_` join — codegen builds every domain name through this, so the + /// "domain name starts with the family name" rule is structural. + pub fn full_name(&self, family_name: &str) -> String { + if self.name.is_empty() { + family_name.to_string() + } else { + format!("{family_name}_{}", self.name) + } } } -impl ScalarSpec { - /// The fully-qualified domain name: `token` + `suffix`. Makes the old - /// "domain name must start with the token" validation structural. - pub fn domain_name(&self, domain: &DomainSpec) -> String { - domain.name_with_token(self.token) +impl DomainFamily { + /// The fully-qualified domain name: family-name + `_` + domain-name. Makes + /// the old "domain name must start with the family name" validation + /// structural. + pub fn domain_name(&self, domain: &Domain) -> String { + domain.full_name(self.name) } - /// 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. + /// 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") + !self.domains.iter().any(|d| d.name == "ord") } /// True when this type is **storage-only / encryption-only**: it declares a - /// single term-less domain (the bare-token storage domain) and no comparison - /// domain (`_eq`/`_ord`/`_match`/…). The shape for a scalar encrypted at rest - /// but never searched server-side (e.g. `bool`, whose two-value cardinality - /// makes any searchable index a plaintext leak). Stricter than - /// `is_eq_only()` — a storage-only type is also `is_eq_only()` (no `_ord`), - /// but has no `_eq` either. + /// single term-less domain (the bare-family-name storage domain) and no + /// comparison domain (`eq`/`ord`/`match`/…). The shape for a scalar encrypted + /// at rest but never searched server-side (e.g. `bool`, whose two-value + /// cardinality makes any searchable index a plaintext leak). Stricter than + /// `is_eq_only()` — a storage-only type is also `is_eq_only()` (no `ord`), + /// but has no `eq` either. pub fn is_storage_only(&self) -> bool { self.domains.len() == 1 - && self.domains[0].suffix.is_empty() + && self.domains[0].name.is_empty() && self.domains[0].terms.is_empty() } - /// The domain on this scalar with the given `suffix`, or `None`. Centralizes - /// the `domains.iter().find(|d| d.suffix == s)` lookup duplicated across the - /// catalog tests and the SQLx harness. - pub fn domain_by_suffix(&self, suffix: &str) -> Option<&DomainSpec> { - self.domains.iter().find(|d| d.suffix == suffix) + /// The domain on this scalar with the given (bare) `name`, or `None`. + /// Centralizes the `domains.iter().find(|d| d.name == n)` lookup duplicated + /// across the catalog tests and the SQLx harness. + pub fn domain_by_name(&self, name: &str) -> Option<&Domain> { + self.domains.iter().find(|d| d.name == name) } } diff --git a/crates/eql-domains/src/tests.rs b/crates/eql-domains/src/tests.rs index 7bd11cac9..be5cfa34f 100644 --- a/crates/eql-domains/src/tests.rs +++ b/crates/eql-domains/src/tests.rs @@ -1,7 +1,7 @@ //! Unit tests for the scalar/term catalog. Kept as one `#[cfg(test)]` module //! (declared from `lib.rs`) rather than co-located with each impl file because //! `rust_tests` spans `BoundedIntKind` + `ScalarKind` + `Fixture` + -//! `ScalarSpec`. Each inner module imports the crate-root catalog with +//! `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`). @@ -174,7 +174,7 @@ mod rust_tests { spec.kind.is_int(), "pivot sentinel {fixture:?} on non-integer kind {:?} (token `{}`)", spec.kind, - spec.token, + spec.name, ); } } @@ -194,11 +194,11 @@ mod rust_tests { #[test] fn is_eq_only_detects_absence_of_ord_domains() { - let int4 = CATALOG.iter().find(|s| s.token == "int4").unwrap(); + let int4 = CATALOG.iter().find(|s| s.name == "int4").unwrap(); assert!(!int4.is_eq_only(), "int4 is ordered"); - let date = CATALOG.iter().find(|s| s.token == "date").unwrap(); + let date = CATALOG.iter().find(|s| s.name == "date").unwrap(); assert!(!date.is_eq_only(), "date is ordered"); - let ts = CATALOG.iter().find(|s| s.token == "timestamptz").unwrap(); + let ts = CATALOG.iter().find(|s| s.name == "timestamptz").unwrap(); assert!( !ts.is_eq_only(), "timestamptz is now ordered (native 12-block ORE, comparator generalized to N blocks)" @@ -207,8 +207,8 @@ mod rust_tests { // No catalog type is currently eq-only, so exercise `is_eq_only()`'s // positive path with a synthetic spec built on the retained // `EQ_ONLY_DOMAINS` shape (storage + `_eq`, no `_ord`). - let eq_only = ScalarSpec { - token: "synthetic_eq_only", + let eq_only = DomainFamily { + name: "synthetic_eq_only", kind: ScalarKind::Timestamptz, domains: EQ_ONLY_DOMAINS, fixtures: &[], @@ -566,16 +566,16 @@ mod fixture_tests { mod catalog_tests { use crate::*; - fn scalar(token: &str) -> &'static ScalarSpec { + fn scalar(token: &str) -> &'static DomainFamily { CATALOG .iter() - .find(|s| s.token == token) + .find(|s| s.name == token) .unwrap_or_else(|| panic!("{token} missing from CATALOG")) } #[test] fn catalog_has_all_tokens_in_order() { - let tokens: Vec<&str> = CATALOG.iter().map(|s| s.token).collect(); + let tokens: Vec<&str> = CATALOG.iter().map(|s| s.name).collect(); assert_eq!( tokens, vec![ @@ -600,7 +600,7 @@ mod catalog_tests { assert_eq!(b.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.suffix, d.terms)).collect(); + 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()); @@ -612,7 +612,7 @@ mod catalog_tests { // storage-only. assert!(b.is_eq_only()); assert!(b.is_storage_only()); - assert!(b.domain_by_suffix("_eq").is_none()); + 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)]); } @@ -624,9 +624,9 @@ mod catalog_tests { for s in CATALOG { assert_eq!( s.is_storage_only(), - s.token == "bool", + s.name == "bool", "{} storage-only classification is wrong", - s.token + s.name ); } } @@ -635,17 +635,17 @@ mod catalog_tests { fn text_spec_is_in_catalog() { let text = scalar("text"); assert_eq!(text.kind, ScalarKind::Text); - let suffixes: Vec<_> = text.domains.iter().map(|d| d.suffix).collect(); + let suffixes: Vec<_> = text.domains.iter().map(|d| d.name).collect(); assert_eq!( suffixes, - vec!["", "_eq", "_match", "_ord_ore", "_ord", "_search"] + vec!["", "eq", "match", "ord_ore", "ord", "search"] ); } #[test] fn text_match_domain_carries_only_bloom() { let text = scalar("text"); - let m = text.domains.iter().find(|d| d.suffix == "_match").unwrap(); + let m = text.domains.iter().find(|d| d.name == "match").unwrap(); assert_eq!(m.terms, &[Term::Bloom]); } @@ -674,26 +674,26 @@ mod catalog_tests { Term::extractor_for_operator(d.terms, op), Some("eq_term"), "text{} must resolve `{op}` to eq_term (exact hm), not ORE", - d.suffix + d.name ); } // And the payload requires hm for these domains. assert!( Term::term_json_keys(d.terms).contains(&"hm"), "text{} must require the `hm` payload key", - d.suffix + d.name ); } } #[test] - fn domain_by_suffix_finds_declared_suffixes() { + fn domain_by_name_finds_declared_names() { let text = scalar("text"); assert_eq!( - text.domain_by_suffix("_search").map(|d| d.suffix), - Some("_search") + text.domain_by_name("search").map(|d| d.name), + Some("search") ); - assert!(text.domain_by_suffix("_nope").is_none()); + assert!(text.domain_by_name("nope").is_none()); } #[test] @@ -702,7 +702,7 @@ mod catalog_tests { let search = text .domains .iter() - .find(|d| d.suffix == "_search") + .find(|d| d.name == "search") .expect("text must declare a _search domain"); assert_eq!( search.terms, @@ -792,40 +792,39 @@ mod catalog_tests { // the two-domain EQ-ONLY shape (storage + `_eq`), the one-domain // STORAGE-ONLY shape (storage only — encryption-only scalars like // `bool`), or the ORDERED shape plus a `_match` domain (text's Bloom - // containment). This catches accidental drift — a typo'd suffix, a wrong + // containment). This catches accidental drift — a typo'd domain name, a wrong // term, a dropped domain — without hardcoding which token gets which // shape (that is the catalog's job; the matrix dispatch and the inventory // snapshots are shape-aware). Subsumes the old per-type // `_maps_to_*_with_four_domains` / `_domain_terms_match_manifest` tests. let ordered: Vec<(&str, &[Term])> = vec![ ("", &[] as &[Term]), - ("_eq", &[Term::Hm][..]), - ("_ord_ore", &[Term::Ore][..]), - ("_ord", &[Term::Ore][..]), + ("eq", &[Term::Hm][..]), + ("ord_ore", &[Term::Ore][..]), + ("ord", &[Term::Ore][..]), ]; - let eq_only: Vec<(&str, &[Term])> = vec![("", &[] as &[Term]), ("_eq", &[Term::Hm][..])]; + let eq_only: Vec<(&str, &[Term])> = vec![("", &[] as &[Term]), ("eq", &[Term::Hm][..])]; let storage_only: Vec<(&str, &[Term])> = vec![("", &[] as &[Term])]; let ordered_match: Vec<(&str, &[Term])> = vec![ ("", &[] as &[Term]), - ("_eq", &[Term::Hm][..]), - ("_match", &[Term::Bloom][..]), - ("_ord_ore", &[Term::Ore][..]), - ("_ord", &[Term::Ore][..]), + ("eq", &[Term::Hm][..]), + ("match", &[Term::Bloom][..]), + ("ord_ore", &[Term::Ore][..]), + ("ord", &[Term::Ore][..]), ]; // text's current shape: equality is exact on the ordered domains (they // lead with `Hm`), plus a combined `_search` domain carrying all three // terms. `=`/`<>` route through `hm` on every eq-capable text domain. let text_search: Vec<(&str, &[Term])> = vec![ ("", &[] as &[Term]), - ("_eq", &[Term::Hm][..]), - ("_match", &[Term::Bloom][..]), - ("_ord_ore", &[Term::Hm, Term::Ore][..]), - ("_ord", &[Term::Hm, Term::Ore][..]), - ("_search", &[Term::Hm, Term::Ore, Term::Bloom][..]), + ("eq", &[Term::Hm][..]), + ("match", &[Term::Bloom][..]), + ("ord_ore", &[Term::Hm, Term::Ore][..]), + ("ord", &[Term::Hm, Term::Ore][..]), + ("search", &[Term::Hm, Term::Ore, Term::Bloom][..]), ]; for s in CATALOG { - let shape: Vec<(&str, &[Term])> = - s.domains.iter().map(|d| (d.suffix, d.terms)).collect(); + let shape: Vec<(&str, &[Term])> = s.domains.iter().map(|d| (d.name, d.terms)).collect(); assert!( shape == ordered || shape == eq_only @@ -833,7 +832,7 @@ mod catalog_tests { || shape == ordered_match || shape == text_search, "{} has an unrecognised domain shape: {shape:?}", - s.token + s.name ); } } @@ -850,7 +849,7 @@ mod catalog_tests { assert!( !is_eq_only, "{} is unexpectedly eq-only; no catalog type is eq-only currently", - s.token + s.name ); } } @@ -861,13 +860,13 @@ mod catalog_tests { // 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.token { + let expected = match s.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.token); + assert_eq!(s.kind, expected, "{} maps to the wrong kind", s.name); } } @@ -889,12 +888,12 @@ 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: &ScalarSpec, values: &[T]) { + fn check>(spec: &DomainFamily, values: &[T]) { assert_eq!( values.len(), spec.fixtures.len(), "{}: value count != fixture count", - spec.token + spec.name ); for (i, (v, f)) in values.iter().zip(spec.fixtures).enumerate() { assert_eq!( @@ -902,7 +901,7 @@ mod values_tests { f.numeric_value(spec.kind) .expect("integer scalar fixture resolves to a number"), "{}: value[{i}] does not match resolved fixture {f:?}", - spec.token + spec.name ); } } @@ -999,10 +998,10 @@ mod values_tests { mod float_tests { use crate::*; - fn scalar(token: &str) -> &'static ScalarSpec { + fn scalar(token: &str) -> &'static DomainFamily { CATALOG .iter() - .find(|s| s.token == token) + .find(|s| s.name == token) .unwrap_or_else(|| panic!("{token} missing from CATALOG")) } @@ -1010,8 +1009,8 @@ mod float_tests { fn float_specs_are_in_catalog_with_ordered_shape() { for token in ["float4", "float8"] { let s = scalar(token); - let suffixes: Vec<_> = s.domains.iter().map(|d| d.suffix).collect(); - assert_eq!(suffixes, vec!["", "_eq", "_ord_ore", "_ord"]); + let suffixes: Vec<_> = s.domains.iter().map(|d| d.name).collect(); + assert_eq!(suffixes, vec!["", "eq", "ord_ore", "ord"]); } assert_eq!(scalar("float4").kind, ScalarKind::F32); assert_eq!(scalar("float8").kind, ScalarKind::F64); @@ -1102,9 +1101,9 @@ mod invariant_tests { for d in s.domains { let name = s.domain_name(d); assert!( - name == s.token || name.starts_with(&format!("{}_", s.token)), + name == s.name || name.starts_with(&format!("{}_", s.name)), "{name} does not start with token {}", - s.token + s.name ); } } @@ -1113,7 +1112,7 @@ mod invariant_tests { #[test] fn every_type_has_at_least_one_domain() { for s in CATALOG { - assert!(!s.domains.is_empty(), "{} has no domains", s.token); + assert!(!s.domains.is_empty(), "{} has no domains", s.name); } } @@ -1164,14 +1163,14 @@ mod invariant_tests { assert!( resolved.contains(&bk.min_value()), "{} fixtures missing MIN", - s.token + s.name ); assert!( resolved.contains(&bk.max_value()), "{} fixtures missing MAX", - s.token + s.name ); - assert!(resolved.contains(&0), "{} fixtures missing zero", s.token); + assert!(resolved.contains(&0), "{} fixtures missing zero", s.name); } } @@ -1181,7 +1180,7 @@ mod invariant_tests { 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.token); + panic!("{}: {f:?} duplicates {prev:?}", s.name); } } } @@ -1225,7 +1224,7 @@ mod invariant_tests { assert!( n >= lo && n <= hi, "{}: fixture {f:?} resolves to {n}, out of range [{lo}, {hi}]", - s.token + s.name ); } } @@ -1234,7 +1233,7 @@ mod invariant_tests { #[test] fn helper_outputs_match_for_known_domains() { // Cross-check the Term helpers against a known domain shape on int4. - let s = CATALOG.iter().find(|s| s.token == "int4").unwrap(); + let s = CATALOG.iter().find(|s| s.name == "int4").unwrap(); // storage domain: no terms. assert_eq!(Term::role_for_terms(s.domains[0].terms), Role::Storage); assert!(Term::operators_for_terms(s.domains[0].terms).is_empty()); diff --git a/crates/eql-tests-macros/src/lib.rs b/crates/eql-tests-macros/src/lib.rs index a722dc86a..c072a4609 100644 --- a/crates/eql-tests-macros/src/lib.rs +++ b/crates/eql-tests-macros/src/lib.rs @@ -58,10 +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. -fn spec_for_token(token: &str) -> &'static eql_domains::ScalarSpec { +fn spec_for_token(token: &str) -> &'static eql_domains::DomainFamily { eql_domains::CATALOG .iter() - .find(|s| s.token == token) + .find(|s| s.name == token) .unwrap_or_else(|| panic!("scalar token `{token}` not in eql-domains::CATALOG")) } @@ -140,7 +140,7 @@ fn has_search_token(token: &str) -> bool { spec_for_token(token) .domains .iter() - .any(|d| d.suffix == "_search") + .any(|d| d.name == "search") } /// The comma-separated list (optional trailing comma). diff --git a/docs/reference/adding-a-scalar-encrypted-domain-type.md b/docs/reference/adding-a-scalar-encrypted-domain-type.md index dec932181..8b68b0bfb 100644 --- a/docs/reference/adding-a-scalar-encrypted-domain-type.md +++ b/docs/reference/adding-a-scalar-encrypted-domain-type.md @@ -20,7 +20,7 @@ needs and is fully self-contained (CI gates this — see §6). The whole SQL surface is **generated** from a single Rust source of truth: the `CATALOG` const in [`crates/eql-domains/src/lib.rs`](../../crates/eql-domains/src/lib.rs), rendered by the [`eql-codegen`](../../crates/eql-codegen/) crate. There is no -TOML manifest and no Python — adding a type is adding one `ScalarSpec` row, +TOML manifest and no Python — adding a type is adding one `DomainFamily` row, validated by the compiler plus catalog `#[test]`s. The reference type is `eql_v3.int4`; `eql_v3.text` is the worked non-integer example (ordered + equality + a `match` capability via the `Bloom` term); `eql_v3.bool` is the @@ -34,7 +34,7 @@ materializer (see §7). To add a scalar type `` (e.g. `int8`), with Rust type `` (e.g. `i64`): -1. **Add a `ScalarSpec` row to `eql_domains::CATALOG`** — `token`, `kind`, +1. **Add a `DomainFamily` row to `eql_domains::CATALOG`** — `name`, `kind`, `domains`, `fixtures` (§2). If the type needs a new scalar width, add a `ScalarKind` variant first; if it needs new term behaviour, that goes in the `Term` enum's `impl`, never in catalog data. @@ -72,20 +72,20 @@ Hand-written SQL beyond the fixed surface goes in --- -## 2. The catalog row (`ScalarSpec`) +## 2. The catalog row (`DomainFamily`) -A scalar type is one `ScalarSpec` row in +A scalar type is one `DomainFamily` row in [`crates/eql-domains/src/lib.rs`](../../crates/eql-domains/src/lib.rs): ```rust -ScalarSpec { - token: "int4", +DomainFamily { + name: "int4", kind: ScalarKind::I32, domains: &[ - DomainSpec { suffix: "", terms: &[] }, - DomainSpec { suffix: "_eq", terms: &[Term::Hm] }, - DomainSpec { suffix: "_ord_ore", terms: &[Term::Ore] }, - DomainSpec { suffix: "_ord", terms: &[Term::Ore] }, + Domain { name: "", terms: &[] }, + Domain { name: "eq", terms: &[Term::Hm] }, + Domain { name: "ord_ore", terms: &[Term::Ore] }, + Domain { name: "ord", terms: &[Term::Ore] }, ], fixtures: INT4_FIXTURES, } @@ -94,8 +94,10 @@ ScalarSpec { The fields, all enforced by the type system and the catalog `#[test]`s rather than a runtime validator: -- **`token`** — the type token (`int4`); supplies `` everywhere. Each - domain's full name is `token` + `suffix` (`ScalarSpec::domain_name`), pinned by +- **`name`** — the type name (`int4`); supplies `` everywhere. Each domain's + full name is the family `name` + `_` + the domain `name` + (`DomainFamily::domain_name`); codegen owns the `_` join (`Domain::full_name`), + and an empty domain `name` yields the bare family name. Pinned by `every_domain_name_starts_with_its_token`. - **`kind`** — a `ScalarKind` (`I16` / `I32` / `I64` / `Numeric` / `Text` / `Jsonb` / `Date` / `Timestamptz`), carrying the Rust type name. Only the @@ -110,10 +112,10 @@ than a runtime validator: `BoundedIntKind` variant** (rust-type name, `MIN`/`MAX`/zero symbols, bounds) plus its `ScalarKind` variant and `as_bounded_int` arm, with unit tests over the `impl` methods. -- **`domains`** — a non-empty `&[DomainSpec]` (pinned by - `every_type_has_at_least_one_domain`), each a `suffix` + the fixed `&[Term]` it - carries. The storage domain is `suffix: ""` with no terms; `_eq => [Term::Hm]`; - `_ord` and `_ord_ore => [Term::Ore]`. A `DomainSpec` declares nothing else — no +- **`domains`** — a non-empty `&[Domain]` (pinned by + `every_type_has_at_least_one_domain`), each a bare `name` + the fixed `&[Term]` it + carries. The storage domain is `name: ""` with no terms; `eq => [Term::Hm]`; + `ord` and `ord_ore => [Term::Ore]`. A `Domain` declares nothing else — no extractor names, no operator lists, no REQUIRE edges. Every behavioural fact comes from the `Term` enum. - **`fixtures`** — the type's plaintext fixture list (see below). @@ -739,11 +741,11 @@ preserved by the name patterns.) Stages, in order (`generate_all` → `generate_type`): 1. **Read the catalog.** `eql_domains::CATALOG` is the in-binary source of truth - — a `&[ScalarSpec]`. There is no parse/validate stage at generation time: the + — a `&[DomainFamily]`. There is no parse/validate stage at generation time: the catalog is validated at compile time (an undefined `Term` or unknown `ScalarKind` does not compile) and by the catalog `#[test]`s, so the data is already well-formed by the time `generate_all` runs. -2. **Resolve terms.** For each `DomainSpec`, the `Term` enum's `impl` methods +2. **Resolve terms.** For each `Domain`, the `Term` enum's `impl` methods supply the extractor name, return type, JSON envelope key, supported operators, and the SQL `-- REQUIRE:` edges those terms imply (`Term::operators_for_terms`, `term_json_keys`, `term_requires`, @@ -876,8 +878,8 @@ deliberately offers no search surface at all. What makes it storage-only: - **One term-less domain.** Its catalog row uses `STORAGE_ONLY_DOMAINS` — a - single `DomainSpec { suffix: "", terms: &[] }`. No `_eq`, no `_ord`, no SEM - index term. `ScalarSpec::is_storage_only()` recognises this shape (a single + single `Domain { name: "", terms: &[] }`. No `_eq`, no `_ord`, no SEM + index term. `DomainFamily::is_storage_only()` recognises this shape (a single term-less storage domain); it is *also* `is_eq_only()` (no `_ord`), so the harness checks storage-only **first**. - **Generator: no changes needed.** The SQL generator already handles a diff --git a/tests/sqlx/src/scalar_domains.rs b/tests/sqlx/src/scalar_domains.rs index e3aa3e180..ca5742cb0 100644 --- a/tests/sqlx/src/scalar_domains.rs +++ b/tests/sqlx/src/scalar_domains.rs @@ -1129,8 +1129,8 @@ impl Variant { pub fn terms_for(self, token: &str) -> &'static [Term] { CATALOG .iter() - .find(|s| s.token == token) - .and_then(|s| s.domain_by_suffix(self.suffix())) + .find(|s| s.name == token) + .and_then(|s| s.domain_by_name(self.suffix().trim_start_matches('_'))) .map(|d| d.terms) .unwrap_or_else(|| { panic!( @@ -1146,8 +1146,8 @@ impl Variant { pub fn is_declared_for(self, token: &str) -> bool { CATALOG .iter() - .find(|s| s.token == token) - .and_then(|s| s.domain_by_suffix(self.suffix())) + .find(|s| s.name == token) + .and_then(|s| s.domain_by_name(self.suffix().trim_start_matches('_'))) .is_some() } @@ -1315,20 +1315,20 @@ combos with distinct dom_names", pub fn token_has_bloom_term(token: &str) -> bool { CATALOG .iter() - .find(|s| s.token == token) + .find(|s| s.name == token) .map(|s| s.domains.iter().any(|d| d.terms.contains(&Term::Bloom))) .unwrap_or(false) } /// True when scalar `token` is **storage-only / encryption-only** (a single /// term-less domain, no `_eq`/`_ord`/`_match`) — e.g. `bool`. Catalog-derived -/// via `ScalarSpec::is_storage_only`. Such a type's fixture is encrypted with no +/// via `DomainFamily::is_storage_only`. Such a type's fixture is encrypted with no /// search index, so its payload carries only `{v,i,c}` (no `hm`/`ob`/`bf`); the /// fixture-shape assertions branch on this. pub fn token_is_storage_only(token: &str) -> bool { CATALOG .iter() - .find(|s| s.token == token) + .find(|s| s.name == token) .map(|s| s.is_storage_only()) .unwrap_or(false) } @@ -1548,18 +1548,18 @@ mod catalog_resolution_tests { let suffix = variant.suffix(); // A variant is instantiated for a token iff that token declares // the suffix; only assert those pairs. - if let Some(d) = spec.domain_by_suffix(suffix) { + if let Some(d) = spec.domain_by_name(suffix.trim_start_matches('_')) { assert!( - variant.is_declared_for(spec.token), + variant.is_declared_for(spec.name), "{}{} declared in CATALOG but is_declared_for is false", - spec.token, + spec.name, suffix ); assert_eq!( - variant.terms_for(spec.token), + variant.terms_for(spec.name), d.terms, "{}{} term set drift between Variant and CATALOG", - spec.token, + spec.name, suffix ); } @@ -1695,8 +1695,8 @@ mod oracle_inventory_tests { // no `_ord` domain and must short-circuit to false, not panic. let ordered: Vec<&str> = CATALOG .iter() - .filter(|s| Variant::Ord.is_declared_for(s.token) && Variant::Ord.supports_ord(s.token)) - .map(|s| s.token) + .filter(|s| Variant::Ord.is_declared_for(s.name) && Variant::Ord.supports_ord(s.name)) + .map(|s| s.name) .collect(); assert_eq!( ordered, @@ -1729,8 +1729,8 @@ mod oracle_inventory_tests { // scalar with no `_ord` domain (bool), so short-circuit first. let ordered: Vec<&str> = CATALOG .iter() - .filter(|s| Variant::Ord.is_declared_for(s.token) && Variant::Ord.supports_ord(s.token)) - .map(|s| s.token) + .filter(|s| Variant::Ord.is_declared_for(s.name) && Variant::Ord.supports_ord(s.name)) + .map(|s| s.name) .collect(); // Keep in lockstep with the fixture_oracle_suite! / e2e_oracle_suite! // instantiation lists. diff --git a/tests/sqlx/src/scalar_types.rs b/tests/sqlx/src/scalar_types.rs index 623adc2e1..b92e7aaaa 100644 --- a/tests/sqlx/src/scalar_types.rs +++ b/tests/sqlx/src/scalar_types.rs @@ -7,7 +7,7 @@ //! `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-domains::CATALOG` row -//! (`ScalarKind::is_temporal()` / `ScalarSpec::is_eq_only()`). A temporal +//! (`ScalarKind::is_temporal()` / `DomainFamily::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. diff --git a/tests/sqlx/tests/encrypted_domain/family/support.rs b/tests/sqlx/tests/encrypted_domain/family/support.rs index b4a0b9496..1e0599ce2 100644 --- a/tests/sqlx/tests/encrypted_domain/family/support.rs +++ b/tests/sqlx/tests/encrypted_domain/family/support.rs @@ -123,7 +123,7 @@ async fn placeholder_payload_casts_to_every_declared_domain(pool: PgPool) -> Res use eql_domains::CATALOG; for spec in CATALOG { for domain in spec.domains { - let sql_domain = format!("eql_v3.{}{}", spec.token, domain.suffix); + let sql_domain = format!("eql_v3.{}", spec.domain_name(domain)); let sql = format!("SELECT $1::jsonb::{sql_domain}"); sqlx::query(&sql) .bind(PLACEHOLDER_PAYLOAD) diff --git a/tests/sqlx/tests/generate_all_fixtures.rs b/tests/sqlx/tests/generate_all_fixtures.rs index eeb2409fe..130baf991 100644 --- a/tests/sqlx/tests/generate_all_fixtures.rs +++ b/tests/sqlx/tests/generate_all_fixtures.rs @@ -27,8 +27,8 @@ eql_tests::scalar_types!(fixture_dispatch); async fn generate_all() -> anyhow::Result<()> { let mut generated = 0usize; for spec in CATALOG { - eprintln!("Generating fixture eql_v3_{}...", spec.token); - generate_for_token(spec.token).await?; + eprintln!("Generating fixture eql_v3_{}...", spec.name); + generate_for_token(spec.name).await?; generated += 1; } assert!(generated > 0, "CATALOG is empty — nothing to generate"); From 697fa611b87b5f7062d34a3a56028090a37fdaf1 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Mon, 29 Jun 2026 11:03:01 +1000 Subject: [PATCH 2/2] refactor: finish catalog clarity (family_name, pay down suffix bridge) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the PR 2 catalog rename, completing the items the plan left as optional/deferred: - Rename the codegen-internal `token` parameter/field to `family_name` throughout (context.rs structs, generate.rs helpers, the four .j2 templates, test locals). Template values still substitute spec.name, so generated SQL is byte-identical (codegen:parity OK). - Pay down the accepted deferred debt in the SQLx harness: add Variant::name() returning the bare catalog key and drop the three suffix().trim_start_matches('_') bridges in favour of direct domain_by_name(variant.name()) lookups. - Add a committed dump.rs #[test] pinning the hand-re-derived `suffix` wire field ("", _eq, _ord_ore, _ord) — the one channel no other gate reads. - Doc/comment vocabulary touch-ups (CLAUDE.md, DEVELOPMENT.md, lib.rs, mise.toml, adding-a-scalar reference). Behavior-preserving: codegen:parity, types:check, test:matrix:inventory, test:crates all green. --- CLAUDE.md | 2 +- DEVELOPMENT.md | 2 +- crates/eql-codegen/src/context.rs | 12 +-- crates/eql-codegen/src/dump.rs | 17 +++ crates/eql-codegen/src/generate.rs | 102 +++++++++--------- .../eql-codegen/templates/aggregates.sql.j2 | 2 +- crates/eql-codegen/templates/functions.sql.j2 | 2 +- crates/eql-codegen/templates/operators.sql.j2 | 2 +- crates/eql-codegen/templates/types.sql.j2 | 4 +- crates/eql-domains/src/lib.rs | 3 +- crates/eql-domains/src/tests.rs | 56 ++++++---- .../adding-a-scalar-encrypted-domain-type.md | 2 +- mise.toml | 2 +- tests/sqlx/src/scalar_domains.rs | 20 +++- 14 files changed, 133 insertions(+), 95 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2b90eba7c..2023eab47 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,7 +74,7 @@ This is the **Encrypt Query Language (EQL)** - a PostgreSQL extension for search Adding a scalar encrypted-domain type is one row in the Rust catalog `eql-domains::CATALOG` (`crates/eql-domains/src/lib.rs`): a `DomainFamily` giving the type `name` (e.g. `int8`), its `ScalarKind` (the `kind` field), the `Domain`s mapping each generated (bare) domain name to its fixed index `Term`s (`eq => [Hm]`, `ord`/`ord_ore => [Ore]`), and the `Fixture` value list. Term capabilities are fixed in the `Term` enum's `impl` methods (with unit tests): `Hm` provides equality, and `Ore` provides equality plus ordering. There is no TOML manifest and no Python — the catalog is the source of truth, validated by the compiler (an undefined term or unknown scalar is a compile error) plus catalog `#[test]`s. `mise run build` runs `cargo run -p eql-codegen`, which regenerates the scalar SQL surface into `src/v3/scalars//` from `CATALOG` at the start of every build; that surface includes supported comparison wrappers plus blockers for native `jsonb` operators that would otherwise be reachable through domain fallback. `cargo run -p eql-codegen` regenerates every type at once (the same call `mise run build` uses; there is no per-type codegen task). The generated `*_types.sql` / `*_functions.sql` / `*_operators.sql` / `*_aggregates.sql` files are gitignored and never committed. The per-type plaintext fixture lists the SQLx matrix consumes are **not** a generated file — they are materialised from each `CATALOG` row at compile time as `eql_domains::INT4_VALUES` / `INT2_VALUES` (the `int_values!` macro) and read directly by `ScalarType::FIXTURE_VALUES`; a Rust source of truth no longer round-trips through a committed generated `.rs`. Generated SQL carries a `-- AUTOMATICALLY GENERATED FILE` header (the project-wide marker `docs:validate` greps on); change the catalog and rebuild, never hand-edit. Hand-written SQL beyond the fixed surface goes in `src/v3/scalars//_extensions.sql` with no auto-generated header and explicit `-- REQUIRE:` edges — that file IS committed. `jsonb` is out of scope for this scalar materializer. -**Adding a new encrypted-domain type: follow `docs/reference/adding-a-scalar-encrypted-domain-type.md`.** The mechanics are fixed for ordered scalar domains; the catalog row only declares the token, kind, domain suffixes, and terms. New term behavior belongs in the `Term` enum's `impl` methods in `crates/eql-domains/src` with tests, not in free-form catalog data. +**Adding a new encrypted-domain type: follow `docs/reference/adding-a-scalar-encrypted-domain-type.md`.** The mechanics are fixed for ordered scalar domains; the catalog row only declares the name, kind, bare domain names, and terms. New term behavior belongs in the `Term` enum's `impl` methods in `crates/eql-domains/src` with tests, not in free-form catalog data. Regeneration is deterministic: an identical `CATALOG` produces byte-identical SQL. If `mise run build` produces unexpected output, the change is in `crates/eql-domains/src` (the catalog/terms) or `crates/eql-codegen/src` (the renderers) — not in random run-to-run variation. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index ced04c4aa..b4dc112d2 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -338,7 +338,7 @@ regenerate the SQL surface. Follow the reference guide: [`docs/reference/adding-a-scalar-encrypted-domain-type.md`](./docs/reference/adding-a-scalar-encrypted-domain-type.md). The mechanics are fixed for ordered scalar domains; the catalog row only -declares the token, kind, domain suffixes, and terms. +declares the name, kind, bare domain names, and terms. A few footguns the generator exists to prevent — worth knowing when reading the output: diff --git a/crates/eql-codegen/src/context.rs b/crates/eql-codegen/src/context.rs index 2df23d725..b23caafee 100644 --- a/crates/eql-codegen/src/context.rs +++ b/crates/eql-codegen/src/context.rs @@ -68,14 +68,14 @@ pub struct DomainBlock { #[derive(serde::Serialize)] pub struct TypesContext { - pub token: String, + pub family_name: String, pub domains: Vec, } /// Build the per-domain block data (port of `render_domain_block`'s value logic, /// minus comment prose and the CHECK skeleton — those are template-resident). -pub fn domain_block(token: &str, domain: &Domain) -> DomainBlock { - let name = domain.full_name(token); +pub fn domain_block(family_name: &str, domain: &Domain) -> DomainBlock { + let name = domain.full_name(family_name); let mut keys: Vec = ENVELOPE_KEYS.iter().map(|k| sql_str(k)).collect(); for k in Term::term_json_keys(domain.terms) { @@ -134,7 +134,7 @@ pub enum FnEntry { #[derive(serde::Serialize)] pub struct FunctionsContext { pub requires: Vec, // dependency paths only; template emits "-- REQUIRE:" - pub token: String, + pub family_name: String, pub name: String, // full domain name (family-name + "_" + domain-name) pub dom: String, // schema-qualified domain, e.g. eql_v3.int4_eq pub domain_lit: String, // sql_str(dom), defensively escaped for the RAISE literal @@ -209,7 +209,7 @@ pub struct OpEntry { #[derive(serde::Serialize)] pub struct OperatorsContext { pub requires: Vec, - pub token: String, + pub family_name: String, pub name: String, pub dom: String, pub operators: Vec, @@ -236,7 +236,7 @@ pub fn operator_entry(op: &Operator, leftarg: &str, rightarg: &str, supported: b #[derive(serde::Serialize)] pub struct AggregatesContext { pub requires: Vec, // dependency paths only; template emits "-- REQUIRE:" - pub token: String, + pub family_name: String, pub name: String, pub dom: String, // schema-qualified domain, hoisted pub aggregates: &'static [AggregateOp], // == AGGREGATE_OPS diff --git a/crates/eql-codegen/src/dump.rs b/crates/eql-codegen/src/dump.rs index a68e80682..dd7d3ef37 100644 --- a/crates/eql-codegen/src/dump.rs +++ b/crates/eql-codegen/src/dump.rs @@ -101,6 +101,23 @@ mod tests { assert_eq!(ord.supported_ops, ["=", "<>", "<", "<=", ">", ">="]); } + /// Pins the hand-re-derived `suffix` wire field — the one channel with no + /// other automated reader — so its underscore-prefixed values stay + /// byte-stable after the catalog dropped the leading underscore from its + /// stored (now bare) domain names. + #[test] + fn int4_suffix_field_is_underscore_prefixed() { + let dump = dump_catalog(); + let int4 = dump + .types + .iter() + .find(|t| t.token == "int4") + .expect("int4 present in catalog"); + + let suffixes: Vec<&str> = int4.domains.iter().map(|d| d.suffix.as_str()).collect(); + assert_eq!(suffixes, ["", "_eq", "_ord_ore", "_ord"]); + } + #[test] fn timestamptz_is_ordered() { // timestamptz was promoted to the ordered shape once diff --git a/crates/eql-codegen/src/generate.rs b/crates/eql-codegen/src/generate.rs index 58d97464a..dfa60a4e0 100644 --- a/crates/eql-codegen/src/generate.rs +++ b/crates/eql-codegen/src/generate.rs @@ -11,14 +11,14 @@ use crate::operator_surface::OPERATORS; const V3_SCHEMA: &str = "src/v3/schema.sql"; /// REQUIRE edge for the hand-written shared blocker helper. const V3_SCALARS_BLOCKER: &str = "src/v3/scalars/functions.sql"; -/// Root of the generated per-token scalar surface. The single place the tree +/// Root of the generated per-type scalar surface. The single place the tree /// layout is spelled out — keeps `types_path`/`scalar_path` and the REQUIRE /// vecs from drifting if the surface ever relocates again. const V3_SCALARS_DIR: &str = "src/v3/scalars"; -/// REQUIRE path for a generated file `file` under a token's scalar dir. -fn scalar_path(token: &str, file: &str) -> String { - format!("{V3_SCALARS_DIR}/{token}/{file}") +/// REQUIRE path for a generated file `file` under a family's scalar dir. +fn scalar_path(family_name: &str, file: &str) -> String { + format!("{V3_SCALARS_DIR}/{family_name}/{file}") } /// The second-parameter name for an operator's generated signature. The `->` and @@ -33,8 +33,8 @@ fn arg_b_name(symbol: &str) -> &'static str { } /// REQUIRE path for a type's _types.sql. Port of `_types_path`. -fn types_path(token: &str) -> String { - scalar_path(token, &format!("{token}_types.sql")) +fn types_path(family_name: &str) -> String { + scalar_path(family_name, &format!("{family_name}_types.sql")) } /// Body for _types.sql: every domain in one idempotent DO block. @@ -42,7 +42,7 @@ fn types_path(token: &str) -> String { pub fn render_types_file(spec: &DomainFamily) -> String { use crate::context::{domain_block, environment, TypesContext}; let ctx = TypesContext { - token: spec.name.to_string(), + family_name: spec.name.to_string(), domains: spec .domains .iter() @@ -57,10 +57,10 @@ pub fn render_types_file(spec: &DomainFamily) -> String { } /// REQUIRE edges for a domain's _functions.sql. Port of `_functions_requires`. -fn functions_requires(token: &str, terms: &[Term]) -> Vec { +fn functions_requires(family_name: &str, terms: &[Term]) -> Vec { let mut reqs = vec![ V3_SCHEMA.to_string(), - types_path(token), + types_path(family_name), V3_SCALARS_BLOCKER.to_string(), ]; for extra in Term::term_requires(terms) { @@ -72,12 +72,12 @@ fn functions_requires(token: &str, terms: &[Term]) -> Vec { } /// Body for a domain's _functions.sql. Port of `render_functions_file`. -pub fn render_functions_file(token: &str, domain: &Domain) -> String { +pub fn render_functions_file(family_name: &str, domain: &Domain) -> String { use crate::consts::sql_str; use crate::context::{ environment, extractor_entry, unsupported_entry, wrapper_entry, FunctionsContext, SqlParam, }; - let name = domain.full_name(token); + let name = domain.full_name(family_name); let dom = domain_name(&name); let domain_lit = sql_str(&dom); let supported = Term::operators_for_terms(domain.terms); @@ -112,8 +112,8 @@ pub fn render_functions_file(token: &str, domain: &Domain) -> String { } let ctx = FunctionsContext { - requires: functions_requires(token, domain.terms), - token: token.to_string(), + requires: functions_requires(family_name, domain.terms), + family_name: family_name.to_string(), name, dom, domain_lit, @@ -127,9 +127,9 @@ pub fn render_functions_file(token: &str, domain: &Domain) -> String { } /// Body for a domain's _operators.sql. Port of `render_operators_file`. -pub fn render_operators_file(token: &str, domain: &Domain) -> String { +pub fn render_operators_file(family_name: &str, domain: &Domain) -> String { use crate::context::{environment, operator_entry, OperatorsContext}; - let name = domain.full_name(token); + let name = domain.full_name(family_name); let dom = domain_name(&name); let supported = Term::operators_for_terms(domain.terms); let is_supported = |op: &str| supported.contains(&op); @@ -152,10 +152,10 @@ pub fn render_operators_file(token: &str, domain: &Domain) -> String { let ctx = OperatorsContext { requires: vec![ V3_SCHEMA.to_string(), - types_path(token), - scalar_path(token, &format!("{name}_functions.sql")), + types_path(family_name), + scalar_path(family_name, &format!("{name}_functions.sql")), ], - token: token.to_string(), + family_name: family_name.to_string(), name, dom, operators, @@ -169,21 +169,21 @@ pub fn render_operators_file(token: &str, domain: &Domain) -> String { /// Body for a domain's _aggregates.sql, or None if not ord-capable. /// Port of `render_aggregates_file`. -pub fn render_aggregates_file(token: &str, domain: &Domain) -> Option { +pub fn render_aggregates_file(family_name: &str, domain: &Domain) -> Option { use crate::context::{environment, AggregatesContext, AGGREGATE_OPS}; if !is_ord_capable(domain.terms) { return None; } - let name = domain.full_name(token); + let name = domain.full_name(family_name); let dom = domain_name(&name); let ctx = AggregatesContext { requires: vec![ V3_SCHEMA.to_string(), - types_path(token), - scalar_path(token, &format!("{name}_functions.sql")), - scalar_path(token, &format!("{name}_operators.sql")), + types_path(family_name), + scalar_path(family_name, &format!("{name}_functions.sql")), + scalar_path(family_name, &format!("{name}_operators.sql")), ], - token: token.to_string(), + family_name: family_name.to_string(), name, dom, // hoisted: one copy, template reads {{ dom }} aggregates: AGGREGATE_OPS, // iterate the const directly (no per-entry wrapper) @@ -204,10 +204,10 @@ use crate::writer::{ /// Regenerate every generated file for one type into `out_dir`. /// Port of `generate_type`. Returns the written paths. pub fn generate_type(spec: &DomainFamily, out_dir: &Path) -> Result, WriteError> { - let token = spec.name; - let mut targets = vec![out_dir.join(format!("{token}_types.sql"))]; + let family_name = spec.name; + let mut targets = vec![out_dir.join(format!("{family_name}_types.sql"))]; for d in spec.domains { - let name = d.full_name(token); + let name = d.full_name(family_name); targets.push(out_dir.join(format!("{name}_functions.sql"))); targets.push(out_dir.join(format!("{name}_operators.sql"))); if is_ord_capable(d.terms) { @@ -219,21 +219,21 @@ pub fn generate_type(spec: &DomainFamily, out_dir: &Path) -> Result let mut written: Vec = Vec::new(); - let types_path = out_dir.join(format!("{token}_types.sql")); + let types_path = out_dir.join(format!("{family_name}_types.sql")); write_generated_file(&types_path, &render_types_file(spec))?; written.push(types_path); for d in spec.domains { - let name = d.full_name(token); + let name = d.full_name(family_name); let fn_path = out_dir.join(format!("{name}_functions.sql")); - write_generated_file(&fn_path, &render_functions_file(token, d))?; + write_generated_file(&fn_path, &render_functions_file(family_name, d))?; written.push(fn_path); let op_path = out_dir.join(format!("{name}_operators.sql")); - write_generated_file(&op_path, &render_operators_file(token, d))?; + write_generated_file(&op_path, &render_operators_file(family_name, d))?; written.push(op_path); - if let Some(agg) = render_aggregates_file(token, d) { + if let Some(agg) = render_aggregates_file(family_name, d) { let agg_path = out_dir.join(format!("{name}_aggregates.sql")); write_generated_file(&agg_path, &agg)?; written.push(agg_path); @@ -248,22 +248,18 @@ pub fn generate_type(spec: &DomainFamily, out_dir: &Path) -> Result /// (`eql_domains::INT4_VALUES` / `INT2_VALUES`), read directly by the SQLx tests. pub fn generate_all(out_root: &Path) -> Result { for spec in eql_domains::CATALOG { - let token = spec.name; - let out_dir = out_root.join(V3_SCALARS_DIR).join(token); + let family_name = spec.name; + let out_dir = out_root.join(V3_SCALARS_DIR).join(family_name); let written = generate_type(spec, &out_dir)?; for p in &written { let rel = p.strip_prefix(out_root).unwrap_or(p); println!("generated {}", rel.display()); } - println!("generated {} files for {token}", written.len()); - } - let tokens: Vec<&str> = eql_domains::CATALOG.iter().map(|s| s.name).collect(); - println!( - "codegen: ok ({} types: {})", - tokens.len(), - tokens.join(", ") - ); + println!("generated {} files for {family_name}", written.len()); + } + let names: Vec<&str> = eql_domains::CATALOG.iter().map(|s| s.name).collect(); + println!("codegen: ok ({} types: {})", names.len(), names.join(", ")); Ok(0) } @@ -272,11 +268,11 @@ mod tests { use super::*; use eql_domains::CATALOG; - fn spec(token: &str) -> &'static DomainFamily { + fn spec(family_name: &str) -> &'static DomainFamily { CATALOG .iter() - .find(|s| s.name == token) - .expect("catalog token") + .find(|s| s.name == family_name) + .expect("catalog family") } fn domain<'a>(spec: &'a DomainFamily, name: &str) -> &'a Domain { @@ -305,20 +301,20 @@ mod tests { out } - fn rendered_for(token: &str, name: &str, spec: &DomainFamily) -> String { - if name == format!("{token}_types.sql") { + fn rendered_for(family_name: &str, name: &str, spec: &DomainFamily) -> String { + if name == format!("{family_name}_types.sql") { return render_types_file(spec); } for d in spec.domains { - let full = d.full_name(token); + let full = d.full_name(family_name); if name == format!("{full}_functions.sql") { - return render_functions_file(token, d); + return render_functions_file(family_name, d); } if name == format!("{full}_operators.sql") { - return render_operators_file(token, d); + return render_operators_file(family_name, d); } if name == format!("{full}_aggregates.sql") { - return render_aggregates_file(token, d) + return render_aggregates_file(family_name, d) .expect("reference exists but generator skipped (not ord-capable)"); } } @@ -577,8 +573,8 @@ mod tests { fn inlinable_functions_have_no_set_search_path() { let s = spec("int4"); // Extractors and wrappers (eq/ord functions files) are inlinable SQL. - for suffix in ["eq", "ord"] { - let sql = render_functions_file("int4", domain(s, suffix)); + for name in ["eq", "ord"] { + let sql = render_functions_file("int4", domain(s, name)); // Inlinable rows are the LANGUAGE sql ones; none may pin search_path. for block in sql.split("CREATE FUNCTION").skip(1) { if block.contains("LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE") { diff --git a/crates/eql-codegen/templates/aggregates.sql.j2 b/crates/eql-codegen/templates/aggregates.sql.j2 index d85ab2830..95d131fc3 100644 --- a/crates/eql-codegen/templates/aggregates.sql.j2 +++ b/crates/eql-codegen/templates/aggregates.sql.j2 @@ -2,7 +2,7 @@ {% for r in requires -%} -- REQUIRE: {{ r }} {% endfor %} ---! @file encrypted_domain/{{ token }}/{{ name }}_aggregates.sql +--! @file encrypted_domain/{{ family_name }}/{{ name }}_aggregates.sql --! @brief Aggregates for {{ dom }}. {% for a in aggregates %} --! @brief State function for {{ a.name }} on {{ dom }}. diff --git a/crates/eql-codegen/templates/functions.sql.j2 b/crates/eql-codegen/templates/functions.sql.j2 index ba4f3a020..69c53f836 100644 --- a/crates/eql-codegen/templates/functions.sql.j2 +++ b/crates/eql-codegen/templates/functions.sql.j2 @@ -2,7 +2,7 @@ {% for r in requires -%} -- REQUIRE: {{ r }} {% endfor %} ---! @file encrypted_domain/{{ token }}/{{ name }}_functions.sql +--! @file encrypted_domain/{{ family_name }}/{{ name }}_functions.sql --! @brief Functions for {{ dom }}. {% for e in entries %} {% include "functions/" ~ e.kind|lower ~ ".sql.j2" -%} diff --git a/crates/eql-codegen/templates/operators.sql.j2 b/crates/eql-codegen/templates/operators.sql.j2 index bb33ff528..3c5460813 100644 --- a/crates/eql-codegen/templates/operators.sql.j2 +++ b/crates/eql-codegen/templates/operators.sql.j2 @@ -2,7 +2,7 @@ {% for r in requires -%} -- REQUIRE: {{ r }} {% endfor %} ---! @file encrypted_domain/{{ token }}/{{ name }}_operators.sql +--! @file encrypted_domain/{{ family_name }}/{{ name }}_operators.sql --! @brief Operators for {{ dom }}. {% for o in operators %} CREATE OPERATOR {{ o.symbol }} ( diff --git a/crates/eql-codegen/templates/types.sql.j2 b/crates/eql-codegen/templates/types.sql.j2 index b6deb4bde..8f87a21a9 100644 --- a/crates/eql-codegen/templates/types.sql.j2 +++ b/crates/eql-codegen/templates/types.sql.j2 @@ -1,8 +1,8 @@ -- AUTOMATICALLY GENERATED FILE. -- REQUIRE: src/v3/schema.sql ---! @file v3/scalars/{{ token }}/{{ token }}_types.sql ---! @brief Encrypted-domain types for {{ token }}. +--! @file v3/scalars/{{ family_name }}/{{ family_name }}_types.sql +--! @brief Encrypted-domain types for {{ family_name }}. DO $$ BEGIN diff --git a/crates/eql-domains/src/lib.rs b/crates/eql-domains/src/lib.rs index 6c3494868..f49e05943 100644 --- a/crates/eql-domains/src/lib.rs +++ b/crates/eql-domains/src/lib.rs @@ -206,7 +206,8 @@ pub struct Domain { } /// A scalar encrypted-domain type: its SQL `name`, native Rust type, generated -/// domains, and fixture plaintext list. The Rust analogue of one `*.toml`. +/// 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`.) #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct DomainFamily { diff --git a/crates/eql-domains/src/tests.rs b/crates/eql-domains/src/tests.rs index be5cfa34f..70b53e429 100644 --- a/crates/eql-domains/src/tests.rs +++ b/crates/eql-domains/src/tests.rs @@ -635,11 +635,8 @@ mod catalog_tests { fn text_spec_is_in_catalog() { let text = scalar("text"); assert_eq!(text.kind, ScalarKind::Text); - let suffixes: Vec<_> = text.domains.iter().map(|d| d.name).collect(); - assert_eq!( - suffixes, - vec!["", "eq", "match", "ord_ore", "ord", "search"] - ); + let names: Vec<_> = text.domains.iter().map(|d| d.name).collect(); + assert_eq!(names, vec!["", "eq", "match", "ord_ore", "ord", "search"]); } #[test] @@ -1007,10 +1004,10 @@ mod float_tests { #[test] fn float_specs_are_in_catalog_with_ordered_shape() { - for token in ["float4", "float8"] { - let s = scalar(token); - let suffixes: Vec<_> = s.domains.iter().map(|d| d.name).collect(); - assert_eq!(suffixes, vec!["", "eq", "ord_ore", "ord"]); + for family_name in ["float4", "float8"] { + let s = scalar(family_name); + 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); @@ -1038,29 +1035,38 @@ mod float_tests { /// ±Inf MUST be present (the boundary pivots). #[test] fn float_fixtures_exclude_nan_and_negative_zero_and_include_infinities() { - for token in ["float4", "float8"] { - let s = scalar(token); + for family_name in ["float4", "float8"] { + let s = scalar(family_name); let strings: Vec<&str> = s .fixtures .iter() .map(|f| match f { Fixture::Float(v) => *v, - other => panic!("{token} fixture must be Fixture::Float, got {other:?}"), + other => panic!("{family_name} fixture must be Fixture::Float, got {other:?}"), }) .collect(); for v in &strings { let parsed: f64 = v .parse() - .unwrap_or_else(|_| panic!("{token} fixture {v:?} must parse as f64")); - assert!(!parsed.is_nan(), "{token} fixture {v:?} is NaN"); + .unwrap_or_else(|_| panic!("{family_name} fixture {v:?} must parse as f64")); + assert!(!parsed.is_nan(), "{family_name} fixture {v:?} is NaN"); assert!( !(parsed == 0.0 && parsed.is_sign_negative()), - "{token} fixture {v:?} is -0.0" + "{family_name} fixture {v:?} is -0.0" ); } - assert!(strings.contains(&"inf"), "{token} must include +inf pivot"); - assert!(strings.contains(&"-inf"), "{token} must include -inf pivot"); - assert!(strings.contains(&"0"), "{token} must include 0 (origin)"); + assert!( + strings.contains(&"inf"), + "{family_name} must include +inf pivot" + ); + assert!( + strings.contains(&"-inf"), + "{family_name} must include -inf pivot" + ); + assert!( + strings.contains(&"0"), + "{family_name} must include 0 (origin)" + ); } } @@ -1069,8 +1075,8 @@ mod float_tests { /// fetch_fixture_payload's fetch_one). #[test] fn float_fixtures_are_distinct_by_value() { - for token in ["float4", "float8"] { - let s = scalar(token); + for family_name in ["float4", "float8"] { + let s = scalar(family_name); let parsed: Vec = s .fixtures .iter() @@ -1086,7 +1092,11 @@ mod float_tests { let mut sorted = parsed.clone(); sorted.sort_unstable(); sorted.dedup(); - assert_eq!(sorted.len(), parsed.len(), "{token} has duplicate fixtures"); + assert_eq!( + sorted.len(), + parsed.len(), + "{family_name} has duplicate fixtures" + ); } } } @@ -1096,13 +1106,13 @@ mod invariant_tests { use std::collections::HashMap; #[test] - fn every_domain_name_starts_with_its_token() { + fn every_domain_name_starts_with_its_family_name() { for s in CATALOG { for d in s.domains { let name = s.domain_name(d); assert!( name == s.name || name.starts_with(&format!("{}_", s.name)), - "{name} does not start with token {}", + "{name} does not start with family name {}", s.name ); } diff --git a/docs/reference/adding-a-scalar-encrypted-domain-type.md b/docs/reference/adding-a-scalar-encrypted-domain-type.md index 8b68b0bfb..52725b209 100644 --- a/docs/reference/adding-a-scalar-encrypted-domain-type.md +++ b/docs/reference/adding-a-scalar-encrypted-domain-type.md @@ -98,7 +98,7 @@ than a runtime validator: full name is the family `name` + `_` + the domain `name` (`DomainFamily::domain_name`); codegen owns the `_` join (`Domain::full_name`), and an empty domain `name` yields the bare family name. Pinned by - `every_domain_name_starts_with_its_token`. + `every_domain_name_starts_with_its_family_name`. - **`kind`** — a `ScalarKind` (`I16` / `I32` / `I64` / `Numeric` / `Text` / `Jsonb` / `Date` / `Timestamptz`), carrying the Rust type name. Only the integer kinds have an diff --git a/mise.toml b/mise.toml index f9bda93f6..0e4acfa31 100644 --- a/mise.toml +++ b/mise.toml @@ -529,7 +529,7 @@ run = """ # Forward catalog-coverage: for each scalar type in the catalog, every domain # the catalog declares for it must have at least one matrix test name. FINER # than test:matrix:inventory (which reconciles only TYPES against list-types): -# a DomainSpec added to a catalog row without matrix wiring passes the type +# a Domain added to a catalog row without matrix wiring passes the type # inventory but fails here. Domain granularity only (Stage 1); per-operator # execution coverage is the Stage 4 matcher's job. No database needed. # diff --git a/tests/sqlx/src/scalar_domains.rs b/tests/sqlx/src/scalar_domains.rs index ca5742cb0..c1dcf1915 100644 --- a/tests/sqlx/src/scalar_domains.rs +++ b/tests/sqlx/src/scalar_domains.rs @@ -1121,6 +1121,20 @@ impl Variant { } } + /// The bare catalog domain name this variant maps to (no leading `_`), as + /// stored in `Domain::name` / looked up via `DomainFamily::domain_by_name`. + /// `suffix()` is the SQL-qualifying form (`_eq`); this is the catalog key + /// (`eq`). Storage is the empty bare name. + pub const fn name(self) -> &'static str { + match self { + Variant::Storage => "", + Variant::Eq => "eq", + Variant::Ord => "ord", + Variant::OrdOre => "ord_ore", + Variant::Search => "search", + } + } + /// The fixed index terms this variant's domain carries for scalar `token`, /// from `CATALOG`. Panics if the `(token, suffix())` pair is not declared — /// the resolution backstop test guarantees every instantiated pair @@ -1130,7 +1144,7 @@ impl Variant { CATALOG .iter() .find(|s| s.name == token) - .and_then(|s| s.domain_by_name(self.suffix().trim_start_matches('_'))) + .and_then(|s| s.domain_by_name(self.name())) .map(|d| d.terms) .unwrap_or_else(|| { panic!( @@ -1147,7 +1161,7 @@ impl Variant { CATALOG .iter() .find(|s| s.name == token) - .and_then(|s| s.domain_by_name(self.suffix().trim_start_matches('_'))) + .and_then(|s| s.domain_by_name(self.name())) .is_some() } @@ -1548,7 +1562,7 @@ mod catalog_resolution_tests { let suffix = variant.suffix(); // A variant is instantiated for a token iff that token declares // the suffix; only assert those pairs. - if let Some(d) = spec.domain_by_name(suffix.trim_start_matches('_')) { + if let Some(d) = spec.domain_by_name(variant.name()) { assert!( variant.is_declared_for(spec.name), "{}{} declared in CATALOG but is_declared_for is false",