Skip to content

Commit ca5408c

Browse files
feat(objectql): serve the nested-relation filter in where — lowered at the engine seam, the related object read as the caller, a loud cap, drivers untouched (#20802) (#20872)
Part of #20802 Clause-②: yes (widening) The engine half of ruling 5907789183 (letter A). The analytics cube read and the analytics read-scope face are the second half, after #5930 step 3 (#20810); `skills/objectstack-query` (#20782) belongs to the skills seat, who is told after this lands. So this PR does not complete the card. ## What this does **The nested-relation form `{ relation: { field: value } }` is served in `where`.** It is lowered at the engine's filter seam that #5930 step 2 built (`cfa931535`). The drivers receive `$in` / `$contains` and are not changed (ADR-0053 D-D1 item 5, D4 (b)). - **Where the lowering runs.** Stage 2 of `where` admission now has three steps: resolve the placeholders, then lower each nested-relation condition, then run the shared `lowerFilterCondition`. `ObjectQL.resolveRelateThenLowerWhere` holds that order, so a verb cannot resolve without it. `resolveWhereTokens` and `withResolvedWhere` became async to carry it. - **How the lowering works.** `lowerRelationConditions` reads the related object with the engine's own `find`: `fields: ['id']`, `limit: RELATION_FILTER_ID_CAP + 1`, and the caller's execution context. The ids it returns become `{ relation: { $in: ids } }` on a single-valued relation. On a multi-valued one they become an `$or` of one `$contains` per id, which matches on any member. That is the spec's own any-of spelling, and the SQL family refuses `$in` over the JSON column. No related record matching gives `$in: []` or `$or: []`: FALSE, never an absent predicate. - **No second walk.** A condition is found by the one filter walk the engine already runs with each column's declaration in hand, `walkCondition` in `number-comparand-declared-type-door.ts`. - At the door (stage 1), that walk admits a condition structurally, from declarations alone (`admitRelationCondition`), instead of refusing it. - The lowering calls the same walk (`mapRelationConditions`) twice: once to collect the conditions, and once, after the reads, to replace them in the same order. - The door and the lowering therefore find a condition at the same boundaries by construction. No driver-local guard exists. - **As the caller.** The related read goes through the middleware chain like any read. The related object's CRUD gate, row scope and field permissions apply, and its own doors judge the condition's comparands. A refusal from any of them is the answer, loudly. - **Bounded.** The cap is `RELATION_FILTER_ID_CAP` = 1000, one named constant, exported from `@objectstack/objectql`. Past it the filter is refused with `INVALID_FILTER` / 400. The refusal names the cap, the related object and the two-step route. The filter is never run over a cut-off list. **The accept set that widens.** It is the engine's `where` (`find`, `findOne`, `count`, `aggregate`, `update`, `delete`) plus `judgeFilter`, and the REST query doors that reach `findData` (`POST /api/v1/data/:object/query` and the `filter` / `$filter` spellings). Per relation kind: - `lookup`, `master_detail`, `user`, `tree`, single-valued: refused, now served (`$in`). - the same kinds with `multiple: true`: refused, now served (any member). **Nothing served today narrows.** - The admission runs only where the arm refused before: a no-operator object beneath a relation column at `where`. - The cap refusal is new, but it applies to a form that was refused. - Every filter without a nested-relation condition reaches the drivers by reference, byte-identical to before (pinned). **What stays refused.** Each is refused in the engine's words, before any read: - a `json` field's object comparand, and the provisioned `id` (unchanged); - a second level (a relation condition beneath the related object's own relation field); - a dotted key inside the condition; - a key the related object does not declare; - `{}`; - a related object that is not registered; - the dotted path `{ 'owner.region': 'NA' }`, still `INVALID_FIELD` (#8371); - the form in an aggregation's own `filter` and in `having`. The engine evaluates both itself, and its evaluator has no member test for a stored list. Their words now say `where` serves it. **Text.** - `FilterCondition`'s docblock item 4 now states the served semantics. - The `QueryFilter` example shows the form again. - The `data-engine.mdx` example that PR #20781 removed comes back, with the cut, the cap and the two-step route. **The dotted-path words, made true again (a bounded in-place fix, named here).** - Both dotted relation refusals said "a filter reaches only columns of '…' itself". This change makes that false. - The two refusals are the engine's (`filter-comparand-shape.ts`) and the query-parameter door's (`metadata-protocol` `protocol.ts`, outside the claim's declared file surface). - Both now name the nested form to write instead (`{ "owner": { "region": VALUE } }`), in the same words, and keep the shared denormalise remedy. - A conformance pin holds the two routes equal. ## Measured On this branch at `56da9b6d50` through `POST /api/v1/data/:object/query`. Owner `u1` is region NA on `d1` and `d3`, and `d4` has no owner. Before, on `origin/main` after PR #20781, every relation row answered `INVALID_FILTER` / 400 on every driver. | `where` | SQLite | PostgreSQL 16.13 (live, local) | InMemoryDriver (measured, not pinned) | |:--|:--|:--|:--| | `{ owner: { region: 'NA' } }` (lookup), `boss` (master_detail) | `d1`, `d3` | `d1`, `d3` | `d1`, `d3` | | `{ owners: { region: 'NA' } }` (multiple lookup) | `d1`, `d3` | `d1`, `d3` | `d1`, `d3` (see the note) | | `{ parent: { title: 'a' } }` (tree) | `d2`, `d3` | `d2`, `d3` | `d2`, `d3` | | `{ $not: { owner: { region: 'NA' } } }` | `d2`, `d4` | `d2`, `d4` | `d2`, `d4` | | `{ $or: [{ owner: { region: 'EU' } }, { title: 'a' }] }` | `d1`, `d2` | `d1`, `d2` | `d1`, `d2` | | `{ owner: { region: 'APAC' } }` (no match) | none | none | none | | a condition matching 1001 related records | 400 `INVALID_FILTER`, the cap words | same | not measured | - **The memory note.** The in-memory driver matches `$contains` over a stored array by substring per element. That is the gap `FILTER_OPERATORS`' `$contains` docblock records for that driver. So with ids `u1` and `u10`, a multi-valued condition meaning `u1` also matches the row holding `['u10']`: measured memory `d1`, `d3`, `d5`, against SQL `d1`, `d3`. Single-valued relations are exact everywhere. - **Why memory is not pinned.** `check:driver-memory-census` refuses a new test consumer of that driver without a ruling. - **H7 (RLS), measured.** A policy `record.owner.region == 'NA'` is refused at compile: "cross-object/nested field path … is not pushdown-able". The policy is dropped to the deny sentinel, so it answers zero rows. The RLS compile seam is untouched, and no async read was added there. ## Mechanism hypotheses: which held - **H1: held, refined.** `lowerFilterCondition` is pure and synchronous, and the relation step needs the engine. So the step lives in objectql, between token resolution and the shared lowering, and each engine filter position reaches it at most once. - Served: `where` on `find`, `findOne`, `count`, `aggregate`, `update` and `delete` (the multi and by-predicate paths alike), and the judge. - Not served (refused, named): `aggregations[i].filter` and `having`. - **H2: held, with one widening of the kind list.** The door admits the form under every `REFERENCE_VALUE_TYPES` kind, not only `lookup` / `master_detail`. - Reason: `user` and `tree` point at a related object the same way, and the #20745 seat answer ruled that one class gets one answer. - `user` needs `sys_user` registered; where it is not, it is refused loudly ("no object 'sys_user' is registered here"). - Every refusal the order listed is kept, as listed above. - **H3: measured "yes": the one check exists and is reused.** - A direct filter on a field the caller cannot read is refused today: `403 PERMISSION_DENIED`, the security layer's filter-oracle guard `assertReadableQueryFields`. - The related read reaches that same check, so the nested form answers the same 403, naming the field. - There is no second copy of the rule. ⚠️ This is not the ruling's literal `INVALID_FILTER`; see the open question in the report. - **H4: `$in` does not mean "any member" everywhere; `$or` of `$contains` does on SQL.** - `$in` over a multi-valued lookup is refused on SQL (JSON column) and is any-member on memory. - `$contains` is membership on SQLite and PostgreSQL, and substring-per-element on memory (the note above). - The `$or` of `$contains` is the spec's declared any-of spelling, so it is the lowered form. - **H5: held, and no bound to reuse.** `expand`'s batch loader bounds nothing: it deliberately forwards no limit. The cap is a new named constant. - **H6: held.** `$and` / `$or` compose as written. `$not` over a relation condition takes the shared lowering's NULL-safe negation, so a row with no relation satisfies it. The driver input is pinned equal to the hand-written two-step route's, and `$not` + no match gives every row. An empty inner result is FALSE, never "no filter". `$nor` is not in the vocabulary. - **H7: held; out of scope.** Measured above. ## Tests (all on `56da9b6d50`) - `@objectstack/objectql` test: 345 files / 6786 passed. typecheck exit 0, `check:test-typecheck` OK. - `@objectstack/rest` test, with `OS_TEST_POSTGRES_URL` set to a local PostgreSQL 16.13: 237 files / 4706 passed / 35 skipped (MySQL cells and suites with no URL). typecheck exit 0. - `@objectstack/metadata-protocol` test: 191 files passed, 3 skipped / 2801 passed, 19 skipped. typecheck exit 0. - `@objectstack/spec` test: 578 files / 17066 passed / 1 todo. typecheck exit 0. `check:generated`: all 15 artifacts up to date (no regeneration needed; docblock only). - Downstream: `@objectstack/plugin-security` 149 files / 3227 passed, 23 skipped. `driver-memory` 65 / 1470 passed. `driver-sql` 201 files passed, 11 skipped / 3254 passed, 188 skipped. The other `...@objectstack/objectql` consumers are declared to CI. - New pins: - `packages/objectql/src/engine-nested-relation-lowering.test.ts` (13 tests, recording driver). It covers: - every relation type; - any-member on a multi-valued relation; - the empty id set; - `$and` / `$or` / `$not` / sugar, pinned equal to the two-step route's driver input; - every verb and the judge; - placeholder resolution; - the related read as the caller, and a middleware refusal surfacing with no outer read; - the cap at 1000 and at 1001; - the kept refusals, with the judge answering execution's words verbatim; - the related object's own doors; - the aggregation `filter` / `having` refusals; - a pass-through control. - `packages/rest/src/data-nested-object-door.test.ts` (rewritten): #20745's table turned into rows on SQLite and live PostgreSQL, plus the two-step equivalence, the kept refusals with the route inside the 500-character REST bound, the cap pin (1001 related records refused, 1000 served) and the controls. - `packages/rest/src/data-nested-relation-permission.test.ts`: the permission pin, with the real `SecurityPlugin` on a real engine and SQLite, through the REST door. An unreadable related field is refused 403, never emptied, while a system read can filter by it. A hidden related record matches nothing. - Fixture triage for the removed refusal branch: - `engine-nested-object-door.test.ts` keeps only what still refuses. - `query-expression-conformance.test.ts`: its nested-form control now pins the served rows, and a new pin checks that both doors' dotted refusals name the same route. - `protocol-explicit-filter-field-gate.test.ts`: its GUARD still proves the name gate never descends. **Ablations, each from the committed fix.** Each ran through `scripts/ablation-replace.mjs` in WRAP mode, trap-restored. After each mutation objectql was rebuilt, and `ablation-dist-preflight` found the marker in 4 built files. - **A, the lowering disabled.** `if (sites.length === 0) return where;` became an unconditional `return where` (marker `__ablated_20802_lowering__`). Blob `9237c3dfc995` → `f9994599356f`. Predicted red, observed red: - objectql pins: 10 failed / 235 passed; - rest pins: 11 failed / 4 passed / 6 skipped. - The structural refusals and controls stayed green. - **B, the related read as the system.** `...(execCtx ? { context: execCtx } : {}),` became `isSystem: true` (marker `__ablated_20802_caller__`). Blob → `b0c1748c5b70`. Predicted red, observed red: - objectql 1 failed / 244 passed (the as-the-caller pin); - rest 2 failed / 13 passed / 6 skipped: the permission pin answered rows instead of 403, and the row-scope pin returned `d4`. - (A first run of B used a mutation that left `execCtx` unused, and its DTS step failed on TS6133; the JS carried the marker. It was re-run type-clean, and those numbers are the ones above.) - **Restore.** Blob equals HEAD `9237c3dfc995`, and `git diff HEAD` is empty. After a rebuild, the `--absent` preflight found both markers absent from all 14 built files, and the whole tree was clean. The pins were green again: 245 passed; 15 passed / 6 skipped. ## Gates `node scripts/pm/dispatch-gates.mjs --commands` at `56da9b6d50` (fresh, not stale) derived 120 commands, and all 120 were run. `--ran` with each exit code recorded: **120 derived, 120 run, 0 NOT-MEASURED, 0 UNRUN**, all exit 0. Three gates refused first with `PREREQUISITE NOT MET` (exit 3), and none of the three is counted as a failure: - `check:skill-examples` - `check:dual-build-cjs-loads` - `check:type-check-debt` They were re-run green after `turbo run build --filter='./packages/*' --filter='./packages/*/*'`. Lint, narrowed and proven: `eslint --no-inline-config --format json` over the 15 changed `.ts` files gave **15 files, 0 errors, 0 warnings**. - `isPathIgnored` is false for all 15. - The population is `eslint.config.mjs`'s `**/*.{ts,…}` block. - `parserOptions.project` / `projectService` are unset for every file. Type-aware linting is off, so this diff cannot move a verdict on an untouched file. ## Changesets - `.changeset/20802-nested-relation-filter-served.md`: `@objectstack/objectql` `minor`, `Clause-②: yes (widening)`. It says it supersedes the relation-field paragraph of the pending `20745-nested-object-door` entry, and it states the in-memory `$contains` substring caveat. - `.changeset/20802-nested-relation-prose.md`: `@objectstack/spec` `patch` (shipped JSDoc). - `.changeset/20802-dotted-relation-route.md`: `@objectstack/metadata-protocol` `patch` (refusal words). - ADR anchor: `scripts/adr-anchors/packages__objectql__src__relation-filter-lowering.ts.json` → ADR-0053. ## Acceptance notes - **The analytics half.** The cube read and the analytics read-scope face are not touched here (#20810 first). Until then the analytics face still flattens the nested form to cube members, and the read scope still refuses it. - **The permission refusal's envelope.** It is `PERMISSION_DENIED` / 403, the one existing check, reused. It is not the ruling's parenthetical `INVALID_FILTER`, and it is raised to the PM as an open question. - **Read order.** The related read runs at stage 2, before the outer verb's own middleware. A caller with no read access to the queried object still gets the outer 403, but the related read has already run as that caller. It reads nothing the caller could not read directly. - **Lint face.** `@objectstack/lint`'s list-view dotted-path hint still says "Filter on a column of … itself". That is an instruction rather than a claim this change made false. Carrier: none; not changed here. - **The in-memory substring gap.** On the in-memory driver, the multi-valued any-member lowering inherits that driver's substring-per-element `$contains`. It is reported, not fixed here (no driver file). --- _Generated by [Claude Code](https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0803a8b commit ca5408c

20 files changed

Lines changed: 1611 additions & 208 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
fix(metadata-protocol): a dotted relation filter path names the nested-relation form as the route
6+
7+
A filter key such as `account.industry` — a dotted path through a relation field — is still refused with `INVALID_FIELD` / 400 at the query parameter door. Its words no longer say a filter reaches only the object's own columns, which stopped being true when the engine began serving the nested-relation form in `where`: they now name that form, `{ "account": { "industry": VALUE } }`, beside the denormalise remedy, in the same words as the engine's own refusal.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
"@objectstack/objectql": minor
3+
---
4+
5+
feat(objectql): the nested-relation filter `{ relation: { field: value } }` is served in `where`, lowered at the engine's filter seam — the drivers receive `$in` / `$contains` and are unchanged
6+
7+
Clause-②: yes (widening)
8+
9+
A condition on a related record's own fields, written beneath a relation field of the queried object — `{ "account": { "industry": "tech" } }` beneath a `lookup` — is now answered by the engine in `where`, on every verb that takes one (`find`, `findOne`, `count`, `aggregate`, `update`, `delete`) and by `judgeFilter`. It was refused with `INVALID_FILTER` / 400 until now; this supersedes the relation-field paragraph of the pending `20745-nested-object-door` entry.
10+
11+
**How it is answered.** The engine reads the related object with the condition, then matches the relation field against the ids that read returns, and the drivers receive only that: `{ "account": { "$in": [ids] } }` on a single-valued relation, and on a multi-valued one (`multiple: true`) an `$or` of one `$contains` per id, so it matches on any member. The relation types are `lookup`, `master_detail`, `user` and `tree`. It composes as written inside `$and` / `$or` / `$not`, and the `FilterArray` sugar lowers to it too. No related record matching selects no rows; under `$not`, a record whose relation is empty satisfies the negation.
12+
13+
**As the caller.** The related read is the engine's own `find` on the related object with the caller's execution context, so that object's access check, row scope and field permissions apply exactly as they do to a direct read of it. A condition on a field the caller cannot read is refused by the same check that refuses a direct filter on it (`PERMISSION_DENIED` / 403, naming the field), never answered with an empty list; a related record the caller cannot see matches no condition.
14+
15+
**Bounded.** At most `RELATION_FILTER_ID_CAP` (1,000, exported) related ids feed one condition. A condition matching more is refused with `INVALID_FILTER` / 400, naming the cap, the related object and the two-step route — never run over a cut-off list.
16+
17+
**Still refused, in the engine's words (`INVALID_FILTER` / 400, before any read):** a second level (a relation condition beneath the related object's own relation field, or a dotted key inside the condition), a key the related object does not declare, an empty condition `{}`, and a related object that is not registered. An aggregation's own `filter` and `having` keep refusing the form, and their words now name `where` as the place it is served. The dotted spelling `{ "account.industry": "tech" }` stays refused with `INVALID_FIELD` / 400, and its words now name the nested form to write instead. The structured-JSON and scalar-field refusals are unchanged.
18+
19+
Measured through `POST /api/v1/data/:object/query` on SQLite and PostgreSQL 16 (owner `u1`, region NA, on `d1` and `d3`; `d4` has no owner):
20+
21+
| `where` | before | now |
22+
|:--|:--|:--|
23+
| `{ owner: { region: "NA" } }` on a `lookup`, and its `master_detail` and multiple-lookup twins | `INVALID_FILTER` / 400 | `d1`, `d3` |
24+
| `{ parent: { title: "a" } }` on a `tree` field | `INVALID_FILTER` / 400 | `d2`, `d3` |
25+
| `{ $not: { owner: { region: "NA" } } }` | `INVALID_FILTER` / 400 | `d2`, `d4` |
26+
| `{ owner: { region: "APAC" } }` (no owner matches) | `INVALID_FILTER` / 400 | no rows |
27+
28+
On the in-memory driver, a multi-valued relation's `$contains` still matches a stored id by substring per element, so there an id that is a substring of another stored id (`u1` inside `u10`) also matches; SQLite and PostgreSQL match the element.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
docs(spec): the `FilterCondition` docblock says the query engine serves the nested-relation form in `where`
6+
7+
`FilterCondition`'s form 4, `{ relation: { field: value } }`, now states the served semantics: the engine reads the related object with the condition as the caller (its row scope and field permissions apply), matches the relation field against the ids it returns (`$in`, or any member on a multi-valued relation), reaches one level, and refuses a condition matching more related records than its cap rather than truncating. The `QueryFilter` example shows the form again, and the `Filter<T>` nested arm's comment says the engine serves one level. The type and the schema are unchanged.

‎content/docs/kernel/contracts/data-engine.mdx‎

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -178,18 +178,40 @@ where: {
178178
],
179179
}
180180

181-
// A condition on a related record's fields: filter the related object first,
182-
// then match the lookup against the ids it returns
181+
// Nested relation filter
182+
where: {
183+
account: { industry: 'tech' },
184+
}
185+
```
186+
187+
A nested relation filter is a condition on a related record's own fields, written
188+
beneath a relation field (`lookup`, `master_detail`, `user` or `tree`, single or
189+
multiple). The engine serves it in `where`, the same on every driver: it reads the
190+
related object with the condition **as the caller** — that object's row scope and field
191+
permissions apply, so a condition on a field the caller cannot read is refused
192+
(`PERMISSION_DENIED` / 403), never answered with an empty list — and then matches the
193+
relation field against the ids that read returns: `$in` on a single-valued relation, any
194+
member on a multi-valued one (`multiple: true`). No related record matching selects no
195+
rows; under `$not`, a record whose relation is empty satisfies the negation.
196+
197+
It reaches **one level**: every key must be a field the related object declares
198+
(`{ account: { owner: { region: 'NA' } } }` and the dotted `{ account: { 'owner.region': 'NA' } }`
199+
are refused), and a condition matching more than 1,000 related records is refused with
200+
`INVALID_FILTER` / 400 rather than run over a cut-off list. For either, run the two steps
201+
yourself — filter the related object, then match its ids:
202+
203+
```typescript
183204
const tech = await engine.find('account', { where: { industry: 'tech' }, fields: ['id'] });
184205
where: { account: { $in: tech.map((a) => a.id) } }
206+
// On a multi-valued lookup, one $contains per id:
207+
where: { $or: tech.map((a) => ({ accounts: { $contains: a.id } })) }
185208
```
186209

187-
A plain object with no `$` operator beneath a field — `{ account: { industry: 'tech' } }`
188-
under a lookup, `{ meta: { a: 1 } }` under a `json` field — is refused with
189-
`INVALID_FILTER` / 400 on every driver: no driver follows a relation into the related
190-
object, and a whole-value match on a JSON value means something different on each
191-
backend. On a multi-valued lookup (`multiple: true`), match each id with `$contains`
192-
(an `$or` of those for several ids) instead of `$in`.
210+
An aggregation's own `filter` and `having` do not serve the nested form (put the
211+
condition in `where`), and a plain object with no `$` operator beneath a `json` field —
212+
`{ meta: { a: 1 } }`, a whole-value match — is refused with `INVALID_FILTER` / 400 on
213+
every driver. A dotted path (`{ 'account.industry': 'tech' }`) is refused with
214+
`INVALID_FIELD` / 400: write it nested instead.
193215

194216
**Supported operators:** `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$in`, `$nin`, `$between`, `$contains`, `$notContains`, `$startsWith`, `$endsWith`, `$null`, `$exists`
195217

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,21 @@ const TYPE_TO_FORM: Readonly<Record<string, FormView>> = METADATA_FORM_REGISTRY;
263263
* spelling-tolerant lookup this comment has rejected since #4432, and it would
264264
* still persist the row under the plural `type`.
265265
*/
266+
/**
267+
* [#20802] The nested-relation spelling of a dotted relation path, as the
268+
* dotted filter refusal names it: `'owner.region'` → `{ "owner": { "region":
269+
* VALUE } }` — the form the engine serves at `where`. A path deeper than one
270+
* relation is given the generic one-level shape. The engine door
271+
* (`@objectstack/objectql`'s `nestedRelationRoute`) words it the same:
272+
* `query-expression-conformance.test.ts` holds the two doors' routes equal.
273+
*/
274+
function nestedRelationRoute(dotted: string): string {
275+
const [head, ...rest] = dotted.split('.');
276+
return rest.length === 1
277+
? `{ "${head}": { "${rest[0]}": VALUE } }`
278+
: `{ "${head}": { "FIELD": VALUE } }, one level deep`;
279+
}
280+
266281
function canonicalMetaType(type: string): string {
267282
return canonicalMetaUrlType(type);
268283
}
@@ -10104,10 +10119,15 @@ export class ObjectStackProtocolImplementation implements
1010410119
const headDef = gate.fields[head];
1010510120
const headClass = classifyDottedFilterHead(headDef);
1010610121
const headType = String(headDef?.type ?? '');
10122+
// [#20802] The relation head names the route the engine now
10123+
// SERVES — the condition nested beneath the relation field — in the
10124+
// engine door's words (`@objectstack/objectql`'s
10125+
// `assertFilterIsMaterializable`). One vocabulary across the doors.
1010710126
const body = headClass === 'relation'
1010810127
? `filters on '${first}', which follows the relationship '${head}' into another `
10109-
+ `object — a filter reaches only columns of '${object}' itself, and '${head}' `
10110-
+ 'stores the related record\'s id, not an embedded document'
10128+
+ `object as a dotted path, and '${head}' stores the related record's id, not an `
10129+
+ 'embedded document — to filter on the related record\'s fields, nest the condition '
10130+
+ `beneath the relation field: ${nestedRelationRoute(first)}`
1011110131
: headClass === 'virtual'
1011210132
? `filters on '${first}', a dotted path whose head '${head}' is a virtual `
1011310133
+ `'${headType}' field on object '${object}' — its value is computed on read, `

‎packages/objectql/src/engine-nested-object-door.test.ts‎

Lines changed: 22 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@
3030
* measured (`d1`, `d3` for `$in` on a lookup and `$contains` on a multiple
3131
* lookup) and are not pinned in a new suite: that driver's test consumers are
3232
* a ruled, closed census (`check:driver-memory-census`).
33+
*
34+
* [#20802] The relation rows at `where` are SERVED now (maintainer ruling,
35+
* letter A): the engine lowers the nested-relation form by reading the related
36+
* object, and `engine-nested-relation-lowering.test.ts` pins it. What stays
37+
* here is what still refuses: the structured-JSON and provisioned-`id` rows,
38+
* `{}` beneath a relation (it names no field of the related object), and the
39+
* relation rows at an aggregation's own `filter` and at `having`, whose words
40+
* now say the form is served in `where`.
3341
*/
3442

3543
import { describe, it, expect, beforeEach } from 'vitest';
@@ -74,15 +82,6 @@ const PROBE = {
7482

7583
const OWNER_OBJECT = { name: OWNER, label: 'Owner', fields: { region: { name: 'region', type: 'text' } } };
7684

77-
/** field · declared type · the related object the words name · whether the route is `$contains`. */
78-
const RELATIONS: ReadonlyArray<readonly [string, string, string, boolean]> = [
79-
['owner', 'lookup', OWNER, false],
80-
['owners', 'lookup', OWNER, true],
81-
['boss', 'master_detail', OWNER, false],
82-
['assignee', 'user', 'sys_user', false],
83-
['parent', 'tree', OBJECT, false],
84-
];
85-
8685
/** field · declared type — structured-JSON columns. */
8786
const JSONS: ReadonlyArray<readonly [string, string]> = [
8887
['meta', 'json'],
@@ -153,25 +152,6 @@ describe('[#20745] a no-operator object beneath a relation, structured-JSON or p
153152

154153
// ── where ────────────────────────────────────────────────────────────────
155154

156-
it('refuses the nested-relation form beneath every relation type, single or multiple, naming the route that works — no read', async () => {
157-
for (const [field, type, related, multiple] of RELATIONS) {
158-
const err = await refusalOf(engine.find(OBJECT, { where: { [field]: { region: 'NA' } } as FilterCondition }));
159-
expect(envelopeOf(err), field).toEqual(ENVELOPE);
160-
expect(err!.httpStatus, field).toBe(400);
161-
expect(err!.message, field).toMatch(/^find\('nested_object_probe'\): /);
162-
expect(err!.message, field).toContain(`filter on '${field}'`);
163-
expect(err!.message, field).toContain(`at where.${field},`);
164-
expect(err!.message, field).toContain(`beneath the declared ${type} field '${field}'`);
165-
expect(err!.message, field).toContain('nested-relation form');
166-
expect(err!.message, field).toContain('NOT applied');
167-
expect(err!.message, field).toContain(`Filter the related object '${related}' first`);
168-
expect(err!.message, field).toContain(
169-
multiple ? `{ "${field}": { "$contains": ID } }` : `{ "${field}": { "$in": [ID, …] } }`,
170-
);
171-
}
172-
expect(reads).toHaveLength(0);
173-
});
174-
175155
it('refuses a whole-value object beneath every structured-JSON type, naming what every driver answers alike — no read', async () => {
176156
for (const [field, type] of JSONS) {
177157
const err = await refusalOf(engine.find(OBJECT, { where: { [field]: { a: 1 } } as FilterCondition }));
@@ -195,20 +175,23 @@ describe('[#20745] a no-operator object beneath a relation, structured-JSON or p
195175
});
196176

197177
it('refuses {} beneath a relation and a JSON column too, in the engine\'s words rather than each driver\'s', async () => {
198-
for (const [where, words] of [
199-
[{ owner: {} }, 'nested-relation form'],
200-
[{ meta: {} }, 'whole-value match'],
178+
for (const [where, empty, words] of [
179+
// [#20802] Served at `where` otherwise — `{}` names no field of the related object.
180+
[{ owner: {} }, '(no keys)', 'names no field of the related object'],
181+
[{ meta: {} }, 'an empty object {}', 'whole-value match'],
201182
] as const) {
202183
const err = await refusalOf(engine.find(OBJECT, { where: where as FilterCondition }));
203184
expect(envelopeOf(err), JSON.stringify(where)).toEqual(ENVELOPE);
204-
expect(err!.message, JSON.stringify(where)).toContain('an empty object {}');
185+
expect(err!.message, JSON.stringify(where)).toContain(empty);
205186
expect(err!.message, JSON.stringify(where)).toContain(words);
206187
}
207188
expect(reads).toHaveLength(0);
208189
});
209190

210191
it('covers every engine verb that collects a filter — read and write sides — and the judge', async () => {
211-
for (const where of [{ owner: { region: 'NA' } }, { meta: { a: 1 } }] as FilterCondition[]) {
192+
// [#20802] The relation row is served at `where` on every verb now:
193+
// `engine-nested-relation-lowering.test.ts`.
194+
for (const where of [{ ship_to: { city: 'Paris' } }, { meta: { a: 1 } }] as FilterCondition[]) {
212195
const path = `at where.${Object.keys(where)[0]},`;
213196
for (const call of [
214197
() => engine.find(OBJECT, { where }),
@@ -230,20 +213,20 @@ describe('[#20745] a no-operator object beneath a relation, structured-JSON or p
230213

231214
it('reaches inside $and / $or / $not, and answers the FilterArray sugar alike', async () => {
232215
const cases: ReadonlyArray<readonly [FilterCondition, string]> = [
233-
[{ $and: [{ title: 'a' }, { owner: { region: 'NA' } }] }, 'where.$and[1].owner'],
216+
[{ $and: [{ title: 'a' }, { ship_to: { city: 'Paris' } }] }, 'where.$and[1].ship_to'],
234217
[{ $or: [{ meta: { a: 1 } }, { amount: 30 }] }, 'where.$or[0].meta'],
235-
[{ $not: { boss: { region: 'NA' } } }, 'where.$not.boss'],
218+
[{ $not: { spec: { k: 1 } } }, 'where.$not.spec'],
236219
];
237220
for (const [where, path] of cases) {
238221
const err = await refusalOf(engine.find(OBJECT, { where }));
239222
expect(envelopeOf(err), path).toEqual(ENVELOPE);
240223
expect(err!.message, path).toContain(`at ${path},`);
241224
}
242225
const sugar = await refusalOf(
243-
engine.find(OBJECT, { where: [['owner', '=', { region: 'NA' }]] } as unknown as EngineQueryOptions),
226+
engine.find(OBJECT, { where: [['meta', '=', { a: 1 }]] } as unknown as EngineQueryOptions),
244227
);
245228
expect(envelopeOf(sugar)).toEqual(ENVELOPE);
246-
expect(sugar!.message).toContain('at where.owner,');
229+
expect(sugar!.message).toContain('at where.meta,');
247230
expect(reads).toHaveLength(0);
248231
});
249232

@@ -333,9 +316,9 @@ describe('[#20745] a no-operator object beneath a relation, structured-JSON or p
333316
});
334317

335318
const DOORS: ReadonlyArray<{ door: string; query: Record<string, unknown> }> = [
336-
{ door: 'where object', query: { where: { owner: { region: 'NA' } } } },
319+
{ door: 'where object', query: { where: { ship_to: { city: 'Paris' } } } },
337320
{ door: '$filter string', query: { $filter: JSON.stringify({ meta: { a: 1 } }) } },
338-
{ door: 'filter AST', query: { filter: [['owner', '=', { region: 'NA' }]] } },
321+
{ door: 'filter AST', query: { filter: [['meta', '=', { a: 1 }]] } },
339322
];
340323

341324
it.each(DOORS)('the $door door refuses it', async ({ query }) => {

0 commit comments

Comments
 (0)