diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c128bdc..82634a02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Each entry that ships in a published release links to the PR that introduced it. - **`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)) +- **`eql_v3.timestamptz` encrypted-domain type family (equality-only).** Two jsonb-backed domains for encrypted `timestamptz` columns — `eql_v3.timestamptz` (storage-only) and `eql_v3.timestamptz_eq` (`=` / `<>` via HMAC) — generated from the `timestamptz` row in `eql-scalars::CATALOG` by the same materializer as the `eql_v3.date` family. Values are **UTC-normalized** (cipherstash has no timezone-preserving type): plaintexts encrypt under the `timestamp` cast. Index via a functional index on the `eql_v3.eq_term` extractor, not an operator class on the domain. **Ordering (`<` `<=` `>` `>=`, `MIN` / `MAX`) is deferred:** cipherstash encrypts `Plaintext::Timestamp` at native 12-block ORE width, but EQL's only ORE comparator (`eql_v2.compare_ore_block_u64_8_256_term`) is hardcoded to 8 blocks, so ordered timestamptz domains would silently mis-order. There are no `eql_v3.timestamptz_ord` / `_ord_ore` domains and no timestamptz `MIN` / `MAX` aggregates until a wide-ORE (12-block) term lands — tracked in [#241](https://github.com/cipherstash/encrypt-query-language/issues/241). Why: a type-safe, equality-searchable encrypted UTC-timestamp column, stacking on the `date` temporal-scalar foundation; ordering follows once the comparator supports the native ciphertext width. ([#257](https://github.com/cipherstash/encrypt-query-language/pull/257)) - **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/crates/eql-scalars/src/lib.rs b/crates/eql-scalars/src/lib.rs index d8d3d7fb..afcd5f4d 100644 --- a/crates/eql-scalars/src/lib.rs +++ b/crates/eql-scalars/src/lib.rs @@ -104,6 +104,13 @@ pub enum ScalarKind { /// live on `BoundedIntKind`, which `Date` cannot be, so they are /// unreachable for it by construction rather than by a runtime panic. Date, + /// UTC timestamp (`chrono::DateTime`). Ordered like the integer kinds + /// via ORE, but string-backed (RFC3339) at the catalog layer and with no + /// i128 range — so it is *not* `is_int()` and the bounded-numeric accessors + /// panic for it, exactly like the other non-integer kinds. UTC-normalized: + /// cipherstash has no tz-preserving type, so it maps to the `timestamp` + /// cast and the SQL `timestamp with time zone` plaintext type. + Timestamptz, } impl ScalarKind { @@ -118,7 +125,11 @@ impl ScalarKind { ScalarKind::I16 => Some(BoundedIntKind::I16), ScalarKind::I32 => Some(BoundedIntKind::I32), ScalarKind::I64 => Some(BoundedIntKind::I64), - ScalarKind::Numeric | ScalarKind::Text | ScalarKind::Jsonb | ScalarKind::Date => None, + ScalarKind::Numeric + | ScalarKind::Text + | ScalarKind::Jsonb + | ScalarKind::Date + | ScalarKind::Timestamptz => None, } } @@ -128,11 +139,11 @@ impl ScalarKind { self.as_bounded_int().is_some() } - /// True for chrono-backed temporal kinds (`Date`; `Timestamptz` once added) — - /// the kinds whose test `ScalarType` impl is generated by `temporal_values!` - /// rather than the integer proc-macro path. Replaces the `[temporal]` marker. + /// True for chrono-backed temporal kinds (`Date`, `Timestamptz`) — the kinds + /// whose test `ScalarType` impl is generated by `temporal_values!` rather + /// than the integer proc-macro path. Replaces the `[temporal]` marker. pub const fn is_temporal(self) -> bool { - matches!(self, ScalarKind::Date) + matches!(self, ScalarKind::Date | ScalarKind::Timestamptz) } /// The Rust type name as it appears in generated source (e.g. `"i32"`). @@ -145,6 +156,7 @@ impl ScalarKind { ScalarKind::Text => "text", ScalarKind::Jsonb => "jsonb", ScalarKind::Date => "chrono::NaiveDate", + ScalarKind::Timestamptz => "chrono::DateTime", } } } @@ -286,6 +298,10 @@ pub enum Fixture { /// the string is parsed into a `chrono::NaiveDate` in the SQLx harness, not /// here. Distinct by literal, like the other string-backed fixtures. Date(&'static str), + /// An RFC3339 UTC timestamp string (`"1970-01-01T00:00:00Z"`). The catalog + /// stays zero-dep, so the string is parsed into a `chrono::DateTime` in + /// the SQLx harness, not here. Distinct by literal, like `Date`. + Timestamptz(&'static str), } impl Fixture { @@ -311,7 +327,11 @@ impl Fixture { }, Fixture::Zero => Some(0), Fixture::Int(n) => Some(n), - Fixture::Numeric(_) | Fixture::Text(_) | Fixture::Jsonb(_) | Fixture::Date(_) => None, + Fixture::Numeric(_) + | Fixture::Text(_) + | Fixture::Jsonb(_) + | Fixture::Date(_) + | Fixture::Timestamptz(_) => None, } } @@ -336,7 +356,11 @@ impl Fixture { .zero_symbol() .to_string(), Fixture::Int(n) => n.to_string(), - Fixture::Numeric(s) | Fixture::Text(s) | Fixture::Jsonb(s) | Fixture::Date(s) => { + Fixture::Numeric(s) + | Fixture::Text(s) + | Fixture::Jsonb(s) + | Fixture::Date(s) + | Fixture::Timestamptz(s) => { format!("{s:?}") } } @@ -398,6 +422,24 @@ const ORDERED_INT_DOMAINS: &[DomainSpec] = &[ }, ]; +/// Equality-only domains: storage (no terms) + `_eq` (hm). Used by scalar types +/// that can hash for equality but cannot (yet) be ordered. `timestamptz` is the +/// first such type: cipherstash encrypts `Plaintext::Timestamp` at native +/// 12-block ORE width, but EQL's only ORE comparator +/// (`eql_v2.compare_ore_block_u64_8_256_term`) is hardcoded to 8 blocks, so an +/// ordered domain would silently mis-order. Ordering is deferred until a +/// wide-ORE (12-block) term exists. +const EQ_ONLY_DOMAINS: &[DomainSpec] = &[ + DomainSpec { + suffix: "", + terms: &[], + }, + DomainSpec { + suffix: "_eq", + terms: &[Term::Hm], + }, +]; + /// Builds a `&[Fixture]`. The `int ;` arm (a tt-muncher over `Min`/`Max`/ /// `Zero` and `N()`) range-checks each literal against `` at compile /// time via `const _RANGE_CHECK`, so out-of-range literals do not compile; @@ -418,6 +460,7 @@ macro_rules! fixtures { (numeric; $($s:literal),* $(,)?) => { &[$(Fixture::Numeric($s)),*] }; (jsonb; $($s:literal),* $(,)?) => { &[$(Fixture::Jsonb($s)),*] }; (date; $($s:literal),* $(,)?) => { &[$(Fixture::Date($s)),*] }; + (timestamptz; $($s:literal),* $(,)?) => { &[$(Fixture::Timestamptz($s)),*] }; } /// int4 fixture plaintexts — verbatim from `tasks/codegen/types/int4.toml`. @@ -453,6 +496,21 @@ const DATE_FIXTURES: &[Fixture] = fixtures!(date; "2012-06-30", "2016-03-15", "2020-10-21", "2024-02-29", "2038-01-19", "2099-12-31"); +/// timestamptz fixture plaintexts — RFC3339 UTC strings, parsed into +/// `chrono::DateTime` in the SQLx harness (the catalog stays zero-dep). +/// The three temporal pivots MUST be present verbatim: `"1900-01-01T00:00:00Z"` +/// (min_pivot), `"1970-01-01T00:00:00Z"` (zero = `DateTime::::default()`, +/// the Unix epoch), and `"2099-12-31T23:59:59Z"` (max_pivot) — the matrix +/// fetches each one's ciphertext via `fetch_fixture_payload`, which fails loudly +/// if a row is absent. The interior timestamps span varied dates AND times of +/// day so range operators yield distinguishable counts. All distinct. +const TIMESTAMPTZ_FIXTURES: &[Fixture] = fixtures!(timestamptz; + "1900-01-01T00:00:00Z", "1950-07-15T06:30:00Z", "1969-12-31T23:59:59Z", + "1970-01-01T00:00:00Z", "1970-01-01T00:00:01Z", "1985-04-12T23:20:50Z", + "1999-12-31T23:59:59Z", "2000-01-01T00:00:00Z", "2004-02-29T12:00:00Z", + "2012-06-30T11:59:59Z", "2016-03-15T08:15:30Z", "2020-10-21T14:45:00Z", + "2024-02-29T17:30:45Z", "2038-01-19T03:14:07Z", "2099-12-31T23:59:59Z"); + const INT4: ScalarSpec = ScalarSpec { token: "int4", kind: ScalarKind::I32, @@ -489,9 +547,30 @@ pub const DATE: ScalarSpec = ScalarSpec { fixtures: DATE_FIXTURES, }; +/// `timestamptz` — an **equality-only** (UTC-normalized) non-integer scalar. +/// Uses `EQ_ONLY_DOMAINS` (storage + `_eq`) rather than the four-domain ordered +/// shape: cipherstash encrypts `Plaintext::Timestamp` at native 12-block ORE +/// width, but EQL's only ORE comparator +/// (`eql_v2.compare_ore_block_u64_8_256_term`) is hardcoded to 8 blocks, so an +/// ordered timestamptz domain would silently mis-order. Ordering is deferred to +/// a future PR that adds a wide-ORE (12-block) term. The three "pivot" fixture +/// values are retained as equality pivots; the kind stays ordered-shaped +/// (carries a rust type, no i128 range) so the harness can parse them. +/// +/// Public (like `DATE`) because the SQLx harness reads `TIMESTAMPTZ.fixtures` +/// directly to parse the RFC3339 strings into `chrono::DateTime` at +/// runtime — there is no `TIMESTAMPTZ_VALUES` const (chrono is not +/// `const`-friendly and `eql-scalars` stays zero-dep). +pub const TIMESTAMPTZ: ScalarSpec = ScalarSpec { + token: "timestamptz", + kind: ScalarKind::Timestamptz, + domains: EQ_ONLY_DOMAINS, + fixtures: TIMESTAMPTZ_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, DATE]; +pub const CATALOG: &[ScalarSpec] = &[INT4, INT2, INT8, DATE, TIMESTAMPTZ]; /// Materialise an integer scalar's fixtures into a typed `&'static` slice at /// compile time. This is the **single-sourced** plaintext list the SQLx test @@ -581,6 +660,7 @@ mod rust_tests { assert_eq!(ScalarKind::Text.as_bounded_int(), None); assert_eq!(ScalarKind::Jsonb.as_bounded_int(), None); assert_eq!(ScalarKind::Date.as_bounded_int(), None); + assert_eq!(ScalarKind::Timestamptz.as_bounded_int(), None); } #[test] @@ -618,6 +698,7 @@ mod rust_tests { assert!(!ScalarKind::Text.is_int()); assert!(!ScalarKind::Jsonb.is_int()); assert!(!ScalarKind::Date.is_int()); + assert!(!ScalarKind::Timestamptz.is_int()); } #[test] @@ -669,10 +750,10 @@ mod rust_tests { #[test] fn is_temporal_classifies_chrono_kinds() { assert!(ScalarKind::Date.is_temporal()); + assert!(ScalarKind::Timestamptz.is_temporal()); assert!(!ScalarKind::I16.is_temporal()); assert!(!ScalarKind::I32.is_temporal()); assert!(!ScalarKind::I64.is_temporal()); - // Timestamptz arrives in Phase 5; assert it here once present. } #[test] @@ -681,6 +762,18 @@ mod rust_tests { assert!(!int4.is_eq_only(), "int4 is ordered"); let date = CATALOG.iter().find(|s| s.token == "date").unwrap(); assert!(!date.is_eq_only(), "date is ordered"); + let ts = CATALOG.iter().find(|s| s.token == "timestamptz").unwrap(); + assert!(ts.is_eq_only(), "timestamptz is equality-only"); + } + + #[test] + fn timestamptz_maps_to_datetime() { + // Temporal, non-integer, equality-only kind: it carries a rust type but + // no i128 range, so it is not `is_int()` and `as_bounded_int()` returns + // `None` — the bounded accessors are not reachable for it. + assert_eq!(ScalarKind::Timestamptz.rust_type(), "chrono::DateTime"); + assert!(!ScalarKind::Timestamptz.is_int()); + assert_eq!(ScalarKind::Timestamptz.as_bounded_int(), None); } } @@ -846,6 +939,10 @@ mod fixture_tests { Fixture::Date("1970-01-01").numeric_value(ScalarKind::Date), None ); + assert_eq!( + Fixture::Timestamptz("1970-01-01T00:00:00Z").numeric_value(ScalarKind::Timestamptz), + None + ); } #[test] @@ -886,6 +983,10 @@ mod fixture_tests { Fixture::Date("1970-01-01").render_literal(ScalarKind::Date), "\"1970-01-01\"" ); + assert_eq!( + Fixture::Timestamptz("1970-01-01T00:00:00Z").render_literal(ScalarKind::Timestamptz), + "\"1970-01-01T00:00:00Z\"" + ); } #[test] @@ -914,6 +1015,15 @@ mod fixture_tests { DATES, &[Fixture::Date("1970-01-01"), Fixture::Date("2099-12-31")] ); + const STAMPS: &[Fixture] = + fixtures!(timestamptz; "1970-01-01T00:00:00Z", "2099-12-31T23:59:59Z"); + assert_eq!( + STAMPS, + &[ + Fixture::Timestamptz("1970-01-01T00:00:00Z"), + Fixture::Timestamptz("2099-12-31T23:59:59Z") + ] + ); } #[test] @@ -946,9 +1056,9 @@ mod catalog_tests { } #[test] - fn catalog_has_int4_int2_int8_date_in_order() { + fn catalog_has_int4_int2_int8_date_timestamptz_in_order() { let tokens: Vec<&str> = CATALOG.iter().map(|s| s.token).collect(); - assert_eq!(tokens, vec!["int4", "int2", "int8", "date"]); + assert_eq!(tokens, vec!["int4", "int2", "int8", "date", "timestamptz"]); } /// The three temporal matrix pivots must be present verbatim in DATE's @@ -975,26 +1085,71 @@ mod catalog_tests { } } + /// The three temporal matrix pivots must be present verbatim in + /// TIMESTAMPTZ's fixture strings — the timestamptz analogue of + /// `temporal_fixtures_include_pivot_plaintexts`. + #[test] + fn timestamptz_fixtures_include_pivot_plaintexts() { + let ts = scalar("timestamptz"); + let strings: Vec<&str> = ts + .fixtures + .iter() + .filter_map(|f| match f { + Fixture::Timestamptz(s) => Some(*s), + _ => None, + }) + .collect(); + for pivot in [ + "1900-01-01T00:00:00Z", + "1970-01-01T00:00:00Z", + "2099-12-31T23:59:59Z", + ] { + assert!( + strings.contains(&pivot), + "timestamptz fixtures missing temporal pivot {pivot}" + ); + } + } + #[test] - fn all_types_share_the_same_domain_shape() { - // Every scalar declares the same four domains with the same terms; - // only the token differs (the matrix-snapshot collapse depends on this). - // Generic over CATALOG, so it covers every type — including new ones — - // and subsumes the old per-type `_maps_to_*_with_four_domains` / - // `_domain_terms_match_manifest` tests (which only restated the - // catalog literal for one token). + fn every_type_uses_a_known_domain_shape() { + // Each scalar's domain shape must be one of the two known-valid shapes: + // the four-domain ORDERED shape (storage + `_eq` + `_ord_ore` + `_ord`) + // or the two-domain EQ-ONLY shape (storage + `_eq`). This catches + // accidental drift — a typo'd suffix, 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][..]), + ]; + let eq_only: Vec<(&str, &[Term])> = vec![("", &[] as &[Term]), ("_eq", &[Term::Hm][..])]; for s in CATALOG { let shape: Vec<(&str, &[Term])> = s.domains.iter().map(|d| (d.suffix, d.terms)).collect(); + assert!( + shape == ordered || shape == eq_only, + "{} has an unrecognised domain shape: {shape:?}", + s.token + ); + } + } + + #[test] + fn ordered_and_eq_only_shapes_are_used_as_declared() { + // Pin which catalog tokens carry which shape, so a row silently flipping + // ORDERED_INT_DOMAINS <-> EQ_ONLY_DOMAINS is caught. timestamptz is + // equality-only (12-block ORE vs 8-block comparator); the rest ordered. + for s in CATALOG { + let is_eq_only = s.domains.len() == 2; + let expect_eq_only = s.token == "timestamptz"; assert_eq!( - shape, - vec![ - ("", &[] as &[Term]), - ("_eq", &[Term::Hm][..]), - ("_ord_ore", &[Term::Ore][..]), - ("_ord", &[Term::Ore][..]), - ], - "{} has unexpected domain shape", + is_eq_only, expect_eq_only, + "{} domain shape (eq_only={is_eq_only}) does not match expectation", s.token ); } @@ -1098,9 +1253,11 @@ mod invariant_tests { fn distinct_key(f: Fixture, kind: ScalarKind) -> DistinctKey { match f { - Fixture::Numeric(s) | Fixture::Text(s) | Fixture::Jsonb(s) | Fixture::Date(s) => { - DistinctKey::Str(s) - } + Fixture::Numeric(s) + | Fixture::Text(s) + | Fixture::Jsonb(s) + | Fixture::Date(s) + | Fixture::Timestamptz(s) => DistinctKey::Str(s), _ => DistinctKey::Num( f.numeric_value(kind) .expect("sentinel/Int fixtures resolve to a number"), diff --git a/tests/sqlx/src/fixtures/eql_plaintext.rs b/tests/sqlx/src/fixtures/eql_plaintext.rs index 65c9f43e..5cb88099 100644 --- a/tests/sqlx/src/fixtures/eql_plaintext.rs +++ b/tests/sqlx/src/fixtures/eql_plaintext.rs @@ -57,6 +57,7 @@ impl PlaintextSqlType { pub const SMALLINT: PlaintextSqlType = PlaintextSqlType("smallint"); pub const BIGINT: PlaintextSqlType = PlaintextSqlType("bigint"); pub const DATE: PlaintextSqlType = PlaintextSqlType("date"); + pub const TIMESTAMPTZ: PlaintextSqlType = PlaintextSqlType("timestamp with time zone"); pub fn as_str(&self) -> &'static str { self.0 @@ -71,15 +72,17 @@ impl fmt::Display for PlaintextSqlType { /// The EQL `cast_as` for a scalar kind, drawn from the `Cast` allowlist. /// -/// 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. +/// Only the wired kinds (the integer kinds plus `Date` / `Timestamptz`) 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::Timestamptz => Cast::TIMESTAMP, ScalarKind::Numeric | ScalarKind::Text | ScalarKind::Jsonb => { panic!("EqlPlaintext is only implemented for the wired scalar kinds") } @@ -88,13 +91,14 @@ const fn cast_for_kind(kind: ScalarKind) -> Cast { /// The `plaintext` oracle column SQL type for a scalar kind, drawn from the /// `PlaintextSqlType` allowlist. As with `cast_for_kind`, only the wired kinds -/// (integers plus `Date`) resolve. +/// (integers plus `Date` / `Timestamptz`) 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::Timestamptz => PlaintextSqlType::TIMESTAMPTZ, ScalarKind::Numeric | ScalarKind::Text | ScalarKind::Jsonb => { panic!("EqlPlaintext is only implemented for the wired scalar kinds") } @@ -107,6 +111,7 @@ mod sealed { impl Sealed for i16 {} impl Sealed for i64 {} impl Sealed for chrono::NaiveDate {} + impl Sealed for chrono::DateTime {} } /// A Rust type usable as a fixture `plaintext` value, carrying its EQL cast @@ -166,6 +171,14 @@ impl EqlPlaintext for chrono::NaiveDate { } } +impl EqlPlaintext for chrono::DateTime { + const KIND: ScalarKind = ScalarKind::Timestamptz; + + fn to_plaintext(&self) -> Plaintext { + Plaintext::Timestamp(Some(*self)) + } +} + #[cfg(test)] mod tests { use super::*; @@ -259,4 +272,33 @@ mod tests { other => panic!("expected Plaintext::NaiveDate(Some(1970-01-01)), got {other:?}"), } } + + #[test] + fn datetime_utc_casts_to_timestamp() { + // timestamptz is UTC-normalized — cipherstash has no tz-preserving + // type, so it encrypts under the `timestamp` cast. + assert_eq!( + as EqlPlaintext>::CAST.as_str(), + "timestamp" + ); + } + + #[test] + fn datetime_utc_plaintext_sql_type_is_timestamptz() { + assert_eq!( + as EqlPlaintext>::PLAINTEXT_SQL_TYPE.as_str(), + "timestamp with time zone" + ); + } + + #[test] + fn datetime_utc_to_plaintext_wraps_in_timestamp_variant() { + // A DateTime must lift into the Timestamp variant so the fixture + // driver encrypts it under the `timestamp` cast. + let ts = chrono::DateTime::::default(); + match ts.to_plaintext() { + Plaintext::Timestamp(Some(value)) => assert_eq!(value, ts), + other => panic!("expected Plaintext::Timestamp(Some(epoch)), got {other:?}"), + } + } } diff --git a/tests/sqlx/src/scalar_domains.rs b/tests/sqlx/src/scalar_domains.rs index 5b304a37..8e8049c4 100644 --- a/tests/sqlx/src/scalar_domains.rs +++ b/tests/sqlx/src/scalar_domains.rs @@ -205,6 +205,31 @@ temporal_values! { sql_lit = |v| format!("'{v}'"), } +// `timestamptz`'s `ScalarType` wiring, generated from its catalog row by the +// same `temporal_values!` path as `date`. timestamptz is equality-only (its +// catalog row uses the eq-only domain shape), but the *value* wiring is +// identical to any temporal scalar: RFC3339 strings parsed once into +// `DateTime` behind `timestamptz_values()`. The pivots are retained as the +// three equality anchors the matrix sweeps. +temporal_values! { + cell = TIMESTAMPTZ_VALUES_CELL, + accessor = timestamptz_values, + rust_type = chrono::DateTime, + spec = eql_scalars::TIMESTAMPTZ, + variant = Timestamptz, + pg_type = "timestamptz", + parse = |s| chrono::DateTime::parse_from_rfc3339(s) + .expect("catalog timestamptz fixture must be RFC3339") + .with_timezone(&chrono::Utc), + min_pivot = "1900-01-01T00:00:00Z" + .parse() + .expect("1900-01-01T00:00:00Z is a valid timestamp"), + max_pivot = "2099-12-31T23:59:59Z" + .parse() + .expect("2099-12-31T23:59:59Z is a valid timestamp"), + sql_lit = |v| format!("'{}'", v.to_rfc3339()), +} + /// 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 feeca605..5ab94293 100644 --- a/tests/sqlx/src/scalar_types.rs +++ b/tests/sqlx/src/scalar_types.rs @@ -55,6 +55,7 @@ macro_rules! scalar_types { int2 => i16, int8 => i64, date => chrono::NaiveDate, + timestamptz => chrono::DateTime, } }; }