feat: initial commit

This commit is contained in:
Mohamed Marrouchi
2024-09-10 10:50:11 +01:00
commit 30e5766487
879 changed files with 122820 additions and 0 deletions

View 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) };
}
}

View 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 },
);
});
});
});

View 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 ...
}
}

View 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;
}

View 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;
}
}

View 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 });
});
});
});

View 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);
}
}

View 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();
});
});

View 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];
}
}