Appearance
Query Filters & Pagination DSL
Overview
The query DSL is a standardised request/response contract for any GET list endpoint that supports optional filtering, sorting, and offset pagination. Filters are expressed as bracket-notation query params (field[op]=value), validated in full by a Zod schema on the API side, translated to TypeORM operators via a whitelist field map, and serialised on the frontend via qs. Use this pattern for every new list endpoint — do NOT build ad-hoc filter handling outside it.
Query-string Convention
Filters use bracketed key notation. The bracket part is the operator; a bare key implies eq.
| Intent | Example |
|---|---|
| Equality (implicit) | ?statusId=abc123 |
| Equality (explicit) | ?statusId[eq]=abc123 |
| Set membership | ?statusId[in]=a,b,c |
| IS NULL (opt-in per field) | ?statusId[null]=true |
| IS NOT NULL (opt-in per field) | ?statusId[null]=false |
| After date | ?createdAt[gte]=2025-01-01T00:00:00Z |
| Before date | ?createdAt[lt]=2026-01-01T00:00:00Z |
| Date range | ?createdAt[between]=2025-01-01T00:00:00Z,2026-01-01T00:00:00Z |
| Sort descending | ?sort=-createdAt |
| Sort ascending | ?sort=updatedAt |
| Multi-sort | ?sort=-createdAt,updatedAt |
| Pagination | ?page=2&limit=20 |
Full example URL:
GET /operations/tasks/search
?statusId[in]=uuid-a,uuid-b
&createdAt[gte]=2025-06-01T00:00:00Z
&sort=-createdAt
&page=1
&limit=20Operators: eq (equality), in (set), gt, gte, lt, lte (scalar range), between (inclusive range), null (IS NULL / IS NOT NULL — opt-in per field). between and scalar range operators are mutually exclusive on the same field. null is mutually exclusive with eq/in on the same field.
Null operator — opt-in per field
The [null] operator is NOT enabled on every column by default. Use it on columns that are semantically nullable AND where "has no value" is a meaningful filter (e.g. statusId on tasks — tasks without a status). To enable it for a field, replace uuidInFilterSchema with nullableUuidInFilterSchema (from @repo/schemas shared query module) in the resource's SearchXxxQuerySchema filters object. The translator handles { null: true } → IsNull() and { null: false } → Not(IsNull()) automatically — no backend changes needed per field.
Adding a New List Endpoint — 5 Steps
Step 1 — Define the search schema (packages/schemas)
Create or extend the resource schema in packages/schemas/src/<module>/<resource>.schema.ts using listQuerySchemaFactory and the operator primitives:
typescript
// packages/schemas/src/operations/task.schema.ts (excerpt)
import { z } from 'zod';
import {
listQuerySchemaFactory,
dateRangeFilterSchema,
uuidInFilterSchema,
} from '../shared/query';
export const SearchTasksQuerySchema = listQuerySchemaFactory({
sortable: ['createdAt', 'updatedAt'] as const,
filters: z.object({
creatorId: uuidInFilterSchema.optional(),
statusId: uuidInFilterSchema.optional(),
projectId: uuidInFilterSchema.optional(),
createdAt: dateRangeFilterSchema.optional(),
updatedAt: dateRangeFilterSchema.optional(),
}),
});
export type ISearchTasksQuery = z.infer<typeof SearchTasksQuerySchema>;Available filter primitives (all from packages/schemas/src/shared/query/filter-operators.schema.ts):
| Primitive | Accepts | Normalises to |
|---|---|---|
dateRangeFilterSchema | { gt?, gte?, lt?, lte?, between? } (ISO strings) | Date values |
uuidInFilterSchema | single UUIDv7 string OR { in: uuid[] } | { in: string[] } |
numericRangeFilterSchema | { gt?, gte?, lt?, lte?, between? } (coerced) | numbers |
equalityFilterSchema(innerSchema) | scalar OR { eq: scalar } | { eq: T } |
listQuerySchemaFactory merges the filters with page, limit, and sort. Unknown top-level keys are stripped automatically.
Step 2 — Define field maps (apps/api)
Create apps/api/src/modules/<module>/infrastructure/field-maps/<resource>-field-map.ts:
typescript
// apps/api/src/modules/operations/infrastructure/field-maps/task-field-map.ts
import type { IFieldMap } from '@api/shared/query/typeorm-translator';
import type { TaskPersistence } from '../persistence/task.persistence';
/** Filter whitelist: only these keys can reach TypeORM. */
export const TASK_FIELD_MAP: IFieldMap<TaskPersistence> = {
creatorId: 'creatorId',
statusId: 'statusId',
projectId: 'projectId',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
};
/** Sort whitelist. */
export const TASK_SORT_MAP: IFieldMap<TaskPersistence> = {
createdAt: 'createdAt',
updatedAt: 'updatedAt',
};Any filter key NOT in the map is silently dropped before reaching TypeORM — this is the security boundary.
Step 3 — Add searchPaginated to the repository
Extend the repository interface and implementation:
typescript
// In the interface (domain/repositories/resource.repository.interface.ts)
searchPaginated(
orgId: string,
where: FindOptionsWhere<ResourcePersistence>,
order: FindOptionsOrder<ResourcePersistence>,
pagination: { page: number; limit: number },
): Promise<PaginationResultDto<ResourcePersistence>>;
// In the implementation (infrastructure/repositories/resource.repository.impl.ts)
async searchPaginated(
orgId: string,
where: FindOptionsWhere<ResourcePersistence>,
order: FindOptionsOrder<ResourcePersistence>,
pagination: { page: number; limit: number },
): Promise<PaginationResultDto<ResourcePersistence>> {
return this.metricsService.recordRepositoryOperation(
'ResourceRepository',
'searchPaginated',
() =>
paginate(this.repo, {
where,
order,
page: pagination.page,
limit: pagination.limit,
}),
);
}paginate is imported from @api/shared/query/paginate.helper. Wrap it in recordRepositoryOperation — see api-review-guidelines §11.
Step 4 — Add CQRS query + handler
typescript
// application/queries/search-resource/search-resource.query.ts
export class SearchResourceQuery {
constructor(
public readonly orgId: string,
public readonly listQuery: ISearchResourceQuery,
) {}
}
// application/queries/search-resource/search-resource.handler.ts
@QueryHandler(SearchResourceQuery)
export class SearchResourceHandler
implements IQueryHandler<SearchResourceQuery, PaginationResultDto<IResourceResponse>>
{
constructor(
@Inject(ResourceRepositoryKey)
private readonly repo: IResourceRepository,
) {}
async execute(query: SearchResourceQuery): Promise<PaginationResultDto<IResourceResponse>> {
const { orgId, listQuery } = query;
const { page, limit, sort, ...filters } = listQuery;
const where = {
orgId,
...toTypeOrmWhere(filters, RESOURCE_FIELD_MAP),
};
const order =
sort.length > 0
? toTypeOrmOrder(sort, RESOURCE_SORT_MAP)
: { createdAt: 'DESC' as const }; // always apply a default sort
const result = await this.repo.searchPaginated(orgId, where, order, { page, limit });
return {
items: result.items.map((r) => ResourceMapper.toDto(r)),
total: result.total,
page: result.page,
limit: result.limit,
};
}
}Key responsibilities: spread orgId into where, apply toTypeOrmWhere/toTypeOrmOrder, fall back to { createdAt: 'DESC' } when sort is empty, return PaginationResultDto.
Step 5 — Add @Get('search') to the controller
typescript
@Get('search') // MUST come BEFORE @Get(':id')
@Auth()
@CheckPolicies(new ReadResourcePolicyHandler())
@ApiOkResponse({ type: PaginatedResourceResponseDto })
async search(
@Query(new QueryFilterPipe(SearchResourceQuerySchema)) query: ISearchResourceQuery,
@ActiveOrg() orgId: string,
): Promise<PaginationResultDto<IResourceResponse>> {
return this.queryBus.execute(new SearchResourceQuery(orgId, query));
}@Get('search') MUST be declared before any @Get(':id') route. If :id comes first, NestJS matches the literal string "search" as a UUID param and the endpoint never resolves.
Frontend How-To
Panel service
Add a searchResource method to the appropriate api service:
typescript
// apps/panel/src/features/<module>/services/<module>-api.service.ts
import { paginationResultSchema } from '@repo/schemas/shared/query/pagination.schema';
import { ResourceResponseSchema } from '@repo/schemas/<module>';
async searchResources(
filters: ISearchResourceQuery,
): Promise<PaginationResultDto<IResourceResponse>> {
const { data } = await this.client.get('/resources/search', { params: filters });
return paginationResultSchema
.extend({ items: z.array(ResourceResponseSchema) })
.parse(data);
}The Axios client at apps/panel/src/infrastructure/api/client.ts uses qs.stringify with arrayFormat: 'comma' as the paramsSerializer, so nested filter objects are serialised to the correct bracket notation automatically.
Sort param: after SearchXxxQuerySchema.parse, sort is an array of { field, direction }. Passing that through qs produces nested keys (sort[0][field]=…) that the API Zod schema does not accept (it expects a single comma string such as -createdAt). The panel operationsApiService therefore re-encodes sort to that string before axios.get (see httpParamsForTasksSearch / httpParamsForProjectsSearch).
Consumer hook
Create apps/panel/src/features/<module>/hooks/use-resource-search.ts:
typescript
import { SearchResourceQuerySchema, type ISearchResourceQuery, type IResourceResponse } from '@repo/schemas';
import { useFilterState } from '@/shared/query/use-filter-state';
import { useListQuery, type IUseListQueryResult } from '@/shared/query/use-list-query';
import { resourceApiService } from '../services/resource-api.service';
const DEFAULTS: ISearchResourceQuery = { page: 1, limit: 10 };
const URL_KEY = 'resourceSearch'; // MUST be unique per hook on the same page
export interface IUseResourceSearchResult extends IUseListQueryResult<IResourceResponse> {
filters: ISearchResourceQuery;
setFilters: (update: Partial<ISearchResourceQuery> | ((prev: ISearchResourceQuery) => ISearchResourceQuery)) => Promise<URLSearchParams>;
resetFilters: () => Promise<URLSearchParams>;
}
export function useResourceSearch(): IUseResourceSearchResult {
const { filters, setFilters, resetFilters } = useFilterState<ISearchResourceQuery>(
SearchResourceQuerySchema,
DEFAULTS,
URL_KEY,
);
const query = useListQuery<IResourceResponse>({
queryKey: ['module', 'resources', 'search'],
filters,
queryFn: (f) => resourceApiService.searchResources(f as ISearchResourceQuery),
});
return { ...query, filters, setFilters, resetFilters };
}Hook integration tests
typescript
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { withNuqsTestingAdapter } from 'nuqs/adapters/testing';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { useResourceSearch } from './use-resource-search';
import * as service from '../services/resource-api.service';
vi.mock('../services/resource-api.service');
const makeMock = (overrides = {}) => ({
items: [],
total: 0,
page: 1,
limit: 10,
...overrides,
});
function wrapper({ children }: { children: React.ReactNode }) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return withNuqsTestingAdapter()(
<QueryClientProvider client={client}>{children}</QueryClientProvider>,
);
}
describe('useResourceSearch', () => {
beforeEach(() => {
vi.mocked(service.resourceApiService.searchResources).mockResolvedValue(makeMock());
});
it('returns default pagination values before first fetch', async () => {
const { result } = renderHook(() => useResourceSearch(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.page).toBe(1);
expect(result.current.limit).toBe(10);
});
});Operator Cheatsheet
| Operator | TypeORM equivalent | Example URL param | Example Zod key |
|---|---|---|---|
eq | scalar equality | ?statusId=abc or ?statusId[eq]=abc | equalityFilterSchema(z.string()) |
in | In([...]) | ?statusId[in]=a,b,c | uuidInFilterSchema |
gt | MoreThan(v) | ?count[gt]=5 | numericRangeFilterSchema |
gte | MoreThanOrEqual(v) | ?createdAt[gte]=2025-01-01T00:00:00Z | dateRangeFilterSchema |
lt | LessThan(v) | ?count[lt]=100 | numericRangeFilterSchema |
lte | LessThanOrEqual(v) | ?createdAt[lte]=2025-12-31T23:59:59Z | dateRangeFilterSchema |
between | Between(a, b) | ?createdAt[between]=2025-01-01T00:00:00Z,2026-01-01T00:00:00Z | dateRangeFilterSchema |
Multiple scalar range operators on the same field are combined with TypeORM And(...).
Security
The fieldMap (Step 2) is the single whitelist barrier between user-supplied query param keys and TypeORM column names. The translator (toTypeOrmWhere, toTypeOrmOrder) silently ignores any key not present in the map. Unknown top-level keys are also stripped by the Zod schema's .strip() call in listQuerySchemaFactory. Pagination limit is capped at 100 by paginationSchema — callers cannot request unbounded result sets.
NEVER bypass the field map by constructing TypeORM query clauses directly from user input.
Don'ts
- Do not add new operators outside the frozen set (
eq,in,gt,gte,lt,lte,between). Extending the set requires updating the schema primitives, translator, and this doc simultaneously. - Do not use this DSL for full-text search. Use the Qdrant vector store integration for semantic / keyword search.
- Do not mix
betweenwith scalar range operators (gt/gte/lt/lte) on the same field. The Zod schemas enforce this at runtime and will return a 400. - Do not rely on the frontend serialiser alone for security. Even if the panel omits a filter key, the API field map enforces the whitelist independently.