@elchinabilov/nestjs-restapi-filters
v1.1.2
Published
Strapi-style REST API filter engine for NestJS. Filter any in-memory array with query-parameter syntax like filters[field][$operator]=value
Maintainers
Readme
@elchinabilov/nestjs-restapi-filters
Strapi-style REST API filter engine for NestJS. Filter any in-memory array with query-parameter syntax.
GET /api/users?filters[name][$eq]=John&filters[age][$gte]=18Installation
npm install @elchinabilov/nestjs-restapi-filtersQuick Start
1. Import the module
import { Module } from '@nestjs/common';
import { RestApiFiltersModule } from '@elchinabilov/nestjs-restapi-filters';
@Module({
imports: [RestApiFiltersModule],
})
export class AppModule {}2. Use in a controller
import { Controller, Get } from '@nestjs/common';
import {
Filters,
FilterQuery,
FilterEngineService,
} from '@elchinabilov/nestjs-restapi-filters';
@Controller('users')
export class UsersController {
private users = [
{ id: 1, name: 'John', age: 25, role: 'admin' },
{ id: 2, name: 'Jane', age: 30, role: 'user' },
{ id: 3, name: 'Bob', age: 22, role: 'user' },
{ id: 4, name: 'Alice', age: 35, role: 'admin' },
];
constructor(private readonly filterEngine: FilterEngineService) {}
@Get()
findAll(@Filters() filters: FilterQuery) {
return this.filterEngine.applyFilters(this.users, filters);
}
}Now you can query:
GET /users?filters[role][$eq]=admin
GET /users?filters[age][$gte]=25&filters[role][$eq]=user
GET /users?filters[name][$containsi]=joStandalone Usage (without DI)
import { applyFilters } from '@elchinabilov/nestjs-restapi-filters';
const data = [
{ id: 1, name: 'John', age: 25 },
{ id: 2, name: 'Jane', age: 30 },
];
const result = applyFilters(data, {
age: { $gte: 25 },
name: { $startsWith: 'J' },
});
// → [{ id: 1, name: 'John', age: 25 }, { id: 2, name: 'Jane', age: 30 }]Available Operators
| Operator | Description | Example |
| --------------- | ----------------------------------- | ----------------------------------------------------------- |
| $eq | Equal | filters[name][$eq]=John |
| $eqi | Equal (case-insensitive) | filters[name][$eqi]=john |
| $ne | Not equal | filters[role][$ne]=admin |
| $nei | Not equal (case-insensitive) | filters[role][$nei]=ADMIN |
| $lt | Less than | filters[age][$lt]=30 |
| $lte | Less than or equal to | filters[age][$lte]=30 |
| $gt | Greater than | filters[age][$gt]=18 |
| $gte | Greater than or equal to | filters[age][$gte]=18 |
| $in | Included in an array | filters[id][$in][0]=1&filters[id][$in][1]=2 |
| $notIn | Not included in an array | filters[id][$notIn][0]=3&filters[id][$notIn][1]=4 |
| $contains | Contains substring | filters[name][$contains]=ohn |
| $notContains | Does not contain substring | filters[name][$notContains]=xyz |
| $containsi | Contains (case-insensitive) | filters[name][$containsi]=OHN |
| $notContainsi | Does not contain (case-insensitive) | filters[name][$notContainsi]=XYZ |
| $startsWith | Starts with | filters[name][$startsWith]=Jo |
| $startsWithi | Starts with (case-insensitive) | filters[name][$startsWithi]=jo |
| $endsWith | Ends with | filters[name][$endsWith]=hn |
| $endsWithi | Ends with (case-insensitive) | filters[name][$endsWithi]=HN |
| $null | Is null | filters[avatar][$null]=true |
| $notNull | Is not null | filters[avatar][$notNull]=true |
| $between | Between two values | filters[age][$between][0]=18&filters[age][$between][1]=30 |
Logical Operators
| Operator | Description | Example |
| -------- | ------------------------- | ------------------------------------------------------------------ |
| $and | All conditions must match | filters[$and][0][age][$gte]=18&filters[$and][1][role][$eq]=admin |
| $or | At least one must match | filters[$or][0][role][$eq]=admin&filters[$or][1][role][$eq]=user |
| $not | Negate a condition | filters[$not][role][$eq]=admin |
Note: Multiple fields at the same level are implicitly combined with
$and.
Deep Filtering (Nested Fields)
Filter on nested object properties:
GET /api/books?filters[author][name][$eq]=Johnconst books = [
{ id: 1, title: 'Book A', author: { name: 'John', country: 'US' } },
{ id: 2, title: 'Book B', author: { name: 'Jane', country: 'UK' } },
];
const result = applyFilters(books, {
author: { name: { $eq: 'John' } },
});
// → [{ id: 1, title: 'Book A', author: { name: 'John', country: 'US' } }]Complex Filtering
Combine $and, $or, and $not for advanced queries:
GET /api/books?filters[$and][0][$or][0][date][$eq]=2020-01-01&filters[$and][0][$or][1][date][$eq]=2020-01-02&filters[$and][1][author][name][$eq]=Johnconst result = applyFilters(books, {
$and: [
{
$or: [
{ date: { $eq: '2020-01-01' } },
{ date: { $eq: '2020-01-02' } },
],
},
{
author: { name: { $eq: 'John' } },
},
],
});Validation Pipe
Use ParseFiltersPipe to validate and sanitize incoming filter queries:
import { Filters, ParseFiltersPipe, FilterQuery } from '@elchinabilov/nestjs-restapi-filters';
@Get()
findAll(
@Filters(new ParseFiltersPipe({ maxDepth: 5, strict: true }))
filters: FilterQuery,
) {
return this.filterEngine.applyFilters(this.data, filters);
}| Option | Default | Description |
| ---------- | ------- | -------------------------------------- |
| maxDepth | 10 | Maximum nesting depth (DoS protection) |
| strict | false | Throw on unknown operators like $foo |
Module Configuration
RestApiFiltersModule.forRoot({
autoCoerce: true, // auto-convert '18' → 18, 'true' → true (default: true)
maxDepth: 5, // max filter nesting depth (default: 10)
});Auto Type Coercion
Query parameters are always strings. By default, the engine auto-coerces:
| Input string | Coerced value |
| ------------ | ------------- |
| 'true' | true |
| 'false' | false |
| 'null' | null |
| '42' | 42 |
| '3.14' | 3.14 |
| 'hello' | 'hello' |
Disable with { autoCoerce: false }.
TypeORM Integration (Database-level Filtering)
Filter directly at the database level by chaining .applyFilters() on any TypeORM SelectQueryBuilder.
Setup (2 steps)
Step 1. Add the type declaration to your project.
Create a file src/types/typeorm-filters.d.ts (or any .d.ts inside your src/):
import { FilterQuery, TypeOrmFilterOptions } from '@elchinabilov/nestjs-restapi-filters';
declare module 'typeorm' {
interface SelectQueryBuilder<Entity> {
applyFilters(filters: FilterQuery, options?: TypeOrmFilterOptions): this;
}
}Step 2. Call extendQueryBuilderWithFilters() once at application startup:
// main.ts
import { extendQueryBuilderWithFilters } from '@elchinabilov/nestjs-restapi-filters';
extendQueryBuilderWithFilters();
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
bootstrap();Step 1 provides TypeScript type support (autocomplete, type checking). Step 2 adds the actual runtime method to the QueryBuilder prototype.
Usage in a service / repository
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Filters, FilterQuery } from '@elchinabilov/nestjs-restapi-filters';
import { Order } from './order.entity';
@Injectable()
export class OrderService {
constructor(
@InjectRepository(Order)
private readonly orderRepo: Repository<Order>,
) {}
async findAll(filters: FilterQuery) {
const qb = this.orderRepo.createQueryBuilder('order');
const [data, total] = await qb
.leftJoin('order.user', 'user')
.addSelect(['user.name', 'user.surname', 'user.phone'])
.leftJoin('order.organization', 'organization')
.addSelect([
'organization.id',
'organization.name',
'organization.slug',
])
.applyFilters(filters)
.orderBy('order.createdAt', 'DESC')
.getManyAndCount();
return { data, total };
}
}Controller
@Controller('orders')
export class OrderController {
constructor(private readonly orderService: OrderService) {}
@Get()
findAll(@Filters() filters: FilterQuery) {
return this.orderService.findAll(filters);
}
}How alias resolution works
The method auto-detects all joined aliases from the query builder and resolves fields in 3 ways:
| Scenario | Filter | Generated SQL (MySQL) |
| -------- | ------ | --------------------- |
| Regular column | filters[status][$eq]=active | order.status = :_filter_0 |
| Joined relation | filters[user][name][$containsi]=john | LOWER(user.name) LIKE :_filter_0 |
| JSON column | filters[meta][pricing][total][$gte]=100 | JSON_UNQUOTE(JSON_EXTRACT(order.meta, '$.pricing.total')) >= :_filter_0 |
| JSON column (shallow) | filters[serviceMethod][name][$containsi]=test | LOWER(JSON_UNQUOTE(JSON_EXTRACT(order.serviceMethod, '$.name'))) LIKE :_filter_0 |
| Logical | filters[$or][0][status][$eq]=pending&... | (order.status = :_filter_0 OR order.status = :_filter_1) |
Resolution logic:
- Field matches a joined alias → relation column (e.g.
user.name) - Field has direct operators (
$eq,$gte, ...) → regular column (e.g.order.status) - Field not an alias, nested objects → JSON column with
JSON_EXTRACT(MySQL),#>>(PostgreSQL),json_extract(SQLite),JSON_VALUE(MSSQL)
JSON Column Filtering
Nested filters on non-joined fields are automatically treated as JSON column paths:
GET /orders?filters[meta][pricing][total][$gte]=100
GET /orders?filters[serviceMethod][name][$containsi]=test
GET /orders?filters[deliveryAddress][city][$eq]=BakuDatabase type is auto-detected from the TypeORM connection. You can also set it manually:
qb.applyFilters(filters, { dbType: 'postgres' });| Database | JSON extraction syntax |
| ---------------- | --------------------------------------------------------- |
| MySQL / MariaDB | JSON_UNQUOTE(JSON_EXTRACT(col, '$.path')) |
| PostgreSQL | col #>> '{path,to,field}' |
| SQLite | json_extract(col, '$.path') |
| MSSQL | JSON_VALUE(col, '$.path') |
Options
qb.applyFilters(filters, {
alias: 'o', // override root entity alias (default: qb.alias)
autoCoerce: false, // disable auto type coercion
dbType: 'postgres', // override database type (default: auto-detected)
});API Reference
FilterEngineService
| Method | Signature | Description |
| -------------- | -------------------------------------------------------------------------------------- | ---------------------------------- |
| applyFilters | applyFilters<T>(data: T[], filters: FilterQuery, options?: FilterEngineOptions): T[] | Filter an array by the given query |
applyFilters() (standalone)
import { applyFilters } from '@elchinabilov/nestjs-restapi-filters';
const result = applyFilters(data, filters, options?);@Filters() (decorator)
@Get()
findAll(@Filters() filters: FilterQuery) { ... }
// Custom query key:
@Get()
findAll(@Filters('filter') filters: FilterQuery) { ... }.applyFilters() (TypeORM QueryBuilder)
import { extendQueryBuilderWithFilters } from '@elchinabilov/nestjs-restapi-filters';
// Call once at startup
extendQueryBuilderWithFilters();
// Then use on any SelectQueryBuilder
const [data, total] = await qb
.applyFilters(filters)
.getManyAndCount();License
MIT
