diff --git a/.changeset/ste-vec-query-support.md b/.changeset/ste-vec-query-support.md new file mode 100644 index 00000000..f92a0321 --- /dev/null +++ b/.changeset/ste-vec-query-support.md @@ -0,0 +1,11 @@ +--- +"@cipherstash/protect": minor +"@cipherstash/schema": minor +--- + +Add STE Vec query support to encryptQuery API for encrypted JSONB columns. + +- New `searchableJson()` method on column schema enables encrypted JSONB queries +- Automatic query operation inference from plaintext shape (string → steVecSelector, object/array → steVecTerm) +- Supports explicit `queryType: 'steVecSelector'` and `queryType: 'steVecTerm'` options +- JSONB path utilities for building encrypted JSON column queries diff --git a/docs/reference/searchable-encryption-postgres.md b/docs/reference/searchable-encryption-postgres.md index 74ead6a4..19cf0eb8 100644 --- a/docs/reference/searchable-encryption-postgres.md +++ b/docs/reference/searchable-encryption-postgres.md @@ -106,6 +106,65 @@ console.log(term.data) // array of search terms ## Search capabilities +### JSONB queries with searchableJson + +For columns storing JSON data, use `.searchableJson()` to enable encrypted JSONB queries: + +```typescript +const schema = csTable('documents', { + metadata: csColumn('metadata_encrypted') + .searchableJson() // Enables JSONB path and containment queries +}) +``` + +**Query types for JSONB columns:** + +When using `encryptQuery` on a `searchableJson()` column, the query operation is automatically inferred from the plaintext type: + +- **String plaintext** → `steVecSelector` (JSONPath queries like `'$.user.email'`) +- **Object/Array plaintext** → `steVecTerm` (containment queries like `{ role: 'admin' }`) + +```typescript +// JSONPath selector query (string → steVecSelector inferred) +const pathTerm = await protectClient.encryptQuery('$.user.email', { + column: schema.metadata, + table: schema, + // queryType is automatically inferred as 'steVecSelector' +}) + +// Containment query (object → steVecTerm inferred) +const containmentTerm = await protectClient.encryptQuery({ role: 'admin' }, { + column: schema.metadata, + table: schema, + // queryType is automatically inferred as 'steVecTerm' +}) +``` + +**Explicit query type:** + +You can also specify `queryType` explicitly if needed: + +```typescript +const term = await protectClient.encryptQuery('$.user.email', { + column: schema.metadata, + table: schema, + queryType: 'steVecSelector', // Explicit +}) +``` + +> [!NOTE] +> When a column uses `searchableJson()`, string values passed to `encryptQuery` are treated as JSONPath selectors. +> If you need to query for a JSON string value itself, wrap it in an object or array: +> +> ```typescript +> // To find documents where a field contains the string "admin" +> const term = await protectClient.encryptQuery(['admin'], { +> column: schema.metadata, +> table: schema, +> queryType: 'steVecTerm', // Explicit for clarity +> }) +> ``` + ### Exact matching Use `.equality()` when you need to find exact matches: diff --git a/packages/protect/__tests__/encrypt-query-stevec.test.ts b/packages/protect/__tests__/encrypt-query-stevec.test.ts new file mode 100644 index 00000000..cf13b68e --- /dev/null +++ b/packages/protect/__tests__/encrypt-query-stevec.test.ts @@ -0,0 +1,384 @@ +import 'dotenv/config' +import { describe, expect, it, beforeAll } from 'vitest' +import { protect } from '../src' + +type ProtectClient = Awaited> +import { + jsonbSchema, + metadata, + unwrapResult, + expectFailure, +} from './fixtures' + +describe('encryptQuery with steVecSelector', () => { + let protectClient: ProtectClient + + beforeAll(async () => { + protectClient = await protect({ schemas: [jsonbSchema, metadata] }) + }) + + it('encrypts a JSONPath selector', async () => { + const result = await protectClient.encryptQuery('$.user.email', { + column: jsonbSchema.metadata, + table: jsonbSchema, + queryType: 'steVecSelector', + }) + + const data = unwrapResult(result) + expect(data).toBeDefined() + expect(data).toMatchObject({ + i: { t: 'documents', c: 'metadata' }, + }) + }, 30000) + + it('encrypts nested path selector', async () => { + const result = await protectClient.encryptQuery('$.user.profile.settings', { + column: jsonbSchema.metadata, + table: jsonbSchema, + queryType: 'steVecSelector', + }) + + const data = unwrapResult(result) + expect(data).toBeDefined() + expect(data).toMatchObject({ + i: { t: 'documents', c: 'metadata' }, + }) + }, 30000) + + it('fails for non-string plaintext with steVecSelector (object)', async () => { + const result = await protectClient.encryptQuery({ role: 'admin' }, { + column: jsonbSchema.metadata, + table: jsonbSchema, + queryType: 'steVecSelector', + }) + + expectFailure(result) + }, 30000) +}) + +describe('encryptQuery with steVecTerm', () => { + let protectClient: ProtectClient + + beforeAll(async () => { + protectClient = await protect({ schemas: [jsonbSchema, metadata] }) + }) + + it('encrypts an object for containment query', async () => { + const result = await protectClient.encryptQuery({ role: 'admin' }, { + column: jsonbSchema.metadata, + table: jsonbSchema, + queryType: 'steVecTerm', + }) + + const data = unwrapResult(result) + expect(data).toBeDefined() + expect(data).toMatchObject({ + i: { t: 'documents', c: 'metadata' }, + }) + }, 30000) + + it('encrypts nested object for containment', async () => { + const result = await protectClient.encryptQuery( + { user: { profile: { role: 'admin' } } }, + { + column: jsonbSchema.metadata, + table: jsonbSchema, + queryType: 'steVecTerm', + } + ) + + const data = unwrapResult(result) + expect(data).toBeDefined() + expect(data).toMatchObject({ + i: { t: 'documents', c: 'metadata' }, + }) + }, 30000) + + it('encrypts array for containment query', async () => { + const result = await protectClient.encryptQuery([1, 2, 3], { + column: jsonbSchema.metadata, + table: jsonbSchema, + queryType: 'steVecTerm', + }) + + const data = unwrapResult(result) + expect(data).toBeDefined() + expect(data).toMatchObject({ + i: { t: 'documents', c: 'metadata' }, + }) + }, 30000) + + it('rejects string plaintext with steVecTerm', async () => { + // steVecTerm requires object or array, not string + // For path queries like '$.field', use steVecSelector instead + const result = await protectClient.encryptQuery('search text', { + column: jsonbSchema.metadata, + table: jsonbSchema, + queryType: 'steVecTerm', + }) + + expectFailure(result, /expected JSON object or array/) + }, 30000) +}) + +describe('encryptQuery STE Vec validation', () => { + let protectClient: ProtectClient + + beforeAll(async () => { + protectClient = await protect({ schemas: [jsonbSchema, metadata] }) + }) + + it('throws when steVecSelector used on non-ste_vec column', async () => { + const result = await protectClient.encryptQuery('$.user.email', { + column: metadata.raw, // raw column has no ste_vec index + table: metadata, + queryType: 'steVecSelector', + }) + + expectFailure(result) + }, 30000) + + it('throws when steVecTerm used on non-ste_vec column', async () => { + const result = await protectClient.encryptQuery({ field: 'value' }, { + column: metadata.raw, // raw column has no ste_vec index + table: metadata, + queryType: 'steVecTerm', + }) + + expectFailure(result) + }, 30000) +}) + +describe('encryptQuery batch with STE Vec', () => { + let protectClient: ProtectClient + + beforeAll(async () => { + protectClient = await protect({ schemas: [jsonbSchema, metadata] }) + }) + + it('handles mixed query types in batch (steVecSelector + steVecTerm)', async () => { + const result = await protectClient.encryptQuery([ + { + value: '$.user.email', + column: jsonbSchema.metadata, + table: jsonbSchema, + queryType: 'steVecSelector', + }, + { + value: { role: 'admin' }, + column: jsonbSchema.metadata, + table: jsonbSchema, + queryType: 'steVecTerm', + }, + ]) + + const data = unwrapResult(result) + + expect(data).toHaveLength(2) + expect(data[0]).toMatchObject({ i: { t: 'documents', c: 'metadata' } }) + expect(data[1]).toMatchObject({ i: { t: 'documents', c: 'metadata' } }) + }, 30000) + + it('handles multiple steVecSelector queries in batch', async () => { + const result = await protectClient.encryptQuery([ + { + value: '$.user.email', + column: jsonbSchema.metadata, + table: jsonbSchema, + queryType: 'steVecSelector', + }, + { + value: '$.settings.theme', + column: jsonbSchema.metadata, + table: jsonbSchema, + queryType: 'steVecSelector', + }, + ]) + + const data = unwrapResult(result) + + expect(data).toHaveLength(2) + expect(data[0]).toMatchObject({ i: { t: 'documents', c: 'metadata' } }) + expect(data[1]).toMatchObject({ i: { t: 'documents', c: 'metadata' } }) + }, 30000) + + it('handles null values with steVecSelector in batch', async () => { + const result = await protectClient.encryptQuery([ + { + value: null, + column: jsonbSchema.metadata, + table: jsonbSchema, + queryType: 'steVecSelector', + }, + { + value: '$.user.email', + column: jsonbSchema.metadata, + table: jsonbSchema, + queryType: 'steVecSelector', + }, + ]) + + const data = unwrapResult(result) + + expect(data).toHaveLength(2) + expect(data[0]).toBeNull() + expect(data[1]).not.toBeNull() + expect(data[1]).toMatchObject({ i: { t: 'documents', c: 'metadata' } }) + }, 30000) + + it('handles null values with steVecTerm in batch', async () => { + const result = await protectClient.encryptQuery([ + { + value: null, + column: jsonbSchema.metadata, + table: jsonbSchema, + queryType: 'steVecTerm', + }, + { + value: { role: 'admin' }, + column: jsonbSchema.metadata, + table: jsonbSchema, + queryType: 'steVecTerm', + }, + ]) + + const data = unwrapResult(result) + + expect(data).toHaveLength(2) + expect(data[0]).toBeNull() + expect(data[1]).not.toBeNull() + expect(data[1]).toMatchObject({ i: { t: 'documents', c: 'metadata' } }) + }, 30000) +}) + +describe('encryptQuery with queryType inference', () => { + let protectClient: ProtectClient + + beforeAll(async () => { + protectClient = await protect({ schemas: [jsonbSchema] }) + }) + + it('infers steVecSelector for string plaintext without queryType', async () => { + const result = await protectClient.encryptQuery('$.user.email', { + column: jsonbSchema.metadata, + table: jsonbSchema, + // No queryType - should infer steVecSelector from string + }) + + const data = unwrapResult(result) + expect(data).toBeDefined() + expect(data).toMatchObject({ + i: { t: 'documents', c: 'metadata' }, + }) + }, 30000) + + it('infers steVecTerm for object plaintext without queryType', async () => { + const result = await protectClient.encryptQuery({ role: 'admin' }, { + column: jsonbSchema.metadata, + table: jsonbSchema, + // No queryType - should infer steVecTerm from object + }) + + const data = unwrapResult(result) + expect(data).toBeDefined() + expect(data).toMatchObject({ + i: { t: 'documents', c: 'metadata' }, + }) + }, 30000) + + it('infers steVecTerm for array plaintext without queryType', async () => { + const result = await protectClient.encryptQuery(['admin', 'user'], { + column: jsonbSchema.metadata, + table: jsonbSchema, + // No queryType - should infer steVecTerm from array + }) + + const data = unwrapResult(result) + expect(data).toBeDefined() + expect(data).toMatchObject({ + i: { t: 'documents', c: 'metadata' }, + }) + }, 30000) + + it('infers steVecTerm for number plaintext but FFI requires wrapping', async () => { + // Numbers infer steVecTerm but FFI requires wrapping in object/array + const result = await protectClient.encryptQuery(42, { + column: jsonbSchema.metadata, + table: jsonbSchema, + // No queryType - infers steVecTerm, FFI rejects with helpful message + }) + + expectFailure(result, /Wrap the number in a JSON object/) + }, 30000) + + it('infers steVecTerm for boolean plaintext but FFI requires wrapping', async () => { + // Booleans infer steVecTerm but FFI requires wrapping in object/array + const result = await protectClient.encryptQuery(true, { + column: jsonbSchema.metadata, + table: jsonbSchema, + // No queryType - infers steVecTerm, FFI rejects with helpful message + }) + + expectFailure(result, /Wrap the boolean in a JSON object/) + }, 30000) + + it('returns null for null plaintext (no inference needed)', async () => { + const result = await protectClient.encryptQuery(null, { + column: jsonbSchema.metadata, + table: jsonbSchema, + // No queryType and null plaintext - should return null + }) + + // Null returns null, doesn't throw + const data = unwrapResult(result) + expect(data).toBeNull() + }, 30000) + + it('uses explicit queryType over plaintext inference', async () => { + // String plaintext would normally infer steVecSelector, but explicit steVecTerm should be used + // Note: steVecTerm with string fails FFI validation, so we test the opposite direction + // Using a number (which would infer steVecTerm) with explicit steVecSelector would also fail + // So we verify with array + steVecTerm (already tested) and trust unit test coverage for precedence + const result = await protectClient.encryptQuery([42], { + column: jsonbSchema.metadata, + table: jsonbSchema, + queryType: 'steVecTerm', // Explicit - matches inference but proves explicit path works + }) + + const data = unwrapResult(result) + expect(data).toBeDefined() + expect(data).toMatchObject({ + i: { t: 'documents', c: 'metadata' }, + }) + }, 30000) +}) + +describe('encryptQuery batch with queryType inference', () => { + let protectClient: ProtectClient + + beforeAll(async () => { + protectClient = await protect({ schemas: [jsonbSchema] }) + }) + + it('infers queryOp for each term independently in batch', async () => { + const results = await protectClient.encryptQuery([ + { + value: '$.user.email', // string → steVecSelector + column: jsonbSchema.metadata, + table: jsonbSchema, + // No queryType + }, + { + value: { role: 'admin' }, // object → steVecTerm + column: jsonbSchema.metadata, + table: jsonbSchema, + // No queryType + }, + ]) + + const data = unwrapResult(results) + expect(data).toHaveLength(2) + expect(data[0]).toBeDefined() + expect(data[1]).toBeDefined() + }, 30000) +}) diff --git a/packages/protect/__tests__/fixtures/index.ts b/packages/protect/__tests__/fixtures/index.ts index f681037c..4ca8ee24 100644 --- a/packages/protect/__tests__/fixtures/index.ts +++ b/packages/protect/__tests__/fixtures/index.ts @@ -33,6 +33,24 @@ export const metadata = csTable('metadata', { raw: csColumn('raw'), }) +/** + * Documents table with searchable JSON column (for STE Vec queries) + */ +export const jsonbSchema = csTable('documents', { + id: csColumn('id'), + metadata: csColumn('metadata').searchableJson(), +}) + +/** + * Schema fixture with mixed column types including JSON. + */ +export const mixedSchema = csTable('records', { + id: csColumn('id'), + email: csColumn('email').equality(), + name: csColumn('name').freeTextSearch(), + metadata: csColumn('metadata').searchableJson(), +}) + // ============ Mock Factories ============ /** diff --git a/packages/protect/__tests__/infer-index-type.test.ts b/packages/protect/__tests__/infer-index-type.test.ts index efb07c94..a321752a 100644 --- a/packages/protect/__tests__/infer-index-type.test.ts +++ b/packages/protect/__tests__/infer-index-type.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest' import { csColumn, csTable } from '@cipherstash/schema' -import { inferIndexType, validateIndexType } from '../src/ffi/helpers/infer-index-type' +import { inferIndexType, validateIndexType } from '../src/index' +import { + inferQueryOpFromPlaintext, + resolveIndexType, +} from '../src/ffi/helpers/infer-index-type' describe('infer-index-type helpers', () => { const users = csTable('users', { @@ -36,6 +40,11 @@ describe('infer-index-type helpers', () => { const noIndex = csTable('t', { col: csColumn('col') }) expect(() => inferIndexType(noIndex.col)).toThrow('no indexes configured') }) + + it('returns ste_vec for searchableJson-only column', () => { + const schema = csTable('t', { col: csColumn('col').searchableJson() }) + expect(inferIndexType(schema.col)).toBe('ste_vec') + }) }) describe('validateIndexType', () => { @@ -46,5 +55,82 @@ describe('infer-index-type helpers', () => { it('throws for unconfigured index type', () => { expect(() => validateIndexType(users.email, 'match')).toThrow('not configured') }) + + it('accepts ste_vec when configured', () => { + const schema = csTable('t', { col: csColumn('col').searchableJson() }) + expect(() => validateIndexType(schema.col, 'ste_vec')).not.toThrow() + }) + + it('rejects ste_vec when not configured', () => { + const schema = csTable('t', { col: csColumn('col').equality() }) + expect(() => validateIndexType(schema.col, 'ste_vec')).toThrow('not configured') + }) + }) + + describe('inferQueryOpFromPlaintext', () => { + it('returns ste_vec_selector for string plaintext', () => { + expect(inferQueryOpFromPlaintext('$.user.email')).toBe('ste_vec_selector') + }) + + it('returns ste_vec_term for object plaintext', () => { + expect(inferQueryOpFromPlaintext({ role: 'admin' })).toBe('ste_vec_term') + }) + + it('returns ste_vec_term for array plaintext', () => { + expect(inferQueryOpFromPlaintext(['admin', 'user'])).toBe('ste_vec_term') + }) + + it('returns ste_vec_term for number plaintext', () => { + expect(inferQueryOpFromPlaintext(42)).toBe('ste_vec_term') + }) + + it('returns ste_vec_term for boolean plaintext', () => { + expect(inferQueryOpFromPlaintext(true)).toBe('ste_vec_term') + }) + }) + + describe('resolveIndexType with plaintext inference', () => { + it('infers ste_vec_selector for string on searchableJson column', () => { + const schema = csTable('t', { col: csColumn('col').searchableJson() }) + const result = resolveIndexType(schema.col, undefined, '$.user.email') + expect(result).toEqual({ indexType: 'ste_vec', queryOp: 'ste_vec_selector' }) + }) + + it('infers ste_vec_term for object on searchableJson column', () => { + const schema = csTable('t', { col: csColumn('col').searchableJson() }) + const result = resolveIndexType(schema.col, undefined, { role: 'admin' }) + expect(result).toEqual({ indexType: 'ste_vec', queryOp: 'ste_vec_term' }) + }) + + it('infers ste_vec_term for array on searchableJson column', () => { + const schema = csTable('t', { col: csColumn('col').searchableJson() }) + const result = resolveIndexType(schema.col, undefined, ['admin']) + expect(result).toEqual({ indexType: 'ste_vec', queryOp: 'ste_vec_term' }) + }) + + it('returns indexType only when ste_vec inferred but plaintext is null', () => { + const schema = csTable('t', { col: csColumn('col').searchableJson() }) + const result = resolveIndexType(schema.col, undefined, null) + expect(result).toEqual({ indexType: 'ste_vec' }) + }) + + it('returns indexType only when ste_vec inferred but plaintext is undefined', () => { + const schema = csTable('t', { col: csColumn('col').searchableJson() }) + const result = resolveIndexType(schema.col) + expect(result).toEqual({ indexType: 'ste_vec' }) + }) + + it('uses explicit queryType over plaintext inference', () => { + const schema = csTable('t', { col: csColumn('col').searchableJson() }) + // Even with object plaintext, explicit steVecSelector should be used + const result = resolveIndexType(schema.col, 'steVecSelector', { role: 'admin' }) + expect(result).toEqual({ indexType: 'ste_vec', queryOp: 'ste_vec_selector' }) + }) + + it('does not require plaintext for non-ste_vec columns', () => { + const schema = csTable('t', { col: csColumn('col').equality() }) + const result = resolveIndexType(schema.col) + expect(result).toEqual({ indexType: 'unique', queryOp: undefined }) + }) }) }) diff --git a/packages/protect/src/ffi/helpers/infer-index-type.ts b/packages/protect/src/ffi/helpers/infer-index-type.ts index fcda480b..ac721906 100644 --- a/packages/protect/src/ffi/helpers/infer-index-type.ts +++ b/packages/protect/src/ffi/helpers/infer-index-type.ts @@ -1,10 +1,11 @@ import type { FfiIndexTypeName, QueryTypeName } from '../../types' -import { queryTypeToFfi } from '../../types' +import { queryTypeToFfi, queryTypeToQueryOp } from '../../types' import type { ProtectColumn } from '@cipherstash/schema' +import type { QueryOpName, JsPlaintext } from '@cipherstash/protect-ffi' /** * Infer the primary index type from a column's configured indexes. - * Priority: unique > match > ore (for scalar queries) + * Priority: unique > match > ore > ste_vec (for scalar queries) */ export function inferIndexType(column: ProtectColumn): FfiIndexTypeName { const config = column.build() @@ -14,15 +15,39 @@ export function inferIndexType(column: ProtectColumn): FfiIndexTypeName { throw new Error(`Column "${column.getName()}" has no indexes configured`) } + // Priority order for inference if (indexes.unique) return 'unique' if (indexes.match) return 'match' if (indexes.ore) return 'ore' + if (indexes.ste_vec) return 'ste_vec' throw new Error( - `Column "${column.getName()}" has no suitable index for scalar queries` + `Column "${column.getName()}" has no suitable index for queries` ) } +/** + * Infer the FFI query operation from plaintext type for STE Vec queries. + * - String → ste_vec_selector (JSONPath queries like '$.user.email') + * - Object/Array/Number/Boolean → ste_vec_term (containment queries) + */ +export function inferQueryOpFromPlaintext(plaintext: JsPlaintext): QueryOpName { + if (typeof plaintext === 'string') { + return 'ste_vec_selector' + } + // Objects, arrays, numbers, booleans are all valid JSONB containment values + if ( + typeof plaintext === 'object' || + typeof plaintext === 'number' || + typeof plaintext === 'boolean' || + typeof plaintext === 'bigint' + ) { + return 'ste_vec_term' + } + // This should never happen with valid JsPlaintext, but keep for safety + return 'ste_vec_term' +} + /** * Validate that the specified index type is configured on the column */ @@ -34,6 +59,7 @@ export function validateIndexType(column: ProtectColumn, indexType: FfiIndexType unique: !!indexes.unique, match: !!indexes.match, ore: !!indexes.ore, + ste_vec: !!indexes.ste_vec, } if (!indexMap[indexType]) { @@ -44,24 +70,37 @@ export function validateIndexType(column: ProtectColumn, indexType: FfiIndexType } /** - * Resolve the index type for a query, either from explicit queryType or by inference. + * Resolve the index type and query operation for a query. * Validates the index type is configured on the column when queryType is explicit. + * For ste_vec columns without explicit queryType, infers queryOp from plaintext shape. * * @param column - The column to resolve the index type for * @param queryType - Optional explicit query type (if provided, validates against column config) - * @returns The FFI index type name to use for the query + * @param plaintext - Optional plaintext value for queryOp inference on ste_vec columns + * @returns The FFI index type name and optional query operation name + * @throws Error if ste_vec is inferred but queryOp cannot be determined */ export function resolveIndexType( column: ProtectColumn, - queryType?: QueryTypeName -): FfiIndexTypeName { - const indexType = queryType - ? queryTypeToFfi[queryType] - : inferIndexType(column) + queryType?: QueryTypeName, + plaintext?: JsPlaintext | null +): { indexType: FfiIndexTypeName; queryOp?: QueryOpName } { + const indexType = queryType ? queryTypeToFfi[queryType] : inferIndexType(column) if (queryType) { validateIndexType(column, indexType) + return { indexType, queryOp: queryTypeToQueryOp[queryType] } + } + + // ste_vec inferred without explicit queryType → must infer from plaintext + if (indexType === 'ste_vec') { + if (plaintext === undefined || plaintext === null) { + // Null plaintext handled by caller (returns null early) - no inference needed + return { indexType } + } + return { indexType, queryOp: inferQueryOpFromPlaintext(plaintext) } } - return indexType + // Non-ste_vec → no queryOp needed + return { indexType } } diff --git a/packages/protect/src/ffi/index.ts b/packages/protect/src/ffi/index.ts index dc92a4c9..50918518 100644 --- a/packages/protect/src/ffi/index.ts +++ b/packages/protect/src/ffi/index.ts @@ -229,6 +229,11 @@ export class ProtectClient { * ``` * * @see {@link EncryptQueryOperation} + * + * **JSONB columns (searchableJson):** + * When `queryType` is omitted on a `searchableJson()` column, the query operation is inferred: + * - String plaintext → `steVecSelector` (JSONPath queries like `'$.user.email'`) + * - Object/Array plaintext → `steVecTerm` (containment queries like `{ role: 'admin' }`) */ encryptQuery( plaintext: JsPlaintext | null, diff --git a/packages/protect/src/ffi/operations/batch-encrypt-query.ts b/packages/protect/src/ffi/operations/batch-encrypt-query.ts index ba95fb3f..f522f6ce 100644 --- a/packages/protect/src/ffi/operations/batch-encrypt-query.ts +++ b/packages/protect/src/ffi/operations/batch-encrypt-query.ts @@ -16,8 +16,8 @@ import { assertValidNumericValue, assertValueIndexCompatibility } from '../helpe import { encryptedToCompositeLiteral, encryptedToEscapedCompositeLiteral } from '../../helpers' /** - * Separates null values from non-null terms in the input array. - * Returns a set of indices where values are null and an array of non-null terms with their original indices. + * Separates null/undefined values from non-null terms in the input array. + * Returns a set of indices where values are null/undefined and an array of non-null terms with their original indices. */ function filterNullTerms( terms: readonly ScalarQueryTerm[], @@ -29,7 +29,7 @@ function filterNullTerms( const nonNullTerms: { term: ScalarQueryTerm; originalIndex: number }[] = [] terms.forEach((term, index) => { - if (term.value === null) { + if (term.value === null || term.value === undefined) { nullIndices.add(index) } else { nonNullTerms.push({ term, originalIndex: index }) @@ -50,7 +50,11 @@ function buildQueryPayload( ): QueryPayload { assertValidNumericValue(term.value) - const indexType = resolveIndexType(term.column, term.queryType) + const { indexType, queryOp } = resolveIndexType( + term.column, + term.queryType, + term.value + ) // Validate value/index compatibility assertValueIndexCompatibility( @@ -64,6 +68,7 @@ function buildQueryPayload( column: term.column.getName(), table: term.table.tableName, indexType, + queryOp, } if (lockContext != null) { diff --git a/packages/protect/src/ffi/operations/encrypt-query.ts b/packages/protect/src/ffi/operations/encrypt-query.ts index 9b27a360..4d8aeb55 100644 --- a/packages/protect/src/ffi/operations/encrypt-query.ts +++ b/packages/protect/src/ffi/operations/encrypt-query.ts @@ -35,7 +35,7 @@ export class EncryptQueryOperation extends ProtectOperation { queryType: this.opts.queryType, }) - if (this.plaintext === null) { + if (this.plaintext === null || this.plaintext === undefined) { return { data: null } } @@ -50,7 +50,11 @@ export class EncryptQueryOperation extends ProtectOperation { const { metadata } = this.getAuditData() - const indexType = resolveIndexType(this.opts.column, this.opts.queryType) + const { indexType, queryOp } = resolveIndexType( + this.opts.column, + this.opts.queryType, + this.plaintext + ) // Validate value/index compatibility assertValueIndexCompatibility( @@ -64,6 +68,7 @@ export class EncryptQueryOperation extends ProtectOperation { column: this.opts.column.getName(), table: this.opts.table.tableName, indexType, + queryOp, unverifiedContext: metadata, }) }, @@ -95,7 +100,7 @@ export class EncryptQueryOperationWithLockContext extends ProtectOperation> { - if (this.plaintext === null) { + if (this.plaintext === null || this.plaintext === undefined) { return { data: null } } @@ -117,7 +122,11 @@ export class EncryptQueryOperationWithLockContext extends ProtectOperation = DecryptionSuccess | DecryptionError * - `'equality'`: For exact match queries. {@link https://cipherstash.com/docs/platform/searchable-encryption/supported-queries/exact | Exact Queries} * - `'freeTextSearch'`: For text search queries. {@link https://cipherstash.com/docs/platform/searchable-encryption/supported-queries/match | Match Queries} * - `'orderAndRange'`: For comparison and range queries. {@link https://cipherstash.com/docs/platform/searchable-encryption/supported-queries/range | Range Queries} + * - `'steVecSelector'`: For STE vector selector queries. + * - `'steVecTerm'`: For STE vector term queries. */ -export type QueryTypeName = 'orderAndRange' | 'freeTextSearch' | 'equality' +export type QueryTypeName = 'orderAndRange' | 'freeTextSearch' | 'equality' | 'steVecSelector' | 'steVecTerm' /** * Internal FFI index type names. * @internal */ -export type FfiIndexTypeName = 'ore' | 'match' | 'unique' +export type FfiIndexTypeName = 'ore' | 'match' | 'unique' | 'ste_vec' /** * Query type constants for use with encryptQuery(). @@ -151,6 +154,8 @@ export const queryTypes = { orderAndRange: 'orderAndRange', freeTextSearch: 'freeTextSearch', equality: 'equality', + steVecSelector: 'steVecSelector', + steVecTerm: 'steVecTerm', } as const satisfies Record /** @@ -161,6 +166,18 @@ export const queryTypeToFfi: Record = { orderAndRange: 'ore', freeTextSearch: 'match', equality: 'unique', + steVecSelector: 'ste_vec', + steVecTerm: 'ste_vec', +} + +/** + * Maps query type names to FFI query operation names. + * Returns undefined for query types that don't need a specific queryOp. + * @internal + */ +export const queryTypeToQueryOp: Partial> = { + steVecSelector: 'ste_vec_selector', + steVecTerm: 'ste_vec_term', } /**