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,ProductPagination
| 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=50The default limit can be configured per-resource in your read definition:
read: {
access: { roles: ["recruiter"] },
pageSize: 25, // Default limit
maxPageSize: 100, // Maximum allowed limit
},Response Shape
{
"data": [ /* records */ ],
"pagination": {
"limit": 25,
"offset": 50,
"count": 12
}
}count— number of records returned on this pagetotal— total matching records across all pages (only when?total=true, see below)
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.
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 columns are available including system columns (id, organizationId, createdAt, modifiedAt, etc.). Invalid field names are silently ignored. If no valid fields are provided, all columns are returned.
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.
GET /api/v1/jobs?status=open&total=true{
"data": [ /* 25 records */ ],
"pagination": {
"limit": 25,
"offset": 0,
"count": 25,
"total": 142
}
}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 | Opaque keyset cursor — rows strictly after the boundary |
ending_before | LIST, VIEW | Opaque keyset cursor — rows strictly before the boundary |
include | LIST, VIEW, GET | FK-graph embedding (contract v2, allowlisted — see below) |
fields[<fk>] | LIST, VIEW, GET | Sparse fields for an embedded include target (contract v2) |
Include & Sparse Fields (contract v2)
On the v2 contract a resource can allowlist FK columns for FK-graph embedding, collapsing client-side N+1 fetches:
export default feature("orderItems", {
columns: {
id: q.id(),
orderId: q.text().required().references(() => orders.id),
organizationId: q.scope("organization"),
},
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 on v2: unknown names in bare ?fields= and in
fields[<fk>]= are rejected with a 400 Problem. (Pinned v1 keeps its
historical silent-drop for bare ?fields=.) 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.