Skip to content

Commit 2f89186

Browse files
committed
feat(stack): EQL v3 Drizzle adapter on protect-ffi 0.28 (public.* domains)
Adds the EQL v3 Drizzle integration at @cipherstash/stack/eql/v3/drizzle: drizzle-native column types, capability-checked operators emitting the two-argument eql_v3 SQL functions, and schema extraction for EncryptionV3. Replayed onto feat/eql-v3-bigint-restack, which already provides the FFI 0.28 concrete types, public.* domains, the bigint family, and the eqlVersion:3 fix. The adapter is namespace-agnostic (domains derived via getEqlType); tests updated to the base's public.<domain> naming while operator functions remain eql_v3.*. Superseded local work (concrete-types/bigint/FFI-0.28 re-vendor, redundant live guards, parallel matrix overhaul) dropped in favour of the base's versions. Original granular history retained in backup/eql-v3-drizzle-concrete-types-pre-ffi028-public-rebase.
1 parent dafcb02 commit 2f89186

21 files changed

Lines changed: 2366 additions & 0 deletions

.changeset/eql-v3-drizzle.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@cipherstash/stack": minor
3+
---
4+
5+
Add EQL v3 Drizzle support at `@cipherstash/stack/eql/v3/drizzle`. A Drizzle-native
6+
`types` namespace (same PascalCase names as `@cipherstash/stack/eql/v3`) declares
7+
encrypted columns whose Postgres type is the semantic `public.<domain>`; the concrete
8+
type drives the legal query operators. `createEncryptionOperatorsV3` provides
9+
capability-checked `eq`/`ne`/`gt`/`gte`/`lt`/`lte`/`between`/`contains`/`inArray`/
10+
`asc`/`desc`/`and`/`or` that emit the latest two-argument `eql_v3` SQL functions with
11+
full-envelope operands, and
12+
`extractEncryptionSchemaV3` rebuilds the schema for `EncryptionV3`. The existing v2
13+
`@cipherstash/stack/drizzle` integration is unchanged.
14+
15+
The v3 text-search helper is `contains`; obsolete `like`/`ilike` helpers are not
16+
exposed because v3 free-text search is token containment rather than SQL wildcard
17+
matching.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { v3FromDriver, v3ToDriver } from '@/eql/v3/drizzle/codec'
3+
4+
describe('v3 codec', () => {
5+
it('serialises an object to a jsonb string', () => {
6+
expect(v3ToDriver({ v: 1, c: 'ct' })).toBe('{"v":1,"c":"ct"}')
7+
})
8+
9+
it('maps null/undefined to SQL NULL (JS null), never the JSON null literal', () => {
10+
expect(v3ToDriver(null)).toBeNull()
11+
expect(v3ToDriver(undefined)).toBeNull()
12+
})
13+
14+
it('parses a jsonb string back to an object', () => {
15+
expect(v3FromDriver('{"v":1,"c":"ct"}')).toEqual({ v: 1, c: 'ct' })
16+
})
17+
18+
it('passes an already-parsed object through unchanged', () => {
19+
const obj = { v: 1 }
20+
expect(v3FromDriver(obj)).toBe(obj)
21+
})
22+
23+
it('passes null/undefined through on read', () => {
24+
expect(v3FromDriver(null)).toBeNull()
25+
expect(v3FromDriver(undefined)).toBeUndefined()
26+
})
27+
})
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { pgTable } from 'drizzle-orm/pg-core'
2+
import { describe, expect, it } from 'vitest'
3+
import { types as v3Types } from '@/eql/v3'
4+
import {
5+
EQL_V3_DOMAINS,
6+
getEqlV3Column,
7+
isEqlV3Column,
8+
makeEqlV3Column,
9+
} from '@/eql/v3/drizzle/column'
10+
import { typedEntries, V3_MATRIX } from '../v3-matrix/catalog'
11+
12+
const slug = (eqlType: string) => eqlType.replace('eql_v3.', '')
13+
14+
describe('makeEqlV3Column', () => {
15+
it('sets dataType() to the concrete eql_v3 domain', () => {
16+
const col = makeEqlV3Column(v3Types.IntegerOrd('age'))
17+
const table = pgTable('users', { age: col })
18+
19+
expect(table.age.getSQLType()).toBe('public.integer_ord')
20+
})
21+
22+
it('recovers the stashed builder before and after pgTable processing', () => {
23+
const col = makeEqlV3Column(v3Types.TextEq('nickname'))
24+
expect(isEqlV3Column(col)).toBe(true)
25+
expect(getEqlV3Column('nickname', col)?.getEqlType()).toBe('public.text_eq')
26+
27+
const t = pgTable('users', { nickname: col })
28+
expect(getEqlV3Column('nickname', t.nickname)?.getEqlType()).toBe(
29+
'public.text_eq',
30+
)
31+
})
32+
33+
it('EQL_V3_DOMAINS contains every concrete domain', () => {
34+
const all = Object.values(v3Types).map((f) => f('x').getEqlType())
35+
for (const domain of all) expect(EQL_V3_DOMAINS.has(domain)).toBe(true)
36+
})
37+
38+
it('isEqlV3Column is false for a plain value', () => {
39+
expect(isEqlV3Column({})).toBe(false)
40+
})
41+
42+
it('recognises v3 columns by the Drizzle getSQLType API', () => {
43+
expect(
44+
isEqlV3Column({
45+
getSQLType: () => 'public.text_eq',
46+
}),
47+
).toBe(true)
48+
})
49+
50+
it('recovers a v3 builder for columns recognised by getSQLType', () => {
51+
const builder = getEqlV3Column('nickname', {
52+
getSQLType: () => 'public.text_eq',
53+
})
54+
55+
expect(builder?.getName()).toBe('nickname')
56+
expect(builder?.getEqlType()).toBe('public.text_eq')
57+
})
58+
59+
it('recognises v3 columns by dataType() when getSQLType is absent', () => {
60+
const column = { dataType: () => 'public.text_eq' }
61+
const builder = getEqlV3Column('nickname', column)
62+
63+
expect(isEqlV3Column(column)).toBe(true)
64+
expect(builder?.getName()).toBe('nickname')
65+
expect(builder?.getEqlType()).toBe('public.text_eq')
66+
})
67+
68+
it('recognises v3 columns by sqlName when getSQLType is absent', () => {
69+
const column = { sqlName: 'public.integer_ord' }
70+
const builder = getEqlV3Column('age', column)
71+
72+
expect(isEqlV3Column(column)).toBe(true)
73+
expect(builder?.getName()).toBe('age')
74+
expect(builder?.getEqlType()).toBe('public.integer_ord')
75+
})
76+
77+
it.each(
78+
typedEntries(V3_MATRIX),
79+
)('%s round-trips through makeEqlV3Column and pgTable', (eqlType, spec) => {
80+
const columnName = slug(eqlType)
81+
const builder = spec.builder(columnName)
82+
const column = makeEqlV3Column(builder)
83+
84+
expect(builder.getEqlType()).toBe(eqlType)
85+
expect(builder).toBeInstanceOf(spec.ColumnClass)
86+
expect(isEqlV3Column(column)).toBe(true)
87+
expect(getEqlV3Column(columnName, column)?.getEqlType()).toBe(eqlType)
88+
89+
const table = pgTable('users', { [columnName]: column } as never)
90+
const pgColumn = (table as Record<string, { getSQLType(): string }>)[
91+
columnName
92+
]
93+
expect(pgColumn?.getSQLType()).toBe(eqlType)
94+
expect(getEqlV3Column(columnName, pgColumn)?.getEqlType()).toBe(eqlType)
95+
})
96+
})
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { describe, expect, it } from 'vitest'
2+
import * as barrel from '@/eql/v3/drizzle'
3+
4+
describe('v3 drizzle barrel', () => {
5+
it('exports the public surface', () => {
6+
expect(typeof barrel.createEncryptionOperatorsV3).toBe('function')
7+
expect(typeof barrel.extractEncryptionSchemaV3).toBe('function')
8+
expect(typeof barrel.EncryptionOperatorError).toBe('function')
9+
expect(typeof barrel.types.TextSearch).toBe('function')
10+
expect(barrel.types.IntegerOrd('age')).toBeDefined()
11+
})
12+
})

0 commit comments

Comments
 (0)