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&region[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.

countries.controller.ts
@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.

requests.http
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 row

A 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:

OperatorsQuery valueExample
eq, neq, gt, gte, lt, lteOne value, coerced by column typeage[gte]=18
like, ilike, startsWith, endsWith, contains, iStartsWith, iEndsWith, iContainsOne value, kept as textname[startsWith]=Ne
in, notIn, between, arrayContains, arrayOverlapsComma separated list, each element coercedid[in]=1,3, created_at[between]=2025-01-01,2025-12-31
isNull, isNotNulltrue, false, 1, 0, or empty for truearchived_at[isNull]=
jsonContainsJSONmeta[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.

ParameterBecomesNotes
page, limitpagination.page, pagination.limitPositive integers, otherwise a 400. An empty value is ignored. The parser does not cap limit; findAll clamps it to pagination.maxLimit (100 by default).
sortBypagination.sortByComma list. -name sorts descending, +name or name ascending, region.name sorts by a belongs-to column. A has-many column is a 400.
sortOrderdirection of the unprefixed sortBy entriesasc (the default) or desc. Anything else is a 400.
fieldsoptions.selectComma list, dotted paths for relation columns.
withoptions.relationsComma list of relation paths.
searchoptions.searchSearches 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 / afterpagination.cursorSets isCursor. Empty means the first page. See cursor pagination.
countpagination.counttrue asks findByCursor for a total.
withCountpagination.withCountfalse makes findAll skip the count: total is null and hasMore is set. See skipping the count query.
countsoptions.countsComma 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, onlyDeletedoptions.withDeleted, options.onlyDeletedOnly 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:

ColumnQuery valueResult
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, fields entry, with path or counts name
  • 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:

tags.controller.ts
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
});
allowTrashOptional

boolean

Accept withDeleted and onlyDeleted as soft delete options instead of treating them as filter columns.

Defaults to false

searchColumnsOptional

string[]

Columns ?search= looks in. Falls back to searchableColumns; with neither, ?search= is a 400.

Defaults to searchableColumns

ignoreOptional

string[]

Query keys to drop before parsing, for parameters your controller reads itself.
relationsOptional

RelationsConfig

The relations, so region[name]=Asia and with=region resolve. parseQuery fills it in.

Defaults to entity relations

primaryKeyOptional

string

Always allowed in fields and sortBy, even outside the allowlists. parseQuery fills it in.

Defaults to "id"

filterableColumnsOptional

string[]

Columns and dotted relation paths a filter may use. Unset allows every column.

Defaults to entity config

sortableColumnsOptional

string[]

Columns and belongsTo.column paths sortBy may name.

Defaults to entity config

selectableColumnsOptional

string[]

Columns and relation.column paths fields may name.

Defaults to entity config

searchableColumnsOptional

string[]

The default search columns.

Defaults to entity config

loadableRelationsOptional

string[]

Relation paths with and counts may name. A listed states.cities also allows states.

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:

query.interface.ts
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:

countries.service.ts
export class CountriesService extends SqlBaseCrudService<
  Country,
  CreateCountryDto,
  UpdateCountryDto,
  CrudFilter
> {}

Common questions

Next steps

Last updated View change history

On this page