likes
comments
collection
share

NestJS最佳实践--#3 DTO请求数据校验

作者站长头像
站长
· 阅读数 48

代码地址: github.com/slashspaces…

学习目标

  1. 使用fastify替换express作NestJS底层驱动
  2. DTO请求数据校验
  3. 自定义ValidationPipe
  4. 通过注解的方式实现自定义校验规则

依赖安装

  • @nestjs/platform-fastify:fastify驱动
  • class-validator : 基于装饰器的类属性验证
  • class-transformer:基于装饰器的转换器,在对象与类之间进行序列化与反序列化
  • validator
pnpm add class-validator class-transformer validator @nestjs/platform-fastify

Fastify

Nest作为一个上层框架,可以通过适配器模式使得底层可以兼容任意 HTTP类型的 Node 框架,本身内置的框架有两种 Express与 Fastify

NestJS最佳实践--#3 DTO请求数据校验

上图是Fastify与其它主流HTTP框架的QPS((并发处理请求)对比,可以发现Fastify的QPS远超其它框架,所以在一些对性能要求较高的场景中,Fastify无疑是更好的选择。

介绍完Fastify的优势后,接下来我们开始在NestJS项目中使用Fastify

现阶段,我们只需更改main.ts文件

async function bootstrap() {
    const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter());
    await app.listen(3000);
}
bootstrap();

DTO请求数据校验

为了安全起见,服务端需要校验客户端传递过来的数据是否正确且安全,在NestJS中我们可以使用class-validator+ValidationPipe来进行DTO校验。

class-validator 为DTO添加校验规则

在上节课中我们创建了 CreatePostDtoUpdatePostDto ,但并没有为它们加上校验规则,现在我们分别为它们加上校验规则:

export class CreatePostDto {
    @MaxLength(255, {
        always: true,
        message: '文章标题长度最大为$constraint1',
    })
    @IsNotEmpty({ groups: ['create'], message: '文章标题必须填写' })
    @IsOptional({ groups: ['update'] })
    title!: string;

    @IsNotEmpty({ groups: ['create'], message: '文章内容必须填写' })
    @IsOptional({ groups: ['update'] })
    body!: string;

    @MaxLength(500, {
        always: true,
        message: '文章描述长度最大为$constraint1',
    })
    @IsOptional({ always: true })
    summary?: string;

    @IsDateString({ strict: true }, { always: true })
    @IsOptional({ always: true })
    @ValidateIf((value) => !isNil(value.publishedAt))
    @Transform(({ value }) => (value === 'null' ? null : value))
    publishedAt?: Date;

    @MaxLength(20, {
        each: true,
        always: true,
        message: '每个关键字长度最大为$constraint1',
    })
    @IsOptional({ always: true })
    keywords?: string[];

    @IsUUID(undefined, {
        each: true,
        always: true,
        message: '分类ID格式不正确',
    })
    @IsOptional({ always: true })
    categories?: string[];

    @Transform(({ value }) => Number(value))
    @Min(0, { always: true, message: '排序值必须大于0' })
    @IsNumber(undefined, { always: true })
    @IsOptional({ always: true })
    customOrder = 0;
}

export class UpdatePostDto extends PartialType(CreatePostDto) {
    @IsUUID(undefined, { groups: ['update'], message: '文章ID格式错误' })
    @IsDefined({ groups: ['update'], message: '文章ID必须指定' })
    id!: string;
}

对于上述代码中的大部分装饰器我相信大家都能做到见名知意,但有几处还是要解释下

  • @Transform : 在我们的ValidationPipe管道进行验证时,一旦有Transform装饰器,则会通过该装饰器自动先把该属性的值转译成需要的数据类型,然后再通过验证约束进行验证

  • @ValidateIf : 一旦@ValidateIf 内的表达式返回false, 该属性上的校验将被忽略

  • $constraint1 : 内置的校验信息

    @MaxLength(50, {
        // here, $constraint1 will be replaced with "50", and $value with actual supplied value
        message: 'Title is too long. Maximal length is $constraint1 characters, but actual is $value',
    })
    title: string;
    
  • groups: 验证组,在针对同一个属性,在不同场景下使用不同的校验规则,比如

    // group为create时,代表新增接口,此时 title 必填
    @IsNotEmpty({ groups: ['create'], message: '文章标题必须填写' })
    // group为update时,代表更新接口,此时 title 非必填
    @IsOptional({ groups: ['update'] })
    title!: string;
    

class-validator仓库文档里对上述代码中用到的所有装饰器都有详细的解释,如果有不理解的地方,可以查阅官方文档。

开启ValidationPipe 管道

有了DTO之后不代表就可以自动对请求数据进行验证了,我们还需要通过配置如下代码:

@Controller('post')
export class PostController {
    constructor(private readonly postService: PostService) {}

    @Post()
    create(
        @Body(
            new ValidationPipe({
                transform: true,
                forbidUnknownValues: true,
                validationError: { target: false },
                groups: ['create'],
            }),
        )
        createPostDto: CreatePostDto,
    ) {
        return this.postService.create(createPostDto);
    }

    @Get('page')
    paginate(
        @Query(
            new ValidationPipe({
                transform: true,
                forbidUnknownValues: true,
                validationError: { target: false },
            }),
        )
        paginationDto: PaginationDto,
    ) {
        return this.postService.paginate(paginationDto);
    }

    @Patch(':id')
    update(
        @Param('id') id: string,
        @Body(
            new ValidationPipe({
                transform: true,
                forbidUnknownValues: true,
                validationError: { target: false },
                groups: ['update'],
            }),
        )
        updatePostDto: UpdatePostDto,
    ) {
        return this.postService.update(id, updatePostDto);
    }

    // ...
}

在实例化ValidationPipe这个验证管道时,传入的参数的作用如下

  • transform 如果设置成true则DTO在之后会被序列化

    @Post()
        create(
            @Body(
                new ValidationPipe({
                    transform: true,
                    forbidUnknownValues: true,
                    validationError: { target: false },
                    groups: ['create'],
                }),
            )
            createPostDto: CreatePostDto,
        ) {
            console.log(createPostDto);
            // return this.postService.create(createPostDto);
        }
    
    /*
    --> transform: true 时输出的是 CreatePostDto类的实例
    CreatePostDto {
      title: 'nestjs最佳实践',
      body: 'nestjs最佳实践',
      summary: 'nodejs',
      publishedAt: '2023-03-15',
      keywords: [ 'nodejs', 'typescript' ],
      categories: undefined,
      customOrder: 1
    }
    
    --> transform: false 时输出的只是一个普通对象
    {
      title: 'nestjs最佳实践',
      body: 'nestjs最佳实践',
      summary: 'nodejs',
      publishedAt: '2023-03-15',
      keywords: [ 'nodejs', 'typescript' ],
      categories: undefined,
      customOrder: 1
    }
    */
    
  • forbidUnknownValues如果请求数据中有多余的数据(比如没有在验证管道中定义的属性)则会报出错误

  • validationError.target确定目标是否要在 ValidationError 中暴露出来

  • groups 验证对象时使用的分组

这样就可以实现对请求数据的校验了。

但是,每次需要对Query或者Body的请求数据进行格式验证时,都要实例化一下ValidationPipe

new ValidationPipe({
    transform: true,
    forbidUnknownValues: true,
    validationError: { target: false },
    groups: ['create'],
}),

这样会导致代码变得非常臃肿。

有没有什么更好的办法呢?

全局验证

其实,除了上面那种将ValidationPipe 添加到每个Controller的路由中的方式之外,我们还可以将ValidationPipe 添加到整个应用程序中去, 这样的话就不需要在每个Query或者Body上实例化ValidationPipe 了。

@Module({
    imports: [
        ConfigModule.forRoot({
            isGlobal: true, // 全局模块
            ignoreEnvFile: true, // 忽略.env相关配置文件
            load: [getConfig], // 读取自定义文件
        }),
        DataBaseModule.forRoot(),
        BusinessModule,
    ],
    providers: [
        {
            // APP_PIPE为nestjs框架的固定常量,用于设置全局管道
            provide: APP_PIPE,
            useValue: new ValidationPipe({
                transform: true,
                forbidUnknownValues: true,
                validationError: { target: false },
            }),
        },
    ],
})
export class AppModule {}

但是这样做会有一些缺陷

  1. 会导致无法自动区分Body还是Query,也就是在发送body数据的时候可能会去验证query
  2. 在区分不同的验证组的时候我们需要传groups选项,或者有时候也可能传一些额外的其它选项,但是这样做之后我们就没办法再传其它验证选项了

为了解决以上两个问题,我们需要编写一个自定义ValidationPipe,然后在继承ValidationPipe的基础上重载transform方法,并进行一些修改。

自定义ValidationPipe

/**
 * DTOValidation装饰器选项
 */
export const DTO_VALIDATION_OPTIONS = 'dto_validation_options';

export type DtoValidationOption = ValidatorOptions & {
    transformOptions?: ClassTransformOptions;
    type?: Paramtype;
};

/**
 * 用于配置通过全局验证管道验证数据的DTO类装饰器
 * @param options
 */
export const DtoValidation = (options?: DtoValidationOption) =>
    SetMetadata(DTO_VALIDATION_OPTIONS, options ?? {});

/**
 * 自定义全局管道
 *
 * @DtoValidation({group: ['create']})
 * class CreatePostDto {}
 *
 *  @Post()
 *  create(@Body() createPostDto: CreatePostDto) {
 *     return this.postService.create(createPostDto);
 *  }
 */
@Injectable()
export class AppPipe extends ValidationPipe {
    /**
     *
     * @param value 前端传递过来的请求数据, 如 createPostDto
     * @param metadata createPostDto参数的元信息,如 是使用@Body() 还是 @Query()
     */
    async transform(value: any, metadata: ArgumentMetadata) {
        // meta: Dto类型,如CreatePostDto
        // type: 'body' | 'query' | 'param' | 'custom';
        const { metatype: DtoClass, type } = metadata;

        /**
         * 获取dto类的装饰器元数据中的自定义验证选项
         * @DtoValidation({group: ['create']})
         * class CreatePostDto {}
         */
        const options = Reflect.getMetadata(
            DTO_VALIDATION_OPTIONS,
            DtoClass,
        ) as DtoValidationOption;

        if (!options) {
            // 没有@DtoValidation 注解
            return super.transform(value, metadata);
        }

        // 把自定义的class-transform和type选项解构出来
        const { transformOptions = {}, type: optionsType, ...customValidatorOptions } = options;
        // 根据DTO类上设置的type来设置当前的DTO请求类型,默认为'body'
        const requestType: Paramtype = optionsType ?? 'body';
        // 如果被验证的DTO设置的请求类型与被验证的数据的请求类型不是同一种类型则跳过此管道
        if (requestType !== type) return value;

        /**
         *  把当前已设置的选项解构到备份对象
         *  {
         *      provide: APP_PIPE,
         *      useValue: new AppPipe({
         *          transform: true,
         *          forbidUnknownValues: true,
         *          validationError: { target: false },
         *      }),
         *  }
         */
        const originValidatorOptions = { ...this.validatorOptions };
        // 把当前已设置的class-transform选项解构到备份对象
        const originTransformOptions = { ...this.transformOptions };

        /**
         * 合并
         */
        // 合并当前transform选项和自定义选项
        this.transformOptions = merge(this.transformOptions, transformOptions ?? {});
        // 合并当前验证选项和自定义选项
        this.validatorOptions = merge(this.validatorOptions, customValidatorOptions ?? {});

        /**
         * 设置待验证的值(这部分在文件上传章节再讲,此处不需要理解,此处直接使用value验证并不影响)
         * 判断请求数据是否为一个对象,如果不是则其值本身就是就是待验证的值(比如只传入一个字符串),
         * 如果请求数据是一个对象(包含数组),则遍历其中的值,
         * 如果是一个文件上传类型的值或者一个对象,则去除这个值中的fields属性
         */
        const toValidate = isObject(value)
            ? Object.fromEntries(
                  Object.entries(value as Record<string, any>).map(([key, v]) => {
                      if (!isObject(v) || !('mimetype' in v)) return [key, v];
                      return [key, omit(v, ['fields'])];
                  }),
              )
            : value;

        // 序列化并验证dto对象
        const result = await super.transform(toValidate, metadata);

        /**
         * 还原
         */
        // 重置验证选项
        this.validatorOptions = originValidatorOptions;
        // 重置transform选项
        this.transformOptions = originTransformOptions;
        return result;
    }
}

@Module({
    imports: [
        ConfigModule.forRoot({
            isGlobal: true, // 全局模块
            ignoreEnvFile: true, // 忽略.env相关配置文件
            load: [getConfig], // 读取自定义文件
        }),
        DataBaseModule.forRoot(),
        BusinessModule,
    ],
    providers: [
        {
            provide: APP_PIPE,
            useValue: new AppPipe({
                transform: true,
                forbidUnknownValues: true,
                validationError: { target: false },
            }),
        },
    ],
})
export class AppModule {}

上面代码可能有点绕,但其实逻辑很简单,用下面一张图就能解释清楚:

NestJS最佳实践--#3 DTO请求数据校验

核心逻辑就是,每次在校验DTO时,都会合并AppPipe的的属性和@DtoValidation装饰器的属性,然后再交还给ValidationPipe处理

自定义校验装饰器

除了class-validator内置校验规则之外,我们还可以创建一些其它的常用校验规则。至于创建规则可以参考官方文档关于 Custom validation decorators 的部分。

  • 同步校验
    • IsMatch
    • IsPassword
    • IsPhone
  • 异步校验
    • IsExist
    • IsUnique
    • IsTreeUnique

其中异步校验需要使用数据库查询,所以我们需要使用到typeorm 的 DataSource,而DTO的验证规则是无法直接注入只有NestJS才可以拿到的typeorm DataSource的,这时我们就需要把NestJS的容器加入到class-validator的useContainer中,以便class-validator的验证约束可以注入NestJS的提供者

// main.ts
async function bootstrap() {
    const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter());
    useContainer(app.select(AppModule), {
        fallbackOnErrors: true,
    });
    await app.listen(3000);
}
bootstrap();

IsMatch

判断请求数据中的两个值是否相等。

/**
 * 判断两个字段的值是否相等的验证规则
 */
@ValidatorConstraint({ name: 'isMatch' })
class MatchConstraint implements ValidatorConstraintInterface {
    /**
     * 定义验证逻辑
     * @param value 被装饰的属性的值
     * @param args {@link ValidationArguments}
     */
    validate(value: any, args: ValidationArguments) {
        const [relatedProperty] = args.constraints;
        // 需要对比的值
        const relatedValue = (args.object as any)[relatedProperty];
        return value === relatedValue;
    }

    /**
     * 验证失败是默认返回的错误消息
     */
    defaultMessage(args: ValidationArguments) {
        const [relatedProperty] = args.constraints;
        return `${relatedProperty} and ${args.property} don't match`;
    }
}
/**
 * 判断DTO中两个属性的值是否相等的验证规则
 * @param relatedProperty 用于对比的属性名称
 * @param validationOptions class-validator库的选项
 * @example {@IsMatch('password', {message: '两次输入密码不匹配'})}
 */
export function IsMatch(relatedProperty: string, validationOptions?: ValidationOptions) {
    return (object: Record<string, any>, propertyName: string) => {
        registerDecorator({
            target: object.constructor,
            propertyName,
            options: validationOptions,
            constraints: [relatedProperty],
            validator: MatchConstraint,
        });
    };
}

使用方式如下:

@Length(8, 50, {
    message: '密码长度不得少于$constraint1',
})
readonly password!: string;

@IsMatch('password', { message: '两次输入密码不同' })
@IsNotEmpty({ message: '请再次输入密码以确认' })
readonly plainPassword!: string;

isPassword

密码格式校验

/**
 * 密码验证规则
 */
@ValidatorConstraint({ name: 'isPassword', async: false })
export class IsPasswordConstraint implements ValidatorConstraintInterface {
    validate(value: string, args: ValidationArguments) {
        const validateModel: ModelType = args.constraints[0] ?? 1;
        switch (validateModel) {
            // 必须由大写或小写字母组成(默认模式)
            case 1:
                return /\d/.test(value) && /[a-zA-Z]/.test(value);
            // 必须由小写字母组成
            case 2:
                return /\d/.test(value) && /[a-z]/.test(value);
            // 必须由大写字母组成
            case 3:
                return /\d/.test(value) && /[A-Z]/.test(value);
            // 必须包含数字,小写字母,大写字母
            case 4:
                return /\d/.test(value) && /[a-z]/.test(value) && /[A-Z]/.test(value);
            // 必须包含数字,小写字母,大写字母,特殊符号
            case 5:
                return (
                    /\d/.test(value) &&
                    /[a-z]/.test(value) &&
                    /[A-Z]/.test(value) &&
                    /[!@#$%^&]/.test(value)
                );
            default:
                return /\d/.test(value) && /[a-zA-Z]/.test(value);
        }
    }

    defaultMessage(_args: ValidationArguments) {
        return "($value) 's format error!";
    }
}
/** 密码复杂度验证:
 ** 模式1: 必须由大写或小写字母组成(默认模式)
 ** 模式2: 必须由小写字母组成
 ** 模式3: 必须由大写字母组成
 ** 模式4: 必须包含数字,小写字母,大写字母
 ** 模式5: 必须包含数字,小写字母,大写字母,特殊符号
 * @param model 验证模式
 * @param validationOptions
 * @example {@IsPassword(5, { message: '密码必须由小写字母,大写字母,数字以及特殊字符组成' })}
 */
export function IsPassword(model?: ModelType, validationOptions?: ValidationOptions) {
    return (object: Record<string, any>, propertyName: string) => {
        registerDecorator({
            target: object.constructor,
            propertyName,
            options: validationOptions,
            constraints: [model],
            validator: IsPasswordConstraint,
        });
    };
}

使用方式如下:

@IsPassword(5, {
    message: '密码必须由小写字母,大写字母,数字以及特殊字符组成',
})
@Length(8, 50, {
    message: '密码长度不得少于$constraint1',
})
readonly password!: string;

isPhone

手机格式校验

/**
 * 手机号验证规则,必须是"区域号.手机号"的形式
 */
export function isMatchPhone(
    value: any,
    locale?: MobilePhoneLocale,
    options?: IsMobilePhoneOptions,
): boolean {
    if (!value) return false;
    const phoneArr: string[] = value.split('.');
    if (phoneArr.length !== 2) return false;
    return isMobilePhone(phoneArr.join(''), locale, options);
}
/**
 * 手机号验证规则,必须是"区域号.手机号"的形式
 * @param locales 区域选项
 * @param options isMobilePhone约束选项
 * @param validationOptions class-validator库的选项
 */
export function IsMatchPhone(
    locales?: MobilePhoneLocale | MobilePhoneLocale[],
    options?: IsMobilePhoneOptions,
    validationOptions?: ValidationOptions,
) {
    return (object: Record<string, any>, propertyName: string) => {
        registerDecorator({
            target: object.constructor,
            propertyName,
            options: validationOptions,
            constraints: [locales || 'any', options],
            validator: {
                validate: (value: any, args: ValidationArguments): boolean =>
                    isMatchPhone(value, args.constraints[0], args.constraints[1]),
                defaultMessage: (_args: ValidationArguments) =>
                    '$property must be a phone number,eg: +86.12345678901',
            },
        });
    };
}

使用方式如下:

@IsMatchPhone(
    undefined,
    { strictMode: true },
    {
        message: '手机格式错误,示例: +86.15005255555',
    },
)
phone: string;

isExist

数据是否存在于数据库

/**
 * 自定义约束:数据存在验证
 */
@ValidatorConstraint({ name: 'dataExist', async: true })
@Injectable()
export class DataExistConstraint implements ValidatorConstraintInterface {
    constructor(private dataSource: DataSource) {}

    async validate(value: string, args: ValidationArguments) {
        let repo: Repository<any>;
        if (!value) return true;
        // 默认对比字段是id
        let map = 'id';
        // 通过传入的entity获取其repository
        if ('entity' in args.constraints[0]) {
            map = args.constraints[0].map ?? 'id';
            repo = this.dataSource.getRepository(args.constraints[0].entity);
        } else {
            repo = this.dataSource.getRepository(args.constraints[0]);
        }
        // 通过查询记录是否存在进行验证
        const item = await repo.findOne({ where: { [map]: value } });
        return !!item;
    }

    defaultMessage(args: ValidationArguments) {
        if (!args.constraints[0]) {
            return 'Model not been specified!';
        }
        return `All instance of ${args.constraints[0].name} must been exists in databse!`;
    }
}

type Condition = {
    entity: ObjectType<any>;
    // 用于查询的比对字段,默认id
    map?: string;
};

/**
 * 数据存在验证
 * @param entity
 * @param validationOptions
 * @example {@IsDataExist(CategoryEntity, { always: true, message: '父分类不存在' })}
 */
function IsExist(
    entity: ObjectType<any>,
    validationOptions?: ValidationOptions,
): (object: Record<string, any>, propertyName: string) => void;

/**
 *  数据存在验证
 * @param condition
 * @param validationOptions
 * @example {@IsDataExist({entity: CategoryEntity, map: 'id'}, { always: true, message: '父分类不存在' })}
 */
function IsExist(
    condition: Condition,
    validationOptions?: ValidationOptions,
): (object: Record<string, any>, propertyName: string) => void;

/**
 * 数据存在验证
 * @param condition
 * @param validationOptions
 * @example 判断 User id 是否存在
 */
function IsExist(
    condition: ObjectType<any> | Condition,
    validationOptions?: ValidationOptions,
): (object: Record<string, any>, propertyName: string) => void {
    return (object: Record<string, any>, propertyName: string) => {
        registerDecorator({
            target: object.constructor,
            propertyName,
            options: validationOptions,
            constraints: [condition],
            validator: DataExistConstraint,
        });
    };
}

使用方式如下:

export class UpdatePostDto extends PartialType(CreatePostDto) {
    @IsExist(Post, { message: '文章ID不存在' })
    @IsUUID(undefined, { groups: ['update'], message: '文章ID格式错误' })
    @IsDefined({ groups: ['update'], message: '文章ID必须指定' })
    id!: string;
}

IsUnique

数据唯一性验证


export type IsUniqueCondition = {
    entity: ObjectType<any>;
    // 如果没有指定字段则使用当前验证的属性作为查询依据
    property?: string;
    // 默认忽略字段为id
    ignore?: string;
};

/**
 * 自定义约束:数据唯一性验证
 */
@ValidatorConstraint({ name: 'dataUnique', async: true })
@Injectable()
export class UniqueConstraint implements ValidatorConstraintInterface {
    constructor(private dataSource: DataSource) {}

    async validate(value: any, args: ValidationArguments) {
        // 获取要验证的模型和字段
        const config: Omit<IsUniqueCondition, 'entity'> = {
            property: args.property,
        };
        const condition = ('entity' in args.constraints[0]
            ? merge(config, args.constraints[0])
            : {
                  ...config,
                  entity: args.constraints[0],
              }) as unknown as Required<IsUniqueCondition>;
        if (!condition.entity) return false;
        try {
            const where = {
                [condition.property]: value,
            };
            // 在传入的dto数据中获取需要忽略的字段的值
            const ignoreValue = (args.object as any)[condition.ignore];
            // 如果忽略字段不存在则验证失败
            if (ignoreValue) {
                where[condition.ignore] = Not(ignoreValue);
            }
            // 查询是否存在数据,如果已经存在则验证失败
            const repo = this.dataSource.getRepository(condition.entity);
            const result = await repo.findOne({ where, withDeleted: true });
            return isNil(result);
        } catch (err) {
            // 如果数据库操作异常则验证失败
            return false;
        }
    }

    defaultMessage(args: ValidationArguments) {
        const { entity, property } = args.constraints[0];
        const queryProperty = property ?? args.property;
        if (!entity) {
            return 'Model not been specified!';
        }
        return `${queryProperty} of ${entity.name} must been unique!`;
    }
}

export function IsUnique(
    params: ObjectType<any> | IsUniqueCondition,
    validationOptions?: ValidationOptions,
) {
    return (object: Record<string, any>, propertyName: string) => {
        registerDecorator({
            target: object.constructor,
            propertyName,
            options: validationOptions,
            constraints: [params],
            validator: UniqueConstraint,
        });
    };
}

使用方式如下:

@DtoValidation({ groups: ['create'] })
export class CreatePostDto {
    @IsUnique({ entity: Post }, { always: true })
    @MaxLength(255, {
        always: true,
        message: '文章标题长度最大为$constraint1',
    })
    @IsNotEmpty({ groups: ['create'], message: '文章标题必须填写' })
    @IsOptional({ groups: ['update'] })
    title!: string;

    // ...
}

IsTreeUnique

树形结构同级别唯一性

export type IsTreeUniqueCondition = {
    entity: ObjectType<any>;
    parentKey?: string;
    property?: string;
};

/**
 * 自定义约束:树形结构同级别唯一性
 */
@Injectable()
@ValidatorConstraint({ name: 'treeDataUnique', async: true })
export class TreeUniqueConstraint implements ValidatorConstraintInterface {
    constructor(private dataSource: DataSource) {}

    async validate(value: any, args: ValidationArguments) {
        const config: Omit<IsTreeUniqueCondition, 'entity'> = {
            parentKey: 'parent',
            property: args.property,
        };
        const condition = ('entity' in args.constraints[0]
            ? merge(config, args.constraints[0])
            : {
                  ...config,
                  entity: args.constraints[0],
              }) as unknown as Required<IsTreeUniqueCondition>;
        // 需要查询的属性名,默认为当前验证的属性
        const argsObj = args.object as any;
        if (!condition.entity) return false;
        try {
            // 获取repository
            const repo = this.dataSource.getTreeRepository(condition.entity);
            if (isNil(value)) return true;
            const collection = await repo.find({
                where: {
                    parent: !argsObj[condition.parentKey]
                        ? null
                        : { id: argsObj[condition.parentKey] },
                },
                withDeleted: true,
            });
            // 对比每个子分类的queryProperty值是否与当前验证的dto属性相同,如果有相同的则验证失败
            return collection.every((item) => item[condition.property] !== value);
        } catch (err) {
            return false;
        }
    }

    defaultMessage(args: ValidationArguments) {
        const { entity, property } = args.constraints[0];
        const queryProperty = property ?? args.property;
        if (!entity) {
            return 'Model not been specified!';
        }
        return `${queryProperty} of ${entity.name} must been unique with siblings element!`;
    }
}

export function IsTreeUnique(
    params: ObjectType<any> | IsTreeUniqueCondition,
    validationOptions?: ValidationOptions,
) {
    return (object: Record<string, any>, propertyName: string) => {
        registerDecorator({
            target: object.constructor,
            propertyName,
            options: validationOptions,
            constraints: [params],
            validator: TreeUniqueConstraint,
        });
    };
}

DataExistConstraint UniqueConstraint TreeUniqueConstraint 注册为Provider

因为 DataExistConstraint UniqueConstraint TreeUniqueConstraint 这三个约束需要被注入DataSource , 因此需要添加 @Injectable 装饰器。所以这三个约束需要作为provider被暴露出去。

@Module({})
export class DataBaseModule {
    static forRoot(): DynamicModule {
        return {
            global: true,
            module: DataBaseModule,
            imports: [
                TypeOrmModule.forRootAsync({
                    imports: [ConfigModule],
                    inject: [ConfigService],
                    useFactory(configService: ConfigService): TypeOrmModuleOptions {
                        const mysqlConnectionOptions = configService.get('DB');
                        return { ...mysqlConnectionOptions };
                    },
                }),
            ],
            providers: [DataExistConstraint, UniqueConstraint, TreeUniqueConstraint],
        };
    }
}
转载自:https://juejin.cn/post/7211894902178840635
评论
请登录