diff --git a/CHANGELOG.md b/CHANGELOG.md index 24663d93..c3f2061f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ Each entry that ships in a published release links to the PR that introduced it. ## [Unreleased] +### Added + +- **`eql_v2_int4` variant family — four capability-encoded domain types for encrypted `int4` columns.** Pick the variant whose operator surface matches the index terms your column carries: `eql_v2_int4` (storage only, every operator blocked — carries `c`), `eql_v2_int4_eq` (HMAC equality only — `=`, `<>` — carries `c`, `hm`), `eql_v2_int4_ord_ore` (equality + ORE-block ordering — `=`, `<>`, `<`, `<=`, `>`, `>=` — carries `c`, `ob`), or `eql_v2_int4_ord` (the recommended ordered name; the identical operator surface to `eql_v2_int4_ord_ore`). Each variant exposes a uniform index extractor — `eql_v2.eq_term(col)` for `eql_v2_int4_eq`, `eql_v2.ord_term(col)` for the ordered variants — and no index recipe needs a `::jsonb` cast. Ordered columns share one functional btree across equality and range, `CREATE INDEX ... USING btree (eql_v2.ord_term(col))`, with `ORDER BY eql_v2.ord_term(col)` sorting in plaintext order; `eql_v2_int4_eq` indexes `eql_v2.eq_term(col)` with `USING hash` or `USING btree`. `eql_v2.ord_term` returns the internal `eql_v2.ore_block_u64_8_256` composite, which carries EQL's existing `DEFAULT` btree operator class, so no operator class is defined on the public domain types. The ordered variants do not carry an `hm` term: ORE on a full-domain `int4` is lossless, so the order term doubles as an exact equality term. All variants live in `public` and survive `eql_v2` uninstall. Each domain carries a `CHECK` constraint requiring the EQL envelope (`v`, `i`), the ciphertext (`c`), and the variant's index term(s), so a payload missing a required key is rejected on insert or cast rather than surfacing later at query time. Note: the ORE operator class is excluded from the Supabase build, so ordered `int4` columns fall back to seq-scan for range on Supabase. ([#225](https://github.com/cipherstash/encrypt-query-language/pull/225)) + ## [2.3.0] — 2026-05-20 `2.3.0` is a breaking release. Customers re-encrypt their data as part of the upgrade — the crypto-side counterpart (`@cipherstash/protect` / `protect-ffi` / proxy) emits a new ste_vec element shape. See [`docs/upgrading/v2.3.md`](docs/upgrading/v2.3.md) for the consolidated upgrade notes. diff --git a/CLAUDE.md b/CLAUDE.md index 65ed6d58..6c00d346 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,6 +61,7 @@ This is the **Encrypt Query Language (EQL)** - a PostgreSQL extension for search - `src/operators/` - SQL operators for encrypted data comparisons - `src/config/` - Configuration management functions - `src/blake3/`, `src/hmac_256/`, `src/bloom_filter/`, `src/ore_*` - Index implementations +- `src/encrypted_domain/` - Encrypted-domain type families (jsonb-backed PostgreSQL domains, one per operator/index capability) - `tasks/` - mise task scripts - `tests/sqlx/` - Rust/SQLx test framework (PostgreSQL 14-17 support) - `release/` - Generated SQL installation files @@ -72,6 +73,20 @@ This is the **Encrypt Query Language (EQL)** - a PostgreSQL extension for search - **Operators**: Support comparisons between encrypted and plain JSONB data - **CipherStash Proxy**: Required for encryption/decryption operations +### Encrypted-Domain Types + +`src/encrypted_domain/` holds **encrypted-domain type families** — jsonb-backed PostgreSQL domains, one domain per operator/index capability (`eql_v2_` storage-only, `eql_v2__eq`, `eql_v2__ord`). `eql_v2_int4` (PR #225) is the reference implementation; `int8`, `bool`, `date`, `float`, `numeric`, `timestamp`, and `jsonb` follow the same pattern. + +**Adding a new encrypted-domain type: follow `docs/reference/encrypted-domain-implementation-spec.md`.** It is the consolidated spec, checklist, and per-section reference. The mechanics are fixed; per-type judgment calls (variant set, payload terms, ORE lossless vs lossy, native edge semantics) go in a short type-specific design note resolved *first*. + +Footguns the spec exists to prevent: + +- **Blockers must never be `STRICT`.** A `STRICT` blocker lets PostgreSQL skip the body and return `NULL` on a `NULL` argument, silently bypassing the "operator not supported" exception. +- **No domain-over-domain** (`CREATE DOMAIN a AS b`). Operators resolve against the ultimate base type (`jsonb`), so a derived domain does not inherit the base domain's operator surface — blockers stop engaging. +- **No operator class on a domain.** Index through a functional index on the extractor (`eq_term` / `ord_term`), whose return type already carries a default opclass. +- **Inlinable functions** (extractors, comparison wrappers) need `LANGUAGE sql`, a single-statement `SELECT`, `IMMUTABLE`, and **no `SET` clause** — a pinned `search_path` disables inlining. Allowlist each one in `tasks/pin_search_path.sql` and `tasks/test/splinter.sh`. +- **Build with `mise run clean && mise run build`** — a bare build can leave stale `release/*.sql`. + ### Testing Infrastructure - Tests are written in Rust using SQLx, located in `tests/sqlx/` - Tests run against PostgreSQL 14, 15, 16, 17 using Docker containers diff --git a/docs/reference/encrypted-domain-implementation-spec.md b/docs/reference/encrypted-domain-implementation-spec.md new file mode 100644 index 00000000..56c1ec94 --- /dev/null +++ b/docs/reference/encrypted-domain-implementation-spec.md @@ -0,0 +1,611 @@ +# Encrypted domain type — implementation spec + +A consolidated, type-agnostic spec and checklist for implementing an +encrypted-domain type family in EQL. The pattern is the one established +by `eql_v2_int4` (PR #225); this document generalises it so the same +mechanics can be applied to `int8`, `bool`, `date`, `float`/`double`, +`numeric`, `timestamp`, and `jsonb`. + +**Audience:** contributors adding a new encrypted-domain type. +**Reference implementation:** `src/encrypted_domain/int4/`, +`tests/sqlx/tests/encrypted_int4*`. + +--- + +## 1. The model + +An encrypted-domain type is a **family of jsonb-backed PostgreSQL +domains**, one domain per operator/index-term **capability**. The +capability is encoded in the domain name: + +| Domain name | Capability | Capability terms | +|------------------------|-------------------------------------|------------------| +| `eql_v2_` | storage only — every operator raises | design-note ciphertext term; int4 uses `c` | +| `eql_v2__eq` | equality (`=`, `<>`) | ciphertext + equality term; int4 uses `c`, `hm` | +| `eql_v2__ord` | equality + ordering (`=` `<>` `<` `<=` `>` `>=`) | ciphertext + order term, and possibly equality term; int4 uses `c`, `ob` | +| `eql_v2__ord_` | as `_ord`, scheme-explicit name | same as that ordered variant's design note | + +A caller picks the domain whose capability matches the searches they +need; an unmatched operator **raises** rather than silently falling +through to native `jsonb` behaviour. + +Every domain also carries the EQL envelope keys (`v`, `i`) in addition +to the capability terms above, and **each domain enforces a `CHECK` +constraint** requiring the envelope plus its capability terms (§3). +The baseline `CHECK` pattern is presence/type-shape validation only: +it rejects non-object and under-populated payloads at the point they are +cast into the domain, but it does not prove that a present term has the +full internal structure an extractor expects unless the type chooses a +stronger structural `CHECK`. + +### What is fixed vs. what each type decides + +**Fixed by this spec (the mechanics):** how domains are declared, the +inlinability rules, the operator surface and arg-shapes, the +no-opclass-on-domains rule, the test structure, the fixture format, and +the coverage bar. Sections 4–10 below. + +**Decided per type (its own design step):** which variants the family +has, the payload terms each variant carries, and the index/equality +scheme. These depend on the type's encryption scheme and are **not** +mechanical. Examples of decisions the int4 family made that do **not** +transfer automatically: + +- int4's ordered variants carry `c`, `ob` and **drop `hm`** because ORE + on a full-domain `int4` is lossless — the order term doubles as an + exact equality term. A type whose ORE is **lossy**, or whose domain is + not fully covered (candidates: `float`/`double`, `numeric`), must keep + `hm` on its ordered variants and route `=`/`<>` through `eq_term`, not + `ord_term`. +- int4 ships two ordered domains (`_ord` and `_ord_ore`) as mechanical + twins. A type with a single ordering scheme needs only `_ord`. +- `jsonb` does not fit the scalar storage/eq/ord shape; see §11. +- Native edge semantics are type-specific. The design note must + explicitly include or exclude edge values such as float/double `NaN`, + infinities and `-0.0`/`0.0`; `numeric` precision/scale boundaries; + date min/max and calendar boundaries; timestamp timezone and DST + behavior; and bool's two-value domain. + +Resolve these before writing code, and record them in a short +type-specific design note. Everything else follows the checklist. + +--- + +## 2. Implementation checklist + +Work top to bottom. Each item links to its reference section. + +### Design (per type — resolve first) +- [ ] Choose the variant set and each variant's payload terms (§1). +- [ ] Confirm whether ORE is lossless for this type — decides whether + ordered variants carry `hm` and where `=`/`<>` route (§1, §4). +- [ ] Pick the index-term type(s) the extractors will return — they + must already carry a default operator class (§4). +- [ ] Document native edge semantics for the type: include or exclude + float/double `NaN`, infinities, and signed zero; `numeric` + precision/scale boundaries; date boundaries; timestamp timezone + and DST behavior; and bool's tiny value domain (§8). + +### Types +- [ ] Declare every domain in `src/encrypted_domain/types.sql` as an + idempotent `CREATE DOMAIN public. AS jsonb` with a `CHECK` + constraint enforcing the envelope (`v`, `i`) plus the variant's + capability terms (§3). + +### Per variant — functions, then operators +- [ ] `src/encrypted_domain//__functions.sql`: the + extractor (eq/ord variants), the inlinable comparison wrappers for + supported operators, and the blockers for every unsupported + operator (§5, §6). +- [ ] `src/encrypted_domain//__operators.sql`: a + `CREATE OPERATOR` for every operator × arg-shape (§6). +- [ ] Add `-- REQUIRE:` headers to every file (§9). + +### Wiring +- [ ] Allowlist every inlinable function in `tasks/pin_search_path.sql` + and `tasks/test/splinter.sh` (§5). +- [ ] `mise run clean && mise run build` — clean first, a bare build can + leave stale `release/*.sql` (§9). +- [ ] Confirm the Supabase and Protect build variants still build (§9). + +### Tests & fixtures +- [ ] Fixture generator `tasks/fixtures/generate_encrypted_.sh` and + its generated migration + `tests/sqlx/migrations/0NN_install_encrypted__fixture.sql` (§8). +- [ ] One SQLx suite per variant, + `tests/sqlx/tests/encrypted_[_variant]_tests.rs` (§7). +- [ ] A twin-sync `#[test]` if any variant is a mechanical twin (§7). +- [ ] Meet the coverage bar in §10. +- [ ] `mise run test` green on PostgreSQL 14–17. + +### Documentation +- [ ] Reference page, walkthrough, `CHANGELOG.md` `[Unreleased]` entry, + and a `docs/upgrading/v.md` upgrade note (§9). + +--- + +## 3. Reference — Type definitions + +- **One domain per capability**, all `AS jsonb`, all in the **`public`** + schema. Public placement matches `public.eql_v2_encrypted`: user table + columns depend on stable public type names, while implementation + functions and operators live in `eql_v2`. `tasks/uninstall.sql` drops + `eql_v2` but leaves the public domains in place. +- **Declare idempotently.** All domains for a type go in + `src/encrypted_domain/types.sql`, inside one `DO $$ … $$` block: + + ```sql + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_type + WHERE typname = 'eql_v2_' AND typnamespace = 'public'::regnamespace + ) THEN + CREATE DOMAIN public.eql_v2_ AS jsonb + CHECK ( + jsonb_typeof(VALUE) = 'object' + AND VALUE ? 'v' AND VALUE ? 'i' AND VALUE ? 'c' + ); + END IF; + -- … one IF NOT EXISTS block per variant … + END + $$; + ``` + +- **Every domain carries a `CHECK` constraint.** The payload must be a + `jsonb` object carrying the EQL envelope (`v`, `i`), the ciphertext + term, **and every capability term the variant relies on** — for + example `hm` for an int4 `_eq` variant and `ob` for an int4 lossless + `_ord` variant. The baseline constraint is enforced when a value is + cast into the domain and is intentionally limited to presence and + coarse type-shape unless the type chooses stronger structural checks. + It rejects non-object and under-populated payloads at write time; a + present but malformed term can still fail later inside an extractor or + query wrapper if the domain `CHECK` does not validate that term's + internal shape. The storage variant requires only the envelope and + ciphertext; each capability variant adds the terms from its design + note: + + ```sql + CREATE DOMAIN public.eql_v2__eq AS jsonb + CHECK ( + jsonb_typeof(VALUE) = 'object' + AND VALUE ? 'v' AND VALUE ? 'i' AND VALUE ? 'c' AND VALUE ? 'hm' + ); + ``` + +- **Every domain is a concrete domain over `jsonb`.** Do **not** declare + one domain as a domain over another (`CREATE DOMAIN a AS b`). The + int4 verification spike showed PostgreSQL resolves operators against + the *ultimate base type* (`jsonb`), so a domain-over-domain does not + inherit the base domain's operator surface — ordered operators fall + through to native `jsonb` comparison and blockers do not engage. Two + domains with the same capability (e.g. `_ord` and `_ord_ore`) are each + a separate concrete domain over `jsonb` carrying their own operator + surface; keep them in sync with a twin-sync test (§7). +- **Payload terms** are a per-variant assumption, documented in each + file's `--! @file` header (e.g. *"Payload-term assumption: `c`, `hm`."*). + If a variant chooses structural `CHECK` validation beyond + presence/type-shape, document that choice in the same design note and + add matching negative tests (§7, §10). + +--- + +## 4. Reference — Operator classes + +**Do not create an operator class on a domain type.** An opclass on a +public domain is a footgun and bloats the index — it stores the whole +`jsonb` payload rather than the compact index term. + +Instead, index through a **functional index on an extractor function**: + +- The extractor (`eq_term` / `ord_term`) returns an **internal + index-term type that already carries a default operator class**. The + exact return type is per-extractor — what matters is that it has a + default opclass for the access method you need: + - `eql_v2.ord_term(col)` returns `eql_v2.ore_block_u64_8_256`, which + carries `main`'s `DEFAULT FOR TYPE … USING btree` operator class. + `CREATE INDEX … USING btree (eql_v2.ord_term(col))` binds it + automatically — no opclass annotation. + - The `eq_term` overload on a scalar variant (e.g. + `eql_v2.eq_term(eql_v2__eq)`) returns `eql_v2.hmac_256` (a domain + over `text`); the `eq_term` overload on a `ste_vec` entry returns + `bytea`. Both `text` and `bytea` have default btree/hash opclasses, + so `USING hash` or `USING btree (eql_v2.eq_term(col))` engages + equality either way. Pick the return type to match an existing + opclass — do not invent one. +- A type implementer therefore creates **no operator class at all**. The + extractor is the bridge: pick a return type that already has the + opclass you need. + +**Build caveat:** the internal ORE composite operator class is excluded +from the **Supabase** build variant, so ORE-backed range/order +predicates have **no indexed range on Supabase** (seq-scan). If an +ordered variant keeps a non-ORE equality term and routes `=`/`<>` +through `eq_term`, that equality path can still use its equality +functional index; only the ORE range/order path loses its opclass. If +equality is routed through a lossless ORE `ord_term`, it is subject to +the same Supabase limitation. Note this in the upgrade doc. + +--- + +## 5. Reference — Inlinable function constraints + +The functional index only engages on a bare `WHERE col $1` if the +comparison wrapper **inlines** so the planner can fold +`col $1` into `extractor(col) extractor($1)` and match it +against the stored index expression. This splits every variant's +functions into two strictly-separated classes. + +### Inlinable: extractors and comparison wrappers + +Applies to `eq_term` / `ord_term` and every supported-operator wrapper. + +```sql +CREATE FUNCTION eql_v2.(…) +RETURNS +LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT … $$; +``` + +Hard requirements — all four are needed for PostgreSQL to inline: + +- `LANGUAGE sql` — PL/pgSQL is never inlined. +- A **single-statement** `SELECT` body. +- `IMMUTABLE` — also required for use as a functional-index expression. +- **No `SET` clause** (no pinned `search_path` / `proconfig`). A pinned + `search_path` disables SQL-function inlining. + +Also `STRICT` and `PARALLEL SAFE`. `STRICT` gives standard three-valued +logic: `col NULL` yields `NULL`. + +Wrapper bodies are one-liners over the extractor: + +```sql +-- (domain, domain) +AS $$ SELECT eql_v2.ord_term(a) < eql_v2.ord_term(b) $$; +-- (domain, jsonb) — cast the jsonb operand to the domain +AS $$ SELECT eql_v2.ord_term(a) < eql_v2.ord_term(b::eql_v2__ord) $$; +-- (jsonb, domain) +AS $$ SELECT eql_v2.ord_term(a::eql_v2__ord) < eql_v2.ord_term(b) $$; +``` + +The extractor itself reads its term from the payload, e.g. +`SELECT eql_v2.ore_block_u64_8_256(a::jsonb)`. + +### Blockers: unsupported operators + +Every operator a variant does **not** support gets a blocker that always +raises. + +```sql +CREATE FUNCTION eql_v2.__(a …, b …) +RETURNS boolean +IMMUTABLE PARALLEL SAFE -- NOTE: not STRICT +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2__', ''); END; $$ +LANGUAGE plpgsql; +``` + +- `LANGUAGE plpgsql`, `IMMUTABLE PARALLEL SAFE`. +- **Never `STRICT`.** A `STRICT` blocker lets PostgreSQL skip the body + and return `NULL` on a `NULL` argument, silently bypassing the + exception. The blocker contract is *always raises* — there is an + explicit regression test for this (§10). +- Boolean blockers delegate to the shared helper + `eql_v2.encrypted_domain_unsupported_bool(type_name, operator_name)` + (`src/encrypted_domain/functions.sql`) for a uniform message: + `operator is not supported for `. +- Path-operator blockers (`->`, `->>`) return non-boolean types, so they + cannot use the boolean helper — they `RAISE EXCEPTION` inline with the + same message text. + +The shared helper is itself `plpgsql`, `IMMUTABLE PARALLEL SAFE`, and +**does** carry `SET search_path = pg_catalog, extensions, public` — it +is never on an inline-critical path, so pinning is correct there. + +### Allowlist wiring (mandatory) + +Every **inlinable** function must be allowlisted, or the build/lint +tooling will break inlining or fail: + +- `tasks/pin_search_path.sql` — otherwise the function gets a pinned + `search_path`, which disables inlining. Add the extractor (matched by + `pronargs = 1 AND proname = ''`) and every wrapper name. +- `tasks/test/splinter.sh` — otherwise the linter flags + `function_search_path_mutable`. Add one row per inlinable function + with a short rationale. + +> **Caveat — overload coverage.** A name-only allowlist clause covers +> every overload of that name; an existing clause scoped by argument +> type (e.g. an `eq_term` clause matched by `proargtypes[0]`) does +> **not** cover a new overload on a different domain. If you reuse an +> extractor name (`eq_term`, `ord_term`) that another module already +> allowlists, confirm the existing clause actually matches your new +> overload — add a fresh `pronargs`/name clause if it does not. + +Blockers and the shared helper are **not** allowlisted — they carry a +pinned `search_path` like ordinary EQL functions. + +--- + +## 6. Reference — Operators + +### The operator surface + +Every variant declares the **full** 12-operator surface. Supported +operators route to an inlinable wrapper; all others route to a blocker. +Declaring the full surface is what prevents fall-through to native +`jsonb`. + +| Operators | Kind | Arg-shapes | +|-----------|------|-----------| +| `=` `<>` `<` `<=` `>` `>=` `~~` `~~*` `@>` `<@` | symmetric boolean (10) | `(domain,domain)`, `(domain,jsonb)`, `(jsonb,domain)` | +| `->` `->>` | path (2) | `(domain,text)`, `(domain,integer)`, `(jsonb,domain)` | + +The `(*,jsonb)` / `(jsonb,*)` boolean shapes cover ORM bind patterns +where one operand arrives as raw `jsonb`. The `(jsonb,domain)` path +shape for `->` / `->>` is defensive blocker coverage so native `jsonb` +path behavior cannot leak through; it is not a supported ORM bind +pattern. That is **12 operators × 3 shapes = 36 `CREATE OPERATOR` +statements per variant.** + +### Function counts per variant + +| Variant | Extractors | Wrappers | Blockers | Functions | Operators | +|---------|------------|----------|----------|-----------|-----------| +| storage `eql_v2_` | 0 | 0 | 36 | 36 | 36 | +| equality-only `eql_v2__eq` | 1 (`eq_term`) | 6 | 30 | 37 | 36 | +| lossless ordered `eql_v2__ord[_scheme]` | 1 (`ord_term`) | 18 | 18 | 37 | 36 | +| lossy ordered `eql_v2__ord[_scheme]` | 2 (`eq_term`, `ord_term`) | 18 | 18 | 38 | 36 | + +(Wrappers/blockers = supported/unsupported operators × 3 shapes; the +storage variant supports nothing.) Lossless ordered variants may route +equality wrappers through `ord_term`; lossy ordered variants route +equality wrappers through `eq_term` and range/order wrappers through +`ord_term`. Lossy ordered variants therefore usually need separate +equality and order index recipes. + +### `CREATE OPERATOR` metadata + +```sql +CREATE OPERATOR = ( + FUNCTION = eql_v2.__eq, + LEFTARG = eql_v2__, RIGHTARG = eql_v2__, + COMMUTATOR = =, NEGATOR = <>, RESTRICT = eqsel, JOIN = eqjoinsel +); +``` + +- Supported operators carry full metadata: `COMMUTATOR`, `NEGATOR`, and + selectivity estimators — `eqsel`/`neqsel` for `=`/`<>`, + `scalarltsel`/`scalarlesel`/`scalargtsel`/`scalargesel` for the range + operators (with matching `*joinsel`). `COMMUTATOR` lets the planner + normalise `$1 < col` to `col > $1`; `NEGATOR` drives `NOT (…)`. +- Blockers carry minimal metadata. The wrappers inline to the index-term + comparison *before* index matching, so this metadata is for + plan-quality completeness, not index engagement. + +### File split + +Per `CLAUDE.md`: implementation in `_functions.sql`, operator +declarations in `_operators.sql`. One pair per variant: +`__functions.sql` and `__operators.sql`. + +--- + +## 7. Reference — Test structure + +- **One SQLx suite per variant**: + `tests/sqlx/tests/encrypted_[_variant]_tests.rs`. +- **Storage variant — synthetic.** No migration fixture is needed; cast + literals (`$1::jsonb::eql_v2_`), a `TEMP TABLE` for the typed-column + case, and payload-`CHECK` probes are sufficient. +- **eq / ord variants — fixture-based** (§8). Tests cast + `payload::eql_v2__` per query. +- Each case is an `async fn` under `#[sqlx::test]`. Source-only checks + (e.g. the twin-sync guard) use a plain `#[test]`. +- **Twin-sync guard.** When two variants are mechanical twins (same + surface, type-name swap only — e.g. `_ord` / `_ord_ore`), add a + `#[test]` that reads both `.sql` files, normalises the two type names + to a common token, and asserts the executable bodies are + byte-identical. This pins the duplication cheaply without a + de-duplication refactor. + +### Test categories (each variant covers the applicable ones) + +| Category | Applies to | +|----------|-----------| +| Supported operators return correct rows, all 3 shapes | eq, ord | +| Range operators match numeric/native semantics | ord | +| `ORDER BY extractor(col)` preserves plaintext order | ord | +| Functional index **engages** (`EXPLAIN` names the index, with `SET LOCAL enable_seqscan = off`) | eq, ord | +| Functional index **correctness** (rows via index = ground truth) | eq, ord | +| Constant-on-left / commuted shape engages the index | eq, ord | +| Index preferred at scale (no `enable_seqscan` override) | eq, ord | +| Unsupported operators raise the variant-specific error, all shapes | all | +| Blockers raise on `NULL` input (guards against `STRICT` regressing in) | all | +| Supported wrappers yield `NULL` on a `NULL` operand | eq, ord | +| Inlinability catalogue assertion (see §10) | eq, ord | +| Operator planner-metadata assertion (`COMMUTATOR`/`NEGATOR` present) | eq, ord | +| Blockers engage on a real typed column, not just cast literals | storage (+ others) | +| Domain `CHECK` rejects non-object / under-populated payloads at the cast | all | +| Malformed required term shapes fail at the domain `CHECK` if structurally enforced, otherwise at extractor/query time | eq, ord | +| Twin-sync source check | twinned variants | + +--- + +## 8. Reference — Fixtures + +- **Generated, not hand-written.** Fixture generation uses three files + under `tasks/fixtures/`: + - `_generate_common.sh` — shared, **sourced** (not run) helper: + resolves the Postgres/Proxy connection, and exposes + `restart_proxy_and_wait` and `dump_fixture_table`. Reuse it as-is. + - `encrypted__schema.sql` — per-type schema: creates the + `bench_` source table with an `eql_v2_encrypted` column and + registers the index terms with `eql_v2.add_search_config(...)` so + Proxy emits the terms the variants need (e.g. `unique` → HMAC, + `ore` → ORE-block). Written to be idempotent. + - `generate_encrypted_.sh` — the per-type generator: applies the + schema, restarts Proxy, inserts plaintext rows, and dumps the + encrypted rows into the migration. +- The generator produces the migration + `tests/sqlx/migrations/0NN_install_encrypted__fixture.sql`, carrying + an `AUTO-GENERATED … DO NOT EDIT BY HAND` header. +- Encrypted payloads are produced via **CipherStash Proxy** (real HMAC + and ORE terms), not synthesised. +- **Table shape:** + + ```sql + CREATE TABLE encrypted__plaintext ( + id BIGINT PRIMARY KEY, + plaintext NOT NULL, + payload JSONB NOT NULL + ); + ``` + +- **One payload, all terms.** Each `payload` carries every term the + family design note says any variant uses, so a single fixture feeds + every variant's suite. For int4 this means `c`, `hm`, and `ob`: the + ordered suites read `ob`, the equality suite reads `hm`, from the same + rows. Do not hard-code those term names for a different type; derive + them from that type's variants and search configuration. +- **Value-set design rules:** + - Choose pivots so each range operator yields a **distinct + cardinality** — a swapped operator then fails an assertion instead + of silently passing. + - Include negative values and boundary values where the type allows. + - All values distinct where the native type and semantics allow, so a + distinctness sweep proves no two plaintexts share an equality term + and, for lossless ordered variants, no two plaintexts share an order + term. + - For lossy ordered variants, do not assert distinct order terms. + Instead, prove `=`/`<>` use the equality term and range/order + predicates use order semantics, with separate equality and order + index recipes when both can be indexed. + - Add negative-space fixture/tests for omitted non-required terms + (for example, ordered variants that intentionally do not carry an + equality term) and malformed required terms per variant. + - Add a native edge-semantics checklist to the type design note: + float/double `NaN`, infinities, and `-0.0`/`0.0`; `numeric` + precision and scale limits; date boundaries; timestamp timezone and + DST behavior; and bool's tiny value domain. Each item must be + explicitly included or excluded, and the encrypted semantics must be + documented. + - int4 uses 14 values; size similarly. + +--- + +## 9. Reference — Build wiring & documentation + +### Build + +- Every `.sql` file declares its dependencies with `-- REQUIRE:` lines; + the build resolves order with `tsort`. Required edges for a variant: + `src/schema.sql`, `src/encrypted_domain/types.sql`, the shared + `src/encrypted_domain/functions.sql` (for blockers), the variant's own + `_functions.sql` (from its `_operators.sql`), and **the module that + defines the extractor's return type** (e.g. + `src/ore_block_u64_8_256/functions.sql` and `…/operators.sql`, or + `src/hmac_256/functions.sql`). +- Build with `mise run clean && mise run build` — clean first; a bare + `mise run build` can report sources up-to-date and leave stale + `release/*.sql`. +- Confirm the **Supabase** and **Protect** build variants still build. + +### Documentation + +A new type is user-facing, so per `CLAUDE.md` release discipline: + +- A reference page and a walkthrough under `docs/reference/`. +- A `## [Unreleased]` entry in `CHANGELOG.md` (`Added`). +- A numbered upgrade note (`U-NNN`) in the active + `docs/upgrading/v.md` — variant set, the extractor interface, + index recipes, and the Supabase seq-scan caveat for ORE range/order + predicates. If ordered equality is routed through a non-ORE equality + term, document that equality indexing remains available separately. +- All SQL functions/types need Doxygen `--!` comments (`@brief`, + `@param`, `@return`, …) per `CLAUDE.md`. + +--- + +## 10. Reference — Coverage expectations + +The bar a new type's test suites must clear: + +- **Full operator surface.** Every declared operator × every arg-shape + is exercised — supported ops asserted for correctness, blocked ops + asserted to raise the exact `operator is not supported for + ` message. +- **Index engagement *and* correctness.** For every index-served + operator, assert both that `EXPLAIN` names the functional index (under + `SET LOCAL enable_seqscan = off`) **and** that the rows returned match + numeric/native ground truth. Cover the commuted (constant-on-left) + shape too. +- **NULL handled both ways.** Blockers must raise on `NULL` input + (catches a `STRICT` regression); supported wrappers must yield `NULL` + on a `NULL` operand (three-valued logic). +- **Inlinability asserted structurally.** Query `pg_catalog.pg_proc`: + every wrapper and a `LANGUAGE sql` extractor must have + `lanname = 'sql'`, `provolatile = 'i'`, and `proconfig IS NULL`. Do + not assume inlining — assert it. +- **Negative space.** Test the absence of capability: unsupported + operators raise; and where a term is dropped by design (e.g. ordered + variants without `hm`), strip that term from the payload and prove + the variant still routes correctly — so an accidental regression to + the wrong term fails instead of passing on a fully-populated fixture. + For lossy ordered variants, also prove equality predicates use the + equality term and range/order predicates use the order term. +- **Payload validation.** Assert the domain `CHECK` rejects payloads + outside its declared validation scope — at minimum a non-object and an + object missing the envelope (`v`, `i`), the ciphertext term, or the + variant's required capability term — with a `violates check + constraint` error at the cast. If a type chooses stronger structural + `CHECK` validation, malformed required term shapes must fail there + too. If it keeps the baseline presence/type-shape `CHECK`, malformed + required term shapes must fail in extractor/query-time tests instead. +- **Real columns, not just literals.** At least one test per variant + runs operators against a genuine `eql_v2__`-typed table + column, the shape a real caller writes. +- **Twin drift.** Twinned variants are pinned byte-identical by a + source-only test. + +--- + +## 11. Appendix — `jsonb` + +`jsonb` uses the same family model but its capabilities differ from a +scalar type, so the variant set and which operators are *supported* +(vs. blocked) change: + +| Domain | Supported operators | Index term / extractor | +|--------------------------|--------------------------------|------------------------| +| `eql_v2_jsonb` | none — storage only | `c` | +| `eql_v2_jsonb_eq` | `=`, `<>` | `hm` via `eq_term` | +| `eql_v2_jsonb_ste_vec` | `@>`, `<@`, `->`, `->>`, and path-scoped `=`/ordering | ste_vec terms | + +Key divergences from the scalar template — the §3–§10 mechanics +otherwise hold unchanged: + +- **The operator surface inverts.** For scalar types `@>`, `<@`, `->`, + `->>` are always blockers. For the `jsonb` containment/ste_vec variant + they are *supported* — `@>`/`<@` are real containment queries and + `->`/`->>` are real path navigation. +- **Path operators return a sub-domain, not a scalar.** `col -> 'sel'` + yields an encrypted value that is itself searchable; the chained + recipe is `WHERE col -> 'sel' = $1` and an `ORDER BY` over an + ordering extractor on the selected entry. The extractors take a + selector, mirroring the existing ste_vec entry extractors. +- **The index term is ste_vec-shaped**, reusing the `eql_v2_encrypted` + ste_vec machinery rather than a single scalar ORE/HMAC term. + +> **Design intent, not current API.** This appendix describes the +> *target* shape, not shipped code. As of writing, `src/ste_vec/` +> exposes `eql_v2.eq_term(eql_v2.ste_vec_entry)` returning `bytea` and +> `eql_v2.ore_cllw(...)` for ordering — there is **no** +> `ord_term(ste_vec_entry)` overload, and no `eql_v2_jsonb` domain +> family exists yet. The existing `public.eql_v2_encrypted` type +> already covers general encrypted `jsonb`; an `eql_v2_jsonb` domain +> family would be the capability-scoped, fall-through-safe presentation +> of the same underlying scheme. Settle the exact extractor surface and +> the relationship to `eql_v2_encrypted` in the `jsonb` type's design +> note before implementing. diff --git a/src/encrypted_domain/functions.sql b/src/encrypted_domain/functions.sql new file mode 100644 index 00000000..3689f14c --- /dev/null +++ b/src/encrypted_domain/functions.sql @@ -0,0 +1,27 @@ +-- REQUIRE: src/schema.sql +-- REQUIRE: src/encrypted_domain/types.sql + +--! @file encrypted_domain/functions.sql +--! @brief Shared blocker helper for the eql_v2_int4 variant family. +--! +--! Per-variant wrapper functions live in src/encrypted_domain/int4/. +--! Blockers in those files delegate to encrypted_domain_unsupported_bool +--! so every variant raises a uniform variant-specific error rather than +--! letting an unsupported operator fall through to native jsonb +--! behaviour. + +--! @brief Shared blocker helper. Raises 'operator X is not supported +--! for TYPE' so unsupported domain operators surface a clear +--! error rather than fall through to native jsonb behaviour. +--! @param type_name Domain type name (eql_v2_int4*) +--! @param operator_name Operator symbol (=, <, @>, ->, etc.) +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.encrypted_domain_unsupported_bool(type_name text, operator_name text) +RETURNS boolean +IMMUTABLE PARALLEL SAFE +SET search_path = pg_catalog, extensions, public +AS $$ +BEGIN + RAISE EXCEPTION 'operator % is not supported for %', operator_name, type_name; +END; +$$ LANGUAGE plpgsql; diff --git a/src/encrypted_domain/int4/int4_eq_functions.sql b/src/encrypted_domain/int4/int4_eq_functions.sql new file mode 100644 index 00000000..acea7f6c --- /dev/null +++ b/src/encrypted_domain/int4/int4_eq_functions.sql @@ -0,0 +1,305 @@ +-- REQUIRE: src/schema.sql +-- REQUIRE: src/encrypted_domain/types.sql +-- REQUIRE: src/encrypted_domain/functions.sql +-- REQUIRE: src/hmac_256/functions.sql + +--! @file encrypted_domain/int4/int4_eq_functions.sql +--! @brief Equality-only int4 variant — comparison/path functions. Supports = and <> via HMAC-256. +--! +--! eql_v2_int4_eq carries `c`, `hm` and supports HMAC equality. A +--! functional index on eql_v2.eq_term(col) — USING hash or USING btree — +--! engages for `=`. `<>` is supported but is a seq-scan (no index serves +--! inequality). All other operators raise. Payload-term assumption: +--! `c`, `hm`. + +-- index extractor + +--! @brief Index extractor for the eql_v2_int4_eq variant. +--! +--! Returns the HMAC-256 equality term carried in the `hm` field of the +--! jsonb payload. The returned eql_v2.hmac_256 is a domain over text, so +--! a functional index — USING hash (eql_v2.eq_term(col)) or +--! USING btree (eql_v2.eq_term(col)) — engages `=`. Inlinable +--! single-statement SQL: `col = $1` folds to +--! `eql_v2.eq_term(col) = eql_v2.eq_term($1)` and matches that index. +--! +--! @param a eql_v2_int4_eq Equality-variant encrypted int4 value +--! @return eql_v2.hmac_256 HMAC-256 equality index term +--! @see eql_v2.hmac_256 +--! @example +--! -- functional index for equality +--! CREATE INDEX t_col_idx ON t USING hash (eql_v2.eq_term(col)); +CREATE FUNCTION eql_v2.eq_term(a eql_v2_int4_eq) +RETURNS eql_v2.hmac_256 +LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.hmac_256(a::jsonb) $$; + +-- = / <> (HMAC equality wrappers, 3 shapes each) + +--! @brief Equality wrapper for eql_v2_int4_eq. Inlines to eq_term comparison. +--! @param a eql_v2_int4_eq +--! @param b eql_v2_int4_eq +--! @return boolean +CREATE FUNCTION eql_v2.eq(a eql_v2_int4_eq, b eql_v2_int4_eq) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.eq_term(a) = eql_v2.eq_term(b) $$; + +--! @brief Equality wrapper for eql_v2_int4_eq (domain, jsonb). +--! @param a eql_v2_int4_eq +--! @param b jsonb +--! @return boolean +CREATE FUNCTION eql_v2.eq(a eql_v2_int4_eq, b jsonb) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.eq_term(a) = eql_v2.eq_term(b::eql_v2_int4_eq) $$; + +--! @brief Equality wrapper for eql_v2_int4_eq (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_eq +--! @return boolean +CREATE FUNCTION eql_v2.eq(a jsonb, b eql_v2_int4_eq) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.eq_term(a::eql_v2_int4_eq) = eql_v2.eq_term(b) $$; + +--! @brief Inequality wrapper for eql_v2_int4_eq. Inlines to eq_term comparison. +--! @param a eql_v2_int4_eq +--! @param b eql_v2_int4_eq +--! @return boolean +CREATE FUNCTION eql_v2.neq(a eql_v2_int4_eq, b eql_v2_int4_eq) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.eq_term(a) <> eql_v2.eq_term(b) $$; + +--! @brief Inequality wrapper for eql_v2_int4_eq (domain, jsonb). +--! @param a eql_v2_int4_eq +--! @param b jsonb +--! @return boolean +CREATE FUNCTION eql_v2.neq(a eql_v2_int4_eq, b jsonb) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.eq_term(a) <> eql_v2.eq_term(b::eql_v2_int4_eq) $$; + +--! @brief Inequality wrapper for eql_v2_int4_eq (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_eq +--! @return boolean +CREATE FUNCTION eql_v2.neq(a jsonb, b eql_v2_int4_eq) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.eq_term(a::eql_v2_int4_eq) <> eql_v2.eq_term(b) $$; + +-- <, <=, >, >=, @>, <@ (blockers, 3 shapes each — 6 ops × 3 = 18 functions) + +--! @brief Blocker for < on eql_v2_int4_eq. +--! @param a eql_v2_int4_eq +--! @param b eql_v2_int4_eq +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.lt(a eql_v2_int4_eq, b eql_v2_int4_eq) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_eq', '<'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for < on eql_v2_int4_eq (domain, jsonb). +--! @param a eql_v2_int4_eq +--! @param b jsonb +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.lt(a eql_v2_int4_eq, b jsonb) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_eq', '<'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for < on eql_v2_int4_eq (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_eq +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.lt(a jsonb, b eql_v2_int4_eq) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_eq', '<'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for <= on eql_v2_int4_eq. +--! @param a eql_v2_int4_eq +--! @param b eql_v2_int4_eq +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.lte(a eql_v2_int4_eq, b eql_v2_int4_eq) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_eq', '<='); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for <= on eql_v2_int4_eq (domain, jsonb). +--! @param a eql_v2_int4_eq +--! @param b jsonb +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.lte(a eql_v2_int4_eq, b jsonb) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_eq', '<='); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for <= on eql_v2_int4_eq (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_eq +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.lte(a jsonb, b eql_v2_int4_eq) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_eq', '<='); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for > on eql_v2_int4_eq. +--! @param a eql_v2_int4_eq +--! @param b eql_v2_int4_eq +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.gt(a eql_v2_int4_eq, b eql_v2_int4_eq) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_eq', '>'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for > on eql_v2_int4_eq (domain, jsonb). +--! @param a eql_v2_int4_eq +--! @param b jsonb +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.gt(a eql_v2_int4_eq, b jsonb) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_eq', '>'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for > on eql_v2_int4_eq (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_eq +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.gt(a jsonb, b eql_v2_int4_eq) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_eq', '>'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for >= on eql_v2_int4_eq. +--! @param a eql_v2_int4_eq +--! @param b eql_v2_int4_eq +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.gte(a eql_v2_int4_eq, b eql_v2_int4_eq) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_eq', '>='); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for >= on eql_v2_int4_eq (domain, jsonb). +--! @param a eql_v2_int4_eq +--! @param b jsonb +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.gte(a eql_v2_int4_eq, b jsonb) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_eq', '>='); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for >= on eql_v2_int4_eq (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_eq +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.gte(a jsonb, b eql_v2_int4_eq) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_eq', '>='); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for @> on eql_v2_int4_eq. +--! @param a eql_v2_int4_eq +--! @param b eql_v2_int4_eq +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.contains(a eql_v2_int4_eq, b eql_v2_int4_eq) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_eq', '@>'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for @> on eql_v2_int4_eq (domain, jsonb). +--! @param a eql_v2_int4_eq +--! @param b jsonb +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.contains(a eql_v2_int4_eq, b jsonb) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_eq', '@>'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for @> on eql_v2_int4_eq (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_eq +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.contains(a jsonb, b eql_v2_int4_eq) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_eq', '@>'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for <@ on eql_v2_int4_eq. +--! @param a eql_v2_int4_eq +--! @param b eql_v2_int4_eq +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.contained_by(a eql_v2_int4_eq, b eql_v2_int4_eq) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_eq', '<@'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for <@ on eql_v2_int4_eq (domain, jsonb). +--! @param a eql_v2_int4_eq +--! @param b jsonb +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.contained_by(a eql_v2_int4_eq, b jsonb) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_eq', '<@'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for <@ on eql_v2_int4_eq (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_eq +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.contained_by(a jsonb, b eql_v2_int4_eq) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_eq', '<@'); END; $$ +LANGUAGE plpgsql; + +-- -> and ->> (blockers, 3 asymmetric shapes each) + +--! @brief Blocker for -> on eql_v2_int4_eq (domain, text). +--! @param a eql_v2_int4_eq +--! @param selector text +--! @return eql_v2_int4_eq (never returns; always raises) +CREATE FUNCTION eql_v2."->"(a eql_v2_int4_eq, selector text) +RETURNS eql_v2_int4_eq IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '->', 'eql_v2_int4_eq'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for -> on eql_v2_int4_eq (domain, integer). +--! @param a eql_v2_int4_eq +--! @param selector integer +--! @return eql_v2_int4_eq (never returns; always raises) +CREATE FUNCTION eql_v2."->"(a eql_v2_int4_eq, selector integer) +RETURNS eql_v2_int4_eq IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '->', 'eql_v2_int4_eq'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for -> on eql_v2_int4_eq (jsonb, domain). +--! @param a jsonb +--! @param selector eql_v2_int4_eq +--! @return eql_v2_int4_eq (never returns; always raises) +CREATE FUNCTION eql_v2."->"(a jsonb, selector eql_v2_int4_eq) +RETURNS eql_v2_int4_eq IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '->', 'eql_v2_int4_eq'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for ->> on eql_v2_int4_eq (domain, text). +--! @param a eql_v2_int4_eq +--! @param selector text +--! @return text (never returns; always raises) +CREATE FUNCTION eql_v2."->>"(a eql_v2_int4_eq, selector text) +RETURNS text IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '->>', 'eql_v2_int4_eq'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for ->> on eql_v2_int4_eq (domain, integer). +--! @param a eql_v2_int4_eq +--! @param selector integer +--! @return text (never returns; always raises) +CREATE FUNCTION eql_v2."->>"(a eql_v2_int4_eq, selector integer) +RETURNS text IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '->>', 'eql_v2_int4_eq'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for ->> on eql_v2_int4_eq (jsonb, domain). +--! @param a jsonb +--! @param selector eql_v2_int4_eq +--! @return text (never returns; always raises) +CREATE FUNCTION eql_v2."->>"(a jsonb, selector eql_v2_int4_eq) +RETURNS text IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '->>', 'eql_v2_int4_eq'; END; $$ +LANGUAGE plpgsql; diff --git a/src/encrypted_domain/int4/int4_eq_operators.sql b/src/encrypted_domain/int4/int4_eq_operators.sql new file mode 100644 index 00000000..90cd1f81 --- /dev/null +++ b/src/encrypted_domain/int4/int4_eq_operators.sql @@ -0,0 +1,118 @@ +-- REQUIRE: src/schema.sql +-- REQUIRE: src/encrypted_domain/types.sql +-- REQUIRE: src/encrypted_domain/int4/int4_eq_functions.sql + +--! @file encrypted_domain/int4/int4_eq_operators.sql +--! @brief Equality-only int4 variant — operator declarations. Supports = and <> via HMAC-256. +--! +--! eql_v2_int4_eq carries `c`, `hm` and supports HMAC equality. The +--! functional btree on ((eql_v2.hmac_256(col::jsonb))) engages for `=`. +--! `<>` is supported but is a seq-scan (btree supports only equality). +--! All other operators raise. Payload-term assumption: `c`, `hm`. + +-- Operator declarations. +-- +-- COMMUTATOR lets the planner normalise `$1 = col` to `col = $1`; +-- NEGATOR drives `NOT (...)` simplification. These wrappers inline to +-- the hmac-256 equality before index matching, so the metadata is for +-- plan-quality completeness, not index engagement. + +CREATE OPERATOR = ( + FUNCTION = eql_v2.eq, + LEFTARG = eql_v2_int4_eq, RIGHTARG = eql_v2_int4_eq, + COMMUTATOR = =, NEGATOR = <>, RESTRICT = eqsel, JOIN = eqjoinsel +); +CREATE OPERATOR = ( + FUNCTION = eql_v2.eq, + LEFTARG = eql_v2_int4_eq, RIGHTARG = jsonb, + COMMUTATOR = =, NEGATOR = <>, RESTRICT = eqsel, JOIN = eqjoinsel +); +CREATE OPERATOR = ( + FUNCTION = eql_v2.eq, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_eq, + COMMUTATOR = =, NEGATOR = <>, RESTRICT = eqsel, JOIN = eqjoinsel +); + +CREATE OPERATOR <> ( + FUNCTION = eql_v2.neq, + LEFTARG = eql_v2_int4_eq, RIGHTARG = eql_v2_int4_eq, + COMMUTATOR = <>, NEGATOR = =, RESTRICT = neqsel, JOIN = neqjoinsel +); +CREATE OPERATOR <> ( + FUNCTION = eql_v2.neq, + LEFTARG = eql_v2_int4_eq, RIGHTARG = jsonb, + COMMUTATOR = <>, NEGATOR = =, RESTRICT = neqsel, JOIN = neqjoinsel +); +CREATE OPERATOR <> ( + FUNCTION = eql_v2.neq, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_eq, + COMMUTATOR = <>, NEGATOR = =, RESTRICT = neqsel, JOIN = neqjoinsel +); + +CREATE OPERATOR < ( + FUNCTION = eql_v2.lt, + LEFTARG = eql_v2_int4_eq, RIGHTARG = eql_v2_int4_eq, + RESTRICT = scalarltsel, JOIN = scalarltjoinsel +); +CREATE OPERATOR < (FUNCTION = eql_v2.lt, + LEFTARG = eql_v2_int4_eq, RIGHTARG = jsonb); +CREATE OPERATOR < (FUNCTION = eql_v2.lt, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_eq); + +CREATE OPERATOR <= ( + FUNCTION = eql_v2.lte, + LEFTARG = eql_v2_int4_eq, RIGHTARG = eql_v2_int4_eq, + RESTRICT = scalarlesel, JOIN = scalarlejoinsel +); +CREATE OPERATOR <= (FUNCTION = eql_v2.lte, + LEFTARG = eql_v2_int4_eq, RIGHTARG = jsonb); +CREATE OPERATOR <= (FUNCTION = eql_v2.lte, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_eq); + +CREATE OPERATOR > ( + FUNCTION = eql_v2.gt, + LEFTARG = eql_v2_int4_eq, RIGHTARG = eql_v2_int4_eq, + RESTRICT = scalargtsel, JOIN = scalargtjoinsel +); +CREATE OPERATOR > (FUNCTION = eql_v2.gt, + LEFTARG = eql_v2_int4_eq, RIGHTARG = jsonb); +CREATE OPERATOR > (FUNCTION = eql_v2.gt, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_eq); + +CREATE OPERATOR >= ( + FUNCTION = eql_v2.gte, + LEFTARG = eql_v2_int4_eq, RIGHTARG = eql_v2_int4_eq, + RESTRICT = scalargesel, JOIN = scalargejoinsel +); +CREATE OPERATOR >= (FUNCTION = eql_v2.gte, + LEFTARG = eql_v2_int4_eq, RIGHTARG = jsonb); +CREATE OPERATOR >= (FUNCTION = eql_v2.gte, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_eq); + +CREATE OPERATOR @> (FUNCTION = eql_v2.contains, + LEFTARG = eql_v2_int4_eq, RIGHTARG = eql_v2_int4_eq); +CREATE OPERATOR @> (FUNCTION = eql_v2.contains, + LEFTARG = eql_v2_int4_eq, RIGHTARG = jsonb); +CREATE OPERATOR @> (FUNCTION = eql_v2.contains, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_eq); + +CREATE OPERATOR <@ (FUNCTION = eql_v2.contained_by, + LEFTARG = eql_v2_int4_eq, RIGHTARG = eql_v2_int4_eq); +CREATE OPERATOR <@ (FUNCTION = eql_v2.contained_by, + LEFTARG = eql_v2_int4_eq, RIGHTARG = jsonb); +CREATE OPERATOR <@ (FUNCTION = eql_v2.contained_by, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_eq); + +CREATE OPERATOR -> (FUNCTION = eql_v2."->", + LEFTARG = eql_v2_int4_eq, RIGHTARG = text); +CREATE OPERATOR -> (FUNCTION = eql_v2."->", + LEFTARG = eql_v2_int4_eq, RIGHTARG = integer); +CREATE OPERATOR -> (FUNCTION = eql_v2."->", + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_eq); + +CREATE OPERATOR ->> (FUNCTION = eql_v2."->>", + LEFTARG = eql_v2_int4_eq, RIGHTARG = text); +CREATE OPERATOR ->> (FUNCTION = eql_v2."->>", + LEFTARG = eql_v2_int4_eq, RIGHTARG = integer); +CREATE OPERATOR ->> (FUNCTION = eql_v2."->>", + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_eq); diff --git a/src/encrypted_domain/int4/int4_functions.sql b/src/encrypted_domain/int4/int4_functions.sql new file mode 100644 index 00000000..13074e11 --- /dev/null +++ b/src/encrypted_domain/int4/int4_functions.sql @@ -0,0 +1,289 @@ +-- REQUIRE: src/schema.sql +-- REQUIRE: src/encrypted_domain/types.sql +-- REQUIRE: src/encrypted_domain/functions.sql + +--! @file encrypted_domain/int4/int4_functions.sql +--! @brief Storage-only int4 variant — comparison/path functions. All bool operators raise. +--! +--! eql_v2_int4 accepts the storage of an encrypted int4 column with +--! ciphertext (`c`) only. Every comparison, containment, and path +--! operator is a blocker so callers cannot accidentally fall through to +--! native jsonb semantics. Payload-term assumption: `c` only. + +-- =, <> (blockers, 3 shapes each) + +--! @brief Blocker for = on eql_v2_int4. +--! @param a eql_v2_int4 +--! @param b eql_v2_int4 +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.eq(a eql_v2_int4, b eql_v2_int4) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '='); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for = on eql_v2_int4 (domain, jsonb). +--! @param a eql_v2_int4 +--! @param b jsonb +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.eq(a eql_v2_int4, b jsonb) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '='); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for = on eql_v2_int4 (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4 +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.eq(a jsonb, b eql_v2_int4) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '='); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for <> on eql_v2_int4. +--! @param a eql_v2_int4 +--! @param b eql_v2_int4 +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.neq(a eql_v2_int4, b eql_v2_int4) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '<>'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for <> on eql_v2_int4 (domain, jsonb). +--! @param a eql_v2_int4 +--! @param b jsonb +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.neq(a eql_v2_int4, b jsonb) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '<>'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for <> on eql_v2_int4 (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4 +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.neq(a jsonb, b eql_v2_int4) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '<>'); END; $$ +LANGUAGE plpgsql; + +-- <, <=, >, >= (blockers, 3 shapes each) + +--! @brief Blocker for < on eql_v2_int4. +--! @param a eql_v2_int4 +--! @param b eql_v2_int4 +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.lt(a eql_v2_int4, b eql_v2_int4) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '<'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for < on eql_v2_int4 (domain, jsonb). +--! @param a eql_v2_int4 +--! @param b jsonb +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.lt(a eql_v2_int4, b jsonb) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '<'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for < on eql_v2_int4 (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4 +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.lt(a jsonb, b eql_v2_int4) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '<'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for <= on eql_v2_int4. +--! @param a eql_v2_int4 +--! @param b eql_v2_int4 +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.lte(a eql_v2_int4, b eql_v2_int4) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '<='); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for <= on eql_v2_int4 (domain, jsonb). +--! @param a eql_v2_int4 +--! @param b jsonb +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.lte(a eql_v2_int4, b jsonb) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '<='); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for <= on eql_v2_int4 (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4 +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.lte(a jsonb, b eql_v2_int4) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '<='); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for > on eql_v2_int4. +--! @param a eql_v2_int4 +--! @param b eql_v2_int4 +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.gt(a eql_v2_int4, b eql_v2_int4) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '>'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for > on eql_v2_int4 (domain, jsonb). +--! @param a eql_v2_int4 +--! @param b jsonb +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.gt(a eql_v2_int4, b jsonb) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '>'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for > on eql_v2_int4 (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4 +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.gt(a jsonb, b eql_v2_int4) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '>'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for >= on eql_v2_int4. +--! @param a eql_v2_int4 +--! @param b eql_v2_int4 +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.gte(a eql_v2_int4, b eql_v2_int4) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '>='); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for >= on eql_v2_int4 (domain, jsonb). +--! @param a eql_v2_int4 +--! @param b jsonb +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.gte(a eql_v2_int4, b jsonb) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '>='); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for >= on eql_v2_int4 (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4 +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.gte(a jsonb, b eql_v2_int4) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '>='); END; $$ +LANGUAGE plpgsql; + +-- @>, <@ (blockers, 3 shapes each) + +--! @brief Blocker for @> on eql_v2_int4. +--! @param a eql_v2_int4 +--! @param b eql_v2_int4 +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.contains(a eql_v2_int4, b eql_v2_int4) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '@>'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for @> on eql_v2_int4 (domain, jsonb). +--! @param a eql_v2_int4 +--! @param b jsonb +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.contains(a eql_v2_int4, b jsonb) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '@>'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for @> on eql_v2_int4 (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4 +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.contains(a jsonb, b eql_v2_int4) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '@>'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for <@ on eql_v2_int4. +--! @param a eql_v2_int4 +--! @param b eql_v2_int4 +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.contained_by(a eql_v2_int4, b eql_v2_int4) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '<@'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for <@ on eql_v2_int4 (domain, jsonb). +--! @param a eql_v2_int4 +--! @param b jsonb +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.contained_by(a eql_v2_int4, b jsonb) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '<@'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for <@ on eql_v2_int4 (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4 +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.contained_by(a jsonb, b eql_v2_int4) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '<@'); END; $$ +LANGUAGE plpgsql; + +-- -> and ->> (blockers, 3 asymmetric shapes each) + +--! @brief Blocker for -> on eql_v2_int4 (domain, text). +--! @param a eql_v2_int4 +--! @param selector text +--! @return eql_v2_int4 (never returns; always raises) +CREATE FUNCTION eql_v2."->"(a eql_v2_int4, selector text) +RETURNS eql_v2_int4 IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '->', 'eql_v2_int4'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for -> on eql_v2_int4 (domain, integer). +--! @param a eql_v2_int4 +--! @param selector integer +--! @return eql_v2_int4 (never returns; always raises) +CREATE FUNCTION eql_v2."->"(a eql_v2_int4, selector integer) +RETURNS eql_v2_int4 IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '->', 'eql_v2_int4'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for -> on eql_v2_int4 (jsonb, domain). +--! @param a jsonb +--! @param selector eql_v2_int4 +--! @return eql_v2_int4 (never returns; always raises) +CREATE FUNCTION eql_v2."->"(a jsonb, selector eql_v2_int4) +RETURNS eql_v2_int4 IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '->', 'eql_v2_int4'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for ->> on eql_v2_int4 (domain, text). +--! @param a eql_v2_int4 +--! @param selector text +--! @return text (never returns; always raises) +CREATE FUNCTION eql_v2."->>"(a eql_v2_int4, selector text) +RETURNS text IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '->>', 'eql_v2_int4'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for ->> on eql_v2_int4 (domain, integer). +--! @param a eql_v2_int4 +--! @param selector integer +--! @return text (never returns; always raises) +CREATE FUNCTION eql_v2."->>"(a eql_v2_int4, selector integer) +RETURNS text IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '->>', 'eql_v2_int4'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for ->> on eql_v2_int4 (jsonb, domain). +--! @param a jsonb +--! @param selector eql_v2_int4 +--! @return text (never returns; always raises) +CREATE FUNCTION eql_v2."->>"(a jsonb, selector eql_v2_int4) +RETURNS text IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '->>', 'eql_v2_int4'; END; $$ +LANGUAGE plpgsql; diff --git a/src/encrypted_domain/int4/int4_operators.sql b/src/encrypted_domain/int4/int4_operators.sql new file mode 100644 index 00000000..9dd02571 --- /dev/null +++ b/src/encrypted_domain/int4/int4_operators.sql @@ -0,0 +1,113 @@ +-- REQUIRE: src/schema.sql +-- REQUIRE: src/encrypted_domain/types.sql +-- REQUIRE: src/encrypted_domain/int4/int4_functions.sql + +--! @file encrypted_domain/int4/int4_operators.sql +--! @brief Storage-only int4 variant — operator declarations. All bool operators raise. +--! +--! eql_v2_int4 accepts the storage of an encrypted int4 column with +--! ciphertext (`c`) only. Every comparison, containment, and path +--! operator is a blocker so callers cannot accidentally fall through to +--! native jsonb semantics. Payload-term assumption: `c` only. + +-- Operator declarations (8 symmetric ops × 3 shapes + 2 path ops × 3 asymmetric shapes) + +CREATE OPERATOR = ( + FUNCTION = eql_v2.eq, + LEFTARG = eql_v2_int4, RIGHTARG = eql_v2_int4, + NEGATOR = <>, RESTRICT = eqsel, JOIN = eqjoinsel +); +CREATE OPERATOR = ( + FUNCTION = eql_v2.eq, + LEFTARG = eql_v2_int4, RIGHTARG = jsonb, + NEGATOR = <>, RESTRICT = eqsel, JOIN = eqjoinsel +); +CREATE OPERATOR = ( + FUNCTION = eql_v2.eq, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4, + NEGATOR = <>, RESTRICT = eqsel, JOIN = eqjoinsel +); + +CREATE OPERATOR <> ( + FUNCTION = eql_v2.neq, + LEFTARG = eql_v2_int4, RIGHTARG = eql_v2_int4, + NEGATOR = =, RESTRICT = neqsel, JOIN = neqjoinsel +); +CREATE OPERATOR <> ( + FUNCTION = eql_v2.neq, + LEFTARG = eql_v2_int4, RIGHTARG = jsonb, + NEGATOR = =, RESTRICT = neqsel, JOIN = neqjoinsel +); +CREATE OPERATOR <> ( + FUNCTION = eql_v2.neq, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4, + NEGATOR = =, RESTRICT = neqsel, JOIN = neqjoinsel +); + +CREATE OPERATOR < ( + FUNCTION = eql_v2.lt, + LEFTARG = eql_v2_int4, RIGHTARG = eql_v2_int4, + RESTRICT = scalarltsel, JOIN = scalarltjoinsel +); +CREATE OPERATOR < (FUNCTION = eql_v2.lt, + LEFTARG = eql_v2_int4, RIGHTARG = jsonb); +CREATE OPERATOR < (FUNCTION = eql_v2.lt, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4); + +CREATE OPERATOR <= ( + FUNCTION = eql_v2.lte, + LEFTARG = eql_v2_int4, RIGHTARG = eql_v2_int4, + RESTRICT = scalarlesel, JOIN = scalarlejoinsel +); +CREATE OPERATOR <= (FUNCTION = eql_v2.lte, + LEFTARG = eql_v2_int4, RIGHTARG = jsonb); +CREATE OPERATOR <= (FUNCTION = eql_v2.lte, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4); + +CREATE OPERATOR > ( + FUNCTION = eql_v2.gt, + LEFTARG = eql_v2_int4, RIGHTARG = eql_v2_int4, + RESTRICT = scalargtsel, JOIN = scalargtjoinsel +); +CREATE OPERATOR > (FUNCTION = eql_v2.gt, + LEFTARG = eql_v2_int4, RIGHTARG = jsonb); +CREATE OPERATOR > (FUNCTION = eql_v2.gt, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4); + +CREATE OPERATOR >= ( + FUNCTION = eql_v2.gte, + LEFTARG = eql_v2_int4, RIGHTARG = eql_v2_int4, + RESTRICT = scalargesel, JOIN = scalargejoinsel +); +CREATE OPERATOR >= (FUNCTION = eql_v2.gte, + LEFTARG = eql_v2_int4, RIGHTARG = jsonb); +CREATE OPERATOR >= (FUNCTION = eql_v2.gte, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4); + +CREATE OPERATOR @> (FUNCTION = eql_v2.contains, + LEFTARG = eql_v2_int4, RIGHTARG = eql_v2_int4); +CREATE OPERATOR @> (FUNCTION = eql_v2.contains, + LEFTARG = eql_v2_int4, RIGHTARG = jsonb); +CREATE OPERATOR @> (FUNCTION = eql_v2.contains, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4); + +CREATE OPERATOR <@ (FUNCTION = eql_v2.contained_by, + LEFTARG = eql_v2_int4, RIGHTARG = eql_v2_int4); +CREATE OPERATOR <@ (FUNCTION = eql_v2.contained_by, + LEFTARG = eql_v2_int4, RIGHTARG = jsonb); +CREATE OPERATOR <@ (FUNCTION = eql_v2.contained_by, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4); + +CREATE OPERATOR -> (FUNCTION = eql_v2."->", + LEFTARG = eql_v2_int4, RIGHTARG = text); +CREATE OPERATOR -> (FUNCTION = eql_v2."->", + LEFTARG = eql_v2_int4, RIGHTARG = integer); +CREATE OPERATOR -> (FUNCTION = eql_v2."->", + LEFTARG = jsonb, RIGHTARG = eql_v2_int4); + +CREATE OPERATOR ->> (FUNCTION = eql_v2."->>", + LEFTARG = eql_v2_int4, RIGHTARG = text); +CREATE OPERATOR ->> (FUNCTION = eql_v2."->>", + LEFTARG = eql_v2_int4, RIGHTARG = integer); +CREATE OPERATOR ->> (FUNCTION = eql_v2."->>", + LEFTARG = jsonb, RIGHTARG = eql_v2_int4); diff --git a/src/encrypted_domain/int4/int4_ord_functions.sql b/src/encrypted_domain/int4/int4_ord_functions.sql new file mode 100644 index 00000000..ba413227 --- /dev/null +++ b/src/encrypted_domain/int4/int4_ord_functions.sql @@ -0,0 +1,321 @@ +-- REQUIRE: src/schema.sql +-- REQUIRE: src/encrypted_domain/types.sql +-- REQUIRE: src/encrypted_domain/functions.sql +-- REQUIRE: src/ore_block_u64_8_256/functions.sql +-- REQUIRE: src/ore_block_u64_8_256/operators.sql + +--! @file encrypted_domain/int4/int4_ord_functions.sql +--! @brief Concrete ordered int4 variant (D-E fallback) — comparison/path +--! functions. The recommended ordered name. +--! +--! eql_v2_int4_ord carries `c`, `ob`. It is a full concrete mirror of +--! int4_ord_ore.sql: the §8 verification spike showed the pure-alias +--! form (a domain over eql_v2_int4_ord_ore) does not transparently +--! inherit the operator surface — PostgreSQL resolves operators against +--! the ultimate base type (jsonb), so ordered operators fall through to +--! native jsonb comparison and the blockers do not engage. +--! eql_v2_int4_ord therefore carries its own eql_v2.ord_term() overload, +--! comparison wrappers, operator declarations, and blockers. +--! eql_v2_int4_ord_ore is the scheme-explicit ordered domain with the +--! identical operator surface. +--! +--! Equality and range both route through eql_v2.ord_term: +--! ord_term(a) ord_term(b) +--! is the corresponding operator on eql_v2.ore_block_u64_8_256. ORE on a +--! full-domain int4 is lossless, so the order term is also an exact +--! equality term — there is no separate `hm` term (D#1). +--! +--! All six comparison wrappers are LANGUAGE sql IMMUTABLE STRICT +--! PARALLEL SAFE with no SET clause, so the planner inlines them: +--! `col < $1` becomes `eql_v2.ord_term(col) < eql_v2.ord_term($1)`. The inner `<` +--! is the operator on eql_v2.ore_block_u64_8_256, a member of main's +--! DEFAULT btree operator class. A functional index +--! `USING btree (eql_v2.ord_term(col))` therefore serves all six operators. +--! +--! @note The ORE-block operator class is excluded from the Supabase +--! build variant, so ordered int4 columns have no indexed range on +--! Supabase (seq-scan). See docs/upgrading/v2.4.md U-001. + +--! @brief Index/ORDER BY extractor for the ordered int4 variants. +--! +--! Returns the ORE-block composite carried in the `ob` field of the +--! jsonb payload. The returned eql_v2.ore_block_u64_8_256 type carries +--! main's DEFAULT btree operator class, so a functional index +--! USING btree (eql_v2.ord_term(col)) binds that opclass automatically. +--! This is the single uniform extractor for index creation and ORDER BY +--! across the ordered variants. +--! +--! @param a eql_v2_int4_ord Ordered encrypted int4 value +--! @return eql_v2.ore_block_u64_8_256 ORE-block index term +--! @throws Exception if the `ob` field is missing from the payload +--! @see eql_v2.ore_block_u64_8_256 +--! @example +--! -- functional index for range + equality +--! CREATE INDEX t_col_idx ON t USING btree (eql_v2.ord_term(col)); +--! -- ordering +--! SELECT ... FROM t ORDER BY eql_v2.ord_term(col); +CREATE FUNCTION eql_v2.ord_term(a eql_v2_int4_ord) +RETURNS eql_v2.ore_block_u64_8_256 +LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ore_block_u64_8_256(a::jsonb) $$; + +-- = <> < <= > >= comparison wrappers, 3 arg-shapes each (18 functions). +-- All LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE, no SET clause, so they +-- inline: `col < $1` becomes `eql_v2.ord_term(col) < eql_v2.ord_term($1)`. + +--! @brief Less-than wrapper for eql_v2_int4_ord. Inlines to ORE-block compare. +--! @param a eql_v2_int4_ord +--! @param b eql_v2_int4_ord +--! @return boolean +CREATE FUNCTION eql_v2.lt(a eql_v2_int4_ord, b eql_v2_int4_ord) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a) < eql_v2.ord_term(b) $$; + +--! @brief Less-than wrapper for eql_v2_int4_ord (domain, jsonb). +--! @param a eql_v2_int4_ord +--! @param b jsonb +--! @return boolean +CREATE FUNCTION eql_v2.lt(a eql_v2_int4_ord, b jsonb) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a) < eql_v2.ord_term(b::eql_v2_int4_ord) $$; + +--! @brief Less-than wrapper for eql_v2_int4_ord (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_ord +--! @return boolean +CREATE FUNCTION eql_v2.lt(a jsonb, b eql_v2_int4_ord) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a::eql_v2_int4_ord) < eql_v2.ord_term(b) $$; + +--! @brief Less-than-or-equal wrapper for eql_v2_int4_ord. Inlines to ORE-block compare. +--! @param a eql_v2_int4_ord +--! @param b eql_v2_int4_ord +--! @return boolean +CREATE FUNCTION eql_v2.lte(a eql_v2_int4_ord, b eql_v2_int4_ord) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a) <= eql_v2.ord_term(b) $$; + +--! @brief Less-than-or-equal wrapper for eql_v2_int4_ord (domain, jsonb). +--! @param a eql_v2_int4_ord +--! @param b jsonb +--! @return boolean +CREATE FUNCTION eql_v2.lte(a eql_v2_int4_ord, b jsonb) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a) <= eql_v2.ord_term(b::eql_v2_int4_ord) $$; + +--! @brief Less-than-or-equal wrapper for eql_v2_int4_ord (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_ord +--! @return boolean +CREATE FUNCTION eql_v2.lte(a jsonb, b eql_v2_int4_ord) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a::eql_v2_int4_ord) <= eql_v2.ord_term(b) $$; + +--! @brief Greater-than wrapper for eql_v2_int4_ord. Inlines to ORE-block compare. +--! @param a eql_v2_int4_ord +--! @param b eql_v2_int4_ord +--! @return boolean +CREATE FUNCTION eql_v2.gt(a eql_v2_int4_ord, b eql_v2_int4_ord) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a) > eql_v2.ord_term(b) $$; + +--! @brief Greater-than wrapper for eql_v2_int4_ord (domain, jsonb). +--! @param a eql_v2_int4_ord +--! @param b jsonb +--! @return boolean +CREATE FUNCTION eql_v2.gt(a eql_v2_int4_ord, b jsonb) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a) > eql_v2.ord_term(b::eql_v2_int4_ord) $$; + +--! @brief Greater-than wrapper for eql_v2_int4_ord (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_ord +--! @return boolean +CREATE FUNCTION eql_v2.gt(a jsonb, b eql_v2_int4_ord) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a::eql_v2_int4_ord) > eql_v2.ord_term(b) $$; + +--! @brief Greater-than-or-equal wrapper for eql_v2_int4_ord. Inlines to ORE-block compare. +--! @param a eql_v2_int4_ord +--! @param b eql_v2_int4_ord +--! @return boolean +CREATE FUNCTION eql_v2.gte(a eql_v2_int4_ord, b eql_v2_int4_ord) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a) >= eql_v2.ord_term(b) $$; + +--! @brief Greater-than-or-equal wrapper for eql_v2_int4_ord (domain, jsonb). +--! @param a eql_v2_int4_ord +--! @param b jsonb +--! @return boolean +CREATE FUNCTION eql_v2.gte(a eql_v2_int4_ord, b jsonb) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a) >= eql_v2.ord_term(b::eql_v2_int4_ord) $$; + +--! @brief Greater-than-or-equal wrapper for eql_v2_int4_ord (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_ord +--! @return boolean +CREATE FUNCTION eql_v2.gte(a jsonb, b eql_v2_int4_ord) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a::eql_v2_int4_ord) >= eql_v2.ord_term(b) $$; + +--! @brief Equality wrapper for eql_v2_int4_ord. Routes through ord — ORE on +--! full-domain int4 is lossless, so this is exact equality. +--! @param a eql_v2_int4_ord +--! @param b eql_v2_int4_ord +--! @return boolean +CREATE FUNCTION eql_v2.eq(a eql_v2_int4_ord, b eql_v2_int4_ord) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a) = eql_v2.ord_term(b) $$; + +--! @brief Equality wrapper for eql_v2_int4_ord (domain, jsonb). +--! @param a eql_v2_int4_ord +--! @param b jsonb +--! @return boolean +CREATE FUNCTION eql_v2.eq(a eql_v2_int4_ord, b jsonb) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a) = eql_v2.ord_term(b::eql_v2_int4_ord) $$; + +--! @brief Equality wrapper for eql_v2_int4_ord (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_ord +--! @return boolean +CREATE FUNCTION eql_v2.eq(a jsonb, b eql_v2_int4_ord) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a::eql_v2_int4_ord) = eql_v2.ord_term(b) $$; + +--! @brief Inequality wrapper for eql_v2_int4_ord. Routes through ord. +--! @param a eql_v2_int4_ord +--! @param b eql_v2_int4_ord +--! @return boolean +CREATE FUNCTION eql_v2.neq(a eql_v2_int4_ord, b eql_v2_int4_ord) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a) <> eql_v2.ord_term(b) $$; + +--! @brief Inequality wrapper for eql_v2_int4_ord (domain, jsonb). +--! @param a eql_v2_int4_ord +--! @param b jsonb +--! @return boolean +CREATE FUNCTION eql_v2.neq(a eql_v2_int4_ord, b jsonb) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a) <> eql_v2.ord_term(b::eql_v2_int4_ord) $$; + +--! @brief Inequality wrapper for eql_v2_int4_ord (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_ord +--! @return boolean +CREATE FUNCTION eql_v2.neq(a jsonb, b eql_v2_int4_ord) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a::eql_v2_int4_ord) <> eql_v2.ord_term(b) $$; + +-- @>, <@ (blockers, 3 shapes each) + +--! @brief Blocker for @> on eql_v2_int4_ord. +--! @param a eql_v2_int4_ord +--! @param b eql_v2_int4_ord +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.contains(a eql_v2_int4_ord, b eql_v2_int4_ord) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_ord', '@>'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for @> on eql_v2_int4_ord (domain, jsonb). +--! @param a eql_v2_int4_ord +--! @param b jsonb +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.contains(a eql_v2_int4_ord, b jsonb) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_ord', '@>'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for @> on eql_v2_int4_ord (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_ord +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.contains(a jsonb, b eql_v2_int4_ord) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_ord', '@>'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for <@ on eql_v2_int4_ord. +--! @param a eql_v2_int4_ord +--! @param b eql_v2_int4_ord +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.contained_by(a eql_v2_int4_ord, b eql_v2_int4_ord) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_ord', '<@'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for <@ on eql_v2_int4_ord (domain, jsonb). +--! @param a eql_v2_int4_ord +--! @param b jsonb +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.contained_by(a eql_v2_int4_ord, b jsonb) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_ord', '<@'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for <@ on eql_v2_int4_ord (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_ord +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.contained_by(a jsonb, b eql_v2_int4_ord) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_ord', '<@'); END; $$ +LANGUAGE plpgsql; + +-- -> and ->> (blockers, 3 asymmetric shapes each) + +--! @brief Blocker for -> on eql_v2_int4_ord (domain, text). +--! @param a eql_v2_int4_ord +--! @param selector text +--! @return eql_v2_int4_ord (never returns; always raises) +CREATE FUNCTION eql_v2."->"(a eql_v2_int4_ord, selector text) +RETURNS eql_v2_int4_ord IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '->', 'eql_v2_int4_ord'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for -> on eql_v2_int4_ord (domain, integer). +--! @param a eql_v2_int4_ord +--! @param selector integer +--! @return eql_v2_int4_ord (never returns; always raises) +CREATE FUNCTION eql_v2."->"(a eql_v2_int4_ord, selector integer) +RETURNS eql_v2_int4_ord IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '->', 'eql_v2_int4_ord'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for -> on eql_v2_int4_ord (jsonb, domain). +--! @param a jsonb +--! @param selector eql_v2_int4_ord +--! @return eql_v2_int4_ord (never returns; always raises) +CREATE FUNCTION eql_v2."->"(a jsonb, selector eql_v2_int4_ord) +RETURNS eql_v2_int4_ord IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '->', 'eql_v2_int4_ord'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for ->> on eql_v2_int4_ord (domain, text). +--! @param a eql_v2_int4_ord +--! @param selector text +--! @return text (never returns; always raises) +CREATE FUNCTION eql_v2."->>"(a eql_v2_int4_ord, selector text) +RETURNS text IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '->>', 'eql_v2_int4_ord'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for ->> on eql_v2_int4_ord (domain, integer). +--! @param a eql_v2_int4_ord +--! @param selector integer +--! @return text (never returns; always raises) +CREATE FUNCTION eql_v2."->>"(a eql_v2_int4_ord, selector integer) +RETURNS text IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '->>', 'eql_v2_int4_ord'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for ->> on eql_v2_int4_ord (jsonb, domain). +--! @param a jsonb +--! @param selector eql_v2_int4_ord +--! @return text (never returns; always raises) +CREATE FUNCTION eql_v2."->>"(a jsonb, selector eql_v2_int4_ord) +RETURNS text IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '->>', 'eql_v2_int4_ord'; END; $$ +LANGUAGE plpgsql; diff --git a/src/encrypted_domain/int4/int4_ord_operators.sql b/src/encrypted_domain/int4/int4_ord_operators.sql new file mode 100644 index 00000000..dae43c53 --- /dev/null +++ b/src/encrypted_domain/int4/int4_ord_operators.sql @@ -0,0 +1,161 @@ +-- REQUIRE: src/schema.sql +-- REQUIRE: src/encrypted_domain/types.sql +-- REQUIRE: src/encrypted_domain/int4/int4_ord_functions.sql + +--! @file encrypted_domain/int4/int4_ord_operators.sql +--! @brief Concrete ordered int4 variant (D-E fallback) — operator +--! declarations. The recommended ordered name. +--! +--! eql_v2_int4_ord carries `c`, `ob`. It is a full concrete mirror of +--! int4_ord_ore.sql: the §8 verification spike showed the pure-alias +--! form (a domain over eql_v2_int4_ord_ore) does not transparently +--! inherit the operator surface — PostgreSQL resolves operators against +--! the ultimate base type (jsonb), so ordered operators fall through to +--! native jsonb comparison and the blockers do not engage. +--! eql_v2_int4_ord therefore carries its own eql_v2.ord_term() overload, +--! comparison wrappers, operator declarations, and blockers. +--! eql_v2_int4_ord_ore is the scheme-explicit ordered domain with the +--! identical operator surface. + +-- Operator declarations. +-- +-- COMMUTATOR lets the planner normalise `$1 < col` to `col > $1`; +-- NEGATOR drives `NOT (...)` simplification. These wrappers inline to +-- the ORE-block composite operators before index matching, so the +-- metadata is for plan-quality completeness, not index engagement. + +CREATE OPERATOR = ( + FUNCTION = eql_v2.eq, + LEFTARG = eql_v2_int4_ord, RIGHTARG = eql_v2_int4_ord, + COMMUTATOR = =, NEGATOR = <>, RESTRICT = eqsel, JOIN = eqjoinsel +); +CREATE OPERATOR = ( + FUNCTION = eql_v2.eq, + LEFTARG = eql_v2_int4_ord, RIGHTARG = jsonb, + COMMUTATOR = =, NEGATOR = <>, RESTRICT = eqsel, JOIN = eqjoinsel +); +CREATE OPERATOR = ( + FUNCTION = eql_v2.eq, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_ord, + COMMUTATOR = =, NEGATOR = <>, RESTRICT = eqsel, JOIN = eqjoinsel +); + +CREATE OPERATOR <> ( + FUNCTION = eql_v2.neq, + LEFTARG = eql_v2_int4_ord, RIGHTARG = eql_v2_int4_ord, + COMMUTATOR = <>, NEGATOR = =, RESTRICT = neqsel, JOIN = neqjoinsel +); +CREATE OPERATOR <> ( + FUNCTION = eql_v2.neq, + LEFTARG = eql_v2_int4_ord, RIGHTARG = jsonb, + COMMUTATOR = <>, NEGATOR = =, RESTRICT = neqsel, JOIN = neqjoinsel +); +CREATE OPERATOR <> ( + FUNCTION = eql_v2.neq, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_ord, + COMMUTATOR = <>, NEGATOR = =, RESTRICT = neqsel, JOIN = neqjoinsel +); + +CREATE OPERATOR < ( + FUNCTION = eql_v2.lt, + LEFTARG = eql_v2_int4_ord, RIGHTARG = eql_v2_int4_ord, + COMMUTATOR = >, NEGATOR = >=, + RESTRICT = scalarltsel, JOIN = scalarltjoinsel +); +CREATE OPERATOR < ( + FUNCTION = eql_v2.lt, + LEFTARG = eql_v2_int4_ord, RIGHTARG = jsonb, + COMMUTATOR = >, NEGATOR = >=, + RESTRICT = scalarltsel, JOIN = scalarltjoinsel +); +CREATE OPERATOR < ( + FUNCTION = eql_v2.lt, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_ord, + COMMUTATOR = >, NEGATOR = >=, + RESTRICT = scalarltsel, JOIN = scalarltjoinsel +); + +CREATE OPERATOR <= ( + FUNCTION = eql_v2.lte, + LEFTARG = eql_v2_int4_ord, RIGHTARG = eql_v2_int4_ord, + COMMUTATOR = >=, NEGATOR = >, + RESTRICT = scalarlesel, JOIN = scalarlejoinsel +); +CREATE OPERATOR <= ( + FUNCTION = eql_v2.lte, + LEFTARG = eql_v2_int4_ord, RIGHTARG = jsonb, + COMMUTATOR = >=, NEGATOR = >, + RESTRICT = scalarlesel, JOIN = scalarlejoinsel +); +CREATE OPERATOR <= ( + FUNCTION = eql_v2.lte, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_ord, + COMMUTATOR = >=, NEGATOR = >, + RESTRICT = scalarlesel, JOIN = scalarlejoinsel +); + +CREATE OPERATOR > ( + FUNCTION = eql_v2.gt, + LEFTARG = eql_v2_int4_ord, RIGHTARG = eql_v2_int4_ord, + COMMUTATOR = <, NEGATOR = <=, + RESTRICT = scalargtsel, JOIN = scalargtjoinsel +); +CREATE OPERATOR > ( + FUNCTION = eql_v2.gt, + LEFTARG = eql_v2_int4_ord, RIGHTARG = jsonb, + COMMUTATOR = <, NEGATOR = <=, + RESTRICT = scalargtsel, JOIN = scalargtjoinsel +); +CREATE OPERATOR > ( + FUNCTION = eql_v2.gt, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_ord, + COMMUTATOR = <, NEGATOR = <=, + RESTRICT = scalargtsel, JOIN = scalargtjoinsel +); + +CREATE OPERATOR >= ( + FUNCTION = eql_v2.gte, + LEFTARG = eql_v2_int4_ord, RIGHTARG = eql_v2_int4_ord, + COMMUTATOR = <=, NEGATOR = <, + RESTRICT = scalargesel, JOIN = scalargejoinsel +); +CREATE OPERATOR >= ( + FUNCTION = eql_v2.gte, + LEFTARG = eql_v2_int4_ord, RIGHTARG = jsonb, + COMMUTATOR = <=, NEGATOR = <, + RESTRICT = scalargesel, JOIN = scalargejoinsel +); +CREATE OPERATOR >= ( + FUNCTION = eql_v2.gte, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_ord, + COMMUTATOR = <=, NEGATOR = <, + RESTRICT = scalargesel, JOIN = scalargejoinsel +); + +CREATE OPERATOR @> (FUNCTION = eql_v2.contains, + LEFTARG = eql_v2_int4_ord, RIGHTARG = eql_v2_int4_ord); +CREATE OPERATOR @> (FUNCTION = eql_v2.contains, + LEFTARG = eql_v2_int4_ord, RIGHTARG = jsonb); +CREATE OPERATOR @> (FUNCTION = eql_v2.contains, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_ord); + +CREATE OPERATOR <@ (FUNCTION = eql_v2.contained_by, + LEFTARG = eql_v2_int4_ord, RIGHTARG = eql_v2_int4_ord); +CREATE OPERATOR <@ (FUNCTION = eql_v2.contained_by, + LEFTARG = eql_v2_int4_ord, RIGHTARG = jsonb); +CREATE OPERATOR <@ (FUNCTION = eql_v2.contained_by, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_ord); + +CREATE OPERATOR -> (FUNCTION = eql_v2."->", + LEFTARG = eql_v2_int4_ord, RIGHTARG = text); +CREATE OPERATOR -> (FUNCTION = eql_v2."->", + LEFTARG = eql_v2_int4_ord, RIGHTARG = integer); +CREATE OPERATOR -> (FUNCTION = eql_v2."->", + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_ord); + +CREATE OPERATOR ->> (FUNCTION = eql_v2."->>", + LEFTARG = eql_v2_int4_ord, RIGHTARG = text); +CREATE OPERATOR ->> (FUNCTION = eql_v2."->>", + LEFTARG = eql_v2_int4_ord, RIGHTARG = integer); +CREATE OPERATOR ->> (FUNCTION = eql_v2."->>", + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_ord); diff --git a/src/encrypted_domain/int4/int4_ord_ore_functions.sql b/src/encrypted_domain/int4/int4_ord_ore_functions.sql new file mode 100644 index 00000000..91e1f1d7 --- /dev/null +++ b/src/encrypted_domain/int4/int4_ord_ore_functions.sql @@ -0,0 +1,318 @@ +-- REQUIRE: src/schema.sql +-- REQUIRE: src/encrypted_domain/types.sql +-- REQUIRE: src/encrypted_domain/functions.sql +-- REQUIRE: src/ore_block_u64_8_256/functions.sql +-- REQUIRE: src/ore_block_u64_8_256/operators.sql + +--! @file encrypted_domain/int4/int4_ord_ore_functions.sql +--! @brief Concrete ordered int4 variant — comparison/path functions +--! (equality + ORE-block ordering). +--! +--! eql_v2_int4_ord_ore carries `c`, `ob`. It is the scheme-explicit +--! ordered domain: it carries the eql_v2.ord_term() extractor, the six +--! comparison wrappers, the operator declarations, and the blockers. +--! eql_v2_int4_ord — the recommended ordered name — is a separate +--! concrete domain (int4_ord.sql) carrying its own copy of this +--! operator surface; the §8 spike showed a domain-over-domain alias +--! does not transparently inherit the operator surface (D-E fallback). +--! +--! Equality and range both route through eql_v2.ord_term: +--! ord_term(a) ord_term(b) +--! is the corresponding operator on eql_v2.ore_block_u64_8_256. ORE on a +--! full-domain int4 is lossless, so the order term is also an exact +--! equality term — there is no separate `hm` term (D#1). +--! +--! All six comparison wrappers are LANGUAGE sql IMMUTABLE STRICT +--! PARALLEL SAFE with no SET clause, so the planner inlines them: +--! `col < $1` becomes `eql_v2.ord_term(col) < eql_v2.ord_term($1)`. The inner `<` +--! is the operator on eql_v2.ore_block_u64_8_256, a member of main's +--! DEFAULT btree operator class. A functional index +--! `USING btree (eql_v2.ord_term(col))` therefore serves all six operators. +--! +--! @note The ORE-block operator class is excluded from the Supabase +--! build variant, so ordered int4 columns have no indexed range on +--! Supabase (seq-scan). See docs/upgrading/v2.4.md U-001. + +--! @brief Index/ORDER BY extractor for the ordered int4 variants. +--! +--! Returns the ORE-block composite carried in the `ob` field of the +--! jsonb payload. The returned eql_v2.ore_block_u64_8_256 type carries +--! main's DEFAULT btree operator class, so a functional index +--! USING btree (eql_v2.ord_term(col)) binds that opclass automatically. +--! This is the single uniform extractor for index creation and ORDER BY +--! across the ordered variants. +--! +--! @param a eql_v2_int4_ord_ore Ordered encrypted int4 value +--! @return eql_v2.ore_block_u64_8_256 ORE-block index term +--! @throws Exception if the `ob` field is missing from the payload +--! @see eql_v2.ore_block_u64_8_256 +--! @example +--! -- functional index for range + equality +--! CREATE INDEX t_col_idx ON t USING btree (eql_v2.ord_term(col)); +--! -- ordering +--! SELECT ... FROM t ORDER BY eql_v2.ord_term(col); +CREATE FUNCTION eql_v2.ord_term(a eql_v2_int4_ord_ore) +RETURNS eql_v2.ore_block_u64_8_256 +LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ore_block_u64_8_256(a::jsonb) $$; + +-- = <> < <= > >= comparison wrappers, 3 arg-shapes each (18 functions). +-- All LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE, no SET clause, so they +-- inline: `col < $1` becomes `eql_v2.ord_term(col) < eql_v2.ord_term($1)`. + +--! @brief Less-than wrapper for eql_v2_int4_ord_ore. Inlines to ORE-block compare. +--! @param a eql_v2_int4_ord_ore +--! @param b eql_v2_int4_ord_ore +--! @return boolean +CREATE FUNCTION eql_v2.lt(a eql_v2_int4_ord_ore, b eql_v2_int4_ord_ore) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a) < eql_v2.ord_term(b) $$; + +--! @brief Less-than wrapper for eql_v2_int4_ord_ore (domain, jsonb). +--! @param a eql_v2_int4_ord_ore +--! @param b jsonb +--! @return boolean +CREATE FUNCTION eql_v2.lt(a eql_v2_int4_ord_ore, b jsonb) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a) < eql_v2.ord_term(b::eql_v2_int4_ord_ore) $$; + +--! @brief Less-than wrapper for eql_v2_int4_ord_ore (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_ord_ore +--! @return boolean +CREATE FUNCTION eql_v2.lt(a jsonb, b eql_v2_int4_ord_ore) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a::eql_v2_int4_ord_ore) < eql_v2.ord_term(b) $$; + +--! @brief Less-than-or-equal wrapper for eql_v2_int4_ord_ore. Inlines to ORE-block compare. +--! @param a eql_v2_int4_ord_ore +--! @param b eql_v2_int4_ord_ore +--! @return boolean +CREATE FUNCTION eql_v2.lte(a eql_v2_int4_ord_ore, b eql_v2_int4_ord_ore) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a) <= eql_v2.ord_term(b) $$; + +--! @brief Less-than-or-equal wrapper for eql_v2_int4_ord_ore (domain, jsonb). +--! @param a eql_v2_int4_ord_ore +--! @param b jsonb +--! @return boolean +CREATE FUNCTION eql_v2.lte(a eql_v2_int4_ord_ore, b jsonb) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a) <= eql_v2.ord_term(b::eql_v2_int4_ord_ore) $$; + +--! @brief Less-than-or-equal wrapper for eql_v2_int4_ord_ore (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_ord_ore +--! @return boolean +CREATE FUNCTION eql_v2.lte(a jsonb, b eql_v2_int4_ord_ore) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a::eql_v2_int4_ord_ore) <= eql_v2.ord_term(b) $$; + +--! @brief Greater-than wrapper for eql_v2_int4_ord_ore. Inlines to ORE-block compare. +--! @param a eql_v2_int4_ord_ore +--! @param b eql_v2_int4_ord_ore +--! @return boolean +CREATE FUNCTION eql_v2.gt(a eql_v2_int4_ord_ore, b eql_v2_int4_ord_ore) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a) > eql_v2.ord_term(b) $$; + +--! @brief Greater-than wrapper for eql_v2_int4_ord_ore (domain, jsonb). +--! @param a eql_v2_int4_ord_ore +--! @param b jsonb +--! @return boolean +CREATE FUNCTION eql_v2.gt(a eql_v2_int4_ord_ore, b jsonb) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a) > eql_v2.ord_term(b::eql_v2_int4_ord_ore) $$; + +--! @brief Greater-than wrapper for eql_v2_int4_ord_ore (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_ord_ore +--! @return boolean +CREATE FUNCTION eql_v2.gt(a jsonb, b eql_v2_int4_ord_ore) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a::eql_v2_int4_ord_ore) > eql_v2.ord_term(b) $$; + +--! @brief Greater-than-or-equal wrapper for eql_v2_int4_ord_ore. Inlines to ORE-block compare. +--! @param a eql_v2_int4_ord_ore +--! @param b eql_v2_int4_ord_ore +--! @return boolean +CREATE FUNCTION eql_v2.gte(a eql_v2_int4_ord_ore, b eql_v2_int4_ord_ore) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a) >= eql_v2.ord_term(b) $$; + +--! @brief Greater-than-or-equal wrapper for eql_v2_int4_ord_ore (domain, jsonb). +--! @param a eql_v2_int4_ord_ore +--! @param b jsonb +--! @return boolean +CREATE FUNCTION eql_v2.gte(a eql_v2_int4_ord_ore, b jsonb) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a) >= eql_v2.ord_term(b::eql_v2_int4_ord_ore) $$; + +--! @brief Greater-than-or-equal wrapper for eql_v2_int4_ord_ore (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_ord_ore +--! @return boolean +CREATE FUNCTION eql_v2.gte(a jsonb, b eql_v2_int4_ord_ore) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a::eql_v2_int4_ord_ore) >= eql_v2.ord_term(b) $$; + +--! @brief Equality wrapper for eql_v2_int4_ord_ore. Routes through ord — ORE on +--! full-domain int4 is lossless, so this is exact equality. +--! @param a eql_v2_int4_ord_ore +--! @param b eql_v2_int4_ord_ore +--! @return boolean +CREATE FUNCTION eql_v2.eq(a eql_v2_int4_ord_ore, b eql_v2_int4_ord_ore) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a) = eql_v2.ord_term(b) $$; + +--! @brief Equality wrapper for eql_v2_int4_ord_ore (domain, jsonb). +--! @param a eql_v2_int4_ord_ore +--! @param b jsonb +--! @return boolean +CREATE FUNCTION eql_v2.eq(a eql_v2_int4_ord_ore, b jsonb) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a) = eql_v2.ord_term(b::eql_v2_int4_ord_ore) $$; + +--! @brief Equality wrapper for eql_v2_int4_ord_ore (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_ord_ore +--! @return boolean +CREATE FUNCTION eql_v2.eq(a jsonb, b eql_v2_int4_ord_ore) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a::eql_v2_int4_ord_ore) = eql_v2.ord_term(b) $$; + +--! @brief Inequality wrapper for eql_v2_int4_ord_ore. Routes through ord. +--! @param a eql_v2_int4_ord_ore +--! @param b eql_v2_int4_ord_ore +--! @return boolean +CREATE FUNCTION eql_v2.neq(a eql_v2_int4_ord_ore, b eql_v2_int4_ord_ore) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a) <> eql_v2.ord_term(b) $$; + +--! @brief Inequality wrapper for eql_v2_int4_ord_ore (domain, jsonb). +--! @param a eql_v2_int4_ord_ore +--! @param b jsonb +--! @return boolean +CREATE FUNCTION eql_v2.neq(a eql_v2_int4_ord_ore, b jsonb) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a) <> eql_v2.ord_term(b::eql_v2_int4_ord_ore) $$; + +--! @brief Inequality wrapper for eql_v2_int4_ord_ore (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_ord_ore +--! @return boolean +CREATE FUNCTION eql_v2.neq(a jsonb, b eql_v2_int4_ord_ore) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a::eql_v2_int4_ord_ore) <> eql_v2.ord_term(b) $$; + +-- @>, <@ (blockers, 3 shapes each) + +--! @brief Blocker for @> on eql_v2_int4_ord_ore. +--! @param a eql_v2_int4_ord_ore +--! @param b eql_v2_int4_ord_ore +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.contains(a eql_v2_int4_ord_ore, b eql_v2_int4_ord_ore) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_ord_ore', '@>'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for @> on eql_v2_int4_ord_ore (domain, jsonb). +--! @param a eql_v2_int4_ord_ore +--! @param b jsonb +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.contains(a eql_v2_int4_ord_ore, b jsonb) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_ord_ore', '@>'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for @> on eql_v2_int4_ord_ore (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_ord_ore +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.contains(a jsonb, b eql_v2_int4_ord_ore) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_ord_ore', '@>'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for <@ on eql_v2_int4_ord_ore. +--! @param a eql_v2_int4_ord_ore +--! @param b eql_v2_int4_ord_ore +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.contained_by(a eql_v2_int4_ord_ore, b eql_v2_int4_ord_ore) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_ord_ore', '<@'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for <@ on eql_v2_int4_ord_ore (domain, jsonb). +--! @param a eql_v2_int4_ord_ore +--! @param b jsonb +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.contained_by(a eql_v2_int4_ord_ore, b jsonb) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_ord_ore', '<@'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for <@ on eql_v2_int4_ord_ore (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_ord_ore +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.contained_by(a jsonb, b eql_v2_int4_ord_ore) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_ord_ore', '<@'); END; $$ +LANGUAGE plpgsql; + +-- -> and ->> (blockers, 3 asymmetric shapes each) + +--! @brief Blocker for -> on eql_v2_int4_ord_ore (domain, text). +--! @param a eql_v2_int4_ord_ore +--! @param selector text +--! @return eql_v2_int4_ord_ore (never returns; always raises) +CREATE FUNCTION eql_v2."->"(a eql_v2_int4_ord_ore, selector text) +RETURNS eql_v2_int4_ord_ore IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '->', 'eql_v2_int4_ord_ore'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for -> on eql_v2_int4_ord_ore (domain, integer). +--! @param a eql_v2_int4_ord_ore +--! @param selector integer +--! @return eql_v2_int4_ord_ore (never returns; always raises) +CREATE FUNCTION eql_v2."->"(a eql_v2_int4_ord_ore, selector integer) +RETURNS eql_v2_int4_ord_ore IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '->', 'eql_v2_int4_ord_ore'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for -> on eql_v2_int4_ord_ore (jsonb, domain). +--! @param a jsonb +--! @param selector eql_v2_int4_ord_ore +--! @return eql_v2_int4_ord_ore (never returns; always raises) +CREATE FUNCTION eql_v2."->"(a jsonb, selector eql_v2_int4_ord_ore) +RETURNS eql_v2_int4_ord_ore IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '->', 'eql_v2_int4_ord_ore'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for ->> on eql_v2_int4_ord_ore (domain, text). +--! @param a eql_v2_int4_ord_ore +--! @param selector text +--! @return text (never returns; always raises) +CREATE FUNCTION eql_v2."->>"(a eql_v2_int4_ord_ore, selector text) +RETURNS text IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '->>', 'eql_v2_int4_ord_ore'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for ->> on eql_v2_int4_ord_ore (domain, integer). +--! @param a eql_v2_int4_ord_ore +--! @param selector integer +--! @return text (never returns; always raises) +CREATE FUNCTION eql_v2."->>"(a eql_v2_int4_ord_ore, selector integer) +RETURNS text IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '->>', 'eql_v2_int4_ord_ore'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for ->> on eql_v2_int4_ord_ore (jsonb, domain). +--! @param a jsonb +--! @param selector eql_v2_int4_ord_ore +--! @return text (never returns; always raises) +CREATE FUNCTION eql_v2."->>"(a jsonb, selector eql_v2_int4_ord_ore) +RETURNS text IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '->>', 'eql_v2_int4_ord_ore'; END; $$ +LANGUAGE plpgsql; diff --git a/src/encrypted_domain/int4/int4_ord_ore_operators.sql b/src/encrypted_domain/int4/int4_ord_ore_operators.sql new file mode 100644 index 00000000..e5cea3f3 --- /dev/null +++ b/src/encrypted_domain/int4/int4_ord_ore_operators.sql @@ -0,0 +1,158 @@ +-- REQUIRE: src/schema.sql +-- REQUIRE: src/encrypted_domain/types.sql +-- REQUIRE: src/encrypted_domain/int4/int4_ord_ore_functions.sql + +--! @file encrypted_domain/int4/int4_ord_ore_operators.sql +--! @brief Concrete ordered int4 variant — operator declarations +--! (equality + ORE-block ordering). +--! +--! eql_v2_int4_ord_ore carries `c`, `ob`. It is the scheme-explicit +--! ordered domain: it carries the eql_v2.ord_term() extractor, the six +--! comparison wrappers, the operator declarations, and the blockers. +--! eql_v2_int4_ord — the recommended ordered name — is a separate +--! concrete domain (int4_ord.sql) carrying its own copy of this +--! operator surface; the §8 spike showed a domain-over-domain alias +--! does not transparently inherit the operator surface (D-E fallback). + +-- Operator declarations. +-- +-- COMMUTATOR lets the planner normalise `$1 < col` to `col > $1`; +-- NEGATOR drives `NOT (...)` simplification. These wrappers inline to +-- the ORE-block composite operators before index matching, so the +-- metadata is for plan-quality completeness, not index engagement. + +CREATE OPERATOR = ( + FUNCTION = eql_v2.eq, + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = eql_v2_int4_ord_ore, + COMMUTATOR = =, NEGATOR = <>, RESTRICT = eqsel, JOIN = eqjoinsel +); +CREATE OPERATOR = ( + FUNCTION = eql_v2.eq, + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = jsonb, + COMMUTATOR = =, NEGATOR = <>, RESTRICT = eqsel, JOIN = eqjoinsel +); +CREATE OPERATOR = ( + FUNCTION = eql_v2.eq, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_ord_ore, + COMMUTATOR = =, NEGATOR = <>, RESTRICT = eqsel, JOIN = eqjoinsel +); + +CREATE OPERATOR <> ( + FUNCTION = eql_v2.neq, + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = eql_v2_int4_ord_ore, + COMMUTATOR = <>, NEGATOR = =, RESTRICT = neqsel, JOIN = neqjoinsel +); +CREATE OPERATOR <> ( + FUNCTION = eql_v2.neq, + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = jsonb, + COMMUTATOR = <>, NEGATOR = =, RESTRICT = neqsel, JOIN = neqjoinsel +); +CREATE OPERATOR <> ( + FUNCTION = eql_v2.neq, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_ord_ore, + COMMUTATOR = <>, NEGATOR = =, RESTRICT = neqsel, JOIN = neqjoinsel +); + +CREATE OPERATOR < ( + FUNCTION = eql_v2.lt, + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = eql_v2_int4_ord_ore, + COMMUTATOR = >, NEGATOR = >=, + RESTRICT = scalarltsel, JOIN = scalarltjoinsel +); +CREATE OPERATOR < ( + FUNCTION = eql_v2.lt, + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = jsonb, + COMMUTATOR = >, NEGATOR = >=, + RESTRICT = scalarltsel, JOIN = scalarltjoinsel +); +CREATE OPERATOR < ( + FUNCTION = eql_v2.lt, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_ord_ore, + COMMUTATOR = >, NEGATOR = >=, + RESTRICT = scalarltsel, JOIN = scalarltjoinsel +); + +CREATE OPERATOR <= ( + FUNCTION = eql_v2.lte, + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = eql_v2_int4_ord_ore, + COMMUTATOR = >=, NEGATOR = >, + RESTRICT = scalarlesel, JOIN = scalarlejoinsel +); +CREATE OPERATOR <= ( + FUNCTION = eql_v2.lte, + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = jsonb, + COMMUTATOR = >=, NEGATOR = >, + RESTRICT = scalarlesel, JOIN = scalarlejoinsel +); +CREATE OPERATOR <= ( + FUNCTION = eql_v2.lte, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_ord_ore, + COMMUTATOR = >=, NEGATOR = >, + RESTRICT = scalarlesel, JOIN = scalarlejoinsel +); + +CREATE OPERATOR > ( + FUNCTION = eql_v2.gt, + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = eql_v2_int4_ord_ore, + COMMUTATOR = <, NEGATOR = <=, + RESTRICT = scalargtsel, JOIN = scalargtjoinsel +); +CREATE OPERATOR > ( + FUNCTION = eql_v2.gt, + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = jsonb, + COMMUTATOR = <, NEGATOR = <=, + RESTRICT = scalargtsel, JOIN = scalargtjoinsel +); +CREATE OPERATOR > ( + FUNCTION = eql_v2.gt, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_ord_ore, + COMMUTATOR = <, NEGATOR = <=, + RESTRICT = scalargtsel, JOIN = scalargtjoinsel +); + +CREATE OPERATOR >= ( + FUNCTION = eql_v2.gte, + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = eql_v2_int4_ord_ore, + COMMUTATOR = <=, NEGATOR = <, + RESTRICT = scalargesel, JOIN = scalargejoinsel +); +CREATE OPERATOR >= ( + FUNCTION = eql_v2.gte, + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = jsonb, + COMMUTATOR = <=, NEGATOR = <, + RESTRICT = scalargesel, JOIN = scalargejoinsel +); +CREATE OPERATOR >= ( + FUNCTION = eql_v2.gte, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_ord_ore, + COMMUTATOR = <=, NEGATOR = <, + RESTRICT = scalargesel, JOIN = scalargejoinsel +); + +CREATE OPERATOR @> (FUNCTION = eql_v2.contains, + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = eql_v2_int4_ord_ore); +CREATE OPERATOR @> (FUNCTION = eql_v2.contains, + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = jsonb); +CREATE OPERATOR @> (FUNCTION = eql_v2.contains, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_ord_ore); + +CREATE OPERATOR <@ (FUNCTION = eql_v2.contained_by, + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = eql_v2_int4_ord_ore); +CREATE OPERATOR <@ (FUNCTION = eql_v2.contained_by, + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = jsonb); +CREATE OPERATOR <@ (FUNCTION = eql_v2.contained_by, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_ord_ore); + +CREATE OPERATOR -> (FUNCTION = eql_v2."->", + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = text); +CREATE OPERATOR -> (FUNCTION = eql_v2."->", + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = integer); +CREATE OPERATOR -> (FUNCTION = eql_v2."->", + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_ord_ore); + +CREATE OPERATOR ->> (FUNCTION = eql_v2."->>", + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = text); +CREATE OPERATOR ->> (FUNCTION = eql_v2."->>", + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = integer); +CREATE OPERATOR ->> (FUNCTION = eql_v2."->>", + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_ord_ore); diff --git a/src/encrypted_domain/types.sql b/src/encrypted_domain/types.sql new file mode 100644 index 00000000..ebfb9aba --- /dev/null +++ b/src/encrypted_domain/types.sql @@ -0,0 +1,101 @@ +-- REQUIRE: src/schema.sql + +--! @file encrypted_domain/types.sql +--! @brief High-level encrypted domain types: the eql_v2_int4 variant family. +--! +--! Four jsonb-backed domains in public, one per operator/index-term +--! capability: +--! eql_v2_int4 — storage only (all operators blocked); carries `c` +--! eql_v2_int4_eq — HMAC equality (=, <>); carries `c`, `hm` +--! eql_v2_int4_ord_ore — equality + ORE-block ordering (= <> < <= > >=); +--! carries `c`, `ob`; the scheme-explicit ordered +--! domain +--! eql_v2_int4_ord — equality + ORE-block ordering; the recommended +--! ordered name. A full concrete domain with its own +--! operators/wrappers/blockers (int4_ord.sql) — +--! identical operator surface to eql_v2_int4_ord_ore. +--! +--! These domains intentionally live in the public schema, matching the +--! existing lifecycle used by public.eql_v2_encrypted in +--! encrypted/types.sql: user table columns depend on stable public type +--! names, while implementation functions and operators live in eql_v2. +--! tasks/uninstall.sql drops eql_v2 but leaves public types in place. +--! +--! eql_v2_int4_ord is a concrete domain over jsonb (not a domain over +--! eql_v2_int4_ord_ore): the §8 verification spike showed that a +--! domain-over-domain does not transparently inherit the base domain's +--! operator surface — PostgreSQL resolves operators against the ultimate +--! base type (jsonb), so the ordered operators fall through to native +--! jsonb comparison and the blockers do not engage. eql_v2_int4_ord +--! therefore carries its own operator surface (int4_ord.sql). +--! +--! Ordered range and equality both engage a functional btree +--! USING btree (eql_v2.ord_term(col)) — eql_v2.ord_term returns +--! eql_v2.ore_block_u64_8_256, which carries main's DEFAULT btree +--! operator class. No operator class is defined on these domains. + +DO $$ +BEGIN + --! @brief Storage-only encrypted int4 domain (jsonb-backed). Every + --! operator is a blocker; carries ciphertext (`c`) only. + --! A CHECK constraint requires the `v`, `i`, `c` payload keys. + IF NOT EXISTS ( + SELECT 1 FROM pg_type + WHERE typname = 'eql_v2_int4' AND typnamespace = 'public'::regnamespace + ) THEN + CREATE DOMAIN public.eql_v2_int4 AS jsonb + CHECK ( + jsonb_typeof(VALUE) = 'object' + AND VALUE ? 'v' AND VALUE ? 'i' AND VALUE ? 'c' + ); + END IF; + + --! @brief Equality-only encrypted int4 domain (jsonb-backed). + --! Supports = and <> via HMAC-256; carries `c`, `hm`. + --! A CHECK constraint requires the `v`, `i`, `c`, `hm` payload keys. + IF NOT EXISTS ( + SELECT 1 FROM pg_type + WHERE typname = 'eql_v2_int4_eq' AND typnamespace = 'public'::regnamespace + ) THEN + CREATE DOMAIN public.eql_v2_int4_eq AS jsonb + CHECK ( + jsonb_typeof(VALUE) = 'object' + AND VALUE ? 'v' AND VALUE ? 'i' AND VALUE ? 'c' AND VALUE ? 'hm' + ); + END IF; + + --! @brief Scheme-explicit ordered encrypted int4 domain (jsonb-backed). + --! Supports = <> < <= > >= via the ORE-block term; carries + --! `c`, `ob`. Carries the eql_v2.ord_term extractor, the comparison + --! wrappers, the operator declarations, and the blockers. + --! A CHECK constraint requires the `v`, `i`, `c`, `ob` payload keys. + IF NOT EXISTS ( + SELECT 1 FROM pg_type + WHERE typname = 'eql_v2_int4_ord_ore' AND typnamespace = 'public'::regnamespace + ) THEN + CREATE DOMAIN public.eql_v2_int4_ord_ore AS jsonb + CHECK ( + jsonb_typeof(VALUE) = 'object' + AND VALUE ? 'v' AND VALUE ? 'i' AND VALUE ? 'c' AND VALUE ? 'ob' + ); + END IF; + + --! @brief Ordered encrypted int4 domain — the recommended ordered + --! name. A full concrete domain (its own operators/wrappers/ + --! blockers in int4_ord.sql) because the pure-alias form does + --! not transparently inherit the operator surface (spike §8). + --! Supports = <> < <= > >= via the ORE-block term; carries + --! `c`, `ob`. + --! A CHECK constraint requires the `v`, `i`, `c`, `ob` payload keys. + IF NOT EXISTS ( + SELECT 1 FROM pg_type + WHERE typname = 'eql_v2_int4_ord' AND typnamespace = 'public'::regnamespace + ) THEN + CREATE DOMAIN public.eql_v2_int4_ord AS jsonb + CHECK ( + jsonb_typeof(VALUE) = 'object' + AND VALUE ? 'v' AND VALUE ? 'i' AND VALUE ? 'c' AND VALUE ? 'ob' + ); + END IF; +END +$$; diff --git a/src/ore_block_u64_8_256/operators.sql b/src/ore_block_u64_8_256/operators.sql index e9e34561..06a4fa65 100644 --- a/src/ore_block_u64_8_256/operators.sql +++ b/src/ore_block_u64_8_256/operators.sql @@ -123,10 +123,17 @@ $$; --! @brief = operator for ORE block types +--! +--! COMMUTATOR is the operator itself: equality is symmetric. The clause +--! is required for a MERGES (mergejoinable) operator — without it the +--! planner raises "could not find commutator" the first time an +--! ore_block equality is used as a join qual (e.g. via the inlined +--! eql_v2_int4_ord_ore equality wrappers). CREATE OPERATOR = ( FUNCTION=eql_v2.ore_block_u64_8_256_eq, LEFTARG=eql_v2.ore_block_u64_8_256, RIGHTARG=eql_v2.ore_block_u64_8_256, + COMMUTATOR = =, NEGATOR = <>, RESTRICT = eqsel, JOIN = eqjoinsel, @@ -137,10 +144,14 @@ CREATE OPERATOR = ( --! @brief <> operator for ORE block types +--! +--! COMMUTATOR is the operator itself: inequality is symmetric. Required +--! alongside the MERGES flag — see the = operator above. CREATE OPERATOR <> ( FUNCTION=eql_v2.ore_block_u64_8_256_neq, LEFTARG=eql_v2.ore_block_u64_8_256, RIGHTARG=eql_v2.ore_block_u64_8_256, + COMMUTATOR = <>, NEGATOR = =, RESTRICT = eqsel, JOIN = eqjoinsel, diff --git a/tasks/build.sh b/tasks/build.sh index 0768dd34..39d657b6 100755 --- a/tasks/build.sh +++ b/tasks/build.sh @@ -38,6 +38,8 @@ rm -f release/cipherstash-encrypt-supabase.sql rm -f release/cipherstash-encrypt-protect.sql rm -f release/cipherstash-encrypt-protect-uninstall.sql +# dbdev/eql--0.0.0.sql is appended with >> below; truncate it here so +# repeated builds do not concatenate onto stale content. rm -f dbdev/eql--0.0.0.sql rm -f src/version.sql diff --git a/tasks/fixtures/encrypted_int4_schema.sql b/tasks/fixtures/encrypted_int4_schema.sql index 5d870590..eb3cb3fb 100644 --- a/tasks/fixtures/encrypted_int4_schema.sql +++ b/tasks/fixtures/encrypted_int4_schema.sql @@ -25,6 +25,6 @@ SELECT eql_v2.remove_search_config('bench_int4', 'encrypted_int4', 'ore') WHERE c.data #> '{tables,bench_int4,encrypted_int4,indexes,ore}' IS NOT NULL ); --- unique → HMAC (drives =, <>); ore → OPE bytes (drives <, <=, >, >=). +-- unique → HMAC (drives =, <>); ore → ORE-block terms (drives <, <=, >, >=). SELECT eql_v2.add_search_config('bench_int4', 'encrypted_int4', 'unique', 'int'); SELECT eql_v2.add_search_config('bench_int4', 'encrypted_int4', 'ore', 'int'); diff --git a/tasks/fixtures/generate_encrypted_int4.sh b/tasks/fixtures/generate_encrypted_int4.sh index 5662845a..a7c11dab 100755 --- a/tasks/fixtures/generate_encrypted_int4.sh +++ b/tasks/fixtures/generate_encrypted_int4.sh @@ -54,13 +54,16 @@ done psql "$PROXY_URL" -v ON_ERROR_STOP=1 -f "$INSERT_SQL" >/dev/null echo "==> Dumping $ROW_COUNT rows to $OUTPUT" -cat > "$OUTPUT" <
"$OUTPUT" <<'HEADER' -- AUTO-GENERATED by tasks/fixtures/generate_encrypted_int4.sh -- DO NOT EDIT BY HAND. Re-run the generator to refresh. -- -- Source: 14-value integer set defined inline in the generator. --- Produced via CipherStash Proxy (HMAC + ORE block terms). --- Used by encrypted_int4 domain SQLx fixture tests. +-- Produced via CipherStash Proxy (HMAC + ORE-block terms). +-- Each row carries `c`, `hm`, `ob`. The ordered variants +-- (eql_v2_int4_ord_ore, eql_v2_int4_ord) read `ob` only; the `hm` +-- term is retained because the same payload feeds eql_v2_int4_eq. +-- Used by the encrypted_int4 variant-family SQLx test suites. DROP TABLE IF EXISTS encrypted_int4_plaintext; diff --git a/tasks/pin_search_path.sql b/tasks/pin_search_path.sql index 8369589e..6002bd4a 100644 --- a/tasks/pin_search_path.sql +++ b/tasks/pin_search_path.sql @@ -32,6 +32,9 @@ DECLARE jsonb_oid oid; text_oid oid; entry_oid oid; + int4_eq_oid oid; + int4_ord_oid oid; + int4_ord_ore_oid oid; BEGIN -- Resolve type oids without depending on caller search_path. The encrypted -- composite type is created in `public`; jsonb / text are in `pg_catalog`; @@ -73,6 +76,38 @@ BEGIN RAISE EXCEPTION 'pin_search_path: type eql_v2.ste_vec_entry not found'; END IF; + -- The eql_v2_int4 variant-family domains are created in `public` + -- (alongside eql_v2_encrypted). Resolved so the inline-critical clauses + -- for the converged eq/neq/lt/lte/gt/gte wrappers can be restricted by + -- argument type — those bare names now collide with the ste_vec_entry + -- and eql_v2_encrypted overloads of the same name. + SELECT t.oid INTO int4_eq_oid + FROM pg_catalog.pg_type t + JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace + WHERE n.nspname = 'public' AND t.typname = 'eql_v2_int4_eq'; + + IF int4_eq_oid IS NULL THEN + RAISE EXCEPTION 'pin_search_path: type public.eql_v2_int4_eq not found'; + END IF; + + SELECT t.oid INTO int4_ord_oid + FROM pg_catalog.pg_type t + JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace + WHERE n.nspname = 'public' AND t.typname = 'eql_v2_int4_ord'; + + IF int4_ord_oid IS NULL THEN + RAISE EXCEPTION 'pin_search_path: type public.eql_v2_int4_ord not found'; + END IF; + + SELECT t.oid INTO int4_ord_ore_oid + FROM pg_catalog.pg_type t + JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace + WHERE n.nspname = 'public' AND t.typname = 'eql_v2_int4_ord_ore'; + + IF int4_ord_ore_oid IS NULL THEN + RAISE EXCEPTION 'pin_search_path: type public.eql_v2_int4_ord_ore not found'; + END IF; + -- Wrappers that must remain inlinable for functional-index matching. -- Verified empirically: with SET, EXPLAIN drops to Seq Scan; without, -- it uses Bitmap Index Scan / Index Scan. @@ -215,13 +250,12 @@ BEGIN OR p.proargtypes[1] = (SELECT t.oid FROM pg_catalog.pg_type t JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace WHERE n.nspname = 'pg_catalog' AND t.typname = 'int4'))) - -- XOR-aware equality term extractor on a ste_vec entry. Must - -- inline so `eql_v2.eq_term(col -> 'sel')` folds into the - -- calling query and matches a functional hash index built on - -- the same expression. - OR (p.pronargs = 1 - AND p.proname = 'eq_term' - AND p.proargtypes[0] = entry_oid) + -- Equality-term extractors — `eq_term` on a ste_vec entry + -- (XOR-aware) and on eql_v2_int4_eq. Must inline so + -- `eql_v2.eq_term(col)` folds into the calling query and matches + -- a functional index built on the same expression. Name-only + -- match (any arity-1 overload), mirroring the `ord_term` clause. + OR (p.pronargs = 1 AND p.proname = 'eq_term') -- Type-safe `@>` / `<@` overloads with typed needles -- (`stevec_query`, `ste_vec_entry`). Inline to the existing -- `ste_vec_contains` machinery — must stay unpinned to engage @@ -241,6 +275,35 @@ BEGIN OR p.proargtypes[0] = (SELECT t.oid FROM pg_catalog.pg_type t JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace WHERE n.nspname = 'eql_v2' AND t.typname = 'stevec_query'))) + -- eql_v2_int4 variant family inline-critical wrappers. + -- + -- After the PR #225 naming convergence the backing functions are + -- overloaded eql_v2.eq / neq / lt / lte / gt / gte discriminated by + -- argument type, sharing those bare names with the ste_vec_entry and + -- eql_v2_encrypted overloads — so these clauses MUST restrict by the + -- int4 domain arg types. The `proargtypes[0] OR proargtypes[1]` form + -- covers all three arg-shapes (domain,domain), (domain,jsonb), + -- (jsonb,domain). + -- + -- Only the real (LANGUAGE sql) wrappers appear here: eq/neq on + -- eql_v2_int4_eq, and all six comparisons on eql_v2_int4_ord / + -- eql_v2_int4_ord_ore. They must inline so the planner rewrites + -- `col $1` to `eql_v2.eq_term(col) ...` / + -- `eql_v2.ord_term(col) ...` and matches the functional index. + -- The extractors eql_v2.eq_term / eql_v2.ord_term stay unpinned via + -- the 1-arg clauses above. The storage-variant eql_v2_int4 blockers + -- and every contains / contained_by / "->" / "->>" blocker are + -- PL/pgSQL and must NOT inline — they are excluded by omission. + OR (p.pronargs = 1 AND p.proname = 'ord_term') + OR (p.pronargs = 2 + AND p.proname IN ('eq', 'neq') + AND (p.proargtypes[0] = int4_eq_oid OR p.proargtypes[1] = int4_eq_oid)) + OR (p.pronargs = 2 + AND p.proname IN ('eq', 'neq', 'lt', 'lte', 'gt', 'gte') + AND (p.proargtypes[0] = int4_ord_oid OR p.proargtypes[1] = int4_ord_oid)) + OR (p.pronargs = 2 + AND p.proname IN ('eq', 'neq', 'lt', 'lte', 'gt', 'gte') + AND (p.proargtypes[0] = int4_ord_ore_oid OR p.proargtypes[1] = int4_ord_ore_oid)) ); FOR fn_oid IN diff --git a/tasks/test/splinter.sh b/tasks/test/splinter.sh index dae147d6..f739ddf6 100755 --- a/tasks/test/splinter.sh +++ b/tasks/test/splinter.sh @@ -81,12 +81,12 @@ function_search_path_mutable eql_v2 jsonb_contained_by function GIN-inlining: sa function_search_path_mutable eql_v2 ore_cllw function Consolidated ORE-CLLW extractor (U-006): inlinable SQL so the planner can fold `eql_v2.ore_cllw(col -> 'sel')` calls into the calling query. SET search_path would silently undo the inlining and prevent functional-index match through the extractor form. Two overloads: (jsonb), (eql_v2.ste_vec_entry). function_search_path_mutable eql_v2 has_ore_cllw function Consolidated ORE-CLLW presence check (U-006): inlinable SQL counterpart to `eql_v2.ore_cllw`. Same rationale as `ore_cllw` — must stay unpinned to inline into the calling query. Two overloads: (jsonb), (eql_v2.ste_vec_entry). function_search_path_mutable eql_v2 selector function STE-vec entry selector extractor (#219): typed (eql_v2.ste_vec_entry) overload, inlinable so the planner can fold `eql_v2.selector(col -> 'sel')` into the calling query. -function_search_path_mutable eql_v2 eq function Equality backing function for `eql_v2.ste_vec_entry × eql_v2.ste_vec_entry` (#219). Inlines to `hmac_256(a) = hmac_256(b)`; the `=` operator must reach the functional hash index on `eql_v2.hmac_256(col -> 'sel')` for bare-form field equality to engage Index Scan. -function_search_path_mutable eql_v2 neq function Inequality backing function for `eql_v2.ste_vec_entry`. Same rationale as `eq`. -function_search_path_mutable eql_v2 lt function Less-than backing function for `eql_v2.ste_vec_entry`. Inlines to `ore_cllw(a) < ore_cllw(b)`; must reach the functional btree opclass on `eql_v2.ore_cllw` for ordered field queries to engage Index Scan. -function_search_path_mutable eql_v2 lte function Less-than-or-equal backing function for `eql_v2.ste_vec_entry`. Same rationale as `lt`. -function_search_path_mutable eql_v2 gt function Greater-than backing function for `eql_v2.ste_vec_entry`. Same rationale as `lt`. -function_search_path_mutable eql_v2 gte function Greater-than-or-equal backing function for `eql_v2.ste_vec_entry`. Same rationale as `lt`. +function_search_path_mutable eql_v2 eq function Equality backing function for `eql_v2.ste_vec_entry × eql_v2.ste_vec_entry` (#219). Inlines to `hmac_256(a) = hmac_256(b)`; the `=` operator must reach the functional hash index on `eql_v2.hmac_256(col -> 'sel')` for bare-form field equality to engage Index Scan. Splinter matches by name only, so this row also covers the converged eql_v2.eq wrappers on eql_v2_int4_eq / _ord / _ord_ore (PR #225). +function_search_path_mutable eql_v2 neq function Inequality backing function for `eql_v2.ste_vec_entry`. Same rationale as `eq`. Also covers the converged eql_v2.neq wrappers on eql_v2_int4_eq / _ord / _ord_ore (PR #225). +function_search_path_mutable eql_v2 lt function Less-than backing function for `eql_v2.ste_vec_entry`. Inlines to `ore_cllw(a) < ore_cllw(b)`; must reach the functional btree opclass on `eql_v2.ore_cllw` for ordered field queries to engage Index Scan. Splinter matches by name only, so this row also covers the converged eql_v2.lt wrappers on eql_v2_int4_ord / _ord_ore (PR #225). +function_search_path_mutable eql_v2 lte function Less-than-or-equal backing function for `eql_v2.ste_vec_entry`. Same rationale as `lt`. Also covers the converged eql_v2.lte wrappers on eql_v2_int4_ord / _ord_ore (PR #225). +function_search_path_mutable eql_v2 gt function Greater-than backing function for `eql_v2.ste_vec_entry`. Same rationale as `lt`. Also covers the converged eql_v2.gt wrappers on eql_v2_int4_ord / _ord_ore (PR #225). +function_search_path_mutable eql_v2 gte function Greater-than-or-equal backing function for `eql_v2.ste_vec_entry`. Same rationale as `lt`. Also covers the converged eql_v2.gte wrappers on eql_v2_int4_ord / _ord_ore (PR #225). function_search_path_mutable eql_v2 ore_cllw_eq function Inner comparator for the `eql_v2.ore_cllw` type's `=` operator (#221). The outer same-type operators back the btree opclass on `eql_v2.ore_cllw`; the planner only carries the inlined form through to functional-index match if this inner function is also inlinable (no SET, IMMUTABLE). Mirrors ore_block_u64_8_256_eq. function_search_path_mutable eql_v2 ore_cllw_neq function Inner comparator for the `eql_v2.ore_cllw` type's `<>` operator (#221). Same rationale as `ore_cllw_eq`. function_search_path_mutable eql_v2 ore_cllw_lt function Inner comparator for the `eql_v2.ore_cllw` type's `<` operator (#221). Same rationale as `ore_cllw_eq`. @@ -94,10 +94,11 @@ function_search_path_mutable eql_v2 ore_cllw_lte function Inner comparator for t function_search_path_mutable eql_v2 ore_cllw_gt function Inner comparator for the `eql_v2.ore_cllw` type's `>` operator (#221). Same rationale as `ore_cllw_eq`. function_search_path_mutable eql_v2 ore_cllw_gte function Inner comparator for the `eql_v2.ore_cllw` type's `>=` operator (#221). Same rationale as `ore_cllw_eq`. function_search_path_mutable eql_v2 -> function Typed sv-element selector lookup (U-007): inlinable SQL so the planner can fold `col -> ''` into the calling query, preserving functional-index match for the chained recipes `WHERE col -> 'sel' = $1::ste_vec_entry` (via eq_term) and `ORDER BY eql_v2.ore_cllw(col -> 'sel')`. Three overloads: (enc, text), (enc, enc), (enc, int). -function_search_path_mutable eql_v2 eq_term function XOR-aware equality term extractor on a ste_vec entry (U-007): coalesces hm and oc as bytea. Must inline so `eql_v2.eq_term(col -> 'sel')` folds into the calling query and matches a functional hash index built on the same expression — same precedent as ore_cllw / hmac_256 extractors on ste_vec_entry. +function_search_path_mutable eql_v2 eq_term function XOR-aware equality term extractor on a ste_vec entry (U-007): coalesces hm and oc as bytea. Must inline so `eql_v2.eq_term(col -> 'sel')` folds into the calling query and matches a functional hash index built on the same expression — same precedent as ore_cllw / hmac_256 extractors on ste_vec_entry. Also covers the eql_v2_int4_eq eq_term overload (PR #225). function_search_path_mutable eql_v2 min function Aggregate (splinter labels these type=function): ALTER AGGREGATE has no SET configuration_parameter syntax, and ALTER ROUTINE/FUNCTION reject aggregates. The aggregate's SFUNC has a pinned search_path. function_search_path_mutable eql_v2 max function Aggregate: same as min. function_search_path_mutable eql_v2 grouped_value function Aggregate: same as min. +function_search_path_mutable eql_v2 ord_term function eql_v2_int4 ordered-variant index extractor: returns eql_v2.ore_block_u64_8_256 (carrying main DEFAULT btree opclass). Used inside the inlinable comparison wrappers and as the functional-index expression USING btree (eql_v2.ord_term(col)); must inline. SET search_path would disable SQL function inlining (see PostgreSQL inline_function). Covers both ord_term overloads (eql_v2_int4_ord_ore, eql_v2_int4_ord). ALLOW # Wrap splinter (a single bare SELECT expression) into a subquery we can diff --git a/tests/sqlx/migrations/009_install_encrypted_int4_fixture.sql b/tests/sqlx/migrations/009_install_encrypted_int4_fixture.sql index 7556d9e6..d800e7ff 100644 --- a/tests/sqlx/migrations/009_install_encrypted_int4_fixture.sql +++ b/tests/sqlx/migrations/009_install_encrypted_int4_fixture.sql @@ -2,8 +2,11 @@ -- DO NOT EDIT BY HAND. Re-run the generator to refresh. -- -- Source: 14-value integer set defined inline in the generator. --- Produced via CipherStash Proxy (HMAC + OPE terms). --- Used by encrypted_int4 domain SQLx fixture tests. +-- Produced via CipherStash Proxy (HMAC + ORE-block terms). +-- Each row carries `c`, `hm`, `ob`. The ordered variants +-- (eql_v2_int4_ord_ore, eql_v2_int4_ord) read `ob` only; the `hm` +-- term is retained because the same payload feeds eql_v2_int4_eq. +-- Used by the encrypted_int4 variant-family SQLx test suites. DROP TABLE IF EXISTS encrypted_int4_plaintext; diff --git a/tests/sqlx/tests/encrypted_int4_eq_tests.rs b/tests/sqlx/tests/encrypted_int4_eq_tests.rs new file mode 100644 index 00000000..e2ab3736 --- /dev/null +++ b/tests/sqlx/tests/encrypted_int4_eq_tests.rs @@ -0,0 +1,487 @@ +//! Synthetic test suite for `eql_v2_int4_eq` — HMAC equality only. +//! +//! `=` engages a functional index on `eql_v2.eq_term(col)` — hash or +//! btree (EXPLAIN assertion). `<>` is supported semantically but is +//! seq-scan (no index serves inequality). All other operators raise. + +use anyhow::Result; +use sqlx::PgPool; + +fn payload(hm: &str) -> String { + format!(r#"{{"v":2,"i":{{"t":"typed","c":"int_col"}},"c":"ct-{hm}","hm":"{hm}"}}"#) +} + +async fn setup_eq_table( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + hmacs: &[&str], +) -> Result<()> { + sqlx::query( + r#" + CREATE TEMP TABLE typed_int4_eq ( + id integer GENERATED ALWAYS AS IDENTITY, + value eql_v2_int4_eq + ) ON COMMIT DROP; + "#, + ) + .execute(&mut **tx) + .await?; + + for hm in hmacs { + sqlx::query("INSERT INTO typed_int4_eq(value) VALUES ($1::jsonb::eql_v2_int4_eq)") + .bind(payload(hm)) + .execute(&mut **tx) + .await?; + } + Ok(()) +} + +#[sqlx::test] +async fn eq_engages_btree_for_equality(pool: PgPool) -> Result<()> { + let mut tx = pool.begin().await?; + setup_eq_table(&mut tx, &["aaa", "bbb", "ccc"]).await?; + + sqlx::query( + "CREATE INDEX typed_int4_eq_btree_idx \ + ON typed_int4_eq USING btree (eql_v2.eq_term(value))", + ) + .execute(&mut *tx) + .await?; + sqlx::query("ANALYZE typed_int4_eq") + .execute(&mut *tx) + .await?; + sqlx::query("SET LOCAL enable_seqscan = off") + .execute(&mut *tx) + .await?; + + let needle = payload("bbb"); + let plan: Vec = sqlx::query_scalar(&format!( + "EXPLAIN SELECT * FROM typed_int4_eq WHERE value = '{}'::jsonb::eql_v2_int4_eq", + needle + )) + .fetch_all(&mut *tx) + .await?; + let plan_text = plan.join("\n"); + assert!( + plan_text.contains("typed_int4_eq_btree_idx"), + "= must engage the eql_v2.eq_term btree index; got plan:\n{plan_text}" + ); + + tx.commit().await?; + Ok(()) +} + +#[sqlx::test] +async fn eq_neq_returns_correct_rows(pool: PgPool) -> Result<()> { + let mut tx = pool.begin().await?; + setup_eq_table(&mut tx, &["aaa", "bbb", "ccc"]).await?; + + let count: i64 = sqlx::query_scalar(&format!( + "SELECT count(*) FROM typed_int4_eq WHERE value = '{}'::jsonb::eql_v2_int4_eq", + payload("bbb") + )) + .fetch_one(&mut *tx) + .await?; + assert_eq!(count, 1, "= must match exactly one row"); + + let count: i64 = sqlx::query_scalar(&format!( + "SELECT count(*) FROM typed_int4_eq WHERE value <> '{}'::jsonb::eql_v2_int4_eq", + payload("bbb") + )) + .fetch_one(&mut *tx) + .await?; + assert_eq!(count, 2, "<> must match the other two rows"); + + tx.commit().await?; + Ok(()) +} + +#[sqlx::test] +async fn eq_cross_type_shapes_for_equality(pool: PgPool) -> Result<()> { + let mut tx = pool.begin().await?; + setup_eq_table(&mut tx, &["aaa", "bbb"]).await?; + let needle = payload("bbb"); + + for sql in [ + format!( + "SELECT count(*) FROM typed_int4_eq WHERE value = '{}'::jsonb::eql_v2_int4_eq", + needle + ), + format!( + "SELECT count(*) FROM typed_int4_eq WHERE value = '{}'::jsonb", + needle + ), + format!( + "SELECT count(*) FROM typed_int4_eq WHERE '{}'::jsonb = value", + needle + ), + ] { + let count: i64 = sqlx::query_scalar(&sql).fetch_one(&mut *tx).await?; + assert_eq!(count, 1, "= shape must match one row; sql: {sql}"); + } + + tx.commit().await?; + Ok(()) +} + +#[sqlx::test] +async fn eq_unsupported_operators_raise(pool: PgPool) -> Result<()> { + let sample = payload("aaa"); + let shapes: &[(&str, &str)] = &[ + ("$1::jsonb::eql_v2_int4_eq", "$2::jsonb::eql_v2_int4_eq"), + ("$1::jsonb::eql_v2_int4_eq", "$2::jsonb"), + ("$1::jsonb", "$2::jsonb::eql_v2_int4_eq"), + ]; + for op in ["<", "<=", ">", ">=", "@>", "<@"] { + for (lhs, rhs) in shapes { + let sql = format!("SELECT {lhs} {op} {rhs}"); + let err = sqlx::query(&sql) + .bind(&sample) + .bind(&sample) + .fetch_one(&pool) + .await + .expect_err(&format!("eql_v2_int4_eq {op} must raise: {sql}")) + .to_string(); + let expected = format!("operator {op} is not supported for eql_v2_int4_eq"); + assert!(err.contains(&expected), "unexpected: {sql} → {err}"); + } + } + + for op in ["->", "->>"] { + for sql in [ + format!("SELECT $1::jsonb::eql_v2_int4_eq {op} 'field'::text"), + format!("SELECT $1::jsonb::eql_v2_int4_eq {op} 0::integer"), + format!("SELECT $1::jsonb {op} $1::jsonb::eql_v2_int4_eq"), + ] { + let err = sqlx::query(&sql) + .bind(&sample) + .fetch_one(&pool) + .await + .expect_err(&format!("eql_v2_int4_eq {op} must raise: {sql}")) + .to_string(); + let expected = format!("operator {op} is not supported for eql_v2_int4_eq"); + assert!(err.contains(&expected), "unexpected: {sql} → {err}"); + } + } + Ok(()) +} + +#[sqlx::test] +async fn eq_blocked_operators_raise_on_null_input(pool: PgPool) -> Result<()> { + // A blocker declared STRICT lets PostgreSQL skip the body and return + // NULL on a NULL argument, silently bypassing the + // "operator … is not supported" exception. The blocker contract is + // "always raises" — guard against STRICT regressing back in. + let null: Option<&str> = None; + + let err = sqlx::query("SELECT $1::jsonb::eql_v2_int4_eq < $2::jsonb::eql_v2_int4_eq") + .bind(null) + .bind(null) + .fetch_one(&pool) + .await + .expect_err("eql_v2_int4_eq < must raise on NULL input") + .to_string(); + assert!( + err.contains("operator < is not supported for eql_v2_int4_eq"), + "unexpected error for < on NULL: {err}" + ); + + let err = sqlx::query("SELECT $1::jsonb -> $2::jsonb::eql_v2_int4_eq") + .bind(null) + .bind(null) + .fetch_one(&pool) + .await + .expect_err("eql_v2_int4_eq -> must raise on NULL input") + .to_string(); + assert!( + err.contains("operator -> is not supported for eql_v2_int4_eq"), + "unexpected error for -> on NULL: {err}" + ); + Ok(()) +} + +#[sqlx::test] +async fn eq_engages_hash_for_equality(pool: PgPool) -> Result<()> { + // `eql_v2.eq_term(col)` extracts the HMAC equality term — a domain + // over `text`, which carries a default hash operator class. A hash + // functional index on it engages `=` (btree does too — see + // eq_engages_btree_for_equality). No `::jsonb` cast: `eql_v2.eq_term` + // is a plain function name with no colliding type. + let mut tx = pool.begin().await?; + sqlx::query( + "CREATE TEMP TABLE eq_idx (plaintext integer, value eql_v2_int4_eq) ON COMMIT DROP", + ) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO eq_idx(plaintext, value) \ + SELECT plaintext, payload::eql_v2_int4_eq FROM encrypted_int4_plaintext", + ) + .execute(&mut *tx) + .await?; + sqlx::query("CREATE INDEX eq_idx_hash ON eq_idx USING hash (eql_v2.eq_term(value))") + .execute(&mut *tx) + .await?; + sqlx::query("ANALYZE eq_idx").execute(&mut *tx).await?; + sqlx::query("SET LOCAL enable_seqscan = off") + .execute(&mut *tx) + .await?; + + let pivot: String = sqlx::query_scalar( + "SELECT payload::text FROM encrypted_int4_plaintext WHERE plaintext = 42", + ) + .fetch_one(&mut *tx) + .await?; + let lit = pivot.replace('\'', "''"); + let eq_query = format!("SELECT * FROM eq_idx WHERE value = '{lit}'::jsonb::eql_v2_int4_eq"); + + let plan: Vec = sqlx::query_scalar(&format!("EXPLAIN {eq_query}")) + .fetch_all(&mut *tx) + .await?; + assert!( + plan.join("\n").contains("eq_idx_hash"), + "the eql_v2.eq_term hash recipe must engage for = ; plan:\n{}", + plan.join("\n") + ); + + let ids: Vec = sqlx::query_scalar(&format!( + "SELECT plaintext FROM eq_idx WHERE value = '{lit}'::jsonb::eql_v2_int4_eq" + )) + .fetch_all(&mut *tx) + .await?; + assert_eq!( + ids, + vec![42], + "= via the eq_term hash index must return the matching row" + ); + + tx.commit().await?; + Ok(()) +} + +#[sqlx::test] +async fn eq_null_operand_yields_null(pool: PgPool) -> Result<()> { + // STRICT equality wrappers: a NULL operand propagates NULL. + let null: Option<&str> = None; + let sample = r#"{"v":2,"i":{"t":"t","c":"c"},"c":"x","hm":"aa"}"#; + for op in ["=", "<>"] { + let result: Option = sqlx::query_scalar(&format!( + "SELECT $1::jsonb::eql_v2_int4_eq {op} $2::jsonb::eql_v2_int4_eq" + )) + .bind(sample) + .bind(null) + .fetch_one(&pool) + .await?; + assert!(result.is_none(), "{op} with NULL operand must yield NULL"); + } + Ok(()) +} + +#[sqlx::test] +async fn eq_engages_btree_constant_on_left(pool: PgPool) -> Result<()> { + // The functional btree must engage when the literal is on the LEFT + // (`$1 = col`) as well as the right — the commuted shape ORMs and + // PostgREST emit. `=` is its own commutator. + let mut tx = pool.begin().await?; + setup_eq_table(&mut tx, &["aaa", "bbb", "ccc"]).await?; + + sqlx::query( + "CREATE INDEX typed_int4_eq_cl_idx \ + ON typed_int4_eq USING btree (eql_v2.eq_term(value))", + ) + .execute(&mut *tx) + .await?; + sqlx::query("ANALYZE typed_int4_eq") + .execute(&mut *tx) + .await?; + sqlx::query("SET LOCAL enable_seqscan = off") + .execute(&mut *tx) + .await?; + + let needle = payload("bbb"); + for sql in [ + format!( + "EXPLAIN SELECT * FROM typed_int4_eq \ + WHERE '{needle}'::jsonb::eql_v2_int4_eq = value" + ), + format!("EXPLAIN SELECT * FROM typed_int4_eq WHERE '{needle}'::jsonb = value"), + ] { + let plan: Vec = sqlx::query_scalar(&sql).fetch_all(&mut *tx).await?; + let plan_text = plan.join("\n"); + assert!( + plan_text.contains("typed_int4_eq_cl_idx"), + "constant-on-left = must engage the eql_v2.eq_term btree; \ + sql: {sql}\nplan:\n{plan_text}" + ); + } + + tx.commit().await?; + Ok(()) +} + +#[sqlx::test] +async fn eq_operators_declare_planner_metadata(pool: PgPool) -> Result<()> { + // The real = / <> operators on eql_v2_int4_eq must declare + // COMMUTATOR, NEGATOR, and selectivity estimators (RESTRICT / JOIN) + // on all three arg-shapes, so the planner can normalise and cost + // commuted and negated predicates. + let rows: Vec<(String, String, String, bool, bool, bool, bool)> = sqlx::query_as( + r#" + SELECT o.oprname, + lt.typname AS lhs, + rt.typname AS rhs, + o.oprcom <> 0 AS has_commutator, + o.oprnegate <> 0 AS has_negator, + o.oprrest::oid <> 0 AS has_restrict, + o.oprjoin::oid <> 0 AS has_join + FROM pg_catalog.pg_operator o + JOIN pg_catalog.pg_type lt ON lt.oid = o.oprleft + JOIN pg_catalog.pg_type rt ON rt.oid = o.oprright + WHERE o.oprname IN ('=', '<>') + AND (lt.typname = 'eql_v2_int4_eq' OR rt.typname = 'eql_v2_int4_eq') + "#, + ) + .fetch_all(&pool) + .await?; + + assert_eq!( + rows.len(), + 6, + "expected = and <> x 3 arg-shapes on eql_v2_int4_eq" + ); + for (op, lhs, rhs, has_com, has_neg, has_rest, has_join) in &rows { + assert!( + has_com, + "operator {op}({lhs},{rhs}) must declare COMMUTATOR" + ); + assert!(has_neg, "operator {op}({lhs},{rhs}) must declare NEGATOR"); + assert!(has_rest, "operator {op}({lhs},{rhs}) must declare RESTRICT"); + assert!(has_join, "operator {op}({lhs},{rhs}) must declare JOIN"); + } + Ok(()) +} + +#[sqlx::test] +async fn eq_wrappers_are_inlinable(pool: PgPool) -> Result<()> { + // The = / <> wrappers on eql_v2_int4_eq must be LANGUAGE sql, + // IMMUTABLE, and carry no pinned search_path, so the planner inlines + // `col = $1` to `eql_v2.eq_term(col) = eql_v2.eq_term($1)` and the + // functional index on eql_v2.eq_term(col) engages. A pinned + // proconfig or a plpgsql body would break the inline chain. + let rows: Vec<(String, String, String, Option>)> = sqlx::query_as( + r#" + SELECT p.proname, l.lanname, p.provolatile::text, p.proconfig + FROM pg_catalog.pg_proc p + JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace + JOIN pg_catalog.pg_language l ON l.oid = p.prolang + JOIN pg_catalog.pg_type lt ON lt.oid = p.proargtypes[0] + JOIN pg_catalog.pg_type rt ON rt.oid = p.proargtypes[1] + WHERE n.nspname = 'eql_v2' + AND p.proname IN ('eq', 'neq') + AND (lt.typname = 'eql_v2_int4_eq' OR rt.typname = 'eql_v2_int4_eq') + "#, + ) + .fetch_all(&pool) + .await?; + + // 2 wrapper names x 3 arg-shapes = 6 rows. + assert_eq!(rows.len(), 6, "expected 6 equality wrapper overloads"); + for (name, lang, volatile, config) in &rows { + assert_eq!(lang, "sql", "{name} must be LANGUAGE sql to inline"); + assert_eq!(volatile, "i", "{name} must be IMMUTABLE"); + assert!( + config.is_none(), + "{name} must have no pinned search_path (proconfig)" + ); + } + + // The eql_v2.eq_term index extractor must be IMMUTABLE — a + // functional index expression requires it. + let eq_term: Vec<(String, String, Option>)> = sqlx::query_as( + r#" + SELECT l.lanname, p.provolatile::text, p.proconfig + FROM pg_catalog.pg_proc p + JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace + JOIN pg_catalog.pg_language l ON l.oid = p.prolang + WHERE n.nspname = 'eql_v2' AND p.proname = 'eq_term' + "#, + ) + .fetch_all(&pool) + .await?; + assert!(!eq_term.is_empty(), "eql_v2.eq_term must exist"); + for (lang, volatile, config) in &eq_term { + assert_eq!(volatile, "i", "eql_v2.eq_term must be IMMUTABLE"); + if lang == "sql" { + assert!( + config.is_none(), + "a LANGUAGE sql eql_v2.eq_term must have no pinned search_path" + ); + } + } + Ok(()) +} + +#[sqlx::test] +async fn eq_btree_index_preferred_at_scale(pool: PgPool) -> Result<()> { + // The other EXPLAIN tests force `enable_seqscan = off`, proving the + // index is *usable*. This test proves the planner *prefers* it: at + // ~5000 rows with a highly selective `=` predicate, the functional + // btree must be chosen with seqscan left enabled. + let mut tx = pool.begin().await?; + sqlx::query("CREATE TEMP TABLE eq_scale (value eql_v2_int4_eq) ON COMMIT DROP") + .execute(&mut *tx) + .await?; + + let filler = payload("filler"); + let pivot = payload("pivot"); + sqlx::query( + "INSERT INTO eq_scale(value) \ + SELECT $1::jsonb::eql_v2_int4_eq FROM generate_series(1, 5000)", + ) + .bind(&filler) + .execute(&mut *tx) + .await?; + sqlx::query("INSERT INTO eq_scale(value) VALUES ($1::jsonb::eql_v2_int4_eq)") + .bind(&pivot) + .execute(&mut *tx) + .await?; + sqlx::query("CREATE INDEX eq_scale_idx ON eq_scale USING btree (eql_v2.eq_term(value))") + .execute(&mut *tx) + .await?; + sqlx::query("ANALYZE eq_scale").execute(&mut *tx).await?; + + let plan: Vec = sqlx::query_scalar(&format!( + "EXPLAIN SELECT * FROM eq_scale WHERE value = '{pivot}'::jsonb::eql_v2_int4_eq" + )) + .fetch_all(&mut *tx) + .await?; + let plan_text = plan.join("\n"); + assert!( + plan_text.contains("eq_scale_idx"), + "with seqscan enabled the planner must prefer the eql_v2.eq_term \ + btree for a selective = ; plan:\n{plan_text}" + ); + + tx.commit().await?; + Ok(()) +} + +#[sqlx::test] +async fn eq_rejects_payload_missing_required_keys(pool: PgPool) -> Result<()> { + // The eql_v2_int4_eq domain CHECK requires v, i, c, hm. A payload + // missing any required key is rejected at the cast. + for (label, json) in [ + ("missing hm", r#"{"v":2,"i":{"t":"t","c":"c"},"c":"x"}"#), + ("missing c", r#"{"v":2,"i":{"t":"t","c":"c"},"hm":"aa"}"#), + ] { + let err = sqlx::query(&format!("SELECT '{json}'::jsonb::eql_v2_int4_eq")) + .fetch_one(&pool) + .await + .expect_err(&format!("eql_v2_int4_eq must reject payload: {label}")) + .to_string(); + assert!( + err.contains("violates check constraint"), + "{label}: expected a check-constraint violation, got: {err}" + ); + } + Ok(()) +} diff --git a/tests/sqlx/tests/encrypted_int4_ord_ore_tests.rs b/tests/sqlx/tests/encrypted_int4_ord_ore_tests.rs new file mode 100644 index 00000000..de202575 --- /dev/null +++ b/tests/sqlx/tests/encrypted_int4_ord_ore_tests.rs @@ -0,0 +1,642 @@ +//! Fixture-based test suite for `eql_v2_int4_ord_ore` — the concrete +//! ordered variant (equality + ORE-block ordering). +//! +//! Consumes `tests/sqlx/migrations/009_install_encrypted_int4_fixture.sql` +//! (table `encrypted_int4_plaintext`, column `payload JSONB NOT NULL`). +//! Each row pairs a plaintext integer with its encrypted JSONB payload +//! carrying `c`, `hm`, `ob` terms. +//! +//! Value set: { -100, -1, 1, 2, 5, 10, 17, 25, 42, 50, 100, 250, 1000, 9999 } +//! 14 rows. Range pivots produce distinct cardinalities so swapped +//! operators would fail the assertions, not silently pass. +//! +//! Equality and range both route through `eql_v2.ord_term`: `col $1` +//! inlines to `eql_v2.ord_term(col) eql_v2.ord_term($1)`, the operator on +//! `eql_v2.ore_block_u64_8_256`. A single functional btree +//! `USING btree (eql_v2.ord_term(col))` serves all six operators — there is +//! no operator class on the domain. `ORDER BY eql_v2.ord_term(col)` sorts in +//! plaintext numeric order. Equality routes through the `ob` term +//! (lossless ORE on full-domain int4 = exact equality); there is no +//! `hm` term on the ordered variants (D#1). +//! +//! Most tests cast `payload::eql_v2_int4_ord_ore` per-query so the +//! fixture table itself stays JSONB-shaped. + +use anyhow::Result; +use sqlx::PgPool; + +/// Pull plaintext column out of fixture rows whose payload satisfies a +/// predicate. The predicate is the SQL fragment that goes after `WHERE`. +async fn plaintexts_matching(pool: &PgPool, predicate: &str) -> Result> { + let sql = format!( + "SELECT plaintext FROM encrypted_int4_plaintext WHERE {predicate} ORDER BY plaintext" + ); + let mut rows: Vec = sqlx::query_scalar(&sql).fetch_all(pool).await?; + rows.sort(); + Ok(rows) +} + +#[sqlx::test] +async fn encrypted_int4_equality_matches_self(pool: PgPool) -> Result<()> { + // For each fixture plaintext, looking up by `=` against that row's own + // payload must return exactly that plaintext. + for target in [-100, -1, 1, 42, 9999] { + let needle: String = sqlx::query_scalar( + "SELECT payload::text FROM encrypted_int4_plaintext WHERE plaintext = $1", + ) + .bind(target) + .fetch_one(&pool) + .await?; + + let matched: Vec = plaintexts_matching( + &pool, + &format!( + "payload::eql_v2_int4_ord_ore = '{}'::jsonb::eql_v2_int4_ord_ore", + needle.replace('\'', "''") + ), + ) + .await?; + assert_eq!(matched, vec![target], "= against payload of {target}"); + } + + Ok(()) +} + +#[sqlx::test] +async fn encrypted_int4_equality_cross_type_shapes(pool: PgPool) -> Result<()> { + // = in all three signature shapes against the payload of 42. + let needle: String = sqlx::query_scalar( + "SELECT payload::text FROM encrypted_int4_plaintext WHERE plaintext = 42", + ) + .fetch_one(&pool) + .await?; + let lit = needle.replace('\'', "''"); + + // (domain, domain) + let ids: Vec = plaintexts_matching( + &pool, + &format!("payload::eql_v2_int4_ord_ore = '{lit}'::jsonb::eql_v2_int4_ord_ore"), + ) + .await?; + assert_eq!(ids, vec![42], "(domain, domain) ="); + + // (domain, jsonb) + let ids: Vec = plaintexts_matching( + &pool, + &format!("payload::eql_v2_int4_ord_ore = '{lit}'::jsonb"), + ) + .await?; + assert_eq!(ids, vec![42], "(domain, jsonb) ="); + + // (jsonb, domain) — ORM bind shape + let ids: Vec = plaintexts_matching( + &pool, + &format!("'{lit}'::jsonb = payload::eql_v2_int4_ord_ore"), + ) + .await?; + assert_eq!(ids, vec![42], "(jsonb, domain) ="); + + Ok(()) +} + +#[sqlx::test] +async fn encrypted_int4_inequality_against_42(pool: PgPool) -> Result<()> { + let needle: String = sqlx::query_scalar( + "SELECT payload::text FROM encrypted_int4_plaintext WHERE plaintext = 42", + ) + .fetch_one(&pool) + .await?; + let lit = needle.replace('\'', "''"); + + let ids: Vec = plaintexts_matching( + &pool, + &format!("payload::eql_v2_int4_ord_ore <> '{lit}'::jsonb::eql_v2_int4_ord_ore"), + ) + .await?; + // 14 rows, exclude 42 → 13 remaining + let mut expected = vec![-100, -1, 1, 2, 5, 10, 17, 25, 50, 100, 250, 1000, 9999]; + expected.sort(); + assert_eq!(ids, expected, "<> against 42 should exclude only 42"); + + // Reverse shape sweep + let ids: Vec = plaintexts_matching( + &pool, + &format!("'{lit}'::jsonb <> payload::eql_v2_int4_ord_ore"), + ) + .await?; + assert_eq!(ids, expected, "reverse-shape <> against 42"); + + Ok(()) +} + +#[sqlx::test] +async fn encrypted_int4_range_operators_match_numeric_semantics(pool: PgPool) -> Result<()> { + // Pivot value 10. Numeric ground truth for each range operator: + // < 10 → { -100, -1, 1, 2, 5 } (5 rows) + // <= 10 → { -100, -1, 1, 2, 5, 10 } (6 rows) + // > 10 → { 17, 25, 42, 50, 100, 250, 1000, 9999 } (8 rows) + // >= 10 → { 10, 17, 25, 42, 50, 100, 250, 1000, 9999 } (9 rows) + let pivot: String = sqlx::query_scalar( + "SELECT payload::text FROM encrypted_int4_plaintext WHERE plaintext = 10", + ) + .fetch_one(&pool) + .await?; + let lit = pivot.replace('\'', "''"); + + let cases: &[(&str, Vec)] = &[ + ("<", vec![-100, -1, 1, 2, 5]), + ("<=", vec![-100, -1, 1, 2, 5, 10]), + (">", vec![17, 25, 42, 50, 100, 250, 1000, 9999]), + (">=", vec![10, 17, 25, 42, 50, 100, 250, 1000, 9999]), + ]; + + for (op, expected) in cases { + let mut expected_sorted = expected.clone(); + expected_sorted.sort(); + + // Forward shapes — value on the LHS. + for rhs in ["'{LIT}'::jsonb::eql_v2_int4_ord_ore", "'{LIT}'::jsonb"] { + let rhs_sql = rhs.replace("{LIT}", &lit); + let predicate = format!("payload::eql_v2_int4_ord_ore {op} {rhs_sql}"); + let ids = plaintexts_matching(&pool, &predicate).await?; + assert_eq!( + ids, expected_sorted, + "forward {op} with rhs {rhs}: predicate={predicate}" + ); + } + + // Reverse shape — pivot on the LHS inverts the expected set. + // Forward `value < 10` → rows where value < 10 + // Reverse `10 < value` → rows where value > 10 → "opposite" op's set + let reverse_expected: Vec = match *op { + "<" => vec![17, 25, 42, 50, 100, 250, 1000, 9999], // > + "<=" => vec![10, 17, 25, 42, 50, 100, 250, 1000, 9999], // >= + ">" => vec![-100, -1, 1, 2, 5], // < + ">=" => vec![-100, -1, 1, 2, 5, 10], // <= + _ => unreachable!(), + }; + let mut reverse_sorted = reverse_expected.clone(); + reverse_sorted.sort(); + let predicate = format!("'{lit}'::jsonb {op} payload::eql_v2_int4_ord_ore"); + let ids = plaintexts_matching(&pool, &predicate).await?; + assert_eq!( + ids, reverse_sorted, + "reverse {op} (pivot {op} value): predicate={predicate}" + ); + } + + Ok(()) +} + +#[sqlx::test] +async fn encrypted_int4_ore_ordering_matches_numeric_ordering(pool: PgPool) -> Result<()> { + // Critical invariant: ORE bytes from Proxy must preserve numeric order. + // Pulling all 14 rows ordered by eql_v2.ord_term — the uniform ordered-int4 + // index/ORDER BY extractor — must yield the plaintext sequence in + // ascending numeric order. A bug in Proxy's ORE-block encoding (sign + // handling, byte-order, padding) would fail this without throwing. + // + // ORDER BY eql_v2.ord_term(payload::eql_v2_int4_ord_ore) pins the sort to + // the ORE-block term; sorting the domain column directly would follow + // native jsonb comparison, not ORE order. + let ordered: Vec = sqlx::query_scalar( + r#" + SELECT plaintext + FROM encrypted_int4_plaintext + ORDER BY eql_v2.ord_term(payload::eql_v2_int4_ord_ore) + "#, + ) + .fetch_all(&pool) + .await?; + + let expected = vec![-100, -1, 1, 2, 5, 10, 17, 25, 42, 50, 100, 250, 1000, 9999]; + assert_eq!( + ordered, expected, + "eql_v2.ord_term ordering must match numeric ordering of plaintext" + ); + + Ok(()) +} + +#[sqlx::test] +async fn encrypted_int4_ord_distinctness_sweep(pool: PgPool) -> Result<()> { + // Pairwise: no two distinct integer plaintexts share an ORE term. + // Equality routes through eql_v2.ord_term (the `ob` term), not HMAC — + // 14 distinct ints → 14 distinct ORE terms → no `=` collisions. + let collisions: i64 = sqlx::query_scalar( + r#" + SELECT count(*) + FROM encrypted_int4_plaintext a + JOIN encrypted_int4_plaintext b ON a.id < b.id + WHERE a.payload::eql_v2_int4_ord_ore = b.payload::eql_v2_int4_ord_ore + "#, + ) + .fetch_one(&pool) + .await?; + assert_eq!( + collisions, 0, + "no two distinct integer plaintexts may share an ORE term" + ); + + Ok(()) +} + +#[sqlx::test] +async fn encrypted_int4_ord_ore_functional_index_serves_range_and_equality( + pool: PgPool, +) -> Result<()> { + // Range + equality on eql_v2_int4_ord_ore are served by one + // functional btree USING btree (eql_v2.ord_term(col)). eql_v2.ord_term + // returns eql_v2.ore_block_u64_8_256, which carries main's DEFAULT + // btree operator class — no opclass annotation needed. + let mut tx = pool.begin().await?; + sqlx::query( + "CREATE TEMP TABLE ord_ore_fi (\ + plaintext integer, \ + value eql_v2_int4_ord_ore\ + ) ON COMMIT DROP", + ) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO ord_ore_fi(plaintext, value) \ + SELECT plaintext, payload::eql_v2_int4_ord_ore FROM encrypted_int4_plaintext", + ) + .execute(&mut *tx) + .await?; + sqlx::query("CREATE INDEX ord_ore_fi_idx ON ord_ore_fi USING btree (eql_v2.ord_term(value))") + .execute(&mut *tx) + .await?; + sqlx::query("ANALYZE ord_ore_fi").execute(&mut *tx).await?; + sqlx::query("SET LOCAL enable_seqscan = off") + .execute(&mut *tx) + .await?; + + let pivot: String = sqlx::query_scalar( + "SELECT payload::text FROM encrypted_int4_plaintext WHERE plaintext = 10", + ) + .fetch_one(&mut *tx) + .await?; + let lit = pivot.replace('\'', "''"); + + // Engagement: =, <, <=, >, >= each engage the functional btree. + for op in ["=", "<", "<=", ">", ">="] { + let plan: Vec = sqlx::query_scalar(&format!( + "EXPLAIN SELECT * FROM ord_ore_fi \ + WHERE value {op} '{lit}'::jsonb::eql_v2_int4_ord_ore" + )) + .fetch_all(&mut *tx) + .await?; + let plan_text = plan.join("\n"); + assert!( + plan_text.contains("ord_ore_fi_idx"), + "{op} must engage the eql_v2.ord_term functional btree; plan:\n{plan_text}" + ); + } + + // Correctness via the index: numeric ground truth against pivot 10. + let cases: &[(&str, Vec)] = &[ + ("=", vec![10]), + ("<", vec![-100, -1, 1, 2, 5]), + ("<=", vec![-100, -1, 1, 2, 5, 10]), + (">", vec![17, 25, 42, 50, 100, 250, 1000, 9999]), + (">=", vec![10, 17, 25, 42, 50, 100, 250, 1000, 9999]), + ]; + for (op, expected) in cases { + let mut ids: Vec = sqlx::query_scalar(&format!( + "SELECT plaintext FROM ord_ore_fi \ + WHERE value {op} '{lit}'::jsonb::eql_v2_int4_ord_ore" + )) + .fetch_all(&mut *tx) + .await?; + ids.sort(); + let mut want = expected.clone(); + want.sort(); + assert_eq!( + ids, want, + "{op} via functional index must match ground truth" + ); + } + + tx.commit().await?; + Ok(()) +} + +#[sqlx::test] +async fn encrypted_int4_ord_ore_unsupported_operators_raise(pool: PgPool) -> Result<()> { + // The _ord_ore variant supports equality + ORE range. Every other + // operator must raise the variant-specific blocker error rather than + // fall through to native jsonb semantics. + // + // We use the fixture payload of 42 (any row would work) cast to the + // domain to exercise the (domain, domain) shape, then sweep the other + // two declared shapes. + let payload: String = sqlx::query_scalar( + "SELECT payload::text FROM encrypted_int4_plaintext WHERE plaintext = 42", + ) + .fetch_one(&pool) + .await?; + let lit = payload.replace('\'', "''"); + + let shapes: &[(&str, &str)] = &[ + ( + "$1::jsonb::eql_v2_int4_ord_ore", + "$2::jsonb::eql_v2_int4_ord_ore", + ), + ("$1::jsonb::eql_v2_int4_ord_ore", "$2::jsonb"), + ("$1::jsonb", "$2::jsonb::eql_v2_int4_ord_ore"), + ]; + + for op in ["@>", "<@"] { + for (lhs, rhs) in shapes { + let sql = format!("SELECT {lhs} {op} {rhs}"); + let err = sqlx::query(&sql) + .bind(&payload) + .bind(&payload) + .fetch_one(&pool) + .await + .expect_err(&format!("eql_v2_int4_ord_ore {op} must raise: {sql}")) + .to_string(); + let expected = format!("operator {op} is not supported for eql_v2_int4_ord_ore"); + assert!( + err.contains(&expected), + "blocker error mismatch: {sql} -> {err}" + ); + } + } + + // Path operators across all three asymmetric shapes. + for op in ["->", "->>"] { + for sql in [ + format!( + "SELECT '{}'::jsonb::eql_v2_int4_ord_ore {op} 'field'::text", + lit + ), + format!( + "SELECT '{}'::jsonb::eql_v2_int4_ord_ore {op} 0::integer", + lit + ), + format!( + "SELECT '{}'::jsonb {op} '{}'::jsonb::eql_v2_int4_ord_ore", + lit, lit + ), + ] { + let err = sqlx::query(&sql) + .fetch_one(&pool) + .await + .expect_err(&format!("eql_v2_int4_ord_ore {op} must raise: {sql}")) + .to_string(); + let expected = format!("operator {op} is not supported for eql_v2_int4_ord_ore"); + assert!( + err.contains(&expected), + "path-op blocker error mismatch: {sql} -> {err}" + ); + } + } + + Ok(()) +} + +#[sqlx::test] +async fn encrypted_int4_ord_ore_blocked_operators_raise_on_null_input(pool: PgPool) -> Result<()> { + // A blocker declared STRICT lets PostgreSQL skip the body and return + // NULL on a NULL argument, silently bypassing the + // "operator … is not supported" exception. The blocker contract is + // "always raises" — guard against STRICT regressing back in. + let null: Option<&str> = None; + + let err = + sqlx::query("SELECT $1::jsonb::eql_v2_int4_ord_ore @> $2::jsonb::eql_v2_int4_ord_ore") + .bind(null) + .bind(null) + .fetch_one(&pool) + .await + .expect_err("eql_v2_int4_ord_ore @> must raise on NULL input") + .to_string(); + assert!( + err.contains("operator @> is not supported for eql_v2_int4_ord_ore"), + "unexpected error for @> on NULL: {err}" + ); + + let err = sqlx::query("SELECT $1::jsonb -> $2::jsonb::eql_v2_int4_ord_ore") + .bind(null) + .bind(null) + .fetch_one(&pool) + .await + .expect_err("eql_v2_int4_ord_ore -> must raise on NULL input") + .to_string(); + assert!( + err.contains("operator -> is not supported for eql_v2_int4_ord_ore"), + "unexpected error for -> on NULL: {err}" + ); + Ok(()) +} + +#[sqlx::test] +async fn encrypted_int4_ord_ore_null_operand_yields_null(pool: PgPool) -> Result<()> { + // STRICT comparison wrappers: a NULL operand propagates NULL + // (standard SQL three-valued logic), not an error and not a match. + let payload: String = sqlx::query_scalar( + "SELECT payload::text FROM encrypted_int4_plaintext WHERE plaintext = 42", + ) + .fetch_one(&pool) + .await?; + let null: Option<&str> = None; + + for op in ["=", "<>", "<", "<=", ">", ">="] { + let result: Option = sqlx::query_scalar(&format!( + "SELECT $1::jsonb::eql_v2_int4_ord_ore {op} $2::jsonb::eql_v2_int4_ord_ore" + )) + .bind(&payload) + .bind(null) + .fetch_one(&pool) + .await?; + assert!( + result.is_none(), + "{op} with a NULL operand must yield NULL, got {result:?}" + ); + } + Ok(()) +} + +#[sqlx::test] +async fn encrypted_int4_ord_ore_equality_uses_ob_not_hm(pool: PgPool) -> Result<()> { + // D#1: ordered variants carry c + ob and drop hm. Equality routes + // through eql_v2.ord_term (the `ob` term), never HMAC. Strip `hm` from + // every payload: with no hm present, an accidental regression to + // HMAC equality fails instead of silently passing on the fixture. + let mut tx = pool.begin().await?; + sqlx::query( + "CREATE TEMP TABLE ord_ore_no_hm (\ + plaintext integer, value eql_v2_int4_ord_ore\ + ) ON COMMIT DROP", + ) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO ord_ore_no_hm(plaintext, value) \ + SELECT plaintext, (payload - 'hm')::eql_v2_int4_ord_ore \ + FROM encrypted_int4_plaintext", + ) + .execute(&mut *tx) + .await?; + // Sanity: no row carries `hm` (jsonb_exists is the function form of + // the `?` key-exists operator — avoids `?` in the SQLx query string). + let with_hm: i64 = sqlx::query_scalar( + "SELECT count(*) FROM ord_ore_no_hm WHERE jsonb_exists(value::jsonb, 'hm')", + ) + .fetch_one(&mut *tx) + .await?; + assert_eq!(with_hm, 0, "test rows must not carry hm"); + + sqlx::query( + "CREATE INDEX ord_ore_no_hm_idx ON ord_ore_no_hm USING btree (eql_v2.ord_term(value))", + ) + .execute(&mut *tx) + .await?; + sqlx::query("ANALYZE ord_ore_no_hm") + .execute(&mut *tx) + .await?; + sqlx::query("SET LOCAL enable_seqscan = off") + .execute(&mut *tx) + .await?; + + let pivot: String = sqlx::query_scalar( + "SELECT (payload - 'hm')::text FROM encrypted_int4_plaintext WHERE plaintext = 42", + ) + .fetch_one(&mut *tx) + .await?; + let lit = pivot.replace('\'', "''"); + + // Equality + inequality return correct rows with no hm present. + let eq: Vec = sqlx::query_scalar(&format!( + "SELECT plaintext FROM ord_ore_no_hm \ + WHERE value = '{lit}'::jsonb::eql_v2_int4_ord_ore" + )) + .fetch_all(&mut *tx) + .await?; + assert_eq!(eq, vec![42], "= must match via ob with no hm present"); + + let neq_count: i64 = sqlx::query_scalar(&format!( + "SELECT count(*) FROM ord_ore_no_hm \ + WHERE value <> '{lit}'::jsonb::eql_v2_int4_ord_ore" + )) + .fetch_one(&mut *tx) + .await?; + assert_eq!(neq_count, 13, "<> must match the other 13 rows"); + + // The functional btree still engages for equality with no hm. + let plan: Vec = sqlx::query_scalar(&format!( + "EXPLAIN SELECT * FROM ord_ore_no_hm \ + WHERE value = '{lit}'::jsonb::eql_v2_int4_ord_ore" + )) + .fetch_all(&mut *tx) + .await?; + assert!( + plan.join("\n").contains("ord_ore_no_hm_idx"), + "= must engage the eql_v2.ord_term functional btree with no hm present" + ); + + tx.commit().await?; + Ok(()) +} + +#[sqlx::test] +async fn ord_ore_functional_index_serves_constant_on_left(pool: PgPool) -> Result<()> { + // The functional btree on eql_v2.ord_term(col) must engage when the + // literal is on the LEFT (`$1 < col`) — the commuted shape — for an + // eql_v2_int4_ord_ore column, in both the (domain, domain) and + // (jsonb, domain) operator forms. + let mut tx = pool.begin().await?; + sqlx::query( + "CREATE TEMP TABLE ord_ore_cl (\ + plaintext integer, value eql_v2_int4_ord_ore\ + ) ON COMMIT DROP", + ) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO ord_ore_cl(plaintext, value) \ + SELECT plaintext, payload::eql_v2_int4_ord_ore FROM encrypted_int4_plaintext", + ) + .execute(&mut *tx) + .await?; + sqlx::query("CREATE INDEX ord_ore_cl_idx ON ord_ore_cl USING btree (eql_v2.ord_term(value))") + .execute(&mut *tx) + .await?; + sqlx::query("ANALYZE ord_ore_cl").execute(&mut *tx).await?; + sqlx::query("SET LOCAL enable_seqscan = off") + .execute(&mut *tx) + .await?; + + let pivot: String = sqlx::query_scalar( + "SELECT payload::text FROM encrypted_int4_plaintext WHERE plaintext = 10", + ) + .fetch_one(&mut *tx) + .await?; + let lit = pivot.replace('\'', "''"); + + // Pivot 10 on the LEFT — the expected set is the commuted operator's + // ground truth (`10 < value` selects rows where value > 10). + let cases: &[(&str, Vec)] = &[ + ("=", vec![10]), + ("<", vec![17, 25, 42, 50, 100, 250, 1000, 9999]), + ("<=", vec![10, 17, 25, 42, 50, 100, 250, 1000, 9999]), + (">", vec![-100, -1, 1, 2, 5]), + (">=", vec![-100, -1, 1, 2, 5, 10]), + ]; + for (op, expected) in cases { + for rhs_cast in ["::eql_v2_int4_ord_ore", ""] { + let predicate = format!("'{lit}'::jsonb{rhs_cast} {op} value"); + let plan: Vec = sqlx::query_scalar(&format!( + "EXPLAIN SELECT * FROM ord_ore_cl WHERE {predicate}" + )) + .fetch_all(&mut *tx) + .await?; + let plan_text = plan.join("\n"); + assert!( + plan_text.contains("ord_ore_cl_idx"), + "constant-on-left {op} must engage the functional btree; \ + predicate={predicate}\nplan:\n{plan_text}" + ); + + let mut ids: Vec = sqlx::query_scalar(&format!( + "SELECT plaintext FROM ord_ore_cl WHERE {predicate}" + )) + .fetch_all(&mut *tx) + .await?; + ids.sort(); + let mut want = expected.clone(); + want.sort(); + assert_eq!( + ids, want, + "constant-on-left {op} must match commuted ground truth; \ + predicate={predicate}" + ); + } + } + + tx.commit().await?; + Ok(()) +} + +#[sqlx::test] +async fn ord_ore_rejects_payload_missing_required_keys(pool: PgPool) -> Result<()> { + // The eql_v2_int4_ord_ore domain CHECK requires v, i, c, ob. A + // payload missing any required key is rejected at the cast. + for (label, json) in [ + ("missing ob", r#"{"v":2,"i":{"t":"t","c":"c"},"c":"x"}"#), + ("missing c", r#"{"v":2,"i":{"t":"t","c":"c"},"ob":["aa"]}"#), + ] { + let err = sqlx::query(&format!("SELECT '{json}'::jsonb::eql_v2_int4_ord_ore")) + .fetch_one(&pool) + .await + .expect_err(&format!("eql_v2_int4_ord_ore must reject payload: {label}")) + .to_string(); + assert!( + err.contains("violates check constraint"), + "{label}: expected a check-constraint violation, got: {err}" + ); + } + Ok(()) +} diff --git a/tests/sqlx/tests/encrypted_int4_ord_tests.rs b/tests/sqlx/tests/encrypted_int4_ord_tests.rs new file mode 100644 index 00000000..746acdbc --- /dev/null +++ b/tests/sqlx/tests/encrypted_int4_ord_tests.rs @@ -0,0 +1,656 @@ +//! End-to-end test suite for `eql_v2_int4_ord` — the recommended +//! ordered domain name. +//! +//! eql_v2_int4_ord is a concrete ordered domain with its own operators +//! (D-E fallback): the §8 verification spike showed a domain-over-domain +//! alias does not transparently inherit the operator surface. This suite +//! asserts eql_v2_int4_ord behaves correctly on a real column typed +//! eql_v2_int4_ord — operator routing to EQL ORE semantics, blocked +//! operators raising rather than falling through to native jsonb, and +//! functional-index engagement. eql_v2_int4_ord_ore (the scheme-explicit +//! domain) carries the identical operator surface. + +use std::path::PathBuf; + +use anyhow::Result; +use sqlx::PgPool; + +#[sqlx::test] +async fn ord_six_operators_resolve_to_ore_semantics(pool: PgPool) -> Result<()> { + // On a column typed eql_v2_int4_ord, every operator must resolve to + // EQL ORE semantics (numeric ground truth), not native jsonb + // comparison. Pivot is the payload of plaintext 10. + let mut tx = pool.begin().await?; + sqlx::query( + "CREATE TEMP TABLE ord_t (plaintext integer, value eql_v2_int4_ord) ON COMMIT DROP", + ) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO ord_t(plaintext, value) \ + SELECT plaintext, payload::eql_v2_int4_ord FROM encrypted_int4_plaintext", + ) + .execute(&mut *tx) + .await?; + + let pivot: String = sqlx::query_scalar( + "SELECT payload::text FROM encrypted_int4_plaintext WHERE plaintext = 10", + ) + .fetch_one(&mut *tx) + .await?; + let lit = pivot.replace('\'', "''"); + + let cases: &[(&str, Vec)] = &[ + ("=", vec![10]), + ( + "<>", + vec![-100, -1, 1, 2, 5, 17, 25, 42, 50, 100, 250, 1000, 9999], + ), + ("<", vec![-100, -1, 1, 2, 5]), + ("<=", vec![-100, -1, 1, 2, 5, 10]), + (">", vec![17, 25, 42, 50, 100, 250, 1000, 9999]), + (">=", vec![10, 17, 25, 42, 50, 100, 250, 1000, 9999]), + ]; + for (op, expected) in cases { + let mut ids: Vec = sqlx::query_scalar(&format!( + "SELECT plaintext FROM ord_t \ + WHERE value {op} '{lit}'::jsonb::eql_v2_int4_ord" + )) + .fetch_all(&mut *tx) + .await?; + ids.sort(); + let mut want = expected.clone(); + want.sort(); + assert_eq!( + ids, want, + "{op} on eql_v2_int4_ord must match ORE ground truth" + ); + } + + tx.commit().await?; + Ok(()) +} + +#[sqlx::test] +async fn ord_blocked_operators_raise(pool: PgPool) -> Result<()> { + // Blocked operators on eql_v2_int4_ord must raise, never fall through + // to native jsonb @>/<@/->/->>. The error names the concrete domain + // the blocker is defined on; assert only "is not supported" + the + // operator symbol so the test is robust to the exact type name. + let payload: String = sqlx::query_scalar( + "SELECT payload::text FROM encrypted_int4_plaintext WHERE plaintext = 42", + ) + .fetch_one(&pool) + .await?; + + let shapes: &[(&str, &str)] = &[ + ("$1::jsonb::eql_v2_int4_ord", "$2::jsonb::eql_v2_int4_ord"), + ("$1::jsonb::eql_v2_int4_ord", "$2::jsonb"), + ("$1::jsonb", "$2::jsonb::eql_v2_int4_ord"), + ]; + for op in ["@>", "<@"] { + for (lhs, rhs) in shapes { + let sql = format!("SELECT {lhs} {op} {rhs}"); + let err = sqlx::query(&sql) + .bind(&payload) + .bind(&payload) + .fetch_one(&pool) + .await + .expect_err(&format!("eql_v2_int4_ord {op} must raise: {sql}")) + .to_string(); + assert!( + err.contains("is not supported") && err.contains(op), + "blocked {op} must raise 'not supported': {sql} -> {err}" + ); + } + } + + let lit = payload.replace('\'', "''"); + for op in ["->", "->>"] { + for sql in [ + format!("SELECT '{lit}'::jsonb::eql_v2_int4_ord {op} 'field'::text"), + format!("SELECT '{lit}'::jsonb::eql_v2_int4_ord {op} 0::integer"), + format!("SELECT '{lit}'::jsonb {op} '{lit}'::jsonb::eql_v2_int4_ord"), + ] { + let err = sqlx::query(&sql) + .fetch_one(&pool) + .await + .expect_err(&format!("eql_v2_int4_ord {op} must raise: {sql}")) + .to_string(); + assert!( + err.contains("is not supported") && err.contains(op), + "blocked {op} must raise 'not supported': {sql} -> {err}" + ); + } + } + Ok(()) +} + +#[sqlx::test] +async fn ord_functional_index_serves_range_and_equality(pool: PgPool) -> Result<()> { + // Range + equality on eql_v2_int4_ord are served by one functional + // btree USING btree (eql_v2.ord_term(col)). + let mut tx = pool.begin().await?; + sqlx::query( + "CREATE TEMP TABLE ord_fi (plaintext integer, value eql_v2_int4_ord) ON COMMIT DROP", + ) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO ord_fi(plaintext, value) \ + SELECT plaintext, payload::eql_v2_int4_ord FROM encrypted_int4_plaintext", + ) + .execute(&mut *tx) + .await?; + sqlx::query("CREATE INDEX ord_fi_idx ON ord_fi USING btree (eql_v2.ord_term(value))") + .execute(&mut *tx) + .await?; + sqlx::query("ANALYZE ord_fi").execute(&mut *tx).await?; + sqlx::query("SET LOCAL enable_seqscan = off") + .execute(&mut *tx) + .await?; + + let pivot: String = sqlx::query_scalar( + "SELECT payload::text FROM encrypted_int4_plaintext WHERE plaintext = 10", + ) + .fetch_one(&mut *tx) + .await?; + let lit = pivot.replace('\'', "''"); + + for op in ["=", "<", "<=", ">", ">="] { + let plan: Vec = sqlx::query_scalar(&format!( + "EXPLAIN SELECT * FROM ord_fi WHERE value {op} '{lit}'::jsonb::eql_v2_int4_ord" + )) + .fetch_all(&mut *tx) + .await?; + let plan_text = plan.join("\n"); + assert!( + plan_text.contains("ord_fi_idx"), + "{op} must engage the eql_v2.ord_term functional btree; plan:\n{plan_text}" + ); + } + + let cases: &[(&str, Vec)] = &[ + ("=", vec![10]), + ("<", vec![-100, -1, 1, 2, 5]), + ("<=", vec![-100, -1, 1, 2, 5, 10]), + (">", vec![17, 25, 42, 50, 100, 250, 1000, 9999]), + (">=", vec![10, 17, 25, 42, 50, 100, 250, 1000, 9999]), + ]; + for (op, expected) in cases { + let mut ids: Vec = sqlx::query_scalar(&format!( + "SELECT plaintext FROM ord_fi WHERE value {op} '{lit}'::jsonb::eql_v2_int4_ord" + )) + .fetch_all(&mut *tx) + .await?; + ids.sort(); + let mut want = expected.clone(); + want.sort(); + assert_eq!( + ids, want, + "{op} via functional index must match ground truth" + ); + } + + tx.commit().await?; + Ok(()) +} + +#[sqlx::test] +async fn ord_order_by_preserves_numeric_order(pool: PgPool) -> Result<()> { + // ORDER BY eql_v2.ord_term(col) sorts an eql_v2_int4_ord column in + // plaintext numeric order. + let mut tx = pool.begin().await?; + sqlx::query( + "CREATE TEMP TABLE ord_sort (plaintext integer, value eql_v2_int4_ord) ON COMMIT DROP", + ) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO ord_sort(plaintext, value) \ + SELECT plaintext, payload::eql_v2_int4_ord FROM encrypted_int4_plaintext", + ) + .execute(&mut *tx) + .await?; + let ordered: Vec = + sqlx::query_scalar("SELECT plaintext FROM ord_sort ORDER BY eql_v2.ord_term(value)") + .fetch_all(&mut *tx) + .await?; + assert_eq!( + ordered, + vec![-100, -1, 1, 2, 5, 10, 17, 25, 42, 50, 100, 250, 1000, 9999], + "ORDER BY eql_v2.ord_term(value) must yield plaintext numeric order" + ); + tx.commit().await?; + Ok(()) +} + +#[sqlx::test] +async fn ord_null_operand_yields_null(pool: PgPool) -> Result<()> { + // STRICT comparison wrappers: a NULL operand propagates NULL + // (standard SQL three-valued logic), not an error and not a match. + let payload: String = sqlx::query_scalar( + "SELECT payload::text FROM encrypted_int4_plaintext WHERE plaintext = 42", + ) + .fetch_one(&pool) + .await?; + let null: Option<&str> = None; + + for op in ["=", "<>", "<", "<=", ">", ">="] { + let result: Option = sqlx::query_scalar(&format!( + "SELECT $1::jsonb::eql_v2_int4_ord {op} $2::jsonb::eql_v2_int4_ord" + )) + .bind(&payload) + .bind(null) + .fetch_one(&pool) + .await?; + assert!( + result.is_none(), + "{op} with a NULL operand must yield NULL, got {result:?}" + ); + } + Ok(()) +} + +#[sqlx::test] +async fn ord_equality_independent_of_hm(pool: PgPool) -> Result<()> { + // D#1: ordered variants carry c + ob and drop hm. Equality on + // eql_v2_int4_ord routes through eql_v2.ord_term (the `ob` term), never + // HMAC. Strip `hm` so an accidental regression to HMAC equality + // fails instead of passing on the hm-carrying fixture. + let mut tx = pool.begin().await?; + sqlx::query( + "CREATE TEMP TABLE ord_no_hm (plaintext integer, value eql_v2_int4_ord) ON COMMIT DROP", + ) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO ord_no_hm(plaintext, value) \ + SELECT plaintext, (payload - 'hm')::eql_v2_int4_ord FROM encrypted_int4_plaintext", + ) + .execute(&mut *tx) + .await?; + // Sanity: no row carries `hm` (jsonb_exists is the function form of + // the `?` key-exists operator — avoids `?` in the SQLx query string). + let with_hm: i64 = + sqlx::query_scalar("SELECT count(*) FROM ord_no_hm WHERE jsonb_exists(value::jsonb, 'hm')") + .fetch_one(&mut *tx) + .await?; + assert_eq!(with_hm, 0, "test rows must not carry hm"); + + sqlx::query("CREATE INDEX ord_no_hm_idx ON ord_no_hm USING btree (eql_v2.ord_term(value))") + .execute(&mut *tx) + .await?; + sqlx::query("ANALYZE ord_no_hm").execute(&mut *tx).await?; + sqlx::query("SET LOCAL enable_seqscan = off") + .execute(&mut *tx) + .await?; + + let pivot: String = sqlx::query_scalar( + "SELECT (payload - 'hm')::text FROM encrypted_int4_plaintext WHERE plaintext = 42", + ) + .fetch_one(&mut *tx) + .await?; + let lit = pivot.replace('\'', "''"); + + let eq: Vec = sqlx::query_scalar(&format!( + "SELECT plaintext FROM ord_no_hm WHERE value = '{lit}'::jsonb::eql_v2_int4_ord" + )) + .fetch_all(&mut *tx) + .await?; + assert_eq!(eq, vec![42], "= must match via ob with no hm present"); + + let neq_count: i64 = sqlx::query_scalar(&format!( + "SELECT count(*) FROM ord_no_hm WHERE value <> '{lit}'::jsonb::eql_v2_int4_ord" + )) + .fetch_one(&mut *tx) + .await?; + assert_eq!(neq_count, 13, "<> must match the other 13 rows"); + + let plan: Vec = sqlx::query_scalar(&format!( + "EXPLAIN SELECT * FROM ord_no_hm WHERE value = '{lit}'::jsonb::eql_v2_int4_ord" + )) + .fetch_all(&mut *tx) + .await?; + assert!( + plan.join("\n").contains("ord_no_hm_idx"), + "= must engage the eql_v2.ord_term functional btree with no hm present" + ); + + tx.commit().await?; + Ok(()) +} + +#[sqlx::test] +async fn ord_ore_wrappers_are_inlinable(pool: PgPool) -> Result<()> { + // The comparison wrappers on eql_v2_int4_ord_ore and eql_v2_int4_ord + // must be LANGUAGE sql, IMMUTABLE, and carry no pinned search_path, + // so the planner inlines `col < $1` to + // `eql_v2.ord_term(col) < eql_v2.ord_term($1)` and the functional btree on + // eql_v2.ord_term(col) engages. A pinned proconfig or a plpgsql body + // would break the inline chain. + let rows: Vec<(String, String, String, Option>)> = sqlx::query_as( + r#" + SELECT p.proname, + l.lanname, + p.provolatile::text, + p.proconfig + FROM pg_catalog.pg_proc p + JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace + JOIN pg_catalog.pg_language l ON l.oid = p.prolang + JOIN pg_catalog.pg_type lt ON lt.oid = p.proargtypes[0] + JOIN pg_catalog.pg_type rt ON rt.oid = p.proargtypes[1] + WHERE n.nspname = 'eql_v2' + AND p.proname IN ('eq', 'neq', 'lt', 'lte', 'gt', 'gte') + AND (lt.typname IN ('eql_v2_int4_ord', 'eql_v2_int4_ord_ore') + OR rt.typname IN ('eql_v2_int4_ord', 'eql_v2_int4_ord_ore')) + "#, + ) + .fetch_all(&pool) + .await?; + + // 6 converged comparison wrappers (eq/neq/lt/lte/gt/gte) × 2 ordered + // domains (_ord_ore and the concrete _ord) × 3 arg-shapes = 36 rows. + assert_eq!( + rows.len(), + 36, + "expected 36 ordered comparison wrapper overloads" + ); + for (name, lang, volatile, config) in &rows { + assert_eq!(lang, "sql", "{name} must be LANGUAGE sql to inline"); + assert_eq!(volatile, "i", "{name} must be IMMUTABLE"); + assert!( + config.is_none(), + "{name} must have no pinned search_path (proconfig)" + ); + } + + // eql_v2.ord_term must be IMMUTABLE (functional-index requirement) in + // every spike outcome. The spike (Task 2) fixed its LANGUAGE as sql, + // so a LANGUAGE sql eql_v2.ord_term must additionally have no proconfig + // (it must inline); a LANGUAGE plpgsql ord is exempt from that check. + let ord: Vec<(String, String, Option>)> = sqlx::query_as( + r#" + SELECT l.lanname, p.provolatile::text, p.proconfig + FROM pg_catalog.pg_proc p + JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace + JOIN pg_catalog.pg_language l ON l.oid = p.prolang + WHERE n.nspname = 'eql_v2' AND p.proname = 'ord_term' + "#, + ) + .fetch_all(&pool) + .await?; + assert!(!ord.is_empty(), "eql_v2.ord_term must exist"); + for (lang, volatile, config) in &ord { + assert_eq!(volatile, "i", "eql_v2.ord_term must be IMMUTABLE"); + if lang == "sql" { + assert!( + config.is_none(), + "a LANGUAGE sql eql_v2.ord_term must have no pinned search_path so it inlines" + ); + } + } + Ok(()) +} + +/// Structural-sync guard for the two ordered int4 domain file pairs. +/// +/// The `_ord_ore` variant (scheme-explicit) and the `_ord` variant (the +/// D-E fallback concrete domain) are deliberate twins: the same +/// `eql_v2.ord_term` extractor, the 18 comparison wrappers, the blockers, and +/// the operator declarations, differing only by the +/// `eql_v2_int4_ord_ore` <-> `eql_v2_int4_ord` type-name swap. A full +/// de-duplication refactor is out of scope for this branch, so this test +/// pins the invariant cheaply: after normalising both type names to a +/// common token, the executable body of each file (from the first +/// declaration onward — the file-header doc comments are intentionally +/// different and excluded) must be byte-identical between the twins. An +/// edit to one file that is not mirrored into the other fails here. +/// +/// The split keeps comparison/path functions and operator declarations +/// in separate `_functions.sql` / `_operators.sql` files, so both pairs +/// are checked: `int4_ord_functions.sql` <-> `int4_ord_ore_functions.sql` +/// and `int4_ord_operators.sql` <-> `int4_ord_ore_operators.sql`. +/// +/// This is a source-only test; it does not touch the database. +#[test] +fn ordered_int4_domain_files_stay_in_sync() { + fn body(rel: &str, marker: &str) -> String { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../src/encrypted_domain/int4") + .join(rel); + let text = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("failed to read {}: {}", path.display(), e)); + // The header doc-comments differ by design; compare only the + // executable body, which starts at the given marker. + let start = text + .find(marker) + .unwrap_or_else(|| panic!("{} is missing the marker {:?}", path.display(), marker)); + // Normalise the two domain type names to one token. Replace the + // longer name first so `eql_v2_int4_ord` does not partially match + // inside `eql_v2_int4_ord_ore`. + text[start..] + .replace("eql_v2_int4_ord_ore", "ORDTYPE") + .replace("eql_v2_int4_ord", "ORDTYPE") + } + + // Functions: executable body starts at the eql_v2.ord_term extractor. + assert_eq!( + body( + "int4_ord_ore_functions.sql", + "--! @brief Index/ORDER BY extractor" + ), + body( + "int4_ord_functions.sql", + "--! @brief Index/ORDER BY extractor" + ), + "int4_ord_ore_functions.sql and int4_ord_functions.sql have \ + drifted apart. They must stay mechanical twins (type-name swap \ + only) below the file header; mirror every change into both files." + ); + + // Operators: executable body starts at the operator declarations. + assert_eq!( + body("int4_ord_ore_operators.sql", "-- Operator declarations"), + body("int4_ord_operators.sql", "-- Operator declarations"), + "int4_ord_ore_operators.sql and int4_ord_operators.sql have \ + drifted apart. They must stay mechanical twins (type-name swap \ + only) below the file header; mirror every change into both files." + ); +} + +#[sqlx::test] +async fn ord_functional_index_serves_constant_on_left(pool: PgPool) -> Result<()> { + // The functional btree on eql_v2.ord_term(col) must engage when the + // literal is on the LEFT (`$1 < col`) — the commuted shape — for + // both the (domain, domain) and (jsonb, domain) operator forms. + // `$1 < col` resolves through COMMUTATOR to `col > $1` for index + // matching. + let mut tx = pool.begin().await?; + sqlx::query( + "CREATE TEMP TABLE ord_cl (plaintext integer, value eql_v2_int4_ord) ON COMMIT DROP", + ) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO ord_cl(plaintext, value) \ + SELECT plaintext, payload::eql_v2_int4_ord FROM encrypted_int4_plaintext", + ) + .execute(&mut *tx) + .await?; + sqlx::query("CREATE INDEX ord_cl_idx ON ord_cl USING btree (eql_v2.ord_term(value))") + .execute(&mut *tx) + .await?; + sqlx::query("ANALYZE ord_cl").execute(&mut *tx).await?; + sqlx::query("SET LOCAL enable_seqscan = off") + .execute(&mut *tx) + .await?; + + let pivot: String = sqlx::query_scalar( + "SELECT payload::text FROM encrypted_int4_plaintext WHERE plaintext = 10", + ) + .fetch_one(&mut *tx) + .await?; + let lit = pivot.replace('\'', "''"); + + // Pivot 10 on the LEFT — the expected set is the commuted operator's + // ground truth (`10 < value` selects rows where value > 10). + let cases: &[(&str, Vec)] = &[ + ("=", vec![10]), + ("<", vec![17, 25, 42, 50, 100, 250, 1000, 9999]), + ("<=", vec![10, 17, 25, 42, 50, 100, 250, 1000, 9999]), + (">", vec![-100, -1, 1, 2, 5]), + (">=", vec![-100, -1, 1, 2, 5, 10]), + ]; + for (op, expected) in cases { + for rhs_cast in ["::eql_v2_int4_ord", ""] { + let predicate = format!("'{lit}'::jsonb{rhs_cast} {op} value"); + let plan: Vec = + sqlx::query_scalar(&format!("EXPLAIN SELECT * FROM ord_cl WHERE {predicate}")) + .fetch_all(&mut *tx) + .await?; + let plan_text = plan.join("\n"); + assert!( + plan_text.contains("ord_cl_idx"), + "constant-on-left {op} must engage the functional btree; \ + predicate={predicate}\nplan:\n{plan_text}" + ); + + let mut ids: Vec = + sqlx::query_scalar(&format!("SELECT plaintext FROM ord_cl WHERE {predicate}")) + .fetch_all(&mut *tx) + .await?; + ids.sort(); + let mut want = expected.clone(); + want.sort(); + assert_eq!( + ids, want, + "constant-on-left {op} must match commuted ground truth; \ + predicate={predicate}" + ); + } + } + + tx.commit().await?; + Ok(()) +} + +#[sqlx::test] +async fn ord_operators_declare_planner_metadata(pool: PgPool) -> Result<()> { + // The real comparison operators on the ordered int4 domains + // (eql_v2_int4_ord and eql_v2_int4_ord_ore) must declare COMMUTATOR, + // NEGATOR, and selectivity estimators (RESTRICT / JOIN) on all three + // arg-shapes, so the planner can normalise and cost commuted and + // negated predicates. + let rows: Vec<(String, String, String, bool, bool, bool, bool)> = sqlx::query_as( + r#" + SELECT o.oprname, + lt.typname AS lhs, + rt.typname AS rhs, + o.oprcom <> 0 AS has_commutator, + o.oprnegate <> 0 AS has_negator, + o.oprrest::oid <> 0 AS has_restrict, + o.oprjoin::oid <> 0 AS has_join + FROM pg_catalog.pg_operator o + JOIN pg_catalog.pg_type lt ON lt.oid = o.oprleft + JOIN pg_catalog.pg_type rt ON rt.oid = o.oprright + WHERE o.oprname IN ('=', '<>', '<', '<=', '>', '>=') + AND (lt.typname IN ('eql_v2_int4_ord', 'eql_v2_int4_ord_ore') + OR rt.typname IN ('eql_v2_int4_ord', 'eql_v2_int4_ord_ore')) + "#, + ) + .fetch_all(&pool) + .await?; + + // 6 operators x 3 arg-shapes x 2 ordered domains = 36 rows. + assert_eq!( + rows.len(), + 36, + "expected 6 operators x 3 arg-shapes x 2 ordered domains" + ); + for (op, lhs, rhs, has_com, has_neg, has_rest, has_join) in &rows { + assert!( + has_com, + "operator {op}({lhs},{rhs}) must declare COMMUTATOR" + ); + assert!(has_neg, "operator {op}({lhs},{rhs}) must declare NEGATOR"); + assert!(has_rest, "operator {op}({lhs},{rhs}) must declare RESTRICT"); + assert!(has_join, "operator {op}({lhs},{rhs}) must declare JOIN"); + } + Ok(()) +} + +#[sqlx::test] +async fn ord_functional_index_preferred_at_scale(pool: PgPool) -> Result<()> { + // The other EXPLAIN tests force `enable_seqscan = off`, proving the + // index is *usable*. This test proves the planner *prefers* it: at + // ~5000 rows with a highly selective `=` predicate, the functional + // btree must be chosen with seqscan left enabled. + let mut tx = pool.begin().await?; + sqlx::query("CREATE TEMP TABLE ord_scale (value eql_v2_int4_ord) ON COMMIT DROP") + .execute(&mut *tx) + .await?; + + let filler: String = sqlx::query_scalar( + "SELECT payload::text FROM encrypted_int4_plaintext WHERE plaintext = 5", + ) + .fetch_one(&mut *tx) + .await?; + let pivot: String = sqlx::query_scalar( + "SELECT payload::text FROM encrypted_int4_plaintext WHERE plaintext = 42", + ) + .fetch_one(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO ord_scale(value) \ + SELECT $1::jsonb::eql_v2_int4_ord FROM generate_series(1, 5000)", + ) + .bind(&filler) + .execute(&mut *tx) + .await?; + sqlx::query("INSERT INTO ord_scale(value) VALUES ($1::jsonb::eql_v2_int4_ord)") + .bind(&pivot) + .execute(&mut *tx) + .await?; + sqlx::query("CREATE INDEX ord_scale_idx ON ord_scale USING btree (eql_v2.ord_term(value))") + .execute(&mut *tx) + .await?; + sqlx::query("ANALYZE ord_scale").execute(&mut *tx).await?; + + let lit = pivot.replace('\'', "''"); + let plan: Vec = sqlx::query_scalar(&format!( + "EXPLAIN SELECT * FROM ord_scale WHERE value = '{lit}'::jsonb::eql_v2_int4_ord" + )) + .fetch_all(&mut *tx) + .await?; + let plan_text = plan.join("\n"); + assert!( + plan_text.contains("ord_scale_idx"), + "with seqscan enabled the planner must prefer the eql_v2.ord_term \ + btree for a selective = ; plan:\n{plan_text}" + ); + + tx.commit().await?; + Ok(()) +} + +#[sqlx::test] +async fn ord_rejects_payload_missing_required_keys(pool: PgPool) -> Result<()> { + // The eql_v2_int4_ord domain CHECK requires v, i, c, ob. A payload + // missing any required key is rejected at the cast. + for (label, json) in [ + ("missing ob", r#"{"v":2,"i":{"t":"t","c":"c"},"c":"x"}"#), + ("missing c", r#"{"v":2,"i":{"t":"t","c":"c"},"ob":["aa"]}"#), + ] { + let err = sqlx::query(&format!("SELECT '{json}'::jsonb::eql_v2_int4_ord")) + .fetch_one(&pool) + .await + .expect_err(&format!("eql_v2_int4_ord must reject payload: {label}")) + .to_string(); + assert!( + err.contains("violates check constraint"), + "{label}: expected a check-constraint violation, got: {err}" + ); + } + Ok(()) +} diff --git a/tests/sqlx/tests/encrypted_int4_tests.rs b/tests/sqlx/tests/encrypted_int4_tests.rs new file mode 100644 index 00000000..4eb3a212 --- /dev/null +++ b/tests/sqlx/tests/encrypted_int4_tests.rs @@ -0,0 +1,186 @@ +//! Synthetic test suite for `eql_v2_int4` — the storage-only variant. +//! +//! Every operator is a blocker that raises +//! `operator X is not supported for eql_v2_int4`. No fixture data is +//! needed; operator-on-literals is sufficient. + +use anyhow::Result; +use sqlx::PgPool; + +const SAMPLE_PAYLOAD: &str = r#"{"v":2,"i":{"t":"t","c":"c"},"c":"sample"}"#; + +#[sqlx::test] +async fn all_symmetric_operators_raise(pool: PgPool) -> Result<()> { + let shapes: &[(&str, &str)] = &[ + ("$1::jsonb::eql_v2_int4", "$2::jsonb::eql_v2_int4"), + ("$1::jsonb::eql_v2_int4", "$2::jsonb"), + ("$1::jsonb", "$2::jsonb::eql_v2_int4"), + ]; + + for op in ["=", "<>", "<", "<=", ">", ">=", "@>", "<@"] { + for (lhs, rhs) in shapes { + let sql = format!("SELECT {lhs} {op} {rhs}"); + let err = sqlx::query(&sql) + .bind(SAMPLE_PAYLOAD) + .bind(SAMPLE_PAYLOAD) + .fetch_one(&pool) + .await + .expect_err(&format!("eql_v2_int4 {op} must raise: {sql}")) + .to_string(); + let expected = format!("operator {op} is not supported for eql_v2_int4"); + assert!( + err.contains(&expected), + "unexpected error for {sql}: got {err}, want {expected}" + ); + } + } + Ok(()) +} + +#[sqlx::test] +async fn path_operators_raise(pool: PgPool) -> Result<()> { + for op in ["->", "->>"] { + for sql in [ + format!("SELECT $1::jsonb::eql_v2_int4 {op} 'field'::text"), + format!("SELECT $1::jsonb::eql_v2_int4 {op} 0::integer"), + format!("SELECT $1::jsonb {op} $1::jsonb::eql_v2_int4"), + ] { + let err = sqlx::query(&sql) + .bind(SAMPLE_PAYLOAD) + .fetch_one(&pool) + .await + .expect_err(&format!("eql_v2_int4 {op} must raise: {sql}")) + .to_string(); + let expected = format!("operator {op} is not supported for eql_v2_int4"); + assert!(err.contains(&expected), "unexpected error for {sql}: {err}"); + } + } + Ok(()) +} + +#[sqlx::test] +async fn like_operators_are_not_declared(pool: PgPool) -> Result<()> { + // EQL no longer declares ~~ / ~~* (LIKE / ILIKE) on the int4 domains — + // int4 has no pattern-match capability. With the operators removed, + // `col ~~ x` raises PostgreSQL's native "operator does not exist" + // rather than an EQL blocker message. Pin that they stay gone. + for op in ["~~", "~~*"] { + let sql = format!("SELECT $1::jsonb::eql_v2_int4 {op} $2::jsonb::eql_v2_int4"); + let err = sqlx::query(&sql) + .bind(SAMPLE_PAYLOAD) + .bind(SAMPLE_PAYLOAD) + .fetch_one(&pool) + .await + .expect_err(&format!("eql_v2_int4 {op} must not resolve: {sql}")) + .to_string(); + assert!( + err.contains("operator does not exist"), + "expected native 'operator does not exist' for {op}: {err}" + ); + } + Ok(()) +} + +#[sqlx::test] +async fn blockers_raise_on_typed_column(pool: PgPool) -> Result<()> { + // The other tests exercise blockers on cast literals + // ($1::jsonb::eql_v2_int4). This pins that the blockers also engage + // when the operand is a genuine eql_v2_int4-typed table column, the + // shape a real caller writes (`WHERE col = col`). + let mut tx = pool.begin().await?; + sqlx::query( + r#" + CREATE TEMP TABLE typed_int4 ( + id integer GENERATED ALWAYS AS IDENTITY, + value eql_v2_int4 + ) ON COMMIT DROP; + "#, + ) + .execute(&mut *tx) + .await?; + sqlx::query("INSERT INTO typed_int4(value) VALUES ($1::jsonb::eql_v2_int4)") + .bind(SAMPLE_PAYLOAD) + .execute(&mut *tx) + .await?; + + for op in ["=", "<>", "<", "<=", ">", ">=", "@>", "<@"] { + // A raised blocker aborts the transaction; wrap each probe in a + // savepoint so the next operator can be checked after rollback. + sqlx::query("SAVEPOINT op_probe").execute(&mut *tx).await?; + let sql = format!("SELECT * FROM typed_int4 WHERE value {op} value"); + let err = sqlx::query(&sql) + .fetch_all(&mut *tx) + .await + .expect_err(&format!("eql_v2_int4 column {op} must raise: {sql}")) + .to_string(); + let expected = format!("operator {op} is not supported for eql_v2_int4"); + assert!( + err.contains(&expected), + "unexpected error for {sql}: got {err}, want {expected}" + ); + sqlx::query("ROLLBACK TO SAVEPOINT op_probe") + .execute(&mut *tx) + .await?; + } + + tx.commit().await?; + Ok(()) +} + +#[sqlx::test] +async fn blocked_operators_raise_on_null_input(pool: PgPool) -> Result<()> { + // A blocker declared STRICT lets PostgreSQL skip the body and return + // NULL on a NULL argument, silently bypassing the + // "operator … is not supported" exception. The blocker contract is + // "always raises" — guard against STRICT regressing back in. + let null: Option<&str> = None; + + let err = sqlx::query("SELECT $1::jsonb::eql_v2_int4 = $2::jsonb::eql_v2_int4") + .bind(null) + .bind(null) + .fetch_one(&pool) + .await + .expect_err("eql_v2_int4 = must raise on NULL input") + .to_string(); + assert!( + err.contains("operator = is not supported for eql_v2_int4"), + "unexpected error for = on NULL: {err}" + ); + + let err = sqlx::query("SELECT $1::jsonb -> $2::jsonb::eql_v2_int4") + .bind(null) + .bind(null) + .fetch_one(&pool) + .await + .expect_err("eql_v2_int4 -> must raise on NULL input") + .to_string(); + assert!( + err.contains("operator -> is not supported for eql_v2_int4"), + "unexpected error for -> on NULL: {err}" + ); + Ok(()) +} + +#[sqlx::test] +async fn int4_rejects_invalid_payloads(pool: PgPool) -> Result<()> { + // The eql_v2_int4 domain CHECK requires a jsonb object carrying the + // EQL envelope (v, i) and the ciphertext (c). A payload missing a + // required key, or a non-object, is rejected at the cast. + for (label, json) in [ + ("missing c", r#"{"v":2,"i":{"t":"t","c":"c"}}"#), + ("missing v", r#"{"i":{"t":"t","c":"c"},"c":"x"}"#), + ("missing i", r#"{"v":2,"c":"x"}"#), + ("not an object", r#"["v","i","c"]"#), + ] { + let err = sqlx::query(&format!("SELECT '{json}'::jsonb::eql_v2_int4")) + .fetch_one(&pool) + .await + .expect_err(&format!("eql_v2_int4 must reject payload: {label}")) + .to_string(); + assert!( + err.contains("violates check constraint"), + "{label}: expected a check-constraint violation, got: {err}" + ); + } + Ok(()) +} diff --git a/tests/sqlx/tests/lint_tests.rs b/tests/sqlx/tests/lint_tests.rs index 1bd080c9..4972c34c 100644 --- a/tests/sqlx/tests/lint_tests.rs +++ b/tests/sqlx/tests/lint_tests.rs @@ -119,3 +119,48 @@ async fn lint_phase_1_operators_are_clean(pool: PgPool) -> Result<()> { ); Ok(()) } + +/// The real comparison operators on the `eql_v2_int4` variant family +/// (`=`, `<>` on `_eq`; `=`, `<>`, `<`, `<=`, `>`, `>=` on `_ord` and +/// `_ord_ore`) must report zero lint violations: they are inlinable +/// `LANGUAGE sql` wrappers, and a regression to plpgsql, VOLATILE, a +/// `SET` clause, or a non-inlinable callee would silently drop their +/// functional indexes to seq scan. The plpgsql blocker operators on the +/// same variants are intentionally non-inlinable and are excluded by +/// the variant-qualified prefixes. +#[sqlx::test] +async fn lint_int4_operators_are_clean(pool: PgPool) -> Result<()> { + let rows = fetch_lints(&pool).await?; + + // object_name is `operator (, ) -> ...`. A variant- + // qualified prefix excludes the storage-only eql_v2_int4 blockers: + // `operator =(eql_v2_int4,` does not match `..._eq` / `..._ord`. + let mut prefixes = vec![ + "operator =(eql_v2_int4_eq".to_string(), + "operator <>(eql_v2_int4_eq".to_string(), + "operator =(jsonb, eql_v2_int4_eq".to_string(), + "operator <>(jsonb, eql_v2_int4_eq".to_string(), + ]; + // `eql_v2_int4_ord` is a prefix of `eql_v2_int4_ord_ore`, so each + // entry covers both ordered variants. + for op in ["=", "<>", "<", "<=", ">", ">="] { + prefixes.push(format!("operator {op}(eql_v2_int4_ord")); + prefixes.push(format!("operator {op}(jsonb, eql_v2_int4_ord")); + } + + let violations: Vec<_> = rows + .iter() + .filter(|row| { + prefixes + .iter() + .any(|prefix| row.object_name.starts_with(prefix.as_str())) + }) + .collect(); + + assert!( + violations.is_empty(), + "eql_v2_int4 real operators should report zero lint violations, but got: {:#?}", + violations + ); + Ok(()) +}