docs: a to-do app as the example schema in the docs, fixtures and tests
This commit is contained in:
+34
-34
@@ -6,10 +6,10 @@ The demo schema has four resources:
|
||||
|
||||
| Resource | Columns | Relations |
|
||||
|---|---|---|
|
||||
| `users` | id, name, email, limit, manager_id | `manager` (one user), `users` (the users they manage), `assets` |
|
||||
| `assets` | id, name, status, owner_id, cost, active, created_at | `owner` (one user), `tags` (many, through `asset_tags`), `incidents` (many) |
|
||||
| `tags` | id, name | `assets` (many, through `asset_tags`) |
|
||||
| `incidents` | id, title, asset_id, severity, opened_on | `asset` (one asset) |
|
||||
| `users` | id, name, email, limit, manager_id | `manager` (one user), `users` (the users they manage), `todos` |
|
||||
| `todos` | id, name, status, owner_id, estimate, pinned, created_at | `owner` (one user), `tags` (many, through `todo_tags`), `steps` (many) |
|
||||
| `tags` | id, name | `todos` (many, through `todo_tags`) |
|
||||
| `steps` | id, title, todo_id, priority, due_on | `todo` (one todo) |
|
||||
|
||||
`users.password_hash` exists in the table but is never served: its name looks sensitive, so it is hidden.
|
||||
|
||||
@@ -20,21 +20,21 @@ Examples are shown unencoded. A client encodes them like any query string: `curl
|
||||
Without `select`, a row has every visible column. With it, only the listed ones, in that order.
|
||||
|
||||
```http
|
||||
GET /assets?select=id,name&status=eq.open&order=id
|
||||
GET /todos?select=id,name&status=eq.open&order=id
|
||||
```
|
||||
|
||||
```json
|
||||
[{ "id": 1, "name": "vpn gateway" }, { "id": 2, "name": "mail relay" }, { "id": 4, "name": "payroll db" }]
|
||||
[{ "id": 1, "name": "water plants" }, { "id": 2, "name": "file taxes" }, { "id": 4, "name": "pay rent" }]
|
||||
```
|
||||
|
||||
One row is read by its key. It answers 404 when the row does not exist or is not visible.
|
||||
|
||||
```http
|
||||
GET /assets/1?select=id,name,owner(name)
|
||||
GET /todos/1?select=id,name,owner(name)
|
||||
```
|
||||
|
||||
```json
|
||||
{ "id": 1, "name": "vpn gateway", "owner": { "name": "Ada" } }
|
||||
{ "id": 1, "name": "water plants", "owner": { "name": "Ada" } }
|
||||
```
|
||||
|
||||
## Related rows: embedding
|
||||
@@ -42,7 +42,7 @@ GET /assets/1?select=id,name,owner(name)
|
||||
`relation(columns)` puts related rows inside each row: an object (or `null`) for a to-one relation, an array for a to-many one. `relation(*)` takes every visible column. Embeds nest up to the depth limit (2 by default).
|
||||
|
||||
```http
|
||||
GET /assets?status=eq.open&select=id,owner(name,manager(name))
|
||||
GET /todos?status=eq.open&select=id,owner(name,manager(name))
|
||||
```
|
||||
|
||||
```json
|
||||
@@ -70,15 +70,15 @@ GET /users?select=id,name,manager(name),users(name)
|
||||
An embedded array is ordered by the related key, unless `relation.order` says otherwise. `relation.limit` and `relation.offset` page it. Without a limit it holds at most `maxPage` rows (1000).
|
||||
|
||||
```http
|
||||
GET /assets?select=id,incidents(title)&incidents.order=severity.desc&incidents.limit=1&order=incidents.count.desc
|
||||
GET /todos?select=id,steps(title)&steps.order=priority.desc&steps.limit=1&order=steps.count.desc
|
||||
```
|
||||
|
||||
```json
|
||||
[
|
||||
{ "id": 1, "incidents": [{ "title": "Brute force" }] },
|
||||
{ "id": 2, "incidents": [{ "title": "Spoofed mail" }] },
|
||||
{ "id": 3, "incidents": [] },
|
||||
{ "id": 4, "incidents": [] }
|
||||
{ "id": 1, "steps": [{ "title": "Buy a watering can" }] },
|
||||
{ "id": 2, "steps": [{ "title": "Find the receipts" }] },
|
||||
{ "id": 3, "steps": [] },
|
||||
{ "id": 4, "steps": [] }
|
||||
]
|
||||
```
|
||||
|
||||
@@ -96,16 +96,16 @@ A filter is `column=operator.value`. Every filter must hold.
|
||||
| `ov.{a,b}`, `cs.{a,b}`, `cd.{a,b}` | the array overlaps, contains, or is contained by the list | array columns (Postgres) |
|
||||
| `is.null`, `is.true`, `is.false` | null, or a boolean value | `null` any column, `true` and `false` booleans |
|
||||
|
||||
`not.` before the operator negates it: `status=not.in.(retired,lost)`.
|
||||
`not.` before the operator negates it: `status=not.in.(done,cancelled)`.
|
||||
|
||||
A value is checked against the column's type before any SQL runs: `id=eq.abc` answers `invalid_value`. An operator that does not fit the column is refused:
|
||||
|
||||
```http
|
||||
GET /assets?cost=like.1*
|
||||
GET /todos?estimate=like.1*
|
||||
```
|
||||
|
||||
```json
|
||||
{ "error": "bad_operator", "message": "like on cost" }
|
||||
{ "error": "bad_operator", "message": "like on estimate" }
|
||||
```
|
||||
|
||||
### Through relations
|
||||
@@ -117,29 +117,29 @@ A filter can name a column of a related resource: `relation.column=operator.valu
|
||||
- **With `not.`** on a relation path: no related row may match.
|
||||
|
||||
```http
|
||||
GET /assets?select=id,name&owner.name=ilike.*a*
|
||||
GET /todos?select=id,name&owner.name=ilike.*a*
|
||||
```
|
||||
|
||||
```json
|
||||
[{ "id": 1, "name": "vpn gateway" }, { "id": 2, "name": "mail relay" }]
|
||||
[{ "id": 1, "name": "water plants" }, { "id": 2, "name": "file taxes" }]
|
||||
```
|
||||
|
||||
```http
|
||||
GET /assets?select=id,name,owner(name),tags(name)&tags.name=in.(pci,gdpr)&order=owner.name.desc
|
||||
GET /todos?select=id,name,owner(name),tags(name)&tags.name=in.(work,home)&order=owner.name.desc
|
||||
```
|
||||
|
||||
```json
|
||||
[
|
||||
{ "id": 4, "name": "payroll db", "owner": { "name": "Linus" }, "tags": [{ "name": "pci" }, { "name": "gdpr" }] },
|
||||
{ "id": 2, "name": "mail relay", "owner": { "name": "Grace" }, "tags": [{ "name": "gdpr" }] },
|
||||
{ "id": 1, "name": "vpn gateway", "owner": { "name": "Ada" }, "tags": [{ "name": "pci" }, { "name": "internal" }] }
|
||||
{ "id": 4, "name": "pay rent", "owner": { "name": "Linus" }, "tags": [{ "name": "work" }, { "name": "home" }] },
|
||||
{ "id": 2, "name": "file taxes", "owner": { "name": "Grace" }, "tags": [{ "name": "home" }] },
|
||||
{ "id": 1, "name": "water plants", "owner": { "name": "Ada" }, "tags": [{ "name": "work" }, { "name": "urgent" }] }
|
||||
]
|
||||
```
|
||||
|
||||
Assets with no `pci` tag, including assets with no tag at all:
|
||||
Todos with no `work` tag, including todos with no tag at all:
|
||||
|
||||
```http
|
||||
GET /assets?select=id&tags.name=not.eq.pci
|
||||
GET /todos?select=id&tags.name=not.eq.work
|
||||
```
|
||||
|
||||
```json
|
||||
@@ -149,7 +149,7 @@ GET /assets?select=id&tags.name=not.eq.pci
|
||||
`relation=is.empty` keeps rows with no related row, `relation=not.is.empty` rows with at least one. `is.null` means the same.
|
||||
|
||||
```http
|
||||
GET /assets?select=id&tags=is.empty
|
||||
GET /todos?select=id&tags=is.empty
|
||||
```
|
||||
|
||||
```json
|
||||
@@ -161,7 +161,7 @@ GET /assets?select=id&tags=is.empty
|
||||
`or=(term,term)` holds when one term does. A term is `path.operator.value`, with the same operators and relation paths. `and=(...)` groups terms inside an `or`, one level deep. `not.or` and `not.and` negate the group.
|
||||
|
||||
```http
|
||||
GET /assets?select=id&or=(status.eq.retired,owner.name.eq.Linus)
|
||||
GET /todos?select=id&or=(status.eq.done,owner.name.eq.Linus)
|
||||
```
|
||||
|
||||
```json
|
||||
@@ -173,16 +173,16 @@ GET /assets?select=id&or=(status.eq.retired,owner.name.eq.Linus)
|
||||
`order=column.direction,...` with `asc` (default) or `desc`, and optionally `nullsfirst` or `nullslast`.
|
||||
|
||||
- **To-one paths:** sort by a related column: `order=owner.name.desc`.
|
||||
- **To-many relations:** sort by how many related rows each row has: `order=incidents.count.desc`. A to-many relation cannot be sorted by one of its columns, because a row has many values.
|
||||
- **To-many relations:** sort by how many related rows each row has: `order=steps.count.desc`. A to-many relation cannot be sorted by one of its columns, because a row has many values.
|
||||
- **Stable pages:** the key is appended to every order.
|
||||
- **Nulls:** they sort last ascending and first descending, as in Postgres, on every database.
|
||||
|
||||
```http
|
||||
GET /assets?select=id,cost&order=cost.desc
|
||||
GET /todos?select=id,estimate&order=estimate.desc
|
||||
```
|
||||
|
||||
```json
|
||||
[{ "id": 3, "cost": null }, { "id": 4, "cost": 4000 }, { "id": 1, "cost": 1200.5 }, { "id": 2, "cost": 300 }]
|
||||
[{ "id": 3, "estimate": null }, { "id": 4, "estimate": 4 }, { "id": 1, "estimate": 2.5 }, { "id": 2, "estimate": 1 }]
|
||||
```
|
||||
|
||||
## Pages and counts
|
||||
@@ -190,7 +190,7 @@ GET /assets?select=id,cost&order=cost.desc
|
||||
`limit` (default 50) and `offset` page a read. A limit over 1000 is refused, never silently reduced:
|
||||
|
||||
```http
|
||||
GET /assets?limit=1001
|
||||
GET /todos?limit=1001
|
||||
```
|
||||
|
||||
```json
|
||||
@@ -200,7 +200,7 @@ GET /assets?limit=1001
|
||||
Every list answers a `Content-Range` header: `0-49/*`, or `*/0` for an empty page. `count=exact` replaces `*` with the total, and `count=estimated` with the planner's estimate where the database has one.
|
||||
|
||||
```http
|
||||
GET /assets?select=id&limit=2&offset=1&count=exact
|
||||
GET /todos?select=id&limit=2&offset=1&count=exact
|
||||
```
|
||||
|
||||
```json
|
||||
@@ -214,11 +214,11 @@ The answer above comes with `Content-Range: 1-2/4`.
|
||||
`count()`, and `column.sum()`, `.avg()`, `.min()`, `.max()`, group by every plain column selected. `sum` and `avg` take numbers; `min` and `max` anything orderable. An aggregate read cannot embed relations, and orders by its selected columns only.
|
||||
|
||||
```http
|
||||
GET /assets?select=status,count(),cost.sum()&order=status
|
||||
GET /todos?select=status,count(),estimate.sum()&order=status
|
||||
```
|
||||
|
||||
```json
|
||||
[{ "status": "open", "count": 3, "sum": 5500.5 }, { "status": "retired", "count": 1, "sum": null }]
|
||||
[{ "status": "done", "count": 1, "sum": null }, { "status": "open", "count": 3, "sum": 7.5 }]
|
||||
```
|
||||
|
||||
## Names that are keywords
|
||||
|
||||
Reference in New Issue
Block a user