Skip to content

Commit 98475bc

Browse files
committed
fix(v3): harden ste_vec_entry min/max over oc-less entries; cover blocker/ORE-<> review gaps
#2 (behaviour): eql_v3.min/max on ste_vec_entry now ignore non-orderable (oc-less) entries instead of letting an oc-less STRICT seed pin a wrong extremum. ore_cllw(entry) is NULL without an oc term, so the naive ore_cllw(value) < ore_cllw(state) was NULL whenever the running extremum was oc-less. The sfuncs skip oc-less candidates and replace an oc-less seed, the same way the ore_cllw btree NULL-filters such rows. New test jsonb_entry_int4_aggregate_ignores_oc_less_entries feeds a forged hm-only entry in the SEED position and asserts the correct orderable extremum. #3a (coverage): v3_jsonb_blocker_return_types_match_native pins each jsonb_blocked% function's return type to the native operator it shadows (#> jsonb, #>> text, -/#-/|| jsonb), and v3_jsonb_blocked_composed_expression_raises asserts composed exprs resolve and raise 'is not supported' (not 'operator does not exist') — guarding aa13065's blocker-return-type fix. #3b (coverage): ore_block_comparison_operators_declare_correct_selectivity (T11) pins ore_block_u64_8_256 <> to neqsel/neqjoinsel with no HASHES, and = to eqsel/eqjoinsel/HASHES. Snapshots: matrix_jsonb_entry_tests 54->55, v3_jsonb_tests 72->74. CHANGELOG: note oc-less entries are ignored by entry min/max.
1 parent a5e4161 commit 98475bc

7 files changed

Lines changed: 295 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ Each entry that ships in a published release links to the PR that introduced it.
3131
- **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))
3232
- **`eql_v3.text` encrypted-domain family (`text`, `text_eq`, `text_match`, `text_ord`, `text_ord_ore`).** 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`). 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` domain — deliberately *not* SQL `LIKE` (no wildcard/anchoring; probabilistic ngram containment) — and never backs equality (which always routes through `Hm`). ([#260](https://github.com/cipherstash/encrypt-query-language/pull/260))
3333
- **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))
34-
- **`eql_v3.min` / `eql_v3.max` aggregates over `eql_v3.ste_vec_entry`.** SteVec document entries extracted at a selector (`doc -> 'sel'`) can now be aggregated like ordered scalars: `eql_v3.min(doc -> 'sel')` / `eql_v3.max(...)` return the entry with the smallest / largest ordered leaf. Ordering routes through the entry's `oc` (CLLW ORE) term via `eql_v3.ore_cllw` — the same comparator the entry `<` / `<=` / `>` / `>=` operators use, not the scalar Block-ORE `ord_term`. Declared `PARALLEL = SAFE` with a combine function (the state function itself), so partial / parallel aggregation is available on large `GROUP BY` workloads. Why: brings encrypted-JSONB entry ordering to parity with the scalar encrypted-domain families' `MIN` / `MAX`, and lets the shared scalar behaviour matrix cover entry aggregation. Additive — the document and entry comparison surface is otherwise unchanged. ([#267](https://github.com/cipherstash/encrypt-query-language/pull/267))
34+
- **`eql_v3.min` / `eql_v3.max` aggregates over `eql_v3.ste_vec_entry`.** SteVec document entries extracted at a selector (`doc -> 'sel'`) can now be aggregated like ordered scalars: `eql_v3.min(doc -> 'sel')` / `eql_v3.max(...)` return the entry with the smallest / largest ordered leaf. Ordering routes through the entry's `oc` (CLLW ORE) term via `eql_v3.ore_cllw` — the same comparator the entry `<` / `<=` / `>` / `>=` operators use, not the scalar Block-ORE `ord_term`. Only `oc`-carrying entries are orderable: an entry without an `oc` term (`eql_v3.ore_cllw` returns NULL) is non-orderable and is ignored by the aggregate — the same way the `eql_v3.ore_cllw` btree NULL-filters such rows — so a mix of `oc`-carrying and `oc`-less entries yields the extremum of the orderable subset rather than a corrupted result. Declared `PARALLEL = SAFE` with a combine function (the state function itself), so partial / parallel aggregation is available on large `GROUP BY` workloads. Why: brings encrypted-JSONB entry ordering to parity with the scalar encrypted-domain families' `MIN` / `MAX`, and lets the shared scalar behaviour matrix cover entry aggregation. Additive — the document and entry comparison surface is otherwise unchanged. ([#267](https://github.com/cipherstash/encrypt-query-language/pull/267))
3535

3636
### Changed
3737

src/v3/jsonb/aggregates.sql

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,27 @@
1515
--! Per the encrypted-domain footgun rules the state functions are
1616
--! `LANGUAGE plpgsql` with the pinned `search_path` — a `LANGUAGE sql` body would
1717
--! be inlinable and the planner could elide it.
18+
--!
19+
--! @note **Only `oc`-carrying entries are orderable.** `eql_v3.ore_cllw(entry)`
20+
--! returns NULL when an entry has no `oc` (CLLW ORE) term — the same entries a
21+
--! `eql_v3.ore_cllw` btree NULL-filters from range scans. The state functions
22+
--! therefore IGNORE `oc`-less entries (they never become or survive as the
23+
--! extremum), so `min`/`max` is well-defined over a mix of `oc`-carrying and
24+
--! `oc`-less entries and is not corrupted by an `oc`-less seed. A naive
25+
--! `ore_cllw(value) < ore_cllw(state)` would be NULL whenever either side
26+
--! lacks `oc`, pinning a wrong (`oc`-less) extremum when the first aggregated
27+
--! row is `oc`-less. An all-`oc`-less input has no orderable extremum and
28+
--! returns the (arbitrary) STRICT seed.
1829

1930
--! @brief State function for min on eql_v3.ste_vec_entry.
2031
--!
21-
--! Keeps whichever entry has the lesser CLLW ORE term. STRICT, so NULL entries
22-
--! (and entries whose `oc` is absent, yielding a NULL `ore_cllw`) are skipped by
23-
--! the aggregate machinery / fall through to `state`.
32+
--! Keeps whichever orderable entry has the lesser CLLW ORE term. STRICT, so SQL
33+
--! NULL entries are skipped by the aggregate machinery; `oc`-less (non-orderable)
34+
--! entries are skipped explicitly (see the @note on this file).
2435
--!
2536
--! @param state eql_v3.ste_vec_entry Running extremum.
2637
--! @param value eql_v3.ste_vec_entry Candidate entry.
27-
--! @return eql_v3.ste_vec_entry The lesser of the two by `ore_cllw`.
38+
--! @return eql_v3.ste_vec_entry The lesser orderable entry by `ore_cllw`.
2839
CREATE FUNCTION eql_v3.ste_vec_entry_min_sfunc(
2940
state eql_v3.ste_vec_entry,
3041
value eql_v3.ste_vec_entry
@@ -33,8 +44,17 @@ RETURNS eql_v3.ste_vec_entry
3344
LANGUAGE plpgsql IMMUTABLE STRICT PARALLEL SAFE
3445
SET search_path = pg_catalog, extensions, public
3546
AS $$
47+
DECLARE
48+
value_ore eql_v3.ore_cllw := eql_v3.ore_cllw(value);
49+
state_ore eql_v3.ore_cllw := eql_v3.ore_cllw(state);
3650
BEGIN
37-
IF eql_v3.ore_cllw(value) < eql_v3.ore_cllw(state) THEN
51+
-- A non-orderable (oc-less) candidate never replaces the running extremum.
52+
IF value_ore IS NULL THEN
53+
RETURN state;
54+
END IF;
55+
-- Adopt the candidate when the running extremum is itself non-orderable
56+
-- (e.g. an oc-less STRICT seed) or strictly greater.
57+
IF state_ore IS NULL OR value_ore < state_ore THEN
3858
RETURN value;
3959
END IF;
4060
RETURN state;
@@ -53,12 +73,12 @@ CREATE AGGREGATE eql_v3.min(eql_v3.ste_vec_entry) (
5373

5474
--! @brief State function for max on eql_v3.ste_vec_entry.
5575
--!
56-
--! Keeps whichever entry has the greater CLLW ORE term. STRICT, mirroring
57-
--! `ste_vec_entry_min_sfunc`.
76+
--! Keeps whichever orderable entry has the greater CLLW ORE term. `oc`-less
77+
--! entries are skipped, mirroring `ste_vec_entry_min_sfunc` (see the file @note).
5878
--!
5979
--! @param state eql_v3.ste_vec_entry Running extremum.
6080
--! @param value eql_v3.ste_vec_entry Candidate entry.
61-
--! @return eql_v3.ste_vec_entry The greater of the two by `ore_cllw`.
81+
--! @return eql_v3.ste_vec_entry The greater orderable entry by `ore_cllw`.
6282
CREATE FUNCTION eql_v3.ste_vec_entry_max_sfunc(
6383
state eql_v3.ste_vec_entry,
6484
value eql_v3.ste_vec_entry
@@ -67,8 +87,17 @@ RETURNS eql_v3.ste_vec_entry
6787
LANGUAGE plpgsql IMMUTABLE STRICT PARALLEL SAFE
6888
SET search_path = pg_catalog, extensions, public
6989
AS $$
90+
DECLARE
91+
value_ore eql_v3.ore_cllw := eql_v3.ore_cllw(value);
92+
state_ore eql_v3.ore_cllw := eql_v3.ore_cllw(state);
7093
BEGIN
71-
IF eql_v3.ore_cllw(value) > eql_v3.ore_cllw(state) THEN
94+
-- A non-orderable (oc-less) candidate never replaces the running extremum.
95+
IF value_ore IS NULL THEN
96+
RETURN state;
97+
END IF;
98+
-- Adopt the candidate when the running extremum is itself non-orderable
99+
-- (e.g. an oc-less STRICT seed) or strictly lesser.
100+
IF state_ore IS NULL OR value_ore > state_ore THEN
72101
RETURN value;
73102
END IF;
74103
RETURN state;

tests/sqlx/snapshots/matrix_jsonb_entry_tests.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
jsonb_entry::jsonb_entry_<T>_aggregate_ignores_oc_less_entries
12
jsonb_entry::jsonb_entry_<T>_fixture_shape
23
jsonb_entry::jsonb_entry_<T>_index_engages
34
jsonb_entry::jsonb_entry_<T>_ore_cllw_injectivity

tests/sqlx/snapshots/v3_jsonb_tests.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ v3_jsonb_arrow_accessors_supported_null
44
v3_jsonb_arrow_integer_index_on_array
55
v3_jsonb_at_at_blocker
66
v3_jsonb_at_question_blocker
7+
v3_jsonb_blocked_composed_expression_raises
8+
v3_jsonb_blocker_return_types_match_native
79
v3_jsonb_concat_blocker
810
v3_jsonb_containment_hm_only
911
v3_jsonb_containment_mixed

tests/sqlx/tests/encrypted_domain/family/sem.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -546,3 +546,54 @@ async fn has_bloom_filter_detects_bf_presence(pool: PgPool) -> Result<()> {
546546
}
547547
Ok(())
548548
}
549+
550+
/// T11 — Planner-selectivity metadata for the `eql_v3.ore_block_u64_8_256`
551+
/// `=` / `<>` operators. `<>` must use the inequality estimators
552+
/// (`neqsel` / `neqjoinsel`) and must NOT declare `HASHES` — an earlier revision
553+
/// copied `=`'s `eqsel` / `eqjoinsel` + `HASHES` onto `<>`, which is meaningless
554+
/// (you cannot hash-join on inequality) and mis-estimates selectivity (#267
555+
/// review / aa13065). `=` is the contrast: it keeps `eqsel` / `eqjoinsel` and
556+
/// `HASHES`. A catalog pin (deterministic, no plan dependence).
557+
#[sqlx::test]
558+
async fn ore_block_comparison_operators_declare_correct_selectivity(pool: PgPool) -> Result<()> {
559+
let (eq_rest, eq_join, eq_hashes, eq_merges): (String, String, bool, bool) = sqlx::query_as(
560+
r#"
561+
SELECT o.oprrest::text, o.oprjoin::text, o.oprcanhash, o.oprcanmerge
562+
FROM pg_operator o
563+
WHERE o.oprname = '='
564+
AND o.oprleft = 'eql_v3.ore_block_u64_8_256'::regtype
565+
AND o.oprright = 'eql_v3.ore_block_u64_8_256'::regtype
566+
"#,
567+
)
568+
.fetch_one(&pool)
569+
.await?;
570+
assert_eq!(eq_rest, "eqsel", "= must use eqsel");
571+
assert_eq!(eq_join, "eqjoinsel", "= must use eqjoinsel");
572+
assert!(eq_hashes, "= must declare HASHES");
573+
assert!(eq_merges, "= must declare MERGES");
574+
575+
let (neq_rest, neq_join, neq_hashes): (String, String, bool) = sqlx::query_as(
576+
r#"
577+
SELECT o.oprrest::text, o.oprjoin::text, o.oprcanhash
578+
FROM pg_operator o
579+
WHERE o.oprname = '<>'
580+
AND o.oprleft = 'eql_v3.ore_block_u64_8_256'::regtype
581+
AND o.oprright = 'eql_v3.ore_block_u64_8_256'::regtype
582+
"#,
583+
)
584+
.fetch_one(&pool)
585+
.await?;
586+
assert_eq!(
587+
neq_rest, "neqsel",
588+
"<> must use neqsel (not eqsel — it estimates the inequality fraction)"
589+
);
590+
assert_eq!(
591+
neq_join, "neqjoinsel",
592+
"<> must use neqjoinsel (not eqjoinsel)"
593+
);
594+
assert!(
595+
!neq_hashes,
596+
"<> must NOT declare HASHES — hash joins are meaningless for inequality"
597+
);
598+
Ok(())
599+
}

tests/sqlx/tests/encrypted_domain/jsonb_entry.rs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,3 +211,86 @@ async fn jsonb_entry_int4_index_engages(pool: sqlx::PgPool) -> anyhow::Result<()
211211
tx.commit().await?;
212212
Ok(())
213213
}
214+
215+
// ----------------------------------------------------------------------------
216+
// Aggregate robustness over non-orderable (oc-less) entries. `eql_v3.ore_cllw`
217+
// is NULL for an entry without an `oc` term, so a naive `ore_cllw(value) <
218+
// ore_cllw(state)` would be NULL whenever the running extremum is oc-less —
219+
// pinning a wrong result when the FIRST aggregated row (the STRICT seed) is
220+
// oc-less. The min/max sfuncs explicitly skip oc-less entries. This feeds a
221+
// forged hm-only (oc-less) entry in the SEED position alongside real oc-carrying
222+
// entries and asserts the extremum is the correct ORDERABLE entry, never the
223+
// oc-less seed. The whole-suite matrix never exercises this (every v3_doc_int4
224+
// entry carries oc).
225+
// ----------------------------------------------------------------------------
226+
#[sqlx::test(fixtures(path = "../../fixtures", scripts("v3_doc_int4")))]
227+
async fn jsonb_entry_int4_aggregate_ignores_oc_less_entries(
228+
pool: sqlx::PgPool,
229+
) -> anyhow::Result<()> {
230+
let sel = SELECTOR;
231+
// A valid eql_v3.ste_vec_entry that is NOT orderable: string s, string c,
232+
// exactly one of hm/oc — here `hm`, so `eql_v3.ore_cllw(entry)` is NULL.
233+
let oc_less = r#"{"s":"forged","c":"x","hm":"00"}"#;
234+
235+
let mut sorted: Vec<i32> = <JsonbEntryInt4 as ScalarType>::fixture_values()
236+
.iter()
237+
.map(|e| e.0)
238+
.collect();
239+
sorted.sort();
240+
let low = sorted[0];
241+
let high = *sorted.last().expect("fixture is non-empty");
242+
243+
let mut tx = pool.begin().await?;
244+
sqlx::query("CREATE TEMP TABLE oc_mix (value eql_v3.ste_vec_entry) ON COMMIT DROP")
245+
.execute(&mut *tx)
246+
.await?;
247+
// SEED position: the oc-less entry is inserted FIRST, so the STRICT seed is
248+
// non-orderable — the exact case the sfunc guard must survive.
249+
sqlx::query("INSERT INTO oc_mix(value) VALUES ($1::jsonb::eql_v3.ste_vec_entry)")
250+
.bind(oc_less)
251+
.execute(&mut *tx)
252+
.await?;
253+
sqlx::query(&format!(
254+
"INSERT INTO oc_mix(value) \
255+
SELECT (payload -> '{sel}'::text)::eql_v3.ste_vec_entry \
256+
FROM fixtures.v3_doc_int4 WHERE plaintext IN ({low}, {high})",
257+
))
258+
.execute(&mut *tx)
259+
.await?;
260+
261+
// Expected extrema: the orderable entries for the smallest / largest int4,
262+
// NOT the oc-less seed.
263+
let expect_min: String = sqlx::query_scalar(&format!(
264+
"SELECT ((payload -> '{sel}'::text)::eql_v3.ste_vec_entry)::text \
265+
FROM fixtures.v3_doc_int4 WHERE plaintext = {low}",
266+
))
267+
.fetch_one(&mut *tx)
268+
.await?;
269+
let expect_max: String = sqlx::query_scalar(&format!(
270+
"SELECT ((payload -> '{sel}'::text)::eql_v3.ste_vec_entry)::text \
271+
FROM fixtures.v3_doc_int4 WHERE plaintext = {high}",
272+
))
273+
.fetch_one(&mut *tx)
274+
.await?;
275+
276+
let got_min: String = sqlx::query_scalar("SELECT eql_v3.min(value)::text FROM oc_mix")
277+
.fetch_one(&mut *tx)
278+
.await?;
279+
let got_max: String = sqlx::query_scalar("SELECT eql_v3.max(value)::text FROM oc_mix")
280+
.fetch_one(&mut *tx)
281+
.await?;
282+
283+
anyhow::ensure!(
284+
got_min == expect_min,
285+
"eql_v3.min must ignore the oc-less seed and return the smallest orderable entry;\n \
286+
want {expect_min}\n got {got_min}",
287+
);
288+
anyhow::ensure!(
289+
got_max == expect_max,
290+
"eql_v3.max must ignore the oc-less entry and return the largest orderable entry;\n \
291+
want {expect_max}\n got {got_max}",
292+
);
293+
294+
tx.commit().await?;
295+
Ok(())
296+
}

0 commit comments

Comments
 (0)