nestjs-drizzle-crud 1.0.6 โ†’ 2.1.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/README.md CHANGED
@@ -1,18 +1,64 @@
1
- # NestJS Drizzle CRUD
1
+ # nestjs-drizzle-crud
2
2
 
3
- A complete, type-safe CRUD abstraction layer for Drizzle ORM in NestJS applications. Supports PostgreSQL and MySQL with advanced features like soft delete, transactions, bulk operations, and full-text search.
3
+ A complete, type-safe CRUD abstraction layer for [Drizzle ORM](https://orm.drizzle.team/) in [NestJS](https://nestjs.com/) applications.
4
+
5
+ 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.
6
+
7
+ ```typescript
8
+ // 1. configure once (app.module.ts)
9
+ DrizzleCrudModule.forRoot({
10
+ dialect: 'postgresql',
11
+ connectionString: process.env.DATABASE_URL,
12
+ schema,
13
+ });
14
+
15
+ // 2. a fully-featured CRUD service is just:
16
+ export class UsersService extends SqlBaseCrudService<User> {}
17
+
18
+ // 3. bind it to a table (users.module.ts)
19
+ DrizzleCrudModule.forFeature([{ service: UsersService, table: users }]);
20
+ ```
21
+
22
+ ---
23
+
24
+ ## Table of contents
25
+
26
+ - [Features](#features)
27
+ - [Installation](#installation)
28
+ - [Quick start](#quick-start)
29
+ - [Configuration](#configuration)
30
+ - [Defining services](#defining-services)
31
+ - [API reference](#api-reference)
32
+ - [Filtering](#filtering)
33
+ - [Pagination & sorting](#pagination--sorting)
34
+ - [Relations](#relations)
35
+ - [Primary keys (serial / uuid)](#primary-keys-serial--uuid)
36
+ - [Soft delete](#soft-delete)
37
+ - [Bulk operations](#bulk-operations)
38
+ - [Transactions](#transactions)
39
+ - [Full-text search (PostgreSQL)](#full-text-search-postgresql)
40
+ - [Lifecycle hooks & validation](#lifecycle-hooks--validation)
41
+ - [Testing](#testing)
42
+ - [For AI agents / LLM tools](#for-ai-agents--llm-tools)
43
+
44
+ ---
4
45
 
5
46
  ## Features
6
47
 
7
- - ๐Ÿš€ **Complete CRUD Operations** - find, create, update, delete, and more
8
- - ๐Ÿ—ƒ๏ธ **SQL Database Support** - PostgreSQL & MySQL with dialect-specific optimizations
9
- - โšก **Type-Safe** - Full TypeScript support with generics
10
- - ๐Ÿ”„ **Soft Delete** - Built-in soft delete with restore functionality
11
- - ๐Ÿ“ฆ **Bulk Operations** - Mass create, update, delete with transaction support
12
- - ๐Ÿ” **Advanced Querying** - Filtering, pagination, sorting, full-text search
13
- - ๐ŸŽฏ **NestJS Native** - Seamless integration with NestJS dependency injection
14
- - ๐Ÿงช **Test Utilities** - Comprehensive testing helpers
15
- - ๐Ÿ›ก๏ธ **Production Ready** - Error handling, transactions, and validation hooks
48
+ - ๐Ÿš€ **Complete CRUD** โ€” `find`, `findOne`, `findAll`, `create`, `update`, `delete`, and more
49
+ - ๐Ÿงฉ **Configure once** โ€” `forRoot()` owns the connection; services only declare a table
50
+ - โšก **Type-safe** โ€” generics over your entity, create/update DTOs and filter types
51
+ - ๐Ÿ—ƒ๏ธ **PostgreSQL & MySQL** โ€” dialect-aware (`RETURNING` vs `insertId`)
52
+ - ๐Ÿ”„ **Soft delete** โ€” opt-in soft delete with `restore`
53
+ - ๐Ÿ“ฆ **Bulk operations** โ€” mass create/update/delete inside a transaction
54
+ - ๐Ÿ” **Rich filtering** โ€” equality, `in`, comparison operators, `like`/`ilike`, null checks
55
+ - ๐Ÿ”Ž **Full-text search** โ€” PostgreSQL `tsvector`/`tsquery`
56
+ - ๐Ÿ”— **Relations** โ€” many-to-one eager loading and filtering by related columns
57
+ - ๐Ÿ”‘ **Flexible primary keys** โ€” `serial` / `int` / `bigint` / `bigserial` / `uuid`
58
+ - ๐Ÿช **Hooks & validation** โ€” `before*`/`after*` hooks, `validateCreate`/`validateUpdate`
59
+ - ๐Ÿงช **Test utilities** โ€” mock db/table/entity factories
60
+
61
+ ---
16
62
 
17
63
  ## Installation
18
64
 
@@ -20,385 +66,583 @@ A complete, type-safe CRUD abstraction layer for Drizzle ORM in NestJS applicati
20
66
  npm install nestjs-drizzle-crud
21
67
  ```
22
68
 
23
- # Quick Start
69
+ Peer dependencies (install the ones you use):
70
+
71
+ ```bash
72
+ # always
73
+ npm install @nestjs/common @nestjs/core drizzle-orm reflect-metadata
74
+
75
+ # PostgreSQL (also required if you use `connectionString` with dialect 'postgresql')
76
+ npm install postgres
77
+
78
+ # MySQL
79
+ npm install mysql2
80
+ ```
81
+
82
+ > `postgres` is an **optional** peer dependency. It's only needed when you let the
83
+ > module build the connection from a `connectionString` for the `postgresql` dialect.
84
+ > If you pass a pre-built `db` instead, you don't need it.
85
+
86
+ ---
87
+
88
+ ## Quick start
89
+
90
+ ### 1. Define your Drizzle schema
91
+
92
+ ```typescript
93
+ // db/schema.ts
94
+ import { pgTable, serial, varchar } from 'drizzle-orm/pg-core';
95
+
96
+ export const users = pgTable('users', {
97
+ id: serial('id').primaryKey(),
98
+ name: varchar('name', { length: 100 }).notNull(),
99
+ email: varchar('email', { length: 255 }).notNull().unique(),
100
+ });
101
+
102
+ export const schema = { users };
103
+ export type User = typeof users.$inferSelect;
104
+ ```
24
105
 
25
- ## 1. Basic Setup
106
+ ### 2. Configure the module once
26
107
 
27
- ``` typescript
108
+ ```typescript
28
109
  // app.module.ts
29
110
  import { Module } from '@nestjs/common';
30
111
  import { DrizzleCrudModule } from 'nestjs-drizzle-crud';
112
+ import { schema } from './db/schema';
113
+ import { UsersModule } from './users/users.module';
31
114
 
32
115
  @Module({
33
116
  imports: [
34
117
  DrizzleCrudModule.forRoot({
35
- dialect: 'postgresql', // or 'mysql'
36
- defaults: {
37
- softDelete: true,
38
- timestamps: true,
39
- pagination: { defaultLimit: 20, maxLimit: 100 },
40
- },
118
+ dialect: 'postgresql',
119
+ connectionString: process.env.DATABASE_URL,
120
+ schema,
41
121
  }),
122
+ UsersModule,
42
123
  ],
43
124
  })
44
125
  export class AppModule {}
45
126
  ```
46
127
 
47
- ## 2. Create a CRUD Service
128
+ The connection is created here, exposed globally, and closed automatically on
129
+ application shutdown (when the module built it from a `connectionString`).
130
+
131
+ ### 3. Create a service
48
132
 
49
- ``` typescript
50
- // user.service.ts
51
- import { Injectable } from '@nestjs/common';
133
+ ```typescript
134
+ // users/users.service.ts
52
135
  import { SqlBaseCrudService } from 'nestjs-drizzle-crud';
136
+ import type { User } from '../db/schema';
137
+
138
+ export interface CreateUserDto { name: string; email: string }
139
+ export interface UpdateUserDto { name?: string; email?: string }
140
+ export interface UserFilters { name?: string; email?: string }
141
+
142
+ export class UsersService extends SqlBaseCrudService<
143
+ User,
144
+ CreateUserDto,
145
+ UpdateUserDto,
146
+ UserFilters
147
+ > {}
148
+ ```
53
149
 
54
- // Your Drizzle table schema
55
- export const users = {
56
- id: 'id',
57
- name: 'name',
58
- email: 'email',
59
- password: 'password',
60
- created_at: 'created_at',
61
- updated_at: 'updated_at',
62
- deleted_at: 'deleted_at',
63
- };
64
-
65
- // DTOs and interfaces
66
- export interface User {
67
- id: number;
68
- name: string;
69
- email: string;
70
- password: string;
71
- created_at: Date;
72
- updated_at: Date;
73
- deleted_at: Date | null;
74
- }
150
+ ### 4. Bind the service to a table
75
151
 
76
- export interface CreateUserDto {
77
- name: string;
78
- email: string;
79
- password: string;
80
- }
152
+ ```typescript
153
+ // users/users.module.ts
154
+ import { Module } from '@nestjs/common';
155
+ import { DrizzleCrudModule } from 'nestjs-drizzle-crud';
156
+ import { users } from '../db/schema';
157
+ import { UsersController } from './users.controller';
158
+ import { UsersService } from './users.service';
81
159
 
82
- export interface UpdateUserDto {
83
- name?: string;
84
- email?: string;
85
- password?: string;
86
- }
160
+ @Module({
161
+ imports: [
162
+ DrizzleCrudModule.forFeature([{ service: UsersService, table: users }]),
163
+ ],
164
+ controllers: [UsersController],
165
+ })
166
+ export class UsersModule {}
167
+ ```
87
168
 
88
- export interface UserFilters {
89
- name?: string;
90
- email?: string;
91
- }
169
+ ### 5. Use it in a controller
92
170
 
93
- @Injectable()
94
- export class UserService extends SqlBaseCrudService<User, CreateUserDto, UpdateUserDto, UserFilters> {
95
- constructor(@Inject('DRIZZLE_DB') db: any) {
96
- super({
97
- dialect: 'postgresql',
98
- db,
99
- table: users,
100
- primaryKey: 'id',
101
- primaryKeyType: 'serial',
102
- softDelete: { enabled: true, column: 'deleted_at' },
103
- timestamps: { createdAt: 'created_at', updatedAt: 'updated_at' },
104
- });
105
- }
171
+ ```typescript
172
+ // users/users.controller.ts
173
+ import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post, Put, Query } from '@nestjs/common';
174
+ import { CreateUserDto, UpdateUserDto, UsersService } from './users.service';
106
175
 
107
- protected async validateCreate(data: CreateUserDto): Promise<void> {
108
- if (!data.email.includes('@')) {
109
- throw new Error('Invalid email format');
110
- }
176
+ @Controller('users')
177
+ export class UsersController {
178
+ constructor(private readonly users: UsersService) {}
179
+
180
+ @Get()
181
+ findAll(@Query('page') page = '1', @Query('limit') limit = '20') {
182
+ return this.users.findAll({}, { page: +page, limit: +limit });
111
183
  }
112
184
 
113
- protected async validateUpdate(id: number, data: UpdateUserDto): Promise<void> {
114
- if (data.email && !data.email.includes('@')) {
115
- throw new Error('Invalid email format');
116
- }
185
+ @Get(':id')
186
+ find(@Param('id', ParseIntPipe) id: number) {
187
+ return this.users.find(id);
117
188
  }
118
189
 
119
- protected mapCreateDtoToEntity(data: CreateUserDto): Record<string, any> {
120
- return {
121
- ...data,
122
- created_at: new Date(),
123
- updated_at: new Date(),
124
- };
190
+ @Post()
191
+ create(@Body() dto: CreateUserDto) {
192
+ return this.users.create(dto);
125
193
  }
126
194
 
127
- protected mapUpdateDtoToEntity(data: UpdateUserDto): Record<string, any> {
128
- return {
129
- ...data,
130
- updated_at: new Date(),
131
- };
195
+ @Put(':id')
196
+ update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateUserDto) {
197
+ return this.users.update(id, dto);
132
198
  }
133
199
 
134
- // Custom methods
135
- async findByEmail(email: string): Promise<User | null> {
136
- return this.findOne({ email });
200
+ @Delete(':id')
201
+ remove(@Param('id', ParseIntPipe) id: number) {
202
+ return this.users.delete(id);
137
203
  }
138
204
  }
139
205
  ```
140
206
 
141
- ## 3. Use in Controller
207
+ ---
142
208
 
143
- ``` typescript
144
- // user.controller.ts
145
- import { Controller, Get, Post, Put, Delete, Body, Param, Query } from '@nestjs/common';
146
- import { UserService } from './user.service';
209
+ ## Configuration
147
210
 
148
- @Controller('users')
149
- export class UserController {
150
- constructor(private readonly userService: UserService) {}
211
+ ### `DrizzleCrudModule.forRoot(config)`
151
212
 
152
- @Get()
153
- async findAll(
154
- @Query('page') page: number = 1,
155
- @Query('limit') limit: number = 20
156
- ) {
157
- return this.userService.findAll({}, { page, limit });
158
- }
213
+ | Field | Type | Description |
214
+ |---|---|---|
215
+ | `dialect` | `'postgresql' \| 'mysql'` | **Required.** Database dialect. |
216
+ | `connectionString` | `string` | Connection string. The module builds the connection (PostgreSQL only). |
217
+ | `db` | `Drizzle instance` | Alternatively, pass a Drizzle instance you built yourself (any dialect). |
218
+ | `schema` | `Record<string, unknown>` | Drizzle schema, used when building from `connectionString`. |
219
+ | `defaults.softDelete` | `boolean` | Enable soft delete for all entities (default `true`). |
220
+ | `defaults.timestamps` | `boolean` | Auto-manage `created_at`/`updated_at` for all entities (default `true`). |
221
+ | `defaults.pagination` | `{ defaultLimit, maxLimit }` | Pagination defaults (default `{ 20, 100 }`). |
222
+ | `sql` | `{ caseSensitive, useReturning, jsonSupport, enableFullTextSearch }` | Dialect tuning. `useReturning` defaults to `true` for PostgreSQL, `false` for MySQL. |
159
223
 
160
- @Get(':id')
161
- async find(@Param('id') id: string) {
162
- return this.userService.find(+id);
163
- }
224
+ > **Provide exactly one of `connectionString` or `db`.** If your tables have no
225
+ > `created_at`/`updated_at`/`deleted_at` columns, set
226
+ > `defaults: { softDelete: false, timestamps: false }`.
164
227
 
165
- @Post()
166
- async create(@Body() createUserDto: CreateUserDto) {
167
- return this.userService.create(createUserDto);
168
- }
228
+ **Build the connection yourself (any dialect, recommended for MySQL):**
169
229
 
170
- @Put(':id')
171
- async update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) {
172
- return this.userService.update(+id, updateUserDto);
230
+ ```typescript
231
+ import { drizzle } from 'drizzle-orm/postgres-js';
232
+ import postgres from 'postgres';
233
+
234
+ DrizzleCrudModule.forRoot({
235
+ dialect: 'postgresql',
236
+ db: drizzle(postgres(process.env.DATABASE_URL!), { schema }),
237
+ });
238
+ ```
239
+
240
+ ### `DrizzleCrudModule.forRootAsync(options)`
241
+
242
+ ```typescript
243
+ DrizzleCrudModule.forRootAsync({
244
+ imports: [ConfigModule],
245
+ inject: [ConfigService],
246
+ useFactory: (cfg: ConfigService) => ({
247
+ dialect: 'postgresql',
248
+ connectionString: cfg.get('DATABASE_URL'),
249
+ schema,
250
+ }),
251
+ });
252
+ ```
253
+
254
+ ### `DrizzleCrudModule.forFeature(entities)`
255
+
256
+ Registers one or more services and binds each to its table. Per-entity overrides
257
+ go in `config`:
258
+
259
+ ```typescript
260
+ DrizzleCrudModule.forFeature([
261
+ { service: UsersService, table: users },
262
+ {
263
+ service: PostsService,
264
+ table: posts,
265
+ config: {
266
+ primaryKey: 'uuid',
267
+ primaryKeyType: 'uuid',
268
+ softDelete: { enabled: true, column: 'deleted_at' },
269
+ },
270
+ },
271
+ ]);
272
+ ```
273
+
274
+ Anything in `config` overrides the project defaults for that entity. The shape is
275
+ [`SqlCrudConfig`](#sqlcrudconfig) minus `db`/`dialect`.
276
+
277
+ ---
278
+
279
+ ## Defining services
280
+
281
+ The minimal service is an empty class โ€” connection, dialect and defaults are
282
+ injected by the module:
283
+
284
+ ```typescript
285
+ export class UsersService extends SqlBaseCrudService<User> {}
286
+ ```
287
+
288
+ Add custom behaviour by overriding hooks (see [Lifecycle hooks](#lifecycle-hooks--validation))
289
+ or add your own methods:
290
+
291
+ ```typescript
292
+ export class UsersService extends SqlBaseCrudService<User, CreateUserDto, UpdateUserDto, UserFilters> {
293
+ findByEmail(email: string) {
294
+ return this.findOne({ email } as Partial<User>);
173
295
  }
174
296
 
175
- @Delete(':id')
176
- async delete(@Param('id') id: string) {
177
- return this.userService.softDelete(+id);
297
+ protected async validateCreate(data: CreateUserDto): Promise<void> {
298
+ if (!data.email.includes('@')) throw new Error('Invalid email');
178
299
  }
179
300
  }
180
301
  ```
181
302
 
182
- ## Advanced Usage
303
+ ---
183
304
 
184
- ### Bulk Operations
305
+ ## API reference
185
306
 
186
- ``` typescript
187
- // Mass create users
188
- const users = await this.userService.massCreate(userDtos);
307
+ `SqlBaseCrudService<T, CreateDto = Partial<T>, UpdateDto = Partial<T>, FilterDto = Partial<T>>`
189
308
 
190
- // Mass update
191
- const updatedUsers = await this.userService.massUpdate(
192
- [1, 2, 3],
193
- { status: 'active' }
194
- );
309
+ ### Read
195
310
 
196
- // Mass soft delete
197
- await this.userService.massSoftDelete([1, 2, 3]);
198
- ```
311
+ | Method | Returns | Notes |
312
+ |---|---|---|
313
+ | `find(id, options?)` | `Promise<T \| null>` | By primary key. Skips soft-deleted rows. |
314
+ | `findOne(where, options?)` | `Promise<T \| null>` | `where` is a `Partial<T>` (equality only). |
315
+ | `findAll(filters?, pagination?, options?)` | `Promise<{ data: T[]; total: number; page: number; limit: number }>` | See [Filtering](#filtering) / [Pagination](#pagination--sorting). |
316
+ | `exists(id, options?)` | `Promise<boolean>` | |
317
+ | `count(filters?, options?)` | `Promise<number>` | |
199
318
 
200
- ### Full-Text Search (PostgreSQL)
319
+ ### Write
201
320
 
202
- ``` typescript
203
- const results = await this.userService.fullTextSearch(
204
- 'john doe',
205
- ['name', 'email', 'bio']
206
- );
207
- ```
321
+ | Method | Returns | Notes |
322
+ |---|---|---|
323
+ | `create(data, options?)` | `Promise<T>` | Runs `validateCreate` โ†’ `beforeCreate` โ†’ insert โ†’ `afterCreate`. |
324
+ | `update(id, data, options?)` | `Promise<T>` | Throws `EntityNotFoundException` if missing. |
325
+ | `delete(id, options?)` | `Promise<boolean>` | Hard delete. |
326
+ | `softDelete(id, options?)` | `Promise<boolean>` | Requires soft delete enabled. |
327
+ | `restore(id, options?)` | `Promise<T>` | Clears the soft-delete column. |
208
328
 
209
- ### Transactions
329
+ ### Bulk (run inside a transaction)
210
330
 
211
- ``` typescript
212
- await this.userService.executeSqlTransaction(async (tx) => {
213
- await this.userService.create(user1, { transaction: tx });
214
- await this.userService.create(user2, { transaction: tx });
215
- });
331
+ | Method | Returns |
332
+ |---|---|
333
+ | `massCreate(data[], options?)` | `Promise<T[]>` |
334
+ | `massUpdate(ids[], data, options?)` | `Promise<T[]>` |
335
+ | `massSoftDelete(ids[], options?)` | `Promise<boolean>` |
336
+ | `massRestore(ids[], options?)` | `Promise<T[]>` |
337
+ | `massDelete(ids[], options?)` | `Promise<boolean>` |
338
+
339
+ ### Search
340
+
341
+ | Method | Returns |
342
+ |---|---|
343
+ | `fullTextSearch(term, columns, pagination?, options?)` | `Promise<{ data: T[]; total: number }>` (PostgreSQL only) |
344
+
345
+ ### `SqlOperationOptions`
346
+
347
+ ```typescript
348
+ interface SqlOperationOptions {
349
+ transaction?: any; // run within an existing transaction
350
+ select?: string[]; // return only these columns
351
+ relations?: string[]; // eager-load these configured relations (see Relations)
352
+ hooks?: { skipBefore?: boolean; skipAfter?: boolean };
353
+ lock?: 'update' | 'share' | 'none';
354
+ forNoKeyUpdate?: boolean;
355
+ }
216
356
  ```
217
357
 
218
- ### Advanced Filtering
358
+ > `relations` eager-loads relations declared in the entity's config โ€” see [Relations](#relations).
219
359
 
220
- ``` typescript
221
- // Complex filters
222
- const results = await this.userService.findAll({
223
- name: { like: 'John%' },
224
- age: { gt: 18, lt: 65 },
225
- status: { in: ['active', 'pending'] }
226
- });
360
+ ---
361
+
362
+ ## Filtering
363
+
364
+ `findAll(filters)` / `count(filters)` accept an object keyed by column name.
365
+ Unknown keys and `null`/`undefined` values are ignored.
227
366
 
228
- // With relations and selection
229
- const user = await this.userService.find(1, {
230
- relations: ['profile', 'posts'],
231
- select: ['id', 'name', 'email']
367
+ ```typescript
368
+ await service.findAll({
369
+ status: 'active', // string: exact match (case-insensitive when sql.caseSensitive === false)
370
+ role: ['admin', 'editor'], // array: IN (...)
371
+ age: { gte: 18, lt: 65 }, // comparison operators
372
+ name: { ilike: 'jo%' }, // pattern match โ€” you supply the wildcards
373
+ deleted_at: { isNull: true }, // null checks
232
374
  });
233
375
  ```
234
376
 
235
- # Module Configuration
236
- ## Async Configuration
377
+ **Operators** (inside an object value):
237
378
 
238
- ``` typescript
239
- DrizzleCrudModule.forRootAsync({
240
- imports: [ConfigModule],
241
- useFactory: async (configService: ConfigService) => ({
242
- dialect: configService.get('DATABASE_DIALECT'),
243
- defaults: {
244
- softDelete: true,
245
- timestamps: true,
246
- },
247
- }),
248
- inject: [ConfigService],
249
- }),
379
+ | Operator | SQL |
380
+ |---|---|
381
+ | `gt` / `gte` / `lt` / `lte` | `>` `>=` `<` `<=` |
382
+ | `neq` | `<>` |
383
+ | `like` / `ilike` | `LIKE` / `ILIKE` โ€” **pass your own `%` wildcards** |
384
+ | `in` | `IN (...)` |
385
+ | `isNull` / `isNotNull` | `IS NULL` / `IS NOT NULL` |
386
+
387
+ > A bare string value is an **exact** match. When `sql.caseSensitive` is `false`
388
+ > (the default) it uses `ILIKE` *without* wildcards (case-insensitive exact match).
389
+ > For partial matching, use the explicit `like`/`ilike` operators with wildcards.
390
+
391
+ ---
392
+
393
+ ## Pagination & sorting
394
+
395
+ ```typescript
396
+ await service.findAll(
397
+ {},
398
+ { page: 2, limit: 25, sortBy: 'created_at', sortOrder: 'desc' },
399
+ );
400
+ // โ†’ { data: [...], total: 240, page: 2, limit: 25 }
250
401
  ```
251
402
 
252
- ## Multiple Entities
403
+ `limit` is capped at `pagination.maxLimit`. `sortOrder` defaults to `'desc'`.
404
+
405
+ ---
406
+
407
+ ## Relations
408
+
409
+ The package supports **many-to-one / belongs-to** relations: a foreign key on
410
+ this entity's table points at another table's key. Declare them in the entity's
411
+ `forFeature` config under `relations`, keyed by relation name:
412
+
413
+ ```typescript
414
+ import { cities, states } from './db/schema';
253
415
 
254
- ``` typescript
255
416
  DrizzleCrudModule.forFeature([
256
417
  {
257
- name: 'User',
258
- table: usersTable,
259
- service: UserService,
260
- config: { primaryKey: 'id' },
261
- },
262
- {
263
- name: 'Post',
264
- table: postsTable,
265
- service: PostService,
266
- config: { softDelete: { enabled: false } },
418
+ service: CitiesService,
419
+ table: cities,
420
+ config: {
421
+ relations: {
422
+ // cities.state_id -> states.id (`references` defaults to 'id')
423
+ state: { table: states, localKey: 'state_id', references: 'id' },
424
+ },
425
+ },
267
426
  },
268
- ]),
427
+ ]);
269
428
  ```
270
429
 
271
- # Testing
272
- ``` typescript
273
- // user.service.spec.ts
274
- import { TestCrudFactory } from 'nestjs-drizzle-crud/test-utils';
275
-
276
- describe('UserService', () => {
277
- let service: UserService;
278
- let mockDb: any;
279
-
280
- beforeEach(() => {
281
- mockDb = TestCrudFactory.createMockDb();
282
- const mockTable = TestCrudFactory.createMockTable();
283
-
284
- service = TestCrudFactory.createTestService(
285
- UserService,
286
- mockDb,
287
- mockTable
288
- );
289
- });
290
-
291
- it('should create user', async () => {
292
- const createDto = { name: 'John', email: 'john@test.com' };
293
- const mockEntity = TestCrudFactory.createMockEntity();
294
-
295
- mockDb.insert.mockReturnValue({
296
- values: jest.fn().mockReturnThis(),
297
- returning: jest.fn().mockResolvedValue([mockEntity]),
298
- });
299
-
300
- const result = await service.create(createDto);
301
- expect(result).toEqual(mockEntity);
302
- });
303
- });
430
+ Once declared, you get two capabilities:
431
+
432
+ ### 1. Eager loading
433
+
434
+ Pass `relations` in the operation options to LEFT JOIN and nest the related row:
435
+
436
+ ```typescript
437
+ await cities.find(1, { relations: ['state'] });
438
+ // โ†’ { id: 1, name: 'Bengaluru', state_id: 7, state: { id: 7, name: 'Karnataka', country_id: 3 } }
439
+
440
+ await cities.findAll({}, { page: 1, limit: 20 }, { relations: ['state'] });
304
441
  ```
305
442
 
306
- # API Reference
307
- ## Core Methods
443
+ If there's no match, the relation comes back as `null`.
308
444
 
309
- * find(id, options?) - Find by primary key
445
+ ### 2. Filtering by related columns
310
446
 
311
- * findOne(where, options?) - Find by criteria
447
+ Use the relation name as a filter key with a nested object of the **related
448
+ table's** columns. Supports the same [operators](#filtering) as normal filters:
312
449
 
313
- * findAll(filters?, pagination?, options?) - Find all with filtering & pagination
450
+ ```typescript
451
+ // all cities whose state is named 'Karnataka' (case-insensitive exact)
452
+ await cities.findAll({ state: { name: 'Karnataka' } });
314
453
 
315
- * create(data, options?) - Create new entity
454
+ // all cities in a country โ€” filter on the intermediate table's FK column
455
+ await cities.findAll({ state: { country_id: 3 } });
316
456
 
317
- * update(id, data, options?) - Update entity
457
+ // combine with normal column filters and operators
458
+ await cities.findAll({ name: { ilike: 'B%' }, state: { country_id: 3 } });
459
+ ```
318
460
 
319
- * softDelete(id, options?) - Soft delete entity
461
+ > **Scope:** only **many-to-one / one-to-one** (belongs-to) relations are
462
+ > supported. Has-many collection loading and many-to-many (join tables) are not
463
+ > handled โ€” model those with a custom service method using `this.config.db`, or
464
+ > orchestrate across services in a controller.
465
+ >
466
+ > **Multi-level filtering** works through the intermediate table's columns
467
+ > (e.g. filter cities by `state.country_id`), so you usually don't need a
468
+ > direct relation to the far table.
320
469
 
321
- * restore(id, options?) - Restore soft-deleted entity
470
+ ---
322
471
 
323
- * delete(id, options?) - Hard delete entity
472
+ ## Primary keys (serial / uuid)
324
473
 
325
- ## Bulk Methods
326
- * massCreate(data[], options?) - Create multiple entities
474
+ Each entity declares its primary key via `primaryKey` (column name, default
475
+ `id`) and `primaryKeyType`. Both auto-increment and UUID keys are supported.
327
476
 
328
- * massUpdate(ids[], data, options?) - Update multiple entities
477
+ ```typescript
478
+ // serial / auto-increment (default)
479
+ { service: UsersService, table: users } // primaryKey 'id', primaryKeyType 'serial'
329
480
 
330
- * massSoftDelete(ids[], options?) - Soft delete multiple entities
481
+ // UUID
482
+ {
483
+ service: TagsService,
484
+ table: tags, // e.g. uuid('id').primaryKey().defaultRandom()
485
+ config: { primaryKey: 'id', primaryKeyType: 'uuid' },
486
+ }
487
+ ```
331
488
 
332
- * massRestore(ids[], options?) - Restore multiple entities
489
+ `primaryKeyType` accepts `'serial' | 'bigserial' | 'int' | 'bigint' | 'uuid'`.
490
+ On PostgreSQL the created row (including a DB-generated UUID) is returned via
491
+ `RETURNING`. On MySQL (no `RETURNING`), provide the UUID in your create payload
492
+ so the row can be re-read โ€” auto-increment keys use the driver's `insertId`.
333
493
 
334
- * massDelete(ids[], options?) - Hard delete multiple entities
494
+ > Remember: with UUID keys, route params are strings โ€” don't apply
495
+ > `ParseIntPipe` in your controller.
335
496
 
336
- ## Utility Methods
497
+ ---
337
498
 
338
- * exists(id, options?) - Check if entity exists
499
+ ## Soft delete
339
500
 
340
- * count(filters?, options?) - Count entities
501
+ Enable per-project via `defaults.softDelete` or per-entity via `forFeature` config:
341
502
 
342
- * fullTextSearch(term, columns, pagination?, options?) - Full-text search (PostgreSQL)
503
+ ```typescript
504
+ { service: UsersService, table: users, config: { softDelete: { enabled: true, column: 'deleted_at' } } }
505
+ ```
343
506
 
507
+ - `softDelete(id)` sets the column to the current timestamp.
508
+ - `restore(id)` sets it back to `null`.
509
+ - `find`/`findOne`/`findAll`/`count` automatically exclude soft-deleted rows.
344
510
 
345
- # Configuration Options
346
- ``` typescript
347
- interface SqlCrudConfig {
348
- dialect: 'postgresql' | 'mysql';
349
- db: any; // Drizzle database instance
350
- table: any; // Drizzle table
351
-
352
- // Primary key configuration
353
- primaryKey: string;
354
- primaryKeyType: 'serial' | 'bigserial' | 'int' | 'bigint' | 'uuid';
355
-
356
- // Soft delete
357
- softDelete?: {
358
- enabled: boolean;
359
- column: string;
360
- };
361
-
362
- // Timestamps
363
- timestamps?: {
364
- createdAt: string;
365
- updatedAt: string;
366
- };
367
-
368
- // Pagination
369
- pagination?: {
370
- defaultLimit: number;
371
- maxLimit: number;
372
- };
373
- }
511
+ ---
512
+
513
+ ## Bulk operations
514
+
515
+ ```typescript
516
+ await service.massCreate([dto1, dto2, dto3]);
517
+ await service.massUpdate([1, 2, 3], { status: 'archived' });
518
+ await service.massSoftDelete([1, 2, 3]);
374
519
  ```
375
520
 
376
- # Supported Versions
377
- * NestJS: >=10.0.0
521
+ All bulk methods run inside a single transaction; if any row fails, a
522
+ `BulkOperationException` (carrying the per-row errors) is thrown and the
523
+ transaction rolls back.
378
524
 
379
- * Drizzle ORM: >=0.28.0
525
+ ---
380
526
 
381
- * Node.js: >=18.0.0
527
+ ## Transactions
382
528
 
383
- * PostgreSQL: >=12.0
529
+ ```typescript
530
+ await service.executeSqlTransaction(async (tx) => {
531
+ const user = await service.create(userDto, { transaction: tx });
532
+ await profileService.create({ userId: user.id }, { transaction: tx });
533
+ });
534
+ ```
384
535
 
385
- * MySQL: >=8.0
536
+ Pass `{ transaction: tx }` in `options` to any method to enlist it.
386
537
 
387
- # Contributing
388
- Contributions are welcome! Please feel free to submit a Pull Request.
538
+ ---
389
539
 
390
- # License
391
- MIT
540
+ ## Full-text search (PostgreSQL)
541
+
542
+ ```typescript
543
+ const { data, total } = await service.fullTextSearch(
544
+ 'john doe',
545
+ ['name', 'email', 'bio'],
546
+ { page: 1, limit: 20 },
547
+ );
548
+ ```
549
+
550
+ Builds `to_tsvector(...) @@ plainto_tsquery(...)` across the given columns and
551
+ orders by `ts_rank`. Throws if the dialect is not `postgresql`.
552
+
553
+ ---
554
+
555
+ ## Lifecycle hooks & validation
556
+
557
+ Override any of these `protected` methods in your service (all are optional;
558
+ defaults are no-op / pass-through):
559
+
560
+ ```typescript
561
+ protected validateCreate(data: CreateDto): Promise<void>
562
+ protected validateUpdate(id: any, data: UpdateDto): Promise<void>
563
+ protected mapCreateDtoToEntity(data: CreateDto): Record<string, any>
564
+ protected mapUpdateDtoToEntity(data: UpdateDto): Record<string, any>
565
+
566
+ protected beforeCreate(data: CreateDto): Promise<CreateDto>
567
+ protected afterCreate(entity: T): Promise<void>
568
+ protected beforeUpdate(id: any, data: UpdateDto): Promise<UpdateDto>
569
+ protected afterUpdate(entity: T): Promise<void>
570
+ protected beforeDelete(id: any): Promise<void>
571
+ protected afterDelete(id: any): Promise<void>
572
+ protected beforeSoftDelete(id: any): Promise<void>
573
+ protected afterSoftDelete(id: any): Promise<void>
574
+ protected beforeRestore(id: any): Promise<void>
575
+ protected afterRestore(entity: T): Promise<void>
576
+ ```
392
577
 
578
+ `mapCreateDtoToEntity` / `mapUpdateDtoToEntity` transform the incoming DTO into the
579
+ row to persist (default returns a shallow copy). When `timestamps` is enabled, the
580
+ service stamps `created_at`/`updated_at` automatically.
393
581
 
394
- This README provides:
582
+ ---
395
583
 
396
- 1. **Clear installation instructions**
397
- 2. **Quick start guide** with code examples
398
- 3. **Advanced usage patterns**
399
- 4. **Comprehensive API documentation**
400
- 5. **Testing examples**
401
- 6. **Configuration reference**
402
- 7. **Version compatibility**
584
+ ## Testing
403
585
 
404
- It's ready to use and will help users understand how to implement your package quickly!
586
+ ```typescript
587
+ import { TestCrudFactory } from 'nestjs-drizzle-crud';
588
+
589
+ const mockDb = TestCrudFactory.createMockDb();
590
+ const mockTable = TestCrudFactory.createMockTable();
591
+ const service = TestCrudFactory.createTestService(UsersService, mockDb, mockTable);
592
+ ```
593
+
594
+ `TestCrudFactory` provides `createMockDb()`, `createMockTable()`,
595
+ `createMockEntity()` and `createTestService()` for unit tests without a database.
596
+
597
+ ---
598
+
599
+ ## For AI agents / LLM tools
600
+
601
+ Concise, accurate facts for code generation. Prefer these over guessing.
602
+
603
+ **Package:** `nestjs-drizzle-crud` ยท **Peers:** `@nestjs/common`, `@nestjs/core`, `drizzle-orm`, `reflect-metadata`; optional `postgres` (PG) / `mysql2` (MySQL).
604
+
605
+ **Setup is two steps and no per-service connection wiring:**
606
+ 1. `DrizzleCrudModule.forRoot({ dialect, connectionString | db, schema, defaults })` once in `AppModule`.
607
+ 2. `DrizzleCrudModule.forFeature([{ service, table, config? }])` in each feature module.
608
+
609
+ **A service is an empty subclass โ€” do NOT inject the db or pass `dialect`/`db`:**
610
+ ```typescript
611
+ export class XService extends SqlBaseCrudService<X, CreateXDto, UpdateXDto, XFilters> {}
612
+ ```
613
+
614
+ **Rules / gotchas:**
615
+ - Generics order: `SqlBaseCrudService<Entity, CreateDto, UpdateDto, FilterDto>`. All but `Entity` default to `Partial<Entity>`.
616
+ - Do **not** add an `@Inject('DRIZZLE_DB')` constructor โ€” `forFeature` constructs the service for you. Adding a constructor that calls `super({...})` is the legacy/manual pattern and is unnecessary.
617
+ - The table is passed in `forFeature`, **not** in the service.
618
+ - If tables lack timestamp/soft-delete columns, set `defaults: { softDelete: false, timestamps: false }`, else inserts will reference non-existent columns.
619
+ - `findAll` returns `{ data, total, page, limit }` โ€” not a bare array.
620
+ - Filter operators live inside an object value: `{ age: { gte: 18 } }`. `like`/`ilike` require caller-supplied `%` wildcards; a bare string is exact match.
621
+ - `delete`/`softDelete` return `boolean`; `update`/`restore` return the entity and throw `EntityNotFoundException` when missing.
622
+ - Relations (many-to-one only): declare in forFeature `config.relations = { relName: { table, localKey, references? } }`. Then eager-load via `options.relations: ['relName']` (nested object on the result, `null` if unmatched) and filter via `findAll({ relName: { col: value } })` (same operators; multi-level via the intermediate table's columns, e.g. `state.country_id`). No has-many/many-to-many.
623
+ - Primary keys: `primaryKey` (default `'id'`) + `primaryKeyType` (`'serial' | 'bigserial' | 'int' | 'bigint' | 'uuid'`) per entity in forFeature config. UUID works on Postgres via RETURNING; with uuid keys, route params are strings โ€” don't use `ParseIntPipe`.
624
+ - Full-text search is PostgreSQL-only.
625
+ - Exports: `SqlBaseCrudService`, `DrizzleCrudModule`, `DRIZZLE_DB`, `DRIZZLE_CRUD_CONFIG`, `TestCrudFactory`, exceptions (`EntityNotFoundException`, `BulkOperationException`, โ€ฆ), and types (`SqlCrudConfig`, `SqlOperationOptions`, `DrizzleCrudConfig`, `CrudFeature`, `SqlDialect`, `PrimaryKeyType`).
626
+
627
+ ### `SqlCrudConfig`
628
+
629
+ ```typescript
630
+ interface SqlCrudConfig {
631
+ dialect: 'postgresql' | 'mysql';
632
+ db: any; // Drizzle instance (injected by forFeature)
633
+ table: any; // Drizzle table (set by forFeature)
634
+ primaryKey: string; // default 'id'
635
+ primaryKeyType: 'serial' | 'bigserial' | 'int' | 'bigint' | 'uuid';
636
+ softDelete?: { enabled: boolean; column: string };
637
+ timestamps?: { createdAt: string; updatedAt: string };
638
+ pagination?: { defaultLimit: number; maxLimit: number };
639
+ sql?: { caseSensitive: boolean; useReturning: boolean; jsonSupport: boolean; enableFullTextSearch: boolean };
640
+ relations?: Record<string, { table: any; localKey: string; references?: string }>;
641
+ }
642
+ ```
643
+
644
+ ---
645
+
646
+ ## License
647
+
648
+ MIT