Last active
October 2, 2024 13:56
-
-
Save mnixry/700ea483b9c632c1a025056ddde82f69 to your computer and use it in GitHub Desktop.
Nest.js CRUD service base class
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* eslint-disable @typescript-eslint/no-unused-vars */ | |
import { Type as ClassType } from '@nestjs/common'; | |
import { ApiProperty } from '@nestjs/swagger'; | |
import { METADATA_FACTORY_NAME } from '@nestjs/swagger/dist/plugin/plugin-constants'; | |
import { BUILT_IN_TYPES } from '@nestjs/swagger/dist/services/constants'; | |
import { Transform, Type } from 'class-transformer'; | |
import { IsEnum, IsOptional, IsPositive, Max } from 'class-validator'; | |
import { Request } from 'express'; | |
import { | |
IPaginationLinks, | |
IPaginationMeta, | |
IPaginationOptions as IPaginationOptionsBase, | |
Pagination, | |
} from 'nestjs-typeorm-paginate'; | |
import { getMetadataArgsStorage } from 'typeorm'; | |
import { EntityFieldsNames } from 'typeorm/common/EntityFieldsNames'; | |
export type TPaginate<T> = Pagination<T, PaginationMeta>; | |
export { EntityFieldsNames }; | |
export enum PaginationOrder { | |
ASC = 'ASC', | |
DESC = 'DESC', | |
} | |
export interface IUserRequest extends Request { | |
user: Express.User; | |
} | |
export interface ICreateMany<T = unknown> { | |
bulk: T[]; | |
} | |
export interface IPaginationOptions<Entity = unknown> | |
extends IPaginationOptionsBase<PaginationMeta> { | |
join?: EntityFieldsNames<Entity>[]; | |
sort_key?: EntityFieldsNames<Entity>; | |
sort_order?: PaginationOrder; | |
} | |
export class PaginationMeta implements IPaginationMeta { | |
@ApiProperty({ type: Number }) | |
itemCount: number; | |
@ApiProperty({ type: Number }) | |
totalItems: number; | |
@ApiProperty({ type: Number }) | |
itemsPerPage: number; | |
@ApiProperty({ type: Number }) | |
totalPages: number; | |
@ApiProperty({ type: Number }) | |
currentPage: number; | |
} | |
export class PaginationLinks implements IPaginationLinks { | |
@ApiProperty({ type: String, format: 'url' }) | |
first: string; | |
@ApiProperty({ type: String, format: 'url' }) | |
previous: string; | |
@ApiProperty({ type: String, format: 'url' }) | |
next: string; | |
@ApiProperty({ type: String, format: 'url' }) | |
last: string; | |
} | |
export function CreateMany<Entity>( | |
model: ClassType<Entity>, | |
): ClassType<ICreateMany<Entity>> { | |
class CreateMany implements ICreateMany<Entity> { | |
@ApiProperty({ type: model }) | |
bulk: Entity[]; | |
} | |
return CreateMany; | |
} | |
export function PaginatedResult<Entity>( | |
model: ClassType<Entity>, | |
): ClassType<TPaginate<Entity>> { | |
class PaginatedResult extends Pagination<Entity, PaginationMeta> { | |
@ApiProperty({ type: model }) | |
items: Entity[]; | |
@ApiProperty({ type: PaginationMeta }) | |
meta: PaginationMeta; | |
@ApiProperty({ type: PaginationLinks }) | |
links: PaginationLinks; | |
} | |
return PaginatedResult; | |
} | |
export function PaginationOptions<Entity>( | |
model: ClassType<Entity>, | |
): ClassType<IPaginationOptions<Entity>> { | |
const baseTypes = new Set([...BUILT_IN_TYPES, Date]); | |
const entityTypes = new Set( | |
getMetadataArgsStorage().tables.map((t) => t.target), | |
); | |
type MetadataModel = Record<string, unknown> & { | |
[METADATA_FACTORY_NAME]: () => Record< | |
string, | |
{ | |
required: boolean; | |
type?: () => ObjectConstructor | [ObjectConstructor]; | |
} | |
>; | |
}; | |
const modelProperties = Object.fromEntries( | |
Object.entries( | |
(model as unknown as MetadataModel)[METADATA_FACTORY_NAME](), | |
).map(([key, value]) => [key, value.type ? value.type() : undefined]), | |
); | |
const baseProperties = Object.entries(modelProperties) | |
.filter(([key, value]) => | |
value ? !Array.isArray(value) && baseTypes.has(value) : false, | |
) | |
.map(([key, value]) => key); | |
const entityProperties = Object.entries(modelProperties) | |
.filter(([key, value]) => | |
value | |
? Array.isArray(value) | |
? entityTypes.has(...value) | |
: entityTypes.has(value) | |
: false, | |
) | |
.map(([key, value]) => key); | |
class PaginationOptions implements IPaginationOptions<Entity> { | |
// eslint-disable-next-line @typescript-eslint/no-inferrable-types | |
@ApiProperty({ type: Number, default: 30, maximum: 50, minimum: 0 }) | |
@IsPositive() | |
@Max(50) | |
@Type(() => Number) | |
limit: number = 30; | |
// eslint-disable-next-line @typescript-eslint/no-inferrable-types | |
@ApiProperty({ type: Number, default: 1, minimum: 1 }) | |
@IsPositive() | |
@Type(() => Number) | |
page: number = 1; | |
@ApiProperty({ | |
type: 'enum', | |
enum: entityProperties, | |
required: false, | |
isArray: true, | |
}) | |
@IsOptional() | |
@IsEnum(entityProperties, { each: true }) | |
@Transform(({ value }) => | |
Array.from(typeof value === 'string' ? [value] : value), | |
) | |
join?: EntityFieldsNames<Entity>[]; | |
@ApiProperty({ type: 'enum', enum: baseProperties, required: false }) | |
@IsOptional() | |
@IsEnum(baseProperties) | |
@Type(() => String) | |
sort_key?: EntityFieldsNames<Entity>; | |
@ApiProperty({ type: 'enum', enum: PaginationOrder, required: false }) | |
@IsOptional() | |
@IsEnum(PaginationOrder) | |
@Type(() => String) | |
sort_order?: PaginationOrder; | |
} | |
return PaginationOptions; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* eslint-disable @typescript-eslint/no-unsafe-return */ | |
/* eslint-disable @typescript-eslint/no-explicit-any */ | |
import { | |
BadRequestException, | |
ForbiddenException, | |
Injectable, | |
InternalServerErrorException, | |
NotFoundException, | |
Type as ClassType, | |
} from '@nestjs/common'; | |
import { plainToClass } from 'class-transformer'; | |
import { isArray } from 'class-validator'; | |
import { InjectRolesBuilder, RolesBuilder } from 'nest-access-control'; | |
import { IPaginationMeta, paginate } from 'nestjs-typeorm-paginate'; | |
import { DeepPartial, FindOptionsUtils, Repository } from 'typeorm'; | |
import { | |
EntityFieldsNames, | |
ICreateMany, | |
IPaginationOptions, | |
IUserRequest, | |
PaginationMeta, | |
PaginationOrder, | |
TPaginate, | |
} from './crud-base.models'; | |
import { storage } from './request-local.middleware'; | |
@Injectable() | |
export abstract class CrudBaseService< | |
Entity, | |
PrimaryType = unknown, | |
CreateDto extends DeepPartial<Entity> = Entity, | |
UpdateDto extends DeepPartial<Entity> = Entity, | |
> { | |
@InjectRolesBuilder() | |
protected rolesBuilder: RolesBuilder; | |
constructor( | |
protected repo: Repository<Entity>, | |
protected primary: EntityFieldsNames<Entity>, | |
) {} | |
protected get entityType(): ClassType<Entity> { | |
return this.repo.target as ClassType<Entity>; | |
} | |
protected request() { | |
const request = storage.getStore()?.request; | |
if (!request?.user) { | |
throw new InternalServerErrorException( | |
'request object is not available now', | |
); | |
} | |
return request as IUserRequest; | |
} | |
protected async assert(result: boolean | Promise<boolean>): Promise<void> { | |
const value = await result; | |
if (!value) | |
throw new ForbiddenException( | |
`Permission denied to operate entity '${this.entityType.name}'`, | |
); | |
} | |
abstract canCreate(data: { | |
dto: CreateDto; | |
user: Express.User; | |
}): Promise<boolean> | boolean; | |
abstract canRead(data: { | |
primary?: PrimaryType; | |
entity?: Entity; | |
user: Express.User; | |
}): Promise<boolean> | boolean; | |
abstract canUpdate(data: { | |
dto: UpdateDto; | |
entity: Entity; | |
user: Express.User; | |
}): Promise<boolean> | boolean; | |
abstract canDelete(data: { | |
primary: PrimaryType; | |
entity: Entity; | |
user: Express.User; | |
}): Promise<boolean> | boolean; | |
public async getMany( | |
options: IPaginationOptions<Entity>, | |
): Promise<TPaginate<Entity>> { | |
const { user, path } = this.request(); | |
await this.assert(this.canRead({ user: user })); | |
const query = FindOptionsUtils.applyOptionsToQueryBuilder( | |
this.repo.createQueryBuilder(), | |
{ | |
relations: options.join as string[] | undefined, | |
order: | |
options.sort_key && options.sort_order | |
? ({ | |
[options.sort_key]: options.sort_order, | |
} as { [P in EntityFieldsNames<Entity>]: PaginationOrder }) | |
: undefined, | |
}, | |
); | |
return await paginate<Entity, PaginationMeta>(query, { | |
...options, | |
route: path, | |
metaTransformer: (meta) => | |
plainToClass<PaginationMeta, IPaginationMeta>(PaginationMeta, meta), | |
}); | |
} | |
public async getOne(primary: PrimaryType): Promise<Entity> { | |
const entity = await this.repo.findOne({ [this.primary]: primary }); | |
await this.assert( | |
this.canRead({ primary, entity, user: this.request().user }), | |
); | |
if (!entity) | |
throw new NotFoundException( | |
`condition ${this.primary}=${primary} not found`, | |
); | |
return entity; | |
} | |
public async createOne(dto: CreateDto): Promise<Entity> { | |
await this.assert(this.canCreate({ dto, user: this.request().user })); | |
const entity = this.repo.merge(new this.entityType(), dto); | |
return await this.repo.save<any>(entity); | |
} | |
public async createMany( | |
dto: ICreateMany<CreateDto>, | |
chunk = 50, | |
): Promise<Entity[]> { | |
if (!isArray(dto?.bulk) || dto.bulk.length <= 0) { | |
throw new BadRequestException(`Empty bulk data`); | |
} | |
const entities = await Promise.all( | |
dto.bulk.map(async (d) => { | |
await this.assert( | |
this.canCreate({ dto: d, user: this.request().user }), | |
); | |
return this.repo.merge(new this.entityType(), d); | |
}), | |
); | |
return await this.repo.save<any>(entities, { chunk }); | |
} | |
public async updateOne( | |
primary: PrimaryType, | |
dto: UpdateDto, | |
): Promise<Entity> { | |
const found = await this.getOne(primary); | |
await this.assert( | |
this.canUpdate({ dto, entity: found, user: this.request().user }), | |
); | |
const entity = this.repo.merge(found, dto); | |
return await this.repo.save<any>(entity); | |
} | |
public async deleteOne( | |
primary: PrimaryType, | |
softDelete = false, | |
): Promise<void> { | |
const entity = await this.getOne(primary); | |
await this.assert( | |
this.canDelete({ primary, entity, user: this.request().user }), | |
); | |
if (softDelete === true) { | |
await this.repo.softDelete(entity); | |
} else { | |
await this.repo.delete(entity); | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment