feat:自定义数据验证约束

1.学习数据自定义约束
2.给自定义数据约束实现依赖注入
This commit is contained in:
3R-喜东东 2023-12-12 16:40:19 +08:00
parent d30273d180
commit c151657116
18 changed files with 695 additions and 1 deletions

Binary file not shown.

View File

@ -34,7 +34,8 @@
"reflect-metadata": "^0.1.14",
"rxjs": "^7.8.1",
"sanitize-html": "^2.11.0",
"typeorm": "^0.3.17"
"typeorm": "^0.3.17",
"validator": "^13.11.0"
},
"devDependencies": {
"@nestjs/cli": "^10.2.1",
@ -47,6 +48,7 @@
"@types/node": "^20.10.4",
"@types/sanitize-html": "^2.9.5",
"@types/supertest": "^2.0.16",
"@types/validator": "^13.11.7",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"eslint": "^8.55.0",

View File

@ -50,6 +50,9 @@ dependencies:
typeorm:
specifier: ^0.3.17
version: 0.3.17(better-sqlite3@9.2.2)(ts-node@10.9.2)
validator:
specifier: ^13.11.0
version: 13.11.0
devDependencies:
'@nestjs/cli':
@ -82,6 +85,9 @@ devDependencies:
'@types/supertest':
specifier: ^2.0.16
version: 2.0.16
'@types/validator':
specifier: ^13.11.7
version: 13.11.7
'@typescript-eslint/eslint-plugin':
specifier: ^6.14.0
version: 6.14.0(@typescript-eslint/parser@6.14.0)(eslint@8.55.0)(typescript@5.3.3)

View File

@ -1,6 +1,8 @@
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import { useContainer } from 'class-validator';
import { AppModule } from '@/app.module';
const bootstrap = async () => {
@ -11,6 +13,11 @@ const bootstrap = async () => {
app.setGlobalPrefix('api');
// 使validator的约束可以使用nestjs的容器
useContainer(app.select(AppModule), {
fallbackOnErrors: true,
});
await app.listen(2333, () => {
console.log('api: http://localhost:2333/api');
});

View File

@ -12,7 +12,10 @@ import {
} from 'class-validator';
import { toNumber } from 'lodash';
import { CategoryEntity } from '@/modules/content/entities';
import { DtoValidation } from '@/modules/core/decorators';
import { IsDataExist, IsTreeUnique, IsTreeUniqueExist } from '@/modules/database/constraints';
import { PaginateOptions } from '@/modules/database/types';
@DtoValidation({ type: 'query' })
@ -35,6 +38,14 @@ export class QueryCategoryDto implements PaginateOptions {
*/
@DtoValidation({ groups: ['create'] })
export class CreateCategoryDto {
@IsTreeUnique(CategoryEntity, {
groups: ['create'],
message: '分类名称重复',
})
@IsTreeUniqueExist(CategoryEntity, {
groups: ['update'],
message: '名称重复',
})
@MaxLength(25, {
always: true,
message: '分类名称长度最大为$constraint1',
@ -43,6 +54,7 @@ export class CreateCategoryDto {
@IsOptional({ groups: ['update'] })
name: string;
@IsDataExist(CategoryEntity, { always: true, message: '父分类不存在' })
@IsUUID(undefined, { always: true, message: '父分类ID格式不正确' })
@ValidateIf((value) => value.parent !== null && value.parent)
@IsOptional({ always: true })

View File

@ -13,12 +13,17 @@ import {
import { toNumber } from 'lodash';
import { CommentEntity, PostEntity } from '@/modules/content/entities';
import { IsDataExist } from '@/modules/database/constraints';
import { PaginateOptions } from '@/modules/database/types';
/**
*
*/
export class QueryCommentDto implements PaginateOptions {
@IsDataExist(PostEntity, {
message: '文章不存在',
})
@IsUUID(undefined, { message: 'ID格式错误' })
@IsOptional()
post?: string;
@ -53,6 +58,9 @@ export class CreateCommentDto {
@IsDefined({ message: 'ID必须指定' })
post: string;
@IsDataExist(CommentEntity, {
message: '父评论不存在',
})
@IsUUID(undefined, { message: 'ID格式错误' })
@ValidateIf((value) => value.parent !== null && value.parent)
@IsOptional({ always: true })

View File

@ -18,8 +18,10 @@ import {
import { isNil, toNumber } from 'lodash';
import { PostOrderType } from '@/modules/content/constants';
import { CategoryEntity, TagEntity } from '@/modules/content/entities';
import { DtoValidation } from '@/modules/core/decorators';
import { toBoolean } from '@/modules/core/helpers';
import { IsDataExist } from '@/modules/database/constraints';
import { PaginateOptions } from '@/modules/database/types';
/**
@ -49,10 +51,18 @@ export class QueryPostDto implements PaginateOptions {
@IsOptional()
limit: 10;
@IsDataExist(CategoryEntity, {
always: true,
message: '分类不存在',
})
@IsUUID(undefined, { message: '分类ID必须是UUID' })
@IsOptional()
category?: string;
@IsDataExist(TagEntity, {
always: true,
message: '标签不存在',
})
@IsUUID(undefined, { message: '标签ID必须是UUID' })
@IsOptional()
tag?: string;
@ -105,10 +115,18 @@ export class CreatePostDto {
@IsOptional({ always: true })
customOrder = 0;
@IsDataExist(CategoryEntity, {
message: '分类不存在',
})
@IsUUID(undefined, { message: '分类ID必须是UUID', each: true, always: true })
@IsOptional({ groups: ['update'] })
category: string;
@IsDataExist(TagEntity, {
each: true,
always: true,
message: '标签不存在',
})
@IsUUID(undefined, {
each: true,
always: true,

View File

@ -0,0 +1,3 @@
export * from './match.constraint';
export * from './match.phone.constraint';
export * from './password.constraint';

View File

@ -0,0 +1,41 @@
import {
ValidationArguments,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
registerDecorator,
} from 'class-validator';
/**
*
*/
@ValidatorConstraint({ name: 'isMatch' })
export class MatchConstraint implements ValidatorConstraintInterface {
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库的选项
*/
export function IsMatch(relatedProperty: string, validationOptions?: ValidationOptions) {
return (object: RecordAny, propertyName: string) => {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: [relatedProperty],
validator: MatchConstraint,
});
};
}

View File

@ -0,0 +1,48 @@
import {
ValidationArguments,
ValidationOptions,
isMobilePhone,
registerDecorator,
} from 'class-validator';
import { IsMobilePhoneOptions, MobilePhoneLocale } from 'validator/lib/isMobilePhone';
/**
* ,"区域号.手机号"
*/
export function isMatchPhone(
value: any,
locale?: MobilePhoneLocale,
options?: IsMobilePhoneOptions,
): boolean {
if (!value) return false;
const phoneArr: string[] = value.split('.');
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: RecordAny, 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',
},
});
};
}

View File

@ -0,0 +1,64 @@
import {
ValidationArguments,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
registerDecorator,
} from 'class-validator';
type ModelType = 1 | 2 | 3 | 4 | 5;
@ValidatorConstraint({ name: 'isPassword', async: false })
export class IsPasswordConstraint implements ValidatorConstraintInterface {
validate(value: any, 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) &&
/[!@#$%^&]/.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
*/
export function IsPassword(model?: ModelType, validationOptions?: ValidationOptions) {
return (object: RecordAny, propertyName: string) => {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: [model],
validator: IsPasswordConstraint,
});
};
}

View File

@ -0,0 +1,94 @@
import { Injectable } from '@nestjs/common';
import {
ValidationArguments,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
registerDecorator,
} from 'class-validator';
import { DataSource, ObjectType, Repository } from 'typeorm';
type Condition = {
entity: ObjectType<any>;
/**
* ,id
*/
map?: string;
};
/**
*
*/
@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 database!`;
}
}
/**
*
* @param entity
* @param validationOptions
*/
function IsDataExist(
entity: ObjectType<any>,
validationOptions?: ValidationOptions,
): (object: RecordAny, propertyName: string) => void;
/**
*
* @param condition
* @param validationOptions
*/
function IsDataExist(
condition: Condition,
validationOptions?: ValidationOptions,
): (object: RecordAny, propertyName: string) => void;
/**
*
* @param condition
* @param validationOptions
*/
function IsDataExist(
condition: ObjectType<any> | Condition,
validationOptions: ValidationOptions,
): (object: RecordAny, propertyName: string) => void {
return (object: RecordAny, propertyName: string) => {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: [condition],
validator: DataExistConstraint,
});
};
}
export { IsDataExist };

View File

@ -0,0 +1,5 @@
export * from './data.exist.constraint';
export * from './tree.unique.constraint';
export * from './tree.unique.exist.constraint';
export * from './unique.constraint';
export * from './unique.exist.constraint';

View File

@ -0,0 +1,90 @@
import { Injectable } from '@nestjs/common';
import {
ValidationArguments,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
registerDecorator,
} from 'class-validator';
import { isNil, merge } from 'lodash';
import { DataSource, ObjectType } from 'typeorm';
type Condition = {
entity: ObjectType<any>;
parentKey?: string;
property?: string;
};
/**
*
*/
@Injectable()
@ValidatorConstraint({ name: 'treeDataUnique', async: true })
export class UniqueTreeConstraint implements ValidatorConstraintInterface {
constructor(private dataSource: DataSource) {}
async validate(value: any, args: ValidationArguments) {
const config: Omit<Condition, '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<Condition>;
// 需要查询的属性名,默认为当前验证的属性
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!`;
}
}
/**
*
* @param params
* @param validationOptions
*/
export function IsTreeUnique(
params: ObjectType<any> | Condition,
validationOptions?: ValidationOptions,
) {
return (object: Record<string, any>, propertyName: string) => {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: [params],
validator: UniqueTreeConstraint,
});
};
}

View File

@ -0,0 +1,106 @@
import { Injectable } from '@nestjs/common';
import {
ValidationArguments,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
registerDecorator,
} from 'class-validator';
import { merge } from 'lodash';
import { DataSource, ObjectType } from 'typeorm';
type Condition = {
entity: ObjectType<any>;
/**
* id
*/
ignore?: string;
/**
* ,ignore
*/
findKey?: string;
/**
* ,
*/
property?: string;
};
/**
* ,ignore指定忽略的字段
*/
@Injectable()
@ValidatorConstraint({ name: 'treeDataUniqueExist', async: true })
export class UniqueTreeExistConstraint implements ValidatorConstraintInterface {
constructor(private dataSource: DataSource) {}
async validate(value: any, args: ValidationArguments) {
const config: Omit<Condition, 'entity'> = {
ignore: 'id',
property: args.property,
};
const condition = ('entity' in args.constraints[0]
? merge(config, args.constraints[0])
: {
...config,
entity: args.constraints[0],
}) as unknown as Required<Condition>;
if (!condition.findKey) {
condition.findKey = condition.ignore;
}
if (!condition.entity) return false;
// 在传入的dto数据中获取需要忽略的字段的值
const ignoreValue = (args.object as any)[condition.ignore];
// 查询条件字段的值
const keyValue = (args.object as any)[condition.findKey];
if (!ignoreValue || !keyValue) return false;
const repo = this.dataSource.getTreeRepository(condition.entity);
// 根据查询条件查询出当前验证的数据
const item = await repo.findOne({
where: { [condition.findKey]: keyValue },
relations: ['parent'],
});
// 没有此数据则验证失败
if (!item) return false;
// 如果验证数据没有parent则把所有顶级分类作为验证数据否则就把同一个父分类下的子分类作为验证数据
const rows: any[] = await repo.find({
where: {
parent: !item.parent ? null : { id: item.parent.id },
},
withDeleted: true,
});
// 在忽略本身数据后如果同级别其它数据与验证的queryProperty的值相同则验证失败
return !rows.find(
(row) => row[condition.property] === value && row[condition.ignore] !== ignoreValue,
);
}
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!`;
}
}
/**
*
* @param params
* @param validationOptions
*/
export function IsTreeUniqueExist(
params: ObjectType<any> | Condition,
validationOptions?: ValidationOptions,
) {
return (object: Record<string, any>, propertyName: string) => {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: [params],
validator: UniqueTreeExistConstraint,
});
};
}

View File

@ -0,0 +1,83 @@
import { Injectable } from '@nestjs/common';
import {
ValidationArguments,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
registerDecorator,
} from 'class-validator';
import { isNil, merge } from 'lodash';
import { DataSource, ObjectType } from 'typeorm';
type Condition = {
entity: ObjectType<any>;
/**
* 使
*/
property?: 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<Condition, '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<Condition>;
if (!condition.entity) return false;
try {
// 查询是否存在数据,如果已经存在则验证失败
const repo = this.dataSource.getRepository(condition.entity);
return isNil(
await repo.findOne({ where: { [condition.property]: value }, withDeleted: true }),
);
} catch (err) {
// 如果数据库操作异常则验证失败
return false;
}
}
defaultMessage(args: ValidationArguments) {
const { entity, property } = args.constraints[0];
const queryProperty = property ?? args.property;
if (!(args.object as any).getManager) {
return 'getManager function not been found!';
}
if (!entity) {
return 'Model not been specified!';
}
return `${queryProperty} of ${entity.name} must been unique!`;
}
}
/**
*
* @param params Entity类或验证条件对象
* @param validationOptions
*/
export function IsUnique(
params: ObjectType<any> | Condition,
validationOptions?: ValidationOptions,
) {
return (object: Record<string, any>, propertyName: string) => {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: [params],
validator: UniqueConstraint,
});
};
}

View File

@ -0,0 +1,93 @@
import { Injectable } from '@nestjs/common';
import {
ValidationArguments,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
registerDecorator,
} from 'class-validator';
import { isNil, merge } from 'lodash';
import { DataSource, Not, ObjectType } from 'typeorm';
type Condition = {
entity: ObjectType<any>;
/**
* id
*/
ignore?: string;
/**
* 使
*/
property?: string;
};
/**
* ,ignore忽略忽略的字段
*/
@ValidatorConstraint({ name: 'dataUniqueExist', async: true })
@Injectable()
export class UniqueExistContraint implements ValidatorConstraintInterface {
constructor(private dataSource: DataSource) {}
async validate(value: any, args: ValidationArguments) {
const config: Omit<Condition, 'entity'> = {
ignore: 'id',
property: args.property,
};
const condition = ('entity' in args.constraints[0]
? merge(config, args.constraints[0])
: {
...config,
entity: args.constraints[0],
}) as unknown as Required<Condition>;
if (!condition.entity) return false;
// 在传入的dto数据中获取需要忽略的字段的值
const ignoreValue = (args.object as any)[condition.ignore];
// 如果忽略字段不存在则验证失败
if (ignoreValue === undefined) return false;
// 通过entity获取repository
const repo = this.dataSource.getRepository(condition.entity);
// 查询忽略字段之外的数据是否对queryProperty的值唯一
return isNil(
await repo.findOne({
where: {
[condition.property]: value,
[condition.ignore]: Not(ignoreValue),
},
withDeleted: true,
}),
);
}
defaultMessage(args: ValidationArguments) {
const { entity, property } = args.constraints[0];
const queryProperty = property ?? args.property;
if (!(args.object as any).getManager) {
return 'getManager function not been found!';
}
if (!entity) {
return 'Model not been specified!';
}
return `${queryProperty} of ${entity.name} must been unique!`;
}
}
/**
*
* @param params Entity类或验证条件对象
* @param validationOptions
*/
export function IsUniqueExist(
params: ObjectType<any> | Condition,
validationOptions?: ValidationOptions,
) {
return (object: Record<string, any>, propertyName: string) => {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: [params],
validator: UniqueExistContraint,
});
};
}

View File

@ -4,6 +4,13 @@ import { TypeOrmModule, TypeOrmModuleOptions, getDataSourceToken } from '@nestjs
import { DataSource, ObjectType } from 'typeorm';
import { CUSTOM_REPOSITORY_METADATA } from '@/modules/database/constants';
import {
DataExistConstraint,
UniqueConstraint,
UniqueExistContraint,
UniqueTreeConstraint,
} from '@/modules/database/constraints';
import { UniqueTreeExistConstraint } from '@/modules/database/constraints/tree.unique.exist.constraint';
@Module({})
export class DatabaseModule {
@ -12,6 +19,13 @@ export class DatabaseModule {
global: true,
module: DatabaseModule,
imports: [TypeOrmModule.forRoot(configRegister())],
providers: [
DataExistConstraint,
UniqueConstraint,
UniqueExistContraint,
UniqueTreeConstraint,
UniqueTreeExistConstraint,
],
};
}