Skip to content
60 changes: 55 additions & 5 deletions tests/sqlx/fixtures/FIXTURE_SCHEMA.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
);
```
Comment thread
tobyhede marked this conversation as resolved.

**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:
Expand All @@ -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");
}
```

Expand All @@ -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

Expand Down
23 changes: 23 additions & 0 deletions tests/sqlx/fixtures/bench_data.sql
Original file line number Diff line number Diff line change
@@ -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);
31 changes: 31 additions & 0 deletions tests/sqlx/fixtures/bench_setup.sql
Original file line number Diff line number Diff line change
@@ -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;
17 changes: 17 additions & 0 deletions tests/sqlx/migrations/007_install_bench_data.sql
Original file line number Diff line number Diff line change
@@ -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
);
15 changes: 9 additions & 6 deletions tests/sqlx/migrations/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
2 changes: 1 addition & 1 deletion tests/sqlx/src/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")?;
Expand Down
186 changes: 186 additions & 0 deletions tests/sqlx/tests/bench_data_tests.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
Loading