Skip to content

Commit 5a3cfda

Browse files
committed
Merge origin/main (e18fea6) into the stage-3 runtime strings branch
Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
2 parents a67666a + e18fea6 commit 5a3cfda

14 files changed

Lines changed: 980 additions & 135 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)