mcpField
N
nestjs-crud

nestjs-crud

github.com/kodjunkie开发1277 次安装5 周前更新
代码仓库

关于 nestjs-crud

@nestjs-crud 是用于 NestJS 的 CRUD 端点生成库,面向需要为实体快速构建 REST API 的开发者。它支持 TypeORM、Drizzle、MikroORM 和 Prisma,并提供查询配置、分页及用户范围控制。

主要功能

  1. 01通过 @Crud() 生成 REST 端点
  2. 02支持四种 ORM 适配器
  3. 03配置过滤、排序和关联查询
  4. 04支持偏移与游标分页
  5. 05提供用户数据范围和缓存配置

适用场景

  1. 01为 NestJS 实体创建 CRUD API
  2. 02配置列表接口的过滤和分页
  3. 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/usersgetManyBase
GET/users/:idgetOneBase
POST/userscreateOneBase
POST/users/bulkcreateManyBase
PATCH/users/:idupdateOneBase
PUT/users/:idreplaceOneBase
DELETE/users/:iddeleteOneBase
PATCH/users/:id/recoverrecoverOneBase(需要 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 中;服务会在其基础上通过 WHERE AND 追加游标条件)。游标分页是 @CrudAuth 筛选结果集的子集 — 与 offset 模式具有相同的隔离保障。
  • 排序:使用 ?sort=,否则使用路由 query.sort 默认值(v2.2.6+ 起支持默认值);单个字段 + 自动添加的主键尾项。多字段排序或未指定排序 → 400。需要 limit 或 maxLimit(缺失时 → 400)。游标被篡改或排序不匹配 → 400。
  • 游标模式会绕过缓存封装(每个游标键的基数没有上限)。对于高频端点,请搭配 @nestjs/throttler 使用。
  • 所有 4 种适配器都会通过各自的 QueryComposer.applyCursor 实现支持。Prisma 内置的 cursor: 参数会被有意绕过(仅支持单列唯一键)。

游标分页 wiki。

查询参数(前端 → 后端)

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 时出现 EBADENGINENode <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 出现 ERESOLVETypeORM 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 moduleMikroORM 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'。

评论

支持 Markdown