# Query Parameters

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

```bash
# 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,Product
```

### Unknown 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](/define/masking) |

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 |

```bash
GET /api/v1/jobs?limit=25&offset=50
```

`read.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

```json
{
  "data": [ /* records */ ],
  "view": null,
  "pagination": {
    "count": 12,
    "page": 3,
    "pageSize": 25,
    "hasMore": true,
    "nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI0…",
    "prevCursor": null
  }
}
```

- `view` — the named projection behind the response; `null` for the default collection read
- `count` — number of records returned on this page
- `page` / `pageSize` — derived from `?offset=` and `?limit=`
- `hasMore` — whether more rows exist in the queried direction. Without `?total=true` it comes from a `limit + 1` over-fetch probe, so it never lies on a final page that exactly fills the limit; with `?total=true` it is page arithmetic (`page < totalPages`).
- `nextCursor` / `prevCursor` — opaque keyset cursors; pass them back as `?starting_after=` and `?ending_before=`. Either can be `null`.
- `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)

```bash
# 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:

```bash
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. 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).

```bash
# 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,salaryMax
```

All 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.

```bash
GET /api/v1/jobs?status=open&total=true
```

```json
{
  "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.

```bash
# Search across all text fields
GET /api/v1/jobs?search=engineer

# Combine with filters
GET /api/v1/jobs?search=engineer&status=open
```

The search generates an OR'd `LIKE` condition across all `text()` columns in your schema:

```sql
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:

```bash
GET /api/v1/jobs?fields=id,title,status,salaryMin&status=open&salaryMin.gte=100000&search=engineer&sort=salaryMin,-createdAt&limit=10&offset=20&total=true
```

This request:
1. **Selects** only `id`, `title`, `status`, `salaryMin` fields
2. **Filters** to open jobs with salaryMin >= 100000
3. **Searches** text columns for "engineer"
4. **Sorts** by salaryMin ascending, then createdAt descending
5. **Paginates** with 10 results starting at offset 20
6. **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 (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:

```ts title="quickback/features/orders/order-items.ts"
import { feature, q } from "@quickback/compiler";
import { orders } from "../orders/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=
  },
});
```

```bash
GET /api/v2/order-items?include=orderId&fields[orderId]=id,total
```

```json
{
  "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.access` is 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.access` is **record-aware** (function access, `record:`
  conditions, `relationship` or `fga` arms) 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 own `include: [...]` — nothing is expandable
  undeclared. Depth is exactly 1. A view that declares `include` must keep
  each listed FK column in its `fields` projection (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.
