Skip to content

Instantly share code, notes, and snippets.

@mnixry
Last active March 6, 2023 16:48
Show Gist options
  • Save mnixry/700ea483b9c632c1a025056ddde82f69 to your computer and use it in GitHub Desktop.
Save mnixry/700ea483b9c632c1a025056ddde82f69 to your computer and use it in GitHub Desktop.
Nest.js CRUD service base class
/* 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;
}
/* 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