NestJS query string filters for Drizzle
Parse NestJS query string filters for Drizzle with parseQuery. It turns ?age[gte]=18&status=active into typed filters, pagination and options.
To turn NestJS query string filters into Drizzle queries, call service.parseQuery(query) in the controller. It reads a query such as ?population[gte]=1000000®ion[name]=Asia&sortBy=-name&page=2, coerces each value by its Drizzle column type, and returns the filters, pagination and options that findAll, findByCursor and count take. Unknown columns, operators and relations are a 400 before any SQL runs.
One line per list endpoint
parseQuery reads the entity's own table, relations, primary key and
allowlists. Outside a service, the standalone parseCrudQuery(query, table, options) export does the same work with the config you pass.
@Get()
findAll(@Query() query: Record<string, unknown>) {
const q = this.countries.parseQuery(query);
return q.isCursor
? this.countries.findByCursor(q.filters, q.pagination, q.options)
: this.countries.findAll(q.filters, q.pagination, q.options);
}What does the query string look like?
Each filter is a key: a bare value for an exact match, or brackets for an operator. A relation name filters by a related column.
GET /countries?status=active exact match
GET /countries?population[gte]=1000000 operator
GET /countries?region_id[in]=1,3 list operand, comma separated
GET /countries?code=IN&code=NP repeated key: IN (...)
GET /countries?region[name]=Asia belongs-to column (region.name=Asia works too)
GET /countries?states[name]=Kerala has-many or many-to-many column
GET /cities?state[country][code]=IN a path through relations (state.country.code=IN too)
GET /countries?states[none][name]=Kerala some / none / every on a collection
GET /countries?states[some]= has at least one state
GET /countries?or[0][code]=IN&or[1][code]=NP OR group
GET /countries?not[region][name]=Asia NOT group
GET /countries?sortBy=region.name,-name&page=2&limit=20
GET /countries?fields=id,name,region.name&with=region
GET /countries?search=nep
GET /countries?cursor= first keyset page
GET /countries?withCount=false skip the COUNT(*)
GET /countries?counts=states,tags _count on each rowA relation filter can go on through the related table's own relations, and a collection takes some, none or every as the next key. filterableColumns is checked against the full dotted path (state.country.code). See filtering by relation.
How do operators and lists work in brackets?
All 22 filter operators work in brackets, and each one expects a particular shape of value:
| Operators | Query value | Example |
|---|---|---|
eq, neq, gt, gte, lt, lte | One value, coerced by column type | age[gte]=18 |
like, ilike, startsWith, endsWith, contains, iStartsWith, iEndsWith, iContains | One value, kept as text | name[startsWith]=Ne |
in, notIn, between, arrayContains, arrayOverlaps | Comma separated list, each element coerced | id[in]=1,3, created_at[between]=2025-01-01,2025-12-31 |
isNull, isNotNull | true, false, 1, 0, or empty for true | archived_at[isNull]= |
jsonContains | JSON | meta[jsonContains]={"size":"L"} |
Use a repeated key (name[in]=a&name[in]=b) or name[]=a&name[]=b when a value contains a comma. between needs exactly two values, and a single-value operator given twice is a 400.
The parser reads both Express 5's default flat keys (age[gte]) and the nested objects of the qs parser (app.set('query parser', 'extended')), so it works whichever one your app uses.
Which parameters are not filters?
These keys are read by the parser itself. Every other key is a filter.
| Parameter | Becomes | Notes |
|---|---|---|
page, limit | pagination.page, pagination.limit | Positive integers, otherwise a 400. An empty value is ignored. The parser does not cap limit; findAll clamps it to pagination.maxLimit (100 by default). |
sortBy | pagination.sortBy | Comma list. -name sorts descending, +name or name ascending, region.name sorts by a belongs-to column. A has-many column is a 400. |
sortOrder | direction of the unprefixed sortBy entries | asc (the default) or desc. Anything else is a 400. |
fields | options.select | Comma list, dotted paths for relation columns. |
with | options.relations | Comma list of relation paths. |
search | options.search | Searches searchColumns, or the entity's searchableColumns, in the default ilike mode. Without either it is a 400 (Search is not enabled for this resource). |
cursor / after | pagination.cursor | Sets isCursor. Empty means the first page. See cursor pagination. |
count | pagination.count | true asks findByCursor for a total. |
withCount | pagination.withCount | false makes findAll skip the count: total is null and hasMore is set. See skipping the count query. |
counts | options.counts | Comma list of has-many or many-to-many relations to count under _count. Checked against loadableRelations; a belongs-to is a 400. See aggregates. |
withDeleted, onlyDeleted | options.withDeleted, options.onlyDeleted | Only with allowTrash: true. Otherwise they are read as filter columns, so a 400 unless the table has such a column. See soft delete. |
The flags (count, withCount, withDeleted, onlyDeleted) accept true, 1 or an empty value for true, and false or 0 for false.
How are values coerced?
By the Drizzle column, so the service gets the type it expects:
| Column | Query value | Result |
|---|---|---|
integer, serial, real, double, numeric in number mode | "18" | 18. Not a number: 400. |
bigint in bigint mode | "9007199254740993" | 9007199254740993n, exact. |
numeric / decimal in string mode | "10.50" | Checked as a number, kept as the exact string. |
| boolean | "true", "false", "1", "0" | true / false. Anything else: 400. |
| timestamp, date (date mode) | "2025-01-01" | A Date. Not a date: 400. |
| timestamp, date (string mode) | "2025-01-01" | Checked as a date, kept as the string. |
| text, varchar, uuid, enum | "Asia" | Left as a string. PostgreSQL checks uuid and enum values, and a bad one is a 400. |
| array operands | "red,blue" | Each element coerced by the array's element type. |
What is a 400?
The same things the service rejects, caught before any SQL runs:
- an unknown column, relation, operator, sort column,
fieldsentry,withpath orcountsname - a value the column cannot hold
- a malformed key such as
a[b, or two keys that conflict (age=1&age[gt]=2) - a key given twice where one value is expected (
page=1&page=2) - anything outside an allowlist
Keys containing __proto__, constructor or prototype are refused outright.
Which options does parseQuery take?
Pass them as the second argument:
const q = this.tags.parseQuery(query, {
allowTrash: true, // accept withDeleted / onlyDeleted
searchColumns: ["name"], // what ?search= looks in
ignore: ["format"], // a parameter the controller reads itself
});allowTrashOptionalboolean
Defaults to false
searchColumnsOptionalstring[]
Defaults to searchableColumns
ignoreOptionalstring[]
relationsOptionalRelationsConfig
Defaults to entity relations
primaryKeyOptionalstring
Defaults to "id"
filterableColumnsOptionalstring[]
Defaults to entity config
sortableColumnsOptionalstring[]
Defaults to entity config
selectableColumnsOptionalstring[]
Defaults to entity config
searchableColumnsOptionalstring[]
Defaults to entity config
loadableRelationsOptionalstring[]
Defaults to entity config
parseQuery copies the entity's relations, primary key and five read allowlists into these options, then spreads yours on top, so an option you pass replaces the entity value for that call. parseCrudQuery(query, table, options) takes the same options and fills in nothing.
What does it return?
A ParsedCrudQuery, with the three findAll arguments ready to pass and the parts split out for your own use:
interface ParsedCrudQuery {
filters: CrudFilter;
pagination: {
page?: number;
limit?: number;
sortBy?: SortColumn[];
cursor?: string | null;
count?: boolean;
withCount?: boolean;
};
sort: SortColumn[];
select?: string[];
relations?: string[];
counts?: string[];
search?: SqlSearchOptions;
options: SqlOperationOptions; // select, relations, counts, search, withDeleted, onlyDeleted
isCursor: boolean; // true when cursor or after was sent
}Type the service's fourth generic as CrudFilter so the parsed filters fit without a cast:
export class CountriesService extends SqlBaseCrudService<
Country,
CreateCountryDto,
UpdateCountryDto,
CrudFilter
> {}Common questions
Yes. It reads Express 5's flat keys such as age[gte] and the nested objects of the qs extended parser, so you do not need to change the query parser setting.
List it in the ignore option and read it from the query yourself. Any key that is not ignored and not a known parameter is treated as a filter, and an unknown filter column is a 400.
No. It only checks that limit is a positive integer. findAll clamps it to pagination.maxLimit, which is 100 by default.
Pass allowTrash: true to parseQuery. Then withDeleted and onlyDeleted become options; without it they are read as filter columns and rejected.
Next steps
Allowlists
Limit what a client may filter, sort, select and load.
Filtering with Drizzle in NestJS
Every operator, and or, and, not groups.
Cursor pagination
Keyset pages with findByCursor and nextCursor.
CRUD controller factory
Every route wired for you, parseQuery included.
Filter by relation in Drizzle ORM
Drizzle filter by relation in NestJS: paths at any depth and some, none, every on collections, compiled to EXISTS subqueries that never multiply rows.
Pagination and sorting with Drizzle
Drizzle pagination in NestJS with findAll: page and limit (20 rows by default, at most 100), withCount: false to skip COUNT(*), and multi-column sorts.