Skip to content

Commit 60d1898

Browse files
authored
Merge pull request #294 from cipherstash/eql-v3-ore-block-256
feat(v3): N-block ORE comparator + ordered numeric & timestamptz
2 parents 6e6d27c + 43c4b01 commit 60d1898

69 files changed

Lines changed: 6108 additions & 113 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ tests/sqlx/migrations/001_install_eql.sql
227227
tests/sqlx/fixtures/eql_v2*
228228
tests/sqlx/fixtures/v3_ste_vec.sql
229229
tests/sqlx/fixtures/v3_doc_int4.sql
230+
tests/sqlx/fixtures/v3_numeric_collision.sql
230231

231232
# Generated encrypted-domain SQL — regenerated by `tasks/build.sh` from the
232233
# eql-scalars::CATALOG via `cargo run -p eql-codegen` on every build. The

CHANGELOG.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ Each entry that ships in a published release links to the PR that introduced it.
2727
- **`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))
2828
- **`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))
2929
- **`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))
30-
- **`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))
30+
- **`eql_v3.timestamptz` encrypted-domain type family (ordered).** Four jsonb-backed domains for encrypted `timestamptz` columns — `eql_v3.timestamptz` (storage-only), `eql_v3.timestamptz_eq` (`=` / `<>` via HMAC), and `eql_v3.timestamptz_ord` / `eql_v3.timestamptz_ord_ore` (also `<` `<=` `>` `>=`, `MIN` / `MAX` via 12-block ORE) — 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. Ordering works because the `eql_v3` ORE block comparator now derives its block count from the ciphertext width (see the comparator entry below) instead of assuming 8. Index via a functional index on the `eql_v3.eq_term` / `eql_v3.ord_term` extractors, not an operator class on the domain. ([#257](https://github.com/cipherstash/encrypt-query-language/pull/257))
31+
- **`eql_v3.numeric` encrypted-domain type family (ordered).** Four jsonb-backed domains for encrypted `numeric` / `decimal` columns — `eql_v3.numeric` (storage-only), `eql_v3.numeric_eq` (`=` / `<>` via HMAC), and `eql_v3.numeric_ord` / `eql_v3.numeric_ord_ore` (also `<` `<=` `>` `>=`, `MIN` / `MAX` via 14-block ORE) — generated from the `numeric` row in `eql-scalars::CATALOG`. cipherstash encrypts `Plaintext::Decimal` at native 14-block ORE width; ordering matches `rust_decimal::Decimal` ordering exactly (equivalent scales such as `1` and `1.0` collide, like Postgres `numeric`). Index via a functional index on the `eql_v3.eq_term` / `eql_v3.ord_term` extractors. Why: a type-safe, ordered encrypted decimal column, the first scalar to exercise an ORE term wider than 8 blocks. ([#241](https://github.com/cipherstash/encrypt-query-language/issues/241), [#276](https://github.com/cipherstash/encrypt-query-language/pull/276))
3132
- **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))
3233
- **`eql_v3.text` encrypted-domain family (`text`, `text_eq`, `text_match`, `text_ord`, `text_ord_ore`, `text_search`).** Adds equality (`=` / `<>` via HMAC), match (`@>` / `<@` via a new self-contained `eql_v3.bloom_filter` SEM index term), and ORE ordering (`<` `<=` `>` `>=`, `min` / `max`) for encrypted text, at parity with EQL v2 text — generated from the `text` row in `eql-scalars::CATALOG` by the same materializer as the `eql_v3.int4` reference. `text` is the first scalar to add a new index `Term` (`Bloom`) and the first non-integer, unbounded ordered kind (lexicographic pivots, hand-written `impl ScalarType`). The combined **`text_search`** domain carries all three capabilities in one type — `=` / `<>` via HMAC, `<` `<=` `>` `>=` / `min` / `max` via ORE, and `@>` / `<@` via bloom filter. Index via a functional index on the `eql_v3.eq_term` / `eql_v3.ord_term` / `eql_v3.match_term` extractors, not an operator class on the domain. Why: brings searchable encrypted text to the namespaced, `eql_v2`-free `eql_v3` surface. Match is exposed as bloom-filter containment on the `text_match` / `text_search` domains — deliberately *not* SQL `LIKE` (no wildcard/anchoring; probabilistic ngram containment) — and never backs equality. **Equality on the ordered text domains (`text_ord`, `text_ord_ore`) and on `text_search` always routes `=` / `<>` through `hm` (exact HMAC), never the ORE term — ORE is not exact-equality for text** (integer ordered domains keep exact ORE equality, which is lossless for them). ([#260](https://github.com/cipherstash/encrypt-query-language/pull/260))
3334
- **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_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))
@@ -37,6 +38,10 @@ Each entry that ships in a published release links to the PR that introduced it.
3738

3839
- **Scalar encrypted-domain types are now defined in a Rust catalog, not TOML manifests; the Python codegen toolchain is removed.** Adding a scalar encrypted-domain type (`int4`, `int8`, …) is now one row in `eql-scalars::CATALOG` (`crates/eql-scalars/src/lib.rs`) instead of authoring `tasks/codegen/types/<T>.toml`. `mise run build` regenerates the gitignored SQL surface via `cargo run -p eql-codegen` (Rust, std-only) rather than the Python generator. The catalog row's `Fixture` list is the single source of truth for that type's plaintext fixtures: the SQLx test matrix reads it directly as a compile-time-materialised const (`eql_scalars::INT4_VALUES` / `INT2_VALUES`, `ScalarType::FIXTURE_VALUES`), so there is no longer a generated, committed `tests/sqlx/src/fixtures/<T>_values.rs` — a Rust source of truth no longer round-trips through generated Rust. The shipped SQL is unchanged — `release/*.sql` is byte-identical across the cutover — so there is no change for callers installing EQL; this only affects contributors who extend the scalar domain families. The `python` mise tool, the `pytest`-based `test:codegen` (now `cargo test -p eql-scalars -p eql-codegen`), the per-type `mise run codegen:domain` tasks, and the per-type `tests/sqlx/snapshots/<T>_matrix_tests.txt` baselines (collapsed into one catalog-reconciled `tests/sqlx/snapshots/matrix_tests.txt`) are gone. Why: a single compiler-validated source of truth shared by the generator and the SQLx test harness, and one fewer toolchain in the build/test path — building and testing EQL no longer needs Python (Python remains only for the separate docs-markdown tooling). ([#252](https://github.com/cipherstash/encrypt-query-language/pull/252))
3940

41+
### Fixed
42+
43+
- **The `eql_v3` ORE block comparator now orders ciphertexts of any block count, not just 8.** `eql_v3.compare_ore_block_256_term` derives the block count `N` from the term length (`octet_length = 49·N + 16`) instead of hardcoding 8, so encrypted types whose native ORE width exceeds 8 blocks — `numeric` (14) and `timestamptz` (12) — order, range-query, `ORDER BY`, and `MIN`/`MAX` correctly instead of silently mis-ordering. Malformed terms (length not `49·N + 16` for `N ≥ 1`) now raise instead of returning a bogus comparison. The self-contained `eql_v3` SEM type was renamed `eql_v3.ore_block_u64_8_256 → eql_v3.ore_block_256` to reflect that it is width-agnostic (the `eql_v2` type is unchanged). No effect on existing 8-block types (a no-op for `N = 8`). ([#241](https://github.com/cipherstash/encrypt-query-language/issues/241), [#276](https://github.com/cipherstash/encrypt-query-language/pull/276))
44+
4045
## [2.3.1] — 2026-05-21
4146

4247
### Fixed

Cargo.lock

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/eql-scalars/src/kind.rs

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -97,11 +97,11 @@ impl ScalarKind {
9797
}
9898

9999
/// A debug/identifier string for the kind: the canonical Rust plaintext type
100-
/// name (`"i32"`, `"chrono::NaiveDate"`). `Numeric`/`Jsonb` have **no
101-
/// generated SQL surface** and no catalog row, so calling this on them is a
102-
/// programming error and panics loudly rather than returning a plausible SQL
103-
/// token a premature caller might feed into codegen. Only call site today is
104-
/// `crates/eql-scalars/src/tests.rs`.
100+
/// name (`"i32"`, `"chrono::NaiveDate"`, `"rust_decimal::Decimal"`). `Jsonb`
101+
/// has **no generated SQL surface** and no catalog row, so calling this on it
102+
/// is a programming error and panics loudly rather than returning a plausible
103+
/// SQL token a premature caller might feed into codegen. Only call site today
104+
/// is `crates/eql-scalars/src/tests.rs`.
105105
pub const fn rust_type(self) -> &'static str {
106106
match self {
107107
ScalarKind::I16 => "i16",
@@ -110,8 +110,9 @@ impl ScalarKind {
110110
ScalarKind::Text => "text",
111111
ScalarKind::Date => "chrono::NaiveDate",
112112
ScalarKind::Timestamptz => "chrono::DateTime<Utc>",
113-
ScalarKind::Numeric | ScalarKind::Jsonb => {
114-
panic!("ScalarKind::rust_type: numeric/jsonb have no generated surface yet")
113+
ScalarKind::Numeric => "rust_decimal::Decimal",
114+
ScalarKind::Jsonb => {
115+
panic!("ScalarKind::rust_type: jsonb has no generated surface yet")
115116
}
116117
}
117118
}

0 commit comments

Comments
 (0)