diff --git a/tests/sqlx/fixtures/FIXTURE_SCHEMA.md b/tests/sqlx/fixtures/FIXTURE_SCHEMA.md index 7988fb230..52c2daa72 100644 --- a/tests/sqlx/fixtures/FIXTURE_SCHEMA.md +++ b/tests/sqlx/fixtures/FIXTURE_SCHEMA.md @@ -9,7 +9,8 @@ EQL Extension (via migrations) ├── encrypted_json.sql ├── array_data.sql ├── order_by_null_data.sql (depends on ore migration) - └── ore_data.sql + ├── ore table (migration 002 — not a fixture) + └── bench_data.sql + bench_setup.sql (depend on migration 007) ``` All fixtures depend on the EQL extension being installed via SQLx migrations. @@ -132,6 +133,55 @@ CREATE TABLE ore ( --- +## bench_data.sql + +**Purpose:** Seeds 10K rows into the `bench` table for performance benchmarking. Opt-in fixture — only loaded when a test explicitly includes `scripts("bench_data")`, so other tests don't pay the cost. + +**Dependencies:** +- Requires `bench` table from migration `007_install_bench_data.sql` +- Uses `create_encrypted_json()` from migration `004_install_test_helpers.sql` + +**Schema:** Uses `bench` table (DDL in migration 007): +```sql +CREATE TABLE bench ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + encrypted_text eql_v2_encrypted, + encrypted_int eql_v2_encrypted, + encrypted_bigint eql_v2_encrypted +); +``` + +**Data:** +- 10,000 rows drawn from 99 distinct encrypted values (ore ids 1-99) +- Zipf-like skew via `setseed(0.42)` + `random()^2` — deterministic and byte-identical across runs +- Top id gets ~5% of rows; tail ids ~0.5% each (top:bottom ratio ~10x) +- Each column draws independently, so column values are decorrelated within a row +- Each row has HMAC, bloom filter, and ORE index terms + +**Used By:** +- bench_data_tests.rs (all tests) + +--- + +## bench_setup.sql + +**Purpose:** Creates the 5 benchmark indexes and refreshes planner statistics. Always loaded after `bench_data.sql` in tests that verify index usage. + +**Dependencies:** +- Requires `bench` table with data from `bench_data.sql` + +**Indexes created:** +- `bench_text_hmac_idx` — hash on `eql_v2.hmac_256(encrypted_text)` for equality +- `bench_text_ore_idx` — btree on `encrypted_text` via operator class for text ordering +- `bench_int_ore_idx` — btree on `encrypted_int` via operator class for range/ORDER BY +- `bench_bigint_ore_idx` — btree on `encrypted_bigint` via operator class +- `bench_text_bloom_idx` — GIN on `eql_v2.bloom_filter(encrypted_text)` for containment + +**Used By:** +- bench_data_tests.rs (index-usage tests: `scripts("bench_data", "bench_setup")`) + +--- + ## Validation Tests Each fixture should have a validation test to ensure correct structure: @@ -148,15 +198,15 @@ async fn fixture_encrypted_json_has_three_records(pool: PgPool) { } ``` -### ore_data Validation +### ore Migration Validation ```rust -#[sqlx::test(fixtures(path = "../fixtures", scripts("ore_data")))] +#[sqlx::test] async fn fixture_ore_data_has_99_records(pool: PgPool) { let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ore") .fetch_one(&pool) .await .unwrap(); - assert_eq!(count, 99, "ore_data fixture should create 99 records"); + assert_eq!(count, 99, "ore migration should provide 99 records"); } ``` @@ -166,7 +216,7 @@ async fn fixture_ore_data_has_99_records(pool: PgPool) { - Use snake_case for fixture file names - Name should describe the data, not the test using it -- Examples: `encrypted_json.sql`, `ore_data.sql`, `array_data.sql` +- Examples: `encrypted_json.sql`, `array_data.sql`, `bench_data.sql` ## Adding New Fixtures diff --git a/tests/sqlx/fixtures/bench_data.sql b/tests/sqlx/fixtures/bench_data.sql new file mode 100644 index 000000000..247d4ed50 --- /dev/null +++ b/tests/sqlx/fixtures/bench_data.sql @@ -0,0 +1,23 @@ +-- Fixture: bench_data.sql +-- +-- Seeds 10K rows into the bench table for performance testing. +-- Each column draws independently from 99 distinct encrypted values (ore ids 1-99) +-- using a Zipf-like skew so the planner sees realistic histograms. +-- +-- Index terms per row: hm (hmac), b3 (blake3), bf (bloom filter), ob (ORE blocks), sv (STE vec) +-- Data generated via create_encrypted_json() from 004_install_test_helpers.sql. +-- +-- Distribution: +-- Deterministic via setseed(0.42) — byte-identical across runs. +-- random()^2 produces a power-law skew: P(id=k) is proportional to 1/sqrt(k). +-- Top id gets ~5% of rows (~500); tail ids get ~0.5% each (~50). Ratio ~10x. +-- Three independent draws per row decorrelate the columns. + +SELECT setseed(0.42); + +INSERT INTO bench (encrypted_text, encrypted_int, encrypted_bigint) +SELECT + create_encrypted_json(1 + floor(99 * power(random(), 2))::int), + create_encrypted_json(1 + floor(99 * power(random(), 2))::int), + create_encrypted_json(1 + floor(99 * power(random(), 2))::int) +FROM generate_series(1, 10000); diff --git a/tests/sqlx/fixtures/bench_setup.sql b/tests/sqlx/fixtures/bench_setup.sql new file mode 100644 index 000000000..0f9979403 --- /dev/null +++ b/tests/sqlx/fixtures/bench_setup.sql @@ -0,0 +1,31 @@ +-- Fixture: bench_setup.sql +-- +-- Creates benchmark indexes and refreshes planner statistics. +-- Table DDL from migration 007_install_bench_data.sql; 10K rows from bench_data.sql fixture. +-- +-- Indexes: +-- bench_text_hmac_idx - hash on eql_v2.hmac_256(encrypted_text) for equality +-- bench_text_ore_idx - btree on encrypted_text via operator class for text ordering +-- bench_int_ore_idx - btree on encrypted_int via operator class for range/ORDER BY +-- bench_bigint_ore_idx - btree on encrypted_bigint via operator class +-- bench_text_bloom_idx - GIN on eql_v2.bloom_filter(encrypted_text) for containment +-- +-- Pattern follows containment_with_index_tests.rs: indexes in fixture (not migration) +-- so tests can verify before/after index creation. + +CREATE INDEX IF NOT EXISTS bench_text_hmac_idx + ON bench USING hash (eql_v2.hmac_256(encrypted_text)); + +CREATE INDEX IF NOT EXISTS bench_text_ore_idx + ON bench USING btree (encrypted_text eql_v2.encrypted_operator_class); + +CREATE INDEX IF NOT EXISTS bench_int_ore_idx + ON bench USING btree (encrypted_int eql_v2.encrypted_operator_class); + +CREATE INDEX IF NOT EXISTS bench_bigint_ore_idx + ON bench USING btree (encrypted_bigint eql_v2.encrypted_operator_class); + +CREATE INDEX IF NOT EXISTS bench_text_bloom_idx + ON bench USING gin (eql_v2.bloom_filter(encrypted_text)); + +ANALYZE bench; diff --git a/tests/sqlx/migrations/007_install_bench_data.sql b/tests/sqlx/migrations/007_install_bench_data.sql new file mode 100644 index 000000000..49ff6975b --- /dev/null +++ b/tests/sqlx/migrations/007_install_bench_data.sql @@ -0,0 +1,17 @@ +-- Migration: 007_install_bench_data.sql +-- +-- Creates benchmark table for performance testing. +-- DDL only — data is loaded by the bench_data.sql fixture so that +-- only bench tests pay the 10K-row seeding cost, not the entire suite. +-- +-- Columns: +-- encrypted_text - text equality (hmac), pattern match (bloom), ordering (ore) +-- encrypted_int - integer ORE range/equality/ordering +-- encrypted_bigint - bigint ORE at scale + +CREATE TABLE bench ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + encrypted_text eql_v2_encrypted, + encrypted_int eql_v2_encrypted, + encrypted_bigint eql_v2_encrypted +); diff --git a/tests/sqlx/migrations/README.md b/tests/sqlx/migrations/README.md index a03dcaa03..abfc74711 100644 --- a/tests/sqlx/migrations/README.md +++ b/tests/sqlx/migrations/README.md @@ -10,16 +10,19 @@ These migrations install EQL and test helpers into the test database using a **h - In `.gitignore` - never commit this file - Ensures tests always use current EQL version -**Migrations 002-004 are static fixtures**: -- 002: Test helpers (`test_helpers.sql`) -- 003: ORE test data (`ore.sql`) -- 004: STE Vec test data (`ste_vec.sql`) +**Migrations 002-007 are static fixtures**: +- 002: ORE test data (`ore.sql`) +- 003: STE Vec test data (`ste_vec.sql`) +- 004: Test helpers (`test_helpers.sql`) +- 005: STE Vec vast data +- 006: ORE text data +- 007: Benchmark table DDL (`bench` table with 3 encrypted columns — DDL only, no rows) ## How SQLx Uses These Migrations When using `#[sqlx::test]`: - Each test gets a fresh database -- All migrations (001-004) run automatically before each test +- All migrations (001-007) run automatically before each test - Migration 001 contains the latest built EQL - No need to manually reset database between tests @@ -36,7 +39,7 @@ cp release/cipherstash-encrypt.sql tests/sqlx/migrations/001_install_eql.sql ## Adding New Test Fixtures To add new test data or helpers: -1. Create a new migration: `tests/sqlx/migrations/005_my_fixture.sql` +1. Create a new migration using the next unused number (e.g. `tests/sqlx/migrations/008_my_fixture.sql`) 2. Add your SQL fixtures 3. Commit it (static migrations are version-controlled) 4. SQLx will apply it automatically in test runs diff --git a/tests/sqlx/src/helpers.rs b/tests/sqlx/src/helpers.rs index a2f454e9f..8fe128443 100644 --- a/tests/sqlx/src/helpers.rs +++ b/tests/sqlx/src/helpers.rs @@ -701,7 +701,7 @@ pub async fn ensure_pg_stat_statements(pool: &PgPool) -> Result<()> { /// let stats = read_pg_stat_statements(&pool, "%FROM bench%").await?; /// ``` pub async fn reset_pg_stat_statements(pool: &PgPool) -> Result<()> { - sqlx::query("SELECT pg_stat_statements_reset(NULL::oid, NULL::oid, (SELECT oid FROM pg_database WHERE datname = current_database()))") + sqlx::query("SELECT pg_stat_statements_reset(NULL::oid, (SELECT oid FROM pg_database WHERE datname = current_database()), 0::bigint)") .execute(pool) .await .with_context(|| "resetting pg_stat_statements counters for current database")?; diff --git a/tests/sqlx/tests/bench_data_tests.rs b/tests/sqlx/tests/bench_data_tests.rs new file mode 100644 index 000000000..a6912b462 --- /dev/null +++ b/tests/sqlx/tests/bench_data_tests.rs @@ -0,0 +1,186 @@ +//! Benchmark data verification tests +//! +//! Validates bench_data fixture (10K rows) and bench_setup fixture (indexes): +//! - 10K rows seeded correctly across 3 encrypted columns +//! - Index terms (hmac, bloom, ORE) are extractable +//! - Indexes are used by the query planner (EXPLAIN assertions) +//! - Sequential scan baseline without indexes + +use anyhow::Result; +use eql_tests::{analyze_table, assert_uses_index, assert_uses_seq_scan, explain_query}; +use sqlx::PgPool; + +const BENCH_ROW_COUNT: i64 = 10000; + +// ========== Data Integrity Tests ========== + +/// Verify fixture seeded exactly 10K rows +#[sqlx::test(fixtures(path = "../fixtures", scripts("bench_data")))] +async fn bench_table_has_expected_row_count(pool: PgPool) -> Result<()> { + let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM bench") + .fetch_one(&pool) + .await?; + assert_eq!( + count.0, BENCH_ROW_COUNT, + "bench table should have 10000 rows" + ); + Ok(()) +} + +/// Verify all three columns have non-null encrypted data +#[sqlx::test(fixtures(path = "../fixtures", scripts("bench_data")))] +async fn bench_columns_are_populated(pool: PgPool) -> Result<()> { + let count: (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM bench + WHERE encrypted_text IS NOT NULL + AND encrypted_int IS NOT NULL + AND encrypted_bigint IS NOT NULL", + ) + .fetch_one(&pool) + .await?; + assert_eq!( + count.0, BENCH_ROW_COUNT, + "all rows should have non-null encrypted columns" + ); + Ok(()) +} + +/// Verify hmac_256 index terms are extractable from encrypted_text +#[sqlx::test(fixtures(path = "../fixtures", scripts("bench_data")))] +async fn bench_encrypted_text_has_hmac_terms(pool: PgPool) -> Result<()> { + let count: (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM bench WHERE eql_v2.hmac_256(encrypted_text) IS NOT NULL", + ) + .fetch_one(&pool) + .await?; + assert_eq!( + count.0, BENCH_ROW_COUNT, + "all rows should have hmac_256 index terms" + ); + Ok(()) +} + +/// Verify bloom_filter index terms are extractable from encrypted_text +#[sqlx::test(fixtures(path = "../fixtures", scripts("bench_data")))] +async fn bench_encrypted_text_has_bloom_filter_terms(pool: PgPool) -> Result<()> { + let count: (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM bench WHERE eql_v2.bloom_filter(encrypted_text) IS NOT NULL", + ) + .fetch_one(&pool) + .await?; + assert_eq!( + count.0, BENCH_ROW_COUNT, + "all rows should have bloom_filter index terms" + ); + Ok(()) +} + +/// Verify ORE terms are extractable from encrypted_int (3 of 5 indexes are ORE btree) +#[sqlx::test(fixtures(path = "../fixtures", scripts("bench_data")))] +async fn bench_encrypted_int_has_ore_terms(pool: PgPool) -> Result<()> { + let count: (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM bench WHERE eql_v2.ore_block_u64_8_256(encrypted_int) IS NOT NULL", + ) + .fetch_one(&pool) + .await?; + assert_eq!( + count.0, BENCH_ROW_COUNT, + "all rows should have ORE block index terms" + ); + Ok(()) +} + +/// Verify ORE terms are extractable from encrypted_bigint +/// +/// Both int and bigint columns use the same eql_v2_encrypted type and ob index structure. +/// These tests verify that data seeding populated both columns, not that encoding differs. +#[sqlx::test(fixtures(path = "../fixtures", scripts("bench_data")))] +async fn bench_encrypted_bigint_has_ore_terms(pool: PgPool) -> Result<()> { + let count: (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM bench WHERE eql_v2.ore_block_u64_8_256(encrypted_bigint) IS NOT NULL", + ) + .fetch_one(&pool) + .await?; + assert_eq!( + count.0, BENCH_ROW_COUNT, + "all rows should have ORE block index terms" + ); + Ok(()) +} + +// ========== Index Usage Tests (with fixture) ========== + +/// Verify hash index is used for hmac_256 equality lookup +#[sqlx::test(fixtures(path = "../fixtures", scripts("bench_data", "bench_setup")))] +async fn bench_hmac_equality_uses_hash_index(pool: PgPool) -> Result<()> { + let encrypted: String = + sqlx::query_scalar("SELECT (encrypted_text).data::text FROM bench WHERE id = 1") + .fetch_one(&pool) + .await?; + + let sql = format!( + "SELECT * FROM bench WHERE eql_v2.hmac_256(encrypted_text) = eql_v2.hmac_256('{}'::jsonb::eql_v2_encrypted)", + encrypted + ); + assert_uses_index(&pool, &sql, "bench_text_hmac_idx").await?; + Ok(()) +} + +/// Verify btree index is used for ORDER BY with LIMIT on encrypted_int +#[sqlx::test(fixtures(path = "../fixtures", scripts("bench_data", "bench_setup")))] +async fn bench_ore_order_uses_btree_index(pool: PgPool) -> Result<()> { + let sql = "SELECT * FROM bench ORDER BY encrypted_int LIMIT 10"; + assert_uses_index(&pool, sql, "bench_int_ore_idx").await?; + Ok(()) +} + +/// Verify GIN index is used for bloom_filter containment +#[sqlx::test(fixtures(path = "../fixtures", scripts("bench_data", "bench_setup")))] +async fn bench_bloom_containment_uses_gin_index(pool: PgPool) -> Result<()> { + let encrypted: String = + sqlx::query_scalar("SELECT (encrypted_text).data::text FROM bench WHERE id = 1") + .fetch_one(&pool) + .await?; + + let sql = format!( + "SELECT * FROM bench WHERE eql_v2.bloom_filter(encrypted_text) @> eql_v2.bloom_filter('{}'::jsonb::eql_v2_encrypted)", + encrypted + ); + assert_uses_index(&pool, &sql, "bench_text_bloom_idx").await?; + Ok(()) +} + +/// Verify btree index is used for ORDER BY with LIMIT on encrypted_text +#[sqlx::test(fixtures(path = "../fixtures", scripts("bench_data", "bench_setup")))] +async fn bench_ore_text_order_uses_btree_index(pool: PgPool) -> Result<()> { + let sql = "SELECT * FROM bench ORDER BY encrypted_text LIMIT 10"; + assert_uses_index(&pool, sql, "bench_text_ore_idx").await?; + Ok(()) +} + +/// Verify btree index is used for ORDER BY with LIMIT on encrypted_bigint +#[sqlx::test(fixtures(path = "../fixtures", scripts("bench_data", "bench_setup")))] +async fn bench_ore_bigint_order_uses_btree_index(pool: PgPool) -> Result<()> { + let sql = "SELECT * FROM bench ORDER BY encrypted_bigint LIMIT 10"; + assert_uses_index(&pool, sql, "bench_bigint_ore_idx").await?; + Ok(()) +} + +/// Verify sequential scan without indexes (before/after pattern sanity check) +#[sqlx::test(fixtures(path = "../fixtures", scripts("bench_data")))] +async fn bench_hmac_without_index_uses_seq_scan(pool: PgPool) -> Result<()> { + analyze_table(&pool, "bench").await?; + + let encrypted: String = + sqlx::query_scalar("SELECT (encrypted_text).data::text FROM bench WHERE id = 1") + .fetch_one(&pool) + .await?; + + let sql = format!( + "SELECT * FROM bench WHERE eql_v2.hmac_256(encrypted_text) = eql_v2.hmac_256('{}'::jsonb::eql_v2_encrypted)", + encrypted + ); + let explain = explain_query(&pool, &sql).await?; + assert_uses_seq_scan(&explain); + Ok(()) +}