Reference
For AI agents
Condensed, accurate package facts for code generation. Prefer these over guessing.
This page is a tight, fact-only summary designed to be read by code-generation tools. Prefer the rules and gotchas below over re-deriving them from the rest of the docs.
Package
- Name:
nestjs-drizzle-crud - Version:
3.5.0 - License: MIT
- Peers:
@nestjs/common,@nestjs/core,drizzle-orm(>=0.28.0 <1.0.0),reflect-metadata. Optional:postgres(PG) /mysql2(MySQL),@nestjs/swagger(only forApiCrudQuery/ApiCrudList).
Setup is two steps and no per-service connection wiring
DrizzleCrudModule.forRoot({ dialect, connectionString | db, schema, defaults })once inAppModule.DrizzleCrudModule.forFeature([{ service, table, config? }])in each feature module.
A service is an empty subclass: do NOT inject the db or pass dialect/db
class XService extends SqlBaseCrudService<X, CreateXDto, UpdateXDto, XFilterDto> {}Rules / gotchas
- Generics order:
SqlBaseCrudService<Entity, CreateDto, UpdateDto, FilterDto>. All butEntitydefault toPartial<Entity>. - Do not add an
@Inject('DRIZZLE_DB')constructor,forFeatureconstructs the service for you. Adding a constructor that callssuper({...})is a legacy/manual pattern and is unnecessary. - The table is passed in
forFeature, not in the service. - If tables lack timestamp / soft-delete columns, set
defaults: { softDelete: false, timestamps: false }.softDelete.columnandtimestamps.createdAt/updatedAtname the key in the Drizzle table object (deletedAtfordeletedAt: timestamp('deleted_at')); a key the table lacks makes the service throw at startup with the key to use. softDelete,timestamps,paginationandsqlmerge one level deep across forRoot,@CrudServiceandforFeature:pagination: { defaultLimit: 5 }keepsmaxLimit.findAllreturns{ data, total, page, limit }. Not a bare array. With{ withCount: false }(or?withCount=false) it skipsCOUNT(*):totalisnullandhasMoreis a boolean.- Filter operators live inside an object value:
{ age: { gte: 18 } }. Operators:eq gt gte lt lte neq like ilike in notIn between isNull isNotNull startsWith endsWith contains iStartsWith iEndsWith iContains arrayContains arrayOverlaps jsonContains.startsWith/endsWith/containsescape the value and followcaseSensitive; theiforms are always case-insensitive; the array and JSON operators are PostgreSQL only. - Groups:
{ or: [filterA, filterB], and: [...], not: filter }, nestable, each member a full filter (columns, relations, collections), also incount.like/ilikerequire caller-supplied%wildcards (ilikeisLIKEon MySQL); a bare string is an exact match, case-insensitive on text columns whencaseSensitive === false, and%/_/\in the value are literal data, never wildcards.{ isNull: false }meansIS NOT NULL, and"true"/"false"strings work. - String filter values are fine on integer, uuid and enum columns (plain
=), and date strings are parsed for date columns. An unknown filter key, an unknown operator or an invalid date is a 400;null/undefinedvalues are skipped. findOne(where)throws a 400 when no condition is left (every valueundefined) or a key is not a column;nullmeansIS NULL.findAllalways endsORDER BYwith the primary key, so offset pages are stable.sortBytakes one column (withsortOrder, default'desc') or a list:['region.name', '-name']or[{ column, order }]; a dotted entry sorts by a top-level belongs-to column.findByCursor(filters, { cursor, limit, sortBy, count })is keyset pagination: returns{ data, limit, nextCursor, total? }; passnextCursorback ascursor;countdefaults to false.service.parseQuery(@Query() query)(orparseCrudQuery(query, table, options)) turns?age[gte]=18®ion[name]=Asia&or[0][code]=IN&sortBy=-name&page=2&fields=id,name&with=region&search=x&cursor=into{ filters, pagination, options, isCursor }, coercing by column type; unknown keys are a 400.withDeleted/onlyDeletedneedallowTrash: true. Type the FilterDto generic asCrudFilter.- Allowlists per entity:
filterableColumns,sortableColumns,selectableColumns,searchableColumns,loadableRelations,writableColumns. Unset allows everything; anything outside a set list is a 400.findOnewhere keys are not checked.skipAllowlists: truefor trusted calls. WithoutwritableColumns, create/update ignore the soft delete column, and update ignores the primary key and createdAt. findAllandcountacceptoptions.search: { term, columns, mode? }. Defaultmodeis'ilike'and joins cross-column contains checks withOR;mode: 'fullText'is PostgreSQL-only. Search is applied to both data and count queries.delete/softDeletereturnboolean;update/restorereturn the entity and throwEntityNotFoundException(→ 404) when missing. On Postgres,restore()returns the row viaRETURNINGrather than re-fetching it.- All exceptions extend
@nestjs/common'sHttpException, so Nest's default filter maps them to status codes automatically: 404 (not found), 409 (unique violation), 400 (validation / bad input, including Postgres22P02/22007/22008on reads). Custom filters caninstanceofthem.DatabaseConnectionExceptionandTransactionExceptionare deprecated; nothing throws them. - Relations: declare in
forFeatureconfig.relations, then eager-load withoptions.relations: ['relName'](a dotted path nests:['states', 'states.cities'],['state.country'], and the parent path is pulled in automatically).{ table, localKey, references? }is belongs-to:LEFT JOINed into the main query, resolves to an object ornull.{ table, type: 'hasMany', foreignKey, parentKey?, orderBy? }resolves to an array.{ table, type: 'manyToMany', through, throughLocalKey, throughForeignKey, references?, parentKey?, orderBy? }resolves to an array; the join-table key never appears in the result.- Nested relations go in a
relationsmap on the relation itself. - Every collection and every nested level costs exactly ONE extra query for the whole result set, never one per row. A page of 50 countries with their states and each state's cities is 4 queries: data, count, states, cities.
- A collection is never joined. Filter via
findAll({ relName: { col: value } })with the same operators: belongs-to uses the join, has-many and many-to-many compile toparentKey IN (SELECT ... WHERE ...), sototaland pagination stay correct. A relation filter can nest through the related table's ownrelationsat any depth ({ state: { country: { code: 'IN' } } },?state[country][code]=IN); only the top-level belongs-to is joined, deeper levels areEXISTS. A collection value may be{ some },{ none }or{ every }(NOT EXISTS;everyis true for a parent with no rows;some: {}= has any,none: {}= has none).filterableColumnstakes the full dotted path. - A relation missing its required keys throws when the service is constructed (nested included, with the full path in the message).
options.selectnarrows the projection and accepts dotted relation paths at any depth:['id', 'name', 'region.code', 'states.cities.name']. Unknown names are ignored. When every entry is dotted the base row is returned in full. The parent key, the child foreign key and any key a nested level needs are re-added automatically, because the grouping needs them.- The package ships an MCP server,
npx nestjs-drizzle-crud-mcp(stdio), withlist_docs,get_docandsearch_docsoverAGENT.md/README.md/CHANGELOG.md. Rules templates for every agent:AGENTS.template.mdandCLAUDE.template.md. - Primary keys:
primaryKey(default'id') per entity inforFeatureconfig.primaryKeyTypeis deprecated and has no effect. UUID works on Postgres viaRETURNING; withuuidkeys, route params are strings. Don't useParseIntPipe. - Nested collections default
parentKeyto their host table's own primary key, not the root service's. - Row locks use
FOR UPDATE OF <base table>, so they work with an eager belongs-to. - Full-text search is PostgreSQL-only.
fullTextSearch()always applies page and limit, and rejects unknown columns with a 400.sql.enableFullTextSearchandsql.jsonSupportare deprecated no-ops. - Bulk methods are transactional and set-based: one
SELECTthat checks the ids plus oneINSERT/UPDATE/DELETEfor the whole set. A failure throwsBulkOperationExceptionand rolls back the entire operation; its 400 body listserrors: [{ index | id, status, message }]for the failed items only.bulkMode: 'perItem'runs one single-row call per item instead. upsert(dto, { conflictTarget, update })andmassUpsert(rows, ...):ON CONFLICT DO UPDATEon Postgres,ON DUPLICATE KEY UPDATEon MySQL. A clash on another unique key is a 409.- A soft-deleted belongs-to parent comes back as
nulland never matches a relation filter. Trash: passonlyDeleted: true(orwithDeleted: true) in the options of any read. Purge:forceDelete(id)/massForceDelete(ids);delete(id)answers 404 for a row in the trash. executeSqlTransaction(async (tx) => ...)opens a transaction; pass{ transaction: tx }inoptionsto any CRUD method to enlist it.
Quick reference
Configure
DrizzleCrudModule.forRoot({
dialect: 'postgresql',
connectionString: process.env.DATABASE_URL,
schema,
});Service
class UsersService extends SqlBaseCrudService<User> {}Bind
DrizzleCrudModule.forFeature([{ service: UsersService, table: users }]);Filter
await service.findAll({
status: 'active', // exact
role: ['admin', 'editor'], // IN
age: { gte: 18, lt: 65 }, // operators
name: { startsWith: 'jo' }, // escaped pattern
publishedAt: { isNull: false }, // IS NOT NULL
or: [{ region: { name: 'Asia' } }, { population: { gte: 1e8 } }],
});Controller list endpoint
@Get()
findAll(@Query() query: Record<string, unknown>) {
const q = this.users.parseQuery(query);
return q.isCursor
? this.users.findByCursor(q.filters, q.pagination, q.options)
: this.users.findAll(q.filters, q.pagination, q.options);
}List search
await service.findAll(
{ status: 'active' },
{ page: 1, limit: 20 },
{ search: { term: 'john', columns: ['name', 'email'] } },
);Eager load relations
await service.findAll({}, { page: 1, limit: 20 }, { relations: ['state'] });Public exports (alphabetical)
and, asc, BaseCrudSpecHelper, between, BulkMode, BulkOperationException,
BulkOperationItemError, BulkOperationResult, ColumnFilter, CrudAllowlists,
CrudFeature, CrudFeatureConfig, CrudFilter, CrudService, CursorPage,
CursorPaginationOptions, DatabaseConnectionException, desc,
DRIZZLE_CONNECTION, DRIZZLE_CRUD_CONFIG, DRIZZLE_DB, DrizzleConnection,
DrizzleCrudConfig, DrizzleCrudModule, DuplicateEntityException,
EntityInUseException, eq, EntityNotFoundException, FilterGroup,
FilterOperator, FilterOperators, FilterScalar, ForeignKeyViolationException,
gt, gte, ICrudService, ilike, inArray, isNotNull, isNull, like, lt, lte, ne,
not, notInArray, OperationOptions, or, PaginationOptions, parseCrudQuery,
ParseCrudQueryOptions, ParsedCrudQuery, PrimaryKeyType, RelationConfig,
RelationNode, RelationsConfig, RelationType, SelectTree, SortColumn,
SortInput, SortOrder, sql, SqlBaseCrudService, SqlCrudConfig, SqlDialect,
SqlOperationOptions, SqlSearchMode, SqlSearchOptions, TestCrudFactory,
TransactionException, UpsertOptions, ValidationFailedExceptionAnti-patterns: do not generate these
// ❌ wrong: db is not injected
class UsersService extends SqlBaseCrudService<User> {
constructor(@Inject(DRIZZLE_DB) private db: any) {
super({ dialect: 'postgresql' as const, db, table: users });
}
}
// ❌ wrong: super() takes no arguments
class UsersService extends SqlBaseCrudService<User> {
constructor() {
super(/* nothing */);
}
}
// ❌ wrong: table is bound in forFeature, not the service
class UsersService extends SqlBaseCrudService<User, /* ... */, users> {}
// ❌ wrong: returning a bare array from a controller
@Get()
async findAll() {
return this.users.findAll({}); // returns { data, total, page, limit }
}
// ❌ wrong: ParseIntPipe on a UUID primary key
@Get(':id')
find(@Param('id', ParseIntPipe) id: number) { /* ... */ }Sources of truth
When in doubt, prefer:
- The TypeScript types,
import type { ... } from 'nestjs-drizzle-crud'is the canonical definition. - The package README on npm.
- The repo at
github.com/avytrix/nestjs-drizzle-crud. - The Changelog. Release-by-release behavior changes since 3.0.0.
This page summarizes (1) and (2) and may lag minor patch releases. If a generated snippet disagrees with the TypeScript types, trust the types.
3.5.0 rules
- Controllers: prefer
class XController extends createCrudController(XService, { dto: { create, update } })under@Controller('x')over hand-writing CRUD routes. Bulk routes are off until enabled inroutes. The id is parsed from the primary key column (ParseIntPipe/ParseUUIDPipe); do not add your own pipe unless you setidType. Do not override a generated method by name; disable the route and add your own. - OpenAPI:
@ApiCrudList({ table, ...entityConfig })on a list route. Keep the entity config in one constant shared withforFeature. Needs@nestjs/swaggerinstalled. - Tenancy: set
scope: (ctx) => ({ tenant_id: ctx.tenantId })on the entity and provide the context withCrudContext.middleware(req => ({ tenantId }))in a module'sconfigure,CrudContext.run(ctx, fn), oroptions.context. Never add a manualtenant_idfilter or set it in the body: the scope is ANDed into every read and write and written on create. Another tenant's id is a 404; a missing context value is a 403 (MissingScopeException).skipScope: trueonly for trusted jobs. Scoped upsert is PostgreSQL only. - Events:
hooks: { enableGlobalHooks: true }inforRoot, then injectCrudEventBusandsubscribe({ afterWrite(e) {...} }). One event per mass call (bulk: true). Usee.transactionto write audit rows atomically.options.skipEventsskips them;hooks.skipBefore/skipAfterdo not. - Optimistic locking:
versionColumn: 'version'(integer, default 1). Clients send the version they read in the update body (orexpectedVersion); a stale one is a 409StaleVersionException. Never write the version yourself. Bulk:expectedVersions: { [String(id)]: version }. - Relations on writes:
create(dto, { relations: ['author'] })returns the row with relations loaded (also update, upsert, restore, mass writes). Nested create needswritableRelations: ['posts']; has-many takes an array, belongs-to an object, many-to-many ids or objects; one transaction, PostgreSQL. Useattach(id, 'tags', ids),detach(id, 'tags', ids?),sync(id, 'tags', ids)for links, never raw inserts into the join table. - Aggregates:
options.counts: ['posts']adds_count: { posts: n }with one grouped query per relation (collections only);?counts=postsfrom a query string.countBy('status', filters)returns[{ value, count }]; the column must be filterable.