hexabot/api/src/utils/generics/base-repository.ts
2024-11-20 19:41:11 +01:00

441 lines
12 KiB
TypeScript

/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import {
EventEmitter2,
IHookEntities,
TNormalizedEvents,
} from '@nestjs/event-emitter';
import { ClassTransformOptions, plainToClass } from 'class-transformer';
import {
Document,
FlattenMaps,
HydratedDocument,
Model,
Query,
SortOrder,
UpdateQuery,
UpdateWithAggregationPipeline,
} from 'mongoose';
import { TFilterQuery } from '@/utils/types/filter.types';
import { PageQueryDto, QuerySortDto } from '../pagination/pagination-query.dto';
import { BaseSchema } from './base-schema';
import { LifecycleHookManager } from './lifecycle-hook-manager';
export type DeleteResult = {
acknowledged: boolean;
deletedCount: number;
};
export enum EHook {
preCreate = 'preCreate',
preUpdate = 'preUpdate',
preUpdateMany = 'preUpdateMany',
preDelete = 'preDelete',
preValidate = 'preValidate',
postCreate = 'postCreate',
postUpdate = 'postUpdate',
postUpdateMany = 'postUpdateMany',
postDelete = 'postDelete',
postValidate = 'postValidate',
}
export abstract class BaseRepository<
T extends FlattenMaps<unknown>,
P extends string = never,
TFull extends Omit<T, P> = never,
U = Omit<T, keyof BaseSchema>,
D = Document<T>,
> {
private readonly transformOpts = { excludePrefixes: ['_', 'password'] };
private readonly leanOpts = { virtuals: true, defaults: true, getters: true };
constructor(
private readonly emitter: EventEmitter2,
readonly model: Model<T>,
private readonly cls: new () => T,
protected readonly populate: P[] = [],
protected readonly clsPopulate: new () => TFull = undefined,
) {
this.registerLifeCycleHooks();
}
getPopulate() {
return this.populate;
}
getEventName(suffix: EHook) {
const entity = this.cls.name.toLocaleLowerCase();
return `hook:${entity}:${suffix}` as `hook:${IHookEntities}:${TNormalizedEvents}`;
}
private registerLifeCycleHooks() {
const repository = this;
const hooks = LifecycleHookManager.getHooks(this.cls.name);
hooks?.validate.pre.execute(async function () {
const doc = this as HydratedDocument<T>;
await repository.preValidate(doc);
repository.emitter.emit(repository.getEventName(EHook.preValidate), doc);
});
hooks?.validate.post.execute(async function (created: HydratedDocument<T>) {
await repository.postValidate(created);
repository.emitter.emit(
repository.getEventName(EHook.postValidate),
created,
);
});
hooks?.save.pre.execute(async function () {
const doc = this as HydratedDocument<T>;
await repository.preCreate(doc);
repository.emitter.emit(repository.getEventName(EHook.preCreate), doc);
});
hooks?.save.post.execute(async function (created: HydratedDocument<T>) {
await repository.postCreate(created);
repository.emitter.emit(
repository.getEventName(EHook.postCreate),
created,
);
});
hooks?.deleteOne.pre.execute(async function () {
const query = this as Query<DeleteResult, D, unknown, T, 'deleteOne'>;
const criteria = query.getQuery();
await repository.preDelete(query, criteria);
repository.emitter.emit(
repository.getEventName(EHook.preDelete),
query,
criteria,
);
});
hooks?.deleteOne.post.execute(async function (result: DeleteResult) {
const query = this as Query<DeleteResult, D, unknown, T, 'deleteOne'>;
await repository.postDelete(query, result);
repository.emitter.emit(
repository.getEventName(EHook.postDelete),
query,
result,
);
});
hooks?.deleteMany.pre.execute(async function () {
const query = this as Query<DeleteResult, D, unknown, T, 'deleteMany'>;
const criteria = query.getQuery();
await repository.preDelete(query, criteria);
});
hooks?.deleteMany.post.execute(async function (result: DeleteResult) {
repository.emitter.emit(
repository.getEventName(EHook.postDelete),
result,
);
const query = this as Query<DeleteResult, D, unknown, T, 'deleteMany'>;
await repository.postDelete(query, result);
});
hooks?.findOneAndUpdate.pre.execute(async function () {
const query = this as Query<D, D, unknown, T, 'findOneAndUpdate'>;
const criteria = query.getFilter();
const updates = query.getUpdate();
await repository.preUpdate(query, criteria, updates);
repository.emitter.emit(
repository.getEventName(EHook.preUpdate),
criteria,
updates?.['$set'],
);
});
hooks?.updateMany.pre.execute(async function () {
const query = this as Query<D, D, unknown, T, 'updateMany'>;
const criteria = query.getFilter();
const updates = query.getUpdate();
await repository.preUpdateMany(query, criteria, updates);
repository.emitter.emit(
repository.getEventName(EHook.preUpdateMany),
criteria,
updates?.['$set'],
);
});
hooks?.updateMany.post.execute(async function (updated: any) {
const query = this as Query<D, D, unknown, T, 'updateMany'>;
await repository.postUpdateMany(query, updated);
repository.emitter.emit(
repository.getEventName(EHook.postUpdateMany),
updated,
);
});
hooks?.findOneAndUpdate.post.execute(async function (
updated: HydratedDocument<T>,
) {
if (updated) {
const query = this as Query<D, D, unknown, T, 'findOneAndUpdate'>;
await repository.postUpdate(
query,
plainToClass(repository.cls, updated, repository.transformOpts),
);
repository.emitter.emit(
repository.getEventName(EHook.postUpdate),
updated,
);
}
});
}
protected async execute<R extends Omit<T, P>>(
query: Query<T[], T>,
cls: new () => R,
) {
const resultSet = await query.lean(this.leanOpts).exec();
return resultSet.map((doc) => plainToClass(cls, doc, this.transformOpts));
}
protected async executeOne<R extends Omit<T, P>>(
query: Query<T, T>,
cls: new () => R,
options?: ClassTransformOptions,
) {
const doc = await query.lean(this.leanOpts).exec();
return plainToClass(cls, doc, options ?? this.transformOpts);
}
protected findOneQuery(criteria: string | TFilterQuery<T>) {
if (!criteria) {
// An empty criteria would return the first document that it finds
throw new Error('findOneQuery() should not have an empty criteria');
}
return typeof criteria === 'string'
? this.model.findById(criteria)
: this.model.findOne<T>(criteria);
}
async findOne(
criteria: string | TFilterQuery<T>,
options?: ClassTransformOptions,
) {
if (!criteria) {
// @TODO : Issue a warning ?
return Promise.resolve(undefined);
}
const query =
typeof criteria === 'string'
? this.model.findById<T>(criteria)
: this.model.findOne<T>(criteria);
return await this.executeOne(query, this.cls, options);
}
async findOneAndPopulate(criteria: string | TFilterQuery<T>) {
this.ensureCanPopulate();
const query = this.findOneQuery(criteria).populate(this.populate);
return await this.executeOne(query, this.clsPopulate);
}
protected findQuery(filter: TFilterQuery<T>, sort?: QuerySortDto<T>) {
const query = this.model.find<T>(filter);
if (sort) {
return query.sort([sort] as [string, SortOrder][]);
}
return query;
}
async find(filter: TFilterQuery<T>, sort?: QuerySortDto<T>) {
const query = this.findQuery(filter, sort);
return await this.execute(query, this.cls);
}
private ensureCanPopulate() {
if (!this.populate || !this.clsPopulate) {
throw new Error('Cannot populate query');
}
}
async findAndPopulate(filters: TFilterQuery<T>, sort?: QuerySortDto<T>) {
this.ensureCanPopulate();
const query = this.findQuery(filters, sort).populate(this.populate);
return await this.execute(query, this.clsPopulate);
}
protected findAllQuery(sort?: QuerySortDto<T>) {
return this.findQuery({}, sort);
}
async findAll(sort?: QuerySortDto<T>) {
return await this.find({}, sort);
}
async findAllAndPopulate(sort?: QuerySortDto<T>) {
this.ensureCanPopulate();
const query = this.findAllQuery(sort).populate(this.populate);
return await this.execute(query, this.clsPopulate);
}
protected findPageQuery(
filters: TFilterQuery<T>,
{ skip, limit, sort }: PageQueryDto<T>,
) {
return this.findQuery(filters)
.skip(skip)
.limit(limit)
.sort([sort] as [string, SortOrder][]);
}
async findPage(
filters: TFilterQuery<T>,
pageQuery: PageQueryDto<T>,
): Promise<T[]> {
const query = this.findPageQuery(filters, pageQuery);
return await this.execute(query, this.cls);
}
async findPageAndPopulate(
filters: TFilterQuery<T>,
pageQuery: PageQueryDto<T>,
) {
this.ensureCanPopulate();
const query = this.findPageQuery(filters, pageQuery).populate(
this.populate,
);
return await this.execute(query, this.clsPopulate);
}
async countAll(): Promise<number> {
return await this.model.estimatedDocumentCount().exec();
}
async count(criteria?: TFilterQuery<T>): Promise<number> {
return await this.model.countDocuments(criteria).exec();
}
async create(dto: U): Promise<T> {
const doc = await this.model.create(dto);
return plainToClass(
this.cls,
doc.toObject(this.leanOpts),
this.transformOpts,
);
}
async createMany(dtoArray: U[]) {
const docs = await this.model.create(dtoArray);
return docs.map((doc) =>
plainToClass(this.cls, doc.toObject(this.leanOpts), this.transformOpts),
);
}
async updateOne<D extends Partial<U>>(
criteria: string | TFilterQuery<T>,
dto: UpdateQuery<D>,
): Promise<T> {
const query = this.model.findOneAndUpdate<T>(
{
...(typeof criteria === 'string' ? { _id: criteria } : criteria),
},
{
$set: dto,
},
{
new: true,
},
);
return await this.executeOne(query, this.cls);
}
async updateMany<D extends Partial<U>>(
filter: TFilterQuery<T>,
dto: UpdateQuery<D>,
) {
return await this.model.updateMany<T>(filter, {
$set: dto,
});
}
async deleteOne(criteria: string | TFilterQuery<T>): Promise<DeleteResult> {
return await this.model
.deleteOne(typeof criteria === 'string' ? { _id: criteria } : criteria)
.exec();
}
async deleteMany(criteria: TFilterQuery<T>): Promise<DeleteResult> {
return await this.model.deleteMany(criteria);
}
async preValidate(_doc: HydratedDocument<T>) {
// Nothing ...
}
async postValidate(_validated: HydratedDocument<T>) {
// Nothing ...
}
async preCreate(_doc: HydratedDocument<T>) {
// Nothing ...
}
async postCreate(_created: HydratedDocument<T>) {
// Nothing ...
}
async preUpdate(
_query: Query<D, D, unknown, T, 'findOneAndUpdate'>,
_criteria: TFilterQuery<T>,
_updates: UpdateWithAggregationPipeline | UpdateQuery<D>,
) {
// Nothing ...
}
async preUpdateMany(
_query: Query<D, D, unknown, T, 'updateMany'>,
_criteria: TFilterQuery<T>,
_updates: UpdateWithAggregationPipeline | UpdateQuery<D>,
) {
// Nothing ...
}
async postUpdateMany(
_query: Query<D, D, unknown, T, 'updateMany'>,
_updated: any,
) {
// Nothing ...
}
async postUpdate(
_query: Query<D, D, unknown, T, 'findOneAndUpdate'>,
_updated: T,
) {
// Nothing ...
}
async preDelete(
_query: Query<DeleteResult, D, unknown, T, 'deleteOne' | 'deleteMany'>,
_criteria: TFilterQuery<T>,
) {
// Nothing ...
}
async postDelete(
_query: Query<DeleteResult, D, unknown, T, 'deleteOne' | 'deleteMany'>,
_result: DeleteResult,
) {
// Nothing ...
}
}