diff --git a/docs/reference/drizzle/drizzle.md b/docs/reference/drizzle/drizzle.md index f250bab0..4de6956c 100644 --- a/docs/reference/drizzle/drizzle.md +++ b/docs/reference/drizzle/drizzle.md @@ -274,6 +274,56 @@ return results --- +## JSONB queries with encrypted data + +Protect.js supports querying encrypted JSON columns using JSONB operators. These operators require `searchableJson: true` and `dataType: 'json'` in the column's `encryptedType` config. + +> [!TIP] +> For details on the low-level `encryptQuery` API and JSONB query types (`steVecSelector`, `steVecTerm`), see the [Searchable encryption with PostgreSQL](../searchable-encryption-postgres.md#jsonb-queries-with-searchablejson-recommended) reference. + +### Check path existence with `jsonbPathExists` + +Use `jsonbPathExists` to check if a JSONPath exists in the JSONB data. This returns a boolean and can be used directly in `WHERE` clauses. This is equivalent to the PostgreSQL `jsonb_path_exists` function. + +```typescript +const results = await db + .select() + .from(usersTable) + .where(await protect.jsonbPathExists(usersTable.profile, '$.bio')) +``` + +### Extract value with `jsonbPathQueryFirst` + +Use `jsonbPathQueryFirst` to extract the first value at a given JSONPath in a `SELECT` expression. This is equivalent to the PostgreSQL `jsonb_path_query_first` function. + +> [!NOTE] +> `jsonbPathQueryFirst` returns an encrypted value, not a boolean. Use it in `SELECT` expressions, not directly in `WHERE` clauses. Use `jsonbPathExists` to filter rows by path existence. + +### Get value with `jsonbGet` + +Use `jsonbGet` to get a value using the JSONB `->` operator in a `SELECT` expression. + +> [!NOTE] +> `jsonbGet` returns an encrypted value, not a boolean. Use it in `SELECT` expressions, not directly in `WHERE` clauses. Use `jsonbPathExists` to filter rows by path existence. + +### Combine JSONB operators with other conditions + +JSONB operators can be combined with other Protect operators using `and` and `or`. Use `jsonbPathExists` for boolean conditions in `WHERE` clauses. + +```typescript +const results = await db + .select() + .from(usersTable) + .where( + await protect.and( + protect.jsonbPathExists(usersTable.profile, '$.name'), + protect.eq(usersTable.email, 'jane@example.com'), + ), + ) +``` + +--- + ## Aggregation operations ### Count all transactions diff --git a/docs/reference/searchable-encryption-postgres.md b/docs/reference/searchable-encryption-postgres.md index cbc07dae..41069681 100644 --- a/docs/reference/searchable-encryption-postgres.md +++ b/docs/reference/searchable-encryption-postgres.md @@ -116,6 +116,9 @@ console.log(term.data) // array of search terms ### JSONB queries with searchableJson (recommended) +> [!TIP] +> **Using Drizzle ORM?** The `@cipherstash/drizzle` package provides higher-level JSONB operators (`jsonbPathQueryFirst`, `jsonbGet`, `jsonbPathExists`) that handle encryption automatically. See the [Drizzle JSONB query examples](./drizzle/drizzle.md#jsonb-queries-with-encrypted-data). + For columns storing JSON data, `.searchableJson()` is the recommended approach. It enables encrypted JSONB queries and automatically infers the correct query operation from the plaintext value type. Use `encryptQuery` to create encrypted query terms for JSONB columns: diff --git a/local/create-ci-table.sql b/local/create-ci-table.sql index d61dfabd..842f37ec 100644 --- a/local/create-ci-table.sql +++ b/local/create-ci-table.sql @@ -4,5 +4,6 @@ CREATE TABLE "protect-ci" ( age eql_v2_encrypted, score eql_v2_encrypted, profile eql_v2_encrypted, - created_at TIMESTAMP DEFAULT NOW() + created_at TIMESTAMP DEFAULT NOW(), + test_run_id TEXT ); \ No newline at end of file diff --git a/packages/drizzle/README.md b/packages/drizzle/README.md index 2f4e82ff..245503a5 100644 --- a/packages/drizzle/README.md +++ b/packages/drizzle/README.md @@ -94,9 +94,10 @@ export const usersTable = pgTable('users', { orderAndRange: true, }), - // JSON object with typed structure + // JSON object with searchable JSONB queries profile: encryptedType<{ name: string; bio: string }>('profile', { dataType: 'json', + searchableJson: true, }), createdAt: timestamp('created_at').defaultNow(), @@ -227,6 +228,31 @@ const decrypted = await protectClient.bulkDecryptModels(results) > [!IMPORTANT] > Sorting with ORE on Supabase and other databases that don't support operator families will not work as expected. +### Query encrypted JSONB data + +```typescript +// Check if a path exists in encrypted JSONB data +const results = await db + .select() + .from(usersTable) + .where(await protectOps.jsonbPathExists(usersTable.profile, '$.bio')) + +// Combine JSONB operators with other conditions +const results = await db + .select() + .from(usersTable) + .where( + await protectOps.and( + protectOps.jsonbPathExists(usersTable.profile, '$.name'), + protectOps.eq(usersTable.email, 'jane@example.com'), + ), + ) +``` + +> [!NOTE] +> `jsonbPathExists` returns a boolean and can be used directly in `WHERE` clauses. +> `jsonbPathQueryFirst` and `jsonbGet` return encrypted values, not booleans — use them in `SELECT` expressions, not in `WHERE` clauses. + ### Complex queries with mixed operators ```typescript @@ -275,6 +301,14 @@ All operators automatically handle encryption for encrypted columns. - `inArray(left, right[])` - In array - `notInArray(left, right[])` - Not in array +### JSONB Operators (async) +- `jsonbPathQueryFirst(column, selector)` - Extract first value at JSONB path +- `jsonbGet(column, selector)` - Get value using JSONB `->` operator +- `jsonbPathExists(column, selector)` - Check if path exists in JSONB + +> [!IMPORTANT] +> JSONB operators require `searchableJson: true` and `dataType: 'json'` in the column's `encryptedType` config. + ### Sorting Operators (sync) - `asc(column)` - Ascending order - `desc(column)` - Descending order @@ -304,6 +338,7 @@ Creates an encrypted column type for Drizzle schemas. - `freeTextSearch?: boolean` - Enable text search (LIKE/ILIKE) - `equality?: boolean` - Enable equality queries - `orderAndRange?: boolean` - Enable range queries and sorting +- `searchableJson?: boolean` - Enable JSONB path queries (requires `dataType: 'json'`) ### `extractProtectSchema(table)` diff --git a/packages/drizzle/__tests__/docs.test.ts b/packages/drizzle/__tests__/docs.test.ts index fec07e06..1ef2b438 100644 --- a/packages/drizzle/__tests__/docs.test.ts +++ b/packages/drizzle/__tests__/docs.test.ts @@ -11,7 +11,7 @@ import { createProtectOperators, encryptedType, extractProtectSchema, -} from '../src/pg' +} from '@cipherstash/drizzle/pg' import { docSeedData } from './fixtures/doc-seed-data' import { type ExecutionContext, executeCodeBlock } from './utils/code-executor' import { extractExecutableBlocks } from './utils/markdown-parser' diff --git a/packages/drizzle/__tests__/drizzle.test.ts b/packages/drizzle/__tests__/drizzle.test.ts index 5c42ea81..6721a4c9 100644 --- a/packages/drizzle/__tests__/drizzle.test.ts +++ b/packages/drizzle/__tests__/drizzle.test.ts @@ -1,6 +1,7 @@ import 'dotenv/config' import { protect } from '@cipherstash/protect' -import { and, eq, inArray, sql } from 'drizzle-orm' +import type { SQL } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import { integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core' import { drizzle } from 'drizzle-orm/postgres-js' import postgres from 'postgres' @@ -9,26 +10,23 @@ import { createProtectOperators, encryptedType, extractProtectSchema, -} from '../src/pg' +} from '@cipherstash/drizzle/pg' +import { userSeedData } from './fixtures/user-seed-data' +import { + type EncryptedUserRow, + type PlaintextUser, + decryptUserRow, + decryptUserRows, + expectRowsToBeEncrypted, + expectUserToMatchPlaintext, + expectUsersToMatchPlaintext, + unwrapResult, +} from './integration-test-helpers' if (!process.env.DATABASE_URL) { throw new Error('Missing env.DATABASE_URL') } -// Test data type -interface TestUser { - id: number - email: string - age: number - score: number - profile: { - name: string - bio: string - level: number - } -} - -// Drizzle table definition with encrypted columns using object configuration const drizzleUsersTable = pgTable('protect-ci', { id: integer('id').primaryKey().generatedAlwaysAsIdentity(), email: encryptedType('email', { @@ -50,511 +48,332 @@ const drizzleUsersTable = pgTable('protect-ci', { 'profile', { dataType: 'json', + searchableJson: true, }, ), createdAt: timestamp('created_at').defaultNow(), testRunId: text('test_run_id'), }) -// Extract Protect.js schema from Drizzle table const users = extractProtectSchema(drizzleUsersTable) -// Hard code this as the CI database doesn't support order by on encrypted columns +// CI database does not currently support ORDER BY on encrypted columns. const SKIP_ORDER_BY_TEST = true - -// Unique identifier for this test run to isolate data from concurrent test runs +const FALLBACK_EMAIL = 'john.doe@example.com' const TEST_RUN_ID = `drizzle-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` -// Test data interface for decrypted results -interface DecryptedUser { - id: number - email: string - age: number - score: number - profile: { - name: string - bio: string - level: number - } +const encryptedUserSelection = { + id: drizzleUsersTable.id, + email: drizzleUsersTable.email, + age: drizzleUsersTable.age, + score: drizzleUsersTable.score, + profile: drizzleUsersTable.profile, } let protectClient: Awaited> let protectOps: ReturnType -let db: ReturnType -const testData: TestUser[] = [] +let db: ReturnType | undefined +let postgresClient: ReturnType | undefined +let fallbackUserId = -1 + +function getDb(): ReturnType { + if (!db) { + throw new Error('Database client is not initialized') + } + return db +} + +function getSeedUser(email: string): PlaintextUser { + const user = userSeedData.find((candidate) => candidate.email === email) + if (!user) { + throw new Error(`Expected seed user not found for email: ${email}`) + } + return user +} + +function filterSeedUsers(predicate: (user: PlaintextUser) => boolean) { + return userSeedData.filter(predicate) +} + +async function selectEncryptedUsers( + condition: SQL | undefined, +): Promise { + if (!condition) { + throw new Error('Expected query condition') + } + + const rows = await getDb() + .select(encryptedUserSelection) + .from(drizzleUsersTable) + .where(condition) + + return rows as unknown as EncryptedUserRow[] +} beforeAll(async () => { - // Initialize Protect.js client using schema extracted from Drizzle table protectClient = await protect({ schemas: [users] }) protectOps = createProtectOperators(protectClient) - const client = postgres(process.env.DATABASE_URL as string) - db = drizzle({ client }) - - // Create test data - const testUsers: Omit[] = [ - { - email: 'john.doe@example.com', - age: 25, - score: 85, - profile: { - name: 'John Doe', - bio: 'Software engineer with 5 years experience', - level: 3, - }, - }, - { - email: 'jane.smith@example.com', - age: 30, - score: 92, - profile: { - name: 'Jane Smith', - bio: 'Senior developer specializing in React', - level: 4, - }, - }, - { - email: 'bob.wilson@example.com', - age: 35, - score: 78, - profile: { - name: 'Bob Wilson', - bio: 'Full-stack developer and team lead', - level: 5, - }, - }, - { - email: 'alice.johnson@example.com', - age: 28, - score: 88, - profile: { - name: 'Alice Johnson', - bio: 'Frontend specialist with design skills', - level: 3, - }, - }, - { - email: 'jill.smith@example.com', - age: 22, - score: 75, - profile: { - name: 'Jill Smith', - bio: 'Backend developer with 3 years experience', - level: 3, - }, - }, - ] - - // Encrypt and insert test data using Drizzle - const encryptedUser = await protectClient.bulkEncryptModels(testUsers, users) + postgresClient = postgres(process.env.DATABASE_URL as string) + db = drizzle({ client: postgresClient }) - if (encryptedUser.failure) { - throw new Error(`Encryption failed: ${encryptedUser.failure.message}`) - } + const encryptedUsers = unwrapResult( + await protectClient.bulkEncryptModels(userSeedData, users), + 'bulkEncryptModels', + ) - // Add test_run_id to each record for test isolation - const dataWithTestRunId = encryptedUser.data.map((user) => ({ + const rowsToInsert = encryptedUsers.map((user) => ({ ...user, testRunId: TEST_RUN_ID, })) - const insertedUsers = await db + const insertedRows = await getDb() .insert(drizzleUsersTable) - .values(dataWithTestRunId) - .returning({ - id: drizzleUsersTable.id, - email: drizzleUsersTable.email, - age: drizzleUsersTable.age, - score: drizzleUsersTable.score, - profile: drizzleUsersTable.profile, - }) - - // @ts-ignore - TODO figure out how to have type safety for returned values from Drizzle - testData.push(...insertedUsers) + .values(rowsToInsert) + .returning({ id: drizzleUsersTable.id }) + + expect(insertedRows).toHaveLength(userSeedData.length) + + const fallbackRows = await selectEncryptedUsers( + and( + eq(drizzleUsersTable.testRunId, TEST_RUN_ID), + await protectOps.eq(drizzleUsersTable.email, FALLBACK_EMAIL), + ), + ) + + expect(fallbackRows).toHaveLength(1) + fallbackUserId = fallbackRows[0].id }, 60000) afterAll(async () => { - // Clean up test data using test_run_id for reliable isolation - await db - .delete(drizzleUsersTable) - .where(eq(drizzleUsersTable.testRunId, TEST_RUN_ID)) + try { + if (db) { + await db + .delete(drizzleUsersTable) + .where(eq(drizzleUsersTable.testRunId, TEST_RUN_ID)) + } + } finally { + await postgresClient?.end() + } }, 30000) describe('Drizzle ORM Integration with Protect.js', () => { - it('should perform equality search using Protect operators', async () => { + it('encrypts values for equality queries and decrypts back to exact plaintext', async () => { const searchEmail = 'jane.smith@example.com' + const expectedUser = getSeedUser(searchEmail) - // Query using Protect operators - encryption is handled automatically - const results = await db - .select({ - id: drizzleUsersTable.id, - email: drizzleUsersTable.email, - age: drizzleUsersTable.age, - score: drizzleUsersTable.score, - profile: drizzleUsersTable.profile, - }) - .from(drizzleUsersTable) - .where( - and( - eq(drizzleUsersTable.testRunId, TEST_RUN_ID), - await protectOps.eq(drizzleUsersTable.email, searchEmail), - ), - ) - - expect(results).toHaveLength(1) + const rows = await selectEncryptedUsers( + and( + eq(drizzleUsersTable.testRunId, TEST_RUN_ID), + await protectOps.eq(drizzleUsersTable.email, searchEmail), + ), + ) - // Decrypt and verify - const decrypted = await protectClient.decryptModel(results[0]) - if (decrypted.failure) { - throw new Error(`Decryption failed: ${decrypted.failure.message}`) - } + expect(rows).toHaveLength(1) + expectRowsToBeEncrypted(rows) - const decryptedUser = decrypted.data as DecryptedUser - expect(decryptedUser.email).toBe(searchEmail) + const decryptedUser = await decryptUserRow(protectClient, rows[0]) + expectUserToMatchPlaintext(decryptedUser, expectedUser) }, 30000) - it('should perform text search using Protect operators', async () => { + it('executes free-text query patterns and matches exact plaintext rows', async () => { const searchText = 'smith' + const expectedUsers = filterSeedUsers((user) => + user.email.toLowerCase().includes(searchText), + ) - // Query using Protect operators - encryption is handled automatically - const results = await db - .select({ - id: drizzleUsersTable.id, - email: drizzleUsersTable.email, - age: drizzleUsersTable.age, - score: drizzleUsersTable.score, - profile: drizzleUsersTable.profile, - }) - .from(drizzleUsersTable) - .where( - and( - eq(drizzleUsersTable.testRunId, TEST_RUN_ID), - await protectOps.ilike(drizzleUsersTable.email, searchText), - ), - ) - - // Should find users with 'smith' in their email - expect(results.length).toBeGreaterThan(0) + const rows = await selectEncryptedUsers( + and( + eq(drizzleUsersTable.testRunId, TEST_RUN_ID), + await protectOps.ilike(drizzleUsersTable.email, searchText), + ), + ) - // Decrypt and verify - const decryptedResults = await protectClient.bulkDecryptModels(results) - if (decryptedResults.failure) { - throw new Error( - `Bulk decryption failed: ${decryptedResults.failure.message}`, - ) - } + expect(rows).toHaveLength(expectedUsers.length) + expectRowsToBeEncrypted(rows) - // Verify at least one result contains the search text - const foundMatch = decryptedResults.data.some((user) => { - const decryptedUser = user as DecryptedUser - return ( - decryptedUser.email?.toLowerCase().includes(searchText.toLowerCase()) || - decryptedUser.profile?.bio - ?.toLowerCase() - .includes(searchText.toLowerCase()) - ) - }) - expect(foundMatch).toBe(true) + const decryptedUsers = await decryptUserRows(protectClient, rows) + expectUsersToMatchPlaintext(decryptedUsers, expectedUsers) }, 30000) - it('should perform number range queries using Protect operators', async () => { + it('executes range query patterns and decrypts exact plaintext matches', async () => { const minAge = 28 + const expectedUsers = filterSeedUsers((user) => user.age >= minAge) - // Query using Protect operators - encryption is handled automatically - const results = await db - .select({ - id: drizzleUsersTable.id, - email: drizzleUsersTable.email, - age: drizzleUsersTable.age, - score: drizzleUsersTable.score, - profile: drizzleUsersTable.profile, - }) - .from(drizzleUsersTable) - .where( - and( - eq(drizzleUsersTable.testRunId, TEST_RUN_ID), - await protectOps.gte(drizzleUsersTable.age, minAge), - ), - ) - - // Should find users with age >= 28 - expect(results.length).toBeGreaterThan(0) + const rows = await selectEncryptedUsers( + and( + eq(drizzleUsersTable.testRunId, TEST_RUN_ID), + await protectOps.gte(drizzleUsersTable.age, minAge), + ), + ) - // Decrypt and verify - const decryptedResults = await protectClient.bulkDecryptModels(results) - if (decryptedResults.failure) { - throw new Error( - `Bulk decryption failed: ${decryptedResults.failure.message}`, - ) - } + expect(rows).toHaveLength(expectedUsers.length) + expectRowsToBeEncrypted(rows) - // Verify all results have age >= 28 - const allValidAges = decryptedResults.data.every((user) => { - const decryptedUser = user as DecryptedUser - return ( - decryptedUser.age !== null && - decryptedUser.age !== undefined && - decryptedUser.age >= minAge - ) - }) - expect(allValidAges).toBe(true) + const decryptedUsers = await decryptUserRows(protectClient, rows) + expectUsersToMatchPlaintext(decryptedUsers, expectedUsers) }, 30000) - it('should perform sorting using Drizzle operators', async () => { - if (SKIP_ORDER_BY_TEST) { - console.log('Skipping order by test - not supported by this database') - return - } - - const a = db - .select({ - id: drizzleUsersTable.id, - email: drizzleUsersTable.email, - age: drizzleUsersTable.age, - score: drizzleUsersTable.score, - profile: drizzleUsersTable.profile, - }) - .from(drizzleUsersTable) - .where(eq(drizzleUsersTable.testRunId, TEST_RUN_ID)) - .orderBy(protectOps.asc(drizzleUsersTable.age)) - - const results = await a - - expect(results.length).toBeGreaterThan(0) - - // Decrypt and verify sorting - const decryptedResults = await protectClient.bulkDecryptModels(results) - if (decryptedResults.failure) { - throw new Error( - `Bulk decryption failed: ${decryptedResults.failure.message}`, + const orderByIt = SKIP_ORDER_BY_TEST ? it.skip : it + orderByIt( + 'supports encrypted ordering and preserves decrypted order', + async () => { + const expectedInAgeOrder = [...userSeedData].sort( + (left, right) => left.age - right.age, ) - } - // Verify ages are sorted in ascending order - const ages = decryptedResults.data - .map((user) => (user as DecryptedUser).age) - .filter((age): age is number => age !== null && age !== undefined) - .sort((a, b) => a - b) + const rows = (await getDb() + .select(encryptedUserSelection) + .from(drizzleUsersTable) + .where(eq(drizzleUsersTable.testRunId, TEST_RUN_ID)) + .orderBy( + protectOps.asc(drizzleUsersTable.age), + )) as unknown as EncryptedUserRow[] - const sortedAges = decryptedResults.data - .map((user) => (user as DecryptedUser).age) - .filter((age): age is number => age !== null && age !== undefined) + expect(rows).toHaveLength(userSeedData.length) + expectRowsToBeEncrypted(rows) - expect(sortedAges).toEqual(ages) - }, 30000) + const decryptedUsers = await decryptUserRows(protectClient, rows) - it('should perform complex queries with multiple conditions using batched and()', async () => { - const minAge = 25 - const maxAge = 35 - const searchText = 'developer' - - // Complex query using Protect operators with batched and() - encryption is handled automatically - // All operator calls are batched into a single createSearchTerms call - const results = await db - .select({ - id: drizzleUsersTable.id, - email: drizzleUsersTable.email, - age: drizzleUsersTable.age, - score: drizzleUsersTable.score, - profile: drizzleUsersTable.profile, - }) - .from(drizzleUsersTable) - .where( - await protectOps.and( - eq(drizzleUsersTable.testRunId, TEST_RUN_ID), - protectOps.gte(drizzleUsersTable.age, minAge), - protectOps.lte(drizzleUsersTable.age, maxAge), - protectOps.ilike(drizzleUsersTable.email, searchText), - ), + expect(decryptedUsers.map((user) => user.age)).toEqual( + expectedInAgeOrder.map((user) => user.age), ) + expectUsersToMatchPlaintext(decryptedUsers, expectedInAgeOrder) + }, + 30000, + ) - // Decrypt and verify - const decryptedResults = await protectClient.bulkDecryptModels(results) - if (decryptedResults.failure) { - throw new Error( - `Bulk decryption failed: ${decryptedResults.failure.message}`, - ) - } + it('batches encrypted predicates with and() and returns exact plaintext rows', async () => { + const minAge = 22 + const maxAge = 35 + const searchText = 'smith' + const expectedUsers = filterSeedUsers( + (user) => + user.age >= minAge && + user.age <= maxAge && + user.email.toLowerCase().includes(searchText), + ) + + const rows = await selectEncryptedUsers( + await protectOps.and( + eq(drizzleUsersTable.testRunId, TEST_RUN_ID), + protectOps.gte(drizzleUsersTable.age, minAge), + protectOps.lte(drizzleUsersTable.age, maxAge), + protectOps.ilike(drizzleUsersTable.email, searchText), + ), + ) - // Verify all results meet the criteria - // Note: We're filtering by id = 1 (regular Drizzle operator) plus encrypted columns - const allValidResults = decryptedResults.data.every((user) => { - const decryptedUser = user as DecryptedUser - // Encrypted operators: age range - const ageValid = - decryptedUser.age !== null && - decryptedUser.age !== undefined && - decryptedUser.age >= minAge && - decryptedUser.age <= maxAge - // Encrypted operator: text search - const textValid = - decryptedUser.email?.toLowerCase().includes(searchText.toLowerCase()) || - decryptedUser.profile?.bio - ?.toLowerCase() - .includes(searchText.toLowerCase()) - return ageValid && textValid - }) - - expect(allValidResults).toBe(true) + expect(rows).toHaveLength(expectedUsers.length) + expectRowsToBeEncrypted(rows) + + const decryptedUsers = await decryptUserRows(protectClient, rows) + expectUsersToMatchPlaintext(decryptedUsers, expectedUsers) }, 30000) - it('should perform queries with multiple conditions using batched or()', async () => { + it('mixes encrypted and plain predicates with or() and decrypts to exact plaintext', async () => { const targetEmails = ['jane.smith@example.com', 'bob.wilson@example.com'] - const fallbackId = testData[0]?.id ?? -1 - - const results = await db - .select({ - id: drizzleUsersTable.id, - email: drizzleUsersTable.email, - age: drizzleUsersTable.age, - score: drizzleUsersTable.score, - profile: drizzleUsersTable.profile, - }) - .from(drizzleUsersTable) - .where( - await protectOps.and( - eq(drizzleUsersTable.testRunId, TEST_RUN_ID), - protectOps.or( - protectOps.eq(drizzleUsersTable.email, targetEmails[0]), - protectOps.eq(drizzleUsersTable.email, targetEmails[1]), - eq(drizzleUsersTable.id, fallbackId), - ), - ), - ) - - expect(results.length).toBe(targetEmails.length + 1) // +1 for fallbackId row + const expectedUsers = filterSeedUsers( + (user) => + targetEmails.includes(user.email) || user.email === FALLBACK_EMAIL, + ) - const decryptedResults = await protectClient.bulkDecryptModels(results) - if (decryptedResults.failure) { - throw new Error( - `Bulk decryption failed: ${decryptedResults.failure.message}`, - ) - } + expect(fallbackUserId).toBeGreaterThan(0) - const emails = decryptedResults.data.map( - (user) => (user as DecryptedUser).email, + const rows = await selectEncryptedUsers( + await protectOps.and( + eq(drizzleUsersTable.testRunId, TEST_RUN_ID), + protectOps.or( + protectOps.eq(drizzleUsersTable.email, targetEmails[0]), + protectOps.eq(drizzleUsersTable.email, targetEmails[1]), + eq(drizzleUsersTable.id, fallbackUserId), + ), + ), ) - for (const email of targetEmails) { - expect(emails).toContain(email) - } + expect(rows).toHaveLength(expectedUsers.length) + expectRowsToBeEncrypted(rows) + + const decryptedUsers = await decryptUserRows(protectClient, rows) + expectUsersToMatchPlaintext(decryptedUsers, expectedUsers) }, 30000) - it('should handle nested field encryption and decryption', async () => { - // Get a user with nested data - const results = await db - .select({ - id: drizzleUsersTable.id, - email: drizzleUsersTable.email, - age: drizzleUsersTable.age, - score: drizzleUsersTable.score, - profile: drizzleUsersTable.profile, - }) - .from(drizzleUsersTable) - .where(eq(drizzleUsersTable.testRunId, TEST_RUN_ID)) - .limit(1) - - if (!results[0]) { - throw new Error('No users found') - } + it('decrypts nested JSON payloads back to the original plaintext object', async () => { + const searchEmail = 'alice.johnson@example.com' + const expectedUser = getSeedUser(searchEmail) - // Decrypt and verify nested fields - const decrypted = await protectClient.decryptModel(results[0]) - if (decrypted.failure) { - throw new Error(`Decryption failed: ${decrypted.failure.message}`) - } + const rows = await selectEncryptedUsers( + and( + eq(drizzleUsersTable.testRunId, TEST_RUN_ID), + await protectOps.eq(drizzleUsersTable.email, searchEmail), + ), + ) - const decryptedUser = decrypted.data as DecryptedUser + expect(rows).toHaveLength(1) + expectRowsToBeEncrypted(rows) - // Verify nested profile structure - expect(decryptedUser.profile).toBeDefined() - expect(decryptedUser.profile.name).toBeDefined() - expect(decryptedUser.profile.bio).toBeDefined() - expect(decryptedUser.profile.level).toBeDefined() - expect(typeof decryptedUser.profile.level).toBe('number') + const decryptedUser = await decryptUserRow(protectClient, rows[0]) + expect(decryptedUser.profile).toEqual(expectedUser.profile) + expectUserToMatchPlaintext(decryptedUser, expectedUser) }, 30000) - it('should handle inArray operator with encrypted columns', async () => { + it('supports encrypted inArray query patterns with exact plaintext matching', async () => { const searchEmails = ['jane.smith@example.com', 'bob.wilson@example.com'] + const expectedUsers = filterSeedUsers((user) => + searchEmails.includes(user.email), + ) - // Query using Protect operators with inArray - const results = await db - .select({ - id: drizzleUsersTable.id, - email: drizzleUsersTable.email, - age: drizzleUsersTable.age, - score: drizzleUsersTable.score, - profile: drizzleUsersTable.profile, - }) - .from(drizzleUsersTable) - .where( - and( - eq(drizzleUsersTable.testRunId, TEST_RUN_ID), - await protectOps.inArray(drizzleUsersTable.email, searchEmails), - ), - ) - - // Should find 2 users - expect(results.length).toBe(2) + const rows = await selectEncryptedUsers( + and( + eq(drizzleUsersTable.testRunId, TEST_RUN_ID), + await protectOps.inArray(drizzleUsersTable.email, searchEmails), + ), + ) - // Decrypt and verify - const decryptedResults = await protectClient.bulkDecryptModels(results) - if (decryptedResults.failure) { - throw new Error( - `Bulk decryption failed: ${decryptedResults.failure.message}`, - ) - } + expect(rows).toHaveLength(expectedUsers.length) + expectRowsToBeEncrypted(rows) - // Verify all results have the expected emails - const emails = decryptedResults.data.map( - (user) => (user as DecryptedUser).email, - ) - expect(emails).toContain('jane.smith@example.com') - expect(emails).toContain('bob.wilson@example.com') + const decryptedUsers = await decryptUserRows(protectClient, rows) + expectUsersToMatchPlaintext(decryptedUsers, expectedUsers) }, 30000) - it('should handle between operator with encrypted columns', async () => { + it('supports encrypted between query patterns with exact plaintext matching', async () => { const minAge = 25 const maxAge = 30 + const expectedUsers = filterSeedUsers( + (user) => user.age >= minAge && user.age <= maxAge, + ) - // Query using Protect operators with between - const results = await db - .select({ - id: drizzleUsersTable.id, - email: drizzleUsersTable.email, - age: drizzleUsersTable.age, - score: drizzleUsersTable.score, - profile: drizzleUsersTable.profile, - }) - .from(drizzleUsersTable) - .where( - and( - eq(drizzleUsersTable.testRunId, TEST_RUN_ID), - await protectOps.between(drizzleUsersTable.age, minAge, maxAge), + const rows = await selectEncryptedUsers( + and( + eq(drizzleUsersTable.testRunId, TEST_RUN_ID), + await protectOps.between(drizzleUsersTable.age, minAge, maxAge), + ), + ) + + expect(rows).toHaveLength(expectedUsers.length) + expectRowsToBeEncrypted(rows) + + const decryptedUsers = await decryptUserRows(protectClient, rows) + expectUsersToMatchPlaintext(decryptedUsers, expectedUsers) + }, 30000) + + it('supports jsonbPathExists in WHERE clause', async () => { + const rows = await selectEncryptedUsers( + and( + eq(drizzleUsersTable.testRunId, TEST_RUN_ID), + await protectOps.jsonbPathExists( + drizzleUsersTable.profile, + '$.name', ), - ) + ), + ) - // Should find users with age between 25 and 30 - expect(results.length).toBeGreaterThan(0) + expect(rows.length).toBeGreaterThan(0) + expectRowsToBeEncrypted(rows) - // Decrypt and verify - const decryptedResults = await protectClient.bulkDecryptModels(results) - if (decryptedResults.failure) { - throw new Error( - `Bulk decryption failed: ${decryptedResults.failure.message}`, - ) + const decryptedUsers = await decryptUserRows(protectClient, rows) + for (const user of decryptedUsers) { + expect(user.profile.name).toBeDefined() } - - // Verify all results have age between min and max - const allValidAges = decryptedResults.data.every((user) => { - const decryptedUser = user as DecryptedUser - return ( - decryptedUser.age !== null && - decryptedUser.age !== undefined && - decryptedUser.age >= minAge && - decryptedUser.age <= maxAge - ) - }) - expect(allValidAges).toBe(true) }, 30000) }) diff --git a/packages/drizzle/__tests__/fixtures/user-seed-data.ts b/packages/drizzle/__tests__/fixtures/user-seed-data.ts new file mode 100644 index 00000000..547541ab --- /dev/null +++ b/packages/drizzle/__tests__/fixtures/user-seed-data.ts @@ -0,0 +1,54 @@ +import type { PlaintextUser } from '../integration-test-helpers' + +export const userSeedData: PlaintextUser[] = [ + { + email: 'john.doe@example.com', + age: 25, + score: 85, + profile: { + name: 'John Doe', + bio: 'Software engineer with 5 years experience', + level: 3, + }, + }, + { + email: 'jane.smith@example.com', + age: 30, + score: 92, + profile: { + name: 'Jane Smith', + bio: 'Senior developer specializing in React', + level: 4, + }, + }, + { + email: 'bob.wilson@example.com', + age: 35, + score: 78, + profile: { + name: 'Bob Wilson', + bio: 'Full-stack developer and team lead', + level: 5, + }, + }, + { + email: 'alice.johnson@example.com', + age: 28, + score: 88, + profile: { + name: 'Alice Johnson', + bio: 'Frontend specialist with design skills', + level: 3, + }, + }, + { + email: 'jill.smith@example.com', + age: 22, + score: 75, + profile: { + name: 'Jill Smith', + bio: 'Backend developer with 3 years experience', + level: 3, + }, + }, +] diff --git a/packages/drizzle/__tests__/integration-test-helpers.ts b/packages/drizzle/__tests__/integration-test-helpers.ts new file mode 100644 index 00000000..aedb7e64 --- /dev/null +++ b/packages/drizzle/__tests__/integration-test-helpers.ts @@ -0,0 +1,114 @@ +import type { ProtectError, Result } from '@cipherstash/protect' +import type { ProtectClient } from '@cipherstash/protect/client' +import { expect } from 'vitest' + +type UserProfile = { + name: string + bio: string + level: number +} + +export type DecryptedUser = { + id: number + email: string + age: number + score: number + profile: UserProfile +} + +export type PlaintextUser = Omit + +export type EncryptedPayload = { + c: string +} & Record + +export type EncryptedUserRow = { + id: number + email: EncryptedPayload + age: EncryptedPayload + score: EncryptedPayload + profile: EncryptedPayload +} + +function toComparableUser(user: PlaintextUser | DecryptedUser): PlaintextUser { + return { + email: user.email, + age: user.age, + score: user.score, + profile: { + name: user.profile.name, + bio: user.profile.bio, + level: user.profile.level, + }, + } +} + +function sortByEmail(users: PlaintextUser[]): PlaintextUser[] { + return [...users].sort((left, right) => left.email.localeCompare(right.email)) +} + +function assertEncryptedPayload( + value: unknown, + columnName: string, +): asserts value is EncryptedPayload { + expect( + value, + `${columnName} should be returned as encrypted payload before decrypt`, + ).toEqual(expect.objectContaining({ c: expect.any(String) })) +} + +export function unwrapResult(result: Result, operation: string): T { + if (result.failure) { + throw new Error(`${operation} failed: ${result.failure.message}`) + } + + return result.data +} + +export function expectRowsToBeEncrypted(rows: EncryptedUserRow[]) { + for (const row of rows) { + expect(row.id).toEqual(expect.any(Number)) + assertEncryptedPayload(row.email, 'email') + assertEncryptedPayload(row.age, 'age') + assertEncryptedPayload(row.score, 'score') + assertEncryptedPayload(row.profile, 'profile') + } +} + +export async function decryptUserRows( + protectClient: ProtectClient, + rows: EncryptedUserRow[], +): Promise { + const decrypted = await protectClient.bulkDecryptModels(rows) + return unwrapResult(decrypted, 'bulkDecryptModels') as unknown as DecryptedUser[] +} + +export async function decryptUserRow( + protectClient: ProtectClient, + row: EncryptedUserRow, +): Promise { + const decrypted = await protectClient.decryptModel(row) + return unwrapResult(decrypted, 'decryptModel') as unknown as DecryptedUser +} + +export function expectUserToMatchPlaintext( + actual: DecryptedUser, + expected: PlaintextUser, +) { + expect(actual.id).toEqual(expect.any(Number)) + expect(toComparableUser(actual)).toEqual(toComparableUser(expected)) +} + +export function expectUsersToMatchPlaintext( + actual: DecryptedUser[], + expected: PlaintextUser[], +) { + const normalizedActual = sortByEmail( + actual.map((user) => toComparableUser(user)), + ) + const normalizedExpected = sortByEmail( + expected.map((user) => toComparableUser(user)), + ) + + expect(normalizedActual).toEqual(normalizedExpected) +} diff --git a/packages/drizzle/__tests__/operators-jsonb.test.ts b/packages/drizzle/__tests__/operators-jsonb.test.ts new file mode 100644 index 00000000..cce8dacd --- /dev/null +++ b/packages/drizzle/__tests__/operators-jsonb.test.ts @@ -0,0 +1,184 @@ +import { pgTable } from 'drizzle-orm/pg-core' +import { describe, expect, it } from 'vitest' +import { encryptedType, ProtectOperatorError } from '@cipherstash/drizzle/pg' +import { setup } from './test-utils' + +const docsTable = pgTable('json_docs', { + metadata: encryptedType>('metadata', { + dataType: 'json', + searchableJson: true, + }), + noJsonConfig: encryptedType('no_json_config', { + equality: true, + }), +}) + +describe('createProtectOperators JSONB selector typing', () => { + it('casts jsonbPathQueryFirst selector params to eql_v2_encrypted', async () => { + const { encryptQuery, protectOps, dialect } = setup() + + const condition = await protectOps.jsonbPathQueryFirst( + docsTable.metadata, + '$.profile.email', + ) + const query = dialect.sqlToQuery(condition) + + expect(query.sql).toMatch( + /eql_v2\.jsonb_path_query_first\([^,]+,\s*\$\d+::eql_v2_encrypted\)/, + ) + expect(query.params).toHaveLength(1) + expect(query.params[0]).toContain('encrypted-value') + expect(encryptQuery).toHaveBeenCalledTimes(1) + expect(encryptQuery.mock.calls[0]?.[0]).toMatchObject([ + { queryType: 'steVecSelector' }, + ]) + }) + + it('casts jsonbPathExists selector params to eql_v2_encrypted', async () => { + const { encryptQuery, protectOps, dialect } = setup() + + const condition = await protectOps.jsonbPathExists( + docsTable.metadata, + '$.profile.email', + ) + const query = dialect.sqlToQuery(condition) + + expect(query.sql).toMatch( + /eql_v2\.jsonb_path_exists\([^,]+,\s*\$\d+::eql_v2_encrypted\)/, + ) + expect(query.params).toHaveLength(1) + expect(query.params[0]).toContain('encrypted-value') + expect(encryptQuery).toHaveBeenCalledTimes(1) + expect(encryptQuery.mock.calls[0]?.[0]).toMatchObject([ + { queryType: 'steVecSelector' }, + ]) + }) + + it('casts jsonbGet selector params to eql_v2_encrypted', async () => { + const { encryptQuery, protectOps, dialect } = setup() + + const condition = await protectOps.jsonbGet( + docsTable.metadata, + '$.profile.email', + ) + const query = dialect.sqlToQuery(condition) + + expect(query.sql).toMatch(/->\s*\$\d+::eql_v2_encrypted/) + expect(query.params).toHaveLength(1) + expect(query.params[0]).toContain('encrypted-value') + expect(encryptQuery).toHaveBeenCalledTimes(1) + expect(encryptQuery.mock.calls[0]?.[0]).toMatchObject([ + { queryType: 'steVecSelector' }, + ]) + }) +}) + +describe('JSONB operator error paths', () => { + it('throws ProtectOperatorError when column lacks searchableJson config', () => { + const { protectOps } = setup() + + expect(() => + protectOps.jsonbPathQueryFirst(docsTable.noJsonConfig, '$.path'), + ).toThrow(ProtectOperatorError) + + expect(() => + protectOps.jsonbPathQueryFirst(docsTable.noJsonConfig, '$.path'), + ).toThrow(/searchableJson/) + }) + + it('throws ProtectOperatorError for jsonbPathExists without searchableJson', () => { + const { protectOps } = setup() + + expect(() => + protectOps.jsonbPathExists(docsTable.noJsonConfig, '$.path'), + ).toThrow(ProtectOperatorError) + }) + + it('throws ProtectOperatorError for jsonbGet without searchableJson', () => { + const { protectOps } = setup() + + expect(() => + protectOps.jsonbGet(docsTable.noJsonConfig, '$.path'), + ).toThrow(ProtectOperatorError) + }) + + it('error includes column name and operator context', () => { + const { protectOps } = setup() + + try { + protectOps.jsonbPathQueryFirst(docsTable.noJsonConfig, '$.path') + expect.fail('Should have thrown') + } catch (error) { + expect(error).toBeInstanceOf(ProtectOperatorError) + const opError = error as ProtectOperatorError + expect(opError.context?.columnName).toBe('no_json_config') + expect(opError.context?.operator).toBe('jsonbPathQueryFirst') + } + }) +}) + +describe('JSONB batched operations', () => { + it('batches jsonbPathQueryFirst and jsonbGet in protectOps.and()', async () => { + const { encryptQuery, protectOps, dialect } = setup() + + const condition = await protectOps.and( + protectOps.jsonbPathQueryFirst(docsTable.metadata, '$.profile.email'), + protectOps.jsonbGet(docsTable.metadata, '$.profile.name'), + ) + const query = dialect.sqlToQuery(condition) + + expect(query.sql).toContain('eql_v2.jsonb_path_query_first') + expect(query.sql).toContain('->') + // Verify batch encryption happened (at least one call with 2 terms) + expect( + encryptQuery.mock.calls.some( + (call: unknown[]) => Array.isArray(call[0]) && call[0].length === 2, + ), + ).toBe(true) + }) + + it('batches jsonbPathExists and jsonbPathQueryFirst in protectOps.or()', async () => { + const { encryptQuery, protectOps, dialect } = setup() + + const condition = await protectOps.or( + protectOps.jsonbPathExists(docsTable.metadata, '$.profile.email'), + protectOps.jsonbPathQueryFirst(docsTable.metadata, '$.profile.name'), + ) + const query = dialect.sqlToQuery(condition) + + expect(query.sql).toContain('eql_v2.jsonb_path_exists') + expect(query.sql).toContain('eql_v2.jsonb_path_query_first') + // Verify batch encryption happened (at least one call with 2 terms) + expect( + encryptQuery.mock.calls.some( + (call: unknown[]) => Array.isArray(call[0]) && call[0].length === 2, + ), + ).toBe(true) + }) + + it('generates SQL combining conditions with AND', async () => { + const { protectOps, dialect } = setup() + + const condition = await protectOps.and( + protectOps.jsonbPathQueryFirst(docsTable.metadata, '$.a'), + protectOps.jsonbPathExists(docsTable.metadata, '$.b'), + ) + const query = dialect.sqlToQuery(condition) + + // AND combines conditions + expect(query.sql).toContain(' and ') + }) + + it('generates SQL combining conditions with OR', async () => { + const { protectOps, dialect } = setup() + + const condition = await protectOps.or( + protectOps.jsonbPathQueryFirst(docsTable.metadata, '$.a'), + protectOps.jsonbPathExists(docsTable.metadata, '$.b'), + ) + const query = dialect.sqlToQuery(condition) + + // OR combines conditions + expect(query.sql).toContain(' or ') + }) +}) diff --git a/packages/drizzle/__tests__/operators.test.ts b/packages/drizzle/__tests__/operators.test.ts new file mode 100644 index 00000000..9a6335e9 --- /dev/null +++ b/packages/drizzle/__tests__/operators.test.ts @@ -0,0 +1,452 @@ +import type { SQL } from 'drizzle-orm' +import { pgTable, integer, text } from 'drizzle-orm/pg-core' +import { describe, expect, it } from 'vitest' +import { encryptedType, ProtectOperatorError, ProtectConfigError } from '@cipherstash/drizzle/pg' +import { setup } from './test-utils' + +// ============================================================================ +// Test table definitions +// ============================================================================ + +const usersTable = pgTable('users', { + email: encryptedType('email', { + equality: true, + freeTextSearch: true, + orderAndRange: true, + }), + age: encryptedType('age', { + dataType: 'number', + orderAndRange: true, + }), + name: encryptedType('name', { + equality: true, + }), + bio: encryptedType('bio', { + freeTextSearch: true, + }), +}) + +// ============================================================================ +// 2a. Comparison operators +// ============================================================================ + +describe('Comparison operators', () => { + it('eq on column with equality config uses = with encrypted param', async () => { + const { encryptQuery, protectOps, dialect } = setup() + + const condition = await protectOps.eq(usersTable.name, 'Alice') + const query = dialect.sqlToQuery(condition) + + // eq with equality config uses regular Drizzle eq (= operator) + expect(query.sql).toContain('=') + expect(query.params).toHaveLength(1) + expect(query.params[0]).toContain('encrypted-value') + expect(encryptQuery).toHaveBeenCalledTimes(1) + expect(encryptQuery.mock.calls[0]?.[0]).toMatchObject([ + { queryType: 'equality' }, + ]) + }) + + it('ne on column with equality config encrypts and uses <>', async () => { + const { encryptQuery, protectOps, dialect } = setup() + + const condition = await protectOps.ne(usersTable.name, 'Alice') + const query = dialect.sqlToQuery(condition) + + expect(query.sql).toContain('<>') + expect(query.params).toHaveLength(1) + expect(encryptQuery).toHaveBeenCalledTimes(1) + }) + + it('gt on column with orderAndRange uses eql_v2.gt()', async () => { + const { encryptQuery, protectOps, dialect } = setup() + + const condition = await protectOps.gt(usersTable.age, 25) + const query = dialect.sqlToQuery(condition) + + expect(query.sql).toContain('eql_v2.gt(') + expect(query.params).toHaveLength(1) + expect(query.params[0]).toContain('encrypted-value') + expect(encryptQuery).toHaveBeenCalledTimes(1) + expect(encryptQuery.mock.calls[0]?.[0]).toMatchObject([ + { queryType: 'orderAndRange' }, + ]) + }) + + it('gte on column with orderAndRange uses eql_v2.gte()', async () => { + const { protectOps, dialect } = setup() + + const condition = await protectOps.gte(usersTable.age, 25) + const query = dialect.sqlToQuery(condition) + + expect(query.sql).toContain('eql_v2.gte(') + }) + + it('lt on column with orderAndRange uses eql_v2.lt()', async () => { + const { protectOps, dialect } = setup() + + const condition = await protectOps.lt(usersTable.age, 30) + const query = dialect.sqlToQuery(condition) + + expect(query.sql).toContain('eql_v2.lt(') + }) + + it('lte on column with orderAndRange uses eql_v2.lte()', async () => { + const { protectOps, dialect } = setup() + + const condition = await protectOps.lte(usersTable.age, 30) + const query = dialect.sqlToQuery(condition) + + expect(query.sql).toContain('eql_v2.lte(') + }) + + it('eq on column without equality falls through to plain Drizzle eq', async () => { + // age has orderAndRange but not equality - eq should fall through to regular Drizzle eq + // since the code checks equality first, then orderAndRange for gt/gte/lt/lte only + const { protectOps, dialect } = setup() + + // age column has orderAndRange but NOT equality + const condition = await protectOps.eq(usersTable.age, 25) + const query = dialect.sqlToQuery(condition) + + // Without equality config, eq falls through to regular Drizzle eq + expect(query.sql).toContain('=') + }) + + it('eq on column with both equality and orderAndRange prefers equality', async () => { + const { encryptQuery, protectOps, dialect } = setup() + + // email has both equality and orderAndRange + const condition = await protectOps.eq(usersTable.email, 'test@example.com') + const query = dialect.sqlToQuery(condition) + + expect(query.sql).toContain('=') + expect(encryptQuery).toHaveBeenCalledTimes(1) + expect(encryptQuery.mock.calls[0]?.[0]).toMatchObject([ + { queryType: 'equality' }, + ]) + }) +}) + +// ============================================================================ +// 2b. Text search operators +// ============================================================================ + +describe('Text search operators', () => { + it('ilike on column with freeTextSearch uses eql_v2.ilike()', async () => { + const { encryptQuery, protectOps, dialect } = setup() + + const condition = await protectOps.ilike(usersTable.bio, '%engineer%') + const query = dialect.sqlToQuery(condition) + + expect(query.sql).toContain('eql_v2.ilike(') + expect(query.params[0]).toContain('encrypted-value') + expect(encryptQuery).toHaveBeenCalledTimes(1) + expect(encryptQuery.mock.calls[0]?.[0]).toMatchObject([ + { queryType: 'freeTextSearch' }, + ]) + }) + + it('like on column with freeTextSearch uses eql_v2.like()', async () => { + const { protectOps, dialect } = setup() + + const condition = await protectOps.like(usersTable.bio, '%test%') + const query = dialect.sqlToQuery(condition) + + expect(query.sql).toContain('eql_v2.like(') + }) + + it('notIlike wraps eql_v2.ilike() with NOT', async () => { + const { protectOps, dialect } = setup() + + const condition = await protectOps.notIlike(usersTable.bio, '%spam%') + const query = dialect.sqlToQuery(condition) + + expect(query.sql).toMatch(/NOT.*eql_v2\.ilike\(/i) + }) + + it('ilike on column without freeTextSearch falls through to Drizzle ilike', () => { + const { protectOps, dialect } = setup() + + // name column has equality but not freeTextSearch + const condition = protectOps.ilike(usersTable.name, '%test%') + + // Should be synchronous (no encryption needed) since no freeTextSearch config + expect(condition).not.toBeInstanceOf(Promise) + + const query = dialect.sqlToQuery(condition as SQL) + expect(query.sql).toContain('ilike') + }) +}) + +// ============================================================================ +// 2c. Range operators +// ============================================================================ + +describe('Range operators', () => { + it('between on column with orderAndRange generates eql_v2.gte AND eql_v2.lte', async () => { + const { encryptQuery, protectOps, dialect } = setup() + + const condition = await protectOps.between(usersTable.age, 20, 30) + const query = dialect.sqlToQuery(condition) + + expect(query.sql).toContain('eql_v2.gte(') + expect(query.sql).toContain('eql_v2.lte(') + // Both min and max values encrypted + expect(query.params).toHaveLength(2) + expect(query.params[0]).toContain('encrypted-value') + expect(query.params[1]).toContain('encrypted-value') + expect(encryptQuery).toHaveBeenCalled() + }) + + it('notBetween wraps range condition with NOT', async () => { + const { protectOps, dialect } = setup() + + const condition = await protectOps.notBetween(usersTable.age, 20, 30) + const query = dialect.sqlToQuery(condition) + + expect(query.sql).toMatch(/NOT/) + expect(query.sql).toContain('eql_v2.gte(') + expect(query.sql).toContain('eql_v2.lte(') + }) + + it('between on column without orderAndRange falls through to Drizzle between', () => { + const { protectOps, dialect } = setup() + + // name column has equality but not orderAndRange + const condition = protectOps.between(usersTable.name, 'A', 'Z') + + // Should be synchronous (plain Drizzle between) + expect(condition).not.toBeInstanceOf(Promise) + + const query = dialect.sqlToQuery(condition as SQL) + expect(query.sql).toContain('between') + }) +}) + +// ============================================================================ +// 2d. Sorting operators +// ============================================================================ + +describe('Sorting operators', () => { + it('asc on column with orderAndRange uses eql_v2.order_by()', () => { + const { protectOps, dialect } = setup() + + const result = protectOps.asc(usersTable.age) + const query = dialect.sqlToQuery(result) + + expect(query.sql).toContain('eql_v2.order_by(') + expect(query.sql).toMatch(/asc/i) + }) + + it('desc on column with orderAndRange uses eql_v2.order_by()', () => { + const { protectOps, dialect } = setup() + + const result = protectOps.desc(usersTable.age) + const query = dialect.sqlToQuery(result) + + expect(query.sql).toContain('eql_v2.order_by(') + expect(query.sql).toMatch(/desc/i) + }) + + it('asc on column without orderAndRange uses plain Drizzle asc', () => { + const { protectOps, dialect } = setup() + + const result = protectOps.asc(usersTable.name) + const query = dialect.sqlToQuery(result) + + expect(query.sql).not.toContain('eql_v2.order_by(') + }) + + it('desc on column without orderAndRange uses plain Drizzle desc', () => { + const { protectOps, dialect } = setup() + + const result = protectOps.desc(usersTable.name) + const query = dialect.sqlToQuery(result) + + expect(query.sql).not.toContain('eql_v2.order_by(') + }) +}) + +// ============================================================================ +// 2e. Array operators +// ============================================================================ + +describe('Array operators', () => { + it('inArray on column with equality encrypts all values', async () => { + const { encryptQuery, protectOps, dialect } = setup() + + const condition = await protectOps.inArray(usersTable.name, [ + 'Alice', + 'Bob', + 'Carol', + ]) + const query = dialect.sqlToQuery(condition) + + // inArray with equality uses OR of eq() conditions + expect(query.params.length).toBeGreaterThanOrEqual(3) + expect(encryptQuery).toHaveBeenCalled() + }) + + it('notInArray on column with equality encrypts all values', async () => { + const { encryptQuery, protectOps, dialect } = setup() + + const condition = await protectOps.notInArray(usersTable.name, [ + 'Alice', + 'Bob', + ]) + const query = dialect.sqlToQuery(condition) + + expect(query.params.length).toBeGreaterThanOrEqual(2) + expect(encryptQuery).toHaveBeenCalled() + }) + + it('inArray batch-encrypts values in single call', async () => { + const { encryptQuery, protectOps } = setup() + + await protectOps.inArray(usersTable.name, ['Alice', 'Bob']) + + // All values should be batch-encrypted in a single call + expect(encryptQuery).toHaveBeenCalledTimes(1) + const terms = encryptQuery.mock.calls[0]?.[0] as unknown[] + expect(terms).toHaveLength(2) + }) +}) + +// ============================================================================ +// 2f. Error classes +// ============================================================================ + +describe('Error classes', () => { + it('ProtectOperatorError stores context with tableName, columnName, operator', () => { + const error = new ProtectOperatorError('Test error', { + tableName: 'users', + columnName: 'email', + operator: 'eq', + }) + + expect(error).toBeInstanceOf(Error) + expect(error).toBeInstanceOf(ProtectOperatorError) + expect(error.name).toBe('ProtectOperatorError') + expect(error.message).toBe('Test error') + expect(error.context?.tableName).toBe('users') + expect(error.context?.columnName).toBe('email') + expect(error.context?.operator).toBe('eq') + }) + + it('ProtectConfigError extends ProtectOperatorError', () => { + const error = new ProtectConfigError('Config error', { + tableName: 'users', + columnName: 'age', + operator: 'gt', + }) + + expect(error).toBeInstanceOf(Error) + expect(error).toBeInstanceOf(ProtectOperatorError) + expect(error).toBeInstanceOf(ProtectConfigError) + expect(error.name).toBe('ProtectConfigError') + expect(error.context?.tableName).toBe('users') + }) + + it('ProtectOperatorError works without context', () => { + const error = new ProtectOperatorError('No context') + + expect(error.message).toBe('No context') + expect(error.context).toBeUndefined() + }) +}) + +// ============================================================================ +// 2g. Pass-through operators +// ============================================================================ + +describe('Pass-through operators', () => { + it('exists is the Drizzle exists function', () => { + const { protectOps } = setup() + + // These should be direct references to drizzle-orm functions + expect(typeof protectOps.exists).toBe('function') + expect(typeof protectOps.notExists).toBe('function') + expect(typeof protectOps.isNull).toBe('function') + expect(typeof protectOps.isNotNull).toBe('function') + expect(typeof protectOps.not).toBe('function') + }) + + it('arrayContains/arrayContained/arrayOverlaps are Drizzle functions', () => { + const { protectOps } = setup() + + expect(typeof protectOps.arrayContains).toBe('function') + expect(typeof protectOps.arrayContained).toBe('function') + expect(typeof protectOps.arrayOverlaps).toBe('function') + }) +}) + +// ============================================================================ +// 2h. Batched and/or +// ============================================================================ + +describe('Batched and/or operators', () => { + it('protectOps.and() batches multiple encrypted operators', async () => { + const { encryptQuery, protectOps, dialect } = setup() + + const condition = await protectOps.and( + protectOps.eq(usersTable.email, 'test@example.com'), + protectOps.gte(usersTable.age, 18), + protectOps.ilike(usersTable.bio, '%engineer%'), + ) + const query = dialect.sqlToQuery(condition) + + // All three conditions should be in the SQL + expect(query.sql).toContain('=') + expect(query.sql).toContain('eql_v2.gte(') + expect(query.sql).toContain('eql_v2.ilike(') + // SQL uses AND + expect(query.sql).toContain(' and ') + expect(encryptQuery).toHaveBeenCalled() + }) + + it('protectOps.or() batches and uses OR', async () => { + const { encryptQuery, protectOps, dialect } = setup() + + const condition = await protectOps.or( + protectOps.eq(usersTable.name, 'Alice'), + protectOps.eq(usersTable.name, 'Bob'), + ) + const query = dialect.sqlToQuery(condition) + + // SQL uses OR + expect(query.sql).toContain(' or ') + expect(query.params.length).toBeGreaterThanOrEqual(2) + // Both eq operators trigger encryption + expect(encryptQuery).toHaveBeenCalled() + }) + + it('protectOps.and() handles undefined conditions', async () => { + const { protectOps, dialect } = setup() + + const condition = await protectOps.and( + protectOps.eq(usersTable.name, 'Alice'), + undefined, + protectOps.gte(usersTable.age, 18), + ) + const query = dialect.sqlToQuery(condition) + + // Should still produce valid SQL with the non-undefined conditions + expect(query.sql).toContain('=') + expect(query.sql).toContain('eql_v2.gte(') + expect(query.sql).toContain(' and ') + }) + + it('protectOps.and() with only non-encrypted conditions', async () => { + const { protectOps } = setup() + + // Using the plain Drizzle eq (non-encrypted column fallback) + // age has no equality, so eq falls through to Drizzle eq (synchronous) + const eqResult = protectOps.eq(usersTable.age, 25) + + // If synchronous (non-encrypted), and() should still work + const condition = await protectOps.and(eqResult) + + expect(condition).toBeTruthy() + }) +}) diff --git a/packages/drizzle/__tests__/schema-extraction.test.ts b/packages/drizzle/__tests__/schema-extraction.test.ts new file mode 100644 index 00000000..51684b57 --- /dev/null +++ b/packages/drizzle/__tests__/schema-extraction.test.ts @@ -0,0 +1,285 @@ +import { integer, pgTable, text } from 'drizzle-orm/pg-core' +import { describe, expect, it } from 'vitest' +import { encryptedType, extractProtectSchema } from '@cipherstash/drizzle/pg' + +// ============================================================================ +// 3a. Basic extraction +// ============================================================================ + +describe('extractProtectSchema basic extraction', () => { + it('extracts a single encrypted column', () => { + const table = pgTable('single_col', { + email: encryptedType('email', { + equality: true, + }), + }) + + const protectTable = extractProtectSchema(table) + const built = protectTable.build() + + expect(built.tableName).toBe('single_col') + expect(built.columns).toHaveProperty('email') + expect(Object.keys(built.columns)).toHaveLength(1) + }) + + it('extracts multiple encrypted columns with different configs', () => { + const table = pgTable('multi_col', { + email: encryptedType('email', { + equality: true, + freeTextSearch: true, + }), + age: encryptedType('age', { + dataType: 'number', + orderAndRange: true, + }), + metadata: encryptedType>('metadata', { + dataType: 'json', + searchableJson: true, + }), + }) + + const protectTable = extractProtectSchema(table) + const built = protectTable.build() + + expect(built.tableName).toBe('multi_col') + expect(Object.keys(built.columns)).toHaveLength(3) + expect(built.columns).toHaveProperty('email') + expect(built.columns).toHaveProperty('age') + expect(built.columns).toHaveProperty('metadata') + }) +}) + +// ============================================================================ +// 3b. Config option mapping +// ============================================================================ + +describe('extractProtectSchema config mapping', () => { + it('equality: true -> column has unique index in build output', () => { + const table = pgTable('eq_test', { + col: encryptedType('col', { equality: true }), + }) + + const built = extractProtectSchema(table).build() + expect(built.columns.col.indexes).toHaveProperty('unique') + }) + + it('orderAndRange: true -> column has ore index in build output', () => { + const table = pgTable('ore_test', { + col: encryptedType('col', { orderAndRange: true }), + }) + + const built = extractProtectSchema(table).build() + expect(built.columns.col.indexes).toHaveProperty('ore') + }) + + it('freeTextSearch: true -> column has match index in build output', () => { + const table = pgTable('match_test', { + col: encryptedType('col', { freeTextSearch: true }), + }) + + const built = extractProtectSchema(table).build() + expect(built.columns.col.indexes).toHaveProperty('match') + }) + + it('searchableJson: true -> column has ste_vec index in build output', () => { + const table = pgTable('ste_vec_test', { + col: encryptedType>('col', { + dataType: 'json', + searchableJson: true, + }), + }) + + const built = extractProtectSchema(table).build() + expect(built.columns.col.indexes).toHaveProperty('ste_vec') + // ste_vec prefix is automatically set to tableName/columnName + expect(built.columns.col.indexes.ste_vec?.prefix).toBe('ste_vec_test/col') + }) + + it('dataType: "json" -> column has cast_as "json"', () => { + const table = pgTable('json_cast_test', { + col: encryptedType>('col', { + dataType: 'json', + searchableJson: true, + }), + }) + + const built = extractProtectSchema(table).build() + expect(built.columns.col.cast_as).toBe('json') + }) + + it('dataType: "number" -> column has appropriate cast_as', () => { + const table = pgTable('number_cast_test', { + col: encryptedType('col', { + dataType: 'number', + orderAndRange: true, + }), + }) + + const built = extractProtectSchema(table).build() + expect(built.columns.col.cast_as).toBe('number') + }) + + it('default dataType is string', () => { + const table = pgTable('default_cast_test', { + col: encryptedType('col', { equality: true }), + }) + + const built = extractProtectSchema(table).build() + expect(built.columns.col.cast_as).toBe('string') + }) + + it('combined configs: equality + orderAndRange + freeTextSearch', () => { + const table = pgTable('combined_test', { + col: encryptedType('col', { + equality: true, + orderAndRange: true, + freeTextSearch: true, + }), + }) + + const built = extractProtectSchema(table).build() + const indexes = built.columns.col.indexes + + expect(indexes).toHaveProperty('unique') + expect(indexes).toHaveProperty('ore') + expect(indexes).toHaveProperty('match') + }) + + it('combined configs: equality + searchableJson', () => { + const table = pgTable('combined_json_test', { + col: encryptedType>('col', { + dataType: 'json', + equality: true, + searchableJson: true, + }), + }) + + const built = extractProtectSchema(table).build() + const indexes = built.columns.col.indexes + + expect(indexes).toHaveProperty('unique') + expect(indexes).toHaveProperty('ste_vec') + }) +}) + +// ============================================================================ +// 3c. Edge cases +// ============================================================================ + +describe('extractProtectSchema edge cases', () => { + it('throws when table has zero encrypted columns', () => { + const table = pgTable('no_encrypted', { + title: text('title'), + count: integer('count'), + }) + + expect(() => extractProtectSchema(table)).toThrow( + /No encrypted columns found/, + ) + }) + + it('throws when searchableJson is used without dataType: json', () => { + const table = pgTable('bad_searchable_json', { + col: encryptedType('col', { + searchableJson: true, + }), + }) + + expect(() => extractProtectSchema(table)).toThrow( + /searchableJson.*requires dataType: 'json'/, + ) + }) + + it('throws when searchableJson is used with dataType: number', () => { + const table = pgTable('bad_searchable_json_number', { + col: encryptedType('col', { + dataType: 'number', + searchableJson: true, + }), + }) + + expect(() => extractProtectSchema(table)).toThrow( + /searchableJson.*requires dataType: 'json'/, + ) + }) + + it('mixed encrypted and regular columns -> only encrypted columns extracted', () => { + const table = pgTable('mixed_cols', { + id: integer('id').primaryKey(), + email: encryptedType('email', { equality: true }), + name: text('name'), + age: encryptedType('age', { + dataType: 'number', + orderAndRange: true, + }), + description: text('description'), + }) + + const built = extractProtectSchema(table).build() + + // Only encrypted columns should be in the output + expect(Object.keys(built.columns)).toHaveLength(2) + expect(built.columns).toHaveProperty('email') + expect(built.columns).toHaveProperty('age') + expect(built.columns).not.toHaveProperty('id') + expect(built.columns).not.toHaveProperty('name') + expect(built.columns).not.toHaveProperty('description') + }) + + it('uses the SQL column name (not property key) for the column', () => { + // When the property key in pgTable differs from the column name + // The encryptedType name parameter is the actual SQL column name + const table = pgTable('name_test', { + userEmail: encryptedType('user_email', { equality: true }), + }) + + const built = extractProtectSchema(table).build() + + // The column name in the build output should be the SQL column name + expect(built.columns).toHaveProperty('user_email') + }) + + it('table name matches the pgTable name', () => { + const table = pgTable('my_custom_table', { + col: encryptedType('col', { equality: true }), + }) + + const built = extractProtectSchema(table).build() + expect(built.tableName).toBe('my_custom_table') + }) + + it('equality with custom token filters is passed through', () => { + const table = pgTable('token_filter_test', { + col: encryptedType('col', { + equality: [{ kind: 'downcase' }], + }), + }) + + const built = extractProtectSchema(table).build() + expect(built.columns.col.indexes).toHaveProperty('unique') + expect(built.columns.col.indexes.unique?.token_filters).toEqual([ + { kind: 'downcase' }, + ]) + }) + + it('freeTextSearch with custom MatchIndexOpts is passed through', () => { + const table = pgTable('match_opts_test', { + col: encryptedType('col', { + freeTextSearch: { + tokenizer: { kind: 'ngram', token_length: 3 }, + k: 6, + m: 2048, + include_original: true, + token_filters: [{ kind: 'downcase' }], + }, + }), + }) + + const built = extractProtectSchema(table).build() + expect(built.columns.col.indexes).toHaveProperty('match') + expect(built.columns.col.indexes.match?.tokenizer).toEqual({ + kind: 'ngram', + token_length: 3, + }) + }) +}) diff --git a/packages/drizzle/__tests__/test-utils.ts b/packages/drizzle/__tests__/test-utils.ts new file mode 100644 index 00000000..2762fd91 --- /dev/null +++ b/packages/drizzle/__tests__/test-utils.ts @@ -0,0 +1,27 @@ +import type { ProtectClient } from '@cipherstash/protect/client' +import { PgDialect } from 'drizzle-orm/pg-core' +import { vi } from 'vitest' +import { createProtectOperators } from '@cipherstash/drizzle/pg' + +export const ENCRYPTED_VALUE = '{"v":"encrypted-value"}' + +export function createMockProtectClient() { + const encryptQuery = vi.fn(async (termsOrValue: unknown) => { + if (Array.isArray(termsOrValue)) { + return { data: termsOrValue.map(() => ENCRYPTED_VALUE) } + } + return { data: ENCRYPTED_VALUE } + }) + + return { + client: { encryptQuery } as unknown as ProtectClient, + encryptQuery, + } +} + +export function setup() { + const { client, encryptQuery } = createMockProtectClient() + const protectOps = createProtectOperators(client) + const dialect = new PgDialect() + return { client, encryptQuery, protectOps, dialect } +} diff --git a/packages/drizzle/src/pg/index.ts b/packages/drizzle/src/pg/index.ts index f8f8bce2..4443f710 100644 --- a/packages/drizzle/src/pg/index.ts +++ b/packages/drizzle/src/pg/index.ts @@ -23,6 +23,11 @@ export type EncryptedColumnConfig = { * Enable order and range index for sorting and range queries. */ orderAndRange?: boolean + /** + * Enable searchable JSON index for JSONB path queries. + * Requires dataType: 'json'. + */ + searchableJson?: boolean } /** @@ -53,6 +58,7 @@ const columnConfigMap = new Map< * - `freeTextSearch`: Enables free text search index. Can be a boolean for default options, or an object for custom configuration. * - `equality`: Enables equality index. Can be a boolean for default options, or an array of token filters. * - `orderAndRange`: Enables order and range index for sorting and range queries. + * - `searchableJson`: Enables searchable JSON index for JSONB path queries on encrypted JSON columns. * * See {@link EncryptedColumnConfig}. * @@ -187,4 +193,8 @@ export function getEncryptedColumnConfig( export { extractProtectSchema } from './schema-extraction.js' // Re-export operators -export { createProtectOperators } from './operators.js' +export { + createProtectOperators, + ProtectOperatorError, + ProtectConfigError, +} from './operators.js' diff --git a/packages/drizzle/src/pg/operators.ts b/packages/drizzle/src/pg/operators.ts index 9addf834..54171d45 100644 --- a/packages/drizzle/src/pg/operators.ts +++ b/packages/drizzle/src/pg/operators.ts @@ -1,10 +1,10 @@ +import type { QueryTypeName } from '@cipherstash/protect' import type { ProtectClient, ProtectColumn, ProtectTable, ProtectTableColumn, } from '@cipherstash/protect/client' -import type { QueryTypeName } from '@cipherstash/protect' import { type SQL, type SQLWrapper, @@ -261,6 +261,7 @@ interface ValueToEncrypt { readonly column: SQLWrapper readonly columnInfo: ColumnInfo readonly queryType?: QueryTypeName + readonly originalIndex: number } /** @@ -269,7 +270,11 @@ interface ValueToEncrypt { */ async function encryptValues( protectClient: ProtectClient, - values: Array<{ value: unknown; column: SQLWrapper; queryType?: QueryTypeName }>, + values: Array<{ + value: unknown + column: SQLWrapper + queryType?: QueryTypeName + }>, protectTable: ProtectTable | undefined, protectTableCache: Map>, ): Promise { @@ -301,6 +306,7 @@ async function encryptValues( column, columnInfo, queryType, + originalIndex: i, }) } @@ -314,13 +320,18 @@ async function encryptValues( { column: ProtectColumn table: ProtectTable - values: Array<{ value: string | number; index: number; queryType?: QueryTypeName }> + columnName: string + values: Array<{ + value: string | number + index: number + queryType?: QueryTypeName + }> resultIndices: number[] } >() let valueIndex = 0 - for (const { value, column, columnInfo, queryType } of valuesToEncrypt) { + for (const { value, columnInfo, queryType, originalIndex } of valuesToEncrypt) { // Safe access with validation - we know these exist from earlier checks if ( !columnInfo.config || @@ -331,32 +342,25 @@ async function encryptValues( } const columnName = columnInfo.config.name - let group = columnGroups.get(columnName) + const groupKey = `${columnInfo.tableName ?? 'unknown'}/${columnName}` + let group = columnGroups.get(groupKey) if (!group) { group = { column: columnInfo.protectColumn, table: columnInfo.protectTable, + columnName, values: [], resultIndices: [], } - columnGroups.set(columnName, group) + columnGroups.set(groupKey, group) } group.values.push({ value, index: valueIndex++, queryType }) - - // Find the original index in the results array - const originalIndex = values.findIndex( - (v, idx) => - v.column === column && - toPlaintext(v.value) === value && - results[idx] === undefined, - ) - if (originalIndex >= 0) { - group.resultIndices.push(originalIndex) - } + group.resultIndices.push(originalIndex) } // Encrypt all values for each column in batches - for (const [columnName, group] of columnGroups) { + for (const [, group] of columnGroups) { + const { columnName } = group try { const terms = group.values.map((v) => ({ value: v.value, @@ -698,8 +702,8 @@ function createComparisonOperator( protectClient, defaultProtectTable, protectTableCache, - undefined, // min - undefined, // max + undefined, // min + undefined, // max 'orderAndRange', ) as Promise } @@ -732,8 +736,8 @@ function createComparisonOperator( protectClient, defaultProtectTable, protectTableCache, - undefined, // min - undefined, // max + undefined, // min + undefined, // max 'equality', ) as Promise } @@ -855,12 +859,90 @@ function createTextSearchOperator( protectClient, defaultProtectTable, protectTableCache, - undefined, // min - undefined, // max + undefined, // min + undefined, // max 'freeTextSearch', ) as Promise } +/** + * Creates a JSONB operator that encrypts a JSON path selector and wraps it + * in the appropriate `eql_v2` function call. + * + * Supports `jsonbPathQueryFirst`, `jsonbGet`, and `jsonbPathExists`. + * The column must have `searchableJson` enabled in its {@link EncryptedColumnConfig}. + * + * @param operator - Which JSONB operation to perform. + * @param left - The encrypted column reference. + * @param right - The JSON path selector value to encrypt. + * @param columnInfo - Resolved column metadata including config and table name. + * @param protectClient - The Protect client used for encryption. + * @param defaultProtectTable - The default protect table for schema resolution. + * @param protectTableCache - Cache of resolved protect tables. + * @returns A promise resolving to the SQL condition with an encrypted, cast parameter. + * @throws {ProtectOperatorError} If `searchableJson` is not enabled on the column, or if encryption fails. + */ +function createJsonbOperator( + operator: 'jsonbPathQueryFirst' | 'jsonbGet' | 'jsonbPathExists', + left: SQLWrapper, + right: unknown, + columnInfo: ColumnInfo, + protectClient: ProtectClient, + defaultProtectTable: ProtectTable | undefined, + protectTableCache: Map>, +): Promise { + const { config } = columnInfo + const encryptedSelector = (value: unknown) => + sql`${bindIfParam(value, left)}::eql_v2_encrypted` + + if (!config?.searchableJson) { + throw new ProtectOperatorError( + `The ${operator} operator requires searchableJson to be enabled on the column configuration.`, + { + columnName: columnInfo.columnName, + tableName: columnInfo.tableName, + operator, + }, + ) + } + + const executeFn = (encrypted: unknown) => { + if (encrypted === undefined) { + throw new ProtectOperatorError( + `Encryption failed for ${operator} operator`, + { + columnName: columnInfo.columnName, + tableName: columnInfo.tableName, + operator, + }, + ) + } + switch (operator) { + case 'jsonbPathQueryFirst': + return sql`eql_v2.jsonb_path_query_first(${left}, ${encryptedSelector(encrypted)})` + case 'jsonbGet': + return sql`${left} -> ${encryptedSelector(encrypted)}` + case 'jsonbPathExists': + return sql`eql_v2.jsonb_path_exists(${left}, ${encryptedSelector(encrypted)})` + } + } + + return createLazyOperator( + operator, + left, + right, + executeFn, + true, + columnInfo, + protectClient, + defaultProtectTable, + protectTableCache, + undefined, + undefined, + 'steVecSelector', + ) as Promise +} + // ============================================================================ // Public API: createProtectOperators // ============================================================================ @@ -1042,6 +1124,60 @@ export function createProtectOperators(protectClient: ProtectClient): { */ ilike: (left: SQLWrapper, right: unknown) => Promise | SQL notIlike: (left: SQLWrapper, right: unknown) => Promise | SQL + + /** + * JSONB path query first operator for encrypted columns with searchable JSON. + * Requires `searchableJson` to be set on {@link EncryptedColumnConfig}. + * + * Encrypts the JSON path selector and calls `eql_v2.jsonb_path_query_first()`, + * casting the parameter to `eql_v2_encrypted`. + * + * @example + * Query the first matching value at a JSON path inside an encrypted column. + * ```ts + * const condition = await protectOps.jsonbPathQueryFirst(docsTable.metadata, '$.profile.email') + * const results = await db.select().from(docsTable).where(condition) + * ``` + * + * @throws {ProtectOperatorError} If the column does not have `searchableJson` enabled. + */ + jsonbPathQueryFirst: (left: SQLWrapper, right: unknown) => Promise + + /** + * JSONB get operator for encrypted columns with searchable JSON. + * Requires `searchableJson` to be set on {@link EncryptedColumnConfig}. + * + * Encrypts the JSON path selector and uses the `->` operator, + * casting the parameter to `eql_v2_encrypted`. + * + * @example + * Get a value at a JSON path inside an encrypted column. + * ```ts + * const condition = await protectOps.jsonbGet(docsTable.metadata, '$.profile.name') + * const results = await db.select().from(docsTable).where(condition) + * ``` + * + * @throws {ProtectOperatorError} If the column does not have `searchableJson` enabled. + */ + jsonbGet: (left: SQLWrapper, right: unknown) => Promise + + /** + * JSONB path exists operator for encrypted columns with searchable JSON. + * Requires `searchableJson` to be set on {@link EncryptedColumnConfig}. + * + * Encrypts the JSON path selector and calls `eql_v2.jsonb_path_exists()`, + * casting the parameter to `eql_v2_encrypted`. + * + * @example + * Check whether a JSON path exists inside an encrypted column. + * ```ts + * const condition = await protectOps.jsonbPathExists(docsTable.metadata, '$.profile.email') + * const results = await db.select().from(docsTable).where(condition) + * ``` + * + * @throws {ProtectOperatorError} If the column does not have `searchableJson` enabled. + */ + jsonbPathExists: (left: SQLWrapper, right: unknown) => Promise // Array operators inArray: (left: SQLWrapper, right: unknown[] | SQLWrapper) => Promise notInArray: (left: SQLWrapper, right: unknown[] | SQLWrapper) => Promise @@ -1309,6 +1445,81 @@ export function createProtectOperators(protectClient: ProtectClient): { ) } + /** + * JSONB path query first operator - encrypts the selector and calls + * `eql_v2.jsonb_path_query_first()` for encrypted columns with searchable JSON. + * + * @throws {ProtectOperatorError} If the column lacks `searchableJson` config. + */ + const protectJsonbPathQueryFirst = ( + left: SQLWrapper, + right: unknown, + ): Promise => { + const columnInfo = getColumnInfo( + left, + defaultProtectTable, + protectTableCache, + ) + return createJsonbOperator( + 'jsonbPathQueryFirst', + left, + right, + columnInfo, + protectClient, + defaultProtectTable, + protectTableCache, + ) + } + + /** + * JSONB get operator - encrypts the selector and uses the `->` operator + * for encrypted columns with searchable JSON. + * + * @throws {ProtectOperatorError} If the column lacks `searchableJson` config. + */ + const protectJsonbGet = (left: SQLWrapper, right: unknown): Promise => { + const columnInfo = getColumnInfo( + left, + defaultProtectTable, + protectTableCache, + ) + return createJsonbOperator( + 'jsonbGet', + left, + right, + columnInfo, + protectClient, + defaultProtectTable, + protectTableCache, + ) + } + + /** + * JSONB path exists operator - encrypts the selector and calls + * `eql_v2.jsonb_path_exists()` for encrypted columns with searchable JSON. + * + * @throws {ProtectOperatorError} If the column lacks `searchableJson` config. + */ + const protectJsonbPathExists = ( + left: SQLWrapper, + right: unknown, + ): Promise => { + const columnInfo = getColumnInfo( + left, + defaultProtectTable, + protectTableCache, + ) + return createJsonbOperator( + 'jsonbPathExists', + left, + right, + columnInfo, + protectClient, + defaultProtectTable, + protectTableCache, + ) + } + /** * In array operator - encrypts all values in the array */ @@ -1334,7 +1545,11 @@ export function createProtectOperators(protectClient: ProtectClient): { // Encrypt all values in the array in a single batch const encryptedValues = await encryptValues( protectClient, - right.map((value) => ({ value, column: left, queryType: 'equality' as const })), + right.map((value) => ({ + value, + column: left, + queryType: 'equality' as const, + })), defaultProtectTable, protectTableCache, ) @@ -1380,7 +1595,11 @@ export function createProtectOperators(protectClient: ProtectClient): { // Encrypt all values in the array in a single batch const encryptedValues = await encryptValues( protectClient, - right.map((value) => ({ value, column: left, queryType: 'equality' as const })), + right.map((value) => ({ + value, + column: left, + queryType: 'equality' as const, + })), defaultProtectTable, protectTableCache, ) @@ -1519,7 +1738,11 @@ export function createProtectOperators(protectClient: ProtectClient): { // Batch encrypt all values const encryptedResults = await encryptValues( protectClient, - valuesToEncrypt.map((v) => ({ value: v.value, column: v.column, queryType: v.queryType })), + valuesToEncrypt.map((v) => ({ + value: v.value, + column: v.column, + queryType: v.queryType, + })), defaultProtectTable, protectTableCache, ) @@ -1674,7 +1897,11 @@ export function createProtectOperators(protectClient: ProtectClient): { const encryptedResults = await encryptValues( protectClient, - valuesToEncrypt.map((v) => ({ value: v.value, column: v.column, queryType: v.queryType })), + valuesToEncrypt.map((v) => ({ + value: v.value, + column: v.column, + queryType: v.queryType, + })), defaultProtectTable, protectTableCache, ) @@ -1761,6 +1988,11 @@ export function createProtectOperators(protectClient: ProtectClient): { ilike: protectIlike, notIlike: protectNotIlike, + // Searchable JSON operators + jsonbPathQueryFirst: protectJsonbPathQueryFirst, + jsonbGet: protectJsonbGet, + jsonbPathExists: protectJsonbPathExists, + // Array operators inArray: protectInArray, notInArray: protectNotInArray, diff --git a/packages/drizzle/src/pg/schema-extraction.ts b/packages/drizzle/src/pg/schema-extraction.ts index a655e07c..e7d7fb63 100644 --- a/packages/drizzle/src/pg/schema-extraction.ts +++ b/packages/drizzle/src/pg/schema-extraction.ts @@ -91,6 +91,15 @@ export function extractProtectSchema>( } } + if (config.searchableJson) { + if (config.dataType !== 'json') { + throw new Error( + `Column "${columnName}" has searchableJson enabled but dataType is "${config.dataType ?? 'string'}". searchableJson requires dataType: 'json'.`, + ) + } + csCol.searchableJson() + } + columns[actualColumnName] = csCol } } diff --git a/packages/drizzle/vitest.config.ts b/packages/drizzle/vitest.config.ts new file mode 100644 index 00000000..e5a6091f --- /dev/null +++ b/packages/drizzle/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config' +import { fileURLToPath } from 'node:url' + +export default defineConfig({ + resolve: { + alias: { + '@cipherstash/drizzle/pg': fileURLToPath(new URL('./src/pg/index.ts', import.meta.url)), + }, + }, +})