nestjs-rest-querynestjs-rest-query

Prisma Adapter

Use nestjs-rest-query with Prisma - the generated client passed directly, a hand-written manifest, and the operators two dialects cannot serve.

The Prisma adapter compiles the query plan into findMany/count arguments. A many relation uses some/none, a one relation uses is/isNot, and the portable text profile queries folded columns so mode: 'insensitive' is never emitted.

Install

pnpm add @prisma/client
pnpm add -D prisma

The supported range is ^6.19.0 || ^7.0.0. The matrix target is Prisma 7.8.0 (CLI and client on the same version), with the official driver adapter per dialect.

Three artefacts

There is no tool that derives them. The generator that would read schema.prisma is a declared gap for 3.1.0, so all three are written by hand and kept in step with the schema by review.

1. The logical schema of every reachable model

query/schemas.ts
import { defineQuerySchema } from 'nestjs-rest-query';
import type { QuerySchema, SchemaRegistry } from 'nestjs-rest-query';

const postSchema: QuerySchema = defineQuerySchema({
  model: 'post',
  primaryKey: ['id'],
  fields: [
    {
      path: 'id',
      kind: 'uuid',
      nullable: false,
      primaryKey: true,
      // Without this the endpoint is refused at startup with
      // CAPABILITY_UNAVAILABLE: the pagination tie-break is always over the
      // primary key, and uuid has no portable total order.
      portableOrderField: 'idOrder',
    },
    {
      path: 'idOrder',
      kind: 'string',
      nullable: false,
      primaryKey: false,
      internal: true,
    },
    {
      path: 'title',
      kind: 'string',
      nullable: false,
      primaryKey: false,
      foldedField: 'titleFolded',
    },
    {
      path: 'titleFolded',
      kind: 'string',
      nullable: false,
      primaryKey: false,
      internal: true,
    },
    { path: 'content', kind: 'string', nullable: true, primaryKey: false },
    { path: 'userId', kind: 'integer', nullable: false, primaryKey: false },
    { path: 'createdAt', kind: 'datetime', nullable: false, primaryKey: false },
  ],
  relations: [
    { path: 'user', target: 'user', cardinality: 'one', nullable: false },
  ],
});

export const APP_SCHEMAS: SchemaRegistry = new Map([
  ['company', companySchema],
  ['user', userSchema],
  ['post', postSchema],
]);

Field path is the client property name, not the column name — it is what goes into the where/select/orderBy the adapter builds. With @map("name_folded") in schema.prisma, the HTTP API stays camelCase while the database keeps the certified profile's snake_case.

2. The manifest

query/manifest.ts
import { createPrismaManifest } from 'nestjs-rest-query/prisma';
import type { PrismaManifest } from 'nestjs-rest-query/prisma';
import { APP_SCHEMAS } from './schemas';

export const APP_MANIFEST: PrismaManifest = createPrismaManifest({
  // Decides the dialect, and with it the pattern escape.
  provider: 'postgresql',
  registry: APP_SCHEMAS,
  models: {
    company: { delegate: 'company' }, // maps the model to prisma.company
    user: { delegate: 'user' },
    post: { delegate: 'post' },
  },
});

createPrismaManifest validates the manifest against itself at startup: a model with no registry entry, or no delegate, fails with SOURCE_CONFIGURATION_INVALID. The delegate named by the manifest is validated again at source construction — a check a type could never do here, because the delegate name comes from data, not from code.

3. The source, per request

users/users.business.ts
import { prismaSource } from 'nestjs-rest-query/prisma';

async findAll(
  query: DynamicQueryDto,
  rules: CompiledQueryRules,
): Promise<NormalizedQueryResult<object>> {
  return this.queryBuilderService.execute(
    prismaSource({
      client: this.prisma,
      model: 'user',
      manifest: APP_MANIFEST,
    }),
    query,
    rules,
  );
}

prismaSource({ client }) takes the generated PrismaClient directly — no cast, and no facade of your own:

prisma/prisma.service.ts
@Injectable()
export class PrismaService extends PrismaClient {}

This is worth stating because it was not true until 3.0.0: client used to be typed Readonly<Record<string, PrismaDelegate>>, and no real PrismaClient satisfied it — a class gets no implicit string index signature in TypeScript, so every consumer needed an assertion.

prismaSource<TRow> is generic in the row type. Nothing in the options carries it — the delegate comes from the manifest, not from a typed client — so this is the one factory where you name the type argument yourself: prismaSource<UserDto>({ client, model, manifest }) lets the method be annotated Promise<NormalizedQueryResult<UserDto>> with no cast. Omit it and you get object, as above.

Refused operators, by provider

This is the one declared divergence in v3. Under the v3 grammar %, _ and \ are literal characters: filter[name][like]=100% searches for the text "100%". TypeORM and Drizzle emit LIKE ... ESCAPE, so they honour it directly. Prisma compiles contains to LIKE ('%' || ? || '%') with no ESCAPE clause, and the typed delegate exposes no way to supply one. All that is left is the dialect's default escape character, which splits the providers in two:

providerlike, notLike, ilike, notIlike, searchWhy
postgresql, mysqlWork, and %/_ are literal — identical to TypeORM and Drizzle\ is the dialect's default LIKE escape, so escaping the value is enough
sqlite, sqlserverRefused: 400 CAPABILITY_UNAVAILABLEno default escape character, so literalness cannot be honoured — refusing beats returning the wrong rows

The refusal is per request, not at startup, so the rest of the grammar stays usable on those dialects. Declaring the wrong provider in the manifest is therefore the difference between a correct endpoint and a silently wrong one.

Measured on Prisma 7.8.0 + PostgreSQL through 04-app-with-prisma: GET /posts?filter[title][like]=100%25 returns only "Desconto de 100% na conta de luz", and filter[title][like]=a_b returns only "Circuito a_b revisado" — the metacharacters are literal.

If you are migrating a Prisma consumer on SQL Server, this is the breaking part: five operators stop being available. Plan for it.

What Prisma does not check

PrismaAdapter.describe() returns the manifest's schema verbatim. So nothing compares your logical schema against schema.prisma or against the database: a mistyped field name surfaces as a Prisma error on the first request that touches it, where the TypeORM path would have failed at boot. The "missing metadata fails closed" guarantee does not extend to Prisma.

customize gets the query arguments

The native context is one query at a time, tagged with which one it is:

await this.queryBuilderService.execute(
  prismaSource({ client: this.prisma, model: 'user', manifest: APP_MANIFEST }),
  query,
  rules,
  {
    customize: (native) => {
      // native: { kind: 'data' | 'count', args }
      native.args.where = { AND: [native.args.where ?? {}, { tenantId }] };
    },
    customizeScope: 'both',
  }
);

Being called once per query — instead of receiving both together — is what makes 'both', the safe default, actually reach data and count.

Prisma 7, if you are coming from 6

Six things change and none is optional:

  1. url left datasource. Keeping the v2 schema fails generation with P1012: The datasource property 'url' is no longer supported in schema files. Connection URLs for CLI/Migrate move to prisma.config.ts; the client gets its connection from a driver adapter.
  2. The generator changed. provider = "prisma-client-js" becomes prisma-client, with output and moduleFormat required, and the client stops existing at @prisma/client: import { PrismaClient } from '@prisma/client' becomes TS2305: Module '"@prisma/client"' has no exported member 'PrismaClient'.
  3. new PrismaClient() needs a driver adapter. Add the adapter for your dialect on the client's major (@prisma/adapter-pg) plus the driver (pg), and pass { adapter: new PrismaPg({ connectionString }) } to the constructor.
  4. The generated client is real TypeScript, not .d.ts. Generating outside the build rootDir breaks nest build with TS6059. Generate inside src (output = "../src/generated/prisma").
  5. The Prisma 7 runtime loads through dynamic import(), so any Jest runner that touches it needs NODE_OPTIONS=--experimental-vm-modules. The ESM ts-jest recipe (useESM: true + extensionsToTreatAsEsm) fails against the generated client with ReferenceError: exports is not defined; what works is ts-jest in CommonJS plus the flag.
  6. .env is no longer read by Prisma (a consequence of 1): load dotenv and validate DATABASE_URL yourself.

Next steps

Edit this page on GitHub

On this page