Query Service
QueryService wraps a Prisma-compatible delegate. It does not import Prisma at runtime; generated Prisma types stay in your application and are connected through a small type map.
Type Map
import { Injectable } from '@nestjs/common';
import { QueryService, type BaseDelegateTypeMap } from '@querry-kit/nest';
import { Prisma, PrismaService } from '../prisma';
interface ProjectTypeMap extends BaseDelegateTypeMap {
select: Prisma.ProjectSelect;
include: Prisma.ProjectInclude;
whereInput: Prisma.ProjectWhereInput;
orderByWithRelationInput: Prisma.ProjectOrderByWithRelationInput;
whereUniqueInput: Prisma.ProjectWhereUniqueInput;
scalarFieldEnum: Prisma.ProjectScalarFieldEnum;
aggregateInputType: Prisma.ProjectAggregateArgs;
orderByWithAggregationInput: Prisma.ProjectOrderByWithAggregationInput;
scalarWhereWithAggregatesInput: Prisma.ProjectScalarWhereWithAggregatesInput;
}
@Injectable()
export class ProjectsService extends QueryService<typeof PrismaService.prototype.project, ProjectTypeMap> {
constructor(prisma: PrismaService) {
super(prisma.project);
}
}
Methods
await projectsService.findOne({ where: { slug: 'alpha' } });
await projectsService.findMany({ where: { archived: false }, orderBy: { name: 'asc' } });
await projectsService.findById('project_1', { include: { members: true } });
await projectsService.findUnique({ where: { slug: 'alpha' } });
await projectsService.aggregate({ where: { archived: false }, _count: true });
await projectsService.count({ where: { archived: false } });
query combines findMany, count, and PageMetaDTO.
const { items, pageMeta } = await projectsService.query({
page: 2,
perPage: 25,
where: { archived: false },
orderBy: { name: 'asc' },
});
Complex Queries Without ResourceQuery
Use ResourceQuery for the common controller flow. For advanced endpoints, call QueryService directly and keep the Prisma-shaped query explicit in your controller or application service.
import { Controller, Get, Param, Query, Req } from '@nestjs/common';
import { Fields, PaginatedDTO, QueryDTO, prepareFieldsQuery } from '@querry-kit/nest';
import type { Request } from 'express';
import { BookDTO } from './dto/book.dto';
import { BooksService, type BookModel, type BookTypeMap } from './books.service';
type RequestWithAbility = Request & {
ability?: unknown;
};
@Controller('books')
export class BooksController {
constructor(private readonly booksService: BooksService) {}
@Get('search')
async search(@Query() query: QueryDTO<BookTypeMap>, @Req() req: RequestWithAbility) {
const result = await this.booksService.query<BookModel>(
{
...query,
where: {
published: true,
author: {
name: { contains: 'Ada', mode: 'insensitive' },
},
tags: {
some: { name: { in: ['nestjs', 'api'] } },
},
},
include: {
author: {
include: {
books: {
select: { id: true, title: true },
},
},
},
tags: true,
},
orderBy: [{ publishedAt: 'desc' }, { id: 'asc' }],
page: query.page ?? 1,
perPage: query.perPage ?? 20,
},
req.ability,
);
return new PaginatedDTO(result.items.map(BookDTO.fromModel), result.pageMeta);
}
}
QueryTransformPipe can parse dotted query keys and JSON-like values before the DTO reaches QueryService.
GET /books/search?where.published=true&where.author.name.contains=Ada&where.author.name.mode=insensitive&include.author.include.books.select.id=true&include.author.include.books.select.title=true&include.tags=true&orderBy=[{"publishedAt":"desc"},{"id":"asc"}]&page=1&perPage=20
In real clients, URL-encode JSON arrays and objects. For local curl testing, curl -g is often useful because it prevents shell globbing for brackets.
Manual Fields Projection
prepareFieldsQuery is not tied to ResourceQuery. It can prepare includes and return the projection while you still call QueryService yourself.
@Get()
async findAll(@Query() query: QueryDTO<BookTypeMap>, @Req() req: RequestWithAbility) {
const prepared = prepareFieldsQuery(query, BookDTO);
const result = await this.booksService.query<BookModel>(prepared.query, req.ability);
const dtoItems = result.items.map(BookDTO.fromModel);
return new PaginatedDTO(Fields.project(dtoItems, prepared.projection), result.pageMeta);
}
For example, this request asks for author data through fields. prepareFieldsQuery merges the required relation include into the query before it reaches Prisma.
GET /books?fields=id,title,author{id,name}&where.published=true&orderBy={"title":"asc"}
Existing includes are preserved. Use baseInclude when the endpoint, CASL-aware mapper, or response policy needs relations even when the client does not request those relation fields. Client include values extend that base include.
const prepared = prepareFieldsQuery(
query,
BookDTO,
{
baseInclude: {
author: { select: { id: true, name: true } },
tags: true,
},
},
);
When fields is omitted, prepareFieldsQuery still returns a query copy with merged endpoint and client includes. Paginated ResourceQuery.query endpoints additionally support projecting the response envelope:
GET /books?fields=items{id,title},meta{page,perPage,itemCount,pageCount}
Detail Queries
Use findById when the route is ID-based and should throw Nest NotFoundException for missing records.
@Get(':id')
async findById(
@Param('id') id: string,
@Query() query: QueryDTO<BookTypeMap>,
@Req() req: RequestWithAbility,
) {
const prepared = prepareFieldsQuery(query, BookDTO);
const book = await this.booksService.findById<BookModel>(id, prepared.query, req.ability);
const dto = BookDTO.fromModel(book);
return Fields.project(dto, prepared.projection);
}
Use findUnique for natural unique keys such as isbn or slug. When an ability is passed, QueryService switches to findFirst internally so the accessibility filter can be merged into where.
@Get('isbn/:isbn')
async findByIsbn(@Param('isbn') isbn: string, @Req() req: RequestWithAbility) {
const book = await this.booksService.findUnique<BookModel>(
{
where: { isbn },
include: {
author: true,
tags: true,
},
},
req.ability,
);
return BookDTO.fromModel(book);
}
Aggregates And Counts
aggregate returns the delegate result with Decimal-like values serialized. count can receive the same ability parameter as paginated reads.
@Get('stats/summary')
async summary(@Req() req: RequestWithAbility) {
const where = {
published: true,
tags: { some: { name: { in: ['nestjs', 'api'] } } },
};
const [aggregate, accessibleCount] = await Promise.all([
this.booksService.aggregate({
where,
_count: true,
_min: { publishedAt: true },
_max: { publishedAt: true },
}),
this.booksService.count({ where }, req.ability),
]);
return {
aggregate,
accessibleCount,
};
}
CASL Merge Behavior
When the service was created with subject and accessibleWhere, all read helpers that accept an ability merge the access filter with your query filter:
// Effective where shape:
{
AND: [
accessibleWhere(ability, subject),
{ published: true, authorId: 'author_1' },
],
}
This applies to findOne, findMany, findById, findUnique with ability, query, and count. aggregate intentionally does not accept an ability parameter in V1; pass an already constrained where object when you expose aggregate endpoints.
Query Parsing
Inputs are normalized with parseQueryObject, a public wrapper around the bundled object utilities.
parseQueryObject({
'owner.email': 'ada@example.com',
archived: 'false',
tags: '["crm","internal"]',
});
// {
// owner: { email: 'ada@example.com' },
// archived: false,
// tags: ['crm', 'internal']
// }
Invalid JSON-like strings are preserved instead of throwing during parsing. Prisma validation errors are converted later by QueryService.
Error Mapping
- Prisma validation-like errors become
BadRequestException. - Known Prisma request errors with a
codebecomeBadRequestException. - Existing Nest
HttpExceptioninstances pass through. - Unexpected errors are logged through
errorLoggerwhen configured and then masked asInternalServerErrorException.
Scalar grouping
groupBy<T>(options, ability?) returns group rows, not resource models. Supported options:
by (required non-empty array), where, having, orderBy, take, skip,
_count, _sum, _avg, _min, _max. Pagination requires ordering. Prisma validates
model fields, aggregate selectors, and the relationship between by, having, and ordering.
select, include, fields, and cursor are unsupported.
await projectsService.groupBy({
by: ['status'],
where: { archived: false },
orderBy: { status: 'asc' },
take: 26,
_count: { _all: true },
}, ability);
Extend your model type map with orderByWithAggregationInput and
scalarWhereWithAggregatesInput using the corresponding generated Prisma types for
strict ordering and having inputs. Existing delegates need no groupBy method;
calling it on an unsupported delegate returns HTTP 501. A separate GroupByDelegate
capability is exported for adapters. Decimal aggregate values serialize to numbers.
Expose an application-owned GET /projects/group-by route before dynamic ID routes.
Use a concrete subclass of GroupByDTO<ProjectTypeMap> with your normal validation
pipe; the inherited Swagger decorators describe the options. Keep this route out of
ResourceQuery.query model mapping and projection. Validate allowed grouping and
aggregate fields against the endpoint's schema and the caller's field permissions.
Pass the same ability to grouping and leaf queries: access conditions are applied
before aggregation. Never replace tenant constraints with a caller's filter.
For exact scalar group keys (for example string "001", "true", or "null"),
configure the service with preserveJsonWhereTypes: true. Send where as a JSON
string for both grouping and leaf requests. When using QueryTransformPipe, enable
its matching opt-in: new QueryTransformPipe({ preserveJsonWhereTypes: true }).
Its default legacy coercion would otherwise erase key types before the service.
Configure the HTTP adapter's query parser for bracket notation, for example
app.getHttpAdapter().getInstance().set('query parser', value => qs.parse(value))
with Express and qs. Keep the normal transforming validation pipe enabled.
The opt-in also preserves already typed object filters when pagination forwards them internally.
Other consumers retain existing parsing defaults. Malformed or non-object JSON
filters produce HTTP 400. Use the same access resolver on both routes.
Nested table grouping calls groupBy once per level with one by field, _count._all,
and parent equality predicates in where. Leaf records use ordinary paginated
queries. Group counts cover every matching accessible record, not only loaded rows.
Offset pagination has no snapshot guarantee during concurrent writes; refresh the
view after mutations. Relations and date intervals are outside this feature.
Grouping keys consumed by the table must be JSON scalar values: strings, numbers, booleans, or null. Dates retain their normal ISO JSON encoding; Decimal-like values serialize to numbers. Binary and BigInt keys require application-owned encoding and filter conversion.