Skip to content

Commit 07a0e7a

Browse files
committed
fix(driver-memory): $contains on a declared JSON-stored field is membership, on every face
A multi-valued or JSON-stored field now answers `$contains` / `$notContains` by whole-element membership (the SQL family's reading), on the query path's two spellings and on the analytics face's `$match` and its SQLite echo. A scalar column keeps the case-exact substring test. The fork is the field's declared storage shape, read from the declaration `syncSchema` recorded. Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude <noreply@anthropic.com>
1 parent f6ccca4 commit 07a0e7a

2 files changed

Lines changed: 271 additions & 19 deletions

File tree

‎packages/drivers/driver-memory/src/memory-analytics.ts‎

Lines changed: 81 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -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

391447
type 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

Comments
 (0)