Skip to content
This repository was archived by the owner on Sep 2, 2026. It is now read-only.
This repository was archived by the owner on Sep 2, 2026. It is now read-only.

perf: flip eql_v2_encrypted infix operator implementations to inlinable SQL (RFC Phase 1) #193

Closed

Description

@coderdan

Summary

The =, <>, ~~ (LIKE), ~~* (ILIKE), @>, and <@ operators on
eql_v2_encrypted are registered today, but their implementation
functions are either LANGUAGE plpgsql (which Postgres can never
inline) or LANGUAGE sql VOLATILE (which the planner refuses to
inline into index matching). As a result, queries like
WHERE col = val on encrypted columns silently fall back to a
sequential scan on every install where the catch-all
eql_v2.encrypted_operator_class btree isn't usable — most
prominently Supabase and any --exclude-operator-family build.

This issue tracks the smallest possible change that fixes the
problem at the EQL layer: rewrite the implementation functions as
LANGUAGE sql IMMUTABLE single-statement SELECTs. Postgres inlines
them during planning, and the resulting expressions match the
documented functional indexes (bench_text_hmac_idx,
bench_text_bloom_idx, bench_jsonb_stevec_idx).

This is Phase 1 of the broader predicate/extractor RFC
(docs/plans/uniform-predicate-extractor-pairs-rfc.md in this repo).
Phase 1 is self-contained and shippable independently — it does not
require the new _sem extractor surface or the predicate function
namespace.

Empirical confirmation

Tested against the cipherstash/stack#424 bench fixture (10k encrypted
rows in local-postgres-1):

-- Custom inlinable function:
CREATE FUNCTION eql_v2.eq_inlinable(a eql_v2_encrypted, b eql_v2_encrypted)
  RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE
AS $$ SELECT eql_v2.hmac_256(a) = eql_v2.hmac_256(b) $$;

CREATE OPERATOR === (LEFTARG = eql_v2_encrypted, RIGHTARG = eql_v2_encrypted,
                    FUNCTION = eql_v2.eq_inlinable);

EXPLAIN (ANALYZE) SELECT id FROM bench WHERE enc_text === <encrypted_value>;
-- Result:
--   Index Scan using bench_text_hmac_idx on bench
--     Index Cond: ((eql_v2.hmac_256(enc_text))::text = (eql_v2.hmac_256(...))::text)
--   Execution Time: 0.628 ms

Same plan as the function-call form WHERE eql_v2.eq_inlinable(col, val).
The planner inlines the operator's SQL function during index matching;
this is the same pattern pg_trgm uses internally.

Operators in scope

Operator Current impl Replace with (LANGUAGE sql IMMUTABLE PARALLEL SAFE)
= (eql_v2_encrypted, eql_v2_encrypted) plpgsql, dispatches via eql_v2.compare SELECT eql_v2.hmac_256(a) = eql_v2.hmac_256(b)
<> plpgsql <> form of the same
~~ (LIKE) sql VOLATILE SELECT eql_v2.bloom_filter(a) @> eql_v2.bloom_filter(b)
~~* (ILIKE) sql VOLATILE same body — case sensitivity is at encryption time
@> (jsonb containment) sql VOLATILE flip to IMMUTABLE; existing body shape OK
<@ sql VOLATILE flip to IMMUTABLE

Out of scope for Phase 1 (deferred to Phase 2 of the RFC, blocked on
cmp_sem extractor design):

  • <, <=, >, >= — currently VOLATILE plpgsql, no Supabase
    index path defined yet.
  • ORDER BY — same.
  • jsonb_path_query_first / jsonb_path_exists (the function-form
    variants) — needs predicate vs extractor split per the RFC.

What this fixes

What's not changed

  • eql_v2.encrypted_operator_class continues to exist. Customers with
    direct-on-column btree indexes via the operator class keep working.
  • The function-form predicates (eql_v2.eq(a, b), eql_v2.compare,
    etc.) keep their current implementations. This is purely an
    operator-implementation swap.
  • No data migration. Existing encrypted payloads contain the index
    components the new operator implementations extract (hm, bf).

Implementation notes

  • The new operator implementation functions should live alongside the
    existing dispatcher functions, with names like eql_v2.eq_inlinable
    (or whatever convention fits the codebase). The existing
    eql_v2.compare-based functions remain available as
    eql_v2.eq / eql_v2.neq etc. for direct invocation; only the
    operator binding changes.
  • Verify all six operators with EXPLAIN against the bench fixture
    before/after the change.
  • Update tests in tests/sqlx/ to assert on plan shape (or use a
    separate bench harness — see perf(bench): add @cipherstash/bench for index-engagement validation stack#424 for the JS-side
    reference implementation).

Related

Activity

  1. added a commit that references this issue on May 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions