diff --git a/.gitignore b/.gitignore index 703356ef..d9a13a2e 100644 --- a/.gitignore +++ b/.gitignore @@ -221,3 +221,11 @@ tests/ste_vec_*M.sql* # Rust build artifacts (using sccache) tests/sqlx/target/ + +# Work files (agent-generated, not for version control) +.work/ +.serena/ + +# Build variants - protect variant deps +src/deps-protect.txt +src/deps-ordered-protect.txt diff --git a/src/ste_vec/functions.sql b/src/ste_vec/functions.sql index 98a2546f..355f299f 100644 --- a/src/ste_vec/functions.sql +++ b/src/ste_vec/functions.sql @@ -241,18 +241,16 @@ $$ LANGUAGE plpgsql; ---! @brief Extract encrypted JSONB as array for GIN indexing +--! @brief Extract full encrypted JSONB elements as array --! ---! Extracts the encrypted JSONB data and returns it as a native jsonb[] ---! array. This enables efficient GIN indexing using PostgreSQL's built-in array_ops ---! which has native hash support for jsonb elements. +--! Extracts all JSONB elements from the STE vector including non-deterministic fields. +--! Use jsonb_array() instead for GIN indexing and containment queries. --! --! @param val jsonb containing encrypted EQL payload ---! @return jsonb[] Array of JSONB elements for indexing +--! @return jsonb[] Array of full JSONB elements --! ---! @note Preferred for GIN indexes as jsonb has native hash support ---! @see eql_v2.jsonb_array(eql_v2_encrypted) -CREATE FUNCTION eql_v2.jsonb_array(val jsonb) +--! @see eql_v2.jsonb_array +CREATE FUNCTION eql_v2.jsonb_array_from_array_elements(val jsonb) RETURNS jsonb[] IMMUTABLE STRICT PARALLEL SAFE LANGUAGE SQL @@ -266,21 +264,53 @@ AS $$ $$; ---! @brief Extract encrypted JSONB as array from encrypted column value ---! ---! Extracts the encrypted JSONB data from an encrypted column value and returns it as a ---! native jsonb[] array for GIN indexing. +--! @brief Extract full encrypted JSONB elements as array from encrypted column --! --! @param val eql_v2_encrypted Encrypted column value ---! @return jsonb[] Array of JSONB elements for indexing +--! @return jsonb[] Array of full JSONB elements --! ---! @example ---! -- Create GIN index for containment queries ---! CREATE INDEX idx_jsonb ON mytable USING GIN (eql_v2.jsonb_array(encrypted_col)); +--! @see eql_v2.jsonb_array_from_array_elements(jsonb) +CREATE FUNCTION eql_v2.jsonb_array_from_array_elements(val eql_v2_encrypted) +RETURNS jsonb[] +IMMUTABLE STRICT PARALLEL SAFE +LANGUAGE SQL +AS $$ + SELECT eql_v2.jsonb_array_from_array_elements(val.data); +$$; + + +--! @brief Extract deterministic fields as array for GIN indexing +--! +--! Extracts only deterministic search term fields (s, b3, hm, ocv, ocf) from each +--! STE vector element. Excludes non-deterministic ciphertext for correct containment +--! comparison using PostgreSQL's native @> operator. +--! +--! @param val jsonb containing encrypted EQL payload +--! @return jsonb[] Array of JSONB elements with only deterministic fields +--! +--! @note Use this for GIN indexes and containment queries +--! @see eql_v2.jsonb_contains +CREATE FUNCTION eql_v2.jsonb_array(val jsonb) +RETURNS jsonb[] +IMMUTABLE STRICT PARALLEL SAFE +LANGUAGE SQL +AS $$ + SELECT ARRAY( + SELECT jsonb_object_agg(kv.key, kv.value) + FROM jsonb_array_elements( + CASE WHEN val ? 'sv' THEN val->'sv' ELSE jsonb_build_array(val) END + ) AS elem, + LATERAL jsonb_each(elem) AS kv(key, value) + WHERE kv.key IN ('s', 'b3', 'hm', 'ocv', 'ocf') + GROUP BY elem + ); +$$; + + +--! @brief Extract deterministic fields as array from encrypted column --! ---! -- Query using containment ---! SELECT * FROM mytable ---! WHERE eql_v2.jsonb_array(encrypted_col) @> eql_v2.jsonb_array(search_value); +--! @param val eql_v2_encrypted Encrypted column value +--! @return jsonb[] Array of JSONB elements with only deterministic fields --! --! @see eql_v2.jsonb_array(jsonb) CREATE FUNCTION eql_v2.jsonb_array(val eql_v2_encrypted) @@ -321,6 +351,46 @@ AS $$ $$; +--! @brief GIN-indexable JSONB containment check (encrypted, jsonb) +--! +--! Checks if encrypted value 'a' contains all JSONB elements from jsonb value 'b'. +--! Uses jsonb[] arrays internally for native PostgreSQL GIN index support. +--! +--! @param a eql_v2_encrypted Container value (typically a table column) +--! @param b jsonb JSONB value to search for +--! @return Boolean True if a contains all elements of b +--! +--! @see eql_v2.jsonb_array +--! @see eql_v2.jsonb_contains(eql_v2_encrypted, eql_v2_encrypted) +CREATE FUNCTION eql_v2.jsonb_contains(a eql_v2_encrypted, b jsonb) +RETURNS boolean +IMMUTABLE STRICT PARALLEL SAFE +LANGUAGE SQL +AS $$ + SELECT eql_v2.jsonb_array(a) @> eql_v2.jsonb_array(b); +$$; + + +--! @brief GIN-indexable JSONB containment check (jsonb, encrypted) +--! +--! Checks if jsonb value 'a' contains all JSONB elements from encrypted value 'b'. +--! Uses jsonb[] arrays internally for native PostgreSQL GIN index support. +--! +--! @param a jsonb Container JSONB value +--! @param b eql_v2_encrypted Encrypted value to search for +--! @return Boolean True if a contains all elements of b +--! +--! @see eql_v2.jsonb_array +--! @see eql_v2.jsonb_contains(eql_v2_encrypted, eql_v2_encrypted) +CREATE FUNCTION eql_v2.jsonb_contains(a jsonb, b eql_v2_encrypted) +RETURNS boolean +IMMUTABLE STRICT PARALLEL SAFE +LANGUAGE SQL +AS $$ + SELECT eql_v2.jsonb_array(a) @> eql_v2.jsonb_array(b); +$$; + + --! @brief GIN-indexable JSONB "is contained by" check --! --! Checks if all JSONB elements from 'a' are contained in 'b'. @@ -341,6 +411,46 @@ AS $$ $$; +--! @brief GIN-indexable JSONB "is contained by" check (encrypted, jsonb) +--! +--! Checks if all JSONB elements from encrypted value 'a' are contained in jsonb value 'b'. +--! Uses jsonb[] arrays internally for native PostgreSQL GIN index support. +--! +--! @param a eql_v2_encrypted Value to check (typically a table column) +--! @param b jsonb Container JSONB value +--! @return Boolean True if all elements of a are contained in b +--! +--! @see eql_v2.jsonb_array +--! @see eql_v2.jsonb_contained_by(eql_v2_encrypted, eql_v2_encrypted) +CREATE FUNCTION eql_v2.jsonb_contained_by(a eql_v2_encrypted, b jsonb) +RETURNS boolean +IMMUTABLE STRICT PARALLEL SAFE +LANGUAGE SQL +AS $$ + SELECT eql_v2.jsonb_array(a) <@ eql_v2.jsonb_array(b); +$$; + + +--! @brief GIN-indexable JSONB "is contained by" check (jsonb, encrypted) +--! +--! Checks if all JSONB elements from jsonb value 'a' are contained in encrypted value 'b'. +--! Uses jsonb[] arrays internally for native PostgreSQL GIN index support. +--! +--! @param a jsonb Value to check +--! @param b eql_v2_encrypted Container encrypted value +--! @return Boolean True if all elements of a are contained in b +--! +--! @see eql_v2.jsonb_array +--! @see eql_v2.jsonb_contained_by(eql_v2_encrypted, eql_v2_encrypted) +CREATE FUNCTION eql_v2.jsonb_contained_by(a jsonb, b eql_v2_encrypted) +RETURNS boolean +IMMUTABLE STRICT PARALLEL SAFE +LANGUAGE SQL +AS $$ + SELECT eql_v2.jsonb_array(a) <@ eql_v2.jsonb_array(b); +$$; + + --! @brief Check if STE vector array contains a specific encrypted element --! --! Tests whether any element in the STE vector array 'a' contains the encrypted value 'b'. diff --git a/tests/sqlx/src/helpers.rs b/tests/sqlx/src/helpers.rs index 53dbdf4e..38fcfd7f 100644 --- a/tests/sqlx/src/helpers.rs +++ b/tests/sqlx/src/helpers.rs @@ -3,6 +3,7 @@ //! Common utilities for working with encrypted data in tests. use anyhow::{Context, Result}; +use serde_json; use sqlx::{PgPool, Row}; /// Fetch ORE encrypted value from pre-seeded ore table @@ -83,7 +84,7 @@ pub async fn get_ore_encrypted_as_jsonb(pool: &PgPool, id: i32) -> Result Result Result { - let sql = format!("SELECT e::text FROM {} WHERE id = {}", table, id); - let row = sqlx::query(&sql) +pub async fn get_ste_vec_encrypted( + pool: &PgPool, + table: &str, + id: i32, +) -> Result { + let sql = format!("SELECT (e).data::jsonb FROM {} WHERE id = {}", table, id); + let result: serde_json::Value = sqlx::query_scalar(&sql) .fetch_one(pool) .await .with_context(|| format!("fetching {} encrypted value for id={}", table, id))?; - let result: Option = row - .try_get(0) - .with_context(|| format!("extracting text column for id={}", id))?; + Ok(result) +} + +/// Fetch two STE vec encrypted values from the same table +/// +/// Useful for encrypted-to-encrypted containment tests where we need +/// two distinct encrypted values from the same table. +/// +/// # Arguments +/// * `pool` - Database connection pool +/// * `table` - Table name to query +/// * `id1` - First row id +/// * `id2` - Second row id +/// +/// # Returns +/// Tuple of (enc1, enc2) as serde_json::Value +pub async fn get_ste_vec_encrypted_pair( + pool: &PgPool, + table: &str, + id1: i32, + id2: i32, +) -> Result<(serde_json::Value, serde_json::Value)> { + let enc1 = get_ste_vec_encrypted(pool, table, id1).await?; + let enc2 = get_ste_vec_encrypted(pool, table, id2).await?; + Ok((enc1, enc2)) +} + +/// Extract a single SV element from an encrypted value as serde_json::Value +/// +/// Fetches an encrypted value from the specified table and extracts +/// a specific element from its sv array by index. +/// +/// # Arguments +/// * `pool` - Database connection pool +/// * `table` - Table name to query (e.g., "ste_vec" or "ste_vec_vast") +/// * `id` - Row id to fetch +/// * `sv_index` - Index into the sv array (0-based) +/// +/// # Returns +/// The sv element as serde_json::Value, suitable for use in containment queries +/// Use .to_string() when a literal string is needed for SQL interpolation +pub async fn get_ste_vec_sv_element( + pool: &PgPool, + table: &str, + id: i32, + sv_index: i32, +) -> Result { + let sql = format!( + "SELECT ((e).data->'sv'->{})::jsonb FROM {} WHERE id = {}", + sv_index, table, id + ); + let result: Option = sqlx::query_scalar(&sql) + .fetch_one(pool) + .await + .with_context(|| { + format!( + "extracting sv element {} from {} id={}", + sv_index, table, id + ) + })?; - result.with_context(|| format!("{} table returned NULL for id={}", table, id)) + result.with_context(|| { + format!( + "{} sv element extraction returned NULL for id={}, index={}", + table, id, sv_index + ) + }) } /// Extract selector term using SQL helper functions diff --git a/tests/sqlx/src/lib.rs b/tests/sqlx/src/lib.rs index 7fc11b19..5358ab9c 100644 --- a/tests/sqlx/src/lib.rs +++ b/tests/sqlx/src/lib.rs @@ -13,7 +13,8 @@ pub use assertions::QueryAssertion; pub use helpers::{ analyze_table, assert_uses_index, assert_uses_seq_scan, create_jsonb_gin_index, explain_query, get_encrypted_term, get_ore_encrypted, get_ore_encrypted_as_jsonb, get_ste_vec_encrypted, - get_ste_vec_selector_term, get_ste_vec_term_by_id, + get_ste_vec_encrypted_pair, get_ste_vec_selector_term, get_ste_vec_sv_element, + get_ste_vec_term_by_id, }; pub use index_types as IndexTypes; pub use selectors::Selectors; diff --git a/tests/sqlx/tests/containment_with_index_tests.rs b/tests/sqlx/tests/containment_with_index_tests.rs index 1d7a65fa..b03f5a23 100644 --- a/tests/sqlx/tests/containment_with_index_tests.rs +++ b/tests/sqlx/tests/containment_with_index_tests.rs @@ -1,17 +1,25 @@ //! Containment with index tests (@> and <@) for encrypted JSONB //! -//! Tests that encrypted JSONB containment operations work correctly with -//! GIN indexes using the jsonb_array() function which returns jsonb[] arrays. +//! Tests cover all operator/type combinations in the coverage matrix: //! -//! The jsonb_array approach leverages PostgreSQL's native hash support for jsonb -//! elements, enabling efficient GIN indexed containment queries at scale. +//! | Operator | LHS | RHS | Test | +//! |--------------------|--------------|--------------|----------------------------------| +//! | jsonb_contains | encrypted | jsonb_param | contains_encrypted_jsonb_param | +//! | jsonb_contains | encrypted | encrypted | contains_encrypted_encrypted | +//! | jsonb_contains | jsonb_param | encrypted | contains_jsonb_param_encrypted | +//! | jsonb_contained_by | encrypted | jsonb_param | contained_by_encrypted_jsonb_param | +//! | jsonb_contained_by | encrypted | encrypted | contained_by_encrypted_encrypted | +//! | jsonb_contained_by | jsonb_param | encrypted | contained_by_jsonb_param_encrypted | +//! +//! Uses parameterized queries (jsonb_param) as the primary pattern since +//! that's what real clients use when integrating with EQL. //! //! Uses the ste_vec_vast table (500 rows) from migration 005_install_ste_vec_vast_data.sql use anyhow::Result; use eql_tests::{ analyze_table, assert_uses_index, assert_uses_seq_scan, create_jsonb_gin_index, explain_query, - get_ste_vec_encrypted, + get_ste_vec_encrypted, get_ste_vec_sv_element, }; use sqlx::PgPool; @@ -33,188 +41,418 @@ async fn setup_ste_vec_vast_gin_index(pool: &PgPool) -> Result<()> { Ok(()) } -/// Assert that a containment query returns at least one row -/// -/// # Arguments -/// * `pool` - Database connection pool -/// * `sql` - SQL query with `{}` placeholder for the encrypted value -/// * `value` - Encrypted value to substitute into the query -/// -/// # Example -/// ```ignore -/// let sql = "SELECT 1 FROM t WHERE eql_v2.ste_vec(e) @> eql_v2.ste_vec('{}'::eql_v2_encrypted) LIMIT 1"; -/// assert_contains(&pool, sql, &row_b).await?; -/// ``` -async fn assert_contains(pool: &PgPool, sql: &str) -> Result<()> { - let result: Option<(i32,)> = sqlx::query_as(&sql).fetch_optional(pool).await?; - assert!( - result.is_some(), - "containment check failed - no rows returned: {}", - sql +// ============================================================================ +// Sanity Tests: Value Contains Itself (Exact Match) +// ============================================================================ +// +// These tests verify basic functionality - a value trivially contains itself. +// They serve as sanity checks that the GIN index and containment functions work. + +#[sqlx::test] +async fn sanity_before_after_index_creation(pool: PgPool) -> Result<()> { + // Demonstrates GIN index impact: Seq Scan before, Index Scan after + analyze_table(&pool, STE_VEC_VAST_TABLE).await?; + + let id = 1; + let row = get_ste_vec_encrypted(&pool, STE_VEC_VAST_TABLE, id).await?; + + let sql = format!( + "SELECT 1 FROM {} WHERE eql_v2.jsonb_array(e) @> eql_v2.jsonb_array('{}'::jsonb) LIMIT 1", + STE_VEC_VAST_TABLE, row + ); + + // BEFORE: Without index, should use Seq Scan + let explain_before = explain_query(&pool, &sql).await?; + assert_uses_seq_scan(&explain_before); + + // Create the GIN index + setup_ste_vec_vast_gin_index(&pool).await?; + + // AFTER: With index, should use the GIN index + assert_uses_index(&pool, &sql, STE_VEC_VAST_GIN_INDEX).await?; + + Ok(()) +} + +#[sqlx::test] +async fn sanity_non_matching_returns_empty(pool: PgPool) -> Result<()> { + // Non-existent value returns no results + setup_ste_vec_vast_gin_index(&pool).await?; + + let sql = format!( + "SELECT count(*) FROM {} WHERE eql_v2.jsonb_array(e) @> ARRAY['{{\"s\":\"nonexistent\",\"v\":1}}'::jsonb]", + STE_VEC_VAST_TABLE ); + + let count: (i64,) = sqlx::query_as(&sql).fetch_one(&pool).await?; + assert_eq!(count.0, 0, "Expected no matches for non-existent selector"); + Ok(()) } // ============================================================================ -// Two-Value Containment Tests (from legacy @>_test.sql) +// Coverage Matrix Tests: All Operator/Type Combinations // ============================================================================ +// +// Each test covers exactly one operator/type combination. +// Uses parameterized queries (jsonb_param) as the primary pattern +// since that's what real clients use. #[sqlx::test] -async fn jsonb_identical_values_contain_each_other_uses_index(pool: PgPool) -> Result<()> { - // Test: GIN indexed ste_vec containment with identical values - // - // 1. Create GIN index on eql_v2.ste_vec(e) - // 2. Verify contains operator returns true - // 3. Verify index is used via EXPLAIN - // - // Uses ste_vec_vast table (500 rows) from migration 005_install_ste_vec_vast_data.sql - // The dataset size ensures PostgreSQL's query planner naturally chooses the GIN index - // over sequential scan without needing to disable seqscan. +async fn contains_encrypted_jsonb_param(pool: PgPool) -> Result<()> { + // Coverage: jsonb_contains(encrypted, jsonb_param) + // Most common pattern - client sends jsonb parameter setup_ste_vec_vast_gin_index(&pool).await?; let id = 1; + let sv_element = get_ste_vec_sv_element(&pool, STE_VEC_VAST_TABLE, id, 0).await?; - // Fetch a record to use as the containment target - let row_b = get_ste_vec_encrypted(&pool, STE_VEC_VAST_TABLE, id).await?; + let sql = format!( + "SELECT id FROM {} WHERE eql_v2.jsonb_contains(e, $1::jsonb) AND id = $2", + STE_VEC_VAST_TABLE + ); - // println!("{}", row_b); + let result: Option<(i64,)> = sqlx::query_as(&sql) + .bind(&sv_element) + .bind(id) + .fetch_optional(&pool) + .await?; - // Test containment: a @> b using jsonb_array arrays (jsonb[] has native GIN support) - // SQL has containment in WHERE clause so GIN index can be used + assert!( + result.is_some(), + "jsonb_contains(encrypted, jsonb_param) should find match" + ); + assert_eq!(result.unwrap().0, id as i64); + + // Verify index usage with literal for EXPLAIN (can't EXPLAIN with params) + let explain_sql = format!( + "SELECT id FROM {} WHERE eql_v2.jsonb_contains(e, '{}'::jsonb) LIMIT 1", + STE_VEC_VAST_TABLE, sv_element + ); + assert_uses_index(&pool, &explain_sql, STE_VEC_VAST_GIN_INDEX).await?; + + Ok(()) +} + +#[sqlx::test] +async fn contains_encrypted_encrypted(pool: PgPool) -> Result<()> { + // Coverage: jsonb_contains(encrypted, encrypted) + // Encrypted column contains another encrypted value + setup_ste_vec_vast_gin_index(&pool).await?; + + let id = 1; + // Get full encrypted value - should contain itself + let encrypted = get_ste_vec_encrypted(&pool, STE_VEC_VAST_TABLE, id).await?; + + // Use parameterized query with encrypted value as jsonb let sql = format!( - "SELECT 1 FROM {} WHERE eql_v2.jsonb_array(e) @> eql_v2.jsonb_array('{}'::eql_v2_encrypted) LIMIT 1", - STE_VEC_VAST_TABLE, row_b + "SELECT id FROM {} WHERE eql_v2.jsonb_contains(e, $1::jsonb) AND id = $2", + STE_VEC_VAST_TABLE ); - assert_contains(&pool, &sql).await?; - assert_uses_index(&pool, &sql, STE_VEC_VAST_GIN_INDEX).await?; + let result: Option<(i64,)> = sqlx::query_as(&sql) + .bind(&encrypted) + .bind(id) + .fetch_optional(&pool) + .await?; + + assert!( + result.is_some(), + "jsonb_contains(encrypted, encrypted) should find match (value contains itself)" + ); + assert_eq!(result.unwrap().0, id as i64); Ok(()) } #[sqlx::test] -async fn jsonb_contains_helper_uses_index(pool: PgPool) -> Result<()> { - // Test: The jsonb_contains helper function with GIN index - // - // Verifies that the convenience wrapper function works correctly - // and the underlying query uses the GIN index. +async fn contains_jsonb_param_encrypted(pool: PgPool) -> Result<()> { + // Coverage: jsonb_contains(jsonb_param, encrypted) + // Check if jsonb parameter contains the encrypted column + // This is the inverse - rarely used but must work setup_ste_vec_vast_gin_index(&pool).await?; let id = 1; - let row_b = get_ste_vec_encrypted(&pool, STE_VEC_VAST_TABLE, id).await?; + // Get full encrypted value - it contains its own sv elements + let encrypted = get_ste_vec_encrypted(&pool, STE_VEC_VAST_TABLE, id).await?; - // Test using the helper function + // Check if the full encrypted value (as param) contains the column + // This should match because encrypted contains itself let sql = format!( - "SELECT 1 FROM {} WHERE eql_v2.jsonb_contains(e, '{}'::eql_v2_encrypted) LIMIT 1", - STE_VEC_VAST_TABLE, row_b + "SELECT id FROM {} WHERE eql_v2.jsonb_contains($1::jsonb, e) AND id = $2", + STE_VEC_VAST_TABLE ); - assert_contains(&pool, &sql).await?; - assert_uses_index(&pool, &sql, STE_VEC_VAST_GIN_INDEX).await?; + let result: Option<(i64,)> = sqlx::query_as(&sql) + .bind(&encrypted) + .bind(id) + .fetch_optional(&pool) + .await?; + + assert!( + result.is_some(), + "jsonb_contains(jsonb_param, encrypted) should find match" + ); + assert_eq!(result.unwrap().0, id as i64); Ok(()) } #[sqlx::test] -async fn jsonb_array_containment_multiple_rows(pool: PgPool) -> Result<()> { - // Test: Verify containment works for multiple different rows - // - // Tests that several different rows can find themselves via containment, - // ensuring the GIN index works across the dataset. +async fn contains_encrypted_param_encrypted(pool: PgPool) -> Result<()> { + // Coverage: jsonb_contains(encrypted_param, encrypted) + // Check if encrypted parameter contains the encrypted column setup_ste_vec_vast_gin_index(&pool).await?; - // Test several different rows across the 500-row dataset - // Selected IDs test distribution across dataset: beginning (1), sparse early (5, 10), - // mid-range powers of ten (50, 100), near-end (250), and end (499) - for id in [1, 5, 10, 50, 100, 250, 499] { - let row = get_ste_vec_encrypted(&pool, STE_VEC_VAST_TABLE, id).await?; + let id = 1; + // Get full encrypted value - it contains its own sv elements + let encrypted_param = get_ste_vec_encrypted(&pool, STE_VEC_VAST_TABLE, id).await?; - let sql = format!( - "SELECT 1 FROM {} WHERE eql_v2.jsonb_array(e) @> eql_v2.jsonb_array('{}'::eql_v2_encrypted) LIMIT 1", - STE_VEC_VAST_TABLE, row - ); + // Check if the encrypted value (as param) contains the column + // Should match because encrypted contains itself + let sql = format!( + "SELECT id FROM {} WHERE eql_v2.jsonb_contains($1::jsonb, e) AND id = $2", + STE_VEC_VAST_TABLE + ); - assert_contains(&pool, &sql).await?; - } + let result: Option<(i64,)> = sqlx::query_as(&sql) + .bind(&encrypted_param) + .bind(id) + .fetch_optional(&pool) + .await?; + + assert!( + result.is_some(), + "jsonb_contains(encrypted_param, encrypted) should find match" + ); + assert_eq!(result.unwrap().0, id as i64); Ok(()) } #[sqlx::test] -async fn jsonb_array_non_matching_returns_empty(pool: PgPool) -> Result<()> { - // Test: Non-matching value returns no results - // - // Verifies that searching for a non-existent value correctly returns empty. +async fn contains_encrypted_encrypted_param(pool: PgPool) -> Result<()> { + // Coverage: jsonb_contains(encrypted, encrypted_param) + // Encrypted column contains an encrypted value passed as parameter setup_ste_vec_vast_gin_index(&pool).await?; - // Create a fake encrypted value that won't match anything - // We'll use a modified version of an existing row + let id = 1; + // Get full encrypted value to use as parameter + let encrypted_param = get_ste_vec_encrypted(&pool, STE_VEC_VAST_TABLE, id).await?; + let sql = format!( - "SELECT count(*) FROM {} WHERE eql_v2.jsonb_array(e) @> ARRAY['{{\"s\":\"nonexistent\",\"v\":1}}'::jsonb]", + "SELECT id FROM {} WHERE eql_v2.jsonb_contains(e, $1::jsonb) AND id = $2", STE_VEC_VAST_TABLE ); - let count: (i64,) = sqlx::query_as(&sql).fetch_one(&pool).await?; - assert_eq!(count.0, 0, "Expected no matches for non-existent selector"); + let result: Option<(i64,)> = sqlx::query_as(&sql) + .bind(&encrypted_param) + .bind(id) + .fetch_optional(&pool) + .await?; + + assert!( + result.is_some(), + "jsonb_contains(encrypted, encrypted_param) should find match" + ); + assert_eq!(result.unwrap().0, id as i64); Ok(()) } +// ============================================================================ +// Helper Function Tests +// ============================================================================ + #[sqlx::test] -async fn jsonb_array_count_with_index(pool: PgPool) -> Result<()> { - // Test: Count query uses GIN index efficiently - // - // Verifies that counting matches also uses the index. +async fn test_get_ste_vec_encrypted_returns_json_value(pool: PgPool) -> Result<()> { + // Test that get_ste_vec_encrypted returns serde_json::Value + let encrypted = get_ste_vec_encrypted(&pool, STE_VEC_VAST_TABLE, 1).await?; + + // Should be an object with expected encrypted structure + assert!( + encrypted.is_object(), + "encrypted value should be a JSON object" + ); + assert!( + encrypted.get("sv").is_some(), + "encrypted value should have 'sv' field" + ); + + Ok(()) +} + +#[sqlx::test] +async fn test_get_ste_vec_sv_element_returns_json_value(pool: PgPool) -> Result<()> { + // Test that get_ste_vec_sv_element returns serde_json::Value with expected fields + let sv_element = get_ste_vec_sv_element(&pool, STE_VEC_VAST_TABLE, 1, 0).await?; + + // Should be an object with expected fields + assert!(sv_element.is_object(), "sv element should be a JSON object"); + assert!( + sv_element.get("s").is_some(), + "sv element should have 's' (selector) field" + ); + + Ok(()) +} + +#[sqlx::test] +async fn contained_by_encrypted_jsonb_param(pool: PgPool) -> Result<()> { + // Coverage: jsonb_contained_by(encrypted, jsonb_param) + // Is encrypted column contained by the jsonb parameter? + // True when param equals or is superset of encrypted setup_ste_vec_vast_gin_index(&pool).await?; let id = 1; - let row = get_ste_vec_encrypted(&pool, STE_VEC_VAST_TABLE, id).await?; + // Get full encrypted value - column is contained by itself + let encrypted = get_ste_vec_encrypted(&pool, STE_VEC_VAST_TABLE, id).await?; let sql = format!( - "SELECT count(*) FROM {} WHERE eql_v2.jsonb_array(e) @> eql_v2.jsonb_array('{}'::eql_v2_encrypted)", - STE_VEC_VAST_TABLE, row + "SELECT id FROM {} WHERE eql_v2.jsonb_contained_by(e, $1::jsonb) AND id = $2", + STE_VEC_VAST_TABLE ); - let count: (i64,) = sqlx::query_as(&sql).fetch_one(&pool).await?; - // Should find at least one match (the row itself) - assert!(count.0 >= 1, "Expected at least one match, got {}", count.0); + let result: Option<(i64,)> = sqlx::query_as(&sql) + .bind(&encrypted) + .bind(id) + .fetch_optional(&pool) + .await?; - // Verify index is used for count queries too - assert_uses_index(&pool, &sql, STE_VEC_VAST_GIN_INDEX).await?; + assert!( + result.is_some(), + "jsonb_contained_by(encrypted, jsonb_param) should find match" + ); + assert_eq!(result.unwrap().0, id as i64); Ok(()) } #[sqlx::test] -async fn jsonb_containment_uses_seq_scan_without_index(pool: PgPool) -> Result<()> { - // Test: Verify sequential scan is used BEFORE GIN index is created - // - // This test demonstrates that the GIN index actually makes a difference: - // 1. Without index: query uses Seq Scan - // 2. After creating index: query uses Index Scan - // - // This is a "before and after" test to prove the index improves query execution. - - // Run ANALYZE first to ensure planner has accurate statistics - analyze_table(&pool, STE_VEC_VAST_TABLE).await?; +async fn contained_by_encrypted_encrypted(pool: PgPool) -> Result<()> { + // Coverage: jsonb_contained_by(encrypted, encrypted) + // Is encrypted column contained by another encrypted value? + setup_ste_vec_vast_gin_index(&pool).await?; let id = 1; - let row = get_ste_vec_encrypted(&pool, STE_VEC_VAST_TABLE, id).await?; + // Get full encrypted value - column is contained by itself + let encrypted = get_ste_vec_encrypted(&pool, STE_VEC_VAST_TABLE, id).await?; let sql = format!( - "SELECT 1 FROM {} WHERE eql_v2.jsonb_array(e) @> eql_v2.jsonb_array('{}'::eql_v2_encrypted) LIMIT 1", - STE_VEC_VAST_TABLE, row + "SELECT id FROM {} WHERE eql_v2.jsonb_contained_by(e, $1::jsonb) AND id = $2", + STE_VEC_VAST_TABLE ); - // BEFORE: Without index, should use Seq Scan - let explain_before = explain_query(&pool, &sql).await?; - assert_uses_seq_scan(&explain_before); + let result: Option<(i64,)> = sqlx::query_as(&sql) + .bind(&encrypted) + .bind(id) + .fetch_optional(&pool) + .await?; - // Create the GIN index + assert!( + result.is_some(), + "jsonb_contained_by(encrypted, encrypted) should find match" + ); + assert_eq!(result.unwrap().0, id as i64); + + Ok(()) +} + +#[sqlx::test] +async fn contained_by_encrypted_encrypted_param(pool: PgPool) -> Result<()> { + // Coverage: jsonb_contained_by(encrypted, encrypted_param) + // Is encrypted column contained by the encrypted parameter? + // True when param equals or is superset of encrypted setup_ste_vec_vast_gin_index(&pool).await?; - // AFTER: With index, should use the GIN index - assert_uses_index(&pool, &sql, STE_VEC_VAST_GIN_INDEX).await?; + let id = 1; + // Get full encrypted value - column is contained by itself + let encrypted_param = get_ste_vec_encrypted(&pool, STE_VEC_VAST_TABLE, id).await?; + + let sql = format!( + "SELECT id FROM {} WHERE eql_v2.jsonb_contained_by(e, $1::jsonb) AND id = $2", + STE_VEC_VAST_TABLE + ); + + let result: Option<(i64,)> = sqlx::query_as(&sql) + .bind(&encrypted_param) + .bind(id) + .fetch_optional(&pool) + .await?; + + assert!( + result.is_some(), + "jsonb_contained_by(encrypted, encrypted_param) should find match" + ); + assert_eq!(result.unwrap().0, id as i64); + + Ok(()) +} + +#[sqlx::test] +async fn contained_by_jsonb_param_encrypted(pool: PgPool) -> Result<()> { + // Coverage: jsonb_contained_by(jsonb_param, encrypted) + // Is jsonb parameter contained by the encrypted column? + // Single sv element should be contained in the full encrypted value + setup_ste_vec_vast_gin_index(&pool).await?; + + let id = 1; + let sv_element = get_ste_vec_sv_element(&pool, STE_VEC_VAST_TABLE, id, 0).await?; + + let sql = format!( + "SELECT id FROM {} WHERE eql_v2.jsonb_contained_by($1::jsonb, e) AND id = $2", + STE_VEC_VAST_TABLE + ); + + let result: Option<(i64,)> = sqlx::query_as(&sql) + .bind(&sv_element) + .bind(id) + .fetch_optional(&pool) + .await?; + + assert!( + result.is_some(), + "jsonb_contained_by(jsonb_param, encrypted) should find match" + ); + assert_eq!(result.unwrap().0, id as i64); + + // Verify index usage + let explain_sql = format!( + "SELECT id FROM {} WHERE eql_v2.jsonb_contained_by('{}'::jsonb, e) LIMIT 1", + STE_VEC_VAST_TABLE, sv_element + ); + assert_uses_index(&pool, &explain_sql, STE_VEC_VAST_GIN_INDEX).await?; + + Ok(()) +} + +#[sqlx::test] +async fn contained_by_encrypted_param_encrypted(pool: PgPool) -> Result<()> { + // Coverage: jsonb_contained_by(encrypted_param, encrypted) + // Is encrypted parameter contained by the encrypted column? + // True when column equals or is superset of parameter + setup_ste_vec_vast_gin_index(&pool).await?; + + let id = 1; + // Get full encrypted value - parameter is contained by itself in column + let encrypted_param = get_ste_vec_encrypted(&pool, STE_VEC_VAST_TABLE, id).await?; + + let sql = format!( + "SELECT id FROM {} WHERE eql_v2.jsonb_contained_by($1::jsonb, e) AND id = $2", + STE_VEC_VAST_TABLE + ); + + let result: Option<(i64,)> = sqlx::query_as(&sql) + .bind(&encrypted_param) + .bind(id) + .fetch_optional(&pool) + .await?; + + assert!( + result.is_some(), + "jsonb_contained_by(encrypted_param, encrypted) should find match" + ); + assert_eq!(result.unwrap().0, id as i64); Ok(()) } diff --git a/tests/sqlx/tests/jsonb_containment_uses_index_tests.rs b/tests/sqlx/tests/jsonb_containment_uses_index_tests.rs new file mode 100644 index 00000000..fb0083cf --- /dev/null +++ b/tests/sqlx/tests/jsonb_containment_uses_index_tests.rs @@ -0,0 +1,484 @@ +//! Macro-based containment tests (@> and <@) for encrypted JSONB with GIN index +//! +//! These tests use a declarative macro pattern to systematically generate containment +//! tests for all operator/argument type combinations. +//! +//! Coverage Matrix (Macro-Generated): +//! +//! | Operator | LHS | RHS | Expected Result | +//! |--------------------|------------------|------------------|-----------------| +//! | jsonb_contains | EncryptedColumn | EncryptedParam | Match (self) | +//! | jsonb_contains | EncryptedColumn | SvElementParam | Match (subset) | +//! | jsonb_contains | EncryptedParam | EncryptedColumn | Match (self) | +//! | jsonb_contains | SvElementParam | EncryptedColumn | NO MATCH | +//! | jsonb_contained_by | EncryptedColumn | EncryptedParam | Match (self) | +//! | jsonb_contained_by | SvElementParam | EncryptedColumn | Match (subset) | +//! | jsonb_contained_by | EncryptedParam | EncryptedColumn | Match (self) | +//! | jsonb_contained_by | EncryptedColumn | SvElementParam | NO MATCH | +//! +//! Uses the ste_vec_vast table (500 rows) from migration 005_install_ste_vec_vast_data.sql + +use anyhow::{Context, Result}; +use eql_tests::{ + analyze_table, assert_uses_index, create_jsonb_gin_index, get_ste_vec_encrypted, + get_ste_vec_sv_element, +}; +use sqlx::PgPool; + +// Constants for ste_vec_vast table testing +const STE_VEC_VAST_TABLE: &str = "ste_vec_vast"; +const STE_VEC_VAST_GIN_INDEX: &str = "ste_vec_vast_gin_idx"; + +// ============================================================================ +// GIN Index Helper Functions +// ============================================================================ + +/// Setup GIN index on ste_vec_vast table for testing +/// +/// Creates the GIN index and runs ANALYZE to ensure query planner +/// has accurate statistics. +async fn setup_ste_vec_vast_gin_index(pool: &PgPool) -> Result<()> { + create_jsonb_gin_index(pool, STE_VEC_VAST_TABLE, STE_VEC_VAST_GIN_INDEX).await?; + analyze_table(pool, STE_VEC_VAST_TABLE).await?; + Ok(()) +} + +// ============================================================================ +// Macro-Based Coverage Matrix Tests +// ============================================================================ +// +// These tests use a declarative macro pattern to generate containment tests +// for all operator/type combinations systematically. + +/// Containment operator under test +#[derive(Debug, Clone, Copy)] +enum ContainmentOp { + /// jsonb_contains(lhs, rhs) - LHS contains RHS + Contains, + /// jsonb_contained_by(lhs, rhs) - LHS is contained by RHS + ContainedBy, +} + +/// Argument type for LHS or RHS position in containment query +#[derive(Debug, Clone, Copy, PartialEq)] +enum ArgumentType { + /// Table column reference: `e` + EncryptedColumn, + /// Full encrypted value as parameter: `$N::jsonb` + EncryptedParam, + /// Single sv element as parameter: `$N::jsonb` + SvElementParam, +} + +/// Test case configuration for containment operator tests +struct ContainmentTestCase { + operator: ContainmentOp, + lhs: ArgumentType, + rhs: ArgumentType, +} + +/// Generate a containment test from operator and argument types +macro_rules! containment_test { + ($name:ident, op = $op:ident, lhs = $lhs:ident, rhs = $rhs:ident) => { + #[sqlx::test] + async fn $name(pool: PgPool) -> Result<()> { + let test_case = ContainmentTestCase { + operator: ContainmentOp::$op, + lhs: ArgumentType::$lhs, + rhs: ArgumentType::$rhs, + }; + test_case + .run(&pool, STE_VEC_VAST_TABLE, STE_VEC_VAST_GIN_INDEX) + .await + } + }; +} + +/// Generate a negative containment test that verifies NO match is returned +macro_rules! containment_negative_test { + ($name:ident, op = $op:ident, lhs = $lhs:ident, rhs = $rhs:ident, $reason:expr) => { + #[sqlx::test] + async fn $name(pool: PgPool) -> Result<()> { + let test_case = ContainmentTestCase { + operator: ContainmentOp::$op, + lhs: ArgumentType::$lhs, + rhs: ArgumentType::$rhs, + }; + test_case + .run_negative(&pool, STE_VEC_VAST_TABLE, $reason) + .await + } + }; +} + +impl ContainmentOp { + fn sql_function(&self) -> &'static str { + match self { + ContainmentOp::Contains => "eql_v2.jsonb_contains", + ContainmentOp::ContainedBy => "eql_v2.jsonb_contained_by", + } + } +} + +impl ArgumentType { + fn is_param(&self) -> bool { + matches!( + self, + ArgumentType::EncryptedParam | ArgumentType::SvElementParam + ) + } +} + +impl ContainmentTestCase { + /// Build SQL query with proper placeholders based on argument types + fn build_query(&self, table: &str) -> (String, usize) { + let mut param_idx = 1usize; + + let lhs_sql = match self.lhs { + ArgumentType::EncryptedColumn => "e".to_string(), + ArgumentType::EncryptedParam | ArgumentType::SvElementParam => { + let s = format!("${}::jsonb", param_idx); + param_idx += 1; + s + } + }; + + let rhs_sql = match self.rhs { + ArgumentType::EncryptedColumn => "e".to_string(), + ArgumentType::EncryptedParam | ArgumentType::SvElementParam => { + let s = format!("${}::jsonb", param_idx); + param_idx += 1; + s + } + }; + + let id_param = format!("${}", param_idx); + + let sql = format!( + "SELECT id FROM {} WHERE {}({}, {}) AND id = {}", + table, + self.operator.sql_function(), + lhs_sql, + rhs_sql, + id_param + ); + + (sql, param_idx) + } + + /// Get the JSON value for a parameter based on argument type + fn get_param_value<'a>( + &self, + arg_type: ArgumentType, + encrypted: &'a serde_json::Value, + sv_element: &'a serde_json::Value, + ) -> &'a serde_json::Value { + match arg_type { + ArgumentType::EncryptedColumn => encrypted, + ArgumentType::EncryptedParam => encrypted, + ArgumentType::SvElementParam => sv_element, + } + } + + /// Execute query with dynamic bindings based on argument types + async fn execute_with_bindings( + &self, + pool: &PgPool, + sql: &str, + encrypted: &serde_json::Value, + sv_element: &serde_json::Value, + id: i64, + ) -> Result> { + let lhs_is_param = self.lhs.is_param(); + let rhs_is_param = self.rhs.is_param(); + + let result = match (lhs_is_param, rhs_is_param) { + (false, false) => sqlx::query_as(sql) + .bind(id) + .fetch_optional(pool) + .await + .with_context(|| { + format!( + "executing {:?}({:?}, {:?}) with id={}", + self.operator, self.lhs, self.rhs, id + ) + })?, + (true, false) => { + let lhs_val = self.get_param_value(self.lhs, encrypted, sv_element); + sqlx::query_as(sql) + .bind(lhs_val) + .bind(id) + .fetch_optional(pool) + .await + .with_context(|| { + format!( + "executing {:?}({:?}, {:?}) with id={}", + self.operator, self.lhs, self.rhs, id + ) + })? + } + (false, true) => { + let rhs_val = self.get_param_value(self.rhs, encrypted, sv_element); + sqlx::query_as(sql) + .bind(rhs_val) + .bind(id) + .fetch_optional(pool) + .await + .with_context(|| { + format!( + "executing {:?}({:?}, {:?}) with id={}", + self.operator, self.lhs, self.rhs, id + ) + })? + } + (true, true) => { + let lhs_val = self.get_param_value(self.lhs, encrypted, sv_element); + let rhs_val = self.get_param_value(self.rhs, encrypted, sv_element); + sqlx::query_as(sql) + .bind(lhs_val) + .bind(rhs_val) + .bind(id) + .fetch_optional(pool) + .await + .with_context(|| { + format!( + "executing {:?}({:?}, {:?}) with id={}", + self.operator, self.lhs, self.rhs, id + ) + })? + } + }; + + Ok(result) + } + + /// Verify that the GIN index is used for this query + async fn verify_index_usage( + &self, + pool: &PgPool, + table: &str, + index: &str, + encrypted: &serde_json::Value, + sv_element: &serde_json::Value, + ) -> Result<()> { + let lhs_sql = match self.lhs { + ArgumentType::EncryptedColumn => "e".to_string(), + ArgumentType::EncryptedParam => format!("'{}'::jsonb", encrypted), + ArgumentType::SvElementParam => format!("'{}'::jsonb", sv_element), + }; + + let rhs_sql = match self.rhs { + ArgumentType::EncryptedColumn => "e".to_string(), + ArgumentType::EncryptedParam => format!("'{}'::jsonb", encrypted), + ArgumentType::SvElementParam => format!("'{}'::jsonb", sv_element), + }; + + let explain_sql = format!( + "SELECT id FROM {} WHERE {}({}, {}) LIMIT 1", + table, + self.operator.sql_function(), + lhs_sql, + rhs_sql + ); + + assert_uses_index(pool, &explain_sql, index) + .await + .with_context(|| { + format!( + "verifying index usage for {:?}({:?}, {:?})", + self.operator, self.lhs, self.rhs + ) + })?; + + Ok(()) + } + + fn should_verify_index(&self) -> bool { + match self.operator { + ContainmentOp::Contains => self.lhs == ArgumentType::EncryptedColumn, + ContainmentOp::ContainedBy => self.rhs == ArgumentType::EncryptedColumn, + } + } + + /// Execute the test case + async fn run(&self, pool: &PgPool, table: &str, index: &str) -> Result<()> { + setup_ste_vec_vast_gin_index(pool) + .await + .with_context(|| format!("setting up GIN index for {:?} test", self.operator))?; + + let id: i64 = 1; + + let encrypted = get_ste_vec_encrypted(pool, table, id as i32) + .await + .with_context(|| { + format!( + "fetching encrypted value for {:?}({:?}, {:?})", + self.operator, self.lhs, self.rhs + ) + })?; + let sv_element = get_ste_vec_sv_element(pool, table, id as i32, 0) + .await + .with_context(|| { + format!( + "fetching sv_element for {:?}({:?}, {:?})", + self.operator, self.lhs, self.rhs + ) + })?; + + let (sql, _param_count) = self.build_query(table); + + let result: Option<(i64,)> = self + .execute_with_bindings(pool, &sql, &encrypted, &sv_element, id) + .await?; + + assert!( + result.is_some(), + "{:?}({:?}, {:?}) should find match for id={}", + self.operator, + self.lhs, + self.rhs, + id + ); + assert_eq!(result.unwrap().0, id); + + if self.should_verify_index() { + self.verify_index_usage(pool, table, index, &encrypted, &sv_element) + .await?; + } + + Ok(()) + } + + /// Execute a negative test case - verifies NO match is returned + /// + /// Used for asymmetric containment cases where a partial value (sv_element) + /// cannot contain a full value, and vice versa. + async fn run_negative(&self, pool: &PgPool, table: &str, reason: &str) -> Result<()> { + // 1. Setup GIN index + setup_ste_vec_vast_gin_index(pool).await.with_context(|| { + format!("setting up GIN index for negative {:?} test", self.operator) + })?; + + let id: i64 = 1; + + // 2. Fetch test data + let encrypted = get_ste_vec_encrypted(pool, table, id as i32) + .await + .with_context(|| { + format!( + "fetching encrypted value for negative {:?}({:?}, {:?})", + self.operator, self.lhs, self.rhs + ) + })?; + let sv_element = get_ste_vec_sv_element(pool, table, id as i32, 0) + .await + .with_context(|| { + format!( + "fetching sv_element for negative {:?}({:?}, {:?})", + self.operator, self.lhs, self.rhs + ) + })?; + + // 3. Build query + let (sql, _param_count) = self.build_query(table); + + // 4. Execute query with appropriate bindings + let result: Option<(i64,)> = self + .execute_with_bindings(pool, &sql, &encrypted, &sv_element, id) + .await?; + + // 5. Assert NO match found (negative test) + assert!( + result.is_none(), + "{:?}({:?}, {:?}) should NOT find match - {}", + self.operator, + self.lhs, + self.rhs, + reason + ); + + Ok(()) + } +} + +// ============================================================================ +// Contains Operator Tests via Macro +// ============================================================================ + +// Encrypted column contains encrypted parameter (self-containment) +containment_test!( + macro_contains_encrypted_encrypted_param, + op = Contains, + lhs = EncryptedColumn, + rhs = EncryptedParam +); + +// Column contains sv element param (element is subset of full value) +containment_test!( + macro_contains_encrypted_jsonb_param, + op = Contains, + lhs = EncryptedColumn, + rhs = SvElementParam +); + +// Encrypted param contains column (self-containment, param position reversed) +containment_test!( + macro_contains_encrypted_param_encrypted, + op = Contains, + lhs = EncryptedParam, + rhs = EncryptedColumn +); + +// ============================================================================ +// ContainedBy Operator Tests via Macro +// ============================================================================ + +// Column contained by encrypted param (self-containment) +containment_test!( + macro_contained_by_encrypted_encrypted_param, + op = ContainedBy, + lhs = EncryptedColumn, + rhs = EncryptedParam +); + +// SV element param contained by column (element is subset of full value) +containment_test!( + macro_contained_by_jsonb_param_encrypted, + op = ContainedBy, + lhs = SvElementParam, + rhs = EncryptedColumn +); + +// Encrypted param contained by column (self-containment, param position reversed) +containment_test!( + macro_contained_by_encrypted_param_encrypted, + op = ContainedBy, + lhs = EncryptedParam, + rhs = EncryptedColumn +); + +// ============================================================================ +// Negative Tests: Asymmetric Containment Cases +// ============================================================================ +// +// These tests verify that asymmetric containment relationships correctly +// return no match. A single sv_element cannot contain a full encrypted value, +// and a full encrypted value is not contained within a single sv_element. + +// SV element param does NOT contain column (element is subset, not superset) +containment_negative_test!( + macro_contains_jsonb_param_encrypted_no_match, + op = Contains, + lhs = SvElementParam, + rhs = EncryptedColumn, + "sv_element is a subset of encrypted value, cannot contain the full value" +); + +// Column is NOT contained by sv element param (full value not subset of element) +containment_negative_test!( + macro_contained_by_encrypted_jsonb_param_no_match, + op = ContainedBy, + lhs = EncryptedColumn, + rhs = SvElementParam, + "encrypted value has more keys than sv_element, cannot be contained in it" +); diff --git a/tests/sqlx/tests/jsonb_path_operators_tests.rs b/tests/sqlx/tests/jsonb_path_operators_tests.rs index e8875cf3..6597b728 100644 --- a/tests/sqlx/tests/jsonb_path_operators_tests.rs +++ b/tests/sqlx/tests/jsonb_path_operators_tests.rs @@ -4,7 +4,6 @@ use anyhow::Result; use eql_tests::{QueryAssertion, Selectors}; -use serde_json; use sqlx::{PgPool, Row}; #[sqlx::test(fixtures(path = "../fixtures", scripts("encrypted_json")))]