@@ -238,15 +238,23 @@ interface MongoPredicateInput {
238238 /** The operands as authored. For operands that are not comparands. */
239239 readonly raw : readonly unknown [ ] ;
240240 /**
241- * A comparand as a literal-substring pattern, built by the DRIVER's own rule
242- * (`filterSubstringPattern`) rather than re-derived here.
241+ * The whole predicate `$contains` (or, `negate`d, `$notContains`) becomes on
242+ * this member's column, built by the DRIVER's own rule
243+ * (`filterContainsPredicate`) rather than re-derived here.
243244 *
244245 * [#7723] Case-EXACT, because that rule is: `filterSubstringPattern` carried
245246 * an `i` flag until #7723 took it off, putting the `$contains` family on the
246247 * #4706 Q2 = A answer across every face of this package. Borrowing the rule
247248 * rather than restating it is what made that one edit reach this face too.
249+ *
250+ * [#20874] Borrowed WHOLE now, not as a pattern. On a declared JSON-stored
251+ * field the rule asks MEMBERSHIP (an `$elemMatch`), not substring, and this
252+ * face used to wrap `filterSubstringPattern` in a `$regex` of its own — so
253+ * `u1` matched a stored `['u10']` here exactly as it did on `find()`. Taking
254+ * the predicate rather than a piece of it is what makes the membership reading
255+ * reach this face with the same edit, the lesson #7723 records above.
248256 */
249- readonly substring : ( value : unknown ) => RegExp ;
257+ readonly containment : ( value : unknown , negate : boolean ) => Record < string , unknown > ;
250258 /**
251259 * [#6520] A comparand as an ASCII-case-insensitive literal-substring pattern —
252260 * `$icontains`' fold, which is NOT {@link substring}'s.
@@ -335,17 +343,19 @@ const CUBE_OPERATOR_TO_MONGO_PREDICATE: Readonly<Record<CubeOperator, MongoPredi
335343 // table while `find()` answered with none of it.
336344 in : ( { comparands } ) => ( { $in : [ ...comparands ] } ) ,
337345 notIn : ( { comparands } ) => ( { $nin : [ ...comparands ] } ) ,
338- // A pattern, not a comparand: `raw`, and the driver's own substring rule.
339- contains : ( { raw, substring } ) => ( { $regex : substring ( raw [ 0 ] ) } ) ,
346+ // A pattern, not a comparand: `raw`, and the driver's own rule — membership on
347+ // a declared JSON-stored field, the substring everywhere else (#20874).
348+ contains : ( { raw, containment } ) => containment ( raw [ 0 ] , false ) ,
340349 // [#6520] The case-INSENSITIVE twin, folding ASCII and nothing else. It takes
341350 // `asciiSubstring`, not `substring`: the neighbour above folds Unicode, so
342351 // reusing it here would answer `CAFÉ` for `café` on this face while the SQL
343352 // family answered no rows — the divergence #6520 closed.
344353 icontains : ( { raw, asciiSubstring } ) => ( { $regex : asciiSubstring ( raw [ 0 ] ) } ) ,
345354 // The fix this issue is about. `{$not: <scalar>}` constrains nothing; the
346355 // negation has to wrap a pattern, which is exactly what the live query path
347- // builds for `$notContains` (`memory-driver.ts` `normalizeFieldOperators`).
348- notContains : ( { raw, substring } ) => ( { $not : { $regex : substring ( raw [ 0 ] ) } } ) ,
356+ // builds for `$notContains` (`memory-driver.ts` `normalizeFieldOperators`) —
357+ // and, since #20874, the SAME test it builds, membership or substring.
358+ notContains : ( { raw, containment } ) => containment ( raw [ 0 ] , true ) ,
349359 // [#13195] A presence flag, not a comparand — and "present" means HAS A
350360 // VALUE (`!= null`), never key presence: #5298 leg 3 / #5369, landed in PR
351361 // #5962, ruled onto this face 2026-08-30. It used to emit `{$exists: <bool>}`
@@ -386,6 +396,52 @@ interface SqlPredicateInput {
386396 * See {@link globSubstringPattern} for why GLOB and not LIKE.
387397 */
388398 readonly globSubstring : ( value : unknown ) => string ;
399+ /**
400+ * [#20874] The stored members a `$contains` comparand names on this column,
401+ * or `null` when the column asks the SUBSTRING question — the DRIVER's answer
402+ * (`filterContainsMembers`), the same one its `$match` twin executes. See
403+ * {@link sqliteMembershipPredicate}.
404+ */
405+ readonly members : ( value : unknown ) => readonly unknown [ ] | null ;
406+ }
407+
408+ /**
409+ * [#20874] The SQLite rendering of a `$contains` MEMBERSHIP test — `driver-sql`'s
410+ * own SQLite construct (`jsonMembershipPredicate`), with literals where that
411+ * one binds.
412+ *
413+ * The echo's job is reproducing execution ({@link globSubstringPattern}). Once
414+ * the `$match` twin asks membership on a declared JSON-stored column, a
415+ * `GLOB '*u1*'` echo over the stored text `["u10"]` would return the row the
416+ * chart excludes — so this renders the question that ran:
417+ *
418+ * - `json_each` over the column, guarded by `json_valid` so a cell holding
419+ * bare text has no members instead of raising — the guard `driver-sql` keeps
420+ * for the same reason;
421+ * - `typeof(os_member.key) = 'integer'` keeps it array-only: an array element
422+ * has an INTEGER key, an object member a TEXT key and a scalar root a NULL
423+ * one — the same "a scalar or object has no member" the `$elemMatch` twin
424+ * answers;
425+ * - each element's JSON TEXT compared with each member's, the `CASE` spelling
426+ * the three JSON literals by type name because SQLite surfaces `true` as the
427+ * INTEGER 1 and `json_quote` would render it `1`.
428+ *
429+ * The members' JSON texts are exactly the texts `driver-sql` binds, because
430+ * both sides read the comparand the same way (`containsMemberCandidates` in
431+ * `memory-driver.ts`, `jsonMembershipCandidates` in `driver-sql`).
432+ */
433+ function sqliteMembershipPredicate (
434+ column : string ,
435+ members : readonly unknown [ ] ,
436+ literal : ( value : unknown ) => string ,
437+ ) : string {
438+ const texts = members . map ( ( member ) => literal ( JSON . stringify ( member ) ) ) . join ( ', ' ) ;
439+ return (
440+ `EXISTS (SELECT 1 FROM json_each(CASE WHEN json_valid(${ column } ) THEN ${ column } ELSE '[]' END) AS os_member ` +
441+ `WHERE typeof(os_member.key) = 'integer' AND CASE os_member.type ` +
442+ `WHEN 'true' THEN 'true' WHEN 'false' THEN 'false' WHEN 'null' THEN 'null' ` +
443+ `ELSE json_quote(os_member.value) END IN (${ texts } ))`
444+ ) ;
389445}
390446
391447type SqlPredicateBuilder = ( input : SqlPredicateInput ) => string ;
@@ -537,10 +593,20 @@ const CUBE_OPERATOR_TO_SQL_PREDICATE: Readonly<Record<CubeOperator, SqlPredicate
537593 comparands . length === 0
538594 ? '1 = 1'
539595 : `(${ column } IS NULL OR ${ column } NOT IN (${ comparands . map ( literal ) . join ( ', ' ) } ))` ,
540- // A pattern, not a comparand: `raw`, and the shared GLOB substring rule.
541- contains : ( { column, raw, globSubstring } ) => `${ column } GLOB ${ globSubstring ( raw [ 0 ] ) } ` ,
542- notContains : ( { column, raw, globSubstring } ) =>
543- `(${ column } IS NULL OR ${ column } NOT GLOB ${ globSubstring ( raw [ 0 ] ) } )` ,
596+ // A pattern, not a comparand: `raw`, and the shared GLOB substring rule — or,
597+ // on a declared JSON-stored column, the membership the `$match` twin runs
598+ // (#20874, {@link sqliteMembershipPredicate}). The negation stays null-safe
599+ // either way: `NOT EXISTS` is never UNKNOWN, but the NULL row still has to be
600+ // admitted by name, since `json_each(NULL)` is not a row set to negate.
601+ contains : ( { column, raw, globSubstring, members, literal } ) => {
602+ const set = members ( raw [ 0 ] ) ;
603+ return set ? sqliteMembershipPredicate ( column , set , literal ) : `${ column } GLOB ${ globSubstring ( raw [ 0 ] ) } ` ;
604+ } ,
605+ notContains : ( { column, raw, globSubstring, members, literal } ) => {
606+ const set = members ( raw [ 0 ] ) ;
607+ const test = set ? `NOT ${ sqliteMembershipPredicate ( column , set , literal ) } ` : `${ column } NOT GLOB ${ globSubstring ( raw [ 0 ] ) } ` ;
608+ return `(${ column } IS NULL OR ${ test } )` ;
609+ } ,
544610 // [#6520] The case-INSENSITIVE twin. SQLite's `lower()` folds ASCII and
545611 // nothing else — measured in #6518: `lower('CAFÉ')` is `'cafÉ'` — so it is
546612 // `$icontains`' fold (#4706 Q1 = A) rather than the Unicode one, and it goes
@@ -1595,10 +1661,11 @@ export class MemoryAnalyticsService implements IAnalyticsService {
15951661 // predicate over `is_active` or `closed_at` selects the same rows
15961662 // `find()` selects instead of none / all of them.
15971663 const storageForm = this . storageFormFor ( cube , filter . member ) ;
1664+ const table = this . extractTableName ( cube . sql ) ;
15981665 const predicate = this . mongoPredicateBuilder ( filter . operator ) ( {
15991666 comparands : filter . values . map ( storageForm ) ,
16001667 raw : filter . values ,
1601- substring : ( value ) => this . driver . filterSubstringPattern ( value ) ,
1668+ containment : ( value , negate ) => this . driver . filterContainsPredicate ( table , fieldPath , value , negate ) ,
16021669 // [#6520] `$icontains`' fold, from the spec's shared definition rather
16031670 // than from the driver's Unicode-folding `filterSubstringPattern`.
16041671 asciiSubstring : ( value ) => new RegExp ( asciiCaseInsensitiveRegexSource ( String ( value ) ) ) ,
@@ -1663,12 +1730,14 @@ export class MemoryAnalyticsService implements IAnalyticsService {
16631730 }
16641731 const fieldPath = this . resolveFieldPath ( cube , entry . member ) ;
16651732 const storageForm = this . storageFormFor ( cube , entry . member ) ;
1733+ const table = this . extractTableName ( cube . sql ) ;
16661734 clauses . push ( this . sqlPredicateBuilder ( entry . operator ) ( {
16671735 column : fieldPath ,
16681736 comparands : entry . values . map ( storageForm ) ,
16691737 raw : entry . values ,
16701738 literal : ( value ) => this . toSqlLiteral ( value ) ,
16711739 globSubstring : ( value ) => this . toSqlLiteral ( globSubstringPattern ( value ) ) ,
1740+ members : ( value ) => this . driver . filterContainsMembers ( table , fieldPath , value ) ,
16721741 } ) ) ;
16731742 }
16741743 return clauses ;
0 commit comments