/* * 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, P extends string = never, TFull extends Omit = never, U = Omit, D = Document, > { private readonly transformOpts = { excludePrefixes: ['_', 'password'] }; private readonly leanOpts = { virtuals: true, defaults: true, getters: true }; constructor( private readonly emitter: EventEmitter2, readonly model: Model, 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; await repository.preValidate(doc); repository.emitter.emit(repository.getEventName(EHook.preValidate), doc); }); hooks?.validate.post.execute(async function (created: HydratedDocument) { await repository.postValidate(created); repository.emitter.emit( repository.getEventName(EHook.postValidate), created, ); }); hooks?.save.pre.execute(async function () { const doc = this as HydratedDocument; await repository.preCreate(doc); repository.emitter.emit(repository.getEventName(EHook.preCreate), doc); }); hooks?.save.post.execute(async function (created: HydratedDocument) { await repository.postCreate(created); repository.emitter.emit( repository.getEventName(EHook.postCreate), created, ); }); hooks?.deleteOne.pre.execute(async function () { const query = this as Query; 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; 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; 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; await repository.postDelete(query, result); }); hooks?.findOneAndUpdate.pre.execute(async function () { const query = this as Query; 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; 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; await repository.postUpdateMany(query, updated); repository.emitter.emit( repository.getEventName(EHook.postUpdateMany), updated, ); }); hooks?.findOneAndUpdate.post.execute(async function ( updated: HydratedDocument, ) { if (updated) { const query = this as Query; await repository.postUpdate( query, plainToClass(repository.cls, updated, repository.transformOpts), ); repository.emitter.emit( repository.getEventName(EHook.postUpdate), updated, ); } }); } protected async execute>( query: Query, cls: new () => R, ) { const resultSet = await query.lean(this.leanOpts).exec(); return resultSet.map((doc) => plainToClass(cls, doc, this.transformOpts)); } protected async executeOne>( query: Query, 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) { 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(criteria); } async findOne( criteria: string | TFilterQuery, options?: ClassTransformOptions, ) { if (!criteria) { // @TODO : Issue a warning ? return Promise.resolve(undefined); } const query = typeof criteria === 'string' ? this.model.findById(criteria) : this.model.findOne(criteria); return await this.executeOne(query, this.cls, options); } async findOneAndPopulate(criteria: string | TFilterQuery) { this.ensureCanPopulate(); const query = this.findOneQuery(criteria).populate(this.populate); return await this.executeOne(query, this.clsPopulate); } protected findQuery(filter: TFilterQuery, sort?: QuerySortDto) { const query = this.model.find(filter); if (sort) { return query.sort([sort] as [string, SortOrder][]); } return query; } async find(filter: TFilterQuery, sort?: QuerySortDto) { 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, sort?: QuerySortDto) { this.ensureCanPopulate(); const query = this.findQuery(filters, sort).populate(this.populate); return await this.execute(query, this.clsPopulate); } protected findAllQuery(sort?: QuerySortDto) { return this.findQuery({}, sort); } async findAll(sort?: QuerySortDto) { return await this.find({}, sort); } async findAllAndPopulate(sort?: QuerySortDto) { this.ensureCanPopulate(); const query = this.findAllQuery(sort).populate(this.populate); return await this.execute(query, this.clsPopulate); } protected findPageQuery( filters: TFilterQuery, { skip, limit, sort }: PageQueryDto, ) { return this.findQuery(filters) .skip(skip) .limit(limit) .sort([sort] as [string, SortOrder][]); } async findPage( filters: TFilterQuery, pageQuery: PageQueryDto, ): Promise { const query = this.findPageQuery(filters, pageQuery); return await this.execute(query, this.cls); } async findPageAndPopulate( filters: TFilterQuery, pageQuery: PageQueryDto, ) { this.ensureCanPopulate(); const query = this.findPageQuery(filters, pageQuery).populate( this.populate, ); return await this.execute(query, this.clsPopulate); } async countAll(): Promise { return await this.model.estimatedDocumentCount().exec(); } async count(criteria?: TFilterQuery): Promise { return await this.model.countDocuments(criteria).exec(); } async create(dto: U): Promise { 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>( criteria: string | TFilterQuery, dto: UpdateQuery, ): Promise { const query = this.model.findOneAndUpdate( { ...(typeof criteria === 'string' ? { _id: criteria } : criteria), }, { $set: dto, }, { new: true, }, ); return await this.executeOne(query, this.cls); } async updateMany>( filter: TFilterQuery, dto: UpdateQuery, ) { return await this.model.updateMany(filter, { $set: dto, }); } async deleteOne(criteria: string | TFilterQuery): Promise { return await this.model .deleteOne(typeof criteria === 'string' ? { _id: criteria } : criteria) .exec(); } async deleteMany(criteria: TFilterQuery): Promise { return await this.model.deleteMany(criteria); } async preValidate(_doc: HydratedDocument) { // Nothing ... } async postValidate(_validated: HydratedDocument) { // Nothing ... } async preCreate(_doc: HydratedDocument) { // Nothing ... } async postCreate(_created: HydratedDocument) { // Nothing ... } async preUpdate( _query: Query, _criteria: TFilterQuery, _updates: UpdateWithAggregationPipeline | UpdateQuery, ) { // Nothing ... } async preUpdateMany( _query: Query, _criteria: TFilterQuery, _updates: UpdateWithAggregationPipeline | UpdateQuery, ) { // Nothing ... } async postUpdateMany( _query: Query, _updated: any, ) { // Nothing ... } async postUpdate( _query: Query, _updated: T, ) { // Nothing ... } async preDelete( _query: Query, _criteria: TFilterQuery, ) { // Nothing ... } async postDelete( _query: Query, _result: DeleteResult, ) { // Nothing ... } }