mirror of
https://github.com/hexastack/hexabot
synced 2025-06-26 18:27:28 +00:00
feat: initial commit
This commit is contained in:
77
api/src/utils/generics/base-controller.ts
Normal file
77
api/src/utils/generics/base-controller.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* 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).
|
||||
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
|
||||
*/
|
||||
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { TFilterQuery } from 'mongoose';
|
||||
|
||||
import { BaseSchema } from './base-schema';
|
||||
import { BaseService } from './base-service';
|
||||
import { TValidateProps, TFilterPopulateFields } from '../types/filter.types';
|
||||
|
||||
export abstract class BaseController<T extends BaseSchema, TStub = never> {
|
||||
constructor(private readonly service: BaseService<T>) {}
|
||||
|
||||
/**
|
||||
* Checks if the given populate fields are allowed based on the allowed fields list.
|
||||
* @param populate - The list of populate fields.
|
||||
* @param allowedFields - The list of allowed populate fields.
|
||||
* @return - True if all populate fields are allowed, otherwise false.
|
||||
*/
|
||||
protected canPopulate(
|
||||
populate: string[],
|
||||
allowedFields: (keyof TFilterPopulateFields<T, TStub>)[],
|
||||
): boolean {
|
||||
return (populate as typeof allowedFields).some((p) =>
|
||||
allowedFields.includes(p),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the provided DTO against allowed IDs.
|
||||
* @param {TValidateProps<T, TStub>} - The validation properties
|
||||
* @throws {NotFoundException} Throws a NotFoundException if any invalid IDs are found.
|
||||
*/
|
||||
protected validate({ dto, allowedIds }: TValidateProps<T, TStub>): void {
|
||||
const exceptions = [];
|
||||
Object.entries(dto)
|
||||
.filter(([key]) => Object.keys(allowedIds).includes(key))
|
||||
.forEach(([field]) => {
|
||||
const invalidIds = (
|
||||
Array.isArray(dto[field]) ? dto[field] : [dto[field]]
|
||||
).filter(
|
||||
(id) =>
|
||||
!(
|
||||
Array.isArray(allowedIds[field])
|
||||
? allowedIds[field]
|
||||
: [allowedIds[field]]
|
||||
).includes(id),
|
||||
);
|
||||
|
||||
if (invalidIds.length) {
|
||||
exceptions.push(
|
||||
`${field} with ID${
|
||||
invalidIds.length > 1 ? 's' : ''
|
||||
} '${invalidIds}' not found`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (exceptions.length) throw new NotFoundException(exceptions.join('; '));
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts filtered items.
|
||||
* @return Object containing the count of items.
|
||||
*/
|
||||
async count(filters?: TFilterQuery<T>): Promise<{
|
||||
count: number;
|
||||
}> {
|
||||
return { count: await this.service.count(filters) };
|
||||
}
|
||||
}
|
||||
215
api/src/utils/generics/base-repository.spec.ts
Normal file
215
api/src/utils/generics/base-repository.spec.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
/*
|
||||
* 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).
|
||||
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
|
||||
*/
|
||||
|
||||
import { getModelToken } from '@nestjs/mongoose';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import mongoose, { Model } from 'mongoose';
|
||||
|
||||
import { DummyRepository } from '@/utils/test/dummy/repositories/dummy.repository';
|
||||
import { closeInMongodConnection } from '@/utils/test/test';
|
||||
|
||||
import { DummyModule } from '../test/dummy/dummy.module';
|
||||
import { Dummy } from '../test/dummy/schemas/dummy.schema';
|
||||
|
||||
describe('BaseRepository', () => {
|
||||
let dummyModel: Model<Dummy>;
|
||||
let dummyRepository: DummyRepository;
|
||||
let createdId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [DummyModule],
|
||||
}).compile();
|
||||
dummyModel = module.get<Model<Dummy>>(getModelToken(Dummy.name));
|
||||
dummyRepository = module.get<DummyRepository>(DummyRepository);
|
||||
});
|
||||
afterEach(jest.clearAllMocks);
|
||||
afterAll(closeInMongodConnection);
|
||||
|
||||
describe('create', () => {
|
||||
it('should create one dummy', async () => {
|
||||
jest.spyOn(dummyModel, 'create');
|
||||
const { id, ...rest } = await dummyRepository.create({
|
||||
dummy: 'dummy test 5',
|
||||
});
|
||||
createdId = id;
|
||||
|
||||
expect(dummyModel.create).toHaveBeenCalledWith({
|
||||
dummy: 'dummy test 5',
|
||||
});
|
||||
expect(rest).toEqualPayload({
|
||||
dummy: 'dummy test 5',
|
||||
});
|
||||
});
|
||||
|
||||
it('should create one dummy and invoke lifecycle hooks', async () => {
|
||||
const mockDto = { dummy: 'dummy test 5' };
|
||||
const spyBeforeCreate = jest
|
||||
.spyOn(dummyRepository, 'preCreate')
|
||||
.mockResolvedValue();
|
||||
const spyAfterCreate = jest
|
||||
.spyOn(dummyRepository, 'postCreate')
|
||||
.mockResolvedValue();
|
||||
|
||||
await dummyRepository.create(mockDto);
|
||||
|
||||
expect(spyBeforeCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining(mockDto),
|
||||
);
|
||||
expect(spyAfterCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining(mockDto),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('should find by id and return one dummy data', async () => {
|
||||
jest.spyOn(dummyModel, 'findById');
|
||||
const result = await dummyRepository.findOne(createdId);
|
||||
|
||||
expect(dummyModel.findById).toHaveBeenCalledWith(createdId);
|
||||
expect(result).toEqualPayload({
|
||||
dummy: 'dummy test 5',
|
||||
});
|
||||
});
|
||||
|
||||
it('should find by criteria and return one dummy data', async () => {
|
||||
jest.spyOn(dummyModel, 'findOne');
|
||||
const result = await dummyRepository.findOne({ dummy: 'dummy test 5' });
|
||||
|
||||
expect(dummyModel.findOne).toHaveBeenCalledWith({
|
||||
dummy: 'dummy test 5',
|
||||
});
|
||||
expect(result).toEqualPayload({
|
||||
dummy: 'dummy test 5',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateOne', () => {
|
||||
it('should updated by id and return one dummy data', async () => {
|
||||
jest.spyOn(dummyModel, 'findOneAndUpdate');
|
||||
const result = await dummyRepository.updateOne(createdId, {
|
||||
dummy: 'updated dummy text',
|
||||
});
|
||||
|
||||
expect(dummyModel.findOneAndUpdate).toHaveBeenCalledWith(
|
||||
{ _id: createdId },
|
||||
{
|
||||
$set: { dummy: 'updated dummy text' },
|
||||
},
|
||||
{
|
||||
new: true,
|
||||
},
|
||||
);
|
||||
expect(result).toEqualPayload({
|
||||
dummy: 'updated dummy text',
|
||||
});
|
||||
});
|
||||
|
||||
it('should updated by criteria and return one dummy data', async () => {
|
||||
jest.spyOn(dummyModel, 'findOneAndUpdate');
|
||||
const result = await dummyRepository.updateOne(
|
||||
{ dummy: 'updated dummy text' },
|
||||
{
|
||||
dummy: 'updated dummy text 2',
|
||||
},
|
||||
);
|
||||
|
||||
expect(dummyModel.findOneAndUpdate).toHaveBeenCalledWith(
|
||||
{ dummy: 'updated dummy text' },
|
||||
{
|
||||
$set: { dummy: 'updated dummy text 2' },
|
||||
},
|
||||
{
|
||||
new: true,
|
||||
},
|
||||
);
|
||||
expect(result).toEqualPayload({
|
||||
dummy: 'updated dummy text 2',
|
||||
});
|
||||
});
|
||||
|
||||
it('should update by id and invoke lifecycle hooks', async () => {
|
||||
const created = await dummyRepository.create({ dummy: 'initial text' });
|
||||
const mockUpdate = { dummy: 'updated dummy text' };
|
||||
const spyBeforeUpdate = jest
|
||||
.spyOn(dummyRepository, 'preUpdate')
|
||||
.mockResolvedValue();
|
||||
const spyAfterUpdate = jest
|
||||
.spyOn(dummyRepository, 'postUpdate')
|
||||
.mockResolvedValue();
|
||||
|
||||
await dummyRepository.updateOne(created.id, mockUpdate);
|
||||
|
||||
expect(spyBeforeUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ $useProjection: true }),
|
||||
{
|
||||
_id: new mongoose.Types.ObjectId(created.id),
|
||||
},
|
||||
expect.objectContaining({ $set: expect.objectContaining(mockUpdate) }),
|
||||
);
|
||||
expect(spyAfterUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ $useProjection: true }),
|
||||
expect.objectContaining({ dummy: 'updated dummy text' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteOne', () => {
|
||||
it('should delete by id one dummy data', async () => {
|
||||
jest.spyOn(dummyModel, 'deleteOne');
|
||||
const result = await dummyRepository.deleteOne(createdId);
|
||||
|
||||
expect(dummyModel.deleteOne).toHaveBeenCalledWith({
|
||||
_id: createdId,
|
||||
});
|
||||
expect(result).toEqualPayload({ acknowledged: true, deletedCount: 1 });
|
||||
});
|
||||
|
||||
it('should delete by criteria one dummy data', async () => {
|
||||
jest.spyOn(dummyModel, 'deleteOne');
|
||||
const result = await dummyRepository.deleteOne({
|
||||
dummy: 'dummy test 2',
|
||||
});
|
||||
|
||||
expect(dummyModel.deleteOne).toHaveBeenCalledWith({
|
||||
dummy: 'dummy test 2',
|
||||
});
|
||||
expect(result).toEqualPayload({ acknowledged: true, deletedCount: 1 });
|
||||
});
|
||||
|
||||
it('should call lifecycle hooks appropriately when deleting by id', async () => {
|
||||
const criteria = createdId;
|
||||
|
||||
// Spies for lifecycle hooks
|
||||
const spyBeforeDelete = jest
|
||||
.spyOn(dummyRepository, 'preDelete')
|
||||
.mockResolvedValue();
|
||||
const spyAfterDelete = jest
|
||||
.spyOn(dummyRepository, 'postDelete')
|
||||
.mockResolvedValue();
|
||||
|
||||
await dummyRepository.deleteOne(criteria);
|
||||
|
||||
// Verifying that lifecycle hooks are called with correct parameters
|
||||
expect(spyBeforeDelete).toHaveBeenCalledTimes(1);
|
||||
expect(spyBeforeDelete).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ $useProjection: true }),
|
||||
{
|
||||
_id: new mongoose.Types.ObjectId(createdId),
|
||||
},
|
||||
);
|
||||
expect(spyAfterDelete).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ $useProjection: true }),
|
||||
{ acknowledged: true, deletedCount: 0 },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
299
api/src/utils/generics/base-repository.ts
Normal file
299
api/src/utils/generics/base-repository.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
/*
|
||||
* 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).
|
||||
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
|
||||
*/
|
||||
|
||||
import { ClassTransformOptions, plainToClass } from 'class-transformer';
|
||||
import {
|
||||
Document,
|
||||
FlattenMaps,
|
||||
HydratedDocument,
|
||||
Model,
|
||||
Query,
|
||||
SortOrder,
|
||||
TFilterQuery,
|
||||
UpdateQuery,
|
||||
UpdateWithAggregationPipeline,
|
||||
} from 'mongoose';
|
||||
|
||||
import { BaseSchema } from './base-schema';
|
||||
import { LifecycleHookManager } from './lifecycle-hook-manager';
|
||||
import { PageQueryDto, QuerySortDto } from '../pagination/pagination-query.dto';
|
||||
|
||||
export type DeleteResult = {
|
||||
acknowledged: boolean;
|
||||
deletedCount: number;
|
||||
};
|
||||
|
||||
export abstract class BaseRepository<
|
||||
T extends FlattenMaps<unknown>,
|
||||
P extends string = never,
|
||||
U = Omit<T, keyof BaseSchema>,
|
||||
D = Document<T>,
|
||||
> {
|
||||
private readonly transformOpts = { excludePrefixes: ['_', 'password'] };
|
||||
|
||||
private readonly leanOpts = { virtuals: true, defaults: true, getters: true };
|
||||
|
||||
constructor(
|
||||
readonly model: Model<T>,
|
||||
private readonly cls: new () => T,
|
||||
) {
|
||||
this.registerLifeCycleHooks();
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
hooks?.validate.post.execute(async function (created: HydratedDocument<T>) {
|
||||
await repository.postValidate(created);
|
||||
});
|
||||
|
||||
hooks?.save.pre.execute(async function () {
|
||||
const doc = this as HydratedDocument<T>;
|
||||
await repository.preCreate(doc);
|
||||
});
|
||||
|
||||
hooks?.save.post.execute(async function (created: HydratedDocument<T>) {
|
||||
await repository.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);
|
||||
});
|
||||
|
||||
hooks?.deleteOne.post.execute(async function (result: DeleteResult) {
|
||||
const query = this as Query<DeleteResult, D, unknown, T, 'deleteOne'>;
|
||||
await repository.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) {
|
||||
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);
|
||||
});
|
||||
|
||||
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),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
protected findAllQuery() {
|
||||
return this.findQuery({});
|
||||
}
|
||||
|
||||
async findAll(sort?: QuerySortDto<T>) {
|
||||
return await this.find({}, sort);
|
||||
}
|
||||
|
||||
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 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: 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: D) {
|
||||
return await this.model.updateMany<T>(filter, {
|
||||
$set: dto,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteOne(criteria: string | TFilterQuery<T>) {
|
||||
return await this.model
|
||||
.deleteOne(typeof criteria === 'string' ? { _id: criteria } : criteria)
|
||||
.exec();
|
||||
}
|
||||
|
||||
async deleteMany(criteria: TFilterQuery<T>) {
|
||||
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 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 ...
|
||||
}
|
||||
}
|
||||
25
api/src/utils/generics/base-schema.ts
Normal file
25
api/src/utils/generics/base-schema.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* 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).
|
||||
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
|
||||
*/
|
||||
|
||||
import { Transform, Type, Expose } from 'class-transformer';
|
||||
|
||||
export abstract class BaseSchema {
|
||||
@Expose()
|
||||
@Transform(({ obj }) => {
|
||||
// We have to return an id for unit test purpose
|
||||
return obj._id ? obj._id.toString() : obj.id;
|
||||
})
|
||||
public readonly id: string;
|
||||
|
||||
@Type(() => Date)
|
||||
public readonly createdAt: Date;
|
||||
|
||||
@Type(() => Date)
|
||||
public readonly updatedAt: Date;
|
||||
}
|
||||
32
api/src/utils/generics/base-seeder.ts
Normal file
32
api/src/utils/generics/base-seeder.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* 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).
|
||||
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
|
||||
*/
|
||||
|
||||
import { BaseRepository } from './base-repository';
|
||||
import { BaseSchema } from './base-schema';
|
||||
|
||||
export abstract class BaseSeeder<T, P extends string = never> {
|
||||
constructor(protected readonly repository: BaseRepository<T, P>) {}
|
||||
|
||||
async findAll(): Promise<T[]> {
|
||||
return await this.repository.findAll();
|
||||
}
|
||||
|
||||
async isEmpty(): Promise<boolean> {
|
||||
const count = await this.repository.countAll();
|
||||
return count === 0;
|
||||
}
|
||||
|
||||
async seed(models: Omit<T, keyof BaseSchema>[]): Promise<boolean> {
|
||||
if (await this.isEmpty()) {
|
||||
await this.repository.createMany(models);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
124
api/src/utils/generics/base-service.spec.ts
Normal file
124
api/src/utils/generics/base-service.spec.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* 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).
|
||||
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { DummyService } from '@/utils/test/dummy/services/dummy.service';
|
||||
import { closeInMongodConnection } from '@/utils/test/test';
|
||||
|
||||
import { DummyModule } from '../test/dummy/dummy.module';
|
||||
import { DummyRepository } from '../test/dummy/repositories/dummy.repository';
|
||||
|
||||
describe('BaseService', () => {
|
||||
let dummyRepository: DummyRepository;
|
||||
let dummyService: DummyService;
|
||||
let createdId: string;
|
||||
const createdPayload = {
|
||||
dummy: 'dummy test 5',
|
||||
};
|
||||
const updatedPayload = { dummy: 'updated dummy text' };
|
||||
const updatedCriteriaPayload = {
|
||||
dummy: 'updated dummy text 2',
|
||||
};
|
||||
const deletedCriteriaPayload = {
|
||||
dummy: 'dummy test 2',
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [DummyModule],
|
||||
}).compile();
|
||||
dummyRepository = module.get<DummyRepository>(DummyRepository);
|
||||
dummyService = module.get<DummyService>(DummyService);
|
||||
});
|
||||
afterEach(jest.clearAllMocks);
|
||||
afterAll(closeInMongodConnection);
|
||||
|
||||
describe('create', () => {
|
||||
it('should create one dummy', async () => {
|
||||
jest.spyOn(dummyRepository, 'create');
|
||||
const { id, ...rest } = await dummyService.create(createdPayload);
|
||||
createdId = id;
|
||||
|
||||
expect(dummyRepository.create).toHaveBeenCalledWith(createdPayload);
|
||||
expect(rest).toEqualPayload(createdPayload);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('should find by id and return one dummy data', async () => {
|
||||
jest.spyOn(dummyRepository, 'findOne');
|
||||
const result = await dummyService.findOne(createdId);
|
||||
|
||||
expect(dummyRepository.findOne).toHaveBeenCalledWith(
|
||||
createdId,
|
||||
undefined,
|
||||
);
|
||||
expect(result).toEqualPayload(createdPayload);
|
||||
});
|
||||
|
||||
it('should find by criteria and return one dummy data', async () => {
|
||||
jest.spyOn(dummyRepository, 'findOne');
|
||||
const result = await dummyService.findOne(createdPayload);
|
||||
|
||||
expect(dummyRepository.findOne).toHaveBeenCalledWith(
|
||||
createdPayload,
|
||||
undefined,
|
||||
);
|
||||
expect(result).toEqualPayload(createdPayload);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateOne', () => {
|
||||
it('should updated by id and return one dummy data', async () => {
|
||||
jest.spyOn(dummyRepository, 'updateOne');
|
||||
const result = await dummyService.updateOne(createdId, updatedPayload);
|
||||
|
||||
expect(dummyRepository.updateOne).toHaveBeenCalledWith(
|
||||
createdId,
|
||||
updatedPayload,
|
||||
);
|
||||
expect(result).toEqualPayload(updatedPayload);
|
||||
});
|
||||
|
||||
it('should updated by criteria and return one dummy data', async () => {
|
||||
jest.spyOn(dummyRepository, 'updateOne');
|
||||
const result = await dummyService.updateOne(
|
||||
updatedPayload,
|
||||
updatedCriteriaPayload,
|
||||
);
|
||||
|
||||
expect(dummyRepository.updateOne).toHaveBeenCalledWith(
|
||||
updatedPayload,
|
||||
updatedCriteriaPayload,
|
||||
);
|
||||
expect(result).toEqualPayload(updatedCriteriaPayload);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteOne', () => {
|
||||
it('should delete by id one dummy data', async () => {
|
||||
jest.spyOn(dummyRepository, 'deleteOne');
|
||||
const result = await dummyService.deleteOne(createdId);
|
||||
|
||||
expect(dummyRepository.deleteOne).toHaveBeenCalledWith(createdId);
|
||||
expect(result).toEqualPayload({ acknowledged: true, deletedCount: 1 });
|
||||
});
|
||||
|
||||
it('should delete by id one dummy data', async () => {
|
||||
jest.spyOn(dummyRepository, 'deleteOne');
|
||||
const result = await dummyService.deleteOne(deletedCriteriaPayload);
|
||||
|
||||
expect(dummyRepository.deleteOne).toHaveBeenCalledWith(
|
||||
deletedCriteriaPayload,
|
||||
);
|
||||
expect(result).toEqualPayload({ acknowledged: true, deletedCount: 1 });
|
||||
});
|
||||
});
|
||||
});
|
||||
103
api/src/utils/generics/base-service.ts
Normal file
103
api/src/utils/generics/base-service.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* 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).
|
||||
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
|
||||
*/
|
||||
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
import { ClassTransformOptions } from 'class-transformer';
|
||||
import { MongoError } from 'mongodb';
|
||||
import { TFilterQuery } from 'mongoose';
|
||||
|
||||
import { BaseRepository } from './base-repository';
|
||||
import { BaseSchema } from './base-schema';
|
||||
import { PageQueryDto, QuerySortDto } from '../pagination/pagination-query.dto';
|
||||
|
||||
export abstract class BaseService<T extends BaseSchema> {
|
||||
constructor(readonly repository: BaseRepository<T, never>) {}
|
||||
|
||||
async findOne(
|
||||
criteria: string | TFilterQuery<T>,
|
||||
options?: ClassTransformOptions,
|
||||
): Promise<T> {
|
||||
return await this.repository.findOne(criteria, options);
|
||||
}
|
||||
|
||||
async find(filter: TFilterQuery<T>, sort?: QuerySortDto<T>): Promise<T[]> {
|
||||
return await this.repository.find(filter, sort);
|
||||
}
|
||||
|
||||
async findAll(sort?: QuerySortDto<T>): Promise<T[]> {
|
||||
return await this.repository.findAll(sort);
|
||||
}
|
||||
|
||||
async findPage(
|
||||
filters: TFilterQuery<T>,
|
||||
pageQueryDto: PageQueryDto<T>,
|
||||
): Promise<T[]> {
|
||||
return await this.repository.findPage(filters, pageQueryDto);
|
||||
}
|
||||
|
||||
async countAll(): Promise<number> {
|
||||
return await this.repository.countAll();
|
||||
}
|
||||
|
||||
async count(criteria?: TFilterQuery<T>): Promise<number> {
|
||||
return await this.repository.count(criteria);
|
||||
}
|
||||
|
||||
async create<D extends Omit<T, keyof BaseSchema>>(dto: D): Promise<T> {
|
||||
try {
|
||||
return await this.repository.create(dto);
|
||||
} catch (error) {
|
||||
if (error instanceof MongoError && error.code === 11000) {
|
||||
throw new ConflictException(
|
||||
'Duplicate key error: element already exists',
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async findOneOrCreate<D extends Omit<T, keyof BaseSchema>>(
|
||||
criteria: string | TFilterQuery<T>,
|
||||
dto: D,
|
||||
): Promise<T> {
|
||||
const result = await this.findOne(criteria);
|
||||
if (!result) {
|
||||
return await this.create(dto);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async createMany<D extends Omit<T, keyof BaseSchema>>(
|
||||
dtoArray: D[],
|
||||
): Promise<T[]> {
|
||||
return await this.repository.createMany(dtoArray);
|
||||
}
|
||||
|
||||
async updateOne<D extends Partial<Omit<T, keyof BaseSchema>>>(
|
||||
criteria: string | TFilterQuery<T>,
|
||||
dto: D,
|
||||
): Promise<T> {
|
||||
return await this.repository.updateOne(criteria, dto);
|
||||
}
|
||||
|
||||
async updateMany<D extends Partial<Omit<T, keyof BaseSchema>>>(
|
||||
filter: TFilterQuery<T>,
|
||||
dto: D,
|
||||
) {
|
||||
return await this.repository.updateMany(filter, dto);
|
||||
}
|
||||
|
||||
async deleteOne(criteria: string | TFilterQuery<T>) {
|
||||
return await this.repository.deleteOne(criteria);
|
||||
}
|
||||
|
||||
async deleteMany(filter: TFilterQuery<T>) {
|
||||
return await this.repository.deleteMany(filter);
|
||||
}
|
||||
}
|
||||
63
api/src/utils/generics/lifecycle-hook-manager.spec.ts
Normal file
63
api/src/utils/generics/lifecycle-hook-manager.spec.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* 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).
|
||||
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
|
||||
*/
|
||||
|
||||
import { ModelDefinition } from '@nestjs/mongoose';
|
||||
|
||||
import { LifecycleHookManager } from './lifecycle-hook-manager';
|
||||
|
||||
describe('LifecycleHookManager', () => {
|
||||
let modelMock: ModelDefinition;
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock ModelDefinition
|
||||
modelMock = {
|
||||
name: 'TestModel',
|
||||
schema: {
|
||||
pre: jest.fn(),
|
||||
post: jest.fn(),
|
||||
},
|
||||
} as unknown as ModelDefinition;
|
||||
});
|
||||
|
||||
it('should attach pre and post hooks to the schema', () => {
|
||||
// Attach hooks
|
||||
const result = LifecycleHookManager.attach(modelMock);
|
||||
|
||||
// Check if the hooks were attached
|
||||
expect(result).toEqual(modelMock);
|
||||
expect(modelMock.schema.pre).toHaveBeenCalledWith(
|
||||
'save',
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(modelMock.schema.post).toHaveBeenCalledWith(
|
||||
'save',
|
||||
expect.any(Function),
|
||||
);
|
||||
// Similarly, you can check for other hooks
|
||||
});
|
||||
|
||||
it('should return hooks attached to a specific model', () => {
|
||||
// Attach hooks to mock model
|
||||
LifecycleHookManager.attach(modelMock);
|
||||
|
||||
// Retrieve hooks
|
||||
const hooks = LifecycleHookManager.getHooks('TestModel');
|
||||
|
||||
// Validate the hooks
|
||||
expect(hooks).toBeDefined();
|
||||
expect(hooks!.save).toBeDefined();
|
||||
expect(hooks!.deleteOne).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return undefined for unknown models', () => {
|
||||
// Ensure undefined is returned for models without attached hooks
|
||||
const hooks = LifecycleHookManager.getHooks('UnknownModel');
|
||||
expect(hooks).toBeUndefined();
|
||||
});
|
||||
});
|
||||
102
api/src/utils/generics/lifecycle-hook-manager.ts
Normal file
102
api/src/utils/generics/lifecycle-hook-manager.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* 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).
|
||||
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
|
||||
*/
|
||||
|
||||
import { ModelDefinition } from '@nestjs/mongoose';
|
||||
|
||||
enum LifecycleOperation {
|
||||
Validate = 'validate',
|
||||
Save = 'save',
|
||||
DeleteOne = 'deleteOne',
|
||||
DeleteMany = 'deleteMany',
|
||||
FindOneAndUpdate = 'findOneAndUpdate',
|
||||
// InsertMany = 'insertMany',
|
||||
// Update = 'update',
|
||||
// UpdateOne = 'updateOne',
|
||||
// UpdateMany = 'updateMany',
|
||||
}
|
||||
|
||||
type PreHook = (...args: any[]) => void;
|
||||
type PostHook = (...args: any[]) => void;
|
||||
|
||||
interface LifecycleHook {
|
||||
pre: PreHook & { execute: (newCallback: PreHook) => void };
|
||||
post?: PostHook & { execute: (newCallback: PostHook) => void };
|
||||
}
|
||||
|
||||
type LifecycleHooks = {
|
||||
[op in LifecycleOperation]: LifecycleHook;
|
||||
};
|
||||
|
||||
interface Registry {
|
||||
[schemaName: string]: LifecycleHooks;
|
||||
}
|
||||
|
||||
export class LifecycleHookManager {
|
||||
private static registry: Registry = {};
|
||||
|
||||
private static createLifecycleCallback<H = PreHook | PostHook>(): H {
|
||||
let currentCallback = (..._args: any[]) => {};
|
||||
|
||||
async function dynamicCallback(...args: any[]) {
|
||||
await currentCallback.apply(this, args);
|
||||
}
|
||||
|
||||
dynamicCallback['execute'] = function (newCallback: H) {
|
||||
if (typeof newCallback !== 'function') {
|
||||
throw new Error('Lifecycle callback must be a function');
|
||||
}
|
||||
currentCallback = newCallback as typeof currentCallback;
|
||||
};
|
||||
|
||||
return dynamicCallback as H;
|
||||
}
|
||||
|
||||
public static attach(model: ModelDefinition): ModelDefinition {
|
||||
const { name, schema } = model;
|
||||
const operations: {
|
||||
[key in LifecycleOperation]: ('pre' | 'post')[];
|
||||
} = {
|
||||
validate: ['pre', 'post'],
|
||||
save: ['pre', 'post'],
|
||||
deleteOne: ['pre', 'post'],
|
||||
deleteMany: ['pre', 'post'],
|
||||
findOneAndUpdate: ['pre', 'post'],
|
||||
// insertMany: ['pre'],
|
||||
// update: ['pre', 'post'],
|
||||
// updateOne: ['pre', 'post'],
|
||||
// updateMany: ['pre', 'post'],
|
||||
};
|
||||
|
||||
const lifecycleHooks: LifecycleHooks = {} as LifecycleHooks;
|
||||
|
||||
for (const [op, types] of Object.entries(operations)) {
|
||||
const hooks: LifecycleHook = {
|
||||
pre: this.createLifecycleCallback() as LifecycleHook['pre'],
|
||||
};
|
||||
|
||||
if (types.includes('post')) {
|
||||
hooks.post = this.createLifecycleCallback() as LifecycleHook['post'];
|
||||
}
|
||||
|
||||
types.forEach((type) => {
|
||||
schema[type](op, hooks[type]);
|
||||
});
|
||||
|
||||
lifecycleHooks[op] = hooks;
|
||||
}
|
||||
|
||||
this.registry[name] = lifecycleHooks;
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
static getHooks(modelName: string): LifecycleHooks | undefined {
|
||||
return this.registry[modelName];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user