Skip to content

Commit 5c0ab3d

Browse files
authored
Merge pull request #272 from cipherstash/eql-v3-ore-cllw
feat(v3): add eql_v3.ore_cllw SEM index-term type and operators
2 parents f27d6c8 + 0aaf966 commit 5c0ab3d

17 files changed

Lines changed: 1062 additions & 72 deletions

File tree

crates/eql-codegen/src/context.rs

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -268,10 +268,13 @@ pub const AGGREGATE_OPS: &[AggregateOp] = &[
268268
},
269269
];
270270

271-
/// True if the domain carries a comparator term (supports `<`).
272-
/// Port of `is_ord_capable`.
271+
/// True if the domain carries a comparator term (any term that supports `<`).
272+
/// Unions across the terms — consistent with `Term::operators_for_terms` —
273+
/// rather than reading only the first, so a future mixed-term domain that
274+
/// carries an ord term anywhere is correctly ord-capable. Port of
275+
/// `is_ord_capable`.
273276
pub fn is_ord_capable(terms: &[Term]) -> bool {
274-
Term::role_for_terms(terms) == Role::Ord
277+
terms.iter().any(|t| t.role() == Role::Ord)
275278
}
276279

277280
#[cfg(test)]
@@ -290,6 +293,17 @@ mod tests {
290293
assert!(!is_ord_capable(&[]));
291294
}
292295

296+
#[test]
297+
fn is_ord_capable_unions_across_terms() {
298+
// An ord term anywhere in the list makes the domain ord-capable,
299+
// regardless of position — consistent with operators_for_terms' union,
300+
// not the first-term role. (No catalog domain is multi-term today; this
301+
// pins the order-independent semantics for a future mixed-term domain.)
302+
assert!(is_ord_capable(&[Term::Hm, Term::Ore]));
303+
assert!(is_ord_capable(&[Term::Ore, Term::Hm]));
304+
assert!(!is_ord_capable(&[Term::Hm, Term::Bloom]));
305+
}
306+
293307
#[test]
294308
fn environment_has_whole_file_and_partial_templates() {
295309
let env = environment();

crates/eql-codegen/tests/parity.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,12 @@ fn rust_generator_matches_reference_files() {
102102
let ref_dir = root.join("tests/codegen/reference").join(&token);
103103
let gen_dir = out.path().join("src/v3/scalars").join(&token);
104104

105-
// Assert the generated .sql file SET matches the reference set first — the
105+
// Assert the generated .sql file SET matches the reference set first. The
106106
// per-file byte comparison below only iterates reference files, so a
107-
// missing generated file (or an extra one the reference never pins) would
108-
// otherwise pass silently.
107+
// missing generated file would surface only as an opaque `unwrap` panic on
108+
// the `read_to_string` below, and an EXTRA generated file (one the
109+
// reference never pins) would pass silently — it is never iterated. This
110+
// set check turns both into a clear file-set diff.
109111
let ref_names = sql_names(&ref_dir);
110112
let gen_names = sql_names(&gen_dir);
111113
assert_eq!(

crates/eql-scalars/src/lib.rs

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -86,11 +86,12 @@ pub enum Term {
8686
Bloom,
8787
}
8888

89-
/// The generated-file role of a domain, derived from its first term (or
90-
/// `Storage` for a term-less domain). Gates ord-only codegen (aggregates) via an
91-
/// exhaustive `==` against [`Role::Ord`] rather than a stringly-typed compare —
92-
/// a typo can no longer silently disable aggregate generation. `label` is the
93-
/// `&'static str` form for any future template/serde consumer.
89+
/// The generated-file role of a domain, resolved from its terms by the
90+
/// richest-comparison precedence in [`Role::rank`] (or `Storage` for a term-less
91+
/// domain). Gates ord-only codegen (aggregates) via an exhaustive `==` against
92+
/// [`Role::Ord`] rather than a stringly-typed compare — a typo can no longer
93+
/// silently disable aggregate generation. `label` is the `&'static str` form for
94+
/// any future template/serde consumer.
9495
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9596
pub enum Role {
9697
Storage,
@@ -109,6 +110,23 @@ impl Role {
109110
Role::Match => "match",
110111
}
111112
}
113+
114+
/// Precedence used by [`Term::role_for_terms`] to resolve a multi-term
115+
/// domain to a single generated-file role: the richest comparison capability
116+
/// wins (`Ord > Eq > Match > Storage`). Ordering subsumes equality, so an
117+
/// `Ore` term anywhere makes the domain ord-shaped; `Match` (containment) is
118+
/// a weaker standalone surface; `Storage` is the absence of any term. The
119+
/// current catalog is single-term, so this only disambiguates a hypothetical
120+
/// future mixed-term domain — and keeps `role_for_terms` consistent with
121+
/// [`Term::operators_for_terms`], which already unions across all terms.
122+
pub const fn rank(self) -> u8 {
123+
match self {
124+
Role::Storage => 0,
125+
Role::Match => 1,
126+
Role::Eq => 2,
127+
Role::Ord => 3,
128+
}
129+
}
112130
}
113131

114132
/// A single fixture plaintext value, value-kind tagged: `Min`/`Max`/`Zero` are

crates/eql-scalars/src/term.rs

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ impl Term {
3333
}
3434
}
3535

36-
/// Generated-file [`Role`] for a domain whose first term is this one.
36+
/// Generated-file [`Role`] contributed by this single term. A domain's role
37+
/// is the richest of its terms' roles — see [`Term::role_for_terms`].
3738
pub const fn role(self) -> Role {
3839
match self {
3940
Term::Hm => Role::Eq,
@@ -117,11 +118,18 @@ impl Term {
117118
}
118119

119120
/// Generated-file [`Role`] for a domain with these terms. No terms =>
120-
/// [`Role::Storage`]; otherwise the first term's role.
121+
/// [`Role::Storage`]; otherwise the **richest** role across the terms by
122+
/// [`Role::rank`] precedence (`Ord > Eq > Match > Storage`). For the current
123+
/// single-term catalog this equals the lone term's role, so generated SQL is
124+
/// unchanged; the precedence only disambiguates a future mixed-term domain
125+
/// (e.g. `[Hm, Ore]` => `Ord`), keeping this consistent with
126+
/// [`Term::operators_for_terms`], which unions across all terms rather than
127+
/// reading only the first.
121128
pub fn role_for_terms(terms: &[Term]) -> Role {
122-
match terms.first() {
123-
None => Role::Storage,
124-
Some(t) => t.role(),
125-
}
129+
terms
130+
.iter()
131+
.map(|t| t.role())
132+
.max_by_key(|r| r.rank())
133+
.unwrap_or(Role::Storage)
126134
}
127135
}

crates/eql-scalars/src/tests.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,26 @@ mod term_helper_tests {
292292
assert_eq!(Term::role_for_terms(&[Term::Ore]), Role::Ord);
293293
}
294294

295+
#[test]
296+
fn role_for_terms_takes_richest_role_order_independently() {
297+
// A mixed-term domain resolves to the richest role by Role::rank
298+
// precedence (Ord > Eq > Match > Storage), regardless of term order —
299+
// consistent with operators_for_terms' union, not the first term. No
300+
// catalog domain is multi-term today; this pins the semantics so a future
301+
// `[Hm, Ore]` domain generates the ord surface (and its aggregates).
302+
assert_eq!(Term::role_for_terms(&[Term::Hm, Term::Ore]), Role::Ord);
303+
assert_eq!(Term::role_for_terms(&[Term::Ore, Term::Hm]), Role::Ord);
304+
assert_eq!(Term::role_for_terms(&[Term::Hm, Term::Bloom]), Role::Eq);
305+
assert_eq!(Term::role_for_terms(&[Term::Bloom, Term::Hm]), Role::Eq);
306+
}
307+
308+
#[test]
309+
fn role_rank_orders_richest_comparison_highest() {
310+
assert!(Role::Ord.rank() > Role::Eq.rank());
311+
assert!(Role::Eq.rank() > Role::Match.rank());
312+
assert!(Role::Match.rank() > Role::Storage.rank());
313+
}
314+
295315
#[test]
296316
fn extractor_terms_dedupes_by_extractor_first_occurrence_wins() {
297317
// No catalog domain currently carries two terms sharing an extractor, so

mise.toml

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -164,23 +164,28 @@ description = "Verify the matrix test-name set against the single canonical snap
164164
dir = "{{config_root}}/tests/sqlx"
165165
run = """
166166
#!/usr/bin/env bash
167-
# ONE canonical, token-normalized snapshot (snapshots/matrix_tests.txt) pins the
168-
# set of macro-emitted matrix test names for the ORDERED scalar shape. There is
169-
# no second committed file for the equality-only shape: an eq-only type's name
170-
# set is exactly the ordered set MINUS the ord-only lines, so the inventory
171-
# DERIVES it from the one baseline (ordered minus `_ord`/`order_by`/
172-
# `routes_through_ob`). Each discovered type must match either the full baseline
173-
# (ordered) or that derived subset (eq-only). This keeps one committed snapshot
174-
# however many ordered/eq-only types exist.
167+
# Two committed, token-normalized snapshots. The canonical one
168+
# (snapshots/matrix_tests.txt) pins the set of macro-emitted matrix test names
169+
# for the ORDERED scalar shape. The second (snapshots/matrix_tests_eq_only.txt)
170+
# is the equality-only shape: an eq-only type's name set is exactly the ordered
171+
# set MINUS the ord-only lines (`_ord`/`order_by`/`routes_through_ob`), so it is
172+
# DERIVED from the one baseline — but it is also committed and PINNED: the gate
173+
# re-derives the subset at runtime and asserts it equals the committed file
174+
# (step 3 below), so a change to the baseline or the strip filter that alters the
175+
# eq-only set must be re-committed deliberately. Each discovered type must then
176+
# match either the full baseline (ordered) or that derived/pinned subset
177+
# (eq-only). One baseline drives both shapes however many types exist.
175178
#
176179
# Steps:
177180
# 1. List the encrypted_domain binary ONCE (deterministic; reused below).
178181
# 2. Discover the set of scalar types present FROM THE BINARY'S OWN OUTPUT
179182
# (scalars::<X>:: prefixes) — never a directory glob.
180-
# 3. For each discovered type, normalize its token to <T> and assert its set
183+
# 3. Derive the eq-only subset from the ordered baseline and assert it equals
184+
# the committed snapshots/matrix_tests_eq_only.txt (pins the derivation).
185+
# 4. For each discovered type, normalize its token to <T> and assert its set
181186
# equals EITHER the canonical snapshot (ordered) OR the derived eq-only
182187
# subset. Assert at least one type is present.
183-
# 4. Completeness cross-check: assert the discovered type set equals
188+
# 5. Completeness cross-check: assert the discovered type set equals
184189
# `eql-codegen list-types`. A catalog type added without its matrix wiring
185190
# (no scalars::<T>:: tests in the binary) fails here.
186191
#

src/v3/sem/ore_cllw/functions.sql

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
-- REQUIRE: src/v3/schema.sql
2+
-- REQUIRE: src/v3/sem/ore_cllw/types.sql
3+
4+
--! @file v3/sem/ore_cllw/functions.sql
5+
--! @brief CLLW ORE index-term extraction and comparison (eql_v3 SEM).
6+
7+
--! @brief Extract CLLW ORE index term from raw jsonb
8+
--!
9+
--! Returns the CLLW ORE ciphertext from the `oc` field of a single sv element
10+
--! supplied as raw jsonb. Inlinable single-statement SQL — the planner folds
11+
--! the body into the calling query.
12+
--!
13+
--! **Missing-`oc` semantics**: returns SQL-level NULL (not a composite with
14+
--! NULL bytes) when `oc` is absent, so btree's NULL handling filters those
15+
--! rows from range queries.
16+
--!
17+
--! @param val jsonb An object carrying an `oc` field
18+
--! @return eql_v3.ore_cllw Composite carrying the CLLW ciphertext, or NULL
19+
--! when the `oc` field is absent.
20+
--! @see eql_v3.has_ore_cllw
21+
--! @see eql_v3.compare_ore_cllw_term
22+
CREATE FUNCTION eql_v3.ore_cllw(val jsonb)
23+
RETURNS eql_v3.ore_cllw
24+
LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE
25+
AS $$
26+
SELECT CASE WHEN val ->> 'oc' IS NULL THEN NULL
27+
ELSE ROW(decode(val ->> 'oc', 'hex'))::eql_v3.ore_cllw
28+
END
29+
$$;
30+
31+
COMMENT ON FUNCTION eql_v3.ore_cllw(jsonb) IS
32+
'eql-inline-critical: raw-jsonb CLLW extractor; must stay inlinable (unpinned search_path)';
33+
34+
--! @brief Check if a raw jsonb value contains a CLLW ORE index term
35+
--! @param val jsonb An object that may carry an `oc` field
36+
--! @return boolean True if `oc` field is present and non-null
37+
CREATE FUNCTION eql_v3.has_ore_cllw(val jsonb)
38+
RETURNS boolean
39+
LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE
40+
AS $$
41+
SELECT val ->> 'oc' IS NOT NULL
42+
$$;
43+
44+
COMMENT ON FUNCTION eql_v3.has_ore_cllw(jsonb) IS
45+
'eql-inline-critical: raw-jsonb CLLW presence helper; must stay inlinable (unpinned search_path)';
46+
47+
--! @brief CLLW per-byte comparison helper
48+
--! @internal
49+
--!
50+
--! Byte-by-byte comparison implementing the CLLW order-revealing protocol.
51+
--! Identify the index of the first differing byte; if `(y_byte + 1) == x_byte`
52+
--! (mod 256) there, then x > y; otherwise x < y. Equal inputs return 0. Inputs
53+
--! MUST be the same length (the caller guarantees this). Stays `LANGUAGE
54+
--! plpgsql` — the per-byte loop can't be a single inlinable SQL expression.
55+
--!
56+
--! @param a bytea First CLLW ciphertext slice
57+
--! @param b bytea Second CLLW ciphertext slice
58+
--! @return integer -1, 0, or 1
59+
--! @throws Exception if inputs are different lengths
60+
--! @see eql_v3.compare_ore_cllw_term
61+
CREATE FUNCTION eql_v3.compare_ore_cllw_term_bytes(a bytea, b bytea)
62+
RETURNS int
63+
SET search_path = pg_catalog, extensions, public
64+
AS $$
65+
DECLARE
66+
len_a INT;
67+
len_b INT;
68+
i INT;
69+
first_diff INT := 0;
70+
BEGIN
71+
72+
len_a := LENGTH(a);
73+
len_b := LENGTH(b);
74+
75+
IF len_a != len_b THEN
76+
RAISE EXCEPTION 'ore_cllw index terms are not the same length';
77+
END IF;
78+
79+
FOR i IN 1..len_a LOOP
80+
IF first_diff = 0 AND get_byte(a, i - 1) != get_byte(b, i - 1) THEN
81+
first_diff := i;
82+
END IF;
83+
END LOOP;
84+
85+
IF first_diff = 0 THEN
86+
RETURN 0;
87+
END IF;
88+
89+
IF ((get_byte(b, first_diff - 1) + 1) & 255) = get_byte(a, first_diff - 1) THEN
90+
RETURN 1;
91+
ELSE
92+
RETURN -1;
93+
END IF;
94+
END;
95+
$$ LANGUAGE plpgsql;
96+
97+
--! @brief Variable-length CLLW ORE term comparison
98+
--! @internal
99+
--!
100+
--! Three-way comparison of two CLLW ORE ciphertext terms of potentially
101+
--! different lengths. Compares the shared prefix via the CLLW per-byte
102+
--! protocol; on equal prefixes, the shorter input sorts first. The leading
103+
--! domain-tag byte makes numeric (`0x00`) sort before string (`0x01`). Stays
104+
--! `LANGUAGE plpgsql` because it dispatches to `compare_ore_cllw_term_bytes`.
105+
--!
106+
--! btree filters NULL composites at the row level, so this should never see a
107+
--! NULL composite under normal operation; the IS-NULL guard returns NULL
108+
--! defensively. A non-NULL composite with NULL `bytes` is a contract violation
109+
--! — the extractor returns SQL NULL (not ROW(NULL)) on missing `oc`, so raise
110+
--! loudly rather than silently misorder.
111+
--!
112+
--! @param a eql_v3.ore_cllw First term
113+
--! @param b eql_v3.ore_cllw Second term
114+
--! @return integer -1, 0, or 1; NULL if either composite is NULL
115+
--! @throws Exception if either composite has a NULL `bytes` field
116+
--! @see eql_v3.compare_ore_cllw_term_bytes
117+
CREATE FUNCTION eql_v3.compare_ore_cllw_term(a eql_v3.ore_cllw, b eql_v3.ore_cllw)
118+
RETURNS int
119+
SET search_path = pg_catalog, extensions, public
120+
AS $$
121+
DECLARE
122+
len_a INT;
123+
len_b INT;
124+
common_len INT;
125+
cmp_result INT;
126+
BEGIN
127+
IF a::text IS NULL OR b::text IS NULL THEN
128+
RETURN NULL;
129+
END IF;
130+
131+
IF a.bytes IS NULL OR b.bytes IS NULL THEN
132+
RAISE EXCEPTION 'eql_v3.compare_ore_cllw_term: composite has NULL bytes field — extractor invariant violated. Check that the index expression uses eql_v3.ore_cllw(...) and not a hand-crafted ROW(NULL).';
133+
END IF;
134+
135+
len_a := LENGTH(a.bytes);
136+
len_b := LENGTH(b.bytes);
137+
138+
IF len_a = 0 AND len_b = 0 THEN
139+
RETURN 0;
140+
ELSIF len_a = 0 THEN
141+
RETURN -1;
142+
ELSIF len_b = 0 THEN
143+
RETURN 1;
144+
END IF;
145+
146+
IF len_a < len_b THEN
147+
common_len := len_a;
148+
ELSE
149+
common_len := len_b;
150+
END IF;
151+
152+
cmp_result := eql_v3.compare_ore_cllw_term_bytes(
153+
SUBSTRING(a.bytes FROM 1 FOR common_len),
154+
SUBSTRING(b.bytes FROM 1 FOR common_len)
155+
);
156+
157+
IF cmp_result = -1 THEN
158+
RETURN -1;
159+
ELSIF cmp_result = 1 THEN
160+
RETURN 1;
161+
END IF;
162+
163+
IF len_a < len_b THEN
164+
RETURN -1;
165+
ELSIF len_a > len_b THEN
166+
RETURN 1;
167+
ELSE
168+
RETURN 0;
169+
END IF;
170+
END;
171+
$$ LANGUAGE plpgsql;
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
-- REQUIRE: src/v3/schema.sql
2+
-- REQUIRE: src/v3/sem/ore_cllw/types.sql
3+
-- REQUIRE: src/v3/sem/ore_cllw/functions.sql
4+
-- REQUIRE: src/v3/sem/ore_cllw/operators.sql
5+
6+
--! @file v3/sem/ore_cllw/operator_class.sql
7+
--! @brief Btree operator class on the eql_v3.ore_cllw composite type.
8+
--!
9+
--! DEFAULT FOR TYPE so a functional btree index on eql_v3.ore_cllw(expr)
10+
--! engages without an explicit opclass annotation. FUNCTION 1 is the three-way
11+
--! comparator btree's internal sort uses; it is plpgsql by design (per-byte
12+
--! CLLW protocol needs iteration) and is called once per index-entry pair
13+
--! during build / search, not per-row in the outer query.
14+
--!
15+
--! @note Excluded from the Supabase build variant by the build glob
16+
--! `**/*operator_class.sql`.
17+
--! @see eql_v3.compare_ore_cllw_term
18+
19+
CREATE OPERATOR FAMILY eql_v3.ore_cllw_ops USING btree;
20+
21+
CREATE OPERATOR CLASS eql_v3.ore_cllw_ops
22+
DEFAULT FOR TYPE eql_v3.ore_cllw
23+
USING btree FAMILY eql_v3.ore_cllw_ops AS
24+
OPERATOR 1 < (eql_v3.ore_cllw, eql_v3.ore_cllw),
25+
OPERATOR 2 <= (eql_v3.ore_cllw, eql_v3.ore_cllw),
26+
OPERATOR 3 = (eql_v3.ore_cllw, eql_v3.ore_cllw),
27+
OPERATOR 4 >= (eql_v3.ore_cllw, eql_v3.ore_cllw),
28+
OPERATOR 5 > (eql_v3.ore_cllw, eql_v3.ore_cllw),
29+
FUNCTION 1 eql_v3.compare_ore_cllw_term(eql_v3.ore_cllw, eql_v3.ore_cllw);

0 commit comments

Comments
 (0)