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

MethodReturnsNotes
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

MethodReturnsNotes
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, syncPromise<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.

MethodReturnsNotes
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

MethodReturnsNotes
parseQuery(query, options?)ParsedCrudQueryTurns @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:

users/users.service.ts
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 } });
  }
}
users/users.controller.ts
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.

list.ts
const page = await users.findAll(
  { name: { iContains: "ali" } },
  { page: 2, limit: 10, sortBy: ["-created_at", "name"] },
);
// { data: User[], total: 37, page: 2, limit: 10 }
dataOptional

T[]

The rows of this page, with any eager loaded relations attached.
totalOptional

number | null

Rows matching the filters across all pages. null when you passed withCount: false.
pageOptional

number

The page actually served. A missing, non-numeric or lower than 1 page becomes 1.
limitOptional

number

The page size actually used: the default (20) when missing, clamped to between 1 and maxLimit (100 by default).
hasMoreOptional

boolean

Only present with withCount: false. True when another page follows, found by fetching one extra row.

The pagination argument takes these keys:

pageOptional

number

1-based page number.

Defaults to 1

limitOptional

number

Page size, capped at pagination.maxLimit.

Defaults to 20

sortByOptional

string | Array<string | SortColumn>

A column, a list such as ["-created_at", "name"] (a - prefix sorts descending), or { column, order } objects. "region.name" sorts by a belongs-to column.
sortOrderOptional

"asc" | "desc"

Direction for a single sortBy string with no prefix. Anything else is a 400.

Defaults to "asc"

withCountOptional

boolean

false skips the COUNT(*) query: total is null and hasMore is set.

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.

MethodExceptionStatusWhen
find, findAll, count, existsValidationFailedException400A value the column type cannot parse, such as "abc" for an integer id.
findOne, findAll, countBadRequestException400An unknown column or operator, a bad sortOrder, a name outside an allowlist, or a findOne with no usable condition.
create, update, upsertDuplicateEntityException409A unique constraint is violated.
create, update, upsertValidationFailedException400A value too long, out of range or failing a check, a column outside writableColumns, or an update with no columns to set.
create, update, upsertForeignKeyViolationException400A foreign key points at a row that does not exist.
update, softDelete, restore, delete, forceDeleteEntityNotFoundException404No row with that id in scope. For delete, a soft deleted row also counts as missing.
update, softDelete, restore, deleteStaleVersionException409Optimistic locking is on and the row is at another version.
delete, forceDeleteEntityInUseException409Other rows still reference this one.
upsertEntityNotFoundException404On a scoped entity, the conflicting row belongs to another tenant.
Every mass* methodBulkOperationException400One or more items failed. errors lists each one with the status it would have had alone; nothing is written.
Every method on a scoped entityMissingScopeException403The 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 callRow is liveRow is soft deleted
softDelete(id)Sets deleted_at, returns true404
restore(id)Returns the row, deleted_at stays nullClears deleted_at, returns the row
delete(id)Removes it, returns true404
forceDelete(id)Removes it, returns trueRemoves 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.

transactionOptional

Drizzle transaction

Run the call inside a transaction you opened. See Transactions.
relationsOptional

string[]

Relations to eager load, dotted for nested levels: ["region", "states.cities"].
selectOptional

string[]

Columns to return instead of all of them. A dotted entry narrows a relation: "region.code".
searchOptional

{ term, columns, mode? }

A text search ANDed with the filters. mode is "ilike" (default) or "fullText".
withDeletedOptional

boolean

Include soft deleted rows.

Defaults to false

onlyDeletedOptional

boolean

Return only soft deleted rows. A 400 when soft delete is off.

Defaults to false

countsOptional

string[] | Record<string, true | CrudFilter>

Relation counts put on each row under _count.
lockOptional

"update" | "share" | "none"

FOR UPDATE or FOR SHARE on the read, PostgreSQL only. Use it inside a transaction. See Row locks.
forNoKeyUpdateOptional

boolean

Take FOR NO KEY UPDATE on the read, PostgreSQL only. Wins over lock.
hooks.skipBeforeOptional

boolean

Skip the before* hook of this call.

Defaults to false

hooks.skipAfterOptional

boolean

Skip the after* hook of this call.

Defaults to false

bulkModeOptional

"set" | "perItem"

Mass writes: one statement for the batch, or the single-row method per item in a savepoint.

Defaults to "set"

expectedVersionOptional

number

Optimistic locking: the version the caller last read. Another version is a 409.
expectedVersionsOptional

Record<string, number>

Optimistic locking for mass writes, keyed by id as a string.
skipAllowlistsOptional

boolean

Ignore the entity's allowlists for a trusted server-side call.

Defaults to false

skipScopeOptional

boolean

Run a trusted server-side call without the tenant scope.

Defaults to false

contextOptional

CrudContextValue

The context a function scope reads. Defaults to CrudContext.current().
skipEventsOptional

boolean

Do not raise module-level events for this call.

Defaults to false

Common questions

Next steps

Last updated View change history

On this page