Compare commits

...

11 Commits

Author SHA1 Message Date
50dbb06b29 add test case 2025-05-31 09:30:32 +08:00
3fe801d448 add test case 2025-05-31 09:15:24 +08:00
88447f0db6 add test case 2025-05-30 22:11:40 +08:00
e26cb841fc add test case 2025-05-29 23:15:43 +08:00
c283df576b add test case 2025-05-29 14:59:00 +08:00
a75a27e627 add constraint 2025-05-29 11:07:25 +08:00
05160509ec add constraint 2025-05-28 22:58:00 +08:00
43d34250e2 add constraint 2025-05-27 23:29:14 +08:00
e0d2f7652c add constraint 2025-05-27 23:09:49 +08:00
a08964bd4a add constraint 2025-05-27 22:01:28 +08:00
e71f08a3de add test case 2025-05-27 21:46:00 +08:00
16 changed files with 1476 additions and 43 deletions

View File

@ -2,6 +2,8 @@ import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import { useContainer } from 'class-validator';
import { AppModule } from './app.module';
async function bootstrap() {
@ -10,6 +12,7 @@ async function bootstrap() {
logger: ['error', 'warn'],
});
app.setGlobalPrefix('api');
useContainer(app.select(AppModule), { fallbackOnErrors: true });
await app.listen(process.env.PORT ?? 3000, () => {
console.log('api: http://localhost:3000');
});

View File

@ -2,8 +2,8 @@ import { PartialType } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
IsDefined,
IsInt,
IsNotEmpty,
IsNumber,
IsOptional,
IsUUID,
MaxLength,
@ -13,25 +13,41 @@ import {
import { toNumber } from 'lodash';
import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator';
import { IsDataExist } from '@/modules/database/constraints/data.exist.constraint';
import { IsTreeUnique } from '@/modules/database/constraints/tree.unique.constraint';
import { IsTreeUniqueExist } from '@/modules/database/constraints/tree.unique.exist.constraint';
import { PaginateOptions } from '@/modules/database/types';
import { CategoryEntity } from '../entities';
@DtoValidation({ type: 'query' })
export class QueryCategoryDto implements PaginateOptions {
@Transform(({ value }) => toNumber(value))
@Min(1, { message: 'The current page must be greater than 1.' })
@IsNumber()
@Min(1, { always: true, message: 'The current page must be greater than 1.' })
@IsInt()
@IsOptional()
page = 1;
@Transform(({ value }) => toNumber(value))
@Min(1, { message: 'The number of data displayed per page must be greater than 1.' })
@IsNumber()
@Min(1, {
always: true,
message: 'The number of data displayed per page must be greater than 1.',
})
@IsInt()
@IsOptional()
limit = 10;
}
@DtoValidation({ groups: ['create'] })
export class CreateCategoryDto {
@IsTreeUnique(CategoryEntity, {
groups: ['create'],
message: 'The Category names are duplicated',
})
@IsTreeUniqueExist(CategoryEntity, {
groups: ['update'],
message: 'The Category names are duplicated',
})
@MaxLength(25, {
always: true,
message: 'The length of the category name shall not exceed $constraint1',
@ -40,6 +56,7 @@ export class CreateCategoryDto {
@IsOptional({ groups: ['update'] })
name: string;
@IsDataExist(CategoryEntity, { always: true, message: 'The parent category does not exist' })
@IsUUID(undefined, {
always: true,
message: 'The format of the parent category ID is incorrect.',
@ -51,13 +68,17 @@ export class CreateCategoryDto {
@Transform(({ value }) => toNumber(value))
@Min(0, { always: true, message: 'The sorted value must be greater than 0.' })
@IsNumber(undefined, { always: true })
@IsInt({ always: true })
@IsOptional({ always: true })
customOrder?: number = 0;
}
@DtoValidation({ groups: ['update'] })
export class UpdateCategoryDto extends PartialType(CreateCategoryDto) {
@IsDataExist(CategoryEntity, {
groups: ['update'],
message: 'category id not exist when update',
})
@IsUUID(undefined, { message: 'The ID format is incorrect', groups: ['update'] })
@IsDefined({ groups: ['update'], message: 'The ID must be specified' })
id: string;

View File

@ -2,8 +2,8 @@ import { PickType } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
IsDefined,
IsInt,
IsNotEmpty,
IsNumber,
IsOptional,
IsUUID,
MaxLength,
@ -13,22 +13,29 @@ import {
import { toNumber } from 'lodash';
import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator';
import { IsDataExist } from '@/modules/database/constraints/data.exist.constraint';
import { PaginateOptions } from '@/modules/database/types';
import { CommentEntity, PostEntity } from '../entities';
@DtoValidation({ type: 'query' })
export class QueryCommentDto implements PaginateOptions {
@Transform(({ value }) => toNumber(value))
@Min(1, { message: 'The current page must be greater than 1.' })
@IsNumber()
@Min(1, { always: true, message: 'The current page must be greater than 1.' })
@IsInt()
@IsOptional()
page = 1;
@Transform(({ value }) => toNumber(value))
@Min(1, { message: 'The number of data displayed per page must be greater than 1.' })
@IsNumber()
@Min(1, {
always: true,
message: 'The number of data displayed per page must be greater than 1.',
})
@IsInt()
@IsOptional()
limit = 10;
@IsDataExist(PostEntity, { message: 'The post does not exist' })
@IsUUID(undefined, { message: 'The ID format is incorrect' })
@IsOptional()
post?: string;
@ -43,10 +50,12 @@ export class CreateCommentDto {
@IsNotEmpty({ message: '' })
body: string;
@IsDataExist(PostEntity, { message: 'The post does not exist' })
@IsUUID(undefined, { message: 'The ID format is incorrect' })
@IsDefined({ message: 'The ID must be specified' })
post: string;
@IsDataExist(CommentEntity, { message: 'The parent comment does not exist' })
@IsUUID(undefined, { message: 'The ID format is incorrect', always: true })
@ValidateIf((value) => value.parent !== null && value.parent)
@IsOptional({ always: true })

View File

@ -5,8 +5,8 @@ import {
IsBoolean,
IsDefined,
IsEnum,
IsInt,
IsNotEmpty,
IsNumber,
IsOptional,
IsUUID,
MaxLength,
@ -19,8 +19,11 @@ import { isNil, toNumber } from 'lodash';
import { PostOrder } from '@/modules/content/constants';
import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator';
import { toBoolean } from '@/modules/core/helpers';
import { IsDataExist } from '@/modules/database/constraints/data.exist.constraint';
import { PaginateOptions } from '@/modules/database/types';
import { CategoryEntity, PostEntity, TagEntity } from '../entities';
@DtoValidation({ type: 'query' })
export class QueryPostDto implements PaginateOptions {
@Transform(({ value }) => toBoolean(value))
@ -35,17 +38,21 @@ export class QueryPostDto implements PaginateOptions {
orderBy: PostOrder;
@Transform(({ value }) => toNumber(value))
@Min(1, { message: 'The current page must be greater than 1.' })
@IsNumber()
@Min(1, { always: true, message: 'The current page must be greater than 1.' })
@IsInt()
@IsOptional()
page = 1;
@Transform(({ value }) => toNumber(value))
@Min(1, { message: 'The number of data displayed per page must be greater than 1.' })
@IsNumber()
@Min(1, {
always: true,
message: 'The number of data displayed per page must be greater than 1.',
})
@IsInt()
@IsOptional()
limit = 10;
@IsDataExist(CategoryEntity, { always: true, message: 'The category does not exist' })
@IsUUID(undefined, { message: 'The ID format is incorrect' })
@IsOptional()
category?: string;
@ -91,11 +98,12 @@ export class CreatePostDto {
keywords?: string[];
@Transform(({ value }) => toNumber(value))
@Min(0, { message: 'The sorted value must be greater than 0.' })
@IsNumber(undefined, { always: true })
@Min(0, { message: 'The sorted value must be greater than 0.', always: true })
@IsInt({ always: true })
@IsOptional({ always: true })
customOrder?: number;
@IsDataExist(CategoryEntity, { always: true, message: 'The category does not exist' })
@IsUUID(undefined, {
always: true,
message: 'The ID format is incorrect',
@ -103,6 +111,11 @@ export class CreatePostDto {
@IsOptional({ always: true })
category?: string;
@IsDataExist(TagEntity, {
always: true,
each: true,
message: 'The tag does not exist',
})
@IsUUID(undefined, {
always: true,
each: true,
@ -119,5 +132,6 @@ export class UpdatePostDto extends PartialType(CreatePostDto) {
message: 'The format of the article ID is incorrect.',
})
@IsDefined({ groups: ['update'], message: 'The article ID must be specified' })
@IsDataExist(PostEntity, { groups: ['update'], message: 'post id not exist when update' })
id: string;
}

View File

@ -1,36 +1,38 @@
import { PartialType } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
IsDefined,
IsNotEmpty,
IsNumber,
IsOptional,
IsUUID,
MaxLength,
Min,
} from 'class-validator';
import { IsDefined, IsInt, IsNotEmpty, IsOptional, IsUUID, MaxLength, Min } from 'class-validator';
import { toNumber } from 'lodash';
import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator';
import { IsDataExist } from '@/modules/database/constraints';
import { IsUnique } from '@/modules/database/constraints/unique.constraint';
import { IsUniqueExist } from '@/modules/database/constraints/unique.exist.constraint';
import { PaginateOptions } from '@/modules/database/types';
import { TagEntity } from '../entities';
@DtoValidation({ type: 'query' })
export class QueryTagDto implements PaginateOptions {
@Transform(({ value }) => toNumber(value))
@Min(1, { message: 'The current page must be greater than 1.' })
@IsNumber()
@Min(1, { always: true, message: 'The current page must be greater than 1.' })
@IsInt()
@IsOptional()
page = 1;
@Transform(({ value }) => toNumber(value))
@Min(1, { message: 'The number of data displayed per page must be greater than 1.' })
@IsNumber()
@Min(1, {
always: true,
message: 'The number of data displayed per page must be greater than 1.',
})
@IsInt()
@IsOptional()
limit = 10;
}
@DtoValidation({ groups: ['create'] })
export class CreateTagDto {
@IsUnique(TagEntity, { groups: ['create'], message: 'The label names are repeated' })
@IsUniqueExist(TagEntity, { groups: ['update'], message: 'The label names are repeated' })
@MaxLength(255, {
always: true,
message: 'The maximum length of the label name is $constraint1',
@ -49,6 +51,7 @@ export class CreateTagDto {
@DtoValidation({ groups: ['update'] })
export class UpdateTagDto extends PartialType(CreateTagDto) {
@IsDataExist(TagEntity, { groups: ['update'], message: 'tag id not exist when update' })
@IsUUID(undefined, { message: 'The ID format is incorrect', groups: ['update'] })
@IsDefined({ groups: ['update'], message: 'The ID must be specified' })
id: string;

View File

@ -22,7 +22,7 @@ export class CategoryEntity extends BaseEntity {
id: string;
@Expose()
@Column({ comment: '分类名称', unique: true })
@Column({ comment: '分类名称' })
name: string;
@Expose({ groups: ['category-tree', 'category-list', 'category-detail'] })
@ -40,7 +40,7 @@ export class CategoryEntity extends BaseEntity {
parent: Relation<CategoryEntity> | null;
@Type(() => CategoryEntity)
@Expose({ groups: ['category-tree'] })
@Expose({ groups: ['category-tree', 'category-detail'] })
@TreeChildren({ cascade: true })
children: Relation<CategoryEntity>[];
}

View File

@ -26,7 +26,7 @@ export class CategoryService {
}
async detail(id: string) {
return this.repository.findOneOrFail({ where: { id }, relations: ['parent'] });
return this.repository.findOneOrFail({ where: { id }, relations: ['parent', 'children'] });
}
async create(data: CreateCategoryDto) {

View File

@ -1,6 +1,6 @@
import { ArgumentMetadata, BadRequestException, Paramtype, ValidationPipe } from '@nestjs/common';
import { isObject, omit } from 'lodash';
import { isNil, isObject, isString, omit } from 'lodash';
import { DTO_VALIDATION_OPTIONS } from '../contants';
import { deepMerge } from '../helpers';
@ -31,12 +31,13 @@ export class AppPipe extends ValidationPipe {
if (isObject(val) && 'mimetype' in val) {
return [key, omit(val, ['fields'])];
}
if (key === 'name' && isString(val)) {
return [key, isNil(val) ? val : val.trim()];
}
return [key, val];
}),
)
: value;
console.log(value);
console.log(toValidate);
try {
let result = await super.transform(toValidate, metadata);
if (typeof result.transform === 'function') {

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,87 @@
import { Injectable } from '@nestjs/common';
import {
ValidatorConstraint,
ValidatorConstraintInterface,
ValidationArguments,
registerDecorator,
ValidationOptions,
} from 'class-validator';
import { merge, isNil } from 'lodash';
import { DataSource, ObjectType } from 'typeorm';
type Condition = {
entity: ObjectType<any>;
parentKey?: string;
property?: string;
};
@ValidatorConstraint({ name: 'treeDataUnique', async: true })
@Injectable()
export class TreeUniqueConstraint 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>;
if (!condition.entity) {
return false;
}
if (isNil(value)) {
return true;
}
const argsObj = args.object as any;
try {
// 查询是否存在数据,如果已经存在则验证失败
const repo = this.dataSource.getTreeRepository(condition.entity);
const collections = await repo.find({
where: {
parent: !argsObj[condition.parentKey]
? null
: { id: argsObj[condition.parentKey] },
},
});
return collections.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 (!(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!`;
}
}
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: TreeUniqueConstraint,
});
};
}

View File

@ -0,0 +1,99 @@
import { Injectable } from '@nestjs/common';
import {
registerDecorator,
ValidationArguments,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
import { merge } from 'lodash';
import { DataSource, ObjectType } from 'typeorm';
type Condition = {
entity: ObjectType<any>;
ignore?: string;
ignoreKey?: string;
property?: string;
};
@ValidatorConstraint({ name: 'treeDataUniqueExist', async: true })
@Injectable()
export class TreeUniqueExistContraint 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;
}
if (!condition.ignoreKey) {
condition.ignoreKey = condition.ignore;
}
const argsObj = args.object as any;
// 在传入的dto数据中获取需要忽略的字段的值
const ignoreValue = argsObj[condition.ignore];
const findValue = argsObj[condition.ignoreKey];
if (!ignoreValue || !findValue) {
return false;
}
// 通过entity获取repository
const repo = this.dataSource.getRepository(condition.entity);
// 查询忽略字段之外的数据是否对queryProperty的值唯一
const item = await repo.findOne({
where: {
[condition.ignoreKey]: findValue,
},
relations: ['parent'],
});
if (!item) {
return false;
}
const rows = await repo.find({
where: { parent: item.parent ? { id: item.parent.id } : null },
withDeleted: true,
});
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 (!(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!`;
}
}
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: TreeUniqueExistContraint,
});
};
}

View File

@ -0,0 +1,74 @@
import { Injectable } from '@nestjs/common';
import {
registerDecorator,
ValidationArguments,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
import { isNil, merge } from 'lodash';
import { DataSource, Not, ObjectType } from 'typeorm';
type Condition = {
entity: ObjectType<any>;
ignore?: string;
ignoreKey?: string;
property?: string;
};
@Injectable()
@ValidatorConstraint({ name: 'dataUniqueExist', async: true })
export class UniqueExistConstraint implements ValidatorConstraintInterface {
constructor(protected dataSource: DataSource) {}
async validate(value: any, args?: ValidationArguments): Promise<boolean> {
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;
}
const ignoreValue = (args.object as any)[
isNil(condition.ignoreKey) ? condition.ignore : condition.ignoreKey
];
if (ignoreValue === undefined) {
return false;
}
const repo = this.dataSource.getRepository(condition.entity);
return isNil(
await repo.findOne({
where: { [condition.property]: value, [condition.ignore]: Not(ignoreValue) },
withDeleted: true,
}),
);
}
defaultMessage?(args?: ValidationArguments): string {
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!`;
}
}
export function IsUniqueExist(params: ObjectType<any> | Condition, options?: ValidationOptions) {
return (object: RecordAny, propertyName: string) => {
registerDecorator({
target: object.constructor,
propertyName,
options,
constraints: [params],
validator: UniqueExistConstraint,
});
};
}

View File

@ -5,6 +5,14 @@ import { DataSource, ObjectType } from 'typeorm';
import { CUSTOM_REPOSITORY_METADATA } from '@/modules/database/constants';
import {
DataExistConstraint,
TreeUniqueConstraint,
TreeUniqueExistContraint,
UniqueConstraint,
UniqueExistConstraint,
} from './constraints';
@Module({})
export class DatabaseModule {
static forRoot(configRegister: () => TypeOrmModuleOptions): DynamicModule {
@ -12,6 +20,13 @@ export class DatabaseModule {
global: true,
module: DatabaseModule,
imports: [TypeOrmModule.forRoot(configRegister())],
providers: [
DataExistConstraint,
UniqueConstraint,
UniqueExistConstraint,
TreeUniqueConstraint,
TreeUniqueExistContraint,
],
};
}
static forRepository<T extends Type<any>>(

File diff suppressed because it is too large Load Diff