From a3b3eb249c788d4540be8ba5b6af3c498d1959a9 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 18 May 2026 11:46:11 +1000 Subject: [PATCH] fix(operators): correct planner selectivity functions on <=, >, >= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Range operators on `eql_v2_encrypted` previously declared `RESTRICT = scalarltsel, JOIN = scalarltjoinsel` across `<=`, `>`, and `>=` — the "less-than" estimators. For `>` / `>=` that's semantically wrong (planner models a `>` predicate as if it filtered rows below the literal, not above); for `<=` it's off-by-one on the histogram boundary. The inner `eql_v2.ore_block_u64_8_256` `>=` operator had a related miss (`scalarlesel` where `scalargesel` belongs). Fix declares the correct estimators: - `<=` → scalarlesel / scalarlejoinsel - `>` → scalargtsel / scalargtjoinsel - `>=` → scalargesel / scalargejoinsel - inner ore_block `>=` → scalargesel / scalargejoinsel No query result changes — only plan choice for range queries. Latent on main because the previous plpgsql operator bodies weren't inlinable, so the planner couldn't match a functional ORE index for these operators regardless. Surfaced by #211 making the operators inlinable, at which point bad selectivity estimates start flipping plan choice. Closes #216. --- CHANGELOG.md | 4 ++++ src/operators/<=.sql | 12 ++++++------ src/operators/>.sql | 12 ++++++------ src/operators/>=.sql | 12 ++++++------ src/ore_block_u64_8_256/operators.sql | 4 ++-- 5 files changed, 24 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e84a3793..a04f6ff7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,10 @@ Targeting `2.3.0` as a breaking release. Customers re-encrypt their data as part - **Operator-class indexes (`CREATE INDEX … (col eql_v2.encrypted_operator_class)`) are discouraged for the equality / `LIKE` query path.** They will continue to function for the lifetime of `2.x` and are not slated for removal in this minor. Functional indexes (`eql_v2.hmac_256(col)`, `eql_v2.bloom_filter(col)`, `eql_v2.ste_vec(col)`) are now the canonical path because they (a) work on Supabase and managed Postgres without superuser, (b) avoid the btree row-size limit (`index row size N exceeds btree version 4 maximum 2704`) that opclass indexes hit on full-payload encryption, and (c) give the planner a structurally matchable extractor. The narrow exception is `ORDER BY` over Block ORE columns, where a custom comparator is strictly required — keep opclass indexes on those columns. See [U-001](docs/upgrading/v2.3.md#u-001-functional-indexes-as-the-canonical-recipe). - **`eql_v2.lt`, `eql_v2.lte`, `eql_v2.gt`, `eql_v2.gte` are deprecated and slated for removal in EQL 3.0.** These plpgsql helpers used to back the `<` / `<=` / `>` / `>=` operators; after the range-operator inlining ([U-005](docs/upgrading/v2.3.md#u-005-range-operators-are-block-ore-only)) the operators bypass them entirely and inline an `ore_block_u64_8_256` comparison directly. The helpers still walk `eql_v2.compare`'s priority list (ore_block → ore_cllw_u64 → ore_cllw_var → ope), so on `ore_cllw_*` / OPE-only columns they will return a Boolean where the matching operator now raises — same name, divergent contract. Callers invoking them directly should switch to the operator form for `ore` columns, or to the relevant extractor form (e.g. `eql_v2.ore_cllw_u64_8(col) < eql_v2.ore_cllw_u64_8($1::jsonb)`) for `ore_cllw_*` / OPE columns. ([#211](https://github.com/cipherstash/encrypt-query-language/pull/211)) +### Fixed + +- **Range operators on `eql_v2_encrypted` now declare the correct planner selectivity functions.** `<=`, `>`, and `>=` (all three type overloads each) previously declared `RESTRICT = scalarltsel, JOIN = scalarltjoinsel` — the "less-than" estimators — which fed the planner inaccurate row-count estimates for the affected predicates. The inner `eql_v2.ore_block_u64_8_256` `>=` operator had a related miss (`scalarlesel` where `scalargesel` belongs). Now `<=` uses `scalarlesel`, `>` uses `scalargtsel`, and `>=` uses `scalargesel` (matching `*joinsel` variants for the JOIN selector). No query result changes — only plan choice for range queries against Block ORE columns, which becomes load-bearing now that bare-form range predicates structurally match a functional ORE index ([#211](https://github.com/cipherstash/encrypt-query-language/pull/211)). ([#216](https://github.com/cipherstash/encrypt-query-language/issues/216)) + ### Upgrade notes See [`docs/upgrading/v2.3.md`](docs/upgrading/v2.3.md). Four numbered notes cover the indexing recipe shift (U-001), the hmac requirement for equality and hashing (U-002), the formalisation of Blake3 as ste_vec-internal (U-003 — now historical, see U-004), and the breaking ste_vec element shape migration plus the new `eql_v2.hmac_256(col, '')` recipe (U-004). diff --git a/src/operators/<=.sql b/src/operators/<=.sql index 8b8f0b8c..c578558e 100644 --- a/src/operators/<=.sql +++ b/src/operators/<=.sql @@ -61,8 +61,8 @@ CREATE OPERATOR <=( RIGHTARG = eql_v2_encrypted, COMMUTATOR = >=, NEGATOR = >, - RESTRICT = scalarltsel, - JOIN = scalarltjoinsel + RESTRICT = scalarlesel, + JOIN = scalarlejoinsel ); --! @brief <= operator for encrypted value and JSONB @@ -80,8 +80,8 @@ CREATE OPERATOR <=( RIGHTARG = jsonb, COMMUTATOR = >=, NEGATOR = >, - RESTRICT = scalarltsel, - JOIN = scalarltjoinsel + RESTRICT = scalarlesel, + JOIN = scalarlejoinsel ); --! @brief <= operator for JSONB and encrypted value @@ -100,6 +100,6 @@ CREATE OPERATOR <=( RIGHTARG = eql_v2_encrypted, COMMUTATOR = >=, NEGATOR = >, - RESTRICT = scalarltsel, - JOIN = scalarltjoinsel + RESTRICT = scalarlesel, + JOIN = scalarlejoinsel ); diff --git a/src/operators/>.sql b/src/operators/>.sql index ca949990..7b9520a9 100644 --- a/src/operators/>.sql +++ b/src/operators/>.sql @@ -69,8 +69,8 @@ CREATE OPERATOR >( RIGHTARG=eql_v2_encrypted, COMMUTATOR = <, NEGATOR = <=, - RESTRICT = scalarltsel, - JOIN = scalarltjoinsel + RESTRICT = scalargtsel, + JOIN = scalargtjoinsel ); --! @brief > operator for encrypted value and JSONB @@ -91,8 +91,8 @@ CREATE OPERATOR >( RIGHTARG = jsonb, COMMUTATOR = <, NEGATOR = <=, - RESTRICT = scalarltsel, - JOIN = scalarltjoinsel + RESTRICT = scalargtsel, + JOIN = scalargtjoinsel ); --! @brief > operator for JSONB and encrypted value @@ -114,6 +114,6 @@ CREATE OPERATOR >( RIGHTARG = eql_v2_encrypted, COMMUTATOR = <, NEGATOR = <=, - RESTRICT = scalarltsel, - JOIN = scalarltjoinsel + RESTRICT = scalargtsel, + JOIN = scalargtjoinsel ); diff --git a/src/operators/>=.sql b/src/operators/>=.sql index 4c4e6f39..d7c2832c 100644 --- a/src/operators/>=.sql +++ b/src/operators/>=.sql @@ -62,8 +62,8 @@ CREATE OPERATOR >=( RIGHTARG = eql_v2_encrypted, COMMUTATOR = <=, NEGATOR = <, - RESTRICT = scalarltsel, - JOIN = scalarltjoinsel + RESTRICT = scalargesel, + JOIN = scalargejoinsel ); --! @brief >= operator for encrypted value and JSONB @@ -84,8 +84,8 @@ CREATE OPERATOR >=( RIGHTARG=jsonb, COMMUTATOR = <=, NEGATOR = <, - RESTRICT = scalarltsel, - JOIN = scalarltjoinsel + RESTRICT = scalargesel, + JOIN = scalargejoinsel ); --! @brief >= operator for JSONB and encrypted value @@ -107,6 +107,6 @@ CREATE OPERATOR >=( RIGHTARG =eql_v2_encrypted, COMMUTATOR = <=, NEGATOR = <, - RESTRICT = scalarltsel, - JOIN = scalarltjoinsel + RESTRICT = scalargesel, + JOIN = scalargejoinsel ); diff --git a/src/ore_block_u64_8_256/operators.sql b/src/ore_block_u64_8_256/operators.sql index 80b34c97..e9e34561 100644 --- a/src/ore_block_u64_8_256/operators.sql +++ b/src/ore_block_u64_8_256/operators.sql @@ -195,6 +195,6 @@ CREATE OPERATOR >= ( RIGHTARG=eql_v2.ore_block_u64_8_256, COMMUTATOR = <=, NEGATOR = <, - RESTRICT = scalarlesel, - JOIN = scalarlejoinsel + RESTRICT = scalargesel, + JOIN = scalargejoinsel );