Skip to content

Commit e18fea6

Browse files
fix(objectql,driver-mongodb,formula): having compiles the whole-day bound it is handed; $contains asks membership on a JSON-stored field (#20822 group 3b) (#21196)
Part of #20822 Clause-②: no Group 3b of #20822 (#5930 step 4, the engine lane), under claim 5930666311. It carries F8, the three #20987 engine faces from triage pointer 5922592744, the stale F1 pointers, and the docs sentence. F7 (`lteBound`) is untouched: its card #21109 was ruled A at 14:16Z and remains open, and the deletion waits until that card's PR is on `main`. ## What changes | face | change | commit | |:--|:--|:--| | F8, objectql `having` and `aggregations[i].filter` walker (`having-filter.ts`) | the whole-day copy is deleted: `wholeDayUpperBound`, its two arms, and the `nextUtcCalendarDay` / `UNBOUNDED_ABOVE` imports | 4256b7c, e7006d8 | | `driver-mongodb` `translateFieldOperators` | `$contains` / `$notContains` ask membership on a declared JSON-stored field (array-only `$elemMatch` over `jsonMembershipCandidates` from `@objectstack/core`) | 098beef, 9080dd0, 565a47a | | `formula` `matchesFilterCondition` | `$contains` / `$notContains` ask membership by the column's declaration when `options.fields` names it, else by the stored value (seat answer Q2 = C, 5926601042) | a525d25, ff3127e | | objectql `engine.ts` delete-probe docblock | comment only: membership on every typed backend, and the superset reading only without a declaration | 1faf2e9 | | spec `filter.zod.ts` | docblock only: the three pointers to the deleted `SqlDriver.calendarDay*Rewrite` name `lowerFilterCondition`; the `$contains` implementation-status list gains `driver-mongodb` and `formula` | 74d434e | | `read-scope-shared-lowering-seam.test.ts` | case titles and header: a guard without types no longer hands the RLS `using` bound as written | 74d434e | | `query-syntax.mdx` | the direct-call sentence names `InMemoryDriver`, `MongoDBDriver` and `applyInMemoryAggregation` beside the SqlDriver family, and the aggregate positions the engine lowers | 74d434e | ## F8: measured first (one grep, one probe) - **Grep.** `applyHaving` and `matchesHaving` are not exported from either objectql entry. `matchesAggregationFilter` is reached through `applyInMemoryAggregation`, which both entries export. In-repo callers are `engine.aggregate` (seam-fed) and `packages/verify/src/date-bucket-parity.ts`, whose ASTs carry no per-aggregation filter. - **The seam covers both positions with the same reader the copy used.** `engine.aggregate` resolves then lowers `aggregations[i].filter` with `declaredDatetimeLowering(schema)`, and `having` with `aggregatedRowColumnTypes(...) === 'datetime'`. The copy's set was `classOfDeclaredType(type) === 'datetime'`, and `INSTANT_TYPES` is `{ datetime }`, so the two sets are equal. With no field map, the seam is type-blind on the per-aggregation filter and passes no column on `having`; the copy passed none on either. Nothing composes into `having` or an aggregation filter after the seam (`predicate-guard.ts` only reads them). - **Probe.** On a `datetime` field, `{ opened_at: { $lte: '2026-02-01' } }` over 6 rows. Through `engine.aggregate`, before and after: 3 (the whole day). Through `applyInMemoryAggregation(rows, ast, undefined, fields)` called directly: 3 before, 2 after (that day's midnight, as written). That is item 5. Group 3a graded the same move on F6 as `no`. ## The `$contains` faces Each face is pinned on `u1` against a stored `["u10"]`, with a scalar control: - **mongodb**: `{ owners: { $contains: 'u1' } }` on a `multiple: true` lookup emits `{ owners: { $elemMatch: { $in: ['u1'], $not: { $type: 'array' } } } }`. It used to emit `$regex: 'u1'`, which MongoDB applies per element. A `text` field keeps `$regex`. A field the driver holds no declaration for keeps `$regex`, as driver-sql does for a table it was never told about. - **formula**: `matchesFilterCondition({ owners: ['u10'] }, { owners: { $contains: 'u1' } })` is false, `['u1', 'u2']` is true, and `{ title: 'u10' }` is true (substring). The declaration H2 asked about: `MongoDBDriver` reads it through `ValueShapeResolver` once `syncSchema` has run, and a direct `translateFilter` call gets none. `jsonMembershipCandidates` (core, PR #21117) supplies the candidates, parsed from JSON text into values. `driver-mongodb` already depended on core. Formula depends on spec alone, so it carries a value-level copy of the same candidate rule, as objectql `having` and `driver-memory` do (see the acceptance notes). The emitted mongo documents were also read through mingo 7.2.4 (driver-memory's evaluator) in a scratch probe. It agreed with the server-free reader on every new case. A real `mongod` was NOT MEASURED: there is no binary here, and the live block in the new test file is skipped. That is the card's recorded gap. ## RLS effect of the formula face (H3) The write check evaluates `check` with `matchesFilterCondition`, handed the object's declared columns. The probe ran through ObjectQL, SecurityPlugin and SqlDriver (better-sqlite3 and sqlite-wasm, identical). Policy: `record.tags.contains('x')` on a `tags` field. The "before" column is formula's pre-change arm, ablated in `dist/`. | post-image `tags` | stored as | read under `using` | `check` before | `check` after | |:--|:--|:--|:--|:--| | `['x']` | `['x']` | shown | 403 | admitted | | `['a', 'x']` | `['a', 'x']` | shown | 403 | admitted | | `['xy']` | `['xy']` | hidden | 403 | 403 | | scalar `'xy'` | `['xy']` | hidden | **admitted** | 403 | | scalar `'x'` | `['x']` | shown | admitted | **403** | | `null` | `null` | hidden | 403 | 403 | - No write path admits a row the read hides after this change, so the stop condition (C widening the check past the read) is not met. - One write path the base admitted while the read hid the stored row is closed: scalar `'xy'`. - One write is newly refused although the read shows the stored row: scalar `'x'`. The check judges the raw post-image, before the write door wraps a scalar into a list. This is the class #21109's ruling A addresses for temporal columns ("the RLS write check judges the row as it will be stored"). The multi-value wrap is not in that ruling's fold, so it is reported to #21109's family rather than worked around here. #21109 remains open. ## `Clause-②` (H5) `no`, as claimed: - No face adds or removes a refusal, and no export, type member or authorable key changes. - The answers move toward the declared contract. On `driver-mongodb`, `$contains` narrows on declared JSON-stored fields (exact member instead of a per-element substring). On `formula`, it widens on arrays and narrows on a scalar stored in a declared JSON-stored column. On objectql, a direct call compares as written. - The reviewer should re-judge one line: through the RLS write check, formula's move becomes an admit-set move in both directions (the table above). Levels: `@objectstack/objectql`, `@objectstack/driver-mongodb`, `@objectstack/formula` and `@objectstack/spec` are `patch`. The spec entry is docblock-only: `filter.zod.ts` ships in the spec tarball (`files` includes `src/**/*.zod.ts`), so its edited docblocks publish (patch round 1, 9a797d0, after review 5935291820). The docs and test edits do not publish. ## Ablations (on committed heads; every restore proven blob == HEAD and `git diff HEAD` empty) - **F8 A1** re-plants the `$lte` whole-day arm (nested WRAP: import, then arm; objectql tests import `src`). 3 red of 798, exactly the direct-call `$lte` cells (per-aggregation `$lte`, `applyInMemoryAggregation`, `having` on `min(datetime)`). Every seam-fed cell stays green: the card's rows 3 and 4, the `having` rows, the temporal kit, and `engine-shared-filter-lowering-seam`. - The first A1 attempt was a no-op. Its replacement contained its own anchor, the tool refused it ("the anchor count moved 1 -> 1"), and it was rerun with a respelled import. - **F8 A2** re-plants the `$between` arm. 1 red of 798, exactly the direct `$between` cell. - **M1** restores the always-`$regex` arms in `mongodb-filter.ts`. 10 red of 690, all membership cells in the new file. The scalar controls, the no-declaration cell and all pre-existing suites stay green. - **C1** forces `containsAsksMembership` to false in formula, then rebuilds formula, which plugin-security consumes through `dist/`. - The first attempt is VOID. That mutation failed the DTS build (unused symbols), and esbuild folded its marker string, so `ablation-dist-preflight` reported the marker absent from `dist/` (exit 1). - The rerun used a marker esbuild keeps. Build exit 0, preflight found the marker in 2 built files. formula: 8 red of 1253, all membership cells. plugin-security: 4 red of 6, the check insert and update cells on both drivers, with both `using` read cells green. - The restore leg rebuilt formula; preflight `--absent` passed and the tree was clean. ## Tests (final head a62f5ff, `vitest run --maxWorkers=2`, under the verify lock) | package | files | tests | |:--|:--|:--| | objectql (`--project local`) | 359 passed | 7078 passed | | driver-mongodb | 31 passed, 5 skipped | 690 passed, 182 skipped (base 675 / 172; +15 / +10 is the new file and its live block) | | formula | 43 passed | 1253 passed (base 1241) | | plugin-security | 155 passed | 3327 passed, 23 skipped | | service-analytics `read-scope-shared-lowering-seam` | 1 passed | 12 passed | - At BASE f0cc16e, the objectql having/aggregate subset was 21 files and 783 passed. After the deletion, before any test edit, it was still 783. - `typecheck` (tsc plus `check:test-typecheck`) exits 0 for objectql, driver-mongodb, formula, plugin-security and service-analytics. - `spec` `check:generated`: 15 of 15 up to date. - `check:driver-conformance` reads the same before (BASE) and after (head): OK, 50 covered cells, 0 DEBT, 0 exempt. - **Lint, narrowed.** `eslint --no-inline-config --format json` over the 11 changed `.ts` files at a62f5ff: 11 files, 0 errors, 0 warnings, none ignored. The `.md` / `.mdx` files are outside eslint's configured population ("no matching configuration"). `eslint.config.mjs` enables no type-aware linting, so no untouched file's verdict can move. The full `pnpm lint` is CI's. - **Gates.** `dispatch-gates --commands` at a62f5ff derived 115 families from 13 paths. All 115 were run with exit codes recorded and all exited 0. `--ran`: 115 derived, 115 run, 0 NOT-MEASURED, 0 UNRUN. - On the first pass three gates refused with PREREQUISITE NOT MET: `check:skill-examples`, `check:dual-build-cjs-loads` and `check:i18n`. They passed after a full turbo build. - `check:where-matcher` caught the new mongodb test double, whose control probe threw on implicit equality; 565a47a fixes it. ## Acceptance notes - **The membership candidate rule now has three value-level copies:** `formula` (this PR), objectql `having-filter.ts` `storedArrayHasMember`, and `driver-memory` `containsMemberCandidates`. Each follows core's `jsonMembershipCandidates`. The home they could all import is `@objectstack/spec/data` (formula depends on spec alone), as the `having` docblock already says. Not filed: it is a duplication, not a wrong answer. - **The delete probe's off-shape scalar.** Membership is array-only on every typed backend, so a `multiple: true` slot holding a bare scalar (out-of-band data; the write door wraps scalars) is not matched by the delete probe's `$contains` pushdown. The `storedReferenceIncludes` scalar arm therefore never sees it. That was already true on driver-sql and driver-memory, and this PR extends it to mongodb. Noted in the docblock; not filed (no in-repo producer of such a slot was measured). - **`query-syntax.mdx`'s `$contains` bullet.** It still describes only the substring reading. That item is #20987's (its comment 5922379046), and #20987 remains open; this PR edits only the direct-call sentence its claim names. - **`compileScopedFilterToSql` with no declarations handed in** reads no column as `datetime` and compiles the bound as written. The RLS compile seam reads a guard without types type-blind since group 2. The seam test header now says so; the divergence is noted, not filed. --- _Generated by [Claude Code](https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b42e034 commit e18fea6

13 files changed

Lines changed: 898 additions & 134 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/objectql': patch
3+
'@objectstack/driver-mongodb': patch
4+
'@objectstack/formula': patch
5+
'@objectstack/spec': patch
6+
---
7+
8+
fix(objectql,driver-mongodb,formula): the `having` and per-aggregation evaluator compiles the whole-day comparison it is handed, and `$contains` asks membership on a JSON-stored field in `MongoDBDriver` and in `matchesFilterCondition` (ADR-0053 D-D1 items 5 and 9; the `FILTER_OPERATORS` `$contains` contract, #20822)
9+
10+
Clause-②: no
11+
12+
- **`@objectstack/objectql`: the aggregate evaluator's own whole-day copy is deleted.** The walker behind `having` and `aggregations[i].filter` no longer widens a bare `YYYY-MM-DD` `$lte`, or a `$between` maximum, on a `datetime` column to the whole day, and no longer drops the bound on `9999-12-31`. Through `engine.aggregate` nothing changes: the engine's seam lowers both positions with the shared `lowerFilterCondition` (`@objectstack/spec/data`) before the walker runs, by the object's declared `datetime` fields for the per-aggregation `filter` and by the aggregated column's type for `having`. A caller that passes no seam gets the comparison it wrote: `applyInMemoryAggregation(rows, ast, tz, fields)` called directly now counts `{ at: { $lte: '2026-02-01' } }` against that day's midnight. To keep the seam's reading on a direct call, lower each `filter` first with `lowerFilterCondition(filter, { isDatetimeColumn })`.
13+
- **`@objectstack/driver-mongodb`: `$contains` / `$notContains` ask membership on a declared JSON-stored field.** On a field `syncSchema` recorded as `multiple: true`, a multi-option type (`tags`, `multiselect`, `checkboxes`) or a JSON type, `translateFilter` (every verb, and the aggregation `$match`) now emits an array-only `$elemMatch` over the members the comparand names, with the candidate rule the SQL dialects bind (`jsonMembershipCandidates`, `@objectstack/core`): `'1'` names the string `'1'` or the number `1`, `'true'` the string or `true`. It used to emit a `$regex`, which MongoDB applies to each element, so `{ owners: { $contains: 'u1' } }` matched a stored `['u10']` and `{ tags: { $contains: 'red' } }` a stored `['redwood']`. `$notContains` is the exact complement, and still admits a row with no value. A scalar column, and a field whose declaration the driver does not hold (an object never synced, a standalone `translateFilter` call), keep the substring `$regex`.
14+
- **`@objectstack/formula`: `matchesFilterCondition` asks membership of a JSON-stored column.** When the caller supplies `options.fields` and it names the column, the declaration decides: membership on a JSON-stored column, substring on any other. Otherwise the stored value decides: an array asks membership, anything else substring. A stored array used to fail `$contains` and pass `$notContains` whatever it held.
15+
- **The RLS write check, which evaluates a policy with this function, moves with it.** Under a `check` such as `record.tags.contains('x')` on a multi-valued field, a write whose post-image holds `['x']` (a row the same policy's read shows) is now admitted; it was refused `PERMISSION_DENIED` / 403. `['xy']` stays refused, and the read hides it.
16+
- A scalar written to a declared multi-valued field is judged as written, before the write door wraps it in a list. So `tags: 'xy'`, which the check used to admit while the read hides the stored `['xy']`, is now refused 403. And `tags: 'x'` is now refused 403 too, although the read shows the stored `['x']`. Send the list, `tags: ['x']`.
17+
- **`@objectstack/spec`: docblock only, in the shipped `src/data/filter.zod.ts`.** The three pointers to the deleted `SqlDriver.calendarDayUpperBoundRewrite` / `calendarDayBetweenRewrite` now name the shared `lowerFilterCondition` at the seams, and the `FILTER_OPERATORS` `$contains` implementation-status list gains `driver-mongodb` and `formula`. No schema, type or export changes.
18+
- No exported name changes.

‎content/docs/protocol/objectql/query-syntax.mdx‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -571,14 +571,17 @@ means the start of that day (midnight UTC); as an upper bound (`$lte`, or the
571571
max of a `$between`) it covers the **whole** day. On a `date` column that is
572572
plain comparison, so the `$between` above includes Dec 31. On a `datetime`
573573
column it is the shared filter lowering (`lowerFilterCondition` from
574-
`@objectstack/spec/data`, ADR-0053 D-D1 as amended): the engine's `where` seam
575-
and the RLS compile seam rewrite a bare-day upper bound on a `datetime` column
576-
half-open (`$lt` the next day) before any driver sees the filter, and a seam
577-
that cannot read the declared types applies that rewrite to every column. A
578-
filter handed directly to `SqlDriver` or a driver built on it
579-
(`SqliteWasmDriver`, `TursoDriver`), past both seams, is compared as written: a
580-
bare-day `$lte` on a `datetime` column compares against that day's midnight. A
581-
full ISO timestamp keeps exact-instant semantics on every operator.
574+
`@objectstack/spec/data`, ADR-0053 D-D1 as amended): the engine's filter seams
575+
(`where`, and on `aggregate` the per-aggregation `filter` and `having`) and the
576+
RLS compile seam rewrite a bare-day upper bound on a `datetime` column
577+
half-open (`$lt` the next day) before any driver or in-memory evaluator sees the
578+
filter, and a seam that cannot read the declared types applies that rewrite to
579+
every column. A filter handed directly to a driver past those seams —
580+
`SqlDriver` or a driver built on it (`SqliteWasmDriver`, `TursoDriver`),
581+
`InMemoryDriver` or `MongoDBDriver` — or to `applyInMemoryAggregation` is
582+
compared as written: a bare-day `$lte` on a `datetime` column compares against
583+
that day's midnight. A full ISO timestamp keeps exact-instant semantics on every
584+
operator.
582585

583586
### Null Checks
584587

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `$contains` / `$notContains` on `translateFilter`, by the field's DECLARED
5+
* value shape — the contract `FILTER_OPERATORS`' `$contains` docblock
6+
* (`@objectstack/spec`) states: MEMBERSHIP on a `multiple: true` field or a
7+
* JSON-stored type, the SUBSTRING test on a scalar string column, as
8+
* `driver-sql` answers on every dialect and `driver-memory` on every face.
9+
*
10+
* | declared shape | `$contains: v` | `$notContains: v` |
11+
* |---|---|---|
12+
* | JSON-stored | `{ f: { $elemMatch: { $in: members, $not: { $type: 'array' } } } }` | the same test under `$not` |
13+
* | anything else, or none held | `{ f: { $regex: escaped(v) } }` | `{ f: { $not: { $regex } } }` |
14+
*
15+
* Before, every field took the `$regex`, and MongoDB applies a `$regex` to each
16+
* element of an array value: `'u1'` matched a stored `['u10']`, the cell this
17+
* file pins first, beside a scalar text column that still answers substring.
18+
*
19+
* Pinned twice, as `mongodb-20444-empty-operator.test.ts` pins `$empty`: the
20+
* emitted DOCUMENTS, and the rows they select under a server-free reading of
21+
* the MongoDB semantics those documents use. A live `mongod` suite runs the
22+
* same cases when the opt-in server is available.
23+
*/
24+
25+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
26+
import type { MongoMemoryServer } from 'mongodb-memory-server';
27+
import type { FilterCondition } from '@objectstack/spec/data';
28+
import { translateFilter, type ValueShapeResolver } from './mongodb-filter.js';
29+
import { buildAggregationPipeline } from './mongodb-aggregation.js';
30+
import { MongoDBDriver } from './mongodb-driver.js';
31+
import { createTestMongod } from './test-mongod.js';
32+
33+
const OBJECT = 'os_contains_membership';
34+
35+
const FIELDS: Record<string, { type: string; multiple?: boolean; reference?: string }> = {
36+
title: { type: 'text' },
37+
owners: { type: 'lookup', reference: OBJECT, multiple: true },
38+
tags: { type: 'tags' },
39+
meta: { type: 'json' },
40+
};
41+
const SHAPES: ValueShapeResolver = (field) => FIELDS[field];
42+
43+
const ROWS: Array<Record<string, unknown>> = [
44+
{ id: 'r1', title: 'u10', owners: ['u10'], tags: ['redwood'], meta: [10, 21] },
45+
{ id: 'r2', title: 'u1', owners: ['u1', 'u2'], tags: ['red'], meta: [1, 2] },
46+
{ id: 'r3', title: 'x', owners: [], tags: [], meta: [true, null] },
47+
{ id: 'r4', title: null, owners: null, tags: null, meta: null },
48+
{ id: 'r5' },
49+
{ id: 'r6', title: 'y', owners: [['u1']], tags: ['RED'], meta: { k: 'u1' } },
50+
{ id: 'r7', title: 'z', owners: 'u1', tags: ['blue'], meta: [1.5] },
51+
];
52+
53+
const CASES: Array<{ name: string; where: FilterCondition; expected: string[] }> = [
54+
{ name: "'u1' against a stored ['u10'] is not a member", where: { owners: { $contains: 'u1' } }, expected: ['r2'] },
55+
{ name: 'the exact complement, the rows with no value included', where: { owners: { $notContains: 'u1' } }, expected: ['r1', 'r3', 'r4', 'r5', 'r6', 'r7'] },
56+
{ name: 'the scalar control: a text column keeps the substring test', where: { title: { $contains: 'u1' } }, expected: ['r1', 'r2'] },
57+
{ name: 'the scalar control, negated', where: { title: { $notContains: 'u1' } }, expected: ['r3', 'r4', 'r5', 'r6', 'r7'] },
58+
{ name: "a tags field: 'red' is not a member of ['redwood'], and the test is case-exact", where: { tags: { $contains: 'red' } }, expected: ['r2'] },
59+
{ name: "a json field: '1' names the number 1, and not 10 or 21", where: { meta: { $contains: '1' } }, expected: ['r2'] },
60+
{ name: "a json field: '1.50' names the number 1.5", where: { meta: { $contains: '1.50' } }, expected: ['r7'] },
61+
{ name: "a json field: 'true' and 'null' name the literals", where: { $and: [{ meta: { $contains: 'true' } }, { meta: { $contains: 'null' } }] }, expected: ['r3'] },
62+
{ name: 'an object root has no member', where: { meta: { $contains: 'u1' } }, expected: [] },
63+
{ name: 'under $or beside a scalar column', where: { $or: [{ owners: { $contains: 'u2' } }, { title: { $contains: 'x' } }] }, expected: ['r2', 'r3'] },
64+
];
65+
66+
// ── A server-free reading of the MongoDB vocabulary these documents use ─────
67+
68+
/** `$elemMatch` with operator queries: some ELEMENT of an array value satisfies every one. */
69+
function elemMatches(value: unknown, query: Record<string, unknown>): boolean {
70+
if (!Array.isArray(value)) return false;
71+
return value.some((element) => Object.entries(query).every(([op, arg]) => {
72+
switch (op) {
73+
// A scalar element equals a member of its own BSON type only: `'1'` never equals `1`.
74+
case '$in': return (arg as unknown[]).some((member) => element === member);
75+
case '$not': {
76+
const inner = arg as Record<string, unknown>;
77+
if (Object.keys(inner).length !== 1 || inner.$type !== 'array') throw new Error(`unmodelled $not ${JSON.stringify(inner)}`);
78+
return !Array.isArray(element);
79+
}
80+
default: throw new Error(`unmodelled $elemMatch operator ${op}`);
81+
}
82+
}));
83+
}
84+
85+
/** `$regex` on a field: a string value, or ANY string element of an array value (one level). */
86+
function regexMatches(value: unknown, pattern: string): boolean {
87+
const re = new RegExp(pattern);
88+
if (typeof value === 'string') return re.test(value);
89+
if (Array.isArray(value)) return value.some((element) => typeof element === 'string' && re.test(element));
90+
return false;
91+
}
92+
93+
function matchOps(value: unknown, ops: Record<string, unknown>): boolean {
94+
for (const [op, arg] of Object.entries(ops)) {
95+
switch (op) {
96+
case '$elemMatch': if (!elemMatches(value, arg as Record<string, unknown>)) return false; break;
97+
case '$regex': if (!regexMatches(value, arg as string)) return false; break;
98+
// MongoDB's field `$not` also matches a document whose field is missing.
99+
case '$not': if (matchOps(value, arg as Record<string, unknown>)) return false; break;
100+
default: throw new Error(`unmodelled field operator ${op}`);
101+
}
102+
}
103+
return true;
104+
}
105+
106+
/** MongoDB implicit equality: a scalar matches itself, and an array value matches by element. */
107+
function equals(value: unknown, comparand: unknown): boolean {
108+
if (Array.isArray(value)) return value.some((element) => element === comparand);
109+
return value === comparand;
110+
}
111+
112+
function matchDoc(row: Record<string, unknown>, doc: Record<string, unknown>): boolean {
113+
for (const [key, value] of Object.entries(doc)) {
114+
if (key === '$and') { if (!(value as Array<Record<string, unknown>>).every((d) => matchDoc(row, d))) return false; continue; }
115+
if (key === '$or') { if (!(value as Array<Record<string, unknown>>).some((d) => matchDoc(row, d))) return false; continue; }
116+
if (key.startsWith('$')) throw new Error(`unmodelled document operator ${key}`);
117+
const cond = value as Record<string, unknown>;
118+
const isOps = cond !== null && typeof cond === 'object' && !Array.isArray(cond)
119+
&& Object.keys(cond).every((k) => k.startsWith('$'));
120+
if (isOps ? !matchOps(row[key], cond) : !equals(row[key], value)) return false;
121+
}
122+
return true;
123+
}
124+
125+
const select = (doc: Record<string, unknown>) => ROWS.filter((r) => matchDoc(r, doc)).map((r) => String(r.id)).sort();
126+
127+
describe('translateFilter — $contains asks membership on a declared JSON-stored field', () => {
128+
it('emits the membership test on a JSON-stored field and the substring pattern on a scalar column', () => {
129+
const membership = (members: unknown[]) => ({ $elemMatch: { $in: members, $not: { $type: 'array' } } });
130+
expect(translateFilter({ owners: { $contains: 'u1' } }, undefined, SHAPES)).toEqual({ owners: membership(['u1']) });
131+
expect(translateFilter({ owners: { $notContains: 'u1' } }, undefined, SHAPES)).toEqual({ owners: { $not: membership(['u1']) } });
132+
expect(translateFilter({ meta: { $contains: '1' } }, undefined, SHAPES)).toEqual({ meta: membership(['1', 1]) });
133+
expect(translateFilter({ meta: { $contains: '1.50' } }, undefined, SHAPES)).toEqual({ meta: membership(['1.50', 1.5]) });
134+
expect(translateFilter({ meta: { $contains: 'true' } }, undefined, SHAPES)).toEqual({ meta: membership(['true', true]) });
135+
expect(translateFilter({ meta: { $contains: 'null' } }, undefined, SHAPES)).toEqual({ meta: membership(['null', null]) });
136+
expect(translateFilter({ title: { $contains: 'u1' } }, undefined, SHAPES)).toEqual({ title: { $regex: 'u1' } });
137+
expect(translateFilter({ title: { $notContains: 'u1' } }, undefined, SHAPES)).toEqual({ title: { $not: { $regex: 'u1' } } });
138+
});
139+
140+
it('a member is matched literally: no regex metacharacter reaches the membership test', () => {
141+
expect(translateFilter({ tags: { $contains: 'a.b+c' } }, undefined, SHAPES))
142+
.toEqual({ tags: { $elemMatch: { $in: ['a.b+c'], $not: { $type: 'array' } } } });
143+
});
144+
145+
for (const c of CASES) {
146+
it(`${c.name}: ${JSON.stringify(c.where)} selects ${JSON.stringify(c.expected)}`, () => {
147+
const doc = translateFilter(c.where, undefined, SHAPES) as Record<string, unknown>;
148+
expect(select(doc), JSON.stringify(doc)).toEqual(c.expected);
149+
});
150+
}
151+
152+
it('beside $startsWith on the same field, both constraints survive — $elemMatch contests no key', () => {
153+
expect(translateFilter({ tags: { $contains: 'red', $startsWith: 'r' } }, undefined, SHAPES))
154+
.toEqual({ tags: { $elemMatch: { $in: ['red'], $not: { $type: 'array' } }, $regex: '^r' } });
155+
});
156+
157+
it('the aggregate $match translates it the way find() does', () => {
158+
const pipeline = buildAggregationPipeline({
159+
where: { owners: { $contains: 'u1' } },
160+
aggregations: [{ function: 'count', alias: 'n' }] as never,
161+
valueShape: SHAPES,
162+
});
163+
expect(pipeline[0]).toEqual({ $match: { owners: { $elemMatch: { $in: ['u1'], $not: { $type: 'array' } } } } });
164+
});
165+
166+
it('with no declaration held, every field keeps the substring reading, as driver-sql does for a table it was never told about', () => {
167+
const doc = translateFilter({ owners: { $contains: 'u1' } }) as Record<string, unknown>;
168+
expect(doc).toEqual({ owners: { $regex: 'u1' } });
169+
// The per-element substring the declaration exists to replace: 'u1' answers
170+
// ['u10']. (Whether a `$regex` reaches into the NESTED array of r6 is left
171+
// unmodelled here; the membership test above excludes it by construction.)
172+
expect(select(doc)).toEqual(expect.arrayContaining(['r1', 'r2', 'r7']));
173+
expect(translateFilter({ nope: { $contains: 'u1' } }, undefined, SHAPES)).toEqual({ nope: { $regex: 'u1' } });
174+
});
175+
});
176+
177+
const sharedMongod: MongoMemoryServer | undefined = await createTestMongod('$contains membership');
178+
179+
describe.skipIf(!sharedMongod)('MongoDBDriver — $contains membership against a live mongod', () => {
180+
const mongod = sharedMongod as MongoMemoryServer;
181+
let driver: MongoDBDriver;
182+
183+
beforeAll(async () => {
184+
driver = new MongoDBDriver({ url: mongod.getUri(), database: OBJECT });
185+
await driver.connect();
186+
await driver.syncSchema(OBJECT, { name: OBJECT, fields: FIELDS });
187+
for (const row of ROWS) await driver.create(OBJECT, { ...row });
188+
}, 90_000);
189+
190+
afterAll(async () => {
191+
if (driver) await driver.disconnect();
192+
if (sharedMongod) await sharedMongod.stop();
193+
});
194+
195+
for (const c of CASES) {
196+
it(`${c.name}: selects ${JSON.stringify(c.expected)}`, async () => {
197+
const rows = (await driver.find(OBJECT, { where: c.where })) as Array<Record<string, unknown>>;
198+
expect(rows.map((r) => String(r.id)).sort()).toEqual(c.expected);
199+
});
200+
}
201+
});

‎packages/drivers/driver-mongodb/src/mongodb-driver.ts‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,9 @@ export class MongoDBDriver implements IDataDriver {
202202
* {@link syncSchema} beside {@link temporalFields} and for the same reason:
203203
* the `$empty` operator is answered by the field's DECLARED row, and a field
204204
* this map does not hold is refused rather than given a row guessed from the
205-
* data.
205+
* data. The same shape decides whether `$contains` asks membership (a
206+
* JSON-stored field) or substring (anything else, a field it does not hold
207+
* included).
206208
*/
207209
private valueShapes = new Map<string, Map<string, ValueShapeFieldDef>>();
208210

@@ -694,7 +696,8 @@ export class MongoDBDriver implements IDataDriver {
694696
// Learn which fields are temporal BEFORE any write can land, so the write
695697
// path and the filter path share one storage convention (#4047).
696698
this.temporalFields.set(object, indexTemporalFields(objectDef.fields));
697-
// [#20444] …and each field's declared value shape, for `$empty`.
699+
// [#20444] …and each field's declared value shape, for `$empty` and for
700+
// the question `$contains` asks.
698701
this.valueShapes.set(object, indexValueShapes(objectDef.fields));
699702
await syncCollectionSchema(this.db, object, objectDef);
700703
}
@@ -833,8 +836,10 @@ export class MongoDBDriver implements IDataDriver {
833836

834837
/**
835838
* [#20444] The declared-value-shape lookup for one object, handed to
836-
* {@link translateFilter} so `$empty` translates the field's declared row.
837-
* `undefined` for an undeclared object — `$empty` is then refused.
839+
* {@link translateFilter} so `$empty` translates the field's declared row,
840+
* and so `$contains` / `$notContains` ask MEMBERSHIP on a declared
841+
* JSON-stored field. `undefined` for an undeclared object — `$empty` is then
842+
* refused, and `$contains` keeps the substring reading.
838843
*/
839844
private valueShapeFor(object: string): ValueShapeResolver | undefined {
840845
const shapes = this.valueShapes.get(object);

0 commit comments

Comments
 (0)