diff --git a/src/v3/sem/ore_block_u64_8_256/functions.sql b/src/v3/sem/ore_block_u64_8_256/functions.sql index ccc6a817..f2e155a9 100644 --- a/src/v3/sem/ore_block_u64_8_256/functions.sql +++ b/src/v3/sem/ore_block_u64_8_256/functions.sql @@ -89,8 +89,16 @@ $$ LANGUAGE plpgsql; --! @param b eql_v3.ore_block_u64_8_256_term Second ORE term --! @return integer -1 if a < b, 0 if a = b, 1 if a > b --! @throws Exception if ciphertexts are different lengths +--! @note Marked `IMMUTABLE` (the three `compare_ore_block_u64_8_256_term(s)` +--! overloads all are). This deliberately diverges from the v2 originals, +--! which carry no volatility marker and so default to `VOLATILE`. The +--! comparison is deterministic — its only crypto call, pgcrypto `encrypt()`, +--! is itself `IMMUTABLE STRICT PARALLEL SAFE` — so `IMMUTABLE` lets the +--! planner fold/cache these in ordering and index contexts. NOT `STRICT`: +--! the NULL-handling branches below are load-bearing for the array overload. CREATE FUNCTION eql_v3.compare_ore_block_u64_8_256_term(a eql_v3.ore_block_u64_8_256_term, b eql_v3.ore_block_u64_8_256_term) RETURNS integer + IMMUTABLE SET search_path = pg_catalog, extensions, public AS $$ DECLARE @@ -169,6 +177,7 @@ $$ LANGUAGE plpgsql; --! @return integer -1/0/1, or NULL if either array is NULL CREATE FUNCTION eql_v3.compare_ore_block_u64_8_256_terms(a eql_v3.ore_block_u64_8_256_term[], b eql_v3.ore_block_u64_8_256_term[]) RETURNS integer + IMMUTABLE SET search_path = pg_catalog, extensions, public AS $$ DECLARE @@ -208,6 +217,7 @@ $$ LANGUAGE plpgsql; --! @return integer -1/0/1 CREATE FUNCTION eql_v3.compare_ore_block_u64_8_256_terms(a eql_v3.ore_block_u64_8_256, b eql_v3.ore_block_u64_8_256) RETURNS integer + IMMUTABLE SET search_path = pg_catalog, extensions, public AS $$ BEGIN diff --git a/tests/sqlx/src/fixtures/cipherstash.rs b/tests/sqlx/src/fixtures/cipherstash.rs index 22e552fa..aa3a26af 100644 --- a/tests/sqlx/src/fixtures/cipherstash.rs +++ b/tests/sqlx/src/fixtures/cipherstash.rs @@ -52,6 +52,11 @@ async fn build_cipher() -> Result>> { Ok(Arc::new(cipher)) } +/// The single encrypted-payload column name. Single-sourced here so the +/// `ColumnConfig` built for encryption and the `INSERT` target column in the +/// driver cannot drift apart. +pub const PAYLOAD_COLUMN: &str = "payload"; + /// Build a `ColumnConfig` from the fixture spec's index list + cast. /// /// `IndexKind` is a typed enum — every value is a real EQL index by @@ -59,11 +64,6 @@ async fn build_cipher() -> Result>> { /// fail on an unknown index name. Extending fixture coverage to a new /// index is one variant on `IndexKind` plus one arm here, both compile- /// time checked. -/// The single encrypted-payload column name. Single-sourced here so the -/// `ColumnConfig` built for encryption and the `INSERT` target column in the -/// driver cannot drift apart. -pub const PAYLOAD_COLUMN: &str = "payload"; - pub fn column_config_for(spec_indexes: &[IndexKind], cast: Cast) -> Result { let column_type = cast_to_column_type(cast)?; let mut config = ColumnConfig::build(PAYLOAD_COLUMN).casts_as(column_type); diff --git a/tests/sqlx/tests/encrypted_domain/family/sem.rs b/tests/sqlx/tests/encrypted_domain/family/sem.rs index b7d2543c..8329a099 100644 --- a/tests/sqlx/tests/encrypted_domain/family/sem.rs +++ b/tests/sqlx/tests/encrypted_domain/family/sem.rs @@ -393,3 +393,46 @@ async fn jsonb_array_to_ore_block_input_shapes(pool: PgPool) -> Result<()> { Ok(()) } + +/// T8 — Volatility pin: all three `compare_ore_block_u64_8_256_term(s)` overloads +/// (term×term, term[]×term[], composite×composite) must be `IMMUTABLE` +/// (`provolatile = 'i'`). This deliberately diverges from the `eql_v2` +/// originals, which carry no marker and default to `VOLATILE`. The comparison +/// is deterministic — pgcrypto `encrypt()` is itself `IMMUTABLE` — and the +/// marker is what lets the planner fold/cache these in ordering/index contexts, +/// so a silent regression to `VOLATILE` (e.g. dropping the keyword on a future +/// edit) must fail CI. +#[sqlx::test] +async fn ore_comparators_are_immutable(pool: PgPool) -> Result<()> { + let rows: Vec<(String, String)> = sqlx::query_as( + r#" + SELECT pg_catalog.pg_get_function_arguments(p.oid) AS args, + p.provolatile::text AS provolatile + FROM pg_catalog.pg_proc p + WHERE p.pronamespace = 'eql_v3'::regnamespace + AND p.proname IN ( + 'compare_ore_block_u64_8_256_term', + 'compare_ore_block_u64_8_256_terms' + ) + ORDER BY args + "#, + ) + .fetch_all(&pool) + .await?; + + // Pin the count so an overload silently disappearing (or a fourth appearing) + // also fails, not just a volatility flip. + assert_eq!( + rows.len(), + 3, + "expected exactly 3 compare overloads, found: {rows:?}" + ); + + for (args, provolatile) in &rows { + assert_eq!( + provolatile, "i", + "compare_ore_block_u64_8_256_term(s)({args}) must be IMMUTABLE, got provolatile={provolatile}" + ); + } + Ok(()) +}