diff --git a/docs/reference/sql-support.md b/docs/reference/sql-support.md index 5a11e430..782df23d 100644 --- a/docs/reference/sql-support.md +++ b/docs/reference/sql-support.md @@ -139,8 +139,8 @@ When the `ste_vec` index is configured, CipherStash Proxy rewrites these standar | `jsonb_array_elements(arr)` | `eql_v2.jsonb_array_elements(arr)` | Path must resolve to a JSON array node | Set-returning; yields `eql_v2_encrypted`. | | `jsonb_array_elements_text(arr)` | `eql_v2.jsonb_array_elements_text(arr)` | Path must resolve to a JSON array node | Set-returning; yields ciphertext as `text`. | | `COUNT(col)` | plain `count(*)` | — | No encrypted term required. | -| `COUNT(DISTINCT col)` | deterministic dedup | `unique`, `ore`, **or** `ope` on the extracted node | For a JSON leaf, that means Object / Array / Bool / Null (dedup via `b3`) or String / Number (dedup via `ocv`/`ocf`). | -| `MIN(col)` / `MAX(col)` | `eql_v2` ORE/OPE aggregates | `ore`, `ope`, **or** ste_vec-extracted String / Number node | Requires a node that emits `ocv` / `ocf` (or a sibling `ore` / `ope` index). | +| `COUNT(DISTINCT col)` | deterministic dedup | An extracted node that emits `b3`, `ocv`, or `ocf` (or a `unique` / `ore` / `ope` index on the outer column) | A ste_vec-extracted leaf dedups via `b3` (Object / Array / Bool / Null) or `ocv` / `ocf` (String / Number). `ope` is never emitted by ste_vec extraction; it only applies to the outer column. | +| `MIN(col)` / `MAX(col)` | `eql_v2` ORE/OPE aggregates | A ste_vec-extracted String / Number node (`ocv` / `ocf`), **or** a sibling `ore` / `ope` index on the outer column | ste_vec extraction can only produce `ocv` / `ocf` ordering terms. Whole-column ordering uses the outer-column `ore` or `ope` index. | Additionally, `eql_v2.jsonb_array`, `eql_v2.jsonb_contains`, and `eql_v2.jsonb_contained_by` are EQL helpers (not automatic rewrites) used when building **GIN-indexed** containment queries. See [GIN Indexes for JSONB Containment](./database-indexes.md#gin-indexes-for-jsonb-containment) for the full setup. diff --git a/src/ope_cllw_u64_65/compare.sql b/src/ope_cllw_u64_65/compare.sql index b799f9df..7f3ff789 100644 --- a/src/ope_cllw_u64_65/compare.sql +++ b/src/ope_cllw_u64_65/compare.sql @@ -9,13 +9,19 @@ --! their fixed-width CLWW OPE ciphertext index terms. Used internally by range --! operators (<, <=, >, >=) for order-preserving comparisons without decryption. --! ---! @param a eql_v2_encrypted First encrypted value to compare ---! @param b eql_v2_encrypted Second encrypted value to compare +--! @param a eql_v2_encrypted First encrypted value to compare (NOT NULL — function is STRICT) +--! @param b eql_v2_encrypted Second encrypted value to compare (NOT NULL — function is STRICT) --! @return Integer -1 if a < b, 0 if a = b, 1 if a > b --! ---! @note NULL values are sorted before non-NULL values +--! @note Declared STRICT, so NULL function inputs short-circuit to NULL before +--! the body runs. The internal `a_term IS NULL` / `b_term IS NULL` +--! branches are NOT redundant with STRICT — they handle the case where +--! a non-NULL `eql_v2_encrypted` payload simply lacks the `opf` field +--! (i.e. `has_ope_cllw_u64_65` returned false). A NULL term sorts before +--! a present term, mirroring the defensive pattern used in +--! compare_ore_block_u64_8_256. --! @note OPE ciphertexts compare via standard lexicographic bytea ordering — ---! no custom per-byte protocol required (unlike the ORE CLWW variants) +--! no custom per-byte protocol required (unlike the ORE CLLW variants). --! --! @see eql_v2.ope_cllw_u64_65 --! @see eql_v2.has_ope_cllw_u64_65 @@ -27,18 +33,6 @@ AS $$ a_term eql_v2.ope_cllw_u64_65; b_term eql_v2.ope_cllw_u64_65; BEGIN - IF a IS NULL AND b IS NULL THEN - RETURN 0; - END IF; - - IF a IS NULL THEN - RETURN -1; - END IF; - - IF b IS NULL THEN - RETURN 1; - END IF; - IF eql_v2.has_ope_cllw_u64_65(a) THEN a_term := eql_v2.ope_cllw_u64_65(a); END IF; diff --git a/src/ope_cllw_u64_65/functions.sql b/src/ope_cllw_u64_65/functions.sql index e06ddd9b..20bbca22 100644 --- a/src/ope_cllw_u64_65/functions.sql +++ b/src/ope_cllw_u64_65/functions.sql @@ -8,7 +8,7 @@ --! Extracts the fixed-width CLWW OPE ciphertext from the 'opf' field of an --! encrypted data payload. Used internally for range query comparisons. --! ---! @param jsonb containing encrypted EQL payload +--! @param val jsonb encrypted EQL payload --! @return eql_v2.ope_cllw_u64_65 CLWW OPE ciphertext --! @throws Exception if 'opf' field is missing when ope index is expected --! @@ -19,10 +19,6 @@ CREATE FUNCTION eql_v2.ope_cllw_u64_65(val jsonb) IMMUTABLE STRICT PARALLEL SAFE AS $$ BEGIN - IF val IS NULL THEN - RETURN NULL; - END IF; - IF NOT (eql_v2.has_ope_cllw_u64_65(val)) THEN RAISE 'Expected a ope_cllw_u64_65 index (opf) value in json: %', val; END IF; @@ -37,7 +33,7 @@ $$ LANGUAGE plpgsql; --! Extracts the fixed-width CLWW OPE ciphertext from an encrypted column value --! by accessing its underlying JSONB data field. --! ---! @param eql_v2_encrypted Encrypted column value +--! @param val eql_v2_encrypted Encrypted column value --! @return eql_v2.ope_cllw_u64_65 CLWW OPE ciphertext --! --! @see eql_v2.ope_cllw_u64_65(jsonb) @@ -45,10 +41,8 @@ CREATE FUNCTION eql_v2.ope_cllw_u64_65(val eql_v2_encrypted) RETURNS eql_v2.ope_cllw_u64_65 IMMUTABLE STRICT PARALLEL SAFE AS $$ - BEGIN - RETURN (SELECT eql_v2.ope_cllw_u64_65(val.data)); - END; -$$ LANGUAGE plpgsql; + SELECT eql_v2.ope_cllw_u64_65(val.data); +$$ LANGUAGE sql; --! @brief Check if JSONB payload contains CLWW OPE index term @@ -56,7 +50,7 @@ $$ LANGUAGE plpgsql; --! Tests whether the encrypted data payload includes an 'opf' field, --! indicating a fixed-width CLWW OPE ciphertext is available for range queries. --! ---! @param jsonb containing encrypted EQL payload +--! @param val jsonb encrypted EQL payload --! @return Boolean True if 'opf' field is present and non-null --! --! @see eql_v2.ope_cllw_u64_65 @@ -64,10 +58,8 @@ CREATE FUNCTION eql_v2.has_ope_cllw_u64_65(val jsonb) RETURNS boolean IMMUTABLE STRICT PARALLEL SAFE AS $$ - BEGIN - RETURN val ->> 'opf' IS NOT NULL; - END; -$$ LANGUAGE plpgsql; + SELECT val ->> 'opf' IS NOT NULL; +$$ LANGUAGE sql; --! @brief Check if encrypted column value contains CLWW OPE index term @@ -75,7 +67,7 @@ $$ LANGUAGE plpgsql; --! Tests whether an encrypted column value includes a fixed-width CLWW OPE --! ciphertext by checking its underlying JSONB data field. --! ---! @param eql_v2_encrypted Encrypted column value +--! @param val eql_v2_encrypted Encrypted column value --! @return Boolean True if CLWW OPE ciphertext is present --! --! @see eql_v2.has_ope_cllw_u64_65(jsonb) @@ -83,7 +75,5 @@ CREATE FUNCTION eql_v2.has_ope_cllw_u64_65(val eql_v2_encrypted) RETURNS boolean IMMUTABLE STRICT PARALLEL SAFE AS $$ - BEGIN - RETURN eql_v2.has_ope_cllw_u64_65(val.data); - END; -$$ LANGUAGE plpgsql; + SELECT eql_v2.has_ope_cllw_u64_65(val.data); +$$ LANGUAGE sql; diff --git a/src/ope_cllw_u64_65/types.sql b/src/ope_cllw_u64_65/types.sql index 8d9226f3..177a46fe 100644 --- a/src/ope_cllw_u64_65/types.sql +++ b/src/ope_cllw_u64_65/types.sql @@ -3,8 +3,8 @@ --! @brief CLWW OPE index term type for fixed-width numeric range queries --! --! Composite type for CLWW (Chenette, Lewi, Weis, Wu) Order-Preserving Encryption ---! over 64-bit integers. Ciphertexts are 65 bytes (8 bytes per plaintext bit plus ---! one reserved carry byte). +--! over 64-bit integers. Ciphertexts are 65 bytes (8 bytes per plaintext byte, +--! plus one reserved carry byte). --! --! Ciphertexts compare with **standard lexicographic byte ordering** — unlike --! the ORE variants there is no custom per-byte compare protocol. The ciphertext diff --git a/src/ope_cllw_var_8/compare.sql b/src/ope_cllw_var_8/compare.sql index 7d2be1a4..f98f0701 100644 --- a/src/ope_cllw_var_8/compare.sql +++ b/src/ope_cllw_var_8/compare.sql @@ -10,13 +10,19 @@ --! range operators (<, <=, >, >=) for order-preserving comparisons without --! decryption. --! ---! @param a eql_v2_encrypted First encrypted value to compare ---! @param b eql_v2_encrypted Second encrypted value to compare +--! @param a eql_v2_encrypted First encrypted value to compare (NOT NULL — function is STRICT) +--! @param b eql_v2_encrypted Second encrypted value to compare (NOT NULL — function is STRICT) --! @return Integer -1 if a < b, 0 if a = b, 1 if a > b --! ---! @note NULL values are sorted before non-NULL values +--! @note Declared STRICT, so NULL function inputs short-circuit to NULL before +--! the body runs. The internal `a_term IS NULL` / `b_term IS NULL` +--! branches are NOT redundant with STRICT — they handle the case where +--! a non-NULL `eql_v2_encrypted` payload simply lacks the `opv` field +--! (i.e. `has_ope_cllw_var_8` returned false). A NULL term sorts before +--! a present term, mirroring the defensive pattern used in +--! compare_ore_block_u64_8_256. --! @note OPE ciphertexts compare via standard lexicographic bytea ordering — ---! bytea compare handles variable-length inputs (shorter prefix is less) +--! bytea compare handles variable-length inputs (shorter prefix is less). --! --! @see eql_v2.ope_cllw_var_8 --! @see eql_v2.has_ope_cllw_var_8 @@ -28,18 +34,6 @@ AS $$ a_term eql_v2.ope_cllw_var_8; b_term eql_v2.ope_cllw_var_8; BEGIN - IF a IS NULL AND b IS NULL THEN - RETURN 0; - END IF; - - IF a IS NULL THEN - RETURN -1; - END IF; - - IF b IS NULL THEN - RETURN 1; - END IF; - IF eql_v2.has_ope_cllw_var_8(a) THEN a_term := eql_v2.ope_cllw_var_8(a); END IF; diff --git a/src/ope_cllw_var_8/functions.sql b/src/ope_cllw_var_8/functions.sql index f3b7c407..05a2bcc6 100644 --- a/src/ope_cllw_var_8/functions.sql +++ b/src/ope_cllw_var_8/functions.sql @@ -8,7 +8,7 @@ --! Extracts the variable-width CLWW OPE ciphertext from the 'opv' field of an --! encrypted data payload. Used internally for range query comparisons. --! ---! @param jsonb containing encrypted EQL payload +--! @param val jsonb encrypted EQL payload --! @return eql_v2.ope_cllw_var_8 Variable-width CLWW OPE ciphertext --! @throws Exception if 'opv' field is missing when ope index is expected --! @@ -19,10 +19,6 @@ CREATE FUNCTION eql_v2.ope_cllw_var_8(val jsonb) IMMUTABLE STRICT PARALLEL SAFE AS $$ BEGIN - IF val IS NULL THEN - RETURN NULL; - END IF; - IF NOT (eql_v2.has_ope_cllw_var_8(val)) THEN RAISE 'Expected a ope_cllw_var_8 index (opv) value in json: %', val; END IF; @@ -37,7 +33,7 @@ $$ LANGUAGE plpgsql; --! Extracts the variable-width CLWW OPE ciphertext from an encrypted column value --! by accessing its underlying JSONB data field. --! ---! @param eql_v2_encrypted Encrypted column value +--! @param val eql_v2_encrypted Encrypted column value --! @return eql_v2.ope_cllw_var_8 Variable-width CLWW OPE ciphertext --! --! @see eql_v2.ope_cllw_var_8(jsonb) @@ -45,10 +41,8 @@ CREATE FUNCTION eql_v2.ope_cllw_var_8(val eql_v2_encrypted) RETURNS eql_v2.ope_cllw_var_8 IMMUTABLE STRICT PARALLEL SAFE AS $$ - BEGIN - RETURN (SELECT eql_v2.ope_cllw_var_8(val.data)); - END; -$$ LANGUAGE plpgsql; + SELECT eql_v2.ope_cllw_var_8(val.data); +$$ LANGUAGE sql; --! @brief Check if JSONB payload contains variable-width CLWW OPE index term @@ -56,7 +50,7 @@ $$ LANGUAGE plpgsql; --! Tests whether the encrypted data payload includes an 'opv' field, --! indicating a variable-width CLWW OPE ciphertext is available for range queries. --! ---! @param jsonb containing encrypted EQL payload +--! @param val jsonb encrypted EQL payload --! @return Boolean True if 'opv' field is present and non-null --! --! @see eql_v2.ope_cllw_var_8 @@ -64,10 +58,8 @@ CREATE FUNCTION eql_v2.has_ope_cllw_var_8(val jsonb) RETURNS boolean IMMUTABLE STRICT PARALLEL SAFE AS $$ - BEGIN - RETURN val ->> 'opv' IS NOT NULL; - END; -$$ LANGUAGE plpgsql; + SELECT val ->> 'opv' IS NOT NULL; +$$ LANGUAGE sql; --! @brief Check if encrypted column value contains variable-width CLWW OPE index term @@ -75,7 +67,7 @@ $$ LANGUAGE plpgsql; --! Tests whether an encrypted column value includes a variable-width CLWW OPE --! ciphertext by checking its underlying JSONB data field. --! ---! @param eql_v2_encrypted Encrypted column value +--! @param val eql_v2_encrypted Encrypted column value --! @return Boolean True if variable-width CLWW OPE ciphertext is present --! --! @see eql_v2.has_ope_cllw_var_8(jsonb) @@ -83,7 +75,5 @@ CREATE FUNCTION eql_v2.has_ope_cllw_var_8(val eql_v2_encrypted) RETURNS boolean IMMUTABLE STRICT PARALLEL SAFE AS $$ - BEGIN - RETURN eql_v2.has_ope_cllw_var_8(val.data); - END; -$$ LANGUAGE plpgsql; + SELECT eql_v2.has_ope_cllw_var_8(val.data); +$$ LANGUAGE sql; diff --git a/src/operators/order_by.sql b/src/operators/order_by.sql index 5d9959f6..6e85e58c 100644 --- a/src/operators/order_by.sql +++ b/src/operators/order_by.sql @@ -55,20 +55,12 @@ $$ LANGUAGE plpgsql; CREATE FUNCTION eql_v2.order_by_ope(a eql_v2_encrypted) RETURNS bytea IMMUTABLE STRICT PARALLEL SAFE - SET search_path = pg_catalog, extensions, public AS $$ - BEGIN - IF eql_v2.has_ope_cllw_u64_65(a) THEN - RETURN (eql_v2.ope_cllw_u64_65(a)).bytes; - END IF; - - IF eql_v2.has_ope_cllw_var_8(a) THEN - RETURN (eql_v2.ope_cllw_var_8(a)).bytes; - END IF; - - RETURN NULL; + SELECT CASE + WHEN eql_v2.has_ope_cllw_u64_65(a) THEN (eql_v2.ope_cllw_u64_65(a)).bytes + WHEN eql_v2.has_ope_cllw_var_8(a) THEN (eql_v2.ope_cllw_var_8(a)).bytes END; -$$ LANGUAGE plpgsql; +$$ LANGUAGE sql; diff --git a/tests/sqlx/tests/ope_tests.rs b/tests/sqlx/tests/ope_tests.rs index 819e3759..0d0e1a87 100644 --- a/tests/sqlx/tests/ope_tests.rs +++ b/tests/sqlx/tests/ope_tests.rs @@ -133,6 +133,256 @@ async fn encrypted_gt_operator_uses_opf(pool: PgPool) -> Result<()> { Ok(()) } +#[sqlx::test] +async fn encrypted_lte_operator_uses_opf(pool: PgPool) -> Result<()> { + let a = opf_payload(1); + let b = opf_payload(2); + + let lt = format!( + "SELECT eql_v2.to_encrypted('{}'::jsonb) <= eql_v2.to_encrypted('{}'::jsonb)", + a, b + ); + QueryAssertion::new(&pool, <) + .returns_bool_value(true) + .await; + + let eq = format!( + "SELECT eql_v2.to_encrypted('{}'::jsonb) <= eql_v2.to_encrypted('{}'::jsonb)", + a, a + ); + QueryAssertion::new(&pool, &eq) + .returns_bool_value(true) + .await; + Ok(()) +} + +#[sqlx::test] +async fn encrypted_gte_operator_uses_opf(pool: PgPool) -> Result<()> { + let a = opf_payload(1); + let b = opf_payload(2); + + let gt = format!( + "SELECT eql_v2.to_encrypted('{}'::jsonb) >= eql_v2.to_encrypted('{}'::jsonb)", + b, a + ); + QueryAssertion::new(&pool, >) + .returns_bool_value(true) + .await; + + let eq = format!( + "SELECT eql_v2.to_encrypted('{}'::jsonb) >= eql_v2.to_encrypted('{}'::jsonb)", + b, b + ); + QueryAssertion::new(&pool, &eq) + .returns_bool_value(true) + .await; + Ok(()) +} + +#[sqlx::test] +async fn encrypted_eq_operator_uses_opf(pool: PgPool) -> Result<()> { + let a = opf_payload(1); + let b = opf_payload(2); + + let eq = format!( + "SELECT eql_v2.to_encrypted('{}'::jsonb) = eql_v2.to_encrypted('{}'::jsonb)", + a, a + ); + QueryAssertion::new(&pool, &eq) + .returns_bool_value(true) + .await; + + let neq = format!( + "SELECT eql_v2.to_encrypted('{}'::jsonb) = eql_v2.to_encrypted('{}'::jsonb)", + a, b + ); + QueryAssertion::new(&pool, &neq) + .returns_bool_value(false) + .await; + Ok(()) +} + +#[sqlx::test] +async fn encrypted_neq_operator_uses_opf(pool: PgPool) -> Result<()> { + let a = opf_payload(1); + let b = opf_payload(2); + + let neq = format!( + "SELECT eql_v2.to_encrypted('{}'::jsonb) <> eql_v2.to_encrypted('{}'::jsonb)", + a, b + ); + QueryAssertion::new(&pool, &neq) + .returns_bool_value(true) + .await; + + let eq = format!( + "SELECT eql_v2.to_encrypted('{}'::jsonb) <> eql_v2.to_encrypted('{}'::jsonb)", + a, a + ); + QueryAssertion::new(&pool, &eq) + .returns_bool_value(false) + .await; + Ok(()) +} + +#[sqlx::test] +async fn encrypted_lt_operator_uses_opv(pool: PgPool) -> Result<()> { + let a = opv_payload(&[0xaa, 0x11]); + let b = opv_payload(&[0xbb, 0x11]); + + let sql = format!( + "SELECT eql_v2.to_encrypted('{}'::jsonb) < eql_v2.to_encrypted('{}'::jsonb)", + a, b + ); + QueryAssertion::new(&pool, &sql) + .returns_bool_value(true) + .await; + Ok(()) +} + +#[sqlx::test] +async fn encrypted_gt_operator_uses_opv(pool: PgPool) -> Result<()> { + let a = opv_payload(&[0xaa, 0x11]); + let b = opv_payload(&[0xbb, 0x11]); + + let sql = format!( + "SELECT eql_v2.to_encrypted('{}'::jsonb) > eql_v2.to_encrypted('{}'::jsonb)", + b, a + ); + QueryAssertion::new(&pool, &sql) + .returns_bool_value(true) + .await; + Ok(()) +} + +#[sqlx::test] +async fn encrypted_lte_operator_uses_opv(pool: PgPool) -> Result<()> { + let a = opv_payload(&[0xaa, 0x11]); + let b = opv_payload(&[0xbb, 0x11]); + + let lt = format!( + "SELECT eql_v2.to_encrypted('{}'::jsonb) <= eql_v2.to_encrypted('{}'::jsonb)", + a, b + ); + QueryAssertion::new(&pool, <) + .returns_bool_value(true) + .await; + + let eq = format!( + "SELECT eql_v2.to_encrypted('{}'::jsonb) <= eql_v2.to_encrypted('{}'::jsonb)", + a, a + ); + QueryAssertion::new(&pool, &eq) + .returns_bool_value(true) + .await; + Ok(()) +} + +#[sqlx::test] +async fn encrypted_gte_operator_uses_opv(pool: PgPool) -> Result<()> { + let a = opv_payload(&[0xaa, 0x11]); + let b = opv_payload(&[0xbb, 0x11]); + + let gt = format!( + "SELECT eql_v2.to_encrypted('{}'::jsonb) >= eql_v2.to_encrypted('{}'::jsonb)", + b, a + ); + QueryAssertion::new(&pool, >) + .returns_bool_value(true) + .await; + + let eq = format!( + "SELECT eql_v2.to_encrypted('{}'::jsonb) >= eql_v2.to_encrypted('{}'::jsonb)", + b, b + ); + QueryAssertion::new(&pool, &eq) + .returns_bool_value(true) + .await; + Ok(()) +} + +#[sqlx::test] +async fn encrypted_eq_operator_uses_opv(pool: PgPool) -> Result<()> { + let a = opv_payload(&[0xaa, 0x11]); + let b = opv_payload(&[0xbb, 0x11]); + + let eq = format!( + "SELECT eql_v2.to_encrypted('{}'::jsonb) = eql_v2.to_encrypted('{}'::jsonb)", + a, a + ); + QueryAssertion::new(&pool, &eq) + .returns_bool_value(true) + .await; + + let neq = format!( + "SELECT eql_v2.to_encrypted('{}'::jsonb) = eql_v2.to_encrypted('{}'::jsonb)", + a, b + ); + QueryAssertion::new(&pool, &neq) + .returns_bool_value(false) + .await; + Ok(()) +} + +#[sqlx::test] +async fn encrypted_neq_operator_uses_opv(pool: PgPool) -> Result<()> { + let a = opv_payload(&[0xaa, 0x11]); + let b = opv_payload(&[0xbb, 0x11]); + + let neq = format!( + "SELECT eql_v2.to_encrypted('{}'::jsonb) <> eql_v2.to_encrypted('{}'::jsonb)", + a, b + ); + QueryAssertion::new(&pool, &neq) + .returns_bool_value(true) + .await; + + let eq = format!( + "SELECT eql_v2.to_encrypted('{}'::jsonb) <> eql_v2.to_encrypted('{}'::jsonb)", + a, a + ); + QueryAssertion::new(&pool, &eq) + .returns_bool_value(false) + .await; + Ok(()) +} + +/// Build the raw 65-byte OPE fixed ciphertext as a hex string (no JSONB +/// wrapper). Mirrors `opf_payload`'s body: a single signal byte at index 8, +/// all other bytes zero. Larger signal → larger ciphertext under lex compare. +fn opf_hex(signal: u8) -> String { + let mut bytes = vec![0u8; 65]; + bytes[8] = signal; + hex::encode(&bytes) +} + +#[sqlx::test] +async fn ore_wins_over_opf_when_both_present(pool: PgPool) -> Result<()> { + // When a row carries both ORE (`ob`) and OPE (`opf`) terms with conflicting + // orderings, eql_v2.compare must dispatch to the ORE branch (it appears + // earlier in the priority chain) — locking in the precedence contract. + // + // Build a value with ORE rank 1 + opf=high(99) and another with ORE rank 2 + // + opf=low(1). ORE-only ordering says (rank 1) < (rank 2). OPE-only + // ordering would say opf=99 > opf=1. compare() must follow ORE → -1. + let opf_high = opf_hex(99); + let opf_low = opf_hex(1); + + // Fixture rows in `ore` have id=N and an `ob` term that orders by N. + let a_sql = format!( + "(create_encrypted_ore_json(1)::jsonb || jsonb_build_object('opf', '{}'))::eql_v2_encrypted", + opf_high + ); + let b_sql = format!( + "(create_encrypted_ore_json(2)::jsonb || jsonb_build_object('opf', '{}'))::eql_v2_encrypted", + opf_low + ); + + let cmp = format!("SELECT eql_v2.compare({}, {})", a_sql, b_sql); + QueryAssertion::new(&pool, &cmp).returns_int_value(-1).await; + Ok(()) +} + #[sqlx::test] async fn compare_opv_short_prefix_sorts_less(pool: PgPool) -> Result<()> { // Shorter ciphertext that is a lex prefix of the longer one. @@ -464,8 +714,454 @@ async fn config_check_rejects_unknown_index(pool: PgPool) -> Result<()> { .expect_err("expected check_indexes to reject unknown index"); let msg = err.to_string(); assert!( - msg.contains("match, ore, ope, unique, ste_vec"), - "expected error to list valid indexes including 'ope'; got: {msg}" + msg.contains("ope") && msg.contains("bogus"), + "expected error to mention the offending 'bogus' index and list 'ope' as valid; got: {msg}" ); Ok(()) } + +// ========== NULL-handling parity with ORE ========== +// +// ORE has explicit coverage for several NULL scenarios that the OPE surface +// must also satisfy: +// 1. NULL index term in payload (`{"opf": null}` / `{"opv": null}`) — the +// generic `eql_v2.compare` dispatcher must skip the OPE branch and fall +// through to the next available term (mirrors `compare_hmac_with_null_ore_index`). +// 2. NULL operands at the comparator level — the `compare_ope_cllw_*` +// helpers are STRICT, so a NULL operand short-circuits to NULL. +// 3. NULL rows mixed with encrypted rows in ORDER BY / sort_compare / +// MIN / MAX must respect SQL NULL semantics. + +#[sqlx::test] +async fn has_opf_false_when_field_is_json_null(pool: PgPool) -> Result<()> { + // `{"opf": null}` must not trigger OPE detection — same contract as + // `{"ob": null}` for ORE (see compare_hmac_with_null_ore_index). + let sql = + r#"SELECT eql_v2.has_ope_cllw_u64_65('{"v":2,"i":{"t":"t","c":"c"},"opf":null}'::jsonb)"#; + QueryAssertion::new(&pool, sql) + .returns_bool_value(false) + .await; + Ok(()) +} + +#[sqlx::test] +async fn has_opv_false_when_field_is_json_null(pool: PgPool) -> Result<()> { + let sql = + r#"SELECT eql_v2.has_ope_cllw_var_8('{"v":2,"i":{"t":"t","c":"c"},"opv":null}'::jsonb)"#; + QueryAssertion::new(&pool, sql) + .returns_bool_value(false) + .await; + Ok(()) +} + +#[sqlx::test] +async fn compare_dispatches_through_null_opf_to_hmac(pool: PgPool) -> Result<()> { + // Mirror of `compare_hmac_with_null_ore_index`: when `opf` is JSON null, + // the dispatcher must skip the OPE branch and use the HMAC term instead. + // Without this, two records with `{"opf": null}` would compare equal via + // the OPE branch (both extract to NULL bytes → equal), masking the HMAC + // ordering. + let a = "('{\"opf\": null}'::jsonb || create_encrypted_json(1, 'hm')::jsonb)::eql_v2_encrypted"; + let b = "('{\"opf\": null}'::jsonb || create_encrypted_json(2, 'hm')::jsonb)::eql_v2_encrypted"; + let c = "('{\"opf\": null}'::jsonb || create_encrypted_json(3, 'hm')::jsonb)::eql_v2_encrypted"; + + for (l, r, expected, label) in [ + (a, a, 0, "compare(a, a)"), + (a, b, -1, "compare(a, b)"), + (a, c, -1, "compare(a, c)"), + (b, b, 0, "compare(b, b)"), + (b, a, 1, "compare(b, a)"), + (b, c, -1, "compare(b, c)"), + (c, c, 0, "compare(c, c)"), + (c, b, 1, "compare(c, b)"), + (c, a, 1, "compare(c, a)"), + ] { + let sql = format!("SELECT eql_v2.compare({}, {})", l, r); + let got: i32 = sqlx::query_scalar(&sql).fetch_one(&pool).await?; + assert_eq!(got, expected, "{label} should equal {expected}"); + } + Ok(()) +} + +#[sqlx::test] +async fn compare_dispatches_through_null_opv_to_hmac(pool: PgPool) -> Result<()> { + // Same as the opf variant but for the variable-width term. Establishes + // that {"opv": null} also short-circuits the OPE branch. + let a = "('{\"opv\": null}'::jsonb || create_encrypted_json(1, 'hm')::jsonb)::eql_v2_encrypted"; + let b = "('{\"opv\": null}'::jsonb || create_encrypted_json(2, 'hm')::jsonb)::eql_v2_encrypted"; + + let lt: i32 = sqlx::query_scalar(&format!("SELECT eql_v2.compare({}, {})", a, b)) + .fetch_one(&pool) + .await?; + assert_eq!(lt, -1, "compare(a, b) should equal -1"); + + let gt: i32 = sqlx::query_scalar(&format!("SELECT eql_v2.compare({}, {})", b, a)) + .fetch_one(&pool) + .await?; + assert_eq!(gt, 1, "compare(b, a) should equal 1"); + Ok(()) +} + +#[sqlx::test] +async fn compare_ope_cllw_u64_65_strict_returns_null_for_null_operand(pool: PgPool) -> Result<()> { + // The comparator is declared STRICT; the runtime returns NULL before the + // body runs. Codifying this so a future change that drops STRICT won't + // silently change semantics on the sort fast path. + let payload = opf_payload(1); + let lhs_null = format!( + "SELECT eql_v2.compare_ope_cllw_u64_65(NULL, eql_v2.to_encrypted('{}'::jsonb))", + payload + ); + let result: Option = sqlx::query_scalar(&lhs_null).fetch_one(&pool).await?; + assert!(result.is_none(), "compare(NULL, x) should return NULL"); + + let rhs_null = format!( + "SELECT eql_v2.compare_ope_cllw_u64_65(eql_v2.to_encrypted('{}'::jsonb), NULL)", + payload + ); + let result: Option = sqlx::query_scalar(&rhs_null).fetch_one(&pool).await?; + assert!(result.is_none(), "compare(x, NULL) should return NULL"); + Ok(()) +} + +#[sqlx::test] +async fn compare_ope_cllw_var_8_strict_returns_null_for_null_operand(pool: PgPool) -> Result<()> { + let payload = opv_payload(&[0xaa, 0x11]); + let lhs_null = format!( + "SELECT eql_v2.compare_ope_cllw_var_8(NULL, eql_v2.to_encrypted('{}'::jsonb))", + payload + ); + let result: Option = sqlx::query_scalar(&lhs_null).fetch_one(&pool).await?; + assert!(result.is_none(), "compare(NULL, x) should return NULL"); + + let rhs_null = format!( + "SELECT eql_v2.compare_ope_cllw_var_8(eql_v2.to_encrypted('{}'::jsonb), NULL)", + payload + ); + let result: Option = sqlx::query_scalar(&rhs_null).fetch_one(&pool).await?; + assert!(result.is_none(), "compare(x, NULL) should return NULL"); + Ok(()) +} + +// ========== ORDER BY NULLS FIRST/LAST with opf-encoded data ========== +// +// Fixture layout (all four tests use the same shape): +// id=1: NULL +// id=2: opf payload with signal byte = 42 (largest non-NULL) +// id=3: opf payload with signal byte = 3 (smallest non-NULL) +// id=4: NULL +// +// Mirrors `order_by_null_data.sql` for the ORE side. + +async fn install_opf_null_fixture(tx: &mut sqlx::Transaction<'_, sqlx::Postgres>) -> Result<()> { + sqlx::query( + "CREATE TABLE encrypted_opf_nulls( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + e eql_v2_encrypted + )", + ) + .execute(&mut **tx) + .await?; + + sqlx::query("INSERT INTO encrypted_opf_nulls(e) VALUES (NULL)") + .execute(&mut **tx) + .await?; + sqlx::query(&format!( + "INSERT INTO encrypted_opf_nulls(e) VALUES (eql_v2.to_encrypted('{}'::jsonb))", + opf_payload(42) + )) + .execute(&mut **tx) + .await?; + sqlx::query(&format!( + "INSERT INTO encrypted_opf_nulls(e) VALUES (eql_v2.to_encrypted('{}'::jsonb))", + opf_payload(3) + )) + .execute(&mut **tx) + .await?; + sqlx::query("INSERT INTO encrypted_opf_nulls(e) VALUES (NULL)") + .execute(&mut **tx) + .await?; + Ok(()) +} + +#[sqlx::test] +async fn order_by_asc_nulls_first_with_opf(pool: PgPool) -> Result<()> { + let mut tx = pool.begin().await?; + install_opf_null_fixture(&mut tx).await?; + + let row = sqlx::query("SELECT id FROM encrypted_opf_nulls ORDER BY e ASC NULLS FIRST, id") + .fetch_one(&mut *tx) + .await?; + let first_id: i64 = row.try_get(0)?; + assert_eq!( + first_id, 1, + "ASC NULLS FIRST + tiebreak by id should put id=1 first" + ); + tx.rollback().await?; + Ok(()) +} + +#[sqlx::test] +async fn order_by_asc_nulls_last_with_opf(pool: PgPool) -> Result<()> { + let mut tx = pool.begin().await?; + install_opf_null_fixture(&mut tx).await?; + + let row = sqlx::query("SELECT id FROM encrypted_opf_nulls ORDER BY e ASC NULLS LAST") + .fetch_one(&mut *tx) + .await?; + let first_id: i64 = row.try_get(0)?; + assert_eq!( + first_id, 3, + "ASC NULLS LAST should return smallest non-NULL (id=3, opf signal=3) first" + ); + tx.rollback().await?; + Ok(()) +} + +#[sqlx::test] +async fn order_by_desc_nulls_first_with_opf(pool: PgPool) -> Result<()> { + let mut tx = pool.begin().await?; + install_opf_null_fixture(&mut tx).await?; + + let row = sqlx::query("SELECT id FROM encrypted_opf_nulls ORDER BY e DESC NULLS FIRST, id") + .fetch_one(&mut *tx) + .await?; + let first_id: i64 = row.try_get(0)?; + assert_eq!( + first_id, 1, + "DESC NULLS FIRST + tiebreak by id should put id=1 first" + ); + tx.rollback().await?; + Ok(()) +} + +#[sqlx::test] +async fn order_by_desc_nulls_last_with_opf(pool: PgPool) -> Result<()> { + let mut tx = pool.begin().await?; + install_opf_null_fixture(&mut tx).await?; + + let row = sqlx::query("SELECT id FROM encrypted_opf_nulls ORDER BY e DESC NULLS LAST") + .fetch_one(&mut *tx) + .await?; + let first_id: i64 = row.try_get(0)?; + assert_eq!( + first_id, 2, + "DESC NULLS LAST should return largest non-NULL (id=2, opf signal=42) first" + ); + tx.rollback().await?; + Ok(()) +} + +// ========== sort_compare with NULL operands ========== + +#[sqlx::test] +async fn sort_compare_asc_puts_nulls_first_with_opf(pool: PgPool) -> Result<()> { + let mut tx = pool.begin().await?; + install_opf_null_fixture(&mut tx).await?; + + let rows = sqlx::query( + "SELECT id FROM eql_v2.sort_compare( + (SELECT array_agg(id ORDER BY id) FROM encrypted_opf_nulls), + (SELECT array_agg(e ORDER BY id) FROM encrypted_opf_nulls), + 'ASC' + )", + ) + .fetch_all(&mut *tx) + .await?; + let ids: Vec = rows.iter().map(|r| r.try_get(0).unwrap()).collect(); + let mut null_ids = ids[..2].to_vec(); + null_ids.sort_unstable(); + + assert_eq!(rows.len(), 4, "should return all 4 rows"); + assert_eq!(null_ids, vec![1i64, 4], "NULL rows should sort first"); + assert_eq!( + ids[2], 3, + "smallest non-NULL (signal=3) should follow NULLs" + ); + assert_eq!(ids[3], 2, "largest non-NULL (signal=42) should sort last"); + tx.rollback().await?; + Ok(()) +} + +#[sqlx::test] +async fn sort_compare_desc_puts_nulls_last_with_opf(pool: PgPool) -> Result<()> { + let mut tx = pool.begin().await?; + install_opf_null_fixture(&mut tx).await?; + + let rows = sqlx::query( + "SELECT id FROM eql_v2.sort_compare( + (SELECT array_agg(id ORDER BY id) FROM encrypted_opf_nulls), + (SELECT array_agg(e ORDER BY id) FROM encrypted_opf_nulls), + 'DESC' + )", + ) + .fetch_all(&mut *tx) + .await?; + let ids: Vec = rows.iter().map(|r| r.try_get(0).unwrap()).collect(); + let mut null_ids = ids[2..].to_vec(); + null_ids.sort_unstable(); + + assert_eq!(rows.len(), 4, "should return all 4 rows"); + assert_eq!(ids[0], 2, "largest non-NULL (signal=42) should sort first"); + assert_eq!(ids[1], 3, "smaller non-NULL (signal=3) should sort second"); + assert_eq!(null_ids, vec![1i64, 4], "NULL rows should sort last"); + tx.rollback().await?; + Ok(()) +} + +// ========== MIN / MAX aggregates over OPE-encoded values ========== +// +// `eql_v2.min` / `eql_v2.max` use `<` / `>`, which dispatch through +// `eql_v2.compare`, so OPE-encoded values must aggregate correctly without +// any aggregate-side changes. + +#[sqlx::test] +async fn eql_v2_min_with_opf_finds_minimum(pool: PgPool) -> Result<()> { + let mut tx = pool.begin().await?; + install_opf_null_fixture(&mut tx).await?; + + // Smallest non-NULL signal=3 lives at id=3. + let actual: String = sqlx::query_scalar("SELECT eql_v2.min(e)::text FROM encrypted_opf_nulls") + .fetch_one(&mut *tx) + .await?; + let expected: String = + sqlx::query_scalar("SELECT e::text FROM encrypted_opf_nulls WHERE id = 3") + .fetch_one(&mut *tx) + .await?; + + assert_eq!( + actual, expected, + "eql_v2.min should return the opf row with the smallest signal byte" + ); + tx.rollback().await?; + Ok(()) +} + +#[sqlx::test] +async fn eql_v2_max_with_opf_finds_maximum(pool: PgPool) -> Result<()> { + let mut tx = pool.begin().await?; + install_opf_null_fixture(&mut tx).await?; + + // Largest non-NULL signal=42 lives at id=2. + let actual: String = sqlx::query_scalar("SELECT eql_v2.max(e)::text FROM encrypted_opf_nulls") + .fetch_one(&mut *tx) + .await?; + let expected: String = + sqlx::query_scalar("SELECT e::text FROM encrypted_opf_nulls WHERE id = 2") + .fetch_one(&mut *tx) + .await?; + + assert_eq!( + actual, expected, + "eql_v2.max should return the opf row with the largest signal byte" + ); + tx.rollback().await?; + Ok(()) +} + +#[sqlx::test] +async fn eql_v2_min_with_opf_null_only_returns_null(pool: PgPool) -> Result<()> { + // Mirrors `eql_v2_min_with_null_values` for ORE: aggregate over a NULL-only + // selection must return NULL (the STRICT state-transition function never + // runs). + let mut tx = pool.begin().await?; + install_opf_null_fixture(&mut tx).await?; + + let result: Option = + sqlx::query_scalar("SELECT eql_v2.min(e)::text FROM encrypted_opf_nulls WHERE e IS NULL") + .fetch_one(&mut *tx) + .await?; + assert!(result.is_none(), "eql_v2.min over NULL-only should be NULL"); + tx.rollback().await?; + Ok(()) +} + +#[sqlx::test] +async fn eql_v2_max_with_opf_null_only_returns_null(pool: PgPool) -> Result<()> { + let mut tx = pool.begin().await?; + install_opf_null_fixture(&mut tx).await?; + + let result: Option = + sqlx::query_scalar("SELECT eql_v2.max(e)::text FROM encrypted_opf_nulls WHERE e IS NULL") + .fetch_one(&mut *tx) + .await?; + assert!(result.is_none(), "eql_v2.max over NULL-only should be NULL"); + tx.rollback().await?; + Ok(()) +} + +// ========== BETWEEN with OPE-encoded data ========== +// +// BETWEEN expands to `lo <= x AND x <= hi`, so this exercises both `<=` +// and `>=` dispatching through compare. + +#[sqlx::test] +async fn between_with_opf_inclusive_bounds(pool: PgPool) -> Result<()> { + // signals 1 < 3 < 5 < 7 < 9; BETWEEN 3 AND 7 should include 3, 5, 7. + let mut tx = pool.begin().await?; + sqlx::query( + "CREATE TABLE encrypted_opf_between( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + e eql_v2_encrypted + )", + ) + .execute(&mut *tx) + .await?; + for signal in [1u8, 3, 5, 7, 9] { + sqlx::query(&format!( + "INSERT INTO encrypted_opf_between(e) VALUES (eql_v2.to_encrypted('{}'::jsonb))", + opf_payload(signal) + )) + .execute(&mut *tx) + .await?; + } + + let lo = opf_payload(3); + let hi = opf_payload(7); + let sql = format!( + "SELECT count(*)::bigint FROM encrypted_opf_between + WHERE e BETWEEN eql_v2.to_encrypted('{}'::jsonb) AND eql_v2.to_encrypted('{}'::jsonb)", + lo, hi + ); + let count: i64 = sqlx::query_scalar(&sql).fetch_one(&mut *tx).await?; + assert_eq!(count, 3, "BETWEEN 3 AND 7 should match signals 3, 5, 7"); + tx.rollback().await?; + Ok(()) +} + +#[sqlx::test] +async fn between_with_opv_inclusive_bounds(pool: PgPool) -> Result<()> { + // Variable-width OPE: two-byte ciphertexts compared by bytea lex order. + let mut tx = pool.begin().await?; + sqlx::query( + "CREATE TABLE encrypted_opv_between( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + e eql_v2_encrypted + )", + ) + .execute(&mut *tx) + .await?; + for first_byte in [0x10u8, 0x30, 0x50, 0x70, 0x90] { + sqlx::query(&format!( + "INSERT INTO encrypted_opv_between(e) VALUES (eql_v2.to_encrypted('{}'::jsonb))", + opv_payload(&[first_byte, 0x00]) + )) + .execute(&mut *tx) + .await?; + } + + let lo = opv_payload(&[0x30, 0x00]); + let hi = opv_payload(&[0x70, 0x00]); + let sql = format!( + "SELECT count(*)::bigint FROM encrypted_opv_between + WHERE e BETWEEN eql_v2.to_encrypted('{}'::jsonb) AND eql_v2.to_encrypted('{}'::jsonb)", + lo, hi + ); + let count: i64 = sqlx::query_scalar(&sql).fetch_one(&mut *tx).await?; + assert_eq!( + count, 3, + "BETWEEN 0x30 AND 0x70 should match 0x30, 0x50, 0x70" + ); + tx.rollback().await?; + Ok(()) +}