|
1 | 1 | --- |
2 | 2 | title: ObjectQL query syntax — the full specification |
3 | 3 | 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 |
5 | 5 | --- |
6 | 6 |
|
7 | 7 | import { Search, Filter, GitMerge, BarChart } from 'lucide-react'; |
@@ -600,29 +600,72 @@ have kept a `null`-valued field. |
600 | 600 |
|
601 | 601 | ### Filtering Across Relationships |
602 | 602 |
|
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 | +``` |
612 | 612 |
|
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: |
614 | 643 |
|
615 | 644 | ```typescript |
| 645 | +// Past the cap: read the related ids, then match the local key |
616 | 646 | const techAccounts = await engine.find('account', { |
617 | 647 | where: { industry: 'tech', annual_revenue: { $gt: 1000000 } }, |
618 | 648 | fields: ['id'], |
619 | 649 | }); |
620 | 650 |
|
621 | 651 | 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) } }, |
623 | 663 | }); |
624 | 664 | ``` |
625 | 665 |
|
| 666 | +On a `multiple: true` relation, match the ids with one `$contains` per id under an |
| 667 | +`$or` instead of `$in`. |
| 668 | + |
626 | 669 | ### Filtering on a `formula` field |
627 | 670 |
|
628 | 671 | <Callout type="warn"> |
|
0 commit comments