Skip to content

Commit 4418dc2

Browse files
committed
feat(eql-types): add numeric + promote timestamptz to ordered domains
The eql-types crate landed on eql_v3 (PR #236) after this branch forked, so merging the base in surfaced a catalog_parity failure: CATALOG now has the numeric family and ordered timestamptz, but v3::all() didn't. - numeric.rs: four ordered domains (storage/_eq/_ord/_ord_ore), mirroring date.rs; numeric is the first scalar with a >8-block ORE term (14) - timestamptz.rs: add the two ordered domains; the eq-only/8-block-limit rationale is gone now that eql_v3.ore_block_256 derives N from term length - mod.rs: register the six new domains in all(), in CATALOG order - terms.rs: the ob term's SQL constructor is eql_v3.ore_block_256 (renamed this branch) and is width-agnostic (8/12/14 blocks) - v3_conformance.rs: cover numeric + timestamptz ord wire shapes; drop the stale equality-only claim - README: timestamptz no longer eq-only; add numeric
1 parent a7af784 commit 4418dc2

6 files changed

Lines changed: 232 additions & 21 deletions

File tree

crates/eql-types/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ hand-copying.
2121

2222
The [`src/v3/`](src/v3/) module has one type per **SQL domain** in the
2323
`eql_v3` schema — `Int4` / `Int4Eq` / `Int4Ord` / `Int4OrdOre`, and likewise
24-
for `int2`, `int8`, `date`, `timestamptz` (eq-only), and `text` (which adds
24+
for `int2`, `int8`, `date`, `timestamptz`, `numeric`, and `text` (which adds
2525
`TextMatch`) — each carrying its index terms as **required** fields. The
2626
capability is the type identity; `Option` never appears. A payload missing
2727
its term key fails to deserialize: the Rust analogue of the SQL domain's

crates/eql-types/src/v3/mod.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ pub mod date;
5050
pub mod int2;
5151
pub mod int4;
5252
pub mod int8;
53+
pub mod numeric;
5354
pub mod terms;
5455
pub mod text;
5556
pub mod timestamptz;
@@ -130,6 +131,12 @@ pub fn all() -> Vec<Box<dyn DomainType>> {
130131
Box::new(PhantomData::<date::DateOrd>),
131132
Box::new(PhantomData::<timestamptz::Timestamptz>),
132133
Box::new(PhantomData::<timestamptz::TimestamptzEq>),
134+
Box::new(PhantomData::<timestamptz::TimestamptzOrdOre>),
135+
Box::new(PhantomData::<timestamptz::TimestamptzOrd>),
136+
Box::new(PhantomData::<numeric::Numeric>),
137+
Box::new(PhantomData::<numeric::NumericEq>),
138+
Box::new(PhantomData::<numeric::NumericOrdOre>),
139+
Box::new(PhantomData::<numeric::NumericOrd>),
133140
Box::new(PhantomData::<text::Text>),
134141
Box::new(PhantomData::<text::TextEq>),
135142
Box::new(PhantomData::<text::TextMatch>),

crates/eql-types/src/v3/numeric.rs

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
//! The `numeric` encrypted-domain family — an ordered, non-integer scalar
2+
//! backed by `rust_decimal::Decimal`. Same four-domain ordered shape as
3+
//! [`crate::v3::int4`] (ORE compares ciphertext, so decimals order like
4+
//! integers); see that module for the capability table.
5+
//!
6+
//! `numeric` is the first scalar whose native ORE term is wider than 8 blocks
7+
//! (14 blocks): the wire shape is unchanged — the `ob` array simply carries
8+
//! more block strings — and the generalized `eql_v3.ore_block_256` comparator
9+
//! orders any block count, so no new type is needed here.
10+
11+
use crate::v3::terms::{Ciphertext, Hmac256, OreBlockU64_8_256};
12+
use crate::v3::DomainType;
13+
use crate::{Identifier, SchemaVersion};
14+
use serde::{Deserialize, Serialize};
15+
16+
/// `eql_v3.numeric` — storage only; every operator is blocked.
17+
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
18+
#[serde(deny_unknown_fields)]
19+
pub struct Numeric {
20+
/// Envelope version — always `2` (`EQL_SCHEMA_VERSION`); any other
21+
/// value fails deserialization.
22+
pub v: SchemaVersion,
23+
/// Table/column identifier. Required by the domain CHECK.
24+
pub i: Identifier,
25+
/// mp_base85 source ciphertext. Required by the domain CHECK.
26+
pub c: Ciphertext,
27+
}
28+
29+
impl DomainType for Numeric {
30+
fn sql_domain_static() -> &'static str {
31+
"eql_v3.numeric"
32+
}
33+
34+
fn sql_domain(&self) -> &'static str {
35+
Self::sql_domain_static()
36+
}
37+
}
38+
39+
/// `eql_v3.numeric_eq` — HMAC equality (`=`, `<>`).
40+
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
41+
#[serde(deny_unknown_fields)]
42+
pub struct NumericEq {
43+
/// Envelope version — always `2` (`EQL_SCHEMA_VERSION`); any other
44+
/// value fails deserialization.
45+
pub v: SchemaVersion,
46+
/// Table/column identifier. Required by the domain CHECK.
47+
pub i: Identifier,
48+
/// mp_base85 source ciphertext. Required by the domain CHECK.
49+
pub c: Ciphertext,
50+
/// HMAC-SHA-256 equality term.
51+
pub hm: Hmac256,
52+
}
53+
54+
impl DomainType for NumericEq {
55+
fn sql_domain_static() -> &'static str {
56+
"eql_v3.numeric_eq"
57+
}
58+
59+
fn sql_domain(&self) -> &'static str {
60+
Self::sql_domain_static()
61+
}
62+
}
63+
64+
/// `eql_v3.numeric_ord_ore` — full comparison, scheme-explicit name.
65+
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
66+
#[serde(deny_unknown_fields)]
67+
pub struct NumericOrdOre {
68+
/// Envelope version — always `2` (`EQL_SCHEMA_VERSION`); any other
69+
/// value fails deserialization.
70+
pub v: SchemaVersion,
71+
/// Table/column identifier. Required by the domain CHECK.
72+
pub i: Identifier,
73+
/// mp_base85 source ciphertext. Required by the domain CHECK.
74+
pub c: Ciphertext,
75+
/// Block-ORE order term (14 blocks for numeric). Serves equality too.
76+
pub ob: OreBlockU64_8_256,
77+
}
78+
79+
impl DomainType for NumericOrdOre {
80+
fn sql_domain_static() -> &'static str {
81+
"eql_v3.numeric_ord_ore"
82+
}
83+
84+
fn sql_domain(&self) -> &'static str {
85+
Self::sql_domain_static()
86+
}
87+
}
88+
89+
/// `eql_v3.numeric_ord` — full comparison (`=` `<>` `<` `<=` `>` `>=`).
90+
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
91+
#[serde(deny_unknown_fields)]
92+
pub struct NumericOrd {
93+
/// Envelope version — always `2` (`EQL_SCHEMA_VERSION`); any other
94+
/// value fails deserialization.
95+
pub v: SchemaVersion,
96+
/// Table/column identifier. Required by the domain CHECK.
97+
pub i: Identifier,
98+
/// mp_base85 source ciphertext. Required by the domain CHECK.
99+
pub c: Ciphertext,
100+
/// Block-ORE order term (14 blocks for numeric). Serves equality too.
101+
pub ob: OreBlockU64_8_256,
102+
}
103+
104+
impl DomainType for NumericOrd {
105+
fn sql_domain_static() -> &'static str {
106+
"eql_v3.numeric_ord"
107+
}
108+
109+
fn sql_domain(&self) -> &'static str {
110+
Self::sql_domain_static()
111+
}
112+
}

crates/eql-types/src/v3/terms.rs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,12 @@ pub struct Ciphertext(pub String);
2323
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
2424
pub struct Hmac256(pub String);
2525

26-
/// Block-ORE (u64, 8 blocks, 256) order term — the `ob` wire key. Backs the
27-
/// `_ord` / `_ord_ore` domains (`=` `<>` `<` `<=` `>` `>=`); ORE is lossless
28-
/// over the scalar's domain, so it serves equality too. SQL-side constructor:
29-
/// `eql_v3.ore_block_u64_8_256`.
26+
/// Block-ORE order term — the `ob` wire key. Backs the `_ord` / `_ord_ore`
27+
/// domains (`=` `<>` `<` `<=` `>` `>=`); ORE is lossless over the scalar's
28+
/// domain, so it serves equality too. The block count is width-agnostic on the
29+
/// wire (8 for the int scalars, 12 for timestamptz, 14 for numeric) — the
30+
/// array just carries more block strings. SQL-side constructor:
31+
/// `eql_v3.ore_block_256`.
3032
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
3133
pub struct OreBlockU64_8_256(pub Vec<String>);
3234

crates/eql-types/src/v3/timestamptz.rs

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
1-
//! The `timestamptz` encrypted-domain family — **equality-only** (storage +
2-
//! `_eq`). There is no ordered domain: cipherstash encrypts timestamps at
3-
//! native 12-block ORE width, but EQL's only ORE comparator is hardcoded to
4-
//! 8 blocks, so an ordered timestamptz domain would silently mis-order.
5-
//! Ordering arrives with a future wide-ORE term (see `eql-scalars`).
1+
//! The `timestamptz` encrypted-domain family — an ordered, non-integer scalar.
2+
//! Same four-domain ordered shape as [`crate::v3::int4`] (ORE compares
3+
//! ciphertext, so timestamps order like integers); see that module for the
4+
//! capability table.
5+
//!
6+
//! cipherstash encrypts timestamps at native 12-block ORE width. The family
7+
//! was equality-only while EQL's ORE comparator was hardcoded to 8 blocks;
8+
//! now that `eql_v3.ore_block_256` derives the block count from the term
9+
//! length, the 12-block `ob` term orders correctly and the ordered domains
10+
//! ship. The wire shape is unchanged — the `ob` array just carries 12 blocks.
611
7-
use crate::v3::terms::{Ciphertext, Hmac256};
12+
use crate::v3::terms::{Ciphertext, Hmac256, OreBlockU64_8_256};
813
use crate::v3::DomainType;
914
use crate::{Identifier, SchemaVersion};
1015
use serde::{Deserialize, Serialize};
@@ -56,3 +61,53 @@ impl DomainType for TimestamptzEq {
5661
Self::sql_domain_static()
5762
}
5863
}
64+
65+
/// `eql_v3.timestamptz_ord_ore` — full comparison, scheme-explicit name.
66+
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
67+
#[serde(deny_unknown_fields)]
68+
pub struct TimestamptzOrdOre {
69+
/// Envelope version — always `2` (`EQL_SCHEMA_VERSION`); any other
70+
/// value fails deserialization.
71+
pub v: SchemaVersion,
72+
/// Table/column identifier. Required by the domain CHECK.
73+
pub i: Identifier,
74+
/// mp_base85 source ciphertext. Required by the domain CHECK.
75+
pub c: Ciphertext,
76+
/// Block-ORE order term (12 blocks for timestamptz). Serves equality too.
77+
pub ob: OreBlockU64_8_256,
78+
}
79+
80+
impl DomainType for TimestamptzOrdOre {
81+
fn sql_domain_static() -> &'static str {
82+
"eql_v3.timestamptz_ord_ore"
83+
}
84+
85+
fn sql_domain(&self) -> &'static str {
86+
Self::sql_domain_static()
87+
}
88+
}
89+
90+
/// `eql_v3.timestamptz_ord` — full comparison (`=` `<>` `<` `<=` `>` `>=`).
91+
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
92+
#[serde(deny_unknown_fields)]
93+
pub struct TimestamptzOrd {
94+
/// Envelope version — always `2` (`EQL_SCHEMA_VERSION`); any other
95+
/// value fails deserialization.
96+
pub v: SchemaVersion,
97+
/// Table/column identifier. Required by the domain CHECK.
98+
pub i: Identifier,
99+
/// mp_base85 source ciphertext. Required by the domain CHECK.
100+
pub c: Ciphertext,
101+
/// Block-ORE order term (12 blocks for timestamptz). Serves equality too.
102+
pub ob: OreBlockU64_8_256,
103+
}
104+
105+
impl DomainType for TimestamptzOrd {
106+
fn sql_domain_static() -> &'static str {
107+
"eql_v3.timestamptz_ord"
108+
}
109+
110+
fn sql_domain(&self) -> &'static str {
111+
Self::sql_domain_static()
112+
}
113+
}

crates/eql-types/tests/v3_conformance.rs

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ fn non_int4_tokens_round_trip_every_domain() {
168168
// `catalog_parity.rs` checks domain *names* only, never the wire shape.
169169
// This sweep roundtrips every non-int4 domain and pins its catalog name,
170170
// failing the instant a token drifts from the shared envelope/term contract.
171-
use eql_types::v3::{date::*, int2::*, int8::*, text::*};
171+
use eql_types::v3::{date::*, int2::*, int8::*, numeric::*, text::*};
172172

173173
// Wire builders for the three shapes the ordered tokens share.
174174
let storage = |t: &str| json!({ "v": 2, "i": { "t": t, "c": "x" }, "c": "ct" });
@@ -200,6 +200,13 @@ fn non_int4_tokens_round_trip_every_domain() {
200200
round_trip!(DateOrd, ord("a"), "eql_v3.date_ord");
201201
round_trip!(DateOrdOre, ord("a"), "eql_v3.date_ord_ore");
202202

203+
// numeric is the first scalar whose native ORE term exceeds 8 blocks (14);
204+
// the wire shape is identical, so the same `ord` builder applies.
205+
round_trip!(Numeric, storage("a"), "eql_v3.numeric");
206+
round_trip!(NumericEq, eq("a"), "eql_v3.numeric_eq");
207+
round_trip!(NumericOrd, ord("a"), "eql_v3.numeric_ord");
208+
round_trip!(NumericOrdOre, ord("a"), "eql_v3.numeric_ord_ore");
209+
203210
// text_match is covered by `text_match_round_trips_signed_bloom_filter`.
204211
round_trip!(Text, storage("a"), "eql_v3.text");
205212
round_trip!(TextEq, eq("a"), "eql_v3.text_eq");
@@ -208,12 +215,16 @@ fn non_int4_tokens_round_trip_every_domain() {
208215
}
209216

210217
#[test]
211-
fn timestamptz_round_trips_and_enforces_equality_term() {
212-
// The one structurally-distinct token: equality-only, no `_ord`/`_ord_ore`
213-
// (the 8-block-ORE limitation). The int4 template was copy-pasted to
214-
// produce it, so an accidental extra `ob` field or a dropped `hm` would
215-
// pass `catalog_parity` (domain names only) but is caught here.
216-
use eql_types::v3::timestamptz::{Timestamptz, TimestamptzEq};
218+
fn timestamptz_round_trips_and_enforces_term_capabilities() {
219+
// timestamptz is an ordered token (12-block ORE) — it carries the full
220+
// storage/`_eq`/`_ord`/`_ord_ore` shape, the same as the int scalars. The
221+
// int4 template was copy-pasted to produce it, so a dropped `hm`/`ob` or a
222+
// field typo would pass `catalog_parity` (domain names only) but is caught
223+
// here. (Was equality-only while the ORE comparator was hardcoded to 8
224+
// blocks; promoted once `eql_v3.ore_block_256` generalized to any width.)
225+
use eql_types::v3::timestamptz::{
226+
Timestamptz, TimestamptzEq, TimestamptzOrd, TimestamptzOrdOre,
227+
};
217228

218229
// Storage-only: envelope, no term.
219230
let storage = json!({
@@ -236,16 +247,40 @@ fn timestamptz_round_trips_and_enforces_equality_term() {
236247
assert_eq!(serde_json::to_value(&parsed).unwrap(), with_hm);
237248
assert_eq!(TimestamptzEq::sql_domain_static(), "eql_v3.timestamptz_eq");
238249

239-
// `_eq` is the only searchable shape this token has, so its equality term
240-
// cannot silently become optional.
250+
// Ordered: envelope + ob (a 12-block array on the wire; shape is the same).
251+
let with_ob = json!({
252+
"v": 2,
253+
"i": { "t": "events", "c": "occurred_at" },
254+
"c": "mp_base85_ciphertext",
255+
"ob": ["b0", "b1"]
256+
});
257+
let parsed: TimestamptzOrd = serde_json::from_value(with_ob.clone()).unwrap();
258+
assert_eq!(serde_json::to_value(&parsed).unwrap(), with_ob);
259+
assert_eq!(
260+
TimestamptzOrd::sql_domain_static(),
261+
"eql_v3.timestamptz_ord"
262+
);
263+
let parsed: TimestamptzOrdOre = serde_json::from_value(with_ob.clone()).unwrap();
264+
assert_eq!(serde_json::to_value(&parsed).unwrap(), with_ob);
265+
assert_eq!(
266+
TimestamptzOrdOre::sql_domain_static(),
267+
"eql_v3.timestamptz_ord_ore"
268+
);
269+
270+
// The searchable domains cannot let their term silently become optional.
241271
let no_hm = json!({
242272
"v": 2,
243273
"i": { "t": "events", "c": "occurred_at" },
244274
"c": "mp_base85_ciphertext"
245275
});
246-
let result: Result<TimestamptzEq, _> = serde_json::from_value(no_hm);
276+
let result: Result<TimestamptzEq, _> = serde_json::from_value(no_hm.clone());
247277
assert!(
248278
result.is_err(),
249279
"TimestamptzEq must reject a payload with no hm"
250280
);
281+
let result: Result<TimestamptzOrd, _> = serde_json::from_value(no_hm);
282+
assert!(
283+
result.is_err(),
284+
"TimestamptzOrd must reject a payload with no ob"
285+
);
251286
}

0 commit comments

Comments
 (0)