@avytrix/nestjs-drizzle-crud 0.0.0-stage → 3.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/AGENT.md +513 -0
  2. package/AGENTS.template.md +31 -0
  3. package/CHANGELOG.md +512 -0
  4. package/CLAUDE.template.md +18 -0
  5. package/LICENSE +21 -0
  6. package/README.md +1300 -2
  7. package/dist/controller/crud-controller.factory.d.ts +27 -0
  8. package/dist/controller/crud-controller.factory.js +251 -0
  9. package/dist/controller/crud-controller.factory.js.map +1 -0
  10. package/dist/core/abstract/sql-base-crud.service.d.ts +225 -0
  11. package/dist/core/abstract/sql-base-crud.service.js +2866 -0
  12. package/dist/core/abstract/sql-base-crud.service.js.map +1 -0
  13. package/dist/core/context/crud-context.d.ts +6 -0
  14. package/dist/core/context/crud-context.js +27 -0
  15. package/dist/core/context/crud-context.js.map +1 -0
  16. package/dist/core/events/crud-event-bus.d.ts +7 -0
  17. package/dist/core/events/crud-event-bus.js +30 -0
  18. package/dist/core/events/crud-event-bus.js.map +1 -0
  19. package/dist/core/interfaces/crud-service.interface.d.ts +54 -0
  20. package/dist/core/interfaces/crud-service.interface.js +3 -0
  21. package/dist/core/interfaces/crud-service.interface.js.map +1 -0
  22. package/dist/core/interfaces/drizzle-crud-config.interface.d.ts +39 -0
  23. package/dist/core/interfaces/drizzle-crud-config.interface.js +3 -0
  24. package/dist/core/interfaces/drizzle-crud-config.interface.js.map +1 -0
  25. package/dist/core/interfaces/events.interface.d.ts +39 -0
  26. package/dist/core/interfaces/events.interface.js +3 -0
  27. package/dist/core/interfaces/events.interface.js.map +1 -0
  28. package/dist/core/interfaces/query.interface.d.ts +112 -0
  29. package/dist/core/interfaces/query.interface.js +3 -0
  30. package/dist/core/interfaces/query.interface.js.map +1 -0
  31. package/dist/core/interfaces/scope.interface.d.ts +3 -0
  32. package/dist/core/interfaces/scope.interface.js +3 -0
  33. package/dist/core/interfaces/scope.interface.js.map +1 -0
  34. package/dist/core/interfaces/sql-crud-config.interface.d.ts +87 -0
  35. package/dist/core/interfaces/sql-crud-config.interface.js +3 -0
  36. package/dist/core/interfaces/sql-crud-config.interface.js.map +1 -0
  37. package/dist/core/types/sql.types.d.ts +7 -0
  38. package/dist/core/types/sql.types.js +3 -0
  39. package/dist/core/types/sql.types.js.map +1 -0
  40. package/dist/core/utils/cursor.d.ts +2 -0
  41. package/dist/core/utils/cursor.js +44 -0
  42. package/dist/core/utils/cursor.js.map +1 -0
  43. package/dist/core/utils/merge-config.d.ts +2 -0
  44. package/dist/core/utils/merge-config.js +22 -0
  45. package/dist/core/utils/merge-config.js.map +1 -0
  46. package/dist/core/utils/query-parser.d.ts +2 -0
  47. package/dist/core/utils/query-parser.js +504 -0
  48. package/dist/core/utils/query-parser.js.map +1 -0
  49. package/dist/decorators/crud-service.decorator.d.ts +4 -0
  50. package/dist/decorators/crud-service.decorator.js +10 -0
  51. package/dist/decorators/crud-service.decorator.js.map +1 -0
  52. package/dist/decorators/index.d.ts +1 -0
  53. package/dist/decorators/index.js +6 -0
  54. package/dist/decorators/index.js.map +1 -0
  55. package/dist/exceptions/crud.exceptions.d.ts +46 -0
  56. package/dist/exceptions/crud.exceptions.js +93 -0
  57. package/dist/exceptions/crud.exceptions.js.map +1 -0
  58. package/dist/index.d.ts +23 -0
  59. package/dist/index.js +61 -0
  60. package/dist/index.js.map +1 -0
  61. package/dist/mcp/server.d.ts +8 -0
  62. package/dist/mcp/server.js +236 -0
  63. package/dist/mcp/server.js.map +1 -0
  64. package/dist/modules/drizzle-connection.d.ts +12 -0
  65. package/dist/modules/drizzle-connection.js +73 -0
  66. package/dist/modules/drizzle-connection.js.map +1 -0
  67. package/dist/modules/drizzle-crud.module.d.ts +11 -0
  68. package/dist/modules/drizzle-crud.module.js +165 -0
  69. package/dist/modules/drizzle-crud.module.js.map +1 -0
  70. package/dist/openapi/api-crud.decorators.d.ts +10 -0
  71. package/dist/openapi/api-crud.decorators.js +216 -0
  72. package/dist/openapi/api-crud.decorators.js.map +1 -0
  73. package/dist/test-utils/base-crud.spec-helper.d.ts +64 -0
  74. package/dist/test-utils/base-crud.spec-helper.js +119 -0
  75. package/dist/test-utils/base-crud.spec-helper.js.map +1 -0
  76. package/dist/test-utils/index.d.ts +2 -0
  77. package/dist/test-utils/index.js +8 -0
  78. package/dist/test-utils/index.js.map +1 -0
  79. package/dist/test-utils/test-factory.d.ts +29 -0
  80. package/dist/test-utils/test-factory.js +81 -0
  81. package/dist/test-utils/test-factory.js.map +1 -0
  82. package/package.json +101 -5
package/README.md CHANGED
@@ -1,3 +1,1301 @@
1
- # Temporary Holding Version
1
+ # nestjs-drizzle-crud
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![docs](https://img.shields.io/badge/docs-crud.avytrix.dev-E0234E)](https://crud.avytrix.dev)
4
+ [![npm](https://img.shields.io/npm/v/nestjs-drizzle-crud?color=E0234E)](https://www.npmjs.com/package/nestjs-drizzle-crud)
5
+ [![license](https://img.shields.io/npm/l/nestjs-drizzle-crud)](./LICENSE)
6
+ [![Sponsor @avytrix](https://img.shields.io/badge/Sponsor-%40avytrix-db61a2?style=for-the-badge&logo=githubsponsors&logoColor=white)](https://github.com/sponsors/avytrix)
7
+
8
+ Built and maintained by **[Avytrix Technologies](https://avytrix.dev)**.
9
+
10
+ ## ❤️ Sponsoring is what keeps this package free
11
+
12
+ A CRUD layer sounds simple until you own it in production: dialect differences,
13
+ driver result shapes, error codes that have to become the right HTTP status,
14
+ counts that stay correct once filters and joins are in play. This package
15
+ absorbs that work and gives it away under MIT.
16
+
17
+ Keeping it current with NestJS, Drizzle and TypeScript releases is ongoing, and
18
+ sponsors are what make keeping it free sustainable.
19
+
20
+ **Even $1 a month helps.** It funds bug fixes, new guides, and the next release.
21
+
22
+ > Sponsors get a spot on the [sponsor page](https://crud.avytrix.dev/docs/sponsor)
23
+ > and our thanks.
24
+
25
+ ### 👉 [Sponsor on GitHub →](https://github.com/sponsors/avytrix)  ·  thank you 🙏
26
+
27
+ ---
28
+
29
+ A type-safe CRUD layer for [Drizzle ORM](https://orm.drizzle.team/) in [NestJS](https://nestjs.com/) applications.
30
+
31
+ **[Documentation](https://crud.avytrix.dev)** · **[npm](https://www.npmjs.com/package/nestjs-drizzle-crud)** · **[GitHub](https://github.com/avytrix/nestjs-drizzle-crud)** · **[Sponsor](https://github.com/sponsors/avytrix)**
32
+
33
+ Configure the database connection **once**, then every entity gets full CRUD (find / create / update / delete / soft-delete / bulk / pagination / filtering / full-text search) by extending one base class. No per-service connection wiring.
34
+
35
+ ```typescript
36
+ // 1. configure once (app.module.ts)
37
+ DrizzleCrudModule.forRoot({
38
+ dialect: 'postgresql',
39
+ connectionString: process.env.DATABASE_URL,
40
+ schema,
41
+ });
42
+
43
+ // 2. a fully-featured CRUD service is just:
44
+ export class UsersService extends SqlBaseCrudService<User> {}
45
+
46
+ // 3. bind it to a table (users.module.ts)
47
+ DrizzleCrudModule.forFeature([{ service: UsersService, table: users }]);
48
+ ```
49
+
50
+ ---
51
+
52
+ ## Table of contents
53
+
54
+ - [Features](#features)
55
+ - [Installation](#installation)
56
+ - [Quick start](#quick-start)
57
+ - [Configuration](#configuration)
58
+ - [Defining services](#defining-services)
59
+ - [API reference](#api-reference)
60
+ - [Filtering](#filtering)
61
+ - [Query strings: parseQuery](#query-strings-parsequery)
62
+ - [Allowlists](#allowlists)
63
+ - [Pagination & sorting](#pagination--sorting)
64
+ - [Cursor pagination](#cursor-pagination)
65
+ - [Relations](#relations)
66
+ - [Column selection](#column-selection)
67
+ - [Primary keys (serial / uuid)](#primary-keys-serial--uuid)
68
+ - [Soft delete](#soft-delete)
69
+ - [Timestamps](#timestamps)
70
+ - [Bulk operations](#bulk-operations)
71
+ - [Upsert](#upsert)
72
+ - [Relations on writes](#relations-on-writes)
73
+ - [Controller factory](#controller-factory)
74
+ - [OpenAPI (Swagger)](#openapi-swagger)
75
+ - [Multi-tenancy: scope](#multi-tenancy-scope)
76
+ - [Events and global hooks](#events-and-global-hooks)
77
+ - [Optimistic locking](#optimistic-locking)
78
+ - [Counts and grouped counts](#counts-and-grouped-counts)
79
+ - [Transactions](#transactions)
80
+ - [Full-text search (PostgreSQL)](#full-text-search-postgresql)
81
+ - [Lifecycle hooks & validation](#lifecycle-hooks--validation)
82
+ - [Testing](#testing)
83
+ - [For AI agents / LLM tools](#for-ai-agents--llm-tools)
84
+ - [Support](#support)
85
+
86
+ ---
87
+
88
+ ## Features
89
+
90
+ - 🚀 **CRUD**: `find`, `findOne`, `findAll`, `create`, `update`, `delete`, and more
91
+ - 🧩 **Configure once**: `forRoot()` owns the connection; services only declare a table
92
+ - ⚡ **Type-safe**: generics over your entity, create/update DTOs and filter types
93
+ - 🗃️ **PostgreSQL & MySQL**: dialect-aware (`RETURNING` vs `insertId`)
94
+ - 🔄 **Soft delete**: opt-in, with `restore`, a trash view (`onlyDeleted`) and `forceDelete`
95
+ - 📦 **Bulk operations**: one statement per mass create/update/delete, inside a transaction, plus `upsert`
96
+ - 🔍 **Filtering**: comparison, `in`/`notIn`, `between`, escaped `contains`/`startsWith`, array and JSON operators, `or`/`not` groups
97
+ - 🧾 **Query string parsing**: `parseQuery` turns `?age[gte]=18&sortBy=-name` into typed filters
98
+ - 🛡️ **Allowlists**: limit what a client may filter, sort, select and write
99
+ - 📜 **Cursor pagination**: `findByCursor` with an opaque `nextCursor`
100
+ - 🔎 **Full-text search**: PostgreSQL `tsvector`/`tsquery`
101
+ - 🔗 **Relations**: belongs-to, has-many, many-to-many and nested eager loading with no N+1, filtering through relation paths with `some` / `none` / `every`, and relations on writes (nested create, `attach` / `detach` / `sync`)
102
+ - 🧭 **Controller factory**: `createCrudController(Service)` wires list, get, create, update, delete, trash, restore and bulk routes, with the id pipe picked from the key
103
+ - 📘 **OpenAPI**: `@ApiCrudList()` documents every list parameter and filterable column (optional `@nestjs/swagger` peer)
104
+ - 🏢 **Multi-tenancy**: a `scope` keeps every read and write inside the caller's tenant; another tenant's id is a 404
105
+ - 📣 **Events**: module-level before/after events for every write, bulk included, for audit trails and cache busting
106
+ - 🔒 **Optimistic locking**: a version column, and a stale write is a 409
107
+ - 🔢 **Counts**: `_count` for collections and `countBy(column)`, batched; `withCount: false` skips `COUNT(*)`
108
+ - 🎯 **Column selection**: ask for the columns you need, relation columns included
109
+ - 🤖 **AI agents**: rules templates for each agent tool, and a bundled MCP server
110
+ - 🔑 **Primary keys**: `serial` / `int` / `bigint` / `bigserial` / `uuid`
111
+ - 🪝 **Hooks & validation**: `before*`/`after*` hooks, `validateCreate`/`validateUpdate`
112
+ - 🧪 **Test utilities**: mock db/table/entity factories
113
+
114
+ ---
115
+
116
+ ## Installation
117
+
118
+ ```bash
119
+ pnpm add nestjs-drizzle-crud
120
+ # or
121
+ npm install nestjs-drizzle-crud
122
+ # or
123
+ yarn add nestjs-drizzle-crud
124
+ ```
125
+
126
+ Peer dependencies (install the ones you use):
127
+
128
+ ```bash
129
+ # always
130
+ pnpm add @nestjs/common @nestjs/core drizzle-orm reflect-metadata
131
+
132
+ # PostgreSQL (also required if you use `connectionString` with dialect 'postgresql')
133
+ pnpm add postgres
134
+
135
+ # MySQL
136
+ pnpm add mysql2
137
+
138
+ # Only for ApiCrudQuery / ApiCrudList (OpenAPI docs)
139
+ pnpm add @nestjs/swagger
140
+ ```
141
+
142
+ > Using npm or yarn? Swap `pnpm add` for `npm install` / `yarn add`.
143
+
144
+ > **pnpm note:** `postgres`/`mysql2` are optional peer dependencies. pnpm enforces
145
+ > peer deps strictly, so if you build the connection from a `connectionString`
146
+ > make sure the matching driver is installed in your app.
147
+
148
+ > `postgres` is an **optional** peer dependency. It's only needed when you let the
149
+ > module build the connection from a `connectionString` for the `postgresql` dialect.
150
+ > If you pass a pre-built `db` instead, you don't need it.
151
+
152
+ ---
153
+
154
+ ## Quick start
155
+
156
+ ### 1. Define your Drizzle schema
157
+
158
+ ```typescript
159
+ // db/schema.ts
160
+ import { pgTable, serial, varchar } from 'drizzle-orm/pg-core';
161
+
162
+ export const users = pgTable('users', {
163
+ id: serial('id').primaryKey(),
164
+ name: varchar('name', { length: 100 }).notNull(),
165
+ email: varchar('email', { length: 255 }).notNull().unique(),
166
+ });
167
+
168
+ export const schema = { users };
169
+ export type User = typeof users.$inferSelect;
170
+ ```
171
+
172
+ ### 2. Configure the module once
173
+
174
+ ```typescript
175
+ // app.module.ts
176
+ import { Module } from '@nestjs/common';
177
+ import { DrizzleCrudModule } from 'nestjs-drizzle-crud';
178
+ import { schema } from './db/schema';
179
+ import { UsersModule } from './users/users.module';
180
+
181
+ @Module({
182
+ imports: [
183
+ DrizzleCrudModule.forRoot({
184
+ dialect: 'postgresql',
185
+ connectionString: process.env.DATABASE_URL,
186
+ schema,
187
+ }),
188
+ UsersModule,
189
+ ],
190
+ })
191
+ export class AppModule {}
192
+ ```
193
+
194
+ The connection is created here, exposed globally, and closed automatically on
195
+ application shutdown (when the module built it from a `connectionString`).
196
+
197
+ ### 3. Create a service
198
+
199
+ ```typescript
200
+ // users/users.service.ts
201
+ import { SqlBaseCrudService } from 'nestjs-drizzle-crud';
202
+ import type { User } from '../db/schema';
203
+
204
+ export interface CreateUserDto { name: string; email: string }
205
+ export interface UpdateUserDto { name?: string; email?: string }
206
+ export interface UserFilterDto { name?: string; email?: string }
207
+
208
+ export class UsersService extends SqlBaseCrudService<
209
+ User,
210
+ CreateUserDto,
211
+ UpdateUserDto,
212
+ UserFilterDto
213
+ > {}
214
+ ```
215
+
216
+ ### 4. Bind the service to a table
217
+
218
+ ```typescript
219
+ // users/users.module.ts
220
+ import { Module } from '@nestjs/common';
221
+ import { DrizzleCrudModule } from 'nestjs-drizzle-crud';
222
+ import { users } from '../db/schema';
223
+ import { UsersController } from './users.controller';
224
+ import { UsersService } from './users.service';
225
+
226
+ @Module({
227
+ imports: [
228
+ DrizzleCrudModule.forFeature([{ service: UsersService, table: users }]),
229
+ ],
230
+ controllers: [UsersController],
231
+ })
232
+ export class UsersModule {}
233
+ ```
234
+
235
+ ### 5. Use it in a controller
236
+
237
+ ```typescript
238
+ // users/users.controller.ts
239
+ import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post, Put, Query } from '@nestjs/common';
240
+ import { CreateUserDto, UpdateUserDto, UsersService } from './users.service';
241
+
242
+ @Controller('users')
243
+ export class UsersController {
244
+ constructor(private readonly users: UsersService) {}
245
+
246
+ // ?status=active&age[gte]=18&sortBy=-created_at&page=2&limit=20
247
+ @Get()
248
+ findAll(@Query() query: Record<string, unknown>) {
249
+ const q = this.users.parseQuery(query);
250
+ return this.users.findAll(q.filters, q.pagination, q.options);
251
+ }
252
+
253
+ @Get(':id')
254
+ find(@Param('id', ParseIntPipe) id: number) {
255
+ return this.users.find(id);
256
+ }
257
+
258
+ @Post()
259
+ create(@Body() dto: CreateUserDto) {
260
+ return this.users.create(dto);
261
+ }
262
+
263
+ @Put(':id')
264
+ update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateUserDto) {
265
+ return this.users.update(id, dto);
266
+ }
267
+
268
+ @Delete(':id')
269
+ remove(@Param('id', ParseIntPipe) id: number) {
270
+ return this.users.delete(id);
271
+ }
272
+ }
273
+ ```
274
+
275
+ ---
276
+
277
+ ## Configuration
278
+
279
+ ### `DrizzleCrudModule.forRoot(config)`
280
+
281
+ | Field | Type | Description |
282
+ |---|---|---|
283
+ | `dialect` | `'postgresql' \| 'mysql'` | **Required.** Database dialect. |
284
+ | `connectionString` | `string` | Connection string. The module builds the connection (PostgreSQL only). |
285
+ | `db` | `Drizzle instance` | Alternatively, pass a Drizzle instance you built yourself (any dialect). |
286
+ | `schema` | `Record<string, unknown>` | Drizzle schema, used when building from `connectionString`. |
287
+ | `defaults.softDelete` | `boolean` | Enable soft delete for all entities (default `true`). |
288
+ | `defaults.timestamps` | `boolean` | Auto-manage `created_at`/`updated_at` for all entities (default `true`). |
289
+ | `defaults.pagination` | `{ defaultLimit?, maxLimit? }` | Pagination defaults (default `{ 20, 100 }`). Either key alone keeps the other's default. |
290
+ | `defaults.sortOrder` | `'asc' \| 'desc'` | Opt-in default sort: entities **without** an explicit `defaultSort` fall back to ordering by their `created_at` column in this direction. Omit it and `findAll` orders by the primary key only. |
291
+ | `sql` | `{ caseSensitive?, useReturning? }` | Dialect tuning. `useReturning` defaults to `true` for PostgreSQL, `false` for MySQL. `jsonSupport` and `enableFullTextSearch` are deprecated and do nothing. |
292
+ | `hooks` | `{ enableGlobalHooks?, subscribers? }` | Module-level write events on the `CrudEventBus`. See [Events](#events-and-global-hooks). |
293
+
294
+ > **Provide exactly one of `connectionString` or `db`.** If your tables have no
295
+ > `created_at`/`updated_at`/`deleted_at` columns, set
296
+ > `defaults: { softDelete: false, timestamps: false }`. The soft-delete and
297
+ > timestamp settings name the **key** in your Drizzle table object; with
298
+ > camelCase keys (`deletedAt: timestamp('deleted_at')`) set them per entity:
299
+ > `softDelete: { column: 'deletedAt' }`,
300
+ > `timestamps: { createdAt: 'createdAt', updatedAt: 'updatedAt' }`. A key the
301
+ > table does not have stops the app at startup, and the message names the key
302
+ > to use.
303
+
304
+ **Build the connection yourself (any dialect, recommended for MySQL):**
305
+
306
+ ```typescript
307
+ import { drizzle } from 'drizzle-orm/postgres-js';
308
+ import postgres from 'postgres';
309
+
310
+ DrizzleCrudModule.forRoot({
311
+ dialect: 'postgresql',
312
+ db: drizzle(postgres(process.env.DATABASE_URL!), { schema }),
313
+ });
314
+ ```
315
+
316
+ ### `DrizzleCrudModule.forRootAsync(options)`
317
+
318
+ ```typescript
319
+ DrizzleCrudModule.forRootAsync({
320
+ imports: [ConfigModule],
321
+ inject: [ConfigService],
322
+ useFactory: (cfg: ConfigService) => ({
323
+ dialect: 'postgresql',
324
+ connectionString: cfg.get('DATABASE_URL'),
325
+ schema,
326
+ }),
327
+ });
328
+ ```
329
+
330
+ ### `DrizzleCrudModule.forFeature(entities)`
331
+
332
+ Registers one or more services and binds each to its table. Per-entity overrides
333
+ go in `config`:
334
+
335
+ ```typescript
336
+ DrizzleCrudModule.forFeature([
337
+ { service: UsersService, table: users },
338
+ {
339
+ service: PostsService,
340
+ table: posts,
341
+ config: {
342
+ primaryKey: 'uuid',
343
+ softDelete: { enabled: true, column: 'deleted_at' },
344
+ // Default ORDER BY for findAll() when the caller passes no sortBy.
345
+ defaultSort: [
346
+ { column: 'position', order: 'asc' },
347
+ { column: 'created_at', order: 'desc' }, // tiebreaker
348
+ ],
349
+ },
350
+ },
351
+ ]);
352
+ ```
353
+
354
+ Anything in `config` overrides the project defaults for that entity. The shape is
355
+ [`SqlCrudConfig`](#sqlcrudconfig) minus `db`/`dialect`.
356
+
357
+ ---
358
+
359
+ ## Defining services
360
+
361
+ The minimal service is an empty class. Connection, dialect and defaults are
362
+ injected by the module:
363
+
364
+ ```typescript
365
+ export class UsersService extends SqlBaseCrudService<User> {}
366
+ ```
367
+
368
+ Add custom behaviour by overriding hooks (see [Lifecycle hooks](#lifecycle-hooks--validation))
369
+ or add your own methods:
370
+
371
+ ```typescript
372
+ export class UsersService extends SqlBaseCrudService<User, CreateUserDto, UpdateUserDto, UserFilterDto> {
373
+ findByEmail(email: string) {
374
+ return this.findOne({ email } as Partial<User>);
375
+ }
376
+
377
+ protected async validateCreate(data: CreateUserDto): Promise<void> {
378
+ if (!data.email.includes('@')) throw new Error('Invalid email');
379
+ }
380
+ }
381
+ ```
382
+
383
+ ---
384
+
385
+ ## API reference
386
+
387
+ `SqlBaseCrudService<T, CreateDto = Partial<T>, UpdateDto = Partial<T>, FilterDto = Partial<T>>`
388
+
389
+ ### Read
390
+
391
+ | Method | Returns | Notes |
392
+ |---|---|---|
393
+ | `find(id, options?)` | `Promise<T \| null>` | By primary key. Skips soft-deleted rows. |
394
+ | `findOne(where, options?)` | `Promise<T \| null>` | Exact values, `null` for `IS NULL`, or operator objects. |
395
+ | `findAll(filters?, pagination?, options?)` | `Promise<{ data: T[]; total: number; page: number; limit: number }>` | See [Filtering](#filtering) / [Pagination](#pagination--sorting). With `withCount: false`, `total` is `null` and `hasMore` is set. |
396
+ | `findByCursor(filters?, pagination?, options?)` | `Promise<{ data: T[]; limit: number; nextCursor: string \| null; total?: number }>` | Keyset pages. See [Cursor pagination](#cursor-pagination). |
397
+ | `exists(id, options?)` | `Promise<boolean>` | |
398
+ | `count(filters?, options?)` | `Promise<number>` | |
399
+ | `countBy(column, filters?, options?)` | `Promise<{ value; count }[]>` | Grouped counts, largest first. See [Counts](#counts-and-grouped-counts). |
400
+ | `parseQuery(query, options?)` | `ParsedCrudQuery` | See [Query strings](#query-strings-parsequery). |
401
+ | `parseId(raw)` | `Promise<unknown>` | Parses a route id by the key's type (ParseIntPipe, ParseUUIDPipe, bigint, string). |
402
+
403
+ ### Write
404
+
405
+ | Method | Returns | Notes |
406
+ |---|---|---|
407
+ | `create(data, options?)` | `Promise<T>` | Runs `validateCreate` → `beforeCreate` → insert → `afterCreate`. |
408
+ | `update(id, data, options?)` | `Promise<T>` | Throws `EntityNotFoundException` if missing. |
409
+ | `delete(id, options?)` | `Promise<boolean>` | Hard delete. |
410
+ | `softDelete(id, options?)` | `Promise<boolean>` | Requires soft delete enabled. |
411
+ | `restore(id, options?)` | `Promise<T>` | Clears the soft-delete column. |
412
+ | `forceDelete(id, options?)` | `Promise<boolean>` | Hard delete, soft-deleted or not. |
413
+ | `upsert(data, upsert?, options?)` | `Promise<T>` | Insert or update by a unique key. See [Upsert](#upsert). |
414
+ | `attach(id, relation, ids, options?)` | `Promise<T>` | Link many-to-many rows. See [Relations on writes](#relations-on-writes). |
415
+ | `detach(id, relation, ids?, options?)` | `Promise<T>` | Unlink some, or all when `ids` is left out. |
416
+ | `sync(id, relation, ids, options?)` | `Promise<T>` | Make the links exactly `ids`. |
417
+
418
+ ### Bulk (run inside a transaction)
419
+
420
+ | Method | Returns |
421
+ |---|---|
422
+ | `massCreate(data[], options?)` | `Promise<T[]>` |
423
+ | `massUpdate(ids[], data, options?)` | `Promise<T[]>` |
424
+ | `massSoftDelete(ids[], options?)` | `Promise<boolean>` |
425
+ | `massRestore(ids[], options?)` | `Promise<T[]>` |
426
+ | `massDelete(ids[], options?)` | `Promise<boolean>` |
427
+ | `massForceDelete(ids[], options?)` | `Promise<boolean>` |
428
+ | `massUpsert(data[], upsert?, options?)` | `Promise<T[]>` |
429
+
430
+ ### Search
431
+
432
+ | Method | Returns |
433
+ |---|---|
434
+ | `fullTextSearch(term, columns, pagination?, options?)` | `Promise<{ data: T[]; total: number; page: number; limit: number }>` (PostgreSQL only) |
435
+
436
+ `findAll` and `count` also accept `options.search` for list-style search where the
437
+ normal filters, pagination, sorting, relation filters, and `total` count should
438
+ all stay in one query path:
439
+
440
+ ```typescript
441
+ await service.findAll(
442
+ {},
443
+ { page: 1, limit: 20, sortBy: 'name', sortOrder: 'asc' },
444
+ { search: { term: 'na', columns: ['name', 'code'] } },
445
+ );
446
+ ```
447
+
448
+ By default this searches the configured columns with `ILIKE '%term%'` joined by
449
+ `OR`. For PostgreSQL full-text matching, pass `mode: 'fullText'`.
450
+
451
+ ### `SqlOperationOptions`
452
+
453
+ ```typescript
454
+ interface SqlOperationOptions {
455
+ transaction?: any; // run within an existing transaction
456
+ select?: string[]; // return only these columns; 'region.code' narrows a relation
457
+ relations?: string[]; // eager-load these configured relations (see Relations)
458
+ search?: {
459
+ term: string; // user search term
460
+ columns: string[]; // table column keys to search
461
+ mode?: 'ilike' | 'fullText'; // default: 'ilike'
462
+ };
463
+ hooks?: { skipBefore?: boolean; skipAfter?: boolean };
464
+ lock?: 'update' | 'share' | 'none';
465
+ forNoKeyUpdate?: boolean;
466
+ withDeleted?: boolean; // also return soft-deleted rows
467
+ onlyDeleted?: boolean; // only soft-deleted rows: the trash view
468
+ skipAllowlists?: boolean; // trusted, server-side call
469
+ bulkMode?: 'set' | 'perItem'; // mass writes: one statement, or one call per item
470
+ counts?: string[] | Record<string, true | CrudFilter>; // _count per collection
471
+ context?: Record<string, unknown>; // what a function `scope` reads
472
+ skipScope?: boolean; // trusted call, without the tenant scope
473
+ skipEvents?: boolean; // no module-level events for this call
474
+ expectedVersion?: number; // optimistic locking: 409 when stale
475
+ expectedVersions?: Record<string, number>; // the same per id, for mass writes
476
+ }
477
+ ```
478
+
479
+ > `relations` eager-loads relations declared in the entity's config. See
480
+ > [Relations](#relations) and [Column selection](#column-selection).
481
+
482
+ ---
483
+
484
+ ## Filtering
485
+
486
+ `findAll(filters)` / `count(filters)` accept an object keyed by column name.
487
+ `null`/`undefined` values are skipped. A key that is not a column or a
488
+ configured relation, or an unknown operator, is a 400 rather than being
489
+ dropped, so a typo can never return every row.
490
+
491
+ ```typescript
492
+ await service.findAll({
493
+ status: 'active', // string: exact match (case-insensitive on text columns)
494
+ author_id: '1', // strings are fine on integer, uuid and enum columns
495
+ role: ['admin', 'editor'], // array: IN (...)
496
+ age: { gte: 18, lt: 65 }, // comparison operators
497
+ created_at: { gte: '2025-01-01' }, // date strings are parsed; an invalid date is a 400
498
+ name: { startsWith: 'jo' }, // escaped pattern: % and _ are literal
499
+ archived_at: { isNull: false }, // IS NOT NULL
500
+ or: [{ team: { name: 'Core' } }, { role: 'admin' }], // OR group, relations included
501
+ not: { tags: { name: 'internal' } }, // NOT, here over a collection
502
+ });
503
+ ```
504
+
505
+ **Operators** (inside an object value):
506
+
507
+ | Operator | SQL |
508
+ |---|---|
509
+ | `eq` | `=` (exact, case-sensitive) |
510
+ | `gt` / `gte` / `lt` / `lte` | `>` `>=` `<` `<=` |
511
+ | `neq` | `<>` |
512
+ | `like` / `ilike` | `LIKE` / `ILIKE` (`LIKE` on MySQL), **pass your own `%` wildcards** |
513
+ | `in` / `notIn` | `IN (...)` / `NOT IN (...)` |
514
+ | `between` | `BETWEEN a AND b`, `[from, to]` |
515
+ | `isNull` / `isNotNull` | `true` / `"true"` gives `IS NULL` / `IS NOT NULL`, `false` / `"false"` the opposite |
516
+ | `startsWith` / `endsWith` / `contains` | escaped `LIKE`, case-insensitive unless `sql.caseSensitive` |
517
+ | `iStartsWith` / `iEndsWith` / `iContains` | escaped `ILIKE` (`LIKE` on MySQL), always case-insensitive |
518
+ | `arrayContains` / `arrayOverlaps` | PostgreSQL arrays, `@>` / `&&` |
519
+ | `jsonContains` | PostgreSQL `jsonb`, `@>` |
520
+
521
+ `and`, `or` and `not` group filter objects and nest. Each member is a full
522
+ filter, so it can hold column, relation and collection filters.
523
+
524
+ `findOne(where)` takes exact values, `null` for `IS NULL`, or an operator
525
+ object. If every value is `undefined`, or a key is not a column, it throws a 400
526
+ instead of returning an arbitrary row.
527
+
528
+ > A bare string value is an **exact** match. On a text column with
529
+ > `sql.caseSensitive` set to `false` (the default) it compares
530
+ > `lower(column) = lower(value)`, so the match is
531
+ > case-insensitive and `%` / `_` / `\` in the value are treated as literal
532
+ > characters, not wildcards. For partial or pattern matching, use the explicit
533
+ > `like` / `ilike` operators and supply your own wildcards, or `startsWith` /
534
+ > `endsWith` / `contains`, which escape the value for you.
535
+
536
+ ---
537
+
538
+ ## Query strings: parseQuery
539
+
540
+ `service.parseQuery(query)` (or `parseCrudQuery(query, table, options)`) turns
541
+ an HTTP query object into the arguments `findAll` takes, coercing every value by
542
+ its Drizzle column type. An unknown column, operator, relation or sort column is
543
+ a 400 before any SQL runs.
544
+
545
+ ```typescript
546
+ // GET /users?status=active&age[gte]=18&team[name]=Core&or[0][role]=admin&or[1][role]=owner
547
+ // &sortBy=team.name,-created_at&page=2&limit=20&fields=id,name&with=team&search=jo
548
+ const q = this.users.parseQuery(query);
549
+ return q.isCursor
550
+ ? this.users.findByCursor(q.filters, q.pagination, q.options)
551
+ : this.users.findAll(q.filters, q.pagination, q.options);
552
+ ```
553
+
554
+ Numbers, bigints, booleans and dates are coerced (a bad one is a 400), `numeric`
555
+ is checked and kept as an exact string, and text, uuid and enum stay strings.
556
+ `withDeleted` / `onlyDeleted` are only read with `allowTrash: true`. Type the
557
+ service's filter generic as `CrudFilter`.
558
+
559
+ ---
560
+
561
+ ## Allowlists
562
+
563
+ Per entity, in `forFeature` config. Unset lists allow everything; anything
564
+ outside a set list is a 400, in the service and in `parseQuery`:
565
+
566
+ ```typescript
567
+ config: {
568
+ filterableColumns: ['status', 'role', 'team.name'],
569
+ sortableColumns: ['name', 'created_at', 'team.name'],
570
+ selectableColumns: ['id', 'name', 'email', 'team.name'], // never password_hash
571
+ searchableColumns: ['name', 'email'],
572
+ loadableRelations: ['team'],
573
+ writableColumns: ['name', 'email', 'role'],
574
+ }
575
+ ```
576
+
577
+ Set them on every entity a client can reach. Without `writableColumns`, create
578
+ and update ignore the soft delete column, and update ignores the primary key and
579
+ `createdAt`. `skipAllowlists: true` skips the lists for a trusted call.
580
+
581
+ ---
582
+
583
+ ## Pagination & sorting
584
+
585
+ ```typescript
586
+ await service.findAll(
587
+ {},
588
+ { page: 2, limit: 25, sortBy: 'created_at', sortOrder: 'desc' },
589
+ );
590
+ // → { data: [...], total: 240, page: 2, limit: 25 }
591
+ ```
592
+
593
+ `limit` is capped at `pagination.maxLimit`. `sortOrder` defaults to `'desc'`.
594
+ The primary key is always the last `ORDER BY` column, so rows that tie on the
595
+ sort column never repeat or go missing between pages.
596
+
597
+ `sortBy` also takes a list, ascending unless prefixed with `-`, and a dotted
598
+ entry sorts by a belongs-to column:
599
+
600
+ ```typescript
601
+ await service.findAll({}, { sortBy: ['team.name', '-created_at'] });
602
+ // LEFT JOIN teams ... ORDER BY teams.name ASC, created_at DESC, id ASC
603
+ ```
604
+
605
+ ### Default sort
606
+
607
+ When the caller passes no `sortBy`, `findAll` falls back to the entity's
608
+ `defaultSort`: an ordered list of columns used as the `ORDER BY` (primary
609
+ sort first, then tiebreakers). An explicit `sortBy` **replaces** the default
610
+ entirely. Per-column `order` defaults to `'asc'`; unknown columns are skipped.
611
+
612
+ ```typescript
613
+ // config.defaultSort: [{ column: 'position', order: 'asc' }, { column: 'created_at', order: 'desc' }]
614
+ await service.findAll(); // ORDER BY position ASC, created_at DESC, id ASC
615
+ await service.findAll({}, { sortBy: 'name' }); // ORDER BY name DESC, id ASC (default ignored)
616
+ ```
617
+
618
+ Set it per entity in [`forFeature`](#drizzlecrudmoduleforfeatureentities) config (or via
619
+ `@CrudService`). Alternatively, set [`defaults.sortOrder`](#drizzlecrudmoduleforrootconfig)
620
+ in `forRoot` to apply a `created_at` fallback to every entity that doesn't define its own.
621
+
622
+ ---
623
+
624
+ ## Cursor pagination
625
+
626
+ `findByCursor` pages with `WHERE (sort columns) > (last row)` instead of
627
+ `OFFSET`, so deep pages stay fast and rows inserted earlier do not shift the
628
+ next page:
629
+
630
+ ```typescript
631
+ let cursor: string | null = null;
632
+ do {
633
+ const page = await service.findByCursor({ status: 'active' }, { sortBy: ['-created_at'], limit: 50, cursor });
634
+ handle(page.data);
635
+ cursor = page.nextCursor; // null on the last page
636
+ } while (cursor);
637
+ ```
638
+
639
+ The cursor is opaque (base64url of the sort values plus the primary key). Pass
640
+ `count: true` for a `total`. A cursor for a different sort is a 400.
641
+
642
+ ---
643
+
644
+ ## Relations
645
+
646
+ Three kinds of relation are supported, declared in the entity's `forFeature`
647
+ config under `relations` and keyed by relation name. Any of them can nest.
648
+
649
+ | Type | SQL | Extra queries | Result |
650
+ | --- | --- | --- | --- |
651
+ | `belongsTo` (default) | `LEFT JOIN` in the main query | 0 | object, or `null` |
652
+ | `hasMany` | `SELECT ... WHERE fk IN (parent keys)` | 1 per relation, for the whole page | array, `[]` when empty |
653
+ | `manyToMany` | `SELECT ... FROM join JOIN target ... WHERE fk IN (parent keys)` | 1 per relation, for the whole page | array, `[]` when empty |
654
+ | nested (any shape) | as above, one level down | 1 per relation per level | as above |
655
+
656
+ None of them runs a query per row, so eager loading a page of 50 rows costs the
657
+ same number of queries as a page of 5. That is the N+1 fix: ask for the relation
658
+ instead of looping.
659
+
660
+ ```typescript
661
+ import { cities, countries, countryTags, regions, states, tags } from './db/schema';
662
+
663
+ DrizzleCrudModule.forFeature([
664
+ {
665
+ service: CountriesService,
666
+ table: countries,
667
+ config: {
668
+ relations: {
669
+ // belongs-to: countries.region_id -> regions.id (`references` defaults to 'id')
670
+ region: { table: regions, localKey: 'region_id', references: 'id' },
671
+ // has-many: states.country_id -> countries.id (`parentKey` defaults to the primary key)
672
+ states: {
673
+ table: states,
674
+ type: 'hasMany',
675
+ foreignKey: 'country_id',
676
+ orderBy: [{ column: 'name', order: 'asc' }],
677
+ // nested: eager-load with relations: ['states', 'states.cities']
678
+ relations: {
679
+ cities: { table: cities, type: 'hasMany', foreignKey: 'state_id' },
680
+ },
681
+ },
682
+ // many-to-many: countries.id <- country_tags -> tags.id
683
+ tags: {
684
+ table: tags,
685
+ type: 'manyToMany',
686
+ through: countryTags,
687
+ throughLocalKey: 'country_id',
688
+ throughForeignKey: 'tag_id',
689
+ },
690
+ },
691
+ },
692
+ },
693
+ ]);
694
+ ```
695
+
696
+ A relation missing its required keys throws when the service is constructed,
697
+ nested relations included, with the full path in the message. A typo shows up at
698
+ startup instead of as an empty array later.
699
+
700
+ ### 1. Eager loading
701
+
702
+ Pass `relations` in the operation options:
703
+
704
+ ```typescript
705
+ await countries.find(1, { relations: ['region', 'states', 'states.cities', 'tags'] });
706
+ // → {
707
+ // id: 1, name: 'India', region_id: 3,
708
+ // region: { id: 3, name: 'Asia', code: 'AS' },
709
+ // states: [{ id: 10, name: 'Karnataka', country_id: 1, cities: [...] }, ...],
710
+ // tags: [{ id: '...', name: 'himalayan' }],
711
+ // }
712
+
713
+ // 5 queries for the whole page: data, count, states, cities, tags
714
+ await countries.findAll({}, { page: 1, limit: 50 }, {
715
+ relations: ['region', 'states', 'states.cities', 'tags'],
716
+ });
717
+ ```
718
+
719
+ A dotted path loads a relation of a relation, and asking for `states.cities`
720
+ alone pulls in `states` too. An unmatched belongs-to relation is `null`, never an
721
+ object full of nulls. A parent with no children is `[]`, so callers can iterate
722
+ without a guard. Relations load concurrently, soft-deleted rows are excluded when
723
+ the related table has the configured column, and the join table of a
724
+ many-to-many never appears in the result.
725
+
726
+ ### 2. Filtering by related columns
727
+
728
+ Use the relation name as a filter key with a nested object of the **related
729
+ table's** columns. Supports the same [operators](#filtering) as normal filters:
730
+
731
+ ```typescript
732
+ // belongs-to: filtered through the join
733
+ await cities.findAll({ state: { name: 'Karnataka' } });
734
+ await cities.findAll({ name: { ilike: 'B%' }, state: { country_id: 3 } });
735
+
736
+ // has-many: countries.id IN (SELECT country_id FROM states WHERE ...)
737
+ await countries.findAll({ states: { name: 'Kerala' } });
738
+
739
+ // many-to-many: through the join table, still a subquery
740
+ await countries.findAll({ tags: { name: 'himalayan' } });
741
+ ```
742
+
743
+ A collection is never joined, on a filter or on an eager load. One country with
744
+ four states would become four rows, `count(*)` would return 4 instead of 1, and
745
+ `limit` would cut a country in half. The subquery and the batched load keep one
746
+ row per parent, so `total` and pagination stay correct.
747
+
748
+ A filter can go on through the related table's own relations, at any depth.
749
+ Below the first joined belongs-to, each level is an `EXISTS` subquery:
750
+
751
+ ```typescript
752
+ await cities.findAll({ state: { country: { code: 'IN' } } }); // ?state[country][code]=IN
753
+ ```
754
+
755
+ A collection takes `some` (the default), `none` or `every`, which compile to
756
+ `IN` / `NOT EXISTS` subqueries and work inside `or` / `not` groups and `count`:
757
+
758
+ ```typescript
759
+ await authors.findAll({ posts: { none: { status: 'draft' } } });
760
+ await authors.findAll({ posts: { every: { status: 'published' } } }); // also authors with no posts
761
+ await authors.findAll({ posts: { some: {} } }); // has at least one
762
+ ```
763
+
764
+ > `orderBy` sorts a collection but does not limit it per parent, and a join
765
+ > table's own columns are not returned. Model a join table that carries data as
766
+ > an entity with two belongs-to relations.
767
+
768
+ ---
769
+
770
+ ## Column selection
771
+
772
+ Pass `options.select` and the query stops being `SELECT *`. A dotted entry
773
+ narrows an eager-loaded relation:
774
+
775
+ ```typescript
776
+ await countries.findAll({}, { page: 1, limit: 20 }, { select: ['id', 'name'] });
777
+ // SELECT "id", "name" FROM "countries" ...
778
+
779
+ await countries.findAll({}, { page: 1, limit: 20 }, {
780
+ relations: ['region', 'states', 'states.cities'],
781
+ select: ['id', 'name', 'region.code', 'states.name', 'states.cities.name'],
782
+ });
783
+ // → { id, name, region: { code }, states: [{ name, country_id, id, cities: [{ name, state_id }] }] }
784
+ ```
785
+
786
+ Rules:
787
+
788
+ - A dotted entry only applies when the relation is also in `options.relations`.
789
+ Paths can be any depth: `'states.cities.name'`.
790
+ - Names that don't match a real column are ignored, not rejected.
791
+ - If every entry is dotted, the base row is returned in full, so
792
+ `select: ['region.name']` reads as "narrow the region".
793
+ - The parent key, each child's foreign key and any key a nested level needs are
794
+ added back automatically, because the grouping needs them.
795
+
796
+ `select` applies to `find`, `findOne` and `findAll`. Write methods return the
797
+ full row through `RETURNING`.
798
+
799
+ ---
800
+
801
+ ## Primary keys (serial / uuid)
802
+
803
+ Each entity names its primary key via `primaryKey` (the column key, default
804
+ `id`). Both auto-increment and UUID keys are supported, and the type comes from
805
+ the Drizzle column. `primaryKeyType` is deprecated and has no effect.
806
+
807
+ ```typescript
808
+ // serial / auto-increment (default)
809
+ { service: UsersService, table: users } // primaryKey 'id'
810
+
811
+ // UUID
812
+ {
813
+ service: TagsService,
814
+ table: tags, // e.g. uuid('id').primaryKey().defaultRandom()
815
+ }
816
+ ```
817
+
818
+ On PostgreSQL the created row (including a DB-generated UUID) is returned via
819
+ `RETURNING`. On MySQL (no `RETURNING`), provide the UUID in your create payload
820
+ so the row can be re-read. Auto-increment keys use the driver's `insertId`.
821
+
822
+ > With UUID keys, route params are strings, so don't apply
823
+ > `ParseIntPipe` in your controller.
824
+
825
+ ---
826
+
827
+ ## Soft delete
828
+
829
+ Enable per-project via `defaults.softDelete` or per-entity via `forFeature` config:
830
+
831
+ ```typescript
832
+ { service: UsersService, table: users, config: { softDelete: { enabled: true, column: 'deleted_at' } } }
833
+ ```
834
+
835
+ - `softDelete(id)` sets the column to the current timestamp.
836
+ - `restore(id)` sets it back to `null`.
837
+ - `find`/`findOne`/`findAll`/`count` automatically exclude soft-deleted rows,
838
+ and a soft-deleted belongs-to parent comes back as `null`.
839
+ - `column` is the key in your Drizzle table object (`deletedAt` for
840
+ `deletedAt: timestamp('deleted_at')`). A key the table lacks fails at startup.
841
+ - The trash view: pass `onlyDeleted: true` (or `withDeleted: true` for both)
842
+ to any read, e.g. `findAll({}, {}, { onlyDeleted: true })`.
843
+ - `forceDelete(id)` / `massForceDelete(ids)` hard-delete rows whether they are
844
+ in the trash or not. `delete(id)` answers 404 for a row in the trash.
845
+
846
+ ---
847
+
848
+ ## Timestamps
849
+
850
+ There are two ways to manage `created_at` / `updated_at`.
851
+
852
+ ### Package-managed (convenient)
853
+
854
+ Enable `defaults.timestamps` (or per-entity `timestamps`). On `create()` the
855
+ service stamps both columns; on `update()`/`softDelete()` it stamps `updated_at`:
856
+
857
+ ```typescript
858
+ DrizzleCrudModule.forRoot({
859
+ dialect: 'postgresql',
860
+ connectionString: process.env.DATABASE_URL,
861
+ schema,
862
+ defaults: { timestamps: true }, // uses columns created_at / updated_at
863
+ });
864
+ ```
865
+
866
+ > Caveat: this uses the **application** clock and only applies to writes that go
867
+ > **through the package**. Raw SQL, migrations or other code paths won't set
868
+ > the values.
869
+
870
+ ### Schema/DB-managed (recommended)
871
+
872
+ For authoritative timestamps (database time, every write path), define them in
873
+ your Drizzle schema and **leave the package's `timestamps` disabled**:
874
+
875
+ ```typescript
876
+ import { timestamp } from 'drizzle-orm/pg-core';
877
+
878
+ created_at: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
879
+ updated_at: timestamp('updated_at', { withTimezone: true })
880
+ .defaultNow()
881
+ .notNull()
882
+ .$onUpdate(() => new Date()),
883
+ ```
884
+
885
+ `defaultNow()` lets the database set `created_at` on insert; `$onUpdate()` makes
886
+ Drizzle bump `updated_at` on every update (including the package's own
887
+ `update()`). For `updated_at` that's authoritative even for raw SQL, add a
888
+ Postgres trigger / MySQL `ON UPDATE CURRENT_TIMESTAMP`.
889
+
890
+ ---
891
+
892
+ ## Bulk operations
893
+
894
+ ```typescript
895
+ await service.massCreate([dto1, dto2, dto3]);
896
+ await service.massUpdate([1, 2, 3], { status: 'archived' });
897
+ await service.massSoftDelete([1, 2, 3]);
898
+ ```
899
+
900
+ Each bulk method is one statement for the whole set (plus one `SELECT` that
901
+ checks the ids exist), inside a single transaction: `massUpdate` over 100 ids is
902
+ two queries. If any item fails, a `BulkOperationException` (400) is thrown and
903
+ the transaction rolls back. The failing items are found by replaying the batch
904
+ inside a savepoint that is rolled back, and the response body lists only them:
905
+
906
+ ```json
907
+ { "statusCode": 400, "error": "Bad Request",
908
+ "message": "Mass create errors. 1 error(s) occurred.",
909
+ "errors": [{ "index": 1, "status": 409, "message": "users with email 'a@x.io' already exists" }] }
910
+ ```
911
+
912
+ `bulkMode: 'perItem'` runs the single-row method per item instead, as in 3.3.
913
+
914
+ ---
915
+
916
+ ## Upsert
917
+
918
+ ```typescript
919
+ await users.upsert(
920
+ { email: 'ada@example.com', name: 'Ada' },
921
+ { conflictTarget: 'email' }, // update: [...] limits the overwritten columns
922
+ );
923
+ await users.massUpsert(rows, { conflictTarget: 'email' });
924
+ ```
925
+
926
+ `ON CONFLICT DO UPDATE` on PostgreSQL, `ON DUPLICATE KEY UPDATE` on MySQL. A
927
+ clash on another unique key is a 409, bad input a 400.
928
+
929
+ ---
930
+
931
+ ## Relations on writes
932
+
933
+ Writes take `relations` and `counts` like reads, and load them with the same
934
+ batched loaders, one query per relation for the whole batch:
935
+
936
+ ```typescript
937
+ const post = await posts.create(dto, { relations: ['author', 'tags'] });
938
+ await posts.massCreate(rows, { relations: ['author'] }); // 1 INSERT + 1 SELECT
939
+ ```
940
+
941
+ A create body can also write relations listed in `writableRelations`, inside one
942
+ transaction: an array for a has-many, an object for a belongs-to, ids or objects
943
+ for a many-to-many.
944
+
945
+ ```typescript
946
+ // forFeature config: writableRelations: ['posts', 'tags']
947
+ await authors.create({ name: 'Ada', posts: [{ title: 'One' }, { title: 'Two' }] });
948
+ await posts.create({ title: 'Tagged', tags: [1, { name: 'new' }] });
949
+ ```
950
+
951
+ Many-to-many links change with set-based `attach`, `detach` and `sync`, which
952
+ return the row with the relation loaded:
953
+
954
+ ```typescript
955
+ await posts.attach(1, 'tags', [2, 3]);
956
+ await posts.detach(1, 'tags', [3]); // or detach(1, 'tags') for all
957
+ await posts.sync(1, 'tags', [2]); // exactly these
958
+ ```
959
+
960
+ ---
961
+
962
+ ## Controller factory
963
+
964
+ `createCrudController(Service, options)` builds a controller with list (offset,
965
+ or keyset with `?cursor=`), `GET /trash`, get, create, update, delete,
966
+ `POST /:id/soft-delete`, `POST /:id/restore` and, when you turn them on, the bulk
967
+ routes. `:id` is parsed from the primary key column: `ParseIntPipe` for an
968
+ integer, `ParseUUIDPipe` for a uuid.
969
+
970
+ ```typescript
971
+ @Controller('projects')
972
+ export class ProjectsController extends createCrudController(ProjectsService, {
973
+ dto: { create: CreateProjectDto, update: UpdateProjectDto },
974
+ routes: { bulkCreate: true, delete: { decorators: [UseGuards(AdminGuard)] } },
975
+ decorators: [UseGuards(AuthGuard)],
976
+ deleteMode: 'soft',
977
+ }) {}
978
+ ```
979
+
980
+ ---
981
+
982
+ ## OpenAPI (Swagger)
983
+
984
+ `@ApiCrudList(source)` documents a list route: `page`, `limit`, `sortBy`,
985
+ `cursor`, `withCount`, `fields`, `with`, `counts`, `search` and one parameter per
986
+ filterable column, plus the page envelope. `@ApiCrudQuery(source)` adds only the
987
+ parameters. `@nestjs/swagger` is an optional peer, loaded only when a decorator
988
+ runs.
989
+
990
+ ```typescript
991
+ @Get()
992
+ @ApiCrudList({ table: countries, ...countriesConfig })
993
+ findAll(@Query() query: Record<string, unknown>) { ... }
994
+ ```
995
+
996
+ ---
997
+
998
+ ## Multi-tenancy: scope
999
+
1000
+ A `scope` is ANDed into every read, count and write, bulk and upsert included,
1001
+ and written onto every row a create inserts. Another tenant's id is a 404.
1002
+
1003
+ ```typescript
1004
+ // forFeature config
1005
+ scope: (ctx) => ({ tenant_id: ctx.tenantId }),
1006
+
1007
+ // per request, in a NestModule
1008
+ consumer
1009
+ .apply(CrudContext.middleware((req) => ({ tenantId: req.user?.tenantId })))
1010
+ .forRoutes('*');
1011
+
1012
+ // or per call
1013
+ await docs.findAll({}, {}, { context: { tenantId: 7 } });
1014
+ ```
1015
+
1016
+ A missing context value is a 403 (`MissingScopeException`), never an unscoped
1017
+ query. A resolver that returns `null`, or `skipScope: true`, runs unscoped on
1018
+ purpose.
1019
+
1020
+ ---
1021
+
1022
+ ## Events and global hooks
1023
+
1024
+ With `hooks.enableGlobalHooks: true`, every service raises a before and an after
1025
+ event on the injectable `CrudEventBus` for each write. A bulk call raises one
1026
+ event with `bulk: true` and every id.
1027
+
1028
+ ```typescript
1029
+ DrizzleCrudModule.forRoot({
1030
+ dialect: 'postgresql', connectionString,
1031
+ hooks: { enableGlobalHooks: true },
1032
+ });
1033
+
1034
+ @Injectable()
1035
+ export class Audit implements OnModuleInit {
1036
+ constructor(private readonly bus: CrudEventBus) {}
1037
+ onModuleInit() {
1038
+ this.bus.subscribe({
1039
+ afterWrite: (e) => this.log(e.entity, e.operation, e.ids),
1040
+ });
1041
+ }
1042
+ }
1043
+ ```
1044
+
1045
+ A before listener that throws stops the write; an after listener that throws
1046
+ inside a transaction rolls it back. The per-service hooks run as before.
1047
+
1048
+ ---
1049
+
1050
+ ## Optimistic locking
1051
+
1052
+ ```typescript
1053
+ // forFeature config
1054
+ versionColumn: 'version',
1055
+
1056
+ await docs.update(1, { title: 'New', version: 3 }); // 409 if the row is past 3
1057
+ await docs.delete(1, { expectedVersion: 4 });
1058
+ await docs.massUpdate([1, 2], data, { expectedVersions: { '1': 4, '2': 7 } });
1059
+ ```
1060
+
1061
+ The version starts at 1 and goes up on every update, soft delete, restore,
1062
+ upsert conflict and bulk write. A stale one is a 409 `StaleVersionException`.
1063
+
1064
+ ---
1065
+
1066
+ ## Counts and grouped counts
1067
+
1068
+ ```typescript
1069
+ await authors.findAll({}, {}, { counts: ['posts'] });
1070
+ // [{ id: 1, name: 'Ann', _count: { posts: 10 } }, ...]
1071
+ await authors.findAll({}, {}, { counts: { posts: { status: 'published' } } });
1072
+
1073
+ await posts.countBy('status'); // [{ value: 'draft', count: 15 }, ...]
1074
+ await posts.countBy('author.name', { status: 'published' });
1075
+ ```
1076
+
1077
+ Each count is one `GROUP BY` query for the whole result, never one per row, and
1078
+ skips soft-deleted related rows. `findAll(filters, { withCount: false })` skips
1079
+ the `COUNT(*)`: `total` is `null` and `hasMore` tells you if another page follows.
1080
+
1081
+ ---
1082
+
1083
+ ## Transactions
1084
+
1085
+ ```typescript
1086
+ await service.executeSqlTransaction(async (tx) => {
1087
+ const user = await service.create(userDto, { transaction: tx });
1088
+ await profileService.create({ userId: user.id }, { transaction: tx });
1089
+ });
1090
+ ```
1091
+
1092
+ Pass `{ transaction: tx }` in `options` to any method to enlist it.
1093
+
1094
+ ---
1095
+
1096
+ ## Full-text search (PostgreSQL)
1097
+
1098
+ ```typescript
1099
+ const { data, total } = await service.fullTextSearch(
1100
+ 'john doe',
1101
+ ['name', 'email', 'bio'],
1102
+ { page: 1, limit: 20 },
1103
+ );
1104
+ ```
1105
+
1106
+ Builds `to_tsvector(...) @@ plainto_tsquery(...)` across the given columns and
1107
+ orders by `ts_rank`. Throws if the dialect is not `postgresql`.
1108
+
1109
+ For paginated list endpoints, prefer `findAll(..., ..., { search })` so search is
1110
+ combined with filters and the count query. Use `fullTextSearch` directly when
1111
+ you want ranked full-text results as the primary operation.
1112
+
1113
+ ---
1114
+
1115
+ ## Lifecycle hooks & validation
1116
+
1117
+ Override any of these `protected` methods in your service (all are optional;
1118
+ defaults are no-op / pass-through):
1119
+
1120
+ ```typescript
1121
+ protected validateCreate(data: CreateDto): Promise<void>
1122
+ protected validateUpdate(id: any, data: UpdateDto): Promise<void>
1123
+ protected mapCreateDtoToEntity(data: CreateDto): Record<string, any>
1124
+ protected mapUpdateDtoToEntity(data: UpdateDto): Record<string, any>
1125
+
1126
+ protected beforeCreate(data: CreateDto): Promise<CreateDto>
1127
+ protected afterCreate(entity: T): Promise<void>
1128
+ protected beforeUpdate(id: any, data: UpdateDto): Promise<UpdateDto>
1129
+ protected afterUpdate(entity: T): Promise<void>
1130
+ protected beforeDelete(id: any): Promise<void>
1131
+ protected afterDelete(id: any): Promise<void>
1132
+ protected beforeSoftDelete(id: any): Promise<void>
1133
+ protected afterSoftDelete(id: any): Promise<void>
1134
+ protected beforeRestore(id: any): Promise<void>
1135
+ protected afterRestore(entity: T): Promise<void>
1136
+ ```
1137
+
1138
+ `mapCreateDtoToEntity` / `mapUpdateDtoToEntity` transform the incoming DTO into the
1139
+ row to persist (default returns a shallow copy). When `timestamps` is enabled, the
1140
+ service stamps `created_at`/`updated_at` automatically.
1141
+
1142
+ ---
1143
+
1144
+ ## Testing
1145
+
1146
+ ```typescript
1147
+ import { TestCrudFactory } from 'nestjs-drizzle-crud';
1148
+
1149
+ const mockDb = TestCrudFactory.createMockDb();
1150
+ const mockTable = TestCrudFactory.createMockTable();
1151
+ const service = TestCrudFactory.createTestService(UsersService, mockDb, mockTable);
1152
+ ```
1153
+
1154
+ `TestCrudFactory` provides `createMockDb()`, `createMockTable()`,
1155
+ `createMockEntity()` and `createTestService()` for unit tests without a database.
1156
+
1157
+ ---
1158
+
1159
+ ## For AI agents / LLM tools
1160
+
1161
+ ### Point your agent at the rules
1162
+
1163
+ `AGENT.md` ships inside the package and holds the rules agents should follow
1164
+ when they use it. Copy the pointer template to the filename your tool reads:
1165
+
1166
+ ```bash
1167
+ cp node_modules/nestjs-drizzle-crud/AGENTS.template.md ./AGENTS.md # Codex, Cursor, Copilot, Zed, Cline, Windsurf
1168
+ cp node_modules/nestjs-drizzle-crud/CLAUDE.template.md ./CLAUDE.md # Claude Code
1169
+ # Gemini CLI reads ./GEMINI.md, Aider reads ./CONVENTIONS.md
1170
+ ```
1171
+
1172
+ Each template is one `@node_modules/nestjs-drizzle-crud/AGENT.md` line, so an
1173
+ upgrade updates the rules with it and you never hold a stale copy.
1174
+
1175
+ ### Or run the MCP server
1176
+
1177
+ ```bash
1178
+ npx nestjs-drizzle-crud-mcp
1179
+ ```
1180
+
1181
+ A stdio MCP server, zero dependencies, that serves this package's docs to any MCP
1182
+ client through three tools: `list_docs`, `get_doc` (`agent-guide` / `readme` /
1183
+ `changelog`, optionally one section) and `search_docs`. Register it the usual way:
1184
+
1185
+ ```json
1186
+ {
1187
+ "mcpServers": {
1188
+ "nestjs-drizzle-crud": {
1189
+ "command": "npx",
1190
+ "args": ["-y", "nestjs-drizzle-crud-mcp"]
1191
+ }
1192
+ }
1193
+ }
1194
+ ```
1195
+
1196
+ That shape works for Claude Code (`.mcp.json`), Cursor (`.cursor/mcp.json`),
1197
+ Windsurf, Gemini CLI and Cline. VS Code and Copilot use `.vscode/mcp.json` with
1198
+ the entries under `servers`; Codex uses `[mcp_servers.*]` in
1199
+ `~/.codex/config.toml`; Zed uses a `context_servers` entry. The server is
1200
+ read-only and never touches your database. Full setup:
1201
+ [crud.avytrix.dev/docs/ai-agents](https://crud.avytrix.dev/docs/ai-agents).
1202
+
1203
+ ### Facts for code generation
1204
+
1205
+ Use these facts instead of guessing.
1206
+
1207
+ **Package:** `nestjs-drizzle-crud` · **Peers:** `@nestjs/common`, `@nestjs/core`, `drizzle-orm`, `reflect-metadata`; optional `postgres` (PG) / `mysql2` (MySQL) / `@nestjs/swagger` (for `ApiCrudList`).
1208
+
1209
+ **Setup is two steps and no per-service connection wiring:**
1210
+ 1. `DrizzleCrudModule.forRoot({ dialect, connectionString | db, schema, defaults })` once in `AppModule`.
1211
+ 2. `DrizzleCrudModule.forFeature([{ service, table, config? }])` in each feature module.
1212
+
1213
+ **A service is an empty subclass. Do NOT inject the db or pass `dialect`/`db`:**
1214
+ ```typescript
1215
+ export class XService extends SqlBaseCrudService<X, CreateXDto, UpdateXDto, XFilterDto> {}
1216
+ ```
1217
+
1218
+ **Rules / gotchas:**
1219
+ - Generics order: `SqlBaseCrudService<Entity, CreateDto, UpdateDto, FilterDto>`. All but `Entity` default to `Partial<Entity>`.
1220
+ - Do **not** add an `@Inject('DRIZZLE_DB')` constructor: `forFeature` constructs the service for you. A constructor that calls `super({...})` is the old manual pattern and is not needed.
1221
+ - The table is passed in `forFeature`, **not** in the service.
1222
+ - If tables lack timestamp/soft-delete columns, set `defaults: { softDelete: false, timestamps: false }`. The settings name table **keys** (`deletedAt`, not `deleted_at`, for a camelCase schema), and a missing key fails at startup.
1223
+ - `findAll` returns `{ data, total, page, limit }`, not a bare array.
1224
+ - Filter operators live inside an object value: `{ age: { gte: 18 } }`. `like`/`ilike` require caller-supplied `%` wildcards; `startsWith`/`endsWith`/`contains` escape the value; a bare string is exact match. Also `notIn`, `between`, the `i` pattern forms and, on PostgreSQL, `arrayContains`/`arrayOverlaps`/`jsonContains`. `or`/`and`/`not` group filters and nest. An unknown filter key or operator is a 400; `{ isNull: false }` is `IS NOT NULL`.
1225
+ - In a controller, `service.parseQuery(@Query() query)` builds filters, pagination and options from the query string; use `findByCursor` when `isCursor` is true. Set the allowlists (`filterableColumns`, `sortableColumns`, `selectableColumns`, `searchableColumns`, `loadableRelations`, `writableColumns`) on any entity a client can reach.
1226
+ - Soft-deleted rows: `onlyDeleted: true` / `withDeleted: true` on any read; `forceDelete(id)` purges. Bulk methods are one statement per set; `upsert(dto, { conflictTarget })` exists.
1227
+ - `delete`/`softDelete` return `boolean`; `update`/`restore` return the entity and throw `EntityNotFoundException` when missing.
1228
+ - Postgres error codes are translated for you: `23505` unique -> 409, `22001`/`23514`/`22003`/`22P02`/`22007`/`22008` bad value -> 400 (reads included), `23503` on create/update (missing FK target) -> 400 `ForeignKeyViolationException`, `23503` on delete (still referenced) -> 409 `EntityInUseException`.
1229
+ - Relations on writes: `create`/`update`/`upsert`/mass writes take `relations` and `counts`. Nested create only for relations in `writableRelations`; many-to-many links change with `attach`/`detach`/`sync`.
1230
+ - Controllers: prefer `createCrudController(Service, { dto, routes, decorators })` over hand-written CRUD routes. The id pipe follows the primary key.
1231
+ - Multi-tenant data: set `scope: (ctx) => ({ tenant_id: ctx.tenantId })` and fill the context with `CrudContext.middleware`. Do not add `tenant_id` filters by hand.
1232
+ - `hooks.enableGlobalHooks: true` + `CrudEventBus.subscribe` for audit and cache busting; one event per bulk call.
1233
+ - `versionColumn` for optimistic locking: a stale `version` in the body or `expectedVersion` is a 409.
1234
+ - `options.counts: ['posts']` gives `_count`; `countBy(column)` gives grouped counts; `withCount: false` skips `COUNT(*)`.
1235
+ - Relations: declare in forFeature `config.relations` and eager-load via `options.relations: ['relName']`, where a dotted path nests (`['states', 'states.cities']`, `['state.country']`; the parent path is pulled in automatically). Belongs-to is `{ table, localKey, references? }`, LEFT JOINed into the main query, resolving to an object or `null`. Has-many is `{ table, type: 'hasMany', foreignKey, parentKey?, orderBy? }`. Many-to-many is `{ table, type: 'manyToMany', through, throughLocalKey, throughForeignKey, references?, parentKey?, orderBy? }`. Nested relations live in a `relations` map on the relation. Every collection and every nested level costs exactly one extra query for the whole result set, never one per row. Filter via `findAll({ relName: { col: value } })` with the same operators; collection filters compile to `parentKey IN (SELECT ... WHERE ...)`, so `total` stays correct. Filters go through relation paths at any depth (`{ state: { country: { code: 'IN' } } }`), and collections take `some` / `none` / `every` (`{ posts: { none: { status: 'draft' } } }`), compiled to subqueries.
1236
+ - `options.select` narrows the projection and takes dotted relation paths at any depth: `['id', 'name', 'region.code', 'states.cities.name']`. Unknown names are ignored; an all-dotted list leaves the base row complete; the parent key, the child foreign key and any nested key are re-added automatically.
1237
+ - Primary keys: `primaryKey` (default `'id'`) per entity in forFeature config (`primaryKeyType` is deprecated and does nothing). UUID works on Postgres via RETURNING; with uuid keys, route params are strings, so don't use `ParseIntPipe`.
1238
+ - Full-text search is PostgreSQL-only.
1239
+ - Exports (3.5): `createCrudController`, `CrudContext`, `CrudEventBus`, `ApiCrudQuery`, `ApiCrudList`, `MissingScopeException`, `StaleVersionException`, and the types `CrudControllerOptions`, `CrudEvent`, `CrudSubscriber`, `CrudScope`, `CollectionFilter`, `CountByRow`, `CrudPage`, `UncountedCrudPage`, `RelationCounts`.
1240
+ - Exports: `SqlBaseCrudService`, `DrizzleCrudModule`, `DRIZZLE_DB`, `DRIZZLE_CRUD_CONFIG`, `TestCrudFactory`, exceptions (`EntityNotFoundException`, `DuplicateEntityException`, `ForeignKeyViolationException`, `EntityInUseException`, `ValidationFailedException`, `BulkOperationException`, plus the deprecated `DatabaseConnectionException` and `TransactionException`), and types (`SqlCrudConfig`, `SqlOperationOptions`, `DrizzleCrudConfig`, `CrudFeature`, `CrudFeatureConfig`, `PaginationOptions`, `OperationOptions`, `SqlSearchOptions`, `SqlSearchMode`, `BulkOperationResult`, `BulkOperationItemError`, `SqlDialect`, `PrimaryKeyType`, `CrudFilter`, `FilterOperators`, `SortInput`, `CursorPaginationOptions`, `CursorPage`, `CrudAllowlists`, `UpsertOptions`, `BulkMode`, `ParsedCrudQuery`), and `parseCrudQuery`.
1241
+
1242
+ ### `SqlCrudConfig`
1243
+
1244
+ ```typescript
1245
+ interface SqlCrudConfig {
1246
+ dialect: 'postgresql' | 'mysql';
1247
+ db: any; // Drizzle instance (injected by forFeature)
1248
+ table: any; // Drizzle table (set by forFeature)
1249
+ primaryKey: string; // default 'id'
1250
+ primaryKeyType?: 'serial' | 'bigserial' | 'int' | 'bigint' | 'uuid'; // deprecated, no effect
1251
+ softDelete?: { enabled: boolean; column: string }; // column: the table key
1252
+ timestamps?: { createdAt: string; updatedAt: string }; // table keys
1253
+ pagination?: { defaultLimit: number; maxLimit: number };
1254
+ sql?: { caseSensitive: boolean; useReturning: boolean; jsonSupport: boolean; enableFullTextSearch: boolean }; // last two deprecated
1255
+ relations?: Record<string, {
1256
+ table: any;
1257
+ type?: 'belongsTo' | 'hasMany' | 'manyToMany';
1258
+ localKey?: string; // belongs-to: FK column on this table
1259
+ references?: string; // target column, default 'id'
1260
+ foreignKey?: string; // has-many: column on the target table
1261
+ parentKey?: string; // collections: column here it matches, default primaryKey
1262
+ through?: any; // many-to-many: the join table
1263
+ throughLocalKey?: string; // many-to-many: join column matching parentKey
1264
+ throughForeignKey?: string; // many-to-many: join column matching references
1265
+ orderBy?: SortColumn[]; // collections: order the children in SQL
1266
+ relations?: RelationsConfig; // nested relations, addressed as 'rel.child'
1267
+ }>;
1268
+ // Allowlists: unset allows everything, anything outside a set list is a 400
1269
+ filterableColumns?: string[];
1270
+ sortableColumns?: string[];
1271
+ selectableColumns?: string[];
1272
+ searchableColumns?: string[];
1273
+ loadableRelations?: string[];
1274
+ writableColumns?: string[];
1275
+ writableRelations?: string[]; // nested create; attach/detach/sync when set
1276
+ scope?: Record<string, unknown> | ((ctx) => Record<string, unknown> | null);
1277
+ versionColumn?: string; // optimistic locking
1278
+ events?: CrudEventBus; // set by the module with enableGlobalHooks
1279
+ }
1280
+ ```
1281
+
1282
+ ---
1283
+
1284
+ ## Support
1285
+
1286
+ - **Docs:** [crud.avytrix.dev](https://crud.avytrix.dev), including a
1287
+ [FAQ and where-to-ask guide](https://crud.avytrix.dev/docs/getting-help).
1288
+ - **Questions:** [GitHub Discussions](https://github.com/avytrix/nestjs-drizzle-crud/discussions).
1289
+ - **Bugs:** [open an issue](https://github.com/avytrix/nestjs-drizzle-crud/issues/new/choose)
1290
+ with the version, dialect, table definition and the call you made.
1291
+ - **Security:** follow [SECURITY.md](https://github.com/avytrix/nestjs-drizzle-crud/blob/main/SECURITY.md),
1292
+ never a public issue.
1293
+ - **AI agents:** this package ships `AGENT.md` plus an MCP server. See
1294
+ [For AI agents / LLM tools](#for-ai-agents--llm-tools).
1295
+ - **Sponsor:** [github.com/sponsors/avytrix](https://github.com/sponsors/avytrix).
1296
+
1297
+ ---
1298
+
1299
+ ## License
1300
+
1301
+ MIT