-
Notifications
You must be signed in to change notification settings - Fork 6
feat(stack-drizzle): EQL v3 JSON selector-with-constraint querying #651
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
59b994e
feat(stack-drizzle): EQL v3 JSON selector-with-constraint querying
coderdan 64e8998
refactor(stack-drizzle): ciphertext-free v3 JSON selector-with-constr…
coderdan b5a5f26
fix(stack-drizzle): v3 JSON selector — storage-needle RHS (interim)
coderdan f45963f
docs(skills): document v3 JSON selector-with-constraint (#623)
coderdan b5b18c3
fix(stack-drizzle): address CodeRabbit review on v3 JSON selector
coderdan 36ecb3b
fix(stack-drizzle): address freshtonic + Toby review on v3 JSON selector
coderdan File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| --- | ||
| '@cipherstash/stack-drizzle': minor | ||
| 'stash': patch | ||
| --- | ||
|
|
||
| Add EQL v3 JSON **selector-with-constraint** querying to the Drizzle integration | ||
| (#623). `ops.selector(col, '$.path')` returns comparison methods bound to a | ||
| JSONPath into a `types.Json` column — `eq`/`ne`/`gt`/`gte`/`lt`/`lte` — emitting | ||
| `col->'<selector>' <op> <value>` over the encrypted document. Its unique power | ||
| over `contains` is **ordering at a path** (`col->'$.age' > 21`), which | ||
| containment cannot express. | ||
|
|
||
| Complements the existing `contains` (JSONB `@>`) containment operator. Core | ||
| `@cipherstash/stack` needs no change — the selector hash and comparison entry are | ||
| produced by `encryptQuery`/`encrypt` on the existing `types.Json` surface. v1 | ||
| supports dot-notation object paths; array-index/wildcard paths are rejected with | ||
| a clear error. The Supabase adapter is tracked separately. | ||
|
|
||
| The right-hand comparison operand is currently a storage-encrypted needle (its | ||
| ste_vec entry carries the ordering term), pending a ciphertext-free ordering | ||
| query needle from protect-ffi (cipherstash/protectjs-ffi#137); until then the | ||
| value's ciphertext appears in the WHERE clause. | ||
|
|
||
| The bundled `stash-encryption` and `stash-drizzle` skills document the new | ||
| `ops.selector(...)` surface (they previously said JSONPath selector queries were | ||
| not yet implemented). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,112 @@ | ||
| import { integer, pgTable } from 'drizzle-orm/pg-core' | ||
| import { describe, expect, it } from 'vitest' | ||
| import { | ||
| createEncryptionOperatorsV3, | ||
| EncryptionOperatorError, | ||
| parseSelectorSegments, | ||
| reconstructSelectorDocument, | ||
| } from '../../src/v3/operators.js' | ||
| import { types } from '../../src/v3/types.js' | ||
|
|
||
| describe('parseSelectorSegments', () => { | ||
| it('parses $-rooted, bare, and whitespace-padded dot paths', () => { | ||
| expect(parseSelectorSegments('$.a')).toEqual(['a']) | ||
| expect(parseSelectorSegments('$.a.b')).toEqual(['a', 'b']) | ||
| expect(parseSelectorSegments('a.b')).toEqual(['a', 'b']) | ||
| expect(parseSelectorSegments(' $.a.b ')).toEqual(['a', 'b']) | ||
| }) | ||
|
|
||
| it('rejects array-index and wildcard syntax', () => { | ||
| expect(() => parseSelectorSegments('$.items[0]')).toThrow(/array\/wildcard/) | ||
| expect(() => parseSelectorSegments('$.items[*].name')).toThrow( | ||
| /array\/wildcard/, | ||
| ) | ||
| }) | ||
|
|
||
| it('rejects the empty / root path', () => { | ||
| expect(() => parseSelectorSegments('$')).toThrow(/addresses no field/) | ||
| expect(() => parseSelectorSegments('$.')).toThrow(/addresses no field/) | ||
| expect(() => parseSelectorSegments('')).toThrow(/addresses no field/) | ||
| }) | ||
|
|
||
| it('rejects malformed paths (.. / stray dots) rather than silently collapsing', () => { | ||
| expect(() => parseSelectorSegments('$..age')).toThrow(/malformed/) | ||
| expect(() => parseSelectorSegments('$.a..b')).toThrow(/malformed/) | ||
| expect(() => parseSelectorSegments('$.a.')).toThrow(/malformed/) | ||
| }) | ||
|
|
||
| it('rejects prototype-pollution keys', () => { | ||
| for (const key of ['__proto__', 'prototype', 'constructor']) { | ||
| expect(() => parseSelectorSegments(`$.${key}`)).toThrow(/forbidden key/) | ||
| expect(() => parseSelectorSegments(`$.a.${key}`)).toThrow(/forbidden key/) | ||
| } | ||
| }) | ||
| }) | ||
|
|
||
| describe('reconstructSelectorDocument', () => { | ||
| it('nests the value under the segments', () => { | ||
| expect(reconstructSelectorDocument(['a'], 30)).toEqual({ a: 30 }) | ||
| expect(reconstructSelectorDocument(['a', 'b'], 'x')).toEqual({ | ||
| a: { b: 'x' }, | ||
| }) | ||
| }) | ||
|
|
||
| it('serializes correctly and creates own keys (no prototype pollution)', () => { | ||
| // parseSelectorSegments rejects __proto__ upstream, but the builder must be | ||
| // safe regardless: a __proto__ segment is an OWN key, not the prototype. | ||
| const doc = reconstructSelectorDocument(['__proto__', 'age'], 1) | ||
| expect(JSON.stringify(doc)).toBe('{"__proto__":{"age":1}}') | ||
| expect(Object.getPrototypeOf(doc)).toBeNull() | ||
| expect({}.age).toBeUndefined() // global prototype untouched | ||
| }) | ||
| }) | ||
|
|
||
| describe('ops.selector — up-front guards (no encryption reached)', () => { | ||
| const table = pgTable('selector_guard', { | ||
| id: integer('id').primaryKey(), | ||
| doc: types.Json('doc'), | ||
| }) | ||
| // The encrypt methods throw if reached — every case below must reject first. | ||
| const failIfCalled = () => { | ||
| throw new Error('guard should reject before encrypting') | ||
| } | ||
| const ops = createEncryptionOperatorsV3({ | ||
| encryptQuery: failIfCalled, | ||
| encrypt: failIfCalled, | ||
| } as never) | ||
|
|
||
| it('rejects a non-scalar leaf value (object / array) — use contains()', async () => { | ||
| await expect(ops.selector(table.doc, '$.a').eq({ x: 1 })).rejects.toThrow( | ||
| /scalar leaf.*contains\(\)/, | ||
| ) | ||
| await expect(ops.selector(table.doc, '$.a').eq([1, 2])).rejects.toThrow( | ||
| /scalar leaf/, | ||
| ) | ||
| }) | ||
|
|
||
| it('rejects ordering a boolean leaf', async () => { | ||
| await expect(ops.selector(table.doc, '$.flag').gt(true)).rejects.toThrow( | ||
| /boolean leaf has no ordering/, | ||
| ) | ||
| }) | ||
|
|
||
| it('allows equality on a boolean leaf (would reach encryption)', async () => { | ||
| // eq on a boolean is permitted by the guard; it fails only because the mock | ||
| // encrypt throws — proving the guard passed it through. | ||
| await expect(ops.selector(table.doc, '$.flag').eq(true)).rejects.toThrow( | ||
| /guard should reject before encrypting/, | ||
| ) | ||
| }) | ||
|
|
||
| it('surfaces path errors as EncryptionOperatorError with context', async () => { | ||
| await expect( | ||
| ops.selector(table.doc, '$.items[0]').eq(1), | ||
| ).rejects.toBeInstanceOf(EncryptionOperatorError) | ||
| await expect(ops.selector(table.doc, '$').eq(1)).rejects.toThrow( | ||
| /addresses no field/, | ||
| ) | ||
| await expect(ops.selector(table.doc, '$.__proto__').eq(1)).rejects.toThrow( | ||
| /forbidden key/, | ||
| ) | ||
| }) | ||
| }) |
169 changes: 169 additions & 0 deletions
169
packages/stack-drizzle/integration/json-selector.integration.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,169 @@ | ||
| /** | ||
| * Live JSONPath selector-with-constraint for the v3 `types.Json()` column (#623). | ||
| * Distinct from containment (`ops.contains`, `@>`): this extracts the encrypted | ||
| * leaf entry at a JSONPath and compares it, so it can express ORDERING at a path | ||
| * (`col->'$.age' > 25`) that containment cannot. Emits | ||
| * `eql_v3.<op>(eql_v3.jsonb_path_query_first(col, '<sel>'), eql_v3.jsonb_path_query_first('<needle>'::eql_v3_json, '<sel>'))`. | ||
| * | ||
| * INTERIM (cipherstash/protectjs-ffi#137): the RHS needle is a STORAGE encryption | ||
| * of `{path: value}` — its ste_vec entry carries `c` + `op`/`hm`, which the | ||
| * comparison extracts. Once protect-ffi can mint a ciphertext-free ordering query | ||
| * needle for a ste_vec column, the RHS drops the ciphertext. | ||
| */ | ||
|
|
||
| import type { JsonDocument } from '@cipherstash/stack/eql/v3' | ||
| import { EncryptionV3 } from '@cipherstash/stack/v3' | ||
| import { databaseUrl, unwrapResult } from '@cipherstash/test-kit' | ||
| import { and, asc, eq, 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, describe, expect, it } from 'vitest' | ||
| import { | ||
| createEncryptionOperatorsV3, | ||
| extractEncryptionSchemaV3, | ||
| types, | ||
| } from '../src/v3/index.js' | ||
|
|
||
| const sqlClient = postgres(databaseUrl(), { prepare: false }) | ||
|
|
||
| const TABLE_NAME = 'protect_ci_v3_json_selector' | ||
| const RUN = `run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` | ||
|
|
||
| const docTable = pgTable(TABLE_NAME, { | ||
| id: integer('id').primaryKey().generatedAlwaysAsIdentity(), | ||
| rowKey: text('row_key').notNull(), | ||
| testRunId: text('test_run_id').notNull(), | ||
| doc: types.Json('doc'), | ||
| }) | ||
|
|
||
| const schema = extractEncryptionSchemaV3(docTable) | ||
|
|
||
| // Distinct ages so ordering-at-a-selector has a definite expected set. | ||
| const DOCS: Record<string, JsonDocument> = { | ||
| ada: { user: 'ada@example.com', age: 30 }, | ||
| grace: { user: 'grace@example.com', age: 20 }, | ||
| zoe: { user: 'zoe@example.com', age: 40 }, | ||
| // No `$.age` — exercises absent-path semantics (excluded by eq/ordering, | ||
| // included by ne). | ||
| noage: { user: 'noage@example.com' }, | ||
| } | ||
|
|
||
| type SelectRow = { rowKey: string } | ||
| let client: Awaited<ReturnType<typeof EncryptionV3>> | ||
| let ops: ReturnType<typeof createEncryptionOperatorsV3> | ||
| let db: ReturnType<typeof drizzle> | ||
|
|
||
| async function matching(condition: SQL): Promise<string[]> { | ||
| const rows = (await db | ||
| .select({ rowKey: docTable.rowKey }) | ||
| .from(docTable) | ||
| .where(and(eq(docTable.testRunId, RUN), condition)) | ||
| .orderBy(asc(docTable.rowKey))) as SelectRow[] | ||
| return rows.map((row) => row.rowKey) | ||
| } | ||
|
|
||
| beforeAll(async () => { | ||
| client = await EncryptionV3({ schemas: [schema] }) | ||
| ops = createEncryptionOperatorsV3(client) | ||
| db = drizzle({ client: sqlClient }) | ||
|
|
||
| await sqlClient.unsafe(`DROP TABLE IF EXISTS ${TABLE_NAME}`) | ||
| await sqlClient.unsafe(` | ||
| CREATE TABLE ${TABLE_NAME} ( | ||
| id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, | ||
| row_key TEXT NOT NULL, | ||
| test_run_id TEXT NOT NULL, | ||
| doc public.eql_v3_json NOT NULL | ||
| ) | ||
| `) | ||
|
|
||
| const rows = Object.entries(DOCS).map(([rowKey, doc]) => ({ | ||
| rowKey, | ||
| testRunId: RUN, | ||
| doc, | ||
| })) | ||
| const encrypted = unwrapResult( | ||
| await client.bulkEncryptModels(rows, schema), | ||
| ) as Array<Record<string, unknown>> | ||
| await db.insert(docTable).values(encrypted as never) | ||
| }, 120000) | ||
|
|
||
| afterAll(async () => { | ||
| await sqlClient`DELETE FROM ${sqlClient(TABLE_NAME)} WHERE test_run_id = ${RUN}` | ||
| await sqlClient.end() | ||
| }, 30000) | ||
|
|
||
| describe('v3 drizzle JSON selector-with-constraint (live pg)', () => { | ||
| it('equality at a scalar selector', async () => { | ||
| expect( | ||
| await matching(await ops.selector(docTable.doc, '$.age').eq(30)), | ||
| ).toEqual(['ada']) | ||
| }, 30000) | ||
|
|
||
| it('equality at a string selector', async () => { | ||
| const condition = await ops | ||
| .selector(docTable.doc, '$.user') | ||
| .eq('zoe@example.com') | ||
| expect(await matching(condition)).toEqual(['zoe']) | ||
| }, 30000) | ||
|
|
||
| it('ordering at a selector: greater-than (the form containment cannot express)', async () => { | ||
| // ages: ada 30, grace 20, zoe 40 → > 25 selects ada, zoe. | ||
| expect( | ||
| await matching(await ops.selector(docTable.doc, '$.age').gt(25)), | ||
| ).toEqual(['ada', 'zoe']) | ||
| }, 30000) | ||
|
|
||
| it('ordering at a selector: less-than', async () => { | ||
| expect( | ||
| await matching(await ops.selector(docTable.doc, '$.age').lt(35)), | ||
| ).toEqual(['ada', 'grace']) | ||
| }, 30000) | ||
|
|
||
| it('ordering at a selector: gte inclusive boundary', async () => { | ||
| expect( | ||
| await matching(await ops.selector(docTable.doc, '$.age').gte(30)), | ||
| ).toEqual(['ada', 'zoe']) | ||
| }, 30000) | ||
|
|
||
| it('returns nothing when no leaf satisfies the constraint', async () => { | ||
| expect( | ||
| await matching(await ops.selector(docTable.doc, '$.age').gt(100)), | ||
| ).toEqual([]) | ||
| }, 30000) | ||
|
|
||
| it('equality excludes rows whose document lacks the path', async () => { | ||
| // `noage` has no `$.age`, so it is not equal to 30 → excluded. | ||
| expect( | ||
| await matching(await ops.selector(docTable.doc, '$.age').eq(30)), | ||
| ).toEqual(['ada']) | ||
| }, 30000) | ||
|
|
||
| it('ne includes rows whose document lacks the path', async () => { | ||
| // ne(30): grace(20), zoe(40), AND noage (no `$.age`) — "not equal to 30" | ||
| // covers "has no age". Without the IS NULL arm, noage would be dropped. | ||
| expect( | ||
| await matching(await ops.selector(docTable.doc, '$.age').ne(30)), | ||
| ).toEqual(['grace', 'noage', 'zoe']) | ||
| }, 30000) | ||
|
|
||
| it('ordering excludes rows whose document lacks the path', async () => { | ||
| // noage has no age → not > 0. | ||
| expect( | ||
| await matching(await ops.selector(docTable.doc, '$.age').gt(0)), | ||
| ).toEqual(['ada', 'grace', 'zoe']) | ||
| }, 30000) | ||
|
|
||
| it('rejects a non-scalar leaf value before querying', async () => { | ||
| await expect( | ||
| ops.selector(docTable.doc, '$.age').eq({ nested: 1 }), | ||
| ).rejects.toThrow(/scalar leaf/) | ||
| }, 30000) | ||
|
|
||
| it('rejects array/wildcard selector paths (v1 supports object keys only)', async () => { | ||
| await expect( | ||
| ops.selector(docTable.doc, '$.items[0].name').eq('x'), | ||
| ).rejects.toThrow(/not yet supported/) | ||
| }, 30000) | ||
| }) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.