Skip to content

Commit 49e6d01

Browse files
authored
Merge pull request #257 from cipherstash/v3-domain-type-timestamptz
feat(scalars): add eql_v3.timestamptz encrypted-domain type (equality-only)
2 parents a0af113 + e0177f3 commit 49e6d01

9 files changed

Lines changed: 314 additions & 34 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ Each entry that ships in a published release links to the PR that introduced it.
2626
- **`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))
2727
- **`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))
2828
- **`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))
29+
- **`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))
2930
- **Per-domain `MIN` / `MAX` aggregates for the encrypted-domain family.** `eql_v3.min(eql_v3.<T>_ord)` / `eql_v3.max(eql_v3.<T>_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))
3031
- **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.<symbol>` 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))
3132

crates/eql-scalars/src/fixture.rs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,11 @@ impl Fixture {
2727
},
2828
Fixture::Zero => Some(0),
2929
Fixture::Int(n) => Some(n),
30-
Fixture::Numeric(_) | Fixture::Text(_) | Fixture::Jsonb(_) | Fixture::Date(_) => None,
30+
Fixture::Numeric(_)
31+
| Fixture::Text(_)
32+
| Fixture::Jsonb(_)
33+
| Fixture::Date(_)
34+
| Fixture::Timestamptz(_) => None,
3135
}
3236
}
3337

@@ -52,7 +56,11 @@ impl Fixture {
5256
.zero_symbol()
5357
.to_string(),
5458
Fixture::Int(n) => n.to_string(),
55-
Fixture::Numeric(s) | Fixture::Text(s) | Fixture::Jsonb(s) | Fixture::Date(s) => {
59+
Fixture::Numeric(s)
60+
| Fixture::Text(s)
61+
| Fixture::Jsonb(s)
62+
| Fixture::Date(s)
63+
| Fixture::Timestamptz(s) => {
5664
format!("{s:?}")
5765
}
5866
}

crates/eql-scalars/src/kind.rs

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,11 @@ impl ScalarKind {
6868
ScalarKind::I16 => Some(BoundedIntKind::I16),
6969
ScalarKind::I32 => Some(BoundedIntKind::I32),
7070
ScalarKind::I64 => Some(BoundedIntKind::I64),
71-
ScalarKind::Numeric | ScalarKind::Text | ScalarKind::Jsonb | ScalarKind::Date => None,
71+
ScalarKind::Numeric
72+
| ScalarKind::Text
73+
| ScalarKind::Jsonb
74+
| ScalarKind::Date
75+
| ScalarKind::Timestamptz => None,
7276
}
7377
}
7478

@@ -78,11 +82,11 @@ impl ScalarKind {
7882
self.as_bounded_int().is_some()
7983
}
8084

81-
/// True for chrono-backed temporal kinds (`Date`; `Timestamptz` once added) —
82-
/// the kinds whose test `ScalarType` impl is generated by `temporal_values!`
83-
/// rather than the integer proc-macro path. Replaces the `[temporal]` marker.
85+
/// True for chrono-backed temporal kinds (`Date`, `Timestamptz`) — the kinds
86+
/// whose test `ScalarType` impl is generated by `temporal_values!` rather
87+
/// than the integer proc-macro path. Replaces the `[temporal]` marker.
8488
pub const fn is_temporal(self) -> bool {
85-
matches!(self, ScalarKind::Date)
89+
matches!(self, ScalarKind::Date | ScalarKind::Timestamptz)
8690
}
8791

8892
/// A debug/identifier string for the kind. For the codegen-supported kinds
@@ -101,6 +105,7 @@ impl ScalarKind {
101105
ScalarKind::Text => "text",
102106
ScalarKind::Jsonb => "jsonb",
103107
ScalarKind::Date => "chrono::NaiveDate",
108+
ScalarKind::Timestamptz => "chrono::DateTime<Utc>",
104109
}
105110
}
106111
}

crates/eql-scalars/src/lib.rs

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,13 @@ pub enum ScalarKind {
6464
/// live on `BoundedIntKind`, which `Date` cannot be, so they are
6565
/// unreachable for it by construction rather than by a runtime panic.
6666
Date,
67+
/// UTC timestamp (`chrono::DateTime<Utc>`). Ordered like the integer kinds
68+
/// via ORE, but string-backed (RFC3339) at the catalog layer and with no
69+
/// i128 range — so it is *not* `is_int()` and the bounded-numeric accessors
70+
/// panic for it, exactly like the other non-integer kinds. UTC-normalized:
71+
/// cipherstash has no tz-preserving type, so it maps to the `timestamp`
72+
/// cast and the SQL `timestamp with time zone` plaintext type.
73+
Timestamptz,
6774
}
6875

6976
/// A fixed index term known to the scalar materializer.
@@ -99,6 +106,10 @@ pub enum Fixture {
99106
/// the string is parsed into a `chrono::NaiveDate` in the SQLx harness, not
100107
/// here. Distinct by literal, like the other string-backed fixtures.
101108
Date(&'static str),
109+
/// An RFC3339 UTC timestamp string (`"1970-01-01T00:00:00Z"`). The catalog
110+
/// stays zero-dep, so the string is parsed into a `chrono::DateTime<Utc>` in
111+
/// the SQLx harness, not here. Distinct by literal, like `Date`.
112+
Timestamptz(&'static str),
102113
}
103114

104115
/// One generated public domain: a suffix appended to the type token and the
@@ -140,6 +151,7 @@ macro_rules! fixtures {
140151
(numeric; $($s:literal),* $(,)?) => { &[$(Fixture::Numeric($s)),*] };
141152
(jsonb; $($s:literal),* $(,)?) => { &[$(Fixture::Jsonb($s)),*] };
142153
(date; $($s:literal),* $(,)?) => { &[$(Fixture::Date($s)),*] };
154+
(timestamptz; $($s:literal),* $(,)?) => { &[$(Fixture::Timestamptz($s)),*] };
143155
}
144156

145157
/// Domains shared by every ordered-integer scalar, in manifest file order:
@@ -163,6 +175,24 @@ const ORDERED_INT_DOMAINS: &[DomainSpec] = &[
163175
},
164176
];
165177

178+
/// Equality-only domains: storage (no terms) + `_eq` (hm). Used by scalar types
179+
/// that can hash for equality but cannot (yet) be ordered. `timestamptz` is the
180+
/// first such type: cipherstash encrypts `Plaintext::Timestamp` at native
181+
/// 12-block ORE width, but EQL's only ORE comparator
182+
/// (`eql_v2.compare_ore_block_u64_8_256_term`) is hardcoded to 8 blocks, so an
183+
/// ordered domain would silently mis-order. Ordering is deferred until a
184+
/// wide-ORE (12-block) term exists.
185+
const EQ_ONLY_DOMAINS: &[DomainSpec] = &[
186+
DomainSpec {
187+
suffix: "",
188+
terms: &[],
189+
},
190+
DomainSpec {
191+
suffix: "_eq",
192+
terms: &[Term::Hm],
193+
},
194+
];
195+
166196
/// int4 fixture plaintexts.
167197
/// `N(..)` literals are range-checked against `i32` at compile time.
168198
const INT4_FIXTURES: &[Fixture] = fixtures!(int i32;
@@ -196,6 +226,21 @@ const DATE_FIXTURES: &[Fixture] = fixtures!(date;
196226
"2012-06-30", "2016-03-15", "2020-10-21", "2024-02-29", "2038-01-19",
197227
"2099-12-31");
198228

229+
/// timestamptz fixture plaintexts — RFC3339 UTC strings, parsed into
230+
/// `chrono::DateTime<Utc>` in the SQLx harness (the catalog stays zero-dep).
231+
/// The three temporal pivots MUST be present verbatim: `"1900-01-01T00:00:00Z"`
232+
/// (min_pivot), `"1970-01-01T00:00:00Z"` (zero = `DateTime::<Utc>::default()`,
233+
/// the Unix epoch), and `"2099-12-31T23:59:59Z"` (max_pivot) — the matrix
234+
/// fetches each one's ciphertext via `fetch_fixture_payload`, which fails loudly
235+
/// if a row is absent. The interior timestamps span varied dates AND times of
236+
/// day so range operators yield distinguishable counts. All distinct.
237+
const TIMESTAMPTZ_FIXTURES: &[Fixture] = fixtures!(timestamptz;
238+
"1900-01-01T00:00:00Z", "1950-07-15T06:30:00Z", "1969-12-31T23:59:59Z",
239+
"1970-01-01T00:00:00Z", "1970-01-01T00:00:01Z", "1985-04-12T23:20:50Z",
240+
"1999-12-31T23:59:59Z", "2000-01-01T00:00:00Z", "2004-02-29T12:00:00Z",
241+
"2012-06-30T11:59:59Z", "2016-03-15T08:15:30Z", "2020-10-21T14:45:00Z",
242+
"2024-02-29T17:30:45Z", "2038-01-19T03:14:07Z", "2099-12-31T23:59:59Z");
243+
199244
const INT4: ScalarSpec = ScalarSpec {
200245
token: "int4",
201246
kind: ScalarKind::I32,
@@ -232,9 +277,30 @@ pub const DATE: ScalarSpec = ScalarSpec {
232277
fixtures: DATE_FIXTURES,
233278
};
234279

280+
/// `timestamptz` — an **equality-only** (UTC-normalized) non-integer scalar.
281+
/// Uses `EQ_ONLY_DOMAINS` (storage + `_eq`) rather than the four-domain ordered
282+
/// shape: cipherstash encrypts `Plaintext::Timestamp` at native 12-block ORE
283+
/// width, but EQL's only ORE comparator
284+
/// (`eql_v2.compare_ore_block_u64_8_256_term`) is hardcoded to 8 blocks, so an
285+
/// ordered timestamptz domain would silently mis-order. Ordering is deferred to
286+
/// a future PR that adds a wide-ORE (12-block) term. The three "pivot" fixture
287+
/// values are retained as equality pivots; the kind stays ordered-shaped
288+
/// (carries a rust type, no i128 range) so the harness can parse them.
289+
///
290+
/// Public (like `DATE`) because the SQLx harness reads `TIMESTAMPTZ.fixtures`
291+
/// directly to parse the RFC3339 strings into `chrono::DateTime<Utc>` at
292+
/// runtime — there is no `TIMESTAMPTZ_VALUES` const (chrono is not
293+
/// `const`-friendly and `eql-scalars` stays zero-dep).
294+
pub const TIMESTAMPTZ: ScalarSpec = ScalarSpec {
295+
token: "timestamptz",
296+
kind: ScalarKind::Timestamptz,
297+
domains: EQ_ONLY_DOMAINS,
298+
fixtures: TIMESTAMPTZ_FIXTURES,
299+
};
300+
235301
/// The scalar catalog — the single source of truth. Order is significant (it
236302
/// drives generation order). New types are appended as their SQL surface lands.
237-
pub const CATALOG: &[ScalarSpec] = &[INT4, INT2, INT8, DATE];
303+
pub const CATALOG: &[ScalarSpec] = &[INT4, INT2, INT8, DATE, TIMESTAMPTZ];
238304

239305
/// Materialise an integer scalar's fixtures into a typed `&'static` slice at
240306
/// compile time. This is the **single-sourced** plaintext list the SQLx test

0 commit comments

Comments
 (0)