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