Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/drizzle-bloom-filter-like-ilike.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cipherstash/stack": patch
---

perf(drizzle): wrap `like` / `ilike` / `notIlike` in `eql_v2.bloom_filter(...) @> eql_v2.bloom_filter(...)` so encrypted free-text searches engage the bloom_filter functional GIN index on Supabase and any `--exclude-operator-family` install. Previously the operators emitted `eql_v2.like(col, value)` / `eql_v2.ilike(col, value)` — the function bodies contain the inlinable bloom-filter form, but they're marked `VOLATILE` so the planner can't inline them, and the documented `bench_text_bloom_idx` GIN index never engages. Drizzle now emits the inlined form directly.
45 changes: 34 additions & 11 deletions packages/bench/__tests__/drizzle/operators.explain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,22 +156,45 @@ describe('#421: equality and array operators', () => {
})
})

// --- #422: investigation operators ----------------------------------------
// --- like / ilike: bloom-filter containment ------------------------------
//
// We don't yet know which call-shaped forms the planner inlines. Record plan
// shape; assertions land in a follow-up once #422 closes.
describe('#422: call-shaped operators (recorded, not asserted)', () => {
it('records like / ilike plan shapes', async () => {
await tryExplainWhere(
'like',
(await ops.like(benchTable.encText, '%value-00000%')) as SQL,
// `eql_v2.like` and `eql_v2.ilike` are SQL functions whose bodies are already
// `SELECT eql_v2.bloom_filter(a) @> eql_v2.bloom_filter(b)` — but they're
// marked VOLATILE, so the planner won't inline them into the index match.
// Drizzle now emits the inlined containment form directly so the bloom GIN
// index engages. The wildcard pattern is irrelevant at the SQL layer —
// bloom-filter match works on the encrypted token set, not LIKE syntax.
describe('like / ilike: engage bloom_filter functional index', () => {
// The pattern needs to be selective — `%value-0000042%` is unique to one
// seeded row, whereas a broad pattern like `%value-00000%` is shared by
// every row in the fixture (all seed values start with `value-`), and
// the planner correctly picks seq scan when the predicate matches every
// row.
it('like engages bench_text_bloom_idx', async () => {
const plan = await explainWhere(
(await ops.like(benchTable.encText, '%value-0000042%')) as SQL,
)
await tryExplainWhere(
'ilike',
(await ops.ilike(benchTable.encText, '%VALUE-00000%')) as SQL,
recordObservation('like', plan)
expect(hasSeqScan(plan), summarize(plan)).toBe(false)
})

it('ilike engages bench_text_bloom_idx', async () => {
const plan = await explainWhere(
(await ops.ilike(benchTable.encText, '%VALUE-0000042%')) as SQL,
)
recordObservation('ilike', plan)
expect(hasSeqScan(plan), summarize(plan)).toBe(false)
})
})

// --- #422: remaining call-shaped operators (recorded, not asserted) ------
//
// gt/gte/lt/lte/between have no Supabase functional index path today (OPE
// work is still in flight in EQL). jsonb_path_* don't have an obvious
// containment form on ste_vec. order_by has no Supabase index path either.
// Record plan shape for the investigation log; assertions land in a
// follow-up once EQL ships the relevant index recipes.
describe('#422: remaining call-shaped operators (recorded, not asserted)', () => {
it('records gt / gte / lt / lte plan shapes', async () => {
for (const [name, build] of [
['gt', () => ops.gt(benchTable.encInt, 5000)],
Expand Down
14 changes: 13 additions & 1 deletion packages/stack/src/drizzle/operators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -851,7 +851,19 @@ function createTextSearchOperator(
)
}

const sqlFn = sql`eql_v2.${sql.raw(operator === 'notIlike' ? 'ilike' : operator)}(${left}, ${bindIfParam(encrypted, left)})`
// Emit the bloom-filter containment form directly. `eql_v2.like` /
// `eql_v2.ilike` are themselves a single-statement `SELECT
// eql_v2.bloom_filter(a) @> eql_v2.bloom_filter(b)` — but the functions
// are marked VOLATILE, so the planner won't inline them, and the
// documented `bench_text_bloom_idx` GIN functional index never engages.
// Inlining by hand here lets the planner match the index on every
// install, including Supabase. (Same shape as the hmac_256 wrap for
// eq/ne/inArray.)
//
// `like` and `ilike` resolve to the same SQL post-encryption — case
// sensitivity is determined by the column's `freeTextSearch` token
// filters, not by which operator the user picked.
const sqlFn = sql`eql_v2.bloom_filter(${left}) @> eql_v2.bloom_filter(${bindIfParam(encrypted, left)}::eql_v2_encrypted)`
return operator === 'notIlike' ? sql`NOT (${sqlFn})` : sqlFn
}

Expand Down