Skip to content

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.

IntentExample
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=20

Operators: 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):

PrimitiveAcceptsNormalises to
dateRangeFilterSchema{ gt?, gte?, lt?, lte?, between? } (ISO strings)Date values
uuidInFilterSchemasingle 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

OperatorTypeORM equivalentExample URL paramExample Zod key
eqscalar equality?statusId=abc or ?statusId[eq]=abcequalityFilterSchema(z.string())
inIn([...])?statusId[in]=a,b,cuuidInFilterSchema
gtMoreThan(v)?count[gt]=5numericRangeFilterSchema
gteMoreThanOrEqual(v)?createdAt[gte]=2025-01-01T00:00:00ZdateRangeFilterSchema
ltLessThan(v)?count[lt]=100numericRangeFilterSchema
lteLessThanOrEqual(v)?createdAt[lte]=2025-12-31T23:59:59ZdateRangeFilterSchema
betweenBetween(a, b)?createdAt[between]=2025-01-01T00:00:00Z,2026-01-01T00:00:00ZdateRangeFilterSchema

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 between with 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.