Skip to content

Commit 373e64d

Browse files
committed
feat(v3): N-block ORE comparator + ordered numeric & timestamptz
Derive the ORE block count N from term length instead of hardcoding 8, and wire the ordered numeric scalar while promoting timestamptz to ordered.
1 parent 008d69c commit 373e64d

13 files changed

Lines changed: 337 additions & 62 deletions

File tree

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/lib.rs

Lines changed: 49 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -219,13 +219,15 @@ const ORDERED_INT_DOMAINS: &[DomainSpec] = &[
219219
},
220220
];
221221

222-
/// Equality-only domains: storage (no terms) + `_eq` (hm). Used by scalar types
223-
/// that can hash for equality but cannot (yet) be ordered. `timestamptz` is the
224-
/// first such type: cipherstash encrypts `Plaintext::Timestamp` at native
225-
/// 12-block ORE width, but EQL's only ORE comparator
226-
/// (`eql_v2.compare_ore_block_u64_8_256_term`) is hardcoded to 8 blocks, so an
227-
/// ordered domain would silently mis-order. Ordering is deferred until a
228-
/// wide-ORE (12-block) term exists.
222+
/// Equality-only domains: storage (no terms) + `_eq` (hm). The canonical shape
223+
/// for a scalar type that can hash for equality but is not ORE-orderable.
224+
/// **Currently unused:** `timestamptz` (the previous sole user) was promoted to
225+
/// the ordered shape once `eql_v3.compare_ore_block_256_term` generalized to N
226+
/// blocks and could order its native 12-block ORE width. Retained — and still
227+
/// validated as a known-valid shape by `every_type_uses_a_known_domain_shape` —
228+
/// so a future non-orderable scalar (e.g. a hash-only type) can reuse it without
229+
/// reconstructing the shape.
230+
#[allow(dead_code)]
229231
const EQ_ONLY_DOMAINS: &[DomainSpec] = &[
230232
DomainSpec {
231233
suffix: "",
@@ -285,6 +287,17 @@ const TIMESTAMPTZ_FIXTURES: &[Fixture] = fixtures!(timestamptz;
285287
"2012-06-30T11:59:59Z", "2016-03-15T08:15:30Z", "2020-10-21T14:45:00Z",
286288
"2024-02-29T17:30:45Z", "2038-01-19T03:14:07Z", "2099-12-31T23:59:59Z");
287289

290+
/// `numeric` fixture plaintexts — distinct by `Decimal` value, spanning sign,
291+
/// magnitude, and scale, and including `0` plus the min/max pivots
292+
/// (`-1000000000000` / `1000000000000`). They mirror `ore-rs`'s own
293+
/// order-pinning vectors so the 14-block ORE edges (sign + high/low blocks) are
294+
/// exercised. Each literal is distinct by parsed value (no `"1"`/`"1.0"`
295+
/// aliasing) — the harness `numeric_fixtures_distinct_by_value` guard enforces
296+
/// this, since the zero-dep catalog only dedupes by literal string.
297+
const NUMERIC_FIXTURES: &[Fixture] = fixtures!(numeric;
298+
"-1000000000000", "-1000000", "-1.001", "-1", "-0.5", "-0.001",
299+
"0", "0.001", "0.5", "0.999999999", "1", "1.001", "1000000", "1000000000000");
300+
288301
const INT4: ScalarSpec = ScalarSpec {
289302
token: "int4",
290303
kind: ScalarKind::I32,
@@ -321,27 +334,42 @@ pub const DATE: ScalarSpec = ScalarSpec {
321334
fixtures: DATE_FIXTURES,
322335
};
323336

324-
/// `timestamptz` — an **equality-only** (UTC-normalized) non-integer scalar.
325-
/// Uses `EQ_ONLY_DOMAINS` (storage + `_eq`) rather than the four-domain ordered
326-
/// shape: cipherstash encrypts `Plaintext::Timestamp` at native 12-block ORE
327-
/// width, but EQL's only ORE comparator
328-
/// (`eql_v2.compare_ore_block_u64_8_256_term`) is hardcoded to 8 blocks, so an
329-
/// ordered timestamptz domain would silently mis-order. Ordering is deferred to
330-
/// a future PR that adds a wide-ORE (12-block) term. The three "pivot" fixture
331-
/// values are retained as equality pivots; the kind stays ordered-shaped
332-
/// (carries a rust type, no i128 range) so the harness can parse them.
337+
/// `timestamptz` — an **ordered**, UTC-normalized non-integer scalar. Uses the
338+
/// four-domain ordered shape (storage, `_eq`, `_ord`, `_ord_ore`): cipherstash
339+
/// encrypts `Plaintext::Timestamp` at native 12-block ORE width, which the
340+
/// generalized `eql_v3.compare_ore_block_256_term` comparator orders correctly.
341+
/// Values are UTC-normalized (cipherstash has no tz-preserving type) and encrypt
342+
/// under the `timestamp` cast.
333343
///
334344
/// Public (like `DATE`) because the SQLx harness reads `TIMESTAMPTZ.fixtures`
335-
/// directly to parse the RFC3339 strings into `chrono::DateTime<Utc>` at
336-
/// runtime — there is no `TIMESTAMPTZ_VALUES` const (chrono is not
337-
/// `const`-friendly and `eql-scalars` stays zero-dep).
345+
/// directly to parse the RFC3339 strings into `chrono::DateTime<Utc>` at runtime
346+
/// (no `TIMESTAMPTZ_VALUES` const; `eql-scalars` stays zero-dep).
338347
pub const TIMESTAMPTZ: ScalarSpec = ScalarSpec {
339348
token: "timestamptz",
340349
kind: ScalarKind::Timestamptz,
341-
domains: EQ_ONLY_DOMAINS,
350+
domains: ORDERED_INT_DOMAINS,
342351
fixtures: TIMESTAMPTZ_FIXTURES,
343352
};
344353

354+
/// `numeric` — an **ordered** non-integer scalar backed by
355+
/// `rust_decimal::Decimal`. Uses the four-domain ordered shape: cipherstash
356+
/// encrypts `Plaintext::Decimal` at native 14-block ORE width, which the
357+
/// generalized `eql_v3.compare_ore_block_256_term` comparator orders correctly.
358+
/// `numeric_value` returns `None` (no i128 range); ordering is supplied by the
359+
/// harness `Decimal: Ord`, which `ore-rs` guarantees agrees with the ciphertext
360+
/// order (equivalent scales collide, like `Decimal`'s own `Ord`).
361+
///
362+
/// Public (like `DATE` / `TIMESTAMPTZ`) so the SQLx harness reads
363+
/// `NUMERIC.fixtures` directly to parse the decimal strings into
364+
/// `rust_decimal::Decimal` at runtime (the catalog stays zero-dep: no
365+
/// `rust_decimal`).
366+
pub const NUMERIC: ScalarSpec = ScalarSpec {
367+
token: "numeric",
368+
kind: ScalarKind::Numeric,
369+
domains: ORDERED_INT_DOMAINS,
370+
fixtures: NUMERIC_FIXTURES,
371+
};
372+
345373
/// Domains for `text`: the ordered shape plus a `_match` domain backed by the
346374
/// `Bloom` term (`@>`/`<@` containment). The ordered subset (`""`, `_eq`,
347375
/// `_ord_ore`, `_ord`) is identical to `ORDERED_INT_DOMAINS`; `_match` is the
@@ -396,7 +424,7 @@ pub const TEXT: ScalarSpec = ScalarSpec {
396424

397425
/// The scalar catalog — the single source of truth. Order is significant (it
398426
/// drives generation order). New types are appended as their SQL surface lands.
399-
pub const CATALOG: &[ScalarSpec] = &[INT4, INT2, INT8, DATE, TIMESTAMPTZ, TEXT];
427+
pub const CATALOG: &[ScalarSpec] = &[INT4, INT2, INT8, DATE, TIMESTAMPTZ, NUMERIC, TEXT];
400428

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

crates/eql-scalars/src/tests.rs

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,24 @@ mod rust_tests {
170170
let date = CATALOG.iter().find(|s| s.token == "date").unwrap();
171171
assert!(!date.is_eq_only(), "date is ordered");
172172
let ts = CATALOG.iter().find(|s| s.token == "timestamptz").unwrap();
173-
assert!(ts.is_eq_only(), "timestamptz is equality-only");
173+
assert!(
174+
!ts.is_eq_only(),
175+
"timestamptz is now ordered (native 12-block ORE, comparator generalized to N blocks)"
176+
);
177+
178+
// No catalog type is currently eq-only, so exercise `is_eq_only()`'s
179+
// positive path with a synthetic spec built on the retained
180+
// `EQ_ONLY_DOMAINS` shape (storage + `_eq`, no `_ord`).
181+
let eq_only = ScalarSpec {
182+
token: "synthetic_eq_only",
183+
kind: ScalarKind::Timestamptz,
184+
domains: EQ_ONLY_DOMAINS,
185+
fixtures: &[],
186+
};
187+
assert!(
188+
eq_only.is_eq_only(),
189+
"a storage+_eq spec (no _ord) must be detected as eq-only"
190+
);
174191
}
175192
}
176193

@@ -496,11 +513,19 @@ mod catalog_tests {
496513
}
497514

498515
#[test]
499-
fn catalog_has_int4_int2_int8_date_timestamptz_text_in_order() {
516+
fn catalog_has_int4_int2_int8_date_timestamptz_numeric_text_in_order() {
500517
let tokens: Vec<&str> = CATALOG.iter().map(|s| s.token).collect();
501518
assert_eq!(
502519
tokens,
503-
vec!["int4", "int2", "int8", "date", "timestamptz", "text"]
520+
vec![
521+
"int4",
522+
"int2",
523+
"int8",
524+
"date",
525+
"timestamptz",
526+
"numeric",
527+
"text"
528+
]
504529
);
505530
}
506531

@@ -607,16 +632,14 @@ mod catalog_tests {
607632

608633
#[test]
609634
fn ordered_and_eq_only_shapes_are_used_as_declared() {
610-
// Pin which catalog tokens carry which shape, so a row silently flipping
611-
// ORDERED_INT_DOMAINS <-> EQ_ONLY_DOMAINS is caught. timestamptz is
612-
// equality-only (12-block ORE vs 8-block comparator); the rest ordered
613-
// (text adds a `_match` domain on top, so it is not eq_only either).
635+
// All current catalog types use the four-domain ordered shape; none is
636+
// equality-only. (timestamptz was promoted to ordered once the ORE
637+
// comparator generalized to N blocks — see the numeric/ORE work.)
614638
for s in CATALOG {
615639
let is_eq_only = s.domains.len() == 2;
616-
let expect_eq_only = s.token == "timestamptz";
617-
assert_eq!(
618-
is_eq_only, expect_eq_only,
619-
"{} domain shape (eq_only={is_eq_only}) does not match expectation",
640+
assert!(
641+
!is_eq_only,
642+
"{} is unexpectedly eq-only; no catalog type is eq-only currently",
620643
s.token
621644
);
622645
}

crates/eql-tests-macros/src/lib.rs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,14 @@ fn is_text_token(token: &str) -> bool {
9393
spec_for_token(token).kind.is_text()
9494
}
9595

96+
/// True when `token`'s catalog row is the `numeric` kind (owned
97+
/// `rust_decimal::Decimal`). Like `text` it is ordered but non-integer and
98+
/// non-chrono, so it stamps the `numeric` fixture discriminator and draws its
99+
/// values from the harness accessor (`numeric_values()`).
100+
fn is_numeric_token(token: &str) -> bool {
101+
matches!(spec_for_token(token).kind, eql_scalars::ScalarKind::Numeric)
102+
}
103+
96104
/// True when `token`'s catalog row declares no ordered domain — equality-only.
97105
/// Replaces the `[eq_only]` marker. Consumed by [`matrix_suite_for_entry`] to
98106
/// keep an eq-only type out of the ordered matrix (which exercises ordering
@@ -213,10 +221,12 @@ fn scalar_fixture_modules_tokens(list: &ScalarList) -> TokenStream2 {
213221
format_ident!("temporal")
214222
} else if is_text_token(&token_str) {
215223
format_ident!("text")
224+
} else if is_numeric_token(&token_str) {
225+
format_ident!("numeric")
216226
} else {
217227
panic!(
218-
"scalar token `{token_str}` is neither integer, temporal, nor \
219-
text — no fixture discriminator is wired for its kind"
228+
"scalar token `{token_str}` is neither integer, temporal, text, \
229+
nor numeric — no fixture discriminator is wired for its kind"
220230
)
221231
};
222232
quote! {

src/v3/sem/ore_block_256/functions.sql

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,16 @@ AS $$
109109

110110
left_block_size CONSTANT smallint := 16;
111111
right_block_size CONSTANT smallint := 32;
112-
right_offset CONSTANT smallint := 136; -- 8 * 17
112+
113+
-- Block count N is DERIVED from the ciphertext length, not hardcoded to 8.
114+
-- Wire format per term:
115+
-- [ N PRP bytes ][ N*16B left blocks ][ 16B hash key ][ N*32B right blocks ]
116+
-- octet_length = 17*N + 16 + 32*N = 49*N + 16 => N = (octet_length - 16) / 49
117+
-- This serves int4 (N=8, 408B), timestamp (N=12, 604B), and numeric
118+
-- (N=14, 702B) with one comparator.
119+
n integer;
120+
left_offset integer; -- ordinal offset of the first left block (1 + N PRP bytes)
121+
right_offset integer; -- ordinal start of the right CT (= total left CT length = 17*N)
113122

114123
indicator smallint := 0;
115124
BEGIN
@@ -129,10 +138,23 @@ AS $$
129138
RAISE EXCEPTION 'Ciphertexts are different lengths';
130139
END IF;
131140

132-
FOR block IN 0..7 LOOP
141+
-- Well-formedness: length must be exactly 49*N + 16 for some N >= 1. The
142+
-- modulo alone is insufficient -- a 16-byte term passes (16 - 16) % 49 = 0
143+
-- and derives N = 0, which would fall through to the all-blocks-equal path
144+
-- and return 0 instead of raising. The `<= 16` clause is load-bearing.
145+
IF octet_length(a.bytes) <= 16 OR (octet_length(a.bytes) - 16) % 49 != 0 THEN
146+
RAISE EXCEPTION 'Malformed ORE term: % bytes', octet_length(a.bytes);
147+
END IF;
148+
149+
n := (octet_length(a.bytes) - 16) / 49;
150+
left_offset := 1 + n; -- left blocks begin right after the N PRP bytes
151+
right_offset := 17 * n; -- right CT begins right after the 17*N-byte left CT
152+
153+
FOR block IN 0..n-1 LOOP
154+
-- Compare each PRP byte (the first N bytes) and its 16-byte left block.
133155
IF
134156
substr(a.bytes, 1 + block, 1) != substr(b.bytes, 1 + block, 1)
135-
OR substr(a.bytes, 9 + left_block_size * block, left_block_size) != substr(b.bytes, 9 + left_block_size * block, left_block_size)
157+
OR substr(a.bytes, left_offset + left_block_size * block, left_block_size) != substr(b.bytes, left_offset + left_block_size * block, left_block_size)
136158
THEN
137159
IF eq THEN
138160
unequal_block := block;
@@ -145,11 +167,13 @@ AS $$
145167
RETURN 0::integer;
146168
END IF;
147169

170+
-- Hash key is the IV from the right CT of b.
148171
hash_key := substr(b.bytes, right_offset + 1, 16);
149172

173+
-- First right block is at right_offset + nonce_size (ordinally indexed).
150174
target_block := substr(b.bytes, right_offset + 17 + (unequal_block * right_block_size), right_block_size);
151175

152-
data_block := substr(a.bytes, 9 + (left_block_size * unequal_block), left_block_size);
176+
data_block := substr(a.bytes, left_offset + (left_block_size * unequal_block), left_block_size);
153177

154178
encrypt_block := encrypt(data_block::bytea, hash_key::bytea, 'aes-ecb');
155179

tests/sqlx/Cargo.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ version = "0.1.0"
44
edition = "2021"
55

66
[dependencies]
7-
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "macros", "chrono"] }
7+
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "macros", "chrono", "rust_decimal"] }
88
tokio = { version = "1", features = ["full"] }
99
serde = { version = "1", features = ["derive"] }
1010
serde_json = "1"
@@ -16,6 +16,11 @@ cipherstash-client = { version = "0.35", features = ["tokio"] }
1616
# it as a direct dependency so the harness can name `chrono::NaiveDate` for the
1717
# `date` scalar (Encode/Decode/Type come from the sqlx `chrono` feature above).
1818
chrono = { version = "0.4", default-features = false }
19+
# rust_decimal backs the `numeric` scalar. The sqlx `rust_decimal` feature above
20+
# provides Encode/Decode/Type<Postgres>; this names the type directly for the
21+
# harness impls (scalar_domains.rs, eql_plaintext.rs). Already in the tree
22+
# transitively (cipherstash-client / ore-rs).
23+
rust_decimal = "1"
1924
paste = "1"
2025
eql-scalars = { path = "../../crates/eql-scalars" }
2126
eql-tests-macros = { path = "../../crates/eql-tests-macros" }

tests/sqlx/src/fixtures/eql_plaintext.rs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ impl PlaintextSqlType {
6060
pub const TIMESTAMPTZ: PlaintextSqlType = PlaintextSqlType("timestamp with time zone");
6161
pub const TEXT: PlaintextSqlType = PlaintextSqlType("text");
6262
pub const JSONB: PlaintextSqlType = PlaintextSqlType("jsonb");
63+
pub const NUMERIC: PlaintextSqlType = PlaintextSqlType("numeric");
6364

6465
pub fn as_str(&self) -> &'static str {
6566
self.0
@@ -86,7 +87,8 @@ const fn cast_for_kind(kind: ScalarKind) -> Cast {
8687
ScalarKind::Date => Cast::DATE,
8788
ScalarKind::Timestamptz => Cast::TIMESTAMP,
8889
ScalarKind::Text => Cast::TEXT,
89-
ScalarKind::Numeric | ScalarKind::Jsonb => {
90+
ScalarKind::Numeric => Cast::DECIMAL,
91+
ScalarKind::Jsonb => {
9092
panic!("EqlPlaintext is only implemented for the wired scalar kinds")
9193
}
9294
}
@@ -103,7 +105,8 @@ const fn plaintext_sql_type_for_kind(kind: ScalarKind) -> PlaintextSqlType {
103105
ScalarKind::Date => PlaintextSqlType::DATE,
104106
ScalarKind::Timestamptz => PlaintextSqlType::TIMESTAMPTZ,
105107
ScalarKind::Text => PlaintextSqlType::TEXT,
106-
ScalarKind::Numeric | ScalarKind::Jsonb => {
108+
ScalarKind::Numeric => PlaintextSqlType::NUMERIC,
109+
ScalarKind::Jsonb => {
107110
panic!("EqlPlaintext is only implemented for the wired scalar kinds")
108111
}
109112
}
@@ -118,6 +121,7 @@ mod sealed {
118121
impl Sealed for chrono::DateTime<chrono::Utc> {}
119122
impl Sealed for String {}
120123
impl Sealed for serde_json::Value {}
124+
impl Sealed for rust_decimal::Decimal {}
121125
}
122126

123127
/// A Rust type usable as a fixture `plaintext` value, carrying its EQL cast
@@ -213,6 +217,14 @@ impl EqlPlaintext for serde_json::Value {
213217
}
214218
}
215219

220+
impl EqlPlaintext for rust_decimal::Decimal {
221+
const KIND: ScalarKind = ScalarKind::Numeric;
222+
223+
fn to_plaintext(&self) -> Plaintext {
224+
Plaintext::Decimal(Some(*self))
225+
}
226+
}
227+
216228
#[cfg(test)]
217229
mod tests {
218230
use super::*;

0 commit comments

Comments
 (0)