diff --git a/CHANGELOG.md b/CHANGELOG.md index d15fa325..0c128bdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ Each entry that ships in a published release links to the PR that introduced it. - **`eql_v3` encrypted-domain schema, with the `int4` family as its first member.** Encrypted-domain type families now live in a new, additional `eql_v3` schema (the existing `eql_v2` schema is unchanged — it keeps the core types/operators and stays the documented public API). Four jsonb-backed domains for encrypted `int4` columns: `eql_v3.int4` (storage-only), `eql_v3.int4_eq` (`=` / `<>` via HMAC), and `eql_v3.int4_ord` / `eql_v3.int4_ord_ore` (also `<` `<=` `>` `>=` via ORE block terms). Supported comparisons resolve to inlinable wrappers; the native `jsonb` operator surface reachable through domain fallback is blocked (raises rather than silently mis-resolving). Each domain's `CHECK` requires the EQL envelope (`v`, `i`), the ciphertext (`c`), and the variant's index term(s), and pins the payload version (`VALUE->>'v' = '2'`, matching `eql_v2._encrypted_check_v`) — so a missing key or wrong-version payload is rejected on insert or cast rather than surfacing later at query time. Index via a functional index on the `eql_v3.eq_term` / `eql_v3.ord_term` extractors, not an operator class on the domain. The extractors return the searchable-encrypted-metadata index-term types `eql_v3.hmac_256` / `eql_v3.ore_block_u64_8_256`, which `eql_v3` owns directly (see the self-contained `eql_v3` schema entry below). Why: a type-safe, per-capability encrypted integer column instead of the untyped `eql_v2_encrypted`, namespaced under its own schema. This is the reference scalar implementation for the generated domain family. ([#239](https://github.com/cipherstash/encrypt-query-language/pull/239), supersedes [#225](https://github.com/cipherstash/encrypt-query-language/pull/225)) - **`eql_v3.int2` encrypted-domain type family.** Four jsonb-backed domains for encrypted `int2` columns — `eql_v3.int2` (storage-only), `eql_v3.int2_eq` (`=` / `<>` via HMAC), and `eql_v3.int2_ord` / `eql_v3.int2_ord_ore` (also `<` `<=` `>` `>=` via ORE block terms, with `MIN` / `MAX` aggregates) — generated from the `int2` row in `eql-scalars::CATALOG` by the same materializer as the `eql_v3.int4` reference. Index via a functional index on the `eql_v3.eq_term` / `eql_v3.ord_term` extractors, not an operator class on the domain. Why: a type-safe, per-capability encrypted `smallint` column, proving the scalar generator generalizes beyond the `int4` reference. ([#243](https://github.com/cipherstash/encrypt-query-language/pull/243)) - **`eql_v3.int8` encrypted-domain type family.** Four jsonb-backed domains for encrypted `int8` columns — `eql_v3.int8` (storage-only), `eql_v3.int8_eq` (`=` / `<>` via HMAC), and `eql_v3.int8_ord` / `eql_v3.int8_ord_ore` (also `<` `<=` `>` `>=` via ORE block terms, with `MIN` / `MAX` aggregates) — generated from the `int8` row in `eql-scalars::CATALOG` by the same materializer as the `eql_v3.int4` reference. Index via a functional index on the `eql_v3.eq_term` / `eql_v3.ord_term` extractors, not an operator class on the domain. Why: a type-safe, per-capability encrypted `bigint` column, extending the scalar generator across the full 64-bit integer width. ([#253](https://github.com/cipherstash/encrypt-query-language/pull/253)) +- **`eql_v3.date` encrypted-domain type family.** Four jsonb-backed domains for encrypted `date` columns — `eql_v3.date` (storage-only), `eql_v3.date_eq` (`=` / `<>` via HMAC), and `eql_v3.date_ord` / `eql_v3.date_ord_ore` (also `<` `<=` `>` `>=` via ORE block terms, with `MIN` / `MAX` aggregates) — generated from the `date` row in `eql-scalars::CATALOG` by the same materializer as the `eql_v3.int4` reference. Plaintexts encrypt under the `date` cast and compare via the same ORE block terms as the integer scalars (ORE is plaintext-agnostic — dates order like integers). Index via a functional index on the `eql_v3.eq_term` / `eql_v3.ord_term` extractors, not an operator class on the domain. Why: the first **non-integer ordered** scalar encrypted-domain type — a type-safe, per-capability encrypted `date` column — proving the generator and SQLx test matrix generalize beyond fixed-width integers. ([#256](https://github.com/cipherstash/encrypt-query-language/pull/256)) - **Per-domain `MIN` / `MAX` aggregates for the encrypted-domain family.** `eql_v3.min(eql_v3._ord)` / `eql_v3.max(eql_v3._ord)` (and the `_ord_ore` twin) are generated for every ord-capable scalar variant, giving type-safe extrema on domain-typed columns — comparison routes through the variant's `<` / `>` operator (ORE block term, no decryption). The aggregates are declared `PARALLEL = SAFE` with a combine function (the state function itself — min/max are associative), so PostgreSQL can use partial/parallel aggregation on large `GROUP BY` workloads. Why: the new domain types previously had no equivalent of the composite-type aggregates. The existing `eql_v2.min(eql_v2_encrypted)` / `eql_v2.max(eql_v2_encrypted)` aggregates are **retained** and continue to work on `eql_v2_encrypted` columns; the per-domain aggregates are additive and coexist with them. ([#239](https://github.com/cipherstash/encrypt-query-language/pull/239)) - **Self-contained `eql_v3` schema + standalone `release/cipherstash-encrypt-v3.sql` installer.** The `eql_v3` encrypted-domain surface no longer depends on `eql_v2` at runtime: it now owns its own copies of the searchable-encrypted-metadata (SEM) index-term types — `eql_v3.hmac_256` and `eql_v3.ore_block_u64_8_256` (with its btree operator class) — so the `eql_v3.eq_term` / `eql_v3.ord_term` extractors return `eql_v3` types and no `eql_v2.` appears anywhere in the v3 SQL. The whole v3 surface relocated under a single `src/v3/` tree (`src/v3/sem/` for the hand-written SEM types, `src/v3/scalars/` for the generated domain families). A new build variant ships the `eql_v3` schema on its own as `release/cipherstash-encrypt-v3.sql`, installable into a database with no `eql_v2` present; a CI gate greps that artifact and its dependency closure to keep it `eql_v2`-free. Why: a clean foundation for the per-scalar encrypted-domain model to stand alone, ahead of it replacing the `eql_v2_encrypted` composite column type. This is additive — a new schema and a new artifact — and leaves `eql_v2` byte-for-byte unchanged. ([#255](https://github.com/cipherstash/encrypt-query-language/pull/255)) diff --git a/Cargo.lock b/Cargo.lock index 3f44eee0..bfecae6b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1182,6 +1182,7 @@ name = "eql_tests" version = "0.1.0" dependencies = [ "anyhow", + "chrono", "cipherstash-client", "eql-scalars", "eql-tests-macros", @@ -3651,6 +3652,7 @@ checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ "base64", "bytes", + "chrono", "crc", "crossbeam-queue", "either", @@ -3726,6 +3728,7 @@ dependencies = [ "bitflags", "byteorder", "bytes", + "chrono", "crc", "digest 0.10.7", "dotenvy", @@ -3767,6 +3770,7 @@ dependencies = [ "base64", "bitflags", "byteorder", + "chrono", "crc", "dotenvy", "etcetera", @@ -3801,6 +3805,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" dependencies = [ "atoi", + "chrono", "flume", "futures-channel", "futures-core", diff --git a/crates/eql-scalars/src/lib.rs b/crates/eql-scalars/src/lib.rs index be910d2e..d1bab768 100644 --- a/crates/eql-scalars/src/lib.rs +++ b/crates/eql-scalars/src/lib.rs @@ -31,6 +31,11 @@ pub enum ScalarKind { Numeric, Text, Jsonb, + /// Calendar date (`chrono::NaiveDate`). Ordered like the integer kinds via + /// ORE, but string-backed (ISO-8601) at the catalog layer and with no i128 + /// range — so it is *not* `is_int()` and the bounded-numeric accessors + /// panic for it, exactly like the other non-integer kinds. + Date, } impl ScalarKind { @@ -50,6 +55,7 @@ impl ScalarKind { ScalarKind::Numeric => "numeric", ScalarKind::Text => "text", ScalarKind::Jsonb => "jsonb", + ScalarKind::Date => "chrono::NaiveDate", } } @@ -61,7 +67,7 @@ impl ScalarKind { ScalarKind::I64 => "i64::MIN", // Explicit (not `_`) so a future integer variant is a compile // error here rather than silently hitting the panic. - ScalarKind::Numeric | ScalarKind::Text | ScalarKind::Jsonb => { + ScalarKind::Numeric | ScalarKind::Text | ScalarKind::Jsonb | ScalarKind::Date => { panic!("min_symbol is only defined for integer kinds") } } @@ -73,7 +79,7 @@ impl ScalarKind { ScalarKind::I16 => "i16::MAX", ScalarKind::I32 => "i32::MAX", ScalarKind::I64 => "i64::MAX", - ScalarKind::Numeric | ScalarKind::Text | ScalarKind::Jsonb => { + ScalarKind::Numeric | ScalarKind::Text | ScalarKind::Jsonb | ScalarKind::Date => { panic!("max_symbol is only defined for integer kinds") } } @@ -83,7 +89,7 @@ impl ScalarKind { pub const fn zero_symbol(self) -> &'static str { match self { ScalarKind::I16 | ScalarKind::I32 | ScalarKind::I64 => "0", - ScalarKind::Numeric | ScalarKind::Text | ScalarKind::Jsonb => { + ScalarKind::Numeric | ScalarKind::Text | ScalarKind::Jsonb | ScalarKind::Date => { panic!("zero_symbol is only defined for integer kinds") } } @@ -96,7 +102,7 @@ impl ScalarKind { ScalarKind::I16 => i16::MIN as i128, ScalarKind::I32 => i32::MIN as i128, ScalarKind::I64 => i64::MIN as i128, - ScalarKind::Numeric | ScalarKind::Text | ScalarKind::Jsonb => { + ScalarKind::Numeric | ScalarKind::Text | ScalarKind::Jsonb | ScalarKind::Date => { panic!("min_value is only defined for integer kinds") } } @@ -109,7 +115,7 @@ impl ScalarKind { ScalarKind::I16 => i16::MAX as i128, ScalarKind::I32 => i32::MAX as i128, ScalarKind::I64 => i64::MAX as i128, - ScalarKind::Numeric | ScalarKind::Text | ScalarKind::Jsonb => { + ScalarKind::Numeric | ScalarKind::Text | ScalarKind::Jsonb | ScalarKind::Date => { panic!("max_value is only defined for integer kinds") } } @@ -249,6 +255,10 @@ pub enum Fixture { Numeric(&'static str), Text(&'static str), Jsonb(&'static str), + /// An ISO-8601 date string (`"1970-01-01"`). The catalog stays zero-dep, so + /// the string is parsed into a `chrono::NaiveDate` in the SQLx harness, not + /// here. Distinct by literal, like the other string-backed fixtures. + Date(&'static str), } impl Fixture { @@ -264,7 +274,7 @@ impl Fixture { Fixture::Max => Some(kind.max_value()), Fixture::Zero => Some(0), Fixture::Int(n) => Some(n), - Fixture::Numeric(_) | Fixture::Text(_) | Fixture::Jsonb(_) => None, + Fixture::Numeric(_) | Fixture::Text(_) | Fixture::Jsonb(_) | Fixture::Date(_) => None, } } @@ -276,7 +286,7 @@ impl Fixture { Fixture::Max => kind.max_symbol().to_string(), Fixture::Zero => kind.zero_symbol().to_string(), Fixture::Int(n) => n.to_string(), - Fixture::Numeric(s) | Fixture::Text(s) | Fixture::Jsonb(s) => { + Fixture::Numeric(s) | Fixture::Text(s) | Fixture::Jsonb(s) | Fixture::Date(s) => { format!("{s:?}") } } @@ -349,6 +359,7 @@ macro_rules! fixtures { (text; $($s:literal),* $(,)?) => { &[$(Fixture::Text($s)),*] }; (numeric; $($s:literal),* $(,)?) => { &[$(Fixture::Numeric($s)),*] }; (jsonb; $($s:literal),* $(,)?) => { &[$(Fixture::Jsonb($s)),*] }; + (date; $($s:literal),* $(,)?) => { &[$(Fixture::Date($s)),*] }; } /// int4 fixture plaintexts — verbatim from `tasks/codegen/types/int4.toml`. @@ -370,6 +381,20 @@ const INT8_FIXTURES: &[Fixture] = fixtures!(int i64; Min, N(-5000000000), N(-100), N(-1), Zero, N(1), N(2), N(5), N(10), N(17), N(25), N(42), N(50), N(100), N(250), N(1000), N(9999), N(5000000000), Max); +/// date fixture plaintexts — ISO-8601 (`YYYY-MM-DD`) strings, parsed into +/// `chrono::NaiveDate` in the SQLx harness (the catalog stays zero-dep). The +/// three temporal pivots MUST be present verbatim: `"1900-01-01"` (min_pivot), +/// `"1970-01-01"` (zero = `NaiveDate::default()`), and `"2099-12-31"` +/// (max_pivot) — the matrix fetches each one's ciphertext via +/// `fetch_fixture_payload`, which fails loudly if a row is absent. The interior +/// dates span varied years/months so range operators yield distinguishable +/// counts. All distinct. +const DATE_FIXTURES: &[Fixture] = fixtures!(date; + "1900-01-01", "1950-07-15", "1969-12-31", "1970-01-01", "1970-01-02", + "1980-02-29", "1991-11-09", "1999-12-31", "2000-01-01", "2004-02-29", + "2012-06-30", "2016-03-15", "2020-10-21", "2024-02-29", "2038-01-19", + "2099-12-31"); + const INT4: ScalarSpec = ScalarSpec { token: "int4", kind: ScalarKind::I32, @@ -391,13 +416,28 @@ const INT8: ScalarSpec = ScalarSpec { fixtures: INT8_FIXTURES, }; +/// `date` — an ordered, non-integer scalar. Reuses `ORDERED_INT_DOMAINS` (the +/// four-domain ordered shape is identical to the integer scalars); only the +/// kind and fixtures differ. +/// +/// Public (unlike the integer specs) because the SQLx harness reads +/// `DATE.fixtures` directly to parse the ISO strings into `chrono::NaiveDate` +/// at runtime — there is no `DATE_VALUES` const (chrono is not `const`-friendly +/// and `eql-scalars` stays zero-dep, so no typed slice is materialised here). +pub const DATE: ScalarSpec = ScalarSpec { + token: "date", + kind: ScalarKind::Date, + domains: ORDERED_INT_DOMAINS, + fixtures: DATE_FIXTURES, +}; + /// 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] = &[INT4, INT2, INT8]; +pub const CATALOG: &[ScalarSpec] = &[INT4, INT2, INT8, DATE]; /// Materialise an integer scalar's fixtures into a typed `&'static` slice at /// compile time. This is the **single-sourced** plaintext list the SQLx test -/// matrix reads as `ScalarType::FIXTURE_VALUES` and the fixture generator +/// matrix reads via `ScalarType::fixture_values()` and the fixture generator /// encrypts — derived from the same `CATALOG` row that drives SQL generation, /// so the oracle cannot drift from the fixture. (It replaces the old generated, /// committed `tests/sqlx/src/fixtures/_values.rs` — a Rust source of truth no @@ -478,6 +518,7 @@ mod rust_tests { assert!(!ScalarKind::Numeric.is_int()); assert!(!ScalarKind::Text.is_int()); assert!(!ScalarKind::Jsonb.is_int()); + assert!(!ScalarKind::Date.is_int()); } // Pin that the bounded-numeric accessors panic (with message) on non-int kinds. @@ -522,6 +563,46 @@ mod rust_tests { assert_eq!(ScalarKind::I64.min_value(), -9_223_372_036_854_775_808_i128); assert_eq!(ScalarKind::I64.max_value(), 9_223_372_036_854_775_807_i128); } + + #[test] + fn date_maps_to_naive_date() { + // Ordered, non-integer kind: it carries a rust type but no i128 range, + // so it is not `is_int()` and the bounded accessors panic (below). + assert_eq!(ScalarKind::Date.rust_type(), "chrono::NaiveDate"); + assert!(!ScalarKind::Date.is_int()); + } + + // The bounded-numeric accessors panic on Date exactly as on the other + // non-integer kinds — Date is not an integer kind. + #[test] + #[should_panic(expected = "min_symbol is only defined for integer kinds")] + fn min_symbol_panics_on_date() { + ScalarKind::Date.min_symbol(); + } + + #[test] + #[should_panic(expected = "max_symbol is only defined for integer kinds")] + fn max_symbol_panics_on_date() { + ScalarKind::Date.max_symbol(); + } + + #[test] + #[should_panic(expected = "zero_symbol is only defined for integer kinds")] + fn zero_symbol_panics_on_date() { + ScalarKind::Date.zero_symbol(); + } + + #[test] + #[should_panic(expected = "min_value is only defined for integer kinds")] + fn min_value_panics_on_date() { + ScalarKind::Date.min_value(); + } + + #[test] + #[should_panic(expected = "max_value is only defined for integer kinds")] + fn max_value_panics_on_date() { + ScalarKind::Date.max_value(); + } } #[cfg(test)] @@ -682,6 +763,10 @@ mod fixture_tests { Fixture::Jsonb(r#"{"a":1}"#).numeric_value(ScalarKind::Jsonb), None ); + assert_eq!( + Fixture::Date("1970-01-01").numeric_value(ScalarKind::Date), + None + ); } #[test] @@ -718,6 +803,10 @@ mod fixture_tests { Fixture::Jsonb(r#"{"a":1}"#).render_literal(ScalarKind::Jsonb), r#""{\"a\":1}""# ); + assert_eq!( + Fixture::Date("1970-01-01").render_literal(ScalarKind::Date), + "\"1970-01-01\"" + ); } #[test] @@ -741,6 +830,11 @@ mod fixture_tests { assert_eq!(NUMS, &[Fixture::Numeric("0.1"), Fixture::Numeric("-2.5")]); const JSONS: &[Fixture] = fixtures!(jsonb; r#"{"a":1}"#); assert_eq!(JSONS, &[Fixture::Jsonb(r#"{"a":1}"#)]); + const DATES: &[Fixture] = fixtures!(date; "1970-01-01", "2099-12-31"); + assert_eq!( + DATES, + &[Fixture::Date("1970-01-01"), Fixture::Date("2099-12-31")] + ); } #[test] @@ -773,9 +867,33 @@ mod catalog_tests { } #[test] - fn catalog_has_int4_int2_int8_in_order() { + fn catalog_has_int4_int2_int8_date_in_order() { let tokens: Vec<&str> = CATALOG.iter().map(|s| s.token).collect(); - assert_eq!(tokens, vec!["int4", "int2", "int8"]); + assert_eq!(tokens, vec!["int4", "int2", "int8", "date"]); + } + + /// The three temporal matrix pivots must be present verbatim in DATE's + /// fixture strings — `fetch_fixture_payload` fetches each one's ciphertext, + /// failing loudly if absent. The integer `fixtures_include_min_max_and_zero` + /// invariant filters `is_int()` and skips date, so this is its temporal + /// analogue. + #[test] + fn temporal_fixtures_include_pivot_plaintexts() { + let date = scalar("date"); + let strings: Vec<&str> = date + .fixtures + .iter() + .filter_map(|f| match f { + Fixture::Date(s) => Some(*s), + _ => None, + }) + .collect(); + for pivot in ["1900-01-01", "1970-01-01", "2099-12-31"] { + assert!( + strings.contains(&pivot), + "date fixtures missing temporal pivot {pivot}" + ); + } } #[test] @@ -901,7 +1019,9 @@ mod invariant_tests { fn distinct_key(f: Fixture, kind: ScalarKind) -> DistinctKey { match f { - Fixture::Numeric(s) | Fixture::Text(s) | Fixture::Jsonb(s) => DistinctKey::Str(s), + Fixture::Numeric(s) | Fixture::Text(s) | Fixture::Jsonb(s) | Fixture::Date(s) => { + DistinctKey::Str(s) + } _ => DistinctKey::Num( f.numeric_value(kind) .expect("sentinel/Int fixtures resolve to a number"), diff --git a/crates/eql-tests-macros/src/lib.rs b/crates/eql-tests-macros/src/lib.rs index b88a0677..e252bca0 100644 --- a/crates/eql-tests-macros/src/lib.rs +++ b/crates/eql-tests-macros/src/lib.rs @@ -32,15 +32,74 @@ use proc_macro2::TokenStream as TokenStream2; use quote::{format_ident, quote}; use syn::parse::{Parse, ParseStream}; use syn::punctuated::Punctuated; -use syn::{Ident, Token, Type}; +use syn::{bracketed, Ident, Token, Type}; -/// One `token => rust_type` entry. +/// One `token => rust_type` entry, with an optional trailing `[temporal]` flag. struct ScalarEntry { /// Postgres type token (`int4`); also the fixture/domain suffix and the /// matrix `suite` ident. 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 { @@ -48,7 +107,28 @@ impl Parse for ScalarEntry { let token: Ident = input.parse()?; input.parse::]>()?; let rust_type: Type = input.parse()?; - Ok(ScalarEntry { token, rust_type }) + let temporal = parse_optional_marker(input)?; + Ok(ScalarEntry { + token, + rust_type, + temporal, + }) + } +} + +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 } } @@ -78,7 +158,9 @@ 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 { - let impls = list.entries.iter().map(|e| { + // 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| { let token_str = e.token.to_string(); let rust_type = &e.rust_type; let values = values_const_ident(&e.token); @@ -88,10 +170,7 @@ fn scalar_type_impls_tokens(list: &ScalarList) -> TokenStream2 { /// The catalog `eql_scalars::*_VALUES` list — the same values /// the fixture generator encrypts, so the oracle can't drift - /// from the fixture. A method (not a `const`) so non-integer - /// scalars whose values can't be `const`-constructed can return - /// a borrow of a lazily-built `Vec`; integer scalars hand back - /// their catalog const directly. + /// from the fixture. fn fixture_values() -> &'static [#rust_type] { ::eql_scalars::#values } @@ -117,16 +196,33 @@ fn scalar_fixture_modules_tokens(list: &ScalarList) -> TokenStream2 { let mods = list.entries.iter().map(|e| { let token_str = e.token.to_string(); let rust_type = &e.rust_type; - let values = values_const_ident(&e.token); let mod_ident = format_ident!("eql_v2_{}", e.token); let fixture_name = format!("eql_v2_{}", token_str); - quote! { - #[doc = concat!("`eql_v2_", #token_str, "` scalar fixture — generated by `scalar_types!`.")] - pub mod #mod_ident { - use ::eql_scalars::#values as VALUES; - // `scalar_fixture!` is `#[macro_export]`ed by `eql-tests`; - // these modules expand into that lib, so `crate::` resolves it. - crate::scalar_fixture!(#fixture_name, #rust_type, VALUES); + if e.is_temporal() { + // 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 + // `temporal` kind so the integer-only signed-extreme asserts are + // replaced by a pivot-presence assert. The accessor name mirrors + // the token (`date` -> `date_values`). + let values_fn = format_ident!("{}_values", e.token); + quote! { + #[doc = concat!("`eql_v2_", #token_str, "` temporal scalar fixture — generated by `scalar_types!`.")] + pub mod #mod_ident { + use crate::scalar_domains::#values_fn as values; + crate::scalar_fixture!(temporal, #fixture_name, #rust_type, values()); + } + } + } else { + let values = values_const_ident(&e.token); + quote! { + #[doc = concat!("`eql_v2_", #token_str, "` scalar fixture — generated by `scalar_types!`.")] + pub mod #mod_ident { + use ::eql_scalars::#values as VALUES; + // `scalar_fixture!` is `#[macro_export]`ed by `eql-tests`; + // these modules expand into that lib, so `crate::` resolves it. + crate::scalar_fixture!(int, #fixture_name, #rust_type, VALUES); + } } } }); @@ -284,6 +380,100 @@ mod tests { assert!(out.contains("crate :: scalar_fixture !")); assert!(out.contains(r#""eql_v2_int4""#)); assert!(out.contains(":: eql_scalars :: INT4_VALUES as VALUES")); + // Integer entries stamp the `int` kind discriminator. + assert!(out.contains("int ,")); + } + + #[test] + fn temporal_entry_skips_impl_and_stamps_temporal_fixture() { + let list = + syn::parse_str::("int4 => i32, date => chrono::NaiveDate [temporal],") + .unwrap(); + // Impl emitter skips the temporal entry (hand-written impl). + let impls = norm(&scalar_type_impls_tokens(&list)); + assert!(impls.contains("impl ScalarType for i32")); + assert!(!impls.contains("NaiveDate")); + // Fixture-module emitter stamps the temporal kind + harness accessor. + let mods = norm(&scalar_fixture_modules_tokens(&list)); + assert!(mods.contains("pub mod eql_v2_date")); + assert!(mods.contains("temporal ,")); + assert!(mods.contains("date_values")); + // Matrix + dispatch emitters include the temporal entry like any other. + let suites = norm(&scalar_matrix_suites_tokens(&list)); + assert!(suites.contains("pub mod date")); + assert!(suites.contains("scalar = chrono :: NaiveDate")); + let dispatch = norm(&fixture_dispatch_tokens(&list)); + 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()); + } + + #[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}" + ); + } + + #[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()); + } + + #[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}"); } #[test] diff --git a/docs/reference/adding-a-scalar-encrypted-domain-type.md b/docs/reference/adding-a-scalar-encrypted-domain-type.md index e4ae242b..95d73a98 100644 --- a/docs/reference/adding-a-scalar-encrypted-domain-type.md +++ b/docs/reference/adding-a-scalar-encrypted-domain-type.md @@ -175,11 +175,41 @@ int_values!(INT4_VALUES, i32, INT4); ``` Both consumers reference that single symbol — the fixture generator -(`fixtures::eql_v2_::spec`) and the matrix oracle's `FIXTURE_VALUES` — so the -oracle cannot drift from the values the generator encrypts. There is no +(`fixtures::eql_v2_::spec`) and the matrix oracle's `fixture_values()` — so +the oracle cannot drift from the values the generator encrypts. There is no committed `_values.rs`: a Rust source of truth does not round-trip through generated Rust. Pin the exact materialised list with a `values_tests` assertion. +### Temporal kinds — string-backed fixtures and the pivot trait + +A **temporal** scalar (the `date` reference; `timestamptz` follows the same +shape) is *ordered but non-integer*, so it diverges from the integer path in +three places — all in the catalog/harness, never the SQL codegen (domains stay +jsonb-backed and token-driven): + +- **String-backed fixtures.** `eql-scalars` stays zero-dependency, so the + catalog stores ISO strings (`Fixture::Date("1970-01-01")`), not `chrono` + values. There is **no** `int_values!` / `_VALUES` const for a temporal kind + (chrono constructors are not `const`). The SQLx harness parses the catalog + strings into a `LazyLock>` and exposes them via a + `date_values()` accessor; `ScalarType::fixture_values()` returns a borrow of + that. The fixtures must include the three pivot plaintexts verbatim — for + `date`: `"1900-01-01"` (min), `"1970-01-01"` (zero = `NaiveDate::default()`), + `"2099-12-31"` (max) — guarded by `temporal_fixtures_include_pivot_plaintexts`. +- **The pivot trait, not `Self::MIN`/`MAX`.** `ScalarType::fixture_values()` is a + method (not a `const`), and the comparison pivots come from + `ScalarType::min_pivot()` / `max_pivot()` (zero stays `Default::default()`). + Integer impls return `Self::MIN`/`Self::MAX` (emitted by the proc-macro); + temporal impls return explicit sentinel dates and are **hand-written** in + `scalar_domains.rs` (the macro emits only integer impls). `to_sql_literal` is + overridden to single-quote the value (`'1970-01-01'`), since a bare `Display` + date is not a valid SQL literal. +- **The sqlx `chrono` feature.** The test crate enables sqlx's `chrono` feature + (and depends on `chrono` directly) so `Encode`/`Decode`/`Type` resolve for + `NaiveDate`. The integer-only fixture asserts (`::MIN`, `contains(&0)`, + `v < 0`) are stamped only for `int` entries; temporal entries stamp a + pivot-presence assert instead (the `kind` discriminator on `scalar_fixture!`). + --- ## 3. Wire the SQLx matrix oracle diff --git a/tasks/build.sh b/tasks/build.sh index 4d3d94e0..98dee8db 100755 --- a/tasks/build.sh +++ b/tasks/build.sh @@ -1,6 +1,8 @@ #!/usr/bin/env bash #MISE description="Build SQL into single release file" #MISE alias="b" +#MISE sources=["src/**/*.sql", "tasks/pin_search_path.sql", "tasks/uninstall.sql", "tasks/uninstall-protect.sql", "crates/eql-scalars/src/**/*.rs", "crates/eql-codegen/src/**/*.rs"] +#MISE outputs=["release/cipherstash-encrypt.sql","release/cipherstash-encrypt-uninstall.sql","release/cipherstash-encrypt-protect.sql","release/cipherstash-encrypt-protect-uninstall.sql"] #USAGE flag "--version " help="Specify release version of EQL" default="DEV" #!/bin/bash diff --git a/tests/sqlx/Cargo.toml b/tests/sqlx/Cargo.toml index 208f6d86..e67941c1 100644 --- a/tests/sqlx/Cargo.toml +++ b/tests/sqlx/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "macros"] } +sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "macros", "chrono"] } tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -12,6 +12,10 @@ anyhow = "1" hex = "0.4" jsonschema = { version = "0.46.4", default-features = false } cipherstash-client = { version = "0.35", features = ["tokio"] } +# chrono is already in the tree transitively (cipherstash-client / ore-rs); pin +# it as a direct dependency so the harness can name `chrono::NaiveDate` for the +# `date` scalar (Encode/Decode/Type come from the sqlx `chrono` feature above). +chrono = { version = "0.4", default-features = false } paste = "1" eql-scalars = { path = "../../crates/eql-scalars" } eql-tests-macros = { path = "../../crates/eql-tests-macros" } diff --git a/tests/sqlx/src/fixtures/eql_plaintext.rs b/tests/sqlx/src/fixtures/eql_plaintext.rs index 72c5b2a0..65c9f43e 100644 --- a/tests/sqlx/src/fixtures/eql_plaintext.rs +++ b/tests/sqlx/src/fixtures/eql_plaintext.rs @@ -56,6 +56,7 @@ impl PlaintextSqlType { pub const INTEGER: PlaintextSqlType = PlaintextSqlType("integer"); pub const SMALLINT: PlaintextSqlType = PlaintextSqlType("smallint"); pub const BIGINT: PlaintextSqlType = PlaintextSqlType("bigint"); + pub const DATE: PlaintextSqlType = PlaintextSqlType("date"); pub fn as_str(&self) -> &'static str { self.0 @@ -70,30 +71,32 @@ impl fmt::Display for PlaintextSqlType { /// The EQL `cast_as` for a scalar kind, drawn from the `Cast` allowlist. /// -/// Only the integer kinds have `EqlPlaintext` impls, so only those resolve; -/// the non-integer kinds mirror the `eql_scalars` accessor convention and -/// `panic!`, since no impl can ever reach them. +/// Only the wired kinds (the integer kinds plus `Date`) have `EqlPlaintext` +/// impls, so only those resolve; the remaining kinds mirror the `eql_scalars` +/// accessor convention and `panic!`, since no impl can ever reach them. const fn cast_for_kind(kind: ScalarKind) -> Cast { match kind { ScalarKind::I32 => Cast::INT, ScalarKind::I16 => Cast::SMALL_INT, ScalarKind::I64 => Cast::BIG_INT, + ScalarKind::Date => Cast::DATE, ScalarKind::Numeric | ScalarKind::Text | ScalarKind::Jsonb => { - panic!("EqlPlaintext is only implemented for integer scalar kinds") + panic!("EqlPlaintext is only implemented for the wired scalar kinds") } } } /// The `plaintext` oracle column SQL type for a scalar kind, drawn from the -/// `PlaintextSqlType` allowlist. As with `cast_for_kind`, only integer kinds -/// resolve. +/// `PlaintextSqlType` allowlist. As with `cast_for_kind`, only the wired kinds +/// (integers plus `Date`) resolve. const fn plaintext_sql_type_for_kind(kind: ScalarKind) -> PlaintextSqlType { match kind { ScalarKind::I32 => PlaintextSqlType::INTEGER, ScalarKind::I16 => PlaintextSqlType::SMALLINT, ScalarKind::I64 => PlaintextSqlType::BIGINT, + ScalarKind::Date => PlaintextSqlType::DATE, ScalarKind::Numeric | ScalarKind::Text | ScalarKind::Jsonb => { - panic!("EqlPlaintext is only implemented for integer scalar kinds") + panic!("EqlPlaintext is only implemented for the wired scalar kinds") } } } @@ -103,6 +106,7 @@ mod sealed { impl Sealed for i32 {} impl Sealed for i16 {} impl Sealed for i64 {} + impl Sealed for chrono::NaiveDate {} } /// A Rust type usable as a fixture `plaintext` value, carrying its EQL cast @@ -154,6 +158,14 @@ impl EqlPlaintext for i64 { } } +impl EqlPlaintext for chrono::NaiveDate { + const KIND: ScalarKind = ScalarKind::Date; + + fn to_plaintext(&self) -> Plaintext { + Plaintext::NaiveDate(Some(*self)) + } +} + #[cfg(test)] mod tests { use super::*; @@ -223,4 +235,28 @@ mod tests { other => panic!("expected Plaintext::BigInt(Some(42)), got {other:?}"), } } + + #[test] + fn naive_date_casts_to_date() { + assert_eq!(::CAST.as_str(), "date"); + } + + #[test] + fn naive_date_plaintext_sql_type_is_date() { + assert_eq!( + ::PLAINTEXT_SQL_TYPE.as_str(), + "date" + ); + } + + #[test] + fn naive_date_to_plaintext_wraps_in_naive_date_variant() { + // A NaiveDate must lift into the NaiveDate variant so the fixture + // driver encrypts it under the `date` cast. + let d = chrono::NaiveDate::from_ymd_opt(1970, 1, 1).unwrap(); + match d.to_plaintext() { + Plaintext::NaiveDate(Some(value)) => assert_eq!(value, d), + other => panic!("expected Plaintext::NaiveDate(Some(1970-01-01)), got {other:?}"), + } + } } diff --git a/tests/sqlx/src/fixtures/scalar_fixture.rs b/tests/sqlx/src/fixtures/scalar_fixture.rs index 1232b8b2..d6a955ab 100644 --- a/tests/sqlx/src/fixtures/scalar_fixture.rs +++ b/tests/sqlx/src/fixtures/scalar_fixture.rs @@ -13,37 +13,28 @@ /// Stamp out the `spec()` builder, the `fixture-gen` generator test, and the /// property-test module for a scalar fixture. /// +/// The leading **kind** discriminator (`int` / `temporal`) selects which +/// property asserts are stamped — the rest of the expansion is identical: +/// +/// - `int` — signed-extreme asserts (`<$ty>::MIN`/`MAX`, `contains(&0)`, +/// `any(|v| v < 0)`). These typecheck only for integer plaintexts. +/// - `temporal` — a pivot-presence assert (`min_pivot`/`max_pivot`/zero from the +/// `ScalarType` impl all appear in the values). `<$ty>::MIN` / `< 0` don't +/// exist for a `chrono::NaiveDate`, so the integer asserts can't be reused. +/// /// - `$name` — the fixture name (`"eql_v2_int2"`), drives every derived path. -/// - `$ty` — the Rust plaintext type (`i16`); `<$ty>::MIN`/`MAX` supply the -/// signed-extreme assertions. -/// - `$values` — the catalog-materialised value const (`eql_scalars::INT2_VALUES`). +/// - `$ty` — the Rust plaintext type (`i16` / `chrono::NaiveDate`). +/// - `$values` — the value source: the catalog const (`eql_scalars::INT2_VALUES`) +/// for integers, or the harness accessor (`date_values()`) for temporal. /// /// Indexes are fixed to `Unique` (HMAC, drives `=` / `<>`) and `Ore` (ORE /// block terms, drives `<` `<=` `>` `>=`) with a committed `jsonb` payload — /// the shape shared by every ordered scalar domain. #[macro_export] macro_rules! scalar_fixture { - ($name:literal, $ty:ty, $values:expr $(,)?) => { - /// The complete fixture definition. `IndexKind::Unique` drives `=` / - /// `<>` (HMAC); `IndexKind::Ore` drives `<` `<=` `>` `>=` (ORE block - /// terms). - pub fn spec() -> $crate::fixtures::FixtureSpec<'static, $ty> { - $crate::fixtures::FixtureSpec::new($name) - .with_index($crate::fixtures::IndexKind::Unique) - .with_index($crate::fixtures::IndexKind::Ore) - .with_column_type("jsonb") - .with_values($values) - } - - /// The generator. Gated by `fixture-gen` so `cargo test` never compiles - /// it; `#[ignore]` is a second guard. Run via - /// `mise run fixture:generate`. - #[cfg(feature = "fixture-gen")] - #[tokio::test] - #[ignore = "generator — run via `mise run fixture:generate`"] - async fn generate() -> anyhow::Result<()> { - spec().run().await - } + // Integer scalars: signed-extreme property asserts. + (int, $name:literal, $ty:ty, $values:expr $(,)?) => { + $crate::scalar_fixture!(@common $name, $ty, $values); #[cfg(test)] mod tests { @@ -79,4 +70,58 @@ macro_rules! scalar_fixture { } } }; + + // Temporal scalars: pivot-presence property assert (no signed extremes). + (temporal, $name:literal, $ty:ty, $values:expr $(,)?) => { + $crate::scalar_fixture!(@common $name, $ty, $values); + + #[cfg(test)] + mod tests { + use super::*; + use $crate::scalar_domains::ScalarType; + + #[test] + fn spec_is_complete() { + assert!(spec().check_complete().is_ok()); + } + + #[test] + fn spec_includes_pivots() { + // The three matrix pivots (min/max/zero) must be present in the + // fixture — `fetch_fixture_payload` fetches each at test time. + let spec = spec(); + let values = spec.values(); + let min = <$ty as ScalarType>::min_pivot(); + let max = <$ty as ScalarType>::max_pivot(); + let zero: $ty = ::core::default::Default::default(); + assert!(values.contains(&min), "spec must include min_pivot {min:?}"); + assert!(values.contains(&max), "spec must include max_pivot {max:?}"); + assert!(values.contains(&zero), "spec must include zero pivot {zero:?}"); + } + } + }; + + // Shared expansion: the `spec()` builder + the gated generator test. + (@common $name:literal, $ty:ty, $values:expr) => { + /// The complete fixture definition. `IndexKind::Unique` drives `=` / + /// `<>` (HMAC); `IndexKind::Ore` drives `<` `<=` `>` `>=` (ORE block + /// terms). + pub fn spec() -> $crate::fixtures::FixtureSpec<'static, $ty> { + $crate::fixtures::FixtureSpec::new($name) + .with_index($crate::fixtures::IndexKind::Unique) + .with_index($crate::fixtures::IndexKind::Ore) + .with_column_type("jsonb") + .with_values($values) + } + + /// The generator. Gated by `fixture-gen` so `cargo test` never compiles + /// it; `#[ignore]` is a second guard. Run via + /// `mise run fixture:generate`. + #[cfg(feature = "fixture-gen")] + #[tokio::test] + #[ignore = "generator — run via `mise run fixture:generate`"] + async fn generate() -> anyhow::Result<()> { + spec().run().await + } + }; } diff --git a/tests/sqlx/src/scalar_domains.rs b/tests/sqlx/src/scalar_domains.rs index 6c3d9a0c..69453f59 100644 --- a/tests/sqlx/src/scalar_domains.rs +++ b/tests/sqlx/src/scalar_domains.rs @@ -12,6 +12,7 @@ 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: @@ -39,9 +40,11 @@ pub trait ScalarType: /// (`[0]`, `[len / 2]`) without sorting. A lazily-built `Vec` impl /// must therefore be built deterministically in that same order. /// - /// A method rather than a `const` so a scalar whose values can't be - /// `const`-constructed can return a borrow of a lazily-built `Vec`; - /// integer scalars return their `eql_scalars::_VALUES` const directly. + /// A method rather than a `const` because non-integer scalars (e.g. + /// `chrono::NaiveDate`, whose `from_ymd_opt` is not `const`) cannot be + /// materialised into a const slice; the harness builds those into a + /// `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 /// include the three pivots (`min_pivot()`, `max_pivot()`, and zero @@ -51,14 +54,15 @@ pub trait ScalarType: fn fixture_values() -> &'static [Self]; /// The low comparison pivot swept by the correctness / cross-shape arms. - /// Integer scalars return `Self::MIN`. A trait method (rather than the - /// matrix referencing `Self::MIN` directly) so a scalar without an inherent - /// `::MIN` const can supply an explicit sentinel; the returned value must be - /// present verbatim in `fixture_values()`. + /// Integer scalars return `Self::MIN`; temporal scalars return an explicit + /// sentinel (e.g. `1900-01-01`). A trait method rather than `Self::MIN` + /// because `chrono::DateTime` exposes `MAX_UTC`, not an inherent + /// `::MAX` const. The pivot must be present verbatim in `fixture_values()`. fn min_pivot() -> Self; - /// The high comparison pivot. Integer scalars return `Self::MAX`. Must be - /// present verbatim in `fixture_values()`. + /// The high comparison pivot. Integer scalars return `Self::MAX`; temporal + /// scalars return an explicit sentinel (e.g. `2099-12-31`). Must be present + /// verbatim in `fixture_values()`. fn max_pivot() -> Self; /// `fixtures.eql_v2_`. @@ -94,13 +98,122 @@ pub trait ScalarType: } } -// The per-type `impl ScalarType` blocks (one per scalar, each carrying its -// `PG_TYPE` token string, `fixture_values() = eql_scalars::_VALUES`, and -// `min_pivot()`/`max_pivot()` = `Self::MIN`/`Self::MAX`) are generated from the -// single harness list in `scalar_types.rs`. To add a type, add a +// The per-type `impl ScalarType` blocks for the **integer** scalars (each +// carrying its `PG_TYPE` token, `fixture_values() = eql_scalars::_VALUES`, +// and `min_pivot()`/`max_pivot()` = `Self::MIN`/`Self::MAX`) are generated from +// the single harness list in `scalar_types.rs`. To add an integer type, add a // `token => rust_type` line there — not an impl here. +// +// Temporal scalars (`chrono::NaiveDate`, and `DateTime` in the stacked +// timestamptz PR) are hand-written below instead: their fixture values cannot be +// a `const` slice (chrono constructors are not `const`), and their pivots are +// explicit sentinels rather than `Self::MIN`/`Self::MAX`. The macro emits only +// integer impls. crate::scalar_types!(scalar_type_impls); +/// 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" + ); + } +} + /// Per-domain capability + payload shape. Storage carries no terms, `Eq` /// adds `hm`, `Ord`/`OrdOre` add `ob`. `Ord` and `OrdOre` are deliberate /// twins — same operator surface, different SQL domain names — for the diff --git a/tests/sqlx/src/scalar_types.rs b/tests/sqlx/src/scalar_types.rs index b9b9f112..6747900a 100644 --- a/tests/sqlx/src/scalar_types.rs +++ b/tests/sqlx/src/scalar_types.rs @@ -4,7 +4,11 @@ //! 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). +//! `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. //! //! The harness pieces live in three separate compilation contexts (the //! `eql-tests` lib, the `encrypted_domain` integration-test binary, and the @@ -48,6 +52,7 @@ macro_rules! scalar_types { int4 => i32, int2 => i16, int8 => i64, + date => chrono::NaiveDate [temporal], } }; }