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 +533 -289
- package/dist/core/abstract/sql-base-crud.service.d.ts +16 -5
- package/dist/core/abstract/sql-base-crud.service.js +184 -78
- package/dist/core/abstract/sql-base-crud.service.js.map +1 -1
- package/dist/core/interfaces/drizzle-crud-config.interface.d.ts +8 -0
- package/dist/core/interfaces/sql-crud-config.interface.d.ts +7 -0
- package/dist/index.d.ts +5 -4
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- 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 +2 -4
- package/dist/modules/drizzle-crud.module.js +92 -49
- package/dist/modules/drizzle-crud.module.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +10 -2
package/README.md
CHANGED
|
@@ -1,18 +1,64 @@
|
|
|
1
|
-
#
|
|
1
|
+
# nestjs-drizzle-crud
|
|
2
2
|
|
|
3
|
-
A complete, type-safe CRUD abstraction layer for Drizzle ORM in NestJS applications.
|
|
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
|
|
8
|
-
-
|
|
9
|
-
- โก **Type-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
106
|
+
### 2. Configure the module once
|
|
26
107
|
|
|
27
|
-
```
|
|
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',
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
```
|
|
50
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
89
|
-
name?: string;
|
|
90
|
-
email?: string;
|
|
91
|
-
}
|
|
169
|
+
### 5. Use it in a controller
|
|
92
170
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
185
|
+
@Get(':id')
|
|
186
|
+
find(@Param('id', ParseIntPipe) id: number) {
|
|
187
|
+
return this.users.find(id);
|
|
117
188
|
}
|
|
118
189
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
return this.
|
|
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
|
-
|
|
207
|
+
---
|
|
142
208
|
|
|
143
|
-
|
|
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
|
-
|
|
149
|
-
export class UserController {
|
|
150
|
-
constructor(private readonly userService: UserService) {}
|
|
211
|
+
### `DrizzleCrudModule.forRoot(config)`
|
|
151
212
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
176
|
-
|
|
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
|
-
|
|
303
|
+
---
|
|
183
304
|
|
|
184
|
-
|
|
305
|
+
## API reference
|
|
185
306
|
|
|
186
|
-
|
|
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
|
-
|
|
191
|
-
const updatedUsers = await this.userService.massUpdate(
|
|
192
|
-
[1, 2, 3],
|
|
193
|
-
{ status: 'active' }
|
|
194
|
-
);
|
|
309
|
+
### Read
|
|
195
310
|
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
###
|
|
319
|
+
### Write
|
|
201
320
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
###
|
|
329
|
+
### Bulk (run inside a transaction)
|
|
210
330
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
358
|
+
> `relations` eager-loads relations declared in the entity's config โ see [Relations](#relations).
|
|
219
359
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
236
|
-
## Async Configuration
|
|
377
|
+
**Operators** (inside an object value):
|
|
237
378
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
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
|
-
|
|
258
|
-
table:
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
307
|
-
## Core Methods
|
|
443
|
+
If there's no match, the relation comes back as `null`.
|
|
308
444
|
|
|
309
|
-
|
|
445
|
+
### 2. Filtering by related columns
|
|
310
446
|
|
|
311
|
-
|
|
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
|
-
|
|
450
|
+
```typescript
|
|
451
|
+
// all cities whose state is named 'Karnataka' (case-insensitive exact)
|
|
452
|
+
await cities.findAll({ state: { name: 'Karnataka' } });
|
|
314
453
|
|
|
315
|
-
|
|
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
|
-
|
|
457
|
+
// combine with normal column filters and operators
|
|
458
|
+
await cities.findAll({ name: { ilike: 'B%' }, state: { country_id: 3 } });
|
|
459
|
+
```
|
|
318
460
|
|
|
319
|
-
|
|
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
|
-
|
|
470
|
+
---
|
|
322
471
|
|
|
323
|
-
|
|
472
|
+
## Primary keys (serial / uuid)
|
|
324
473
|
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
477
|
+
```typescript
|
|
478
|
+
// serial / auto-increment (default)
|
|
479
|
+
{ service: UsersService, table: users } // primaryKey 'id', primaryKeyType 'serial'
|
|
329
480
|
|
|
330
|
-
|
|
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
|
-
|
|
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
|
-
|
|
494
|
+
> Remember: with UUID keys, route params are strings โ don't apply
|
|
495
|
+
> `ParseIntPipe` in your controller.
|
|
335
496
|
|
|
336
|
-
|
|
497
|
+
---
|
|
337
498
|
|
|
338
|
-
|
|
499
|
+
## Soft delete
|
|
339
500
|
|
|
340
|
-
|
|
501
|
+
Enable per-project via `defaults.softDelete` or per-entity via `forFeature` config:
|
|
341
502
|
|
|
342
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
377
|
-
|
|
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
|
-
|
|
525
|
+
---
|
|
380
526
|
|
|
381
|
-
|
|
527
|
+
## Transactions
|
|
382
528
|
|
|
383
|
-
|
|
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
|
-
|
|
536
|
+
Pass `{ transaction: tx }` in `options` to any method to enlist it.
|
|
386
537
|
|
|
387
|
-
|
|
388
|
-
Contributions are welcome! Please feel free to submit a Pull Request.
|
|
538
|
+
---
|
|
389
539
|
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
582
|
+
---
|
|
395
583
|
|
|
396
|
-
|
|
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
|
-
|
|
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
|