Query Parameters
Filter, paginate, sort, search, and select fields in API responses. Reference for all query parameter operators.
The generated API supports filtering, pagination, sorting, field selection, search, and total count via query parameters on GET list endpoints.
Filter Operators
| Operator | Query Param | SQL Equivalent |
|---|---|---|
| Equals | ?field=value | WHERE field = value |
| Not equals | ?field.ne=value | WHERE field != value |
| Greater than | ?field.gt=value | WHERE field > value |
| Greater or equal | ?field.gte=value | WHERE field >= value |
| Less than | ?field.lt=value | WHERE field < value |
| Less or equal | ?field.lte=value | WHERE field <= value |
| Pattern match | ?field.like=value | WHERE field LIKE '%value%' |
| In list | ?field.in=a,b,c | WHERE field IN ('a','b','c') |
Examples
# Filter by status
GET /api/v1/jobs?status=open
# Range query
GET /api/v1/jobs?salaryMin.gte=100000&salaryMax.lte=200000
# Pattern matching
GET /api/v1/jobs?title.like=Engineer
# Multiple values
GET /api/v1/jobs?department.in=Engineering,Design,ProductUnknown filters are rejected, not ignored
A filter the handler cannot apply returns 400 (VALIDATION_INVALID_INPUT).
It is never dropped:
| Request | Result |
|---|---|
?guestId=g_1 where guestId isn't a column | 400 — Unknown filter field "guestId" |
?status=open on a view whose query.filterable omits status | 400 — Field "status" is not filterable on this view |
?title.matches=x (no such operator) | 400 — Unknown filter operator "matches" |
?ssn=123 where ssn is masked for your role | 400 — see Masking |
?resolvedAt.gte=yesterday on a Postgres timestamp column | 400 — "yesterday" is not an ISO 8601 timestamp |
?resolvedAt.like=2026 on a Postgres timestamp column | 400 — use eq, ne, gt, gte, lt, lte or in |
A column named like a list parameter (sort, order, limit, offset,
search, fields, total, include, starting_after, ending_before) is
not filterable: ?sort= is always the sort control, and the OpenAPI spec omits
the column filter rather than declaring the parameter twice. Filter it through
a view or rename the column.
Silently dropping a predicate would return every row the firewall allows
while still reading as a scoped list — a typo'd or join-table field turns a
narrow query into a full dump. The same rule closes ?search= and ?sort=.
Filtering on a relationship that lives in a join table is not a column filter.
GET /travel-bookings?guestId=... cannot work when membership is
travel_booking_guests — expose a view or an include for it instead.
Pagination
| Parameter | Default | Description |
|---|---|---|
limit | 50 | Number of records to return (min: 1, max: 100) |
offset | 0 | Number of records to skip |
GET /api/v1/jobs?limit=25&offset=50read.pageSize and read.maxPageSize are accepted and validated in a resource
definition, but the generated collection handler clamps ?limit= to the
compiler-wide 50 / 1 / 100 defaults — the per-resource values are not
currently wired into the emitted route.
Response Shape
{
"data": [ /* records */ ],
"view": null,
"pagination": {
"count": 12,
"page": 3,
"pageSize": 25,
"hasMore": true,
"nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI0…",
"prevCursor": null
}
}view— the named projection behind the response;nullfor the default collection readcount— number of records returned on this pagepage/pageSize— derived from?offset=and?limit=hasMore— whether more rows exist in the queried direction. Without?total=trueit comes from alimit + 1over-fetch probe, so it never lies on a final page that exactly fills the limit; with?total=trueit is page arithmetic (page < totalPages) on offset pages, and the probe again on cursor pages.nextCursor/prevCursor— keyset cursors; pass them back as?starting_after=and?ending_before=. Treat them as opaque, but they are encoded, not encrypted: they carry the boundary row's sort-key values. Either can benull— including when the sort includes a masked column the caller may sort by (query.roles) but not see (show.roles), or a column the response does not carry (a view sorting on a field it doesn't list, or?fields=without the sort key); page with?offset=in that case. A cursor carries a timestamp sort key at millisecond precision, so sort on a column that stores milliseconds: the managedcreatedAt/modifiedAt/deletedAtdo on both dialects (Postgres stores them astimestamptz(3)). A Postgres timestamp column you declare with microsecond precision and fill withnow()can repeat or skip rows across pages.total/totalPages— present only when the request passed?total=true(see below)
There is no limit or offset member in pagination.
Sorting
Sort by one or more fields. Use the - prefix for descending order.
Multi-Sort (Recommended)
# Sort by status ascending, then createdAt descending
GET /api/v1/jobs?sort=status,-createdAt
# Single field descending
GET /api/v1/jobs?sort=-createdAt
# Multiple fields
GET /api/v1/jobs?sort=department,-salaryMax,title| Prefix | Direction |
|---|---|
| (none) | Ascending |
- | Descending |
Legacy Format
The original sort + order format is still supported for backwards compatibility:
GET /api/v1/jobs?sort=title&order=asc| Parameter | Values | Default | Description |
|---|---|---|---|
sort | Any column name | createdAt | Field to sort by |
order | asc, desc | desc | Sort direction |
When the multi-sort format is detected (comma or - prefix), the order parameter is ignored.
A client-supplied ?sort= naming an unknown column — or one outside a view's
query.sortable — returns 400, for the same reason unknown filters do: rows
would come back in some other order that reads as the requested one. Array
columns, and q.decimal() columns on D1 (stored as TEXT, so they would sort
as strings), are never sortable — see
Decimals, dates, 64-bit integers, and arrays. A view's
own defaultSort fallback is config, not caller input, and is never rejected.
Field Selection
Select which columns to return using ?fields=. Available on LIST and GET routes (not Views — they define their own field set).
# Return only id, title, and status
GET /api/v1/jobs?fields=id,title,status
# Combine with other query params
GET /api/v1/jobs?fields=id,title,status&status=open&sort=-createdAt
# Single record
GET /api/v1/jobs/job_123?fields=id,title,salaryMin,salaryMaxAll declared columns are available, including system columns (id, organizationId, createdAt, modifiedAt, etc.). An unknown field name is a 400 Problem (Unknown field "<name>"), never silently dropped. An empty ?fields= returns all columns. Views ignore ?fields= — they project their own fields.
Security: Masking still applies to selected fields. Requesting ?fields=ssn will return the masked value, not the raw data.
Total Count
Get the total number of matching records across all pages by adding ?total=true. Available on LIST and VIEW routes. The count ignores ?starting_after= / ?ending_before=: a cursor page reports the same total as the first page.
GET /api/v1/jobs?status=open&total=true{
"data": [ /* 25 records */ ],
"view": null,
"pagination": {
"total": 142,
"count": 25,
"page": 1,
"pageSize": 25,
"totalPages": 6,
"hasMore": true,
"nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI0…",
"prevCursor": null
}
}This is opt-in because it runs an additional COUNT(*) query. Only use it when you need the total (e.g., for pagination UI).
Search
Full-text search across all text columns using ?search=. Available on LIST and VIEW routes.
# Search across all text fields
GET /api/v1/jobs?search=engineer
# Combine with filters
GET /api/v1/jobs?search=engineer&status=openThe search generates an OR'd LIKE condition across all text() columns in your schema:
WHERE (title LIKE '%engineer%' OR department LIKE '%engineer%')Only columns defined with text() in your Drizzle schema are searchable. Non-text columns (integers, timestamps, UUIDs, blobs) are automatically excluded.
Complete Example
Combine all query parameters together:
GET /api/v1/jobs?fields=id,title,status,salaryMin&status=open&salaryMin.gte=100000&search=engineer&sort=salaryMin,-createdAt&limit=10&offset=20&total=trueThis request:
- Selects only
id,title,status,salaryMinfields - Filters to open jobs with salaryMin >= 100000
- Searches text columns for "engineer"
- Sorts by salaryMin ascending, then createdAt descending
- Paginates with 10 results starting at offset 20
- Counts total matching records
Parameter Summary
| Parameter | Applies To | Description |
|---|---|---|
limit | LIST, VIEW | Page size (default: 50, max: 100) |
offset | LIST, VIEW | Skip N records |
sort | LIST, VIEW | Sort fields (comma-separated, - prefix for desc) |
order | LIST, VIEW | Legacy sort direction (asc or desc) |
fields | LIST, GET | Comma-separated column names to return |
total | LIST, VIEW | Set to true to include total count |
search | LIST, VIEW | Search text across all text columns |
field=value | LIST, VIEW | Filter by exact match |
field.op=value | LIST, VIEW | Filter with operator (gt, gte, lt, lte, ne, like, in) |
starting_after | LIST, VIEW | Keyset cursor (from nextCursor) — rows strictly after the boundary |
ending_before | LIST, VIEW | Keyset cursor (from prevCursor) — rows strictly before the boundary |
include | LIST, VIEW, GET | FK-graph embedding (allowlisted — see below) |
fields[<fk>] | LIST, VIEW, GET | Sparse fields for an embedded include target |
Include & Sparse Fields
A resource can allowlist FK columns for FK-graph embedding, collapsing client-side N+1 fetches:
// features/orders/orders.ts
import { feature, q } from "@quickback/compiler";
export default feature("orders", {
columns: {
id: q.id(),
total: q.int().required(),
organizationId: q.scope("organization"),
...q.audit(),
...q.softDelete(),
},
// include is a read of the TARGET resource — embedding orders rows
// requires orders to declare its own read gate (fail-closed).
read: { access: { roles: ["member", "admin"] } },
});
// features/orders/order-items.ts
import { feature, q } from "@quickback/compiler";
import orders from "./orders";
export default feature("orderItems", {
columns: {
id: q.id(),
orderId: q.text().required().references(() => orders.id),
organizationId: q.scope("organization"),
...q.audit(),
...q.softDelete(),
},
read: {
access: { roles: ["member", "admin"] },
include: ["orderId"], // FK columns eligible for ?include=
},
});GET /api/v2/order-items?include=orderId&fields[orderId]=id,total{
"data": [ { "id": "oi_1", "orderId": "ord_9" } ],
"included": { "orderId": { "ord_9": { "id": "ord_9", "total": 129 } } }
}Embedded rows land in a top-level included map keyed by FK column, then
by target primary key — row objects are never mutated.
Security semantics (all enforced, fail-closed):
- An embedded read is a read of the target resource: the target's own
read.accessis checked before any fetch (403 on failure, never silent omission), the child query runs under the target's tenant firewall on the caller's own database handle, and embedded rows pass through the target's masking before attachment. - Targets whose
read.accessis record-aware (function access,record:conditions,relationshiporfgaarms) are refused at compile time — embedding them would skip their post-fetch record checks. - A tenant-scoped target with no resolvable firewall clause is a compile
error; a global reference table must opt out explicitly with
firewall: { exception: true }. Even then, the child fetch still calls the target's own firewall helper — for a soft-deleting reference table that helper is the soft-delete predicate, so an embed never serves rows the target's own routes refuse. ?include=on a resource (or view) without an allowlist is a 400. Explicit views declare their owninclude: [...]— nothing is expandable undeclared. Depth is exactly 1. A view that declaresincludemust keep each listed FK column in itsfieldsprojection (compile error otherwise — the embedded lookup pivots on that column).
Strict fields: unknown names in bare ?fields= and in
fields[<fk>]= are rejected with a 400 Problem. A ?fields= projection that
drops the FK column of a requested ?include= is also a 400 —
include=orderId requires "orderId" in ?fields= — never a silently-empty
included slot.