247 lines
7.9 KiB
Markdown
247 lines
7.9 KiB
Markdown
# Query grammar
|
|
|
|
A read is a GET with a query string. Every example on this page runs against the [demo schema](../conformance/fixtures/postgres.sql) in the test suite, against every database, so the answers shown are the answers you get.
|
|
|
|
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.password_hash` exists in the table but is never served: its name looks sensitive, so it is hidden.
|
|
|
|
Examples are shown unencoded. A client encodes them like any query string: `curl -g` sends them as written.
|
|
|
|
## Choosing columns: `select`
|
|
|
|
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
|
|
```
|
|
|
|
```json
|
|
[{ "id": 1, "name": "vpn gateway" }, { "id": 2, "name": "mail relay" }, { "id": 4, "name": "payroll db" }]
|
|
```
|
|
|
|
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)
|
|
```
|
|
|
|
```json
|
|
{ "id": 1, "name": "vpn gateway", "owner": { "name": "Ada" } }
|
|
```
|
|
|
|
## Related rows: embedding
|
|
|
|
`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))
|
|
```
|
|
|
|
```json
|
|
[
|
|
{ "id": 1, "owner": { "name": "Ada", "manager": null } },
|
|
{ "id": 2, "owner": { "name": "Grace", "manager": { "name": "Ada" } } },
|
|
{ "id": 4, "owner": { "name": "Linus", "manager": { "name": "Ada" } } }
|
|
]
|
|
```
|
|
|
|
A relation of a table to itself works the same way. Here `users` is the list of people each user manages; rename it with a [relation override](./discovery.md#relations) if `reports` reads better.
|
|
|
|
```http
|
|
GET /users?select=id,name,manager(name),users(name)
|
|
```
|
|
|
|
```json
|
|
[
|
|
{ "id": 1, "name": "Ada", "manager": null, "users": [{ "name": "Grace" }, { "name": "Linus" }] },
|
|
{ "id": 2, "name": "Grace", "manager": { "name": "Ada" }, "users": [] },
|
|
{ "id": 3, "name": "Linus", "manager": { "name": "Ada" }, "users": [] }
|
|
]
|
|
```
|
|
|
|
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
|
|
```
|
|
|
|
```json
|
|
[
|
|
{ "id": 1, "incidents": [{ "title": "Brute force" }] },
|
|
{ "id": 2, "incidents": [{ "title": "Spoofed mail" }] },
|
|
{ "id": 3, "incidents": [] },
|
|
{ "id": 4, "incidents": [] }
|
|
]
|
|
```
|
|
|
|
## Filtering
|
|
|
|
A filter is `column=operator.value`. Every filter must hold.
|
|
|
|
| Operator | Means | Applies to |
|
|
|---|---|---|
|
|
| `eq`, `neq` | equal, not equal | any comparable column |
|
|
| `gt`, `gte`, `lt`, `lte` | greater or less than | text, numbers, dates and times |
|
|
| `in.(a,b,c)` | one of the list; quote an item holding a comma: `in.("a,b",c)` | any comparable column |
|
|
| `like`, `ilike` | pattern, `*` for any text; `ilike` ignores case | text |
|
|
| `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)`.
|
|
|
|
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*
|
|
```
|
|
|
|
```json
|
|
{ "error": "bad_operator", "message": "like on cost" }
|
|
```
|
|
|
|
### Through relations
|
|
|
|
A filter can name a column of a related resource: `relation.column=operator.value`.
|
|
|
|
- **To-one** (`owner.name`): the related row must match.
|
|
- **To-many** and **many-to-many** (`tags.name`): at least one related row must match.
|
|
- **With `not.`** on a relation path: no related row may match.
|
|
|
|
```http
|
|
GET /assets?select=id,name&owner.name=ilike.*a*
|
|
```
|
|
|
|
```json
|
|
[{ "id": 1, "name": "vpn gateway" }, { "id": 2, "name": "mail relay" }]
|
|
```
|
|
|
|
```http
|
|
GET /assets?select=id,name,owner(name),tags(name)&tags.name=in.(pci,gdpr)&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" }] }
|
|
]
|
|
```
|
|
|
|
Assets with no `pci` tag, including assets with no tag at all:
|
|
|
|
```http
|
|
GET /assets?select=id&tags.name=not.eq.pci
|
|
```
|
|
|
|
```json
|
|
[{ "id": 2 }, { "id": 3 }]
|
|
```
|
|
|
|
`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
|
|
```
|
|
|
|
```json
|
|
[{ "id": 3 }]
|
|
```
|
|
|
|
### Either-or: `or` and `and`
|
|
|
|
`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)
|
|
```
|
|
|
|
```json
|
|
[{ "id": 3 }, { "id": 4 }]
|
|
```
|
|
|
|
## Ordering: `order`
|
|
|
|
`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.
|
|
- **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
|
|
```
|
|
|
|
```json
|
|
[{ "id": 3, "cost": null }, { "id": 4, "cost": 4000 }, { "id": 1, "cost": 1200.5 }, { "id": 2, "cost": 300 }]
|
|
```
|
|
|
|
## Pages and counts
|
|
|
|
`limit` (default 50) and `offset` page a read. A limit over 1000 is refused, never silently reduced:
|
|
|
|
```http
|
|
GET /assets?limit=1001
|
|
```
|
|
|
|
```json
|
|
{ "error": "limit_exceeded", "message": "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
|
|
```
|
|
|
|
```json
|
|
[{ "id": 2 }, { "id": 3 }]
|
|
```
|
|
|
|
The answer above comes with `Content-Range: 1-2/4`.
|
|
|
|
## Totals: aggregates
|
|
|
|
`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
|
|
```
|
|
|
|
```json
|
|
[{ "status": "open", "count": 3, "sum": 5500.5 }, { "status": "retired", "count": 1, "sum": null }]
|
|
```
|
|
|
|
## Names that are keywords
|
|
|
|
`select`, `order`, `limit`, `offset`, `count`, `or` and `and` are reserved, and so are `order`, `limit` and `offset` after a relation name. A double-quoted name is always a column, never a keyword, wherever it appears: `"limit"=gte.2`, `select="order"`, `order="limit".desc`, `tags."order"=eq.x`.
|
|
|
|
```http
|
|
GET /users?select=id,"limit"&"limit"=gte.2
|
|
```
|
|
|
|
```json
|
|
[{ "id": 1, "limit": 5 }, { "id": 3, "limit": 2 }]
|
|
```
|
|
|
|
## What is never served
|
|
|
|
A column that is hidden answers exactly as a column that does not exist, so a client cannot learn it is there:
|
|
|
|
```http
|
|
GET /users?select=password_hash
|
|
```
|
|
|
|
```json
|
|
{ "error": "unknown_column", "message": "password_hash" }
|
|
```
|
|
|
|
Every error code is listed in [errors](./errors.md).
|