CRUD operations with Drizzle in NestJS
The Drizzle ORM NestJS CRUD methods every service inherits, from find and findAll to create, update, softDelete and mass writes, with errors.
For Drizzle ORM NestJS CRUD, every service that extends SqlBaseCrudService inherits the same
methods: find, findOne, findAll, findByCursor, count and exists to read; create,
update and upsert to write; softDelete, restore, delete and forceDelete to remove; and
mass* versions that write a list in one transaction. Each method is typed against your row and
DTO types and throws NestJS HTTP exceptions, so a controller can return its result directly.
Missing rows: reads return, writes throw
find and findOne return null for a missing row, and your controller decides whether that
is a 404. update, softDelete, restore and delete throw EntityNotFoundException, which
NestJS answers with a 404 on its own.
Which methods does a service have?
These are the public methods of SqlBaseCrudService<T, CreateDto, UpdateDto, FilterDto>. T is
the row type, and every options argument is the SqlOperationOptions object listed in
the options table.
Reads
| Method | Returns | Notes |
|---|---|---|
find(id, options?) | Promise<T | null> | By primary key. Hides soft deleted rows unless withDeleted. |
findOne(where, options?) | Promise<T | null> | First row matching where: exact, case-sensitive values, null for IS NULL, or an operator object. undefined values are skipped. |
findAll(filters?, pagination?, options?) | Promise<CrudPage<T>> | { data, total, page, limit }. With withCount: false, total is null and hasMore is set. |
findByCursor(filters?, pagination?, options?) | Promise<CursorPage<T>> | { data, limit, nextCursor }, plus total when you pass count: true. See Cursor pagination. |
count(filters?, options?) | Promise<number> | Same filters and soft delete rule as findAll. |
countBy(column, filters?, options?) | Promise<CountByRow[]> | [{ value, count }], largest group first. See Aggregates. |
exists(id, options?) | Promise<boolean> | Selects only the primary key. A soft deleted row counts as missing. |
fullTextSearch(term, columns, pagination?, options?) | Promise<{ data, total, page, limit }> | PostgreSQL only, ranked by ts_rank. See Full-text search. |
Writes
| Method | Returns | Notes |
|---|---|---|
create(dto, options?) | Promise<T> | Runs validateCreate, beforeCreate, the insert, then afterCreate. Sets the timestamps. |
update(id, dto, options?) | Promise<T> | Checks the row exists, runs validateUpdate and beforeUpdate, updates, then afterUpdate. Sets updatedAt. |
upsert(dto, upsert?, options?) | Promise<T> | Insert, or update on a conflict. See Upsert. |
softDelete(id, options?) | Promise<boolean> | Sets the soft delete column. Needs soft delete on. |
restore(id, options?) | Promise<T> | Clears the soft delete column and returns the row. Needs soft delete on. |
delete(id, options?) | Promise<boolean> | Hard delete of a live row. |
forceDelete(id, options?) | Promise<boolean> | Hard delete whether the row is soft deleted or not. |
attach, detach, sync | Promise<T> | Change many-to-many links. See Relations on writes. |
Mass writes
Every mass write runs in one transaction and writes all rows or none.
| Method | Returns | Notes |
|---|---|---|
massCreate(dtos, options?) | Promise<T[]> | One multi-row INSERT (row by row on MySQL when the database generates the keys). |
massUpdate(ids, dto, options?) | Promise<T[]> | One UPDATE for every id, unless beforeUpdate returns different data per id. Rows come back in ids order. |
massUpsert(dtos, upsert?, options?) | Promise<T[]> | One upsert statement for the batch. |
massSoftDelete(ids, options?) | Promise<boolean> | Needs soft delete on. |
massRestore(ids, options?) | Promise<T[]> | Needs soft delete on. |
massDelete(ids, options?) | Promise<boolean> | Hard delete of live rows. |
massForceDelete(ids, options?) | Promise<boolean> | Hard delete, soft deleted or not. |
Duplicate ids in a mass update, delete or restore are written once. Pass bulkMode: "perItem" to run each item through the single-row
method in its own savepoint instead. Bulk operations covers both modes.
Helpers
| Method | Returns | Notes |
|---|---|---|
parseQuery(query, options?) | ParsedCrudQuery | Turns @Query() into filters, pagination and options. See Query parsing. |
parseId(raw) | Promise<unknown> | Parses a route id like the matching Nest pipe would. |
idKind() | "int" | "bigint" | "uuid" | "string" | The primary key kind, read from the Drizzle column. |
executeSqlTransaction is protected: call it from a method on your service, not from a
controller. See Transactions.
How do I use them in a service and a controller?
Call the inherited methods directly, and add your own methods to the service when a route needs more than one call. This service adds two methods on top of the inherited ones:
import { type CrudFilter, SqlBaseCrudService } from "@avytrix/nestjs-drizzle-crud";
import type { users } from "../db/schema";
import type { CreateUserDto, UpdateUserDto } from "./users.dto";
export type User = typeof users.$inferSelect;
export class UsersService extends SqlBaseCrudService<
User,
CreateUserDto,
UpdateUserDto,
CrudFilter
> {
findByEmail(email: string) {
return this.findOne({ email });
}
countCreatedSince(since: Date) {
return this.count({ created_at: { gte: since } });
}
}import {
Body,
Controller,
Delete,
Get,
HttpCode,
NotFoundException,
Param,
ParseIntPipe,
Patch,
Post,
Query,
} from "@nestjs/common";
import { CreateUserDto, UpdateUserDto } from "./users.dto";
import { UsersService } from "./users.service";
@Controller("users")
export class UsersController {
constructor(private readonly users: UsersService) {}
@Get()
list(@Query() query: Record<string, unknown>) {
const q = this.users.parseQuery(query);
return this.users.findAll(q.filters, q.pagination, q.options);
}
@Get(":id")
async get(@Param("id", ParseIntPipe) id: number) {
const user = await this.users.find(id, { relations: ["posts"] });
if (!user) throw new NotFoundException(`User ${id} not found`);
return user;
}
@Post()
create(@Body() dto: CreateUserDto) {
return this.users.create(dto);
}
@Post("bulk")
createMany(@Body() dtos: CreateUserDto[]) {
return this.users.massCreate(dtos);
}
@Patch(":id")
update(@Param("id", ParseIntPipe) id: number, @Body() dto: UpdateUserDto) {
return this.users.update(id, dto);
}
@Delete(":id")
remove(@Param("id", ParseIntPipe) id: number) {
return this.users.softDelete(id);
}
@Post(":id/restore")
@HttpCode(200)
restore(@Param("id", ParseIntPipe) id: number) {
return this.users.restore(id);
}
}relations: ["posts"] only works once the entity declares a posts relation; see
Relations. Drop it if yours has none.
What does findAll return?
findAll returns one page and the total count in a { data, total, page, limit } envelope. It
runs the data query and the COUNT(*) query together, so a list costs two queries, plus one
batched query per eager loaded relation that is not a joined belongs-to.
const page = await users.findAll(
{ name: { iContains: "ali" } },
{ page: 2, limit: 10, sortBy: ["-created_at", "name"] },
);
// { data: User[], total: 37, page: 2, limit: 10 }dataOptionalT[]
totalOptionalnumber | null
pageOptionalnumber
limitOptionalnumber
hasMoreOptionalboolean
The pagination argument takes these keys:
pageOptionalnumber
Defaults to 1
limitOptionalnumber
Defaults to 20
sortByOptionalstring | Array<string | SortColumn>
sortOrderOptional"asc" | "desc"
Defaults to "asc"
withCountOptionalboolean
Defaults to true
With no sortBy, rows follow the entity's defaultSort, and the primary key is always added
last so pages never overlap. Note the sortOrder default: sortBy: "name" on its own sorts
descending. Pagination and sorting covers every rule, and
Filtering covers the filters object.
Which exceptions does each method throw?
Each exception extends a NestJS HTTP exception, so the default exception filter returns the right
status without any setup. They come from
src/exceptions/crud.exceptions.ts.
| Method | Exception | Status | When |
|---|---|---|---|
find, findAll, count, exists | ValidationFailedException | 400 | A value the column type cannot parse, such as "abc" for an integer id. |
findOne, findAll, count | BadRequestException | 400 | An unknown column or operator, a bad sortOrder, a name outside an allowlist, or a findOne with no usable condition. |
create, update, upsert | DuplicateEntityException | 409 | A unique constraint is violated. |
create, update, upsert | ValidationFailedException | 400 | A value too long, out of range or failing a check, a column outside writableColumns, or an update with no columns to set. |
create, update, upsert | ForeignKeyViolationException | 400 | A foreign key points at a row that does not exist. |
update, softDelete, restore, delete, forceDelete | EntityNotFoundException | 404 | No row with that id in scope. For delete, a soft deleted row also counts as missing. |
update, softDelete, restore, delete | StaleVersionException | 409 | Optimistic locking is on and the row is at another version. |
delete, forceDelete | EntityInUseException | 409 | Other rows still reference this one. |
upsert | EntityNotFoundException | 404 | On a scoped entity, the conflicting row belongs to another tenant. |
Every mass* method | BulkOperationException | 400 | One or more items failed. errors lists each one with the status it would have had alone; nothing is written. |
| Every method on a scoped entity | MissingScopeException | 403 | The scope needs a context value, such as a tenant id, that the call does not have. |
softDelete, restore, massSoftDelete and massRestore on an entity with soft delete off,
and fullTextSearch on MySQL (or search.mode: "fullText" there), are a 400
BadRequestException. Errors shows the response
bodies and how to write a custom filter.
How do delete, softDelete and forceDelete differ?
softDelete hides a row by setting its deleted_at column, delete removes a live row, and
forceDelete removes a row whether it is soft deleted or not.
| You call | Row is live | Row is soft deleted |
|---|---|---|
softDelete(id) | Sets deleted_at, returns true | 404 |
restore(id) | Returns the row, deleted_at stays null | Clears deleted_at, returns the row |
delete(id) | Removes it, returns true | 404 |
forceDelete(id) | Removes it, returns true | Removes it, returns true |
Reads skip soft deleted rows by default. Pass withDeleted: true to include them, or
onlyDeleted: true for a trash view. Soft delete covers the column setting
and the trash routes.
Which options does every method take?
The last argument of every method is SqlOperationOptions. A method ignores the keys that do not
apply to it.
transactionOptionalDrizzle transaction
relationsOptionalstring[]
selectOptionalstring[]
searchOptional{ term, columns, mode? }
withDeletedOptionalboolean
Defaults to false
onlyDeletedOptionalboolean
Defaults to false
countsOptionalstring[] | Record<string, true | CrudFilter>
lockOptional"update" | "share" | "none"
forNoKeyUpdateOptionalboolean
hooks.skipBeforeOptionalboolean
Defaults to false
hooks.skipAfterOptionalboolean
Defaults to false
bulkModeOptional"set" | "perItem"
Defaults to "set"
expectedVersionOptionalnumber
expectedVersionsOptionalRecord<string, number>
skipAllowlistsOptionalboolean
Defaults to false
skipScopeOptionalboolean
Defaults to false
contextOptionalCrudContextValue
skipEventsOptionalboolean
Defaults to false
Common questions
A read that finds nothing is not an error in the service, so find and findOne return null and the controller decides. The write methods throw EntityNotFoundException, which becomes a 404.
An object with data, total, page and limit. page and limit are the values actually used after clamping, and with withCount: false the total is null and hasMore is added.
No, delete treats a soft deleted row as missing and throws a 404. Use forceDelete to remove a row whether it is soft deleted or not.
No. They check every item, then throw one BulkOperationException that lists each failed item with its status, and the transaction writes nothing.
No, it is a protected method. Add a method to your service that calls it, and call that method from the controller.
Next steps
Filtering
Operators, groups and relation filters for findAll, findByCursor and count.
Pagination and sorting
Page sizes, limits, multi-column and relation sorts, and skipping the count.
Soft delete
The deleted_at column, trash views, restore and purge.
API reference
Every signature and type in one place.
NestJS Drizzle CRUD quick start
Build NestJS Drizzle CRUD endpoints for one table. Define the schema, configure the module, add an empty service and call it from a controller.
Define a NestJS Drizzle CRUD service
A NestJS Drizzle CRUD service is an empty subclass of SqlBaseCrudService. forFeature injects the db and table, and you add custom methods.