Filtering & Pagination
The query format every generated GET accepts - filters, sorting, projection, paging - and the helpers that let you reuse it anywhere.
Every GET endpoint generated by createCRUD accepts a rich query for filtering, sorting, projection and paging. The /count endpoint accepts the filtering part of it.
You do not have to build any of this yourself, and the generated SDK and Thunder UI both speak it already.
Query Parameters
Query strings are parsed with qs, so nested values use bracket notation:
GET /users/api?filters[name][$eq]=john&limit=50&sort[createdAt]=-1| Param | Default | Notes |
|---|---|---|
filters | - | The client filter object (see below) |
subFilters | - | A second, independently applied filter layer |
offset | 0 | $skip |
limit | 2000 | $limit; minimum 1, maximum 2000 |
sort | { _id: -1 } | MongoDB sort object; values between -1 and 1 |
project | - | MongoDB projection; a value may also be { $slice: n } or { $slice: [n, n] } |
/count accepts only filters and subFilters.
limit is capped at 2000. If you need more, page through with offset rather than trying to raise it.
The Filter Format
A filter value is either a plain string or a tagged object. The tag is how a client sends a typed value over a query string, which is otherwise all text:
{
"name": { "$regex": { "type": "regex", "value": "^jo", "options": { "regexFlags": "i" } } },
"user": { "$eq": { "type": "objectId", "value": "68f0a1..." } },
"age": { "$gte": { "type": "number", "value": "18" } },
"verified": { "$eq": { "type": "boolean", "value": "true" } }, // "true" / "1" are truthy
"deletedAt": { "$exists": { "type": "boolean", "value": "false" } },
"parent": { "$eq": { "type": "null", "value": "" } },
"role": { "$in": ["admin", "owner"] },
"banned": { "$not": { "$eq": "true" } },
// A date takes an ISO string, or `nldate:` + a natural-language expression
"createdAt": { "$gte": { "type": "date", "value": "nldate:7 days ago" } },
"expiresAt": { "$lte": { "type": "date", "value": "nldate:next friday" } }
}Value types
string, number, boolean, objectId, date, regex, null.
Operators
$exists, $eq, $ne, $gt, $gte, $lt, $lte, $regex, $in, $nin, $all, $mod, and $not wrapping any expression.
Combine whole filter objects with { "$and": [...] } and { "$or": [...] }.
$mod takes a raw [divisor, remainder] tuple, not a tagged value.
Dates in plain English
nldate: accepts a natural-language expression, which is far easier to send from a UI than a computed timestamp:
?filters[createdAt][$gte][type]=date&filters[createdAt][$gte][value]=nldate:7 days agoA $regex pattern longer than 200 characters is rejected with a 400. The pattern comes from the client and is executed by mongod, so the limit is a safety measure - keep patterns short.
Reusing the Filter Layer
The filter layer is exported separately, so the same client filter format can be used outside a CRUD endpoint. Reach for these instead of inventing a second filter format.
All are exported from @/core/utils/createCRUD.ts:
| Helper | Use |
|---|---|
normalizeFilters(filters) | Convert a client filter into a MongoDB filter, resolving objectId, date, nldate:, regex, … |
normalizeFilterExpression(value) | The same, for a single expression |
testFilters(filters, data) | Evaluate a client filter against a plain object in memory, with no database round trip |
paginatedAggregation(query) | Build the $match / $sort / $skip / $limit / $project stages for a parsed pagination query |
Schemas are exported too, for validating a query yourself: $pagination, $filter, $basicFilter, $multiFilter, $expression, $clientValue (each also available as …Schema).
testFilters for access rules
testFilters is the interesting one: because it runs in memory, you can use a client-supplied filter as a policy without ever hitting the database.
import { testFilters } from "@/core/utils/createCRUD.ts";
// Does this record satisfy the rule attached to the user's role?
if (!testFilters(rule.conditions, record)) {
throw Response.forbidden("Not allowed for this record");
}paginatedAggregation in a custom route
When you hand-roll an aggregation but still want the standard paging behaviour:
import { paginatedAggregation } from "@/core/utils/createCRUD.ts";
const stages = paginatedAggregation(query);
const results = await orderModel
.aggregate([{ $match: { tenant } }, ...stages])
.toArray();Related
- createCRUD - where these endpoints come from
- Database Indexes - index the fields you filter and sort on
- Schema Metadata -
canFilterdecides which fields Thunder UI offers as filters