nestjs-rest-querynestjs-rest-query

Customizing the Query

The two hooks on execute() — transformPlan and customize — their scope, and when to reach for search instead.

execute() takes a fourth, optional argument: an options object with two hooks.

execute(source, query, rules, {
  transformPlan?: (plan: TypedQueryPlan) => TypedQueryPlan,
  customize?: (native: TNative) => void,
  customizeScope?: 'data' | 'count' | 'both',   // default 'both'
})

In 2.x the fourth argument was the callback itself. It is an object now, because there are two different hooks at two different altitudes.

The pipeline, and where each hook sits

Rendering Mermaid diagram...

The order is fixed. transformPlan is the last point at which the plan can change; customize runs after compilation, on the adapter's native context. Data and count derive from the same frozen plan, which is why the two queries always describe the same question.

transformPlan — adapter-agnostic

Use it for policy that belongs to your application and must hold whatever ORM is behind the source: tenant scoping expressed in plan terms, an internal default, a forced projection. It receives and returns a TypedQueryPlan.

await this.queryBuilderService.execute(source, query, rules, {
  // Adapter-agnostic hook: tenant, soft delete, internal policy.
  // Return the plan, or a modified copy of it.
  transformPlan: (plan) => plan,
});

Return a new plan; do not mutate the one you get

The plan handed to transformPlan is already deep-frozen — every nested object, array and Map included. Assigning to one of its properties throws a TypeError under the module system's strict mode, so build a new object and return it. The result is frozen again before compilation, which is what stops a hook from mutating the plan between the data query and the count query.

Note where it sits: parsing, authorisation and coercion have already happened, so the plan you receive is a valid one — and the terms you add to it are not re-validated before compilation. A hook that introduces a term the schema cannot serve is your bug, not a rejected request. Prefer reshaping what the plan already contains over inventing new terms.

customize — adapter-specific

Use it for capabilities outside the REST contract. It receives the native context, once per query in scope, and what "native" means depends on the adapter:

AdapterTNative
TypeORMthe typed SelectQueryBuilder<T>
Prisma{ kind: 'data' | 'count', args }
Drizzle{ kind: 'data' | 'count', statement }
TypeORM
await this.queryBuilderService.execute(
  typeormSource(this.userRepository),
  query,
  rules,
  {
    customize: (qb) => {
      qb.andWhere('root.tenant_id = :tenant', { tenant });
    },
  }
);
Prisma
await this.queryBuilderService.execute(
  prismaSource({ client: this.prisma, model: 'user', manifest }),
  query,
  rules,
  {
    customize: (native) => {
      native.args.where = { AND: [native.args.where ?? {}, { tenantId }] };
    },
  }
);
Drizzle
await this.queryBuilderService.execute(
  drizzleSource({ db, dialect: 'postgres', table, relations }),
  query,
  rules,
  {
    customize: (native) => {
      native.statement.where = {
        op: 'and',
        terms: [
          ...(native.statement.where ? [native.statement.where] : []),
          {
            op: 'compare',
            ref: { alias: native.statement.alias, column: 'tenantId' },
            comparator: '=',
            value: tenant,
          },
        ],
      };
    },
  }
);

The root alias under TypeORM is root.

The library's public parameters (filters, sorts, fields, includes, search) all speak in declared field paths. Inside customize, when you write SQL as a string, you are past that boundary and use the database's own identifiers.

customizeScope

The hook is invoked once per query, not once with both — and that is what makes the default actually work.

ScopeReachesUse when
'both'data and countDefault. Anything that changes which rows qualify.
'data'the data querySomething that cannot appear in a count — and you accept the mismatch.
'count'the count queryRare; almost always a mistake.

A single qb.andWhere(...) under 'both' therefore lands on both queries. A partial scope lets the count answer a different question than the data — so the library logs a structured warning when you choose one:

customize is scoped to a single query; data and count may diverge

When to use what

NeedReach for
Conditions never exposed to the client (soft delete, tenant)customize, or transformPlan
Filtering by the authenticated usercustomize, or transformPlan
Database-specific full-text searchcustomize
A filter the client should controla filters rule in defineQueryRules
A quick search box over several fieldssearch in defineQueryRules
Relation loadingincludes in defineQueryRules

search is a first-class parameter, not something to build in customize. Declare the searchable paths in the rules and send ?search=term:

export const userRules = defineQueryRules(USER_SCHEMAS, 'user', {
  // ...
  search: ['name', 'email', 'company.name'],
});

The targets are combined with OR, each compared through its folded column, against the term normalised by the same foldText. So no ILIKE is emitted, and the result does not depend on the server's collation. A target that crosses one many relation compiles to a correlated EXISTS, so the page keeps its size and total keeps counting root rows.

Two constraints are worth repeating here: a search path needs a foldedField or the application does not boot, and folding does not strip diacritics. See Endpoint whitelist rules.

search versus filter

filter[name][like]=johnsearch=john
Who picks the fieldthe clientthe backend
Whitelistedyes, per field + operatoryes, via search
Several fields at oncenot directlyyes, combined with OR
Case sensitivitylike is literal; ilike is foldedalways folded

Still reach for customize when search has to deviate from that: per-field weights, AND between term groups, a database-specific full-text index, or rules that depend on tenant, role or authentication context.

Inspecting the plan

There is no buildQuery() in v3 — nothing hands you a half-built SelectQueryBuilder, because the plan is the ORM-free artefact and each adapter compiles it. What exists is buildPlan, which stops right after transformPlan and the freeze:

const plan = this.queryBuilderService.buildPlan(query, rules, {
  transformPlan: (p) => p,
});

It is the seam to assert on in tests, or to inspect when a request does not produce the SQL you expected. To reach the ORM's own builder, use customize.

Edit this page on GitHub

On this page