Prisma Adapter
Use nestjs-rest-query with Prisma — native nested graphs, additive integration, no contract changes.
The Prisma adapter lets you use nestjs-rest-query with Prisma. It is additive: no public-API change, the same query string format every other adapter accepts, and the result shape is Prisma's native nested graph.
Install
pnpm add @prisma/client
pnpm add -D prismaThen install nestjs-rest-query:
pnpm add nestjs-rest-query@prisma/client is an optional peer dependency. If you only use TypeORM
or Drizzle, you do not need to install Prisma.
Module setup
import { Module } from '@nestjs/common';
import { DynamicQueryBuilderModule } from 'nestjs-rest-query';
import { PrismaAdapter } from 'nestjs-rest-query/prisma';
@Module({
imports: [
DynamicQueryBuilderModule.forRoot({
adapter: new PrismaAdapter(),
pagination: { defaultPerPage: 20, maxPerPage: 100 },
}),
],
})
export class AppModule {}Defining PrismaSource
Each Prisma endpoint passes a PrismaSource object to queryBuilderService.execute(). This object describes:
prisma— thePrismaClientinstance (or any compatible facade)model— the delegate key on the client (e.g.'user','company','post')primaryKeyField(optional) — root primary key field, defaults to'id'relations(optional) — relation metadata for dotted-path validation and'one'vs'many'translation
Notice what's not here:
- No
columnMap— Prisma resolves field names through the schema-generated client. - No per-relation
primaryKeycolumn object —primaryKeyField: stringis enough because Prisma'sfindManyreturns root rows directly without needing client-side dedupe.
import { Injectable } from '@nestjs/common';
import {
DynamicQueryDto,
PrismaSource,
QueryBuilderService,
QueryResult,
RulesConfig,
} from 'nestjs-rest-query';
import { PrismaService } from './prisma/prisma.service';
@Injectable()
export class UsersBusiness {
constructor(
private readonly prisma: PrismaService,
private readonly qb: QueryBuilderService
) {}
list(
query: DynamicQueryDto,
rules: RulesConfig
): Promise<QueryResult<unknown>> {
const source: PrismaSource = {
prisma: this.prisma,
model: 'user',
primaryKeyField: 'id',
relations: {
company: { cardinality: 'one' },
posts: { cardinality: 'many' },
},
};
// `as never`: QueryBuilderService is typed against ObjectLiteral (TypeORM)
// for backward compatibility. Runtime is adapter-agnostic.
return this.qb.execute(source as never, query, rules);
}
}The relations map is required for dotted paths
Prisma knows your schema, but the adapter intentionally does not introspect Prisma's DMMF or other internal APIs. If a query references a dotted path (?filter[company.name][eq]=Acme, ?includes=posts), the relation hop must be declared in PrismaSource.relations.
If a hop is missing:
Unknown relation 'company' in path 'company.name'. Declare it in PrismaSource.relations.Use the nested form to express deeper paths:
relations: {
company: {
cardinality: 'one',
relations: {
owner: { cardinality: 'one' },
},
},
posts: { cardinality: 'many' },
}primaryKeyField per relation is only needed when the related model's PK is not 'id'. It defaults to 'id'.
Dotted-path semantics: 'one' vs 'many'
Through a 'one' relation, the adapter nests a plain object:
GET /users?filter[company.name][eq]=Acmewhere: {
company: {
name: {
equals: 'Acme';
}
}
}Through a 'many' relation, the adapter wraps each 'many' hop in some so the semantics ("any related row matches") match every other adapter:
GET /users?filter[posts.title][ilike]=hellowhere: { posts: { some: { title: { contains: 'hello', mode: 'insensitive' } } } }Deep chains wrap each 'many' hop independently:
GET /users?filter[posts.tags.label][eq]=urgentwhere: {
posts: {
some: {
tags: {
some: {
label: {
equals: 'urgent';
}
}
}
}
}
}ORDER BY through a 'many' relation is rejected
GET /users?sort=-posts.createdAtCannot sort by 'posts.createdAt': sorting through to-many relations is not supported.This matches the Drizzle adapter behavior. To order presented relation arrays, sort them in the application layer after the adapter returns.
fields + includes is constrained on purpose
Prisma rejects select and include at the same level. When fields is present, the adapter builds a select tree and reconciles any include state into it — but without auto-expanding relation scalar columns:
GET /users?fields=id,name&includes=company{
select: {
id: true,
name: true,
company: { select: { id: true } }, // PK only
},
}To get company.name, opt in explicitly:
GET /users?fields=id,name,company.name&includes=company{
select: {
id: true,
name: true,
company: { select: { id: true, name: true } },
},
}This avoids any dependency on Prisma's internal metadata and keeps the contract predictable.
Result shape — Prisma's native nested graph
GET /users?includes=company,posts&page=1&perPage=2{
"data": [
{
"id": "f1c4...",
"name": "Ana",
"email": "ana@acme.com",
"createdAt": "2024-01-15T10:00:00Z",
"company": { "id": "c1...", "name": "Acme" },
"posts": [{ "id": "p1...", "title": "Hello", "userId": "f1c4..." }]
}
],
"page": 1,
"perPage": 2,
"total": 17,
"lastPage": 9
}Prisma hydrates relation arrays natively — no two-phase pagination needed. total is computed via prisma.<model>.count({ where }) against the same where accumulator the findMany call used.
Differences from the TypeORM and Drizzle adapters
The same query string can produce slightly different runtime behavior across adapters. These are documented differences, not bugs:
like/ilikeare literal substring matches. Prisma'scontainsdoes not interpret%or_as SQL wildcards. The adapter does not re-introduce wildcard parsing because that would couple the contract to a SQL dialect.ilikerequires a Prisma provider that supportsmode: 'insensitive'. Postgres and MongoDB do; SQLite does not. RestrictOperatorsConfig.allowedto omitilike/notIlikeif you target SQLite, or expect a Prisma runtime error.isNulltranslation differs by leaf kind. Scalar fields use{ not: null }/null; relation fields use{ isNot: null }/{ is: null }. The adapter picks the right shape automatically based onPrismaSource.relationsmetadata — but if a relation hop is missing fromrelations, the adapter throws (instead of silently emitting an invalid shape).AND: [...]containers are visible in the generatedwhere. Repeated filters always stack under a top-levelANDarray. Equivalent to merged objects for most operators, but visually different in Prisma's query log.
customize receives the internal accumulator
this.qb.execute(prismaSource as never, query, rules, (qb) => {
// `qb` is the internal PrismaQB accumulator:
// { source, alias, where: { AND: [] }, orderBy, include?, select? }
qb.where.AND.push({ tenantId: 'tenant-1' });
});Mutations are visible to both findMany and count because the adapter snapshots arguments after customize runs. Use this for tenant scoping, soft-delete filters, or adapter-native flags Prisma doesn't expose through the REST query grammar.
Next steps
- For a complete guide to parameters, operators, and the whitelist, see Usage Guide.
- The Drizzle adapter page explains the same concepts from a different ORM angle.
- If you want to implement your own adapter, see Writing your own.