diff --git a/scripts/role-word-baseline.json b/scripts/role-word-baseline.json index 6d8a1a13ddb..6e89b81606e 100644 --- a/scripts/role-word-baseline.json +++ b/scripts/role-word-baseline.json @@ -39,7 +39,7 @@ "skills/objectstack-data/references/data-hooks.md": 3, "skills/objectstack-data/rules/relationships.md": 1, "skills/objectstack-platform/SKILL.md": 2, - "skills/objectstack-query/rules/filters.md": 7, + "skills/objectstack-query/rules/filters.md": 2, "skills/objectstack-ui/rules/list-views.md": 1, "skills/objectstack-ui/rules/navigation.md": 1, "skills/objectstack-upgrade/references/examples-upgrade.md": 1 diff --git a/skills/objectstack-query/SKILL.md b/skills/objectstack-query/SKILL.md index 62f9efe2466..10119688854 100644 --- a/skills/objectstack-query/SKILL.md +++ b/skills/objectstack-query/SKILL.md @@ -73,7 +73,7 @@ both are given. Writes take only the trailing argument. | Removed key | Live replacement | |:--|:--| | `query.cursor` | keyset paging — `where` on the sort key + `orderBy` + `limit` | -| `query.joins` | `expand` (display), or filter the related object and `$in` its ids | +| `query.joins` | `expand` (display), or `{ relation: { field: value } }` in `where` (filter) | | `query.distinct` | `groupBy` the fields — each unique combination is one row | | `query.windowFunctions` | report/dashboard metadata (**objectstack-ui**), or rank / accumulate in app code | | aggregation `distinct: true` | `count_distinct` | @@ -186,9 +186,10 @@ Combine conditions with `$and`, `$or`, and `$not`: ### Filtering by a related record -`{ customer: { country: 'US' } }` beneath a lookup is refused, `INVALID_FILTER` / -400: filter the related object first, then `$in` its ids — -**[filter rules → Relation Filters](./rules/filters.md)**. +`{ customer: { country: 'US' } }` beneath a lookup is served in `where`: the +engine reads the related object as the caller and matches its ids. The limits +(one level, forward only, `where` only, 1000 ids, the caller's permissions) and +the two-step route past them: **[filter rules → Relation Filters](./rules/filters.md)**. ### Cross-field comparisons @@ -328,7 +329,7 @@ that set, never widen it: over the REST/protocol ingress a name outside it is Mirror the related record's title into a **stored** field on the queried object and search that; the field, the write hooks and the lint wording are **objectstack-data → Search Fields (`searchableFields`)**. To *filter* by a -related record's column, `$in` ids from its own query; to *display* it, `expand`. +related record's column, `where: { relation: { column: value } }`; to *display* it, `expand`. ## Common Patterns @@ -337,7 +338,7 @@ related record's column, `$in` ids from its own query; to *display* it, `expand` | Scenario | Use | |:---------|:----| | Load lookup fields for display | `expand` | -| Filter rows by their lookup target's column | Query the target object, then `{ lookup: { $in: ids } }` — `$contains` per id when `multiple` | +| Filter rows by their lookup target's column | `{ lookup: { column: value } }` in `where`, up to 1000 related ids; past the cap query the target, 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 | | **Keyword-search by a related record's title** | **Mirror the title into a stored field on this object and search that** — `search` never traverses | | Paginate/sort a parent's related records | Query the related object directly | diff --git a/skills/objectstack-query/rules/filters.md b/skills/objectstack-query/rules/filters.md index a2dd2abe794..bfeb1017079 100644 --- a/skills/objectstack-query/rules/filters.md +++ b/skills/objectstack-query/rules/filters.md @@ -37,47 +37,6 @@ allowlist that omits them and refuse — `Unsupported filter operator`, `INVALID_FILTER` / 400 — rather than approximating. A pattern ending in a lone unpaired backslash is refused by every face. -## Logical Operators - -### AND (implicit) - -All top-level conditions are AND-combined by default: - -```typescript -// ✅ Implicit AND — all conditions must match -where: { - status: 'active', - role: 'admin', - age: { $gte: 18 } -} - -// ✅ Explicit $and — same result -where: { - $and: [ - { status: 'active' }, - { role: 'admin' }, - { age: { $gte: 18 } } - ] -} -``` - -### OR - -```typescript -// ✅ Find admins OR managers -where: { - $or: [ - { role: 'admin' }, - { role: 'manager' } - ] -} - -// ✅ Equivalent using $in -where: { - role: { $in: ['admin', 'manager'] } -} -``` - ## Field References > ✅ **Enforced.** `{ $field: '...' }` compares two columns of the same row. @@ -97,14 +56,26 @@ reference there. ## Relation Filters -A plain object with no `$` operator beneath a relation field (`lookup`, -`master_detail`, `user`, `tree`) is refused, `INVALID_FILTER` / 400 on every -driver: the column stores the related record's id, and no driver follows it into -the related object. Filter the related object first, then `$in` its ids: +A condition on a related record's fields beneath a relation field (`lookup`, +`master_detail`, `user`, `tree`) is served in `where`: the engine reads the +related object with it **as the caller**, then matches the field against the +ids it returns (`$in`; any member when `multiple: true`). ```typescript -// ❌ Refused: where: { customer: { country: 'US' } } // ✅ Orders whose customer is in the US +where: { customer: { country: 'US' } } +``` + +Limits — one level: every key a field the related object declares, no relation +or dotted key inside; forward only: never a parent by its children; `where` +only: an aggregation's `filter` and `having` refuse it, `INVALID_FILTER` / 400; +at most 1000 related ids, refused past that, `INVALID_FILTER` / 400, never +truncated; as the caller: the related object's row scope and field permissions +apply, so a field the caller cannot read is refused, `PERMISSION_DENIED` / 403, +never an empty result. Past the cap, run the two steps yourself — filter the +related object, then `$in` its ids: + +```typescript const us = await engine.find('customer', { where: { country: 'US' }, fields: ['id'] }); where: { customer: { $in: us.map((c) => c.id) } } ``` @@ -119,7 +90,7 @@ fields is the same two steps reversed: query the child with the condition and ### ❌ Wrong: expecting sibling keys to be an OR `where: { role: 'admin', status: 'active' }` is an AND — sibling keys always -are. For OR, wrap them in a `$or` array (see **Logical Operators** above). +are. For OR, wrap them in a `$or` array (SKILL.md, **Logical Operators**). ### ❌ Wrong: Using string operators on non-string fields