Skip to content

Commit 1d20245

Browse files
docs(skills): objectstack-query teaches the served route for a related record's column, not the refused nested form (#20811)
Fixes #20782 Clause-②: no ## What this changes The published skill `skills/objectstack-query` taught `{ relation: { field: value } }` beneath a lookup as a working `where` form. On `main` the engine refuses that form on every driver — `INVALID_FILTER` / 400, in the words of `relationWords()` in `packages/objectql/src/no-operator-object-door.ts` — and names the route it serves: filter the related object first, then match the relation column against the ids it returns (`$in` on a single-valued column; `$contains` per id, an `$or` of those for several, on a `multiple: true` one, whose JSON column the SQL driver refuses `$in` on). The skill now states that refusal and that route at every site that taught or pointed at the form. It anticipates neither letter of the open v18 decision (#20802 is not addressed here): it states today's behaviour. Six landing sites, all in `skills/objectstack-query`, measured on `origin/main` `96e72447`: | Site (at `96e72447`) | Before | After | |:--|:--|:--| | `SKILL.md:76`, Removed-key row `query.joins` | "`expand`, or a nested relation filter" | "`expand` (display), or filter the related object and `$in` its ids" | | `SKILL.md:88`, rules index | "nested relations" | "filtering by a related record" | | `SKILL.md:187-:194`, subsection | "Nested Relation Filters" with the `{ contact: { profile: { verified: true } } }` example | "Filtering by a related record": the refusal, the route, a pointer to the rule | | `SKILL.md:334` | "use a nested relation filter" | "`$in` ids from its own query" | | `SKILL.md:343`, Cross-Object row | "Filter parent by child conditions — Nested relation filter" | two rows, one per direction (below) | | `rules/filters.md:132-:152`, section | "Nested Relation Filters", two ✅ examples of the refused form | "Relation Filters": the refusal, one two-step example, the multi-valued spelling, the reverse direction | **The `:343` row was mislabelled.** "Filter parent by child conditions" names the reverse direction (a parent by its children's fields), while the form it pointed at, even had it been served, expresses only the forward one (rows by their lookup target's column). The row is now two: "Filter rows by their lookup target's column" → query the target object, then `{ lookup: { $in: ids } }` (`$contains` per id when `multiple`); "Filter parent by child conditions" → query the child with `fields: [lookup]`, then `{ id: { $in: those ids } }` on the parent. Both routes are served: the engine's own `expand` batch-loads with `{ id: { $in } }` (`engine.ts`, the "Batch-load related records using $in query" block), and the REST ingress admits `id` as a filter key (`protocol.ts` `resolveQueryFields`, `known.add('id')`). **No dotted alternative is taught.** `'owner.region'` is refused one door earlier by the #8371 dotted verdict (`filter-comparand-shape.ts`, `INVALID_FIELD` / 400); a grep of the skill for a dotted `where` path finds none. **Census.** `grep -rn -i -E 'nested relation|relation filter|nested relations' skills/` on `96e72447` finds exactly the six sites; a multi-line shape grep for a `where` example nesting a no-operator object under a relation key finds the same three (`SKILL.md:193`, `rules/filters.md:138`, `:145`) and nothing else in `skills/**`. ## Paying for it inside `rules/filters.md` The file sat at 2148 / 2149 tokens. The section rewrite (881 bytes) is paid for by deleting three examples whose rule already has a home in the same package, so the file lands at 2149 / 2149 (headroom 0, ceiling untouched): | Deleted from `rules/filters.md` | Home | |:--|:--| | `## Implicit Equality (Shorthand)` (`:40-:51`) | `SKILL.md` "Implicit Equality (Shorthand)" (`:94-:101`), and the `$eq` row of this file's Operator Reference | | `### NOT` (`:93-:100`) | `SKILL.md` Logical Operators, `{ where: { $not: { status: 'closed' } } }` (`:183-:184`) | | `### Combining Logical Operators` (`:102-:113`) | `SKILL.md` "AND + OR combined" (`:173-:181`); sibling-keys-are-AND is this file's first Common Mistake | The `role: 'admin'` literal in the deleted third example moved `check:role-word`'s count for the file 8 → 7; the gate prescribes the ratchet-down ("run `--update` and commit the baseline"), and commit `b893787b` is that `--update` output: one row of `scripts/role-word-baseline.json`. That file is outside the claim's declared surface; declared here and in the report. ## `skills/**` readings (lines and tokens; tokens are the ratchet's `ceil(utf8 bytes / 4)`) | | Before (`96e72447`) | After (`b893787b`) | Δ | |:--|--:|--:|--:| | `SKILL.md` | 401 lines · 3990 tokens (ceiling 5552) | 399 lines · 4055 tokens | −2 lines · +65 tokens | | `rules/filters.md` | 251 lines · 2148 tokens (ceiling 2149) | 214 lines · 2149 tokens | −37 lines · +1 token | | Whole package `skills/objectstack-query` (6 files) | 1184 lines · 10461 tokens | 1145 lines · 10527 tokens | −39 lines · +66 tokens | Line budget (PM-set, net 0 at most): −39. No untouched line was re-wrapped; no ceiling row moved. ## Verification Gates, all at head `b893787b`, exit codes captured by redirect and verdict lines quoted from each log: the 31 families `dispatch-gates --commands --repo objectstack-ai/objectstack` derives from the change set, reconciled with `--ran` ("31 derived, 31 run, 0 NOT-MEASURED, 0 UNRUN") — every one exit 0, including `check-skills-token-ratchet` ("54 authored bundle file(s) within their ceilings") and its `--self-test` (65 cases), `check:role-word` after the ratchet-down ("OK, no new occurrences of the reserved word"), `check:corpus-claim-drift`, `check:skill-identifier-liveness`, `check:doc-authoring`, `check:nul-bytes`, `check:doc-formula-expressions` (`@objectstack/formula` and `@objectstack/lint` built first), spec `check:skill-docs` ("Skill docs in sync") and spec `check:skill-refs` ("9 generated files in sync"). `check:skill-examples` does not apply: no block in this skill carries an `os:check` marker. Pin: the skill's example validation does not cover these snippets (no `os:check` marker), so the pin is PR #20781's, on `main`: `packages/objectql/src/engine-nested-object-door.test.ts` (the refusal envelope `{ code: 'INVALID_FILTER', status: 400 }` beneath `lookup`, `master_detail`, `multiple: true` lookup, `user` and `tree`, and the CONTROL that `{ owner: { $in: [...] } }`, `{ owners: { $contains: ... } }`, its `$or`, and `{ id: { $in: [...] } }` reach the driver as written) and `packages/rest/src/data-nested-object-door.test.ts` (400 over `POST /api/v1/data/:object/query`; the routes answer `d1`, `d3`). Run first-hand here: `pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 src/engine-nested-object-door.test.ts` → "Test Files 1 passed (1), Tests 15 passed (15)". No package is touched, so no package build or test suite is owed beyond that. ## Acceptance notes - `content/docs/protocol/objectql/query-syntax.mdx` (`:602-:610`, read-only here): the "Filtering Across Relationships" callout's headline is true (neither the nested form nor a dotted path is served), but its mechanism paragraph is stale — it says `SqlDriver.applyFilters()` compiles the nested object as a single-column comparison and emits the dotted key verbatim to Knex, while on `main` both are refused at the engine before any driver (`INVALID_FILTER` / 400 for the nested form, `INVALID_FIELD` / 400 for the dotted path), and it names no served route. Noted, not filed: documentation drift under a true headline; no carrier known. - `check:pm-governed-merges` as the package script spells it is the `--self-test` alone (exit 0 here); the live sweep is CI's. - The report's `api_writes` lists every relay write of this run. ## 维护者速读(草稿) **改了什么。** 发布的查询技能包 `skills/objectstack-query` 原先把「在关联字段下直接写条件」(`{ customer: { country: 'US' } }`)当作能用的过滤写法来教。现在六处教它或指向它的句子都改成平台今天的真实行为:这种写法被引擎拒绝(`INVALID_FILTER` / 400),可行的路是先查关联对象拿到 id,再对关联字段用 `$in`(多值关联用 `$contains` 逐个 id)。规则文件里删掉了三段在 SKILL.md 已有同样规则的重复示例,用来支付这段改写;整个包净减 39 行,每个文件的 token 上限都没动。 **为什么改。** AI 作者照着技能包写,写出来的查询在内存驱动上静默返回 0 行、在 SQL 驱动上报 400;PR #20781 合并后所有驱动都统一拒绝。技能包是客户项目里 AI 的教材,教错一句就是每个客户项目里的错误查询。本 PR 不预判 v18 决策卡(#20802)的任一方向,只陈述今天的行为。 **风险与代价(含回滚)。** 只改文档文字与一行门禁基线(`role-word` 计数 8 → 7,门禁自己要求的下调),不改任何代码或发布包。风险在措辞:若维护者裁定 v18 支持关联过滤,这几句还要再改一次(决策卡已注明)。回滚即 revert 本 PR 的两个提交。 **席位意见。** **你要做的。** 复核六处措辞与三处删除各有归宿;批准后由席位落地(Tier H)。 --- _Generated by [Claude Code](https://claude.ai/code/session_01KTZmMfzVzjNvyaLyQ8mHvg)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 07356a6 commit 1d20245

3 files changed

Lines changed: 24 additions & 63 deletions

File tree

‎scripts/role-word-baseline.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
"skills/objectstack-data/references/data-hooks.md": 3,
4040
"skills/objectstack-data/rules/relationships.md": 1,
4141
"skills/objectstack-platform/SKILL.md": 2,
42-
"skills/objectstack-query/rules/filters.md": 8,
42+
"skills/objectstack-query/rules/filters.md": 7,
4343
"skills/objectstack-ui/rules/list-views.md": 1,
4444
"skills/objectstack-ui/rules/navigation.md": 1,
4545
"skills/objectstack-upgrade/references/examples-upgrade.md": 1

‎skills/objectstack-query/SKILL.md‎

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ both are given. Writes take only the trailing argument.
7373
| Removed key | Live replacement |
7474
|:--|:--|
7575
| `query.cursor` | keyset paging — `where` on the sort key + `orderBy` + `limit` |
76-
| `query.joins` | `expand`, or a nested relation filter |
76+
| `query.joins` | `expand` (display), or filter the related object and `$in` its ids |
7777
| `query.distinct` | `groupBy` the fields — each unique combination is one row |
7878
| `query.windowFunctions` | report/dashboard metadata (**objectstack-ui**), or rank / accumulate in app code |
7979
| aggregation `distinct: true` | `count_distinct` |
@@ -85,7 +85,7 @@ procedure and the full tombstone register are **objectstack-upgrade**.
8585

8686
## Quick Reference — Detailed Rules
8787

88-
- **[Filters](./rules/filters.md)** — all operators, logical combinations, nested relations, date macros and session tokens
88+
- **[Filters](./rules/filters.md)** — all operators, logical combinations, filtering by a related record, date macros and session tokens
8989
- **[Aggregation](./rules/aggregation.md)** — groupBy, date bucketing, functions, `having`, per-measure `filter`
9090
- **[Pagination](./rules/pagination.md)** — offset vs keyset, best practices, performance
9191

@@ -184,14 +184,11 @@ Combine conditions with `$and`, `$or`, and `$not`:
184184
{ where: { $not: { status: 'closed' } } }
185185
```
186186

187-
### Nested Relation Filters
187+
### Filtering by a related record
188188

189-
Filter through relationships without an explicit join:
190-
191-
```typescript
192-
// Accounts whose related contact has a verified profile
193-
{ object: 'account', where: { contact: { profile: { verified: true } } } }
194-
```
189+
`{ customer: { country: 'US' } }` beneath a lookup is refused, `INVALID_FILTER` /
190+
400: filter the related object first, then `$in` its ids —
191+
**[filter rules → Relation Filters](./rules/filters.md)**.
195192

196193
### Cross-field comparisons
197194

@@ -331,7 +328,7 @@ that set, never widen it: over the REST/protocol ingress a name outside it is
331328
Mirror the related record's title into a **stored** field on the queried object
332329
and search that; the field, the write hooks and the lint wording are
333330
**objectstack-data → Search Fields (`searchableFields`)**. To *filter* by a
334-
related record's column use a nested relation filter; to *display* it, `expand`.
331+
related record's column, `$in` ids from its own query; to *display* it, `expand`.
335332

336333
## Common Patterns
337334

@@ -340,7 +337,8 @@ related record's column use a nested relation filter; to *display* it, `expand`.
340337
| Scenario | Use |
341338
|:---------|:----|
342339
| Load lookup fields for display | `expand` |
343-
| Filter parent by child conditions | Nested relation filter |
340+
| Filter rows by their lookup target's column | Query the target object, then `{ lookup: { $in: ids } }` — `$contains` per id when `multiple` |
341+
| Filter parent by child conditions | Query the child with `fields: [lookup]`, then `{ id: { $in: those ids } }` on the parent |
344342
| **Keyword-search by a related record's title** | **Mirror the title into a stored field on this object and search that** — `search` never traverses |
345343
| Paginate/sort a parent's related records | Query the related object directly |
346344
| Analytical queries across objects | Report/dashboard metadata, or separate queries combined in app code |

‎skills/objectstack-query/rules/filters.md‎

Lines changed: 14 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -37,18 +37,6 @@ allowlist that omits them and refuse — `Unsupported filter operator`,
3737
`INVALID_FILTER` / 400 — rather than approximating. A pattern ending in a lone
3838
unpaired backslash is refused by every face.
3939

40-
## Implicit Equality (Shorthand)
41-
42-
The most common filter — equality — has a shorthand:
43-
44-
```typescript
45-
// ✅ Implicit equality (preferred for simple cases)
46-
where: { status: 'active' }
47-
48-
// ✅ Explicit equality (same result)
49-
where: { status: { $eq: 'active' } }
50-
```
51-
5240
## Logical Operators
5341

5442
### AND (implicit)
@@ -90,28 +78,6 @@ where: {
9078
}
9179
```
9280

93-
### NOT
94-
95-
```typescript
96-
// ✅ Exclude deleted records
97-
where: {
98-
$not: { status: 'deleted' }
99-
}
100-
```
101-
102-
### Combining Logical Operators
103-
104-
```typescript
105-
// ✅ Active users who are admin OR have high score
106-
where: {
107-
status: 'active', // AND
108-
$or: [
109-
{ role: 'admin' },
110-
{ score: { $gte: 90 } }
111-
]
112-
}
113-
```
114-
11581
## Field References
11682

11783
> ✅ **Enforced.** `{ $field: '...' }` compares two columns of the same row.
@@ -129,28 +95,25 @@ Legal in a **comparison** position only. As an `$in` / `$nin` member or a
12995
`$between` endpoint it is refused at parse — no evaluation path resolves a
13096
reference there.
13197

132-
## Nested Relation Filters
98+
## Relation Filters
13399

134-
Filter by a related object's fields:
100+
A plain object with no `$` operator beneath a relation field (`lookup`,
101+
`master_detail`, `user`, `tree`) is refused, `INVALID_FILTER` / 400 on every
102+
driver: the column stores the related record's id, and no driver follows it into
103+
the related object. Filter the related object first, then `$in` its ids:
135104

136105
```typescript
137-
// ✅ Find orders where the customer is in the US
138-
where: {
139-
customer: {
140-
country: 'US'
141-
}
142-
}
143-
144-
// ✅ Deeper nesting
145-
where: {
146-
customer: {
147-
organization: {
148-
industry: 'Technology'
149-
}
150-
}
151-
}
106+
// ❌ Refused: where: { customer: { country: 'US' } }
107+
// ✅ Orders whose customer is in the US
108+
const us = await engine.find('customer', { where: { country: 'US' }, fields: ['id'] });
109+
where: { customer: { $in: us.map((c) => c.id) } }
152110
```
153111

112+
On a `multiple: true` lookup match each id with `$contains` (an `$or` of those
113+
for several); the SQL driver refuses `$in` there. A parent by its children's
114+
fields is the same two steps reversed: query the child with the condition and
115+
`fields: [the lookup]`, then `{ id: { $in: … } }` on the parent.
116+
154117
## Common Mistakes
155118

156119
### ❌ Wrong: expecting sibling keys to be an OR

0 commit comments

Comments
 (0)