@@ -50,13 +50,21 @@ function setup(
5050 } ) ,
5151) {
5252 const encrypt = vi . fn ( ( ...args : unknown [ ] ) => chainable ( encryptImpl ( ...args ) ) )
53+ // `encryptQuery` backs JSON containment: it returns a narrowed `query_jsonb`
54+ // term (no ciphertext). The double returns a recognisable ste_vec-shaped term
55+ // so a rendered `@>` query can be asserted.
56+ const encryptQuery = vi . fn ( ( ) =>
57+ chainable (
58+ Promise . resolve ( { data : { sv : [ { s : 'sel' , hm : 'h' } ] } } ) as never ,
59+ ) ,
60+ )
5361 // The factory's `client` parameter is the structural `{ encrypt }` surface,
5462 // so this hand-rolled double satisfies it with no cast (M1).
55- const client = { encrypt }
63+ const client = { encrypt, encryptQuery }
5664 const ops = createEncryptionOperatorsV3 ( client , { lockContext, audit } )
5765 const dialect = new PgDialect ( )
5866 const render = ( s : unknown ) => dialect . sqlToQuery ( s as SQL )
59- return { ops, encrypt, render }
67+ return { ops, encrypt, encryptQuery , render }
6068}
6169
6270type BulkPayload = Array < { id ?: string ; plaintext : unknown } >
@@ -107,13 +115,23 @@ const orderDomains = matrixEntries.filter(
107115 ( [ , spec ] ) => spec . indexes . ore || spec . indexes . ope ,
108116)
109117const matchDomains = matrixEntries . filter ( ( [ , spec ] ) => spec . indexes . match )
110- const storageDomains = matrixEntries . filter (
118+ // Domains with NO scalar query index (unique/ore/ope/match), so `eq`/`ne`/
119+ // ordering all throw. This is the truly storage-only scalar domains PLUS
120+ // `eql_v3_json` — json is a QUERYABLE domain (containment), it just answers no
121+ // scalar op, so it lands here for the eq/order rejection checks.
122+ const nonScalarQueryDomains = matrixEntries . filter (
111123 ( [ , spec ] ) =>
112124 ! spec . indexes . unique &&
113125 ! spec . indexes . ore &&
114126 ! spec . indexes . ope &&
115127 ! spec . indexes . match ,
116128)
129+ // Of those, the ones that also reject `contains` — i.e. everything except json.
130+ // json answers `contains` via `@>`, so it is excluded here and gets its own
131+ // positive assertion in the JSON containment describe above.
132+ const noContainmentDomains = nonScalarQueryDomains . filter (
133+ ( [ , spec ] ) => ! spec . indexes . ste_vec ,
134+ )
117135
118136const users = pgTable ( 'users' , {
119137 id : integer ( ) . primaryKey ( ) ,
@@ -393,22 +411,91 @@ describe('createEncryptionOperatorsV3 - free-text match', () => {
393411 } )
394412} )
395413
396- describe ( 'createEncryptionOperatorsV3 - storage-only domains' , ( ) => {
397- it . each ( storageDomains ) ( '%s eq throws' , async ( eqlType , spec ) => {
414+ describe ( 'createEncryptionOperatorsV3 - JSON containment' , ( ) => {
415+ const JSON_TYPE = 'public.eql_v3_json'
416+
417+ // json has no `eql_v3.contains` overload: containment is the `@>` operator,
418+ // and the needle is a narrowed `query_jsonb` term from `encryptQuery` (no
419+ // ciphertext), cast to `eql_v3.query_jsonb`.
420+ it ( 'contains emits the @> operator with a query_jsonb needle' , async ( ) => {
421+ const { ops, encryptQuery, render } = setup ( )
422+ const q = render (
423+ await ops . contains ( matrixColumn ( JSON_TYPE ) , { roles : [ 'eng' ] } ) ,
424+ )
425+
426+ expect ( q . sql . toLowerCase ( ) ) . toContain (
427+ `"matrix_users"."${ slug ( JSON_TYPE ) } " operator(public.@>) $1::eql_v3.query_jsonb` ,
428+ )
429+ expect ( q . sql ) . not . toContain ( 'eql_v3.contains' )
430+ // The needle is the encryptQuery result, not a full storage envelope.
431+ expect ( q . params ) . toEqual ( [ JSON . stringify ( { sv : [ { s : 'sel' , hm : 'h' } ] } ) ] )
432+ expect ( encryptQuery ) . toHaveBeenCalledTimes ( 1 )
433+ expect ( encryptQuery . mock . calls [ 0 ] ?. [ 1 ] ) . toMatchObject ( {
434+ queryType : 'searchableJson' ,
435+ } )
436+ } )
437+
438+ it ( 'contains surfaces an encryptQuery failure as an EncryptionOperatorError' , async ( ) => {
439+ const { ops, encryptQuery } = setup ( )
440+ encryptQuery . mockReturnValueOnce (
441+ chainable (
442+ Promise . resolve ( { failure : { message : 'boom' } } ) as never ,
443+ ) as never ,
444+ )
445+ await expect (
446+ ops . contains ( matrixColumn ( JSON_TYPE ) , { roles : [ 'eng' ] } ) ,
447+ ) . rejects . toBeInstanceOf ( EncryptionOperatorError )
448+ } )
449+
450+ it ( 'contains rejects a null operand before calling encryptQuery' , async ( ) => {
451+ const { ops, encryptQuery } = setup ( )
452+ await expect (
453+ ops . contains ( matrixColumn ( JSON_TYPE ) , null ) ,
454+ ) . rejects . toBeInstanceOf ( EncryptionOperatorError )
455+ expect ( encryptQuery ) . not . toHaveBeenCalled ( )
456+ } )
457+
458+ // `doc @> '{}'` holds for every row (jsonb `{} ⊆ anything`); an empty-object
459+ // needle would silently return the whole table, so it is refused before
460+ // encrypting — the same whole-table guard the bloom path applies.
461+ it ( 'contains rejects an empty-object needle before calling encryptQuery' , async ( ) => {
462+ const { ops, encryptQuery } = setup ( )
463+ await expect ( ops . contains ( matrixColumn ( JSON_TYPE ) , { } ) ) . rejects . toThrow (
464+ / m a t c h e s e v e r y r o w / ,
465+ )
466+ expect ( encryptQuery ) . not . toHaveBeenCalled ( )
467+ } )
468+
469+ // `encryptQuery` is OPTIONAL on the operand client (see OperandEncryptionClient):
470+ // a `{ encrypt }`-only client is structurally valid but cannot build the
471+ // `query_jsonb` needle JSON containment needs. Without this guard the call would
472+ // be a raw `TypeError: encryptQuery is not a function`; the guard turns it into a
473+ // typed EncryptionOperatorError. Exercises the branch the real double can't.
474+ it ( 'contains throws a typed error when the client lacks encryptQuery' , async ( ) => {
475+ const encrypt = vi . fn ( ( ) => chainable ( Promise . resolve ( { data : TERM } ) ) )
476+ const ops = createEncryptionOperatorsV3 ( { encrypt } )
477+ await expect (
478+ ops . contains ( matrixColumn ( JSON_TYPE ) , { roles : [ 'eng' ] } ) ,
479+ ) . rejects . toBeInstanceOf ( EncryptionOperatorError )
480+ } )
481+ } )
482+
483+ describe ( 'createEncryptionOperatorsV3 - domains with no scalar query' , ( ) => {
484+ it . each ( nonScalarQueryDomains ) ( '%s eq throws' , async ( eqlType , spec ) => {
398485 const { ops } = setup ( )
399486 await expect (
400487 ops . eq ( matrixColumn ( eqlType ) , sampleFor ( spec ) ) ,
401488 ) . rejects . toBeInstanceOf ( EncryptionOperatorError )
402489 } )
403490
404- it . each ( storageDomains ) ( '%s contains throws' , async ( eqlType , spec ) => {
491+ it . each ( noContainmentDomains ) ( '%s contains throws' , async ( eqlType , spec ) => {
405492 const { ops } = setup ( )
406493 await expect (
407494 ops . contains ( matrixColumn ( eqlType ) , sampleFor ( spec ) ) ,
408495 ) . rejects . toBeInstanceOf ( EncryptionOperatorError )
409496 } )
410497
411- it . each ( storageDomains ) ( '%s asc throws synchronously' , ( eqlType ) => {
498+ it . each ( nonScalarQueryDomains ) ( '%s asc throws synchronously' , ( eqlType ) => {
412499 const { ops } = setup ( )
413500 expect ( ( ) => ops . asc ( matrixColumn ( eqlType ) ) ) . toThrow (
414501 EncryptionOperatorError ,
0 commit comments