Skip to content
Open
Show file tree
Hide file tree
Changes from 13 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
12 changes: 12 additions & 0 deletions .changeset/eql-v3-drizzle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@cipherstash/stack": minor
---

Add EQL v3 Drizzle support at `@cipherstash/stack/eql/v3/drizzle`. A Drizzle-native
`types` namespace (same PascalCase names as `@cipherstash/stack/eql/v3`) declares
encrypted columns whose Postgres type is the concrete `eql_v3.<domain>`; the concrete
type drives the legal query operators. `createEncryptionOperatorsV3` provides
capability-checked `eq`/`ne`/`gt`/`gte`/`lt`/`lte`/`between`/`like`/`ilike`/`inArray`/
`asc`/`desc`/`and`/`or` that emit `eql_v3` term-function SQL, and
`extractEncryptionSchemaV3` rebuilds the schema for `EncryptionV3`. The existing v2
`@cipherstash/stack/drizzle` integration is unchanged.

Large diffs are not rendered by default.

27 changes: 27 additions & 0 deletions packages/stack/__tests__/drizzle-v3/codec.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { v3FromDriver, v3ToDriver } from '@/eql/v3/drizzle/codec'

describe('v3 codec', () => {
it('serialises an object to a jsonb string', () => {
expect(v3ToDriver({ v: 1, c: 'ct' })).toBe('{"v":1,"c":"ct"}')
})

it('maps null/undefined to SQL NULL (JS null), never the JSON null literal', () => {
expect(v3ToDriver(null)).toBeNull()
expect(v3ToDriver(undefined)).toBeNull()
})

it('parses a jsonb string back to an object', () => {
expect(v3FromDriver('{"v":1,"c":"ct"}')).toEqual({ v: 1, c: 'ct' })
})

it('passes an already-parsed object through unchanged', () => {
const obj = { v: 1 }
expect(v3FromDriver(obj)).toBe(obj)
})

it('passes null/undefined through on read', () => {
expect(v3FromDriver(null)).toBeNull()
expect(v3FromDriver(undefined)).toBeUndefined()
})
})
56 changes: 56 additions & 0 deletions packages/stack/__tests__/drizzle-v3/column.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { pgTable } from 'drizzle-orm/pg-core'
import { describe, expect, it } from 'vitest'
import { types as v3Types } from '@/eql/v3'
import {
EQL_V3_DOMAINS,
getEqlV3Column,
isEqlV3Column,
makeEqlV3Column,
} from '@/eql/v3/drizzle/column'

describe('makeEqlV3Column', () => {
it('sets dataType() to the concrete eql_v3 domain', () => {
const col = makeEqlV3Column(v3Types.Int4Ord('age'))
// biome-ignore lint/suspicious/noExplicitAny: reading drizzle internals in test
expect((col as any).config.customTypeParams.dataType()).toBe(
'eql_v3.int4_ord',
)
})

it('recovers the stashed builder before and after pgTable processing', () => {
const col = makeEqlV3Column(v3Types.TextEq('nickname'))
expect(isEqlV3Column(col)).toBe(true)
expect(getEqlV3Column('nickname', col)?.getEqlType()).toBe('eql_v3.text_eq')

const t = pgTable('users', { nickname: col })
expect(getEqlV3Column('nickname', t.nickname)?.getEqlType()).toBe(
'eql_v3.text_eq',
)
})

it('EQL_V3_DOMAINS contains every concrete domain', () => {
const all = Object.values(v3Types).map((f) => f('x').getEqlType())
for (const domain of all) expect(EQL_V3_DOMAINS.has(domain)).toBe(true)
})

it('isEqlV3Column is false for a plain value', () => {
expect(isEqlV3Column({})).toBe(false)
})

it('recognises v3 columns by the Drizzle getSQLType API', () => {
expect(
isEqlV3Column({
getSQLType: () => 'eql_v3.text_eq',
}),
).toBe(true)
})

it('recovers a v3 builder for columns recognised by getSQLType', () => {
const builder = getEqlV3Column('nickname', {
getSQLType: () => 'eql_v3.text_eq',
})

expect(builder?.getName()).toBe('nickname')
expect(builder?.getEqlType()).toBe('eql_v3.text_eq')
})
})
12 changes: 12 additions & 0 deletions packages/stack/__tests__/drizzle-v3/exports.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest'
import * as barrel from '@/eql/v3/drizzle'

describe('v3 drizzle barrel', () => {
it('exports the public surface', () => {
expect(typeof barrel.createEncryptionOperatorsV3).toBe('function')
expect(typeof barrel.extractEncryptionSchemaV3).toBe('function')
expect(typeof barrel.EncryptionOperatorError).toBe('function')
expect(typeof barrel.types.TextSearch).toBe('function')
expect(barrel.types.Int4Ord('age')).toBeDefined()
})
})
133 changes: 133 additions & 0 deletions packages/stack/__tests__/drizzle-v3/operators-live-pg.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import 'dotenv/config'
import { and, eq as drizzleEq, type SQL } from 'drizzle-orm'
import { integer, pgTable, text } from 'drizzle-orm/pg-core'
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
import { afterAll, beforeAll, expect, it } from 'vitest'
import { EncryptionV3 } from '@/encryption/v3'
import {
createEncryptionOperatorsV3,
extractEncryptionSchemaV3,
types,
} from '@/eql/v3/drizzle'
import { installEqlV3IfNeeded } from '../helpers/eql-v3'
import { describeLivePg, LIVE_EQL_V3_PG_ENABLED } from '../helpers/live-gate'

const url = process.env.DATABASE_URL
const sqlClient = LIVE_EQL_V3_PG_ENABLED
? postgres(url as string, { prepare: false })
: (undefined as unknown as postgres.Sql)

const usersTable = pgTable('protect_ci_v3_drizzle', {
id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
email: types.TextSearch('email'),
nickname: types.TextEq('nickname'),
age: types.Int4Ord('age'),
testRunId: text('test_run_id').notNull(),
})

const RUN = `run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const schema = extractEncryptionSchemaV3(usersTable)

// biome-ignore lint/suspicious/noExplicitAny: test client typing
let client: any
// biome-ignore lint/suspicious/noExplicitAny: test operator typing
let ops: any
// biome-ignore lint/suspicious/noExplicitAny: drizzle db typing
let db: any

beforeAll(async () => {
if (!LIVE_EQL_V3_PG_ENABLED) return
await installEqlV3IfNeeded(sqlClient)
client = await EncryptionV3({ schemas: [schema] })
ops = createEncryptionOperatorsV3(client)
db = drizzle({ client: sqlClient })

await sqlClient`
CREATE TABLE IF NOT EXISTS protect_ci_v3_drizzle (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
email eql_v3.text_search NOT NULL,
nickname eql_v3.text_eq NOT NULL,
age eql_v3.int4_ord NOT NULL,
test_run_id TEXT NOT NULL
)
`

const seed = [
{ email: 'ada@example.com', nickname: 'ada', age: 37 },
{ email: 'grace@example.com', nickname: 'grace', age: 30 },
{ email: 'alan@example.net', nickname: 'alan', age: 42 },
]
for (const row of seed) {
const enc = unwrap(await client.encryptModel({ ...row }, schema))
await sqlClient`
INSERT INTO protect_ci_v3_drizzle (email, nickname, age, test_run_id)
VALUES (
${sqlClient.json(enc.email)}::eql_v3.text_search,
${sqlClient.json(enc.nickname)}::eql_v3.text_eq,
${sqlClient.json(enc.age)}::eql_v3.int4_ord,
${RUN}
)
`
}
}, 60000)

afterAll(async () => {
if (!LIVE_EQL_V3_PG_ENABLED) return
await sqlClient`DELETE FROM protect_ci_v3_drizzle WHERE test_run_id = ${RUN}`
await sqlClient.end()
}, 30000)

// biome-ignore lint/suspicious/noExplicitAny: result unwrap
function unwrap(r: any) {
if (r.failure) throw new Error(r.failure.message)
return r.data
}

describeLivePg('v3 drizzle operators (live pg)', () => {
const scoped = (cond: SQL) => and(drizzleEq(usersTable.testRunId, RUN), cond)

it('eq selects the exact nickname row', async () => {
const rows = await db
.select({ id: usersTable.id, age: usersTable.age })
.from(usersTable)
.where(scoped(await ops.eq(usersTable.nickname, 'grace')))
const decrypted = unwrap(await client.bulkDecryptModels(rows, schema))
expect(decrypted.map((r: { age: number }) => r.age)).toEqual([30])
}, 30000)

it('gte selects rows at or above the bound', async () => {
const rows = await db
.select({ id: usersTable.id })
.from(usersTable)
.where(scoped(await ops.gte(usersTable.age, 37)))
expect(rows.length).toBe(2)
}, 30000)

it('between selects the inclusive range', async () => {
const rows = await db
.select({ id: usersTable.id })
.from(usersTable)
.where(scoped(await ops.between(usersTable.age, 30, 37)))
expect(rows.length).toBe(2)
}, 30000)

it('ilike matches on the free-text email column', async () => {
const rows = await db
.select({ id: usersTable.id })
.from(usersTable)
.where(scoped(await ops.ilike(usersTable.email, 'example.com')))
expect(rows.length).toBe(2)
}, 30000)

it('orderBy asc returns ascending ages', async () => {
const rows = await db
.select({ age: usersTable.age })
.from(usersTable)
.where(scoped(await ops.gte(usersTable.age, 0)))
.orderBy(ops.asc(usersTable.age))
const decrypted = unwrap(await client.bulkDecryptModels(rows, schema))
const ages = decrypted.map((r: { age: number }) => r.age)
expect(ages).toEqual([...ages].sort((a, b) => a - b))
}, 30000)
})
Loading