关于 nestjs-crud
@nestjs-crud 是用于 NestJS 的 CRUD 端点生成库,面向需要为实体快速构建 REST API 的开发者。它支持 TypeORM、Drizzle、MikroORM 和 Prisma,并提供查询配置、分页及用户范围控制。
主要功能
- 01通过 @Crud() 生成 REST 端点
- 02支持四种 ORM 适配器
- 03配置过滤、排序和关联查询
- 04支持偏移与游标分页
- 05提供用户数据范围和缓存配置
适用场景
- 01为 NestJS 实体创建 CRUD API
- 02配置列表接口的过滤和分页
- 03按当前用户限制读写数据
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ kodjunkie/ nestjs-crud --skill nestjs-crud安装后在 Claude Code 中输入/nestjs-crud确认已加载。
详细介绍
@nestjs-crud
根据 @Crud() 自动生成 RESTful CRUD 端点。支持四种适配器:TypeORM、Drizzle、MikroORM、Prisma。需要 Node 22.12+(NestJS 12 仅支持 ESM → 使用 Node 的 require(esm),该功能自 22.12 起提供)。支持 NestJS 10–12、TypeORM 0.3.30+ 或 1.x(从 v2.3 起支持 NestJS 12 + TypeORM 1)。更新日志。
安装
npm install @nestjs-crud/core @nestjs-crud/<adapter> # typeorm | drizzle | mikro-orm | prisma
npm install @nestjs-crud/request # optional frontend QB
# All four adapters declare the DB driver as an optional peer — install whichever your backend uses:
npm install pg # Postgres (typeorm/drizzle/mikro-orm)
npm install mysql2 # MySQL (typeorm/drizzle/mikro-orm)
# Prisma 7 needs a driver adapter:
npm install @prisma/adapter-pg pg # Postgres
npm install @prisma/adapter-mariadb mariadb # MySQL
npm install -D prisma # CLI
Swagger 为可选项 — 安装 @nestjs/swagger 即可启用元数据;如果未安装,库会对所有 Swagger 路径进行空值保护。
快速入门
@Crud() 代码块和 @Controller() 在所有适配器中完全相同。只有服务构造函数不同。
@Crud({
model: { type: User },
query: {
limit: 25, maxLimit: 100,
join: { profile: { eager: true }, posts: { allow: ['id', 'title'] } },
},
})
@Controller('users')
export class UsersController implements CrudController<User> {
constructor(public service: UsersService) {}
}
TypeORM 服务
@Injectable()
export class UsersService extends TypeOrmCrudService<User> {
constructor(@InjectRepository(User) repo: Repository<User>) {
super(repo);
}
}
Drizzle 服务
@Injectable()
export class UsersService extends DrizzleCrudService<typeof users.$inferSelect> {
constructor(@Inject('DB') drizzleDb: typeof db) {
super(drizzleDb, users); // (db instance, table reference)
}
}
MikroORM 服务
import { InjectRepository } from '@mikro-orm/nestjs';
import { EntityRepository } from '@mikro-orm/core';
@Injectable()
export class UsersService extends MikroOrmCrudService<User> {
constructor(@InjectRepository(User) usersRepo: EntityRepository<User>) {
super(usersRepo, User);
}
}
构造函数接受 EntityManager | EntityRepository<T>(v2.2+)。super(usersRepo, User) 会在内部通过 repo.getEntityManager() 解包。通过 DI 注入的 em 代理会解析为 RequestContext 中按请求分叉的 em,无需额外操作即可保留按请求隔离的 identity map。若要直接传入 EntityManager:constructor(em: EntityManager) { super(em, User); } — 行为相同。
Prisma 服务(v2.1+,搭配 Prisma 7)
import { PrismaCrudService, PrismaJoinResolver } from '@nestjs-crud/prisma';
const prisma = new PrismaClient({
adapter: new PrismaPg(new Pool({ connectionString: process.env.DATABASE_URL })),
});
@Injectable()
export class UsersService extends PrismaCrudService<User> {
constructor(@Inject('PRISMA') prisma: PrismaClient) {
super(prisma, 'user', {
entityColumns: ['id', 'email', 'isActive', 'companyId', 'deletedAt'],
entityPrimaryColumns: ['id'],
entityHasDeleteColumn: true,
softDeleteColumn: 'deletedAt',
onBadRequest: (msg) => { throw new BadRequestException(msg); },
joinResolver: new PrismaJoinResolver({
relationFields: ['company'],
allowedColumnsByRelation: { company: ['id', 'name'] },
}),
});
}
}
Prisma 7 移除了 env-URL 路径 — 必须使用驱动适配器。Schema 改写方式请参阅 v2.1 迁移 wiki。在 MySQL 上使用时,将 @prisma/adapter-pg 替换为 @prisma/adapter-mariadb。第 3 个构造函数参数为 PrismaCrudServiceConfig;以上所有字段均为必填项,logger 和 cacheStrategy 是其中的可选字段。
生成的端点
| 方法 | 路径 | 处理程序 |
|---|---|---|
| GET | /users | getManyBase |
| GET | /users/:id | getOneBase |
| POST | /users | createOneBase |
| POST | /users/bulk | createManyBase |
| PATCH | /users/:id | updateOneBase |
| PUT | /users/:id | replaceOneBase |
| DELETE | /users/:id | deleteOneBase |
| PATCH | /users/:id/recover | recoverOneBase(需要 query.softDelete: true) |
@Crud() 关键选项
@Crud({
model: { type: Entity }, // required
dto: { create, update, replace }, // optional — see DTOs
serialize: { getMany, get }, // per-route response DTOs
query: {
limit: 25, maxLimit: 100,
pagination: 'offset' | 'cursor', // v2.2+ — default 'offset'
cache: 2000, // ms
alwaysPaginate: false,
softDelete: false, // enables recoverOneBase
relationLoadStrategy: 'join' | 'query', // TypeORM only
join: { rel: { eager, allow, exclude, required } },
filter: [{ field, operator, value }],
sort: [{ field, order }],
exclude: ['password'],
},
routes: { exclude: ['createManyBase'], updateOneBase: { allowParamsOverride: false } },
params: { id: { field: 'id', type: 'uuid', primary: true } },
serviceProperty: 'usersService', // v2.2+ — defaults 'service'; see §IntelliSense for typing
})
完整参考:Controllers wiki。
嵌套关联(v2.3+)。 ?join=profile.licenses 要求在 ?join= 中请求所有祖先关联,或将其设为 eager,顺序和深度不限。否则,所有 4 种适配器都会返回 400 Invalid join: 'profile.licenses'。如果某个 eager 嵌套键的祖先不是 eager,@Crud() 会在启动时抛出异常。只有 TypeORM 和 Drizzle 会加载嵌套关联;MikroORM 和 Prisma 只会 join 或 include 顶层关联。?join= 中缺少 query.join 的关联会被忽略,不会被拒绝。
游标分页(v2.2+,选择启用)
@Crud({ query: { pagination: 'cursor', limit: 25 } })
将 getManyBase 切换为键集游标模式。响应:{ data, count, cursor: { next, prev } }。默认仍为 'offset'(不破坏兼容性)。
- 游标 = 不透明的 base64url JSON
{sortField, sortValue, id, dir}。未签名 — 授权仍由@CrudAuth负责。游标不透明 ≠ 访问边界。 @CrudAuth筛选条件会在游标之前应用(拦截器会将授权合并到parsed.search中;服务会在其基础上通过WHEREAND 追加游标条件)。游标分页是@CrudAuth筛选结果集的子集 — 与 offset 模式具有相同的隔离保障。- 排序:使用
?sort=,否则使用路由query.sort默认值(v2.2.6+ 起支持默认值);单个字段 + 自动添加的主键尾项。多字段排序或未指定排序 → 400。需要limit或maxLimit(缺失时 → 400)。游标被篡改或排序不匹配 → 400。 - 游标模式会绕过缓存封装(每个游标键的基数没有上限)。对于高频端点,请搭配
@nestjs/throttler使用。 - 所有 4 种适配器都会通过各自的
QueryComposer.applyCursor实现支持。Prisma 内置的cursor:参数会被有意绕过(仅支持单列唯一键)。
查询参数(前端 → 后端)
RequestQueryBuilder.create()
.setFilter({ field: 'isActive', operator: CondOperator.EQUALS, value: true })
.setOr({ field: 'role', operator: CondOperator.IN, value: ['admin'] })
.setJoin({ field: 'profile' })
.sortBy({ field: 'createdAt', order: 'DESC' })
.setLimit(20).setPage(2)
.query();
23 个运算符(CondOperator): $eq $ne $gt $gte $lt $lte $between $isnull $notnull $in $notin $cont $excl $starts $ends + 8 个不区分大小写的 *L 变体。
原始形式:?filter=name||$cont||john&sort=createdAt,DESC&join=profile&limit=25&page=2&cursor=<opaque>
全局默认值
在 main.ts 中、NestFactory.create 之前设置项目级默认值。每个 @Crud() 都会进行深度合并;控制器标量值优先,数组则会替换。
CrudConfigService.load({
query: { limit: 25, maxLimit: 100, alwaysPaginate: true, cache: 2000, softDelete: false },
routes: { updateOneBase: { allowParamsOverride: false, returnShallow: false }, deleteOneBase: { returnDeleted: false } },
params: { id: { field: 'id', type: 'number', primary: true } },
serialize: { getMany: false }, // disable serialize globally for list responses
queryParser: { delimiter: ',' }, // passed to RequestQueryBuilder.setOptions
auth: { property: 'user' }, // default user property for @CrudAuth
});
@CrudAuth — 按用户范围限定
@Crud({ model: { type: Post } })
@CrudAuth({
property: 'user',
filter: (u: User) => ({ authorId: { $eq: u.id } }), // appended to every read
persist: (u: User) => ({ authorId: u.id }), // set on every write
// or: (u: User) => ({ isPublic: { $eq: true } }), // alternative to filter: rows match `or` OR the request search; filter skipped when set
})
@UseGuards(JwtAuthGuard)
@Controller('posts')
export class PostsController { constructor(public service: PostsService) {} }
运行时会根据实体列验证 persist 键;拼写错误 → 400 @CrudAuth persist: invalid key(s) "X" — not columns on the target entity。
Guard 顺序: @UseGuards() 必须加在控制器类上(在 CrudRequestInterceptor 读取 req.user 之前运行)。
ACL / RBAC 集成
生成的路由处理程序会自动应用 @Action(CrudActions.<X>) — 值为:Read-All、Read-One、Create-One、Create-Many、Update-One、Replace-One、Delete-One、Recover-One。使用 @Feature(name) 标记控制器,并通过 Guard 调用 getFeature(ctx.getClass()) + getAction(ctx.getHandler()) 获取两者,然后分发到你的 ACL 后端(nest-access-control、CASL 等)。完整的 ACLGuard 示例:Controllers wiki §Additional decorators。
@Override() — 自定义处理程序
@Override('getOneBase')
getUser(@ParsedRequest() req: CrudRequest, @Param('id') id: string) {
return this.service.getOne(req);
}
@Override('createOneBase')
createUser(@ParsedRequest() req: CrudRequest, @ParsedBody() dto: User) {
return this.service.createOne(req, dto);
}
@Override 接受:'getManyBase'、'getOneBase'、'createOneBase'、'createManyBase'、'updateOneBase'、'replaceOneBase'、'deleteOneBase'、'recoverOneBase'。
在写入操作的重写处理程序中使用 @ParsedBody(),而不是 @Body() — @Body() 会绕过 class-validator 的分组选择(CREATE 和 UPDATE 验证会变得相同)。
DTO — 两种模式
模式 A(默认):实体作为 DTO,配合 class-validator 分组:
const { CREATE, UPDATE } = CrudValidationGroups;
@IsOptional({ groups: [UPDATE] })
@IsNotEmpty({ groups: [CREATE] })
@IsString({ always: true })
name: string;
CREATE → POST + PUT;UPDATE → PATCH;{ always: true } → 两者都适用。
模式 B:专用 DTO,通过 @Crud({ dto: { create, update, replace } }) 配置。使用 dto 时,不会使用 CrudValidationGroups — 控制器会直接根据 DTO 类进行验证。可以自由组合实体输入(A)+ DTO 输出(@Crud({ serialize }))。
验证分组:工厂与手动编写的控制器
只有在验证器使用匹配的 groups 运行时,CrudValidationGroups.CREATE / UPDATE 才会生效。@Crud() 工厂会自动为每个生成的路由绑定 new ValidationPipe({ groups: [CREATE | UPDATE], ... })。未使用 @Crud() 构建的手动编写控制器会使用默认管道(groups: undefined);按组限定的装饰器会被静默跳过 — 必填字段验证器不会触发。
// ✗ Hand-rolled — group-scoped decorators silently skip
@Controller('special')
export class SpecialController {
@Post('/users')
createSpecial(@Body() user: User) { ... } // CREATE-group validators DO NOT run
}
// ✓ Hand-rolled — wire the group pipe explicitly
@Post('/users')
createSpecial(
@Body(new ValidationPipe({ groups: [CrudValidationGroups.CREATE], whitelist: true })) user: User,
) { ... }
这类路由很多?可以提取一个辅助函数。另一种方法是改用专用 DTO(模式 B),不使用分组装饰器 — 这样无论在哪里都会以相同方式验证。
严格字段允许列表(v2 重大变更)
?sort/?filter/?search/?fields 中的每个字段都必须是实体列或已加入允许列表的关联。否则返回 400;具体措辞因适配器和分支而异:Field "X" is not allowed、Invalid field: 'X'、Invalid sort field: 'X'、Unknown column: X。无法关闭此限制。常见问题:TypeORM @VirtualColumn / @Formula(不在 metadata.columns 中 — 在子类中重写 protected entityColumnsHash 以允许使用)、客户端别名、针对未联接关联的点分路径。
缓存
当接入 CacheStrategy 时,所有 4 个适配器都会支持 @Crud({ query: { cache: <ttl-ms> } })(v2.2 之前仅限 TypeORM)。TTL 单位为毫秒。
import { createClient } from 'redis';
import { CrudConfigService } from '@nestjs-crud/core';
import { TypeOrmCacheStrategy } from '@nestjs-crud/typeorm';
const redis = createClient({ url: 'redis://localhost:6379' });
CrudConfigService.load({
query: { cache: 5000, cacheStrategy: new TypeOrmCacheStrategy(redis) },
});
可通过构造函数按 CrudService 覆盖。可通过 @Crud({ query: { cache } }) 按路由覆盖。可通过 ?cache=0 按请求绕过缓存。游标路由永不缓存(§游标分页)。
策略:TypeOrm | MikroOrm | Drizzle | PrismaRedis | PrismaAccelerate。接受 redis(node-redis v5,或 v2.3 起支持的 v6)或 ioredis 客户端,并自动进行一次惰性连接——无需显式调用 connect()。自定义后端:实现来自 @nestjs-crud/core/cache 的 RedisLike(set / get / del / scanPrefix)。写入会按实体前缀自动失效缓存。
设置了 @Crud cache,但未配置策略(且没有 TypeORM DataSource.cache 回退)时 → 下次读取缓存会触发 CrudCacheNotConfiguredError。设置、安全性和调优请参阅:缓存 Wiki。
写入覆盖中的事务
updateOne / replaceOne / deleteOne 会在 READ COMMITTED 隔离级别下,将读取-修改-写入操作包装在事务中——从而避免丢失更新竞态。recoverOne 除外(没有事先读取)。如果你在自己的外层事务中包装 @Override(),适配器的内层事务会成为外层事务中的保存点;不会降低较高的外层隔离级别;回滚会逐层向上传播。
TypeORM 分离查询式关联加载
@Crud({ query: { relationLoadStrategy: 'query' } })——通过 setFindOptions 为每个关联执行单独的查询。注意事项:JoinOption.allow 会被忽略;关联会加载所有列。如果你使用 allow 隐藏敏感列,请勿启用此选项。只有当嵌套联接的完整点分路径是 query.join 键时才会加载(v2.3+)。Drizzle、MikroORM 和 Prisma 会忽略此选项。
| 策略 | 行为 | 适用场景 |
|---|---|---|
'join' | 通过 leftJoin/innerJoin 执行单条 SQL | 浅层联接;或依赖 JoinOption.allow 时 |
'query' | 通过 setFindOptions 为每个关联执行单独的查询 | 深层多关联读取,且 JOIN 笛卡尔积会显著增大载荷时 |
Swagger 自定义
@Crud({ swagger: {...} }) 用于控制操作文本、标签、示例、错误响应以及按版本分组。默认行为:祈使句摘要、多行描述、生成 400/401(如果启用了身份验证)/404 响应、自动设置 @ApiTags、根据 @ApiProperty 自动生成请求体示例。
**限制:**无法覆盖 operationId;默认生成 401 响应的依据是 @CrudAuth(使用 APP_GUARD 配置时,设置 swagger.errorResponses.unauthorized: true);synthExample 消费者函数优先于元数据;@Override() + 手动设置的 @ApiOperation() 优先于 swagger.operations[...]。完整参考:Swagger Wiki。
日志记录器
所有 4 个适配器默认使用 new Logger(<ServiceName>)。TypeORM/Drizzle/MikroORM 在 cacheStrategy? 之前,将 logger?: LoggerService 作为位置构造函数参数——分别为 (repo, logger?, cacheStrategy?)、(db, table, relationsConfig?, logger?, cacheStrategy?)、(emOrRepo, entityClass, logger?, cacheStrategy?)。Prisma 的日志记录器位于 serviceConfig 中,格式为 { error, warn?, debug? }。
**日志输出约定:**查询跟踪使用 debug;SQLi 拒绝和事务回滚使用 warn;未捕获的数据库错误使用 error。绝不要插入 err.message——数据库驱动会将 SQL 参数值泄露到消息中;应将 err.name 作为消息,并将 err.stack 作为第二个参数传入。
IntelliSense
@Crud() 会在运行时挂接处理程序;TS 无法识别它们。实现 CrudController<T> 可恢复 service 的类型。在 @Override() 内部,this.service.getMany(req) 拥有完整类型信息,符合惯用写法。要在一个重写方法中组合两个生成的处理程序:get base(): CrudController<User> { return this; }。
使用 serviceProperty(v2.2.5+): 实现 CrudController<T> 会失败——TS2559 has no properties in common。CrudController<T> 的属性全部可选(弱类型);只声明重命名字段的类与它没有任何共有属性。请改用 CrudControllerFor<T, P>:
@Crud({ model: { type: Contact }, serviceProperty: 'contactService' })
@Controller('contacts')
export class ContactsController implements CrudControllerFor<Contact, 'contactService'> {
constructor(public contactService: ContactsCrudService) {}
}
默认情况下,CrudControllerFor<T> ≡ CrudController<T>。另一种做法是完全移除 implements——路由不需要它。
最佳实践
- 使用
CondOperator枚举,不要使用原始字符串('$cont'拼写错误时不会报错) - 始终在
query配置中exclude敏感字段(否则?fields=password会返回该字段) - 始终为敏感关联中的联接字段设置允许列表
- 使用
search组合 AND/OR;filter仅支持 AND - 在服务端设置
maxLimit——绝不要信任客户端的limit
常见问题
| 症状 | 修复方法 |
|---|---|
| 关系未加载 | 添加到 query.join。否则,客户端的 join 请求会被静默忽略。 |
超出 maxLimit → 400 | 提高 maxLimit,或设置 alwaysPaginate: false。 |
| 更新时验证总是失败 | 字段需要 @IsOptional({ groups: [UPDATE] })。 |
Cannot find module 'typeorm' / Cannot find module 'pg' / Cannot find module 'mysql2' / repo.createQueryBuilder is not a function | 未安装适配器 ORM 或数据库驱动 peer 依赖。npm install typeorm pg(Postgres),或 npm install typeorm mysql2(MySQL)。四个适配器都将数据库驱动声明为可选 peer 依赖——选择后端使用的驱动即可。 |
| Swagger 元数据为空 | 未安装 @nestjs/swagger。库会跳过 Swagger 配置;安装后重启。 |
@CrudAuth 筛选器未生效 | @UseGuards() 必须放在控制器类上(在拦截器之前运行)。 |
返回扁平数组而不是 { data, count, total, page } | 在 query: 内设置 alwaysPaginate: true(不是顶层)。 |
400 Field "X" is not allowed / Invalid field: 'X' / Invalid sort field: 'X' / Unknown column: X | 添加到实体列或 query.join 允许列表中。 |
400 @CrudAuth persist: invalid key(s) "X" | @CrudAuth({ persist }) 中的键与实体列不匹配。 |
TS2559 has no properties in common with type 'CrudController<T>' | serviceProperty 重命名了字段;弱类型检查失败。使用 implements CrudControllerFor<Entity, 'fieldName'>(v2.2.5+),或删除 implements。 |
npm install 时出现 EBADENGINE | Node <22.12。升级,或固定到 ^1.0.2。 |
CrudCacheNotConfiguredError | 已设置 @Crud cache,但未接入 CacheStrategy(且没有 TypeORM DataSource.cache 回退方案)。 |
游标:Cursor pagination supports a single sort field → 400 | 不支持多字段排序——请使用一个字段。完全不排序时:传入 ?sort= 或设置 query.sort(v2.2.6+ 会采用默认值;更早版本需要显式传入 ?sort=)。 |
游标:Cursor pagination requires a limit → 400 | 游标模式需要 query.limit 或 maxLimit。 |
游标:Invalid cursor / Cursor token exceeds maximum length → 400 | 游标遭到篡改或格式错误,或长度超过 1024 个字符。请重新获取第一页。 |
游标:Cursor sort field mismatch → 400 | 游标是在另一个 ?sort= 字段下签发的。请发送相同的排序字段。 |
400 Invalid join: 'a.b' | 未 join 父级 a。请将 a 添加到 ?join=,或将其标记为 eager(v2.3+)。 |
启动时出现 @Crud: eager join 'a.b' on X needs 'a' to be eager too | 将 a 标记为 eager,或从 a.b 中移除 eager。 |
安装 @nestjs-crud/typeorm 时 npm 出现 ERESOLVE | TypeORM 1.x 的 peer 依赖要求 ioredis@^5;NestJS 12 会解析到 6。请运行 npm install ioredis@^5,或添加 "overrides": { "ioredis": "^5.0.4" }。 |
MikroORM:实体过时 / em.flush() 未持久化 | 在构造函数中缓存了 em 子类。请使用 getEm() thunk;不要将 em 存为字段。 |
Jest:Cannot use 'import.meta' outside a module | MikroORM 7 和 NestJS 12 是纯 ESM。使用 NODE_OPTIONS=--experimental-vm-modules 和 ts-jest ESM 预设运行 Jest。 |
Prisma:include 上出现 Unknown argument 'where' | Prisma 不允许在 to-one include 上使用 where。请在父级 where 中筛选。适配器会自动处理 SCondition 点分路径。 |
外层事务 + @Override() 写入:SERIALIZABLE 持久化,回滚级联 | 适配器的内部事务是你事务中的保存点。请决定是移除外层包装,还是接受保存点嵌套。 |
分拆查询:尽管设置了 allow,关系仍加载所有列 | setFindOptions 不提供别名级别的 select。对于敏感关系,请继续使用 'join'。 |