Generic Operators
Operator-based filtering with ColumnFilter, 22 operators, query string bracket notation, body format, AND/OR composition, and security.
nestjs-filter supports generic operator-based filtering alongside the declarative @FilterFor pattern. Instead of writing a method for every field, you can send structured filter conditions with operators like equals, contains, in, between, and 18 more.
ColumnFilter shape
A single filter condition is represented as a ColumnFilter object:
interface ColumnFilter {
field: string; // column name (e.g. 'name', 'age')
operator: FilterOperatorInput; // one of 22 operators (or a symbol alias like '=')
value?: unknown; // the filter value (omitted for unary operators)
AND?: ColumnFilter[]; // nested AND conditions
OR?: ColumnFilter[]; // nested OR conditions
}All 22 operators
Comparison operators
| Operator | SQL equivalent | Value type | Example |
|---|---|---|---|
equals | = value | scalar | { field: 'status', operator: 'equals', value: 'active' } |
notEquals | != value | scalar | { field: 'status', operator: 'notEquals', value: 'deleted' } |
gt | > value | scalar | { field: 'age', operator: 'gt', value: 18 } |
gte | >= value | scalar | { field: 'age', operator: 'gte', value: 18 } |
lt | < value | scalar | { field: 'age', operator: 'lt', value: 65 } |
lte | <= value | scalar | { field: 'age', operator: 'lte', value: 65 } |
String operators
| Operator | SQL equivalent | Value type | Example |
|---|---|---|---|
contains | LIKE '%value%' | string | { field: 'name', operator: 'contains', value: 'fleet' } |
notContains | NOT LIKE '%value%' | string | { field: 'name', operator: 'notContains', value: 'test' } |
iContains | LOWER(col) LIKE LOWER('%value%') | string | { field: 'email', operator: 'iContains', value: 'ACME' } |
startsWith | LIKE 'value%' | string | { field: 'name', operator: 'startsWith', value: 'A' } |
endsWith | LIKE '%value' | string | { field: 'email', operator: 'endsWith', value: '.com' } |
Array operators
| Operator | SQL equivalent | Value type | Example |
|---|---|---|---|
in | IN (...) | array | { field: 'status', operator: 'in', value: ['A', 'B'] } |
notIn | NOT IN (...) | array | { field: 'role', operator: 'notIn', value: ['banned'] } |
isAnyOf | IN (...) | array | Alias for in |
isAnyOf is an alias for in. Both produce the same WHERE ... IN (...) clause. Use whichever reads better in your context.
Range operators
| Operator | SQL equivalent | Value type | Example |
|---|---|---|---|
between | BETWEEN low AND high | [low, high] | { field: 'age', operator: 'between', value: [18, 65] } |
notBetween | NOT BETWEEN low AND high | [low, high] | { field: 'price', operator: 'notBetween', value: [0, 10] } |
Unary operators (no value)
| Operator | SQL equivalent | Example |
|---|---|---|
isNull | IS NULL | { field: 'deletedAt', operator: 'isNull' } |
isNotNull | IS NOT NULL | { field: 'email', operator: 'isNotNull' } |
isEmpty | IS NULL OR = '' | { field: 'bio', operator: 'isEmpty' } |
isNotEmpty | IS NOT NULL AND != '' | { field: 'bio', operator: 'isNotEmpty' } |
exists | IS NOT NULL | { field: 'avatar', operator: 'exists' } |
notExists | IS NULL | { field: 'avatar', operator: 'notExists' } |
Operator aliases
For the common comparison operators you can also use their familiar SQL symbols.
Aliases are normalized to the canonical operator before validation and query
building, so { operator: '=' } behaves exactly like { operator: 'equals' }.
| Alias | Canonical |
|---|---|
=, == | equals |
!=, <> | notEquals |
> | gt |
>= | gte |
< | lt |
<= | lte |
{
"where": [
{ "field": "status", "operator": "=", "value": "active" },
{ "field": "age", "operator": ">=", "value": 18 }
]
}This makes migrating from a hand-rolled SQL-style query builder painless — the
symbol operators your clients already send keep working. If you need the
canonical form programmatically, the normalizeOperator() helper (and the
OPERATOR_ALIASES map) are exported from the core package:
import { normalizeOperator } from '@dudousxd/nestjs-filter';
normalizeOperator('='); // 'equals'
normalizeOperator('<>'); // 'notEquals'
normalizeOperator('iContains'); // 'iContains' (canonical operators pass through)Only scalar comparison operators have symbol aliases. Array, range, and unary
operators (in, between, isNull, …) have no symbol form — use the
canonical name.
Input formats
There are two ways to send operator-based filters: query string bracket notation (GET requests) and body format (POST requests).
Query string bracket notation
For GET requests, use bracket notation to specify operators:
GET /users?name[contains]=fleet&age[gte]=18&status=activeThis is parsed into:
namewith operatorcontainsand valuefleetagewith operatorgteand value18statuswith implicitequalsand valueactive
Bracket notation works with auto-fields. The adapter detects the operator object { contains: 'fleet' } and applies the correct SQL.
Body format with where array
For POST requests, send a where array of ColumnFilter objects in the request body:
{
"where": [
{ "field": "name", "operator": "contains", "value": "fleet" },
{ "field": "age", "operator": "gte", "value": 18 },
{ "field": "status", "operator": "in", "value": ["active", "pending"] }
]
}The FilterRunner extracts the where array, validates each ColumnFilter, and passes them to the adapter's applyColumnFilters() method. Any remaining keys in the body (outside where) are dispatched to @FilterFor methods as usual.
Controller setup for body format
@Post('search')
search(@ApplyFilter(UserFilter, { source: 'body' }) qb: QueryBuilder<User>) {
return qb.getResultList();
}AND/OR composition
Nested AND
All top-level filters in the where array are implicitly ANDed. You can also nest AND conditions:
{
"where": [
{
"field": "status",
"operator": "equals",
"value": "active",
"AND": [
{ "field": "age", "operator": "gte", "value": 18 },
{ "field": "age", "operator": "lte", "value": 65 }
]
}
]
}This produces: WHERE status = 'active' AND age >= 18 AND age <= 65.
Nested OR
{
"where": [
{
"field": "role",
"operator": "equals",
"value": "admin",
"OR": [
{ "field": "name", "operator": "contains", "value": "sync" },
{ "field": "email", "operator": "contains", "value": "sync" }
]
}
]
}This produces: WHERE role = 'admin' AND (name LIKE '%sync%' OR email LIKE '%sync%').
JSON sub-path filtering
When a field is a dotted path whose head segment is a JSON column (json/jsonb), the remaining segments are treated as keys inside the JSON value. No special operator or syntax is needed — you reuse the same dot-notation as relation paths, and the adapter detects that the head is a JSON column rather than a relation.
{
"where": [
{ "field": "metadata.tier", "operator": "equals", "value": "pro" },
{ "field": "metadata.amount", "operator": "gte", "value": 100 }
]
}Given a metadata JSON column, metadata.tier resolves to the tier key inside it, and metadata.amount to the amount key. Deeper paths work too — metadata.a.b extracts a.b. MikroORM compiles these to engine-specific JSON extraction (e.g. ->> on PostgreSQL).
Supported operators on a JSON sub-path:
| Group | Operators |
|---|---|
| Comparison | equals, notEquals, gt, gte, lt, lte |
| String | contains |
| Array | in |
| Range | between |
| Unary | isNull, isNotNull |
JSON sub-paths can also be used for sorting — see Sort.
JSON sub-path filtering and sorting are implemented by the MikroORM adapter only. The TypeORM adapter resolves dotted paths as relations, not JSON keys.
Numeric WHERE comparisons on a JSON sub-path compare numerically on SQLite, MySQL, and PostgreSQL. But numeric sort by a JSON sub-path on PostgreSQL is lexical: ->> extracts text and ORDER BY does not auto-cast (unlike WHERE). Numeric JSON sort is correct on SQLite and MySQL. For portable ordering, sort by JSON sub-paths whose values are strings.
Mixed mode: operators + @FilterFor
You can use both approaches together. The FilterRunner processes where (column filters) first, then dispatches remaining keys to @FilterFor methods:
@Injectable()
@Filterable({ entity: User })
export class UserFilter extends MikroOrmFilter<User> {
@FilterFor('search')
applySearch(value: string) {
// Custom full-text search logic
this.$query.andWhere({
$or: [
{ name: { $like: `%${escapeLike(value)}%` } },
{ email: { $like: `%${escapeLike(value)}%` } },
],
});
}
}A POST body like this uses both:
{
"where": [
{ "field": "status", "operator": "equals", "value": "active" }
],
"search": "fleet"
}The status filter is applied via applyColumnFilters, and search is dispatched to applySearch().
Validation with ColumnFilterDto
For class-validator integration, the core package exports ColumnFilterDto:
import { IsOptional, IsArray, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { ColumnFilterDto } from '@dudousxd/nestjs-filter';
class SearchDto {
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => ColumnFilterDto)
where?: ColumnFilterDto[];
}Runtime validation
The validateColumnFilter() and validateColumnFilters() functions are also available for manual validation:
import { validateColumnFilters, InvalidColumnFilterError } from '@dudousxd/nestjs-filter';
try {
validateColumnFilters(filters);
} catch (err) {
if (err instanceof InvalidColumnFilterError) {
// Handle invalid filter
}
}The validator checks:
fieldis a non-empty string with only letters, digits, underscores, and dotsoperatoris one of the 22 known operators (or a symbol alias, which is normalized in place to its canonical form)valuematches the operator's expected type (scalar, array, tuple, or none for unary)AND/ORarrays are validated recursively
Security
Field validation
The validateColumnFilter function rejects field names containing SQL-unsafe characters. Only [a-zA-Z_][a-zA-Z0-9_.]* is accepted, which prevents SQL injection through field names.
Parameterized queries
All values are parameterized by the ORM adapters. The TypeORM adapter uses named parameters (:paramName), and the MikroORM adapter uses FilterQuery objects. No raw SQL interpolation occurs.
LIKE escaping
All string operators (contains, notContains, iContains, startsWith, endsWith) escape LIKE wildcards (%, _, \) via the escapeLike() utility before interpolation.
Always validate and restrict which fields are queryable. Use the allowed option on @Filterable to whitelist accepted column names, or validate the field property in your own middleware before it reaches the filter.
ORM adapter support
Both the MikroORM and TypeORM adapters implement applyColumnFilters():
Translates ColumnFilter objects into MikroORM FilterQuery conditions using $like, $gte, $in, $and, $or, etc.
Translates ColumnFilter objects into TypeORM andWhere/orWhere calls with parameterized queries and Brackets for AND/OR groups.
Testing
FilterTestingModule, makeMockQueryBuilder, unit testing filters, integration testing with real databases, and Docker Compose setup.
Typed Client (nestjs-codegen)
Generate a type-safe filterQuery() builder for your filter endpoints with the @dudousxd/nestjs-filter-codegen extension for nestjs-codegen.