Skip to content

Commit 9509ea1

Browse files
docs(query-syntax): Filtering Across Relationships states the served nested-relation form (#20906)
Fixes #20876 Clause-②: no ## What changed `content/docs/protocol/objectql/query-syntax.mdx`, section "Filtering Across Relationships" only. The old callout said relation traversal in `where` is "not supported" and blamed `SqlDriver.applyFilters()` for compiling a nested object and emitting a dotted key to Knex. Both were false: the headline since PR #20872 (`ca5408c62`), the mechanism sentences since the engine refuses the nested form before any driver. The section now states the served form, per triage 5914865117: - `{ relation: { field: value } }` in `where`, one level, forward only, evaluated as the caller; a multi-valued relation matches any member; refused past 1000 ids; an unreadable related field answers `403`. - Verbs and REST doors that serve it; second level and reverse direction refused; dotted path still `INVALID_FIELD` / 400; an aggregation's `filter` and `having` refuse the form. - The two-step example is kept, now framed as the route past the cap and for the reverse direction (a reverse snippet is added). - No driver-mechanism prose. ## Code anchors (all at `origin/main` 33b6e8b) - Lowering seam: `packages/objectql/src/engine.ts:11268` (`resolveRelateThenLowerWhere`, calls `lowerRelationConditions` at :11276); the related read is the engine's own `find` with the caller's context, `:11322`. - `$in` vs `$contains`: `packages/objectql/src/relation-filter-lowering.ts:213-218` (`lowerRelationSite`): `$in` for single-valued, an `$or` of one `$contains` per id for `multiple: true`. - Cap: `RELATION_FILTER_ID_CAP = 1000` at `relation-filter-lowering.ts:84`; read asks for cap+1 (`engine.ts:11325`), refuses at `:11330` via `relationFilterCapError` (`relation-filter-lowering.ts:297`) as `INVALID_FILTER` / 400 (`filter-comparand-shape.ts:78-85`). - 403: the related read goes through the security layer's `assertReadableQueryFields` (`packages/plugins/plugin-security/src/predicate-guard.ts:106`, called at `security-plugin.ts:3652`), `PERMISSION_DENIED` / 403. Pinned: `engine-nested-relation-lowering.test.ts:227`, `packages/rest/src/data-nested-relation-permission.test.ts`. - One level: `admitRelationCondition`, `relation-filter-lowering.ts:162`; dotted key inside the condition `:189`, relation key inside `:197` (`second-level`), both `INVALID_FILTER` / 400. Pinned: `engine-nested-relation-lowering.test.ts:284`, `packages/rest/src/data-nested-object-door.test.ts` (REFUSED table). - Reverse direction: not served by the lowering (`relation-filter-lowering.ts:16-17`); over REST the parent has no such field, so `assertFilterFieldsExist` answers `INVALID_FIELD` / 400 (`packages/metadata-protocol/src/protocol.ts:10057`, called at `:11510`). The direct `engine.find` has no field-name door (`query-syntax.mdx` section 9, "Unknown Fields Are Tolerated"), so the page states the reverse refusal for the REST doors only. - Dotted path: `classifyDottedFilterHead` (`packages/spec/src/data/filter-dotted-head.ts:119`); engine door `assertFilterIsMaterializable` (`filter-comparand-shape.ts:204`, code set at `:270`); REST ingress `protocol.ts:10114`. - Verbs: `find` `engine.ts:11662`, `findOne` `:11935`, `update` `:13558`, `delete` `:16227`, `count` `:16760`, `aggregate` `:17138`; pinned for all six plus `judgeFilter` at `engine-nested-relation-lowering.test.ts:190`. REST: `GET /data/:object` and `POST /data/:object/query` (`packages/rest/src/rest-server.ts:8635`, `:8818`) both call `findData` (`protocol.ts:11183`); the POST query door is the one the REST test drives. - `aggregations[i].filter` and `having` refuse the form: `no-operator-object-door.ts:307`; pinned `engine-nested-relation-lowering.test.ts:329`, REST `data-nested-object-door.test.ts:256`. ## Shared wording with #20888 The skill half is PR #20902 (open, not merged). Two passages are quoted verbatim from its `skills/objectstack-query/rules/filters.md`: the served-form sentence ("A condition on a related record's fields beneath a relation field ... any member when `multiple: true`).") and the "Limits — one level: ..." sentence. The code sentence is identical; if #20902 changes those sentences before landing, this page must be re-quoted. ## Census of hand-written `content/docs/**` Searched: "Relation traversal", "not supported" near where/relation, `applyFilters`, dotted `'account.` examples, "two queries" / "$in its ids" / "nested form". - Fixed: `protocol/objectql/query-syntax.mdx` (the section above), the only hit. - Already correct, no change: `kernel/contracts/data-engine.mdx:187-215` states the served form, the 1000 cap, one level, `403`, aggregation `filter`/`having` and the dotted refusal (landed with #20872). - Not hits: `permissions/*.mdx` dotted `'account.annual_revenue'` keys are field-permission keys, not filters; `protocol/objectql/types.mdx:1035` `'metadata.color'` is the deliberate structured/JSON carve-out; `query-syntax.mdx` :1135/:1167 and `data-modeling/queries.mdx:442,519`, `schema-design.mdx:111`, `api/data-api.mdx:177` concern the search axis and `expand`/`fields` paths, not `where`. - Generated reference pages (`content/docs/references/**`) not touched. Also fixed (contract review, second commit `a2a66881e2`): the `query-syntax.mdx` line 4 frontmatter `description` listed "joins", a tombstoned key (`packages/spec/src/data/query.zod.ts:562`; the page says so at its Joins section); it now reads "expand", which the page's section 4 (Relationships (Expand)) covers. ## Gates `node scripts/pm/dispatch-gates.mjs --commands` derived 40 commands at head `a2a66881e2` (re-run after the second commit; the first pass was at `bd718b653a`); 39 ran green (exit 0) after `pnpm install` and a lint-closure build. `--ran` reconciliation: 39 of 40 run, 1 UNRUN: `pnpm --filter @objectstack/spec run check:skill-examples` exited 3 (prerequisite: the client-SDK surface has no built output). NOT MEASURED: it type-checks marked TypeScript examples in skills and docs, and this diff adds no marked block. CI owns it. No changeset (docs only, nothing published). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01VDtqoecgES7ScQYGbFVDRv --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cf684c9 commit 9509ea1

1 file changed

Lines changed: 55 additions & 12 deletions

File tree

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

Lines changed: 55 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: ObjectQL query syntax — the full specification
33
navTitle: Query Syntax
4-
description: Database-agnostic query language with filtering, joins, aggregations, and sorting — aligned with the canonical @objectstack/spec QuerySchema
4+
description: Database-agnostic query language with filtering, expand, aggregations, and sorting — aligned with the canonical @objectstack/spec QuerySchema
55
---
66

77
import { Search, Filter, GitMerge, BarChart } from 'lucide-react';
@@ -600,29 +600,72 @@ have kept a `null`-valued field.
600600

601601
### Filtering Across Relationships
602602

603-
<Callout type="warn">
604-
**Relation traversal inside `where` is not supported.** Neither the nested form
605-
(`where: { account: { industry: 'tech' } }`) nor a dotted path
606-
(`where: { 'account.industry': 'tech' }`) is resolved. `SqlDriver.applyFilters()` only
607-
recognises a nested object as an operator map when its keys start with `$`; anything
608-
else is compiled as a comparison against a single column of the queried table, and a
609-
dotted key is emitted verbatim, so Knex renders it as `"account"."industry"` against a
610-
table that was never joined.
611-
</Callout>
603+
Filter on a related record's fields by nesting the condition beneath the relation
604+
field in `where`:
605+
606+
```typescript
607+
// Opportunities whose account is in the tech industry
608+
const opportunities = await engine.find('opportunity', {
609+
where: { account: { industry: 'tech' } },
610+
});
611+
```
612612

613-
Filter on the local foreign key, or run two queries:
613+
A condition on a related record's fields beneath a relation field (`lookup`,
614+
`master_detail`, `user`, `tree`) is served in `where`: the engine reads the
615+
related object with it **as the caller**, then matches the field against the
616+
ids it returns (`$in`; any member when `multiple: true`).
617+
618+
Limits — one level: every key a field the related object declares, no relation
619+
or dotted key inside; forward only: never a parent by its children; `where`
620+
only: an aggregation's `filter` and `having` refuse it, `INVALID_FILTER` / 400;
621+
at most 1000 related ids, refused past that, `INVALID_FILTER` / 400, never
622+
truncated; as the caller: the related object's row scope and field permissions
623+
apply, so a field the caller cannot read is refused, `PERMISSION_DENIED` / 403,
624+
never an empty result.
625+
626+
- **Every verb that takes a `where`** serves it — `find`, `findOne`, `count`,
627+
`aggregate`, `update` and `delete` — and so do the REST `GET /data/:object` and
628+
`POST /data/:object/query` doors, which read through the same engine calls. A
629+
single-valued relation matches with `$in` on the related ids; a multi-valued one
630+
(`multiple: true`) matches a record when **any** member qualifies, written as one
631+
`$contains` per id under an `$or`.
632+
- **A second level is refused.** `{ account: { owner: { region: 'NA' } } }` and
633+
`{ account: { 'owner.region': 'NA' } }` answer `INVALID_FILTER` / 400.
634+
- **The reverse direction is not served.** A parent filtered by its children is not
635+
written as `{ opportunities: { stage: 'won' } }`: the parent has no such field, so the
636+
REST doors answer `INVALID_FIELD` / 400.
637+
- **A dotted path is still refused.** `where: { 'account.industry': 'tech' }` answers
638+
`INVALID_FIELD` / 400 at the engine and at the REST doors; the message names the
639+
nested spelling to write instead.
640+
641+
Past the 1000-id cap, and for the reverse direction, run the two steps yourself —
642+
filter the related object, then `$in` its ids:
614643

615644
```typescript
645+
// Past the cap: read the related ids, then match the local key
616646
const techAccounts = await engine.find('account', {
617647
where: { industry: 'tech', annual_revenue: { $gt: 1000000 } },
618648
fields: ['id'],
619649
});
620650

621651
const opportunities = await engine.find('opportunity', {
622-
where: { account_id: { $in: techAccounts.map((a) => a.id) } },
652+
where: { account: { $in: techAccounts.map((a) => a.id) } },
653+
});
654+
655+
// Reverse: accounts that have a won opportunity
656+
const won = await engine.find('opportunity', {
657+
where: { stage: 'won' },
658+
fields: ['account'],
659+
});
660+
661+
const accounts = await engine.find('account', {
662+
where: { id: { $in: won.map((o) => o.account) } },
623663
});
624664
```
625665

666+
On a `multiple: true` relation, match the ids with one `$contains` per id under an
667+
`$or` instead of `$in`.
668+
626669
### Filtering on a `formula` field
627670

628671
<Callout type="warn">

0 commit comments

Comments
 (0)