Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion scripts/role-word-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 7 additions & 6 deletions skills/objectstack-query/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -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 |
Expand Down
65 changes: 18 additions & 47 deletions skills/objectstack-query/rules/filters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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) } }
```
Expand All @@ -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

Expand Down
Loading