From 7ac21647b13359abd568b34d6d08cdbb20888857 Mon Sep 17 00:00:00 2001 From: Lexus Drumgold Date: Wed, 26 May 2021 19:20:35 -0400 Subject: [PATCH] feat(abstracts): add `AbstractMangoRepository` --- .eslintrc.js | 5 +- __tests__/__fixtures__/cars.fixture.ts | 4 +- .../__fixtures__/cars-repo.fixture.ts | 40 ++ .../__tests__/mango-finder.abstract.spec.ts | 49 +- .../__tests__/mango-repo.abstract.spec.ts | 301 +++++++++++ src/abstracts/index.ts | 1 + src/abstracts/mango-repo.abstract.ts | 471 ++++++++++++++++++ .../abstract-mango-repo.interface.ts | 2 +- .../__tests__/mango-parser.mixin.spec.ts | 6 +- .../__tests__/mango-validator.mixin.spec.ts | 21 +- .../mango-finder-async.plugin.spec.ts | 7 +- .../__tests__/mango-finder.plugin.spec.ts | 7 +- src/types/index.ts | 2 +- ...-plugin.types.ts => mango-finder.types.ts} | 2 +- 14 files changed, 867 insertions(+), 51 deletions(-) create mode 100644 src/abstracts/__tests__/__fixtures__/cars-repo.fixture.ts create mode 100644 src/abstracts/__tests__/mango-repo.abstract.spec.ts create mode 100644 src/abstracts/mango-repo.abstract.ts rename src/types/{mango-finder-plugin.types.ts => mango-finder.types.ts} (96%) diff --git a/.eslintrc.js b/.eslintrc.js index f4fe50f..0245157 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -175,7 +175,10 @@ module.exports = { } }, { - files: ['src/abstracts/__tests__/__fixtures__/cars-finder.fixture.ts'], + files: [ + 'src/abstracts/__tests__/__fixtures__/cars-finder.fixture.ts', + 'src/abstracts/__tests__/__fixtures__/cars-repo.fixture.ts' + ], rules: { '@typescript-eslint/no-unused-vars': 0 } diff --git a/__tests__/__fixtures__/cars.fixture.ts b/__tests__/__fixtures__/cars.fixture.ts index 553ea7d..56bed3f 100644 --- a/__tests__/__fixtures__/cars.fixture.ts +++ b/__tests__/__fixtures__/cars.fixture.ts @@ -38,7 +38,7 @@ export class Car implements ICar { export const CARS_UID: CarUID = 'vin' -const CARS_ROOT = { +export const CARS_ROOT = { '5b38c222-bf0c-4972-9810-d8cd7e399a56': { make: 'Mitsubishi', model: '3000GT', @@ -79,7 +79,7 @@ export const CARS_MOCK_CACHE: MangoCacheFinder = { collection: Object.freeze(Object.values(CARS_ROOT)) } -export const CARS_FINDER_OPTIONS: MangoFinderOptionsDTO = { +export const CARS_MANGO_OPTIONS: MangoFinderOptionsDTO = { cache: CARS_MOCK_CACHE as MangoFinderOptionsDTO['cache'], mingo: { idKey: CARS_UID } } diff --git a/src/abstracts/__tests__/__fixtures__/cars-repo.fixture.ts b/src/abstracts/__tests__/__fixtures__/cars-repo.fixture.ts new file mode 100644 index 0000000..091b352 --- /dev/null +++ b/src/abstracts/__tests__/__fixtures__/cars-repo.fixture.ts @@ -0,0 +1,40 @@ +import type { CreateEntityDTO, EntityDTO, PatchEntityDTO } from '@/dtos' +import type { UID } from '@/types' +import type { OneOrMany, OrPromise, Path } from '@flex-development/tutils' +import type { + CarParams, + CarQuery, + CarUID, + ICar +} from '@tests/fixtures/cars.fixture' +import AbstractMangoRepository from '../../mango-repo.abstract' + +/** + * @file Test Fixture - CarsRepo + * @module abstracts/tests/fixtures/cars-repo.fixture + */ + +export default class CarsRepo extends AbstractMangoRepository< + ICar, + CarUID, + CarParams, + CarQuery +> { + create>(dto: CreateEntityDTO): OrPromise { + throw new Error('Method not implemented') + } + + patch>( + uid: UID, + dto: PatchEntityDTO, + rfields?: string[] + ): OrPromise { + throw new Error('Method not implemented') + } + + save>( + dto: OneOrMany> + ): OrPromise { + throw new Error('Method not implemented') + } +} diff --git a/src/abstracts/__tests__/mango-finder.abstract.spec.ts b/src/abstracts/__tests__/mango-finder.abstract.spec.ts index f6d475b..e5699dd 100644 --- a/src/abstracts/__tests__/mango-finder.abstract.spec.ts +++ b/src/abstracts/__tests__/mango-finder.abstract.spec.ts @@ -12,9 +12,8 @@ import { PlainObject } from '@flex-development/exceptions/types' import type { ObjectPlain } from '@flex-development/tutils' import type { CarUID, ICar } from '@tests/fixtures/cars.fixture' import { - CARS_FINDER_OPTIONS as OPTIONS, - CARS_MOCK_CACHE_EMPTY, - CARS_UID + CARS_MANGO_OPTIONS as OPTIONS, + CARS_MOCK_CACHE_EMPTY } from '@tests/fixtures/cars.fixture' import faker from 'faker' import TestSubjectAbstract from '../mango-finder.abstract' @@ -38,8 +37,8 @@ describe('unit:abstracts/AbstractMangoFinder', () => { const MOPTIONS = OPTIONS.mingo as MingoOptions const DOCUMENT = COLLECTION[3] - const UID = DOCUMENT[CARS_UID] - const UIDS = [UID, COLLECTION[0][CARS_UID], COLLECTION[2][CARS_UID]] + const UID = DOCUMENT[MOPTIONS.idKey] + const UIDS = [UID, COLLECTION[0][MOPTIONS.idKey]] const FUID = `vin-0${faker.datatype.number(5)}` describe('constructor', () => { @@ -77,9 +76,11 @@ describe('unit:abstracts/AbstractMangoFinder', () => { } // Expect - expect(exception.code).toBe(ExceptionStatusCode.BAD_REQUEST) - expect(exception.data).toMatchObject({ pipeline: [] }) - expect(exception.message).toBe(error_message) + expect(exception.toJSON()).toMatchObject({ + code: ExceptionStatusCode.BAD_REQUEST, + data: { pipeline: [] }, + message: error_message + }) }) describe('runs pipeline', () => { @@ -140,7 +141,7 @@ describe('unit:abstracts/AbstractMangoFinder', () => { it('should handle query criteria', () => { // Arrange - const params = { [CARS_UID]: UID } + const params = { [MOPTIONS.idKey]: UID } // Act TestSubject.find(params, COLLECTION, MOPTIONS, mockMingo) @@ -152,7 +153,7 @@ describe('unit:abstracts/AbstractMangoFinder', () => { it('should sort results', () => { // Arrange - const options = { sort: { [CARS_UID]: SortOrder.ASCENDING } } + const options = { sort: { [MOPTIONS.idKey]: SortOrder.ASCENDING } } // Act TestSubject.find({ options }, COLLECTION, MOPTIONS, mockMingo) @@ -204,9 +205,11 @@ describe('unit:abstracts/AbstractMangoFinder', () => { } // Expect - expect(exception.code).toBe(ExceptionStatusCode.BAD_REQUEST) - expect(exception.data).toMatchObject({ params: {} }) - expect(exception.message).toBe(error_message) + expect(exception.toJSON()).toMatchObject({ + code: ExceptionStatusCode.BAD_REQUEST, + data: { params: {} }, + message: error_message + }) }) }) @@ -273,7 +276,7 @@ describe('unit:abstracts/AbstractMangoFinder', () => { it('should return document', () => { // Arrange - const eparams = { [CARS_UID]: UID } + const eparams = { [MOPTIONS.idKey]: UID } spy_find.mockReturnValueOnce([DOCUMENT] as ObjectPlain[]) // Act @@ -287,7 +290,7 @@ describe('unit:abstracts/AbstractMangoFinder', () => { it('should return null if document is not found', () => { // Arrange - const eparams = { [CARS_UID]: FUID } + const eparams = { [MOPTIONS.idKey]: FUID } spy_find.mockReturnValueOnce([]) // Act @@ -328,10 +331,12 @@ describe('unit:abstracts/AbstractMangoFinder', () => { } // Expect - expect(exception.code).toBe(ExceptionStatusCode.NOT_FOUND) - expect(exception.data).toMatchObject({ params: {} }) - expect((exception.errors as ObjectPlain)[CARS_UID]).toBe(FUID) - expect(exception.message).toMatch(new RegExp(`"${FUID}" does not exist`)) + expect(exception.toJSON()).toMatchObject({ + code: ExceptionStatusCode.NOT_FOUND, + data: { params: {} }, + errors: { [MOPTIONS.idKey]: FUID }, + message: `Document with ${MOPTIONS.idKey} "${FUID}" does not exist` + }) }) }) @@ -455,7 +460,7 @@ describe('unit:abstracts/AbstractMangoFinder', () => { const spy_findOne = jest.spyOn(TestSubjectAbstract, 'findOne') beforeEach(() => { - Subject.queryOne(Subject.cache.collection[0][CARS_UID]) + Subject.queryOne(Subject.cache.collection[0][MOPTIONS.idKey]) }) it('should call #mparser.params', () => { @@ -476,7 +481,7 @@ describe('unit:abstracts/AbstractMangoFinder', () => { beforeEach(() => { spy_findOneOrFail.mockReturnValueOnce(DOCUMENT as ObjectPlain) - Subject.queryOneOrFail(DOCUMENT[CARS_UID]) + Subject.queryOneOrFail(DOCUMENT[MOPTIONS.idKey]) }) it('should call #mparser.params', () => { @@ -517,7 +522,7 @@ describe('unit:abstracts/AbstractMangoFinder', () => { describe('#uid', () => { it('should return name of document uid field', () => { - expect(Subject.uid()).toBe(CARS_UID) + expect(Subject.uid()).toBe(MOPTIONS.idKey) }) }) }) diff --git a/src/abstracts/__tests__/mango-repo.abstract.spec.ts b/src/abstracts/__tests__/mango-repo.abstract.spec.ts new file mode 100644 index 0000000..bc488ab --- /dev/null +++ b/src/abstracts/__tests__/mango-repo.abstract.spec.ts @@ -0,0 +1,301 @@ +import type { CreateEntityDTO, MangoRepoOptionsDTO } from '@/dtos' +import type { MingoOptions } from '@/interfaces' +import { ExceptionStatusCode } from '@flex-development/exceptions/enums' +import Exception from '@flex-development/exceptions/exceptions/base.exception' +import type { ObjectPlain } from '@flex-development/tutils' +import type { CarUID, ICar } from '@tests/fixtures/cars.fixture' +import { + Car, + CARS_MANGO_OPTIONS as OPTIONS, + CARS_MOCK_CACHE_EMPTY, + CARS_ROOT as ROOT +} from '@tests/fixtures/cars.fixture' +import faker from 'faker' +import merge from 'lodash.merge' +import omit from 'lodash.omit' +import TestSubjectAbstract from '../mango-repo.abstract' +import TestSubject from './__fixtures__/cars-repo.fixture' + +/** + * @file Unit Tests - AbstractMangoRepository + * @module abstracts/tests/MangoRepository + */ + +const mockMerge = merge as jest.MockedFunction +const mockOmit = omit as jest.MockedFunction + +describe('unit:abstracts/AbstractMangoRepository', () => { + const Subject = new TestSubject(Car, OPTIONS) + + const CACHE = { collection: OPTIONS.cache?.collection as ICar[], root: ROOT } + const COLLECTION = CACHE.collection + const MOPTIONS = OPTIONS.mingo as MingoOptions + + const ENTITY = Object.assign({}, COLLECTION[0]) + const ENTITY_UID = ENTITY[MOPTIONS.idKey] + + describe('constructor', () => { + it('should initialize instance properties', () => { + expect(Subject.cache).toMatchObject(CACHE) + expect(Subject.options).toMatchObject({ mingo: MOPTIONS, validation: {} }) + expect(Subject.validator).toBeDefined() + }) + }) + + describe('.createCache', () => { + it('should return empty cache', () => { + // Act + const result = TestSubject.createCache(MOPTIONS.idKey) + + // Expect + expect(result).toMatchObject(CARS_MOCK_CACHE_EMPTY) + }) + + it('should return non-empty cache', () => { + // Act + const result = TestSubject.createCache( + MOPTIONS.idKey, + COLLECTION + ) + + // Expect + expect(result).toMatchObject(CACHE) + }) + }) + + describe('.delete', () => { + const UIDS = [ENTITY_UID, faker.datatype.string()] + const UIDS_EXPECTED = [ENTITY_UID] + + it('should throw if any entity does not exist but should', () => { + // Arrange + const should_exist = true + let exception = {} as Exception + + // Act + try { + TestSubject.delete(UIDS, should_exist, CACHE, MOPTIONS) + } catch (error) { + exception = error + } + + // Expect + expect(exception.toJSON()).toMatchObject({ + code: ExceptionStatusCode.NOT_FOUND, + data: { should_exist, uids: UIDS } + }) + }) + + it('should filter out uids of entities that do not exist', () => { + // Arrange + const should_exist = false + + // Act + const result = TestSubject.delete(UIDS, should_exist, CACHE, MOPTIONS) + + // Expect + expect(mockOmit).toBeCalledWith(CACHE.root, UIDS_EXPECTED) + expect(result.uids).toIncludeSameMembers(UIDS_EXPECTED) + }) + + it('should return object with new cache and deleted uids', () => { + // Arrange + const should_exist = false + + // Act + const result = TestSubject.delete( + ENTITY_UID, + should_exist, + CACHE, + MOPTIONS + ) + + // Expect + expect(mockOmit).toBeCalledWith(CACHE.root, UIDS_EXPECTED) + expect(result.cache[ENTITY_UID]).not.toBeDefined() + expect(result.uids).toIncludeSameMembers(UIDS_EXPECTED) + }) + }) + + describe('.formatCreateEntityDTO', () => { + it('should merge dto with formatted entity uid', () => { + // Arrange + const uid = `${faker.datatype.string()} ` + const dto = { [MOPTIONS.idKey]: uid } + const euid = uid.trim() + + // Act + const result = TestSubject.formatCreateEntityDTO( + dto as CreateEntityDTO, + COLLECTION, + MOPTIONS + ) + + // Expect + expect(mockMerge).toBeCalledTimes(1) + expect(mockMerge.mock.calls[0][1]).toMatchObject(dto) + expect(mockMerge.mock.calls[0][2]).toMatchObject({ + [MOPTIONS.idKey]: euid + }) + expect(result[MOPTIONS.idKey]).toBe(euid) + }) + + it('should throw Exception if entity uid conflict occurs', () => { + // Arrange + const dto = { [MOPTIONS.idKey]: ENTITY_UID } + let exception = {} as Exception + + // Act + try { + TestSubject.formatCreateEntityDTO( + dto as CreateEntityDTO, + COLLECTION, + MOPTIONS + ) + } catch (error) { + exception = error + } + + // Expect + expect(exception.toJSON()).toMatchObject({ + code: ExceptionStatusCode.CONFLICT, + data: { dto }, + errors: { [MOPTIONS.idKey]: ENTITY_UID }, + message: `Entity with ${MOPTIONS.idKey} "${ENTITY_UID}" already exists` + }) + }) + }) + + describe('.formatPatchEntityDTO', () => { + it('should call .findOneOrFail', () => { + // Arrange + // @ts-expect-error testing + const spy_findOneOrFail = jest.spyOn(TestSubjectAbstract, 'findOneOrFail') + + // Act + TestSubject.formatPatchEntityDTO(ENTITY_UID, {}, [], COLLECTION, MOPTIONS) + + // Expect + expect(spy_findOneOrFail).toBeCalledTimes(1) + expect(spy_findOneOrFail.mock.calls[0][0]).toBe(ENTITY_UID) + }) + + it('should remove readonly fields from dto', () => { + // Arrange + const dto = { [MOPTIONS.idKey]: faker.datatype.string(), foo: true } + const rfields = ['foo'] + + // Act + const result = TestSubject.formatPatchEntityDTO( + ENTITY_UID, + dto, + rfields, + COLLECTION, + MOPTIONS + ) + + // Expect + expect(result[MOPTIONS.idKey]).not.toBe(dto[MOPTIONS.idKey]) + expect((result as ObjectPlain).foo).not.toBeDefined() + }) + + it('should merge entity with formatted dto', () => { + // Arrange + const dto = { make: 'MAKE' } + + // Act + const result = TestSubject.formatPatchEntityDTO( + ENTITY_UID, + dto, + [], + COLLECTION, + MOPTIONS + ) + + // Expect + expect(result).toMatchObject(merge({}, ENTITY, dto)) + }) + }) + + describe('#clear', () => { + it('should call .createCache and clear repository', () => { + // Arrange + const spy_createCache = jest.spyOn(TestSubjectAbstract, 'createCache') + + // Act + Subject.clear() + + // Expect + expect(spy_createCache).toBeCalledTimes(1) + expect(spy_createCache).toBeCalledWith(MOPTIONS.idKey, []) + }) + + it('should return true when repository is cleared', () => { + // Act + const result = Subject.clear() + + // Expect + expect(result).toBeTrue() + }) + }) + + describe('#delete', () => { + it('should call .delete and remove entities from cache', () => { + // Arrange + const spy_delete = jest.spyOn(TestSubjectAbstract, 'delete') + const uid = COLLECTION[1][MOPTIONS.idKey] + + // Act + Subject.delete(uid) + + // Expect + expect(spy_delete).toBeCalledTimes(1) + expect(spy_delete).toBeCalledWith( + uid, + undefined, + Subject.cache, + Subject.options.mingo, + Subject.mingo + ) + }) + + it('should return array of deleted uids', () => { + // Act + const result = Subject.delete() + + // Expect + expect(result).toBeArray() + }) + }) + + describe('#setCache', () => { + const options: MangoRepoOptionsDTO = { + ...CARS_MOCK_CACHE_EMPTY, + mingo: MOPTIONS + } + + it('should call .createCache', () => { + // Arrange + const spy_createCache = jest.spyOn(TestSubjectAbstract, 'createCache') + const Subject = new TestSubject(Car, options) + + // Act + Subject.setCache(COLLECTION) + + // Expect + expect(spy_createCache).toBeCalledTimes(1) + expect(spy_createCache).toBeCalledWith(MOPTIONS.idKey, COLLECTION) + }) + + it('should return copy of new cache', () => { + // Arrange + const Subject = new TestSubject(Car, options) + + // Act + const result = Subject.setCache(COLLECTION) + + // Expect + expect(result).toMatchObject(CACHE) + }) + }) +}) diff --git a/src/abstracts/index.ts b/src/abstracts/index.ts index 5ee24b2..112637f 100644 --- a/src/abstracts/index.ts +++ b/src/abstracts/index.ts @@ -4,3 +4,4 @@ */ export { default as AbstractMangoFinder } from './mango-finder.abstract' +export { default as AbstractMangoRepository } from './mango-repo.abstract' diff --git a/src/abstracts/mango-repo.abstract.ts b/src/abstracts/mango-repo.abstract.ts new file mode 100644 index 0000000..51f466f --- /dev/null +++ b/src/abstracts/mango-repo.abstract.ts @@ -0,0 +1,471 @@ +import logger from '@/config/logger' +import MINGO from '@/config/mingo' +import type { + CreateEntityDTO, + EntityDTO, + MangoRepoOptionsDTO, + PatchEntityDTO +} from '@/dtos' +import type { + IAbstractMangoRepository, + IMangoValidator, + MangoCacheRepo, + MangoParserOptions, + MangoRepoOptions, + MangoValidatorOptions, + MingoOptions +} from '@/interfaces' +import MangoValidator from '@/mixins/mango-validator.mixin' +import type { + DocumentArray, + DUID, + MangoParsedUrlQuery, + MangoSearchParams, + RepoRoot, + UID +} from '@/types' +import { ExceptionStatusCode } from '@flex-development/exceptions/enums' +import Exception from '@flex-development/exceptions/exceptions/base.exception' +import type { + ObjectPlain, + ObjectUnknown, + OneOrMany, + OrPromise, + Path +} from '@flex-development/tutils' +import type { ClassType } from 'class-transformer-validator' +import type { Debugger } from 'debug' +import merge from 'lodash.merge' +import omit from 'lodash.omit' +import uniq from 'lodash.uniq' +import { v4 as uuid } from 'uuid' +import AbstractMangoFinder from './mango-finder.abstract' + +/** + * @file Abstract Classes - AbstractMangoRepository + * @module abstracts/MangoRepository + */ + +/** + * Repository API for in-memory object collections. + * + * This class is used to inject common functionality into the `MangoRepository` + * and `MangoRepositoryAsync` classes. + * + * @template E - Entity + * @template U - Name of entity uid field + * @template P - Repository search parameters (query criteria and options) + * @template Q - Parsed URL query object + * + * @abstract + * @class + * @extends AbstractMangoFinder + * @implements {IAbstractMangoRepository} + */ +export default abstract class AbstractMangoRepository< + E extends ObjectPlain = ObjectUnknown, + U extends string = DUID, + P extends MangoSearchParams = MangoSearchParams, + Q extends MangoParsedUrlQuery = MangoParsedUrlQuery + > + extends AbstractMangoFinder + implements IAbstractMangoRepository { + /** + * @readonly + * @instance + * @property {MangoCacheRepo} cache - Repository data cache + */ + readonly cache: MangoCacheRepo + + /** + * @readonly + * @instance + * @property {Debugger} logger - Internal logger + */ + readonly logger: Debugger = logger.extend('repo') + + /** + * @readonly + * @instance + * @property {MangoRepoOptions} options - Repository options + */ + readonly options: MangoRepoOptions + + /** + * @readonly + * @instance + * @property {IMangoValidator} validator - Repository Validation API client + */ + readonly validator: IMangoValidator + + /** + * Creates a new in-memory repository. + * + * See: + * + * - https://github.com/pleerock/class-validator + * - https://github.com/typestack/class-transformer + * - https://github.com/MichalLytek/class-transformer-validator + * + * @param {ClassType} model - Entity model + * @param {MangoRepoOptionsDTO} [options] - Repository options + * @param {MingoOptions} [options.mingo] - Global mingo options + * @param {MangoParserOptions} [options.parser] - MangoParser options + * @param {MangoValidatorOptions} [options.validation] - Validation options + */ + constructor(model: ClassType, options: MangoRepoOptionsDTO = {}) { + super(options) + + const cache = merge({}, this.cache, { root: {} }) + + if (cache.collection.length) { + cache.collection.forEach(entity => { + cache.root[entity[this.options.mingo.idKey]] = entity + }) + } + + this.cache = Object.freeze(cache) + this.validator = new MangoValidator(model, options.validation) + this.options = merge(this.options, { validation: this.validator.tvo }) + } + + /** + * Creates a repository cache. + * + * @template AE - Entity + * @template AU - Name of entity uid field + * + * @param {AU} uid - Name of entity uid field + * @param {DocumentArray} [collection] - Entities to insert into cache + * @return {MangoCacheRepo} New repository cache + * @throws {Exception} + */ + static createCache< + AE extends ObjectPlain = ObjectUnknown, + AU extends string = DUID + >(uid: AU, collection: DocumentArray = []): MangoCacheRepo { + // Init new root + const root: RepoRoot = {} + + // Copy collection + const entities = Object.assign([], collection) + + try { + // Add entities to new root + if (entities.length) entities.forEach(e => (root[e[uid]] = e)) + + return { collection: Object.freeze(Object.values(root)), root } + } catch (error) { + if (error.constructor.name === 'Exception') throw error + + const code = ExceptionStatusCode.INTERNAL_SERVER_ERROR + const { message, stack } = error + + throw new Exception(code, message, { collection, root }, stack) + } + } + + /** + * Deletes a single entity or group of entities. + * + * If {@param should_exist} is `true`, a `404 NOT_FOUND` error will be thrown + * if the entity or one of the entities doesn't exist. + * + * @template AE - Entity + * @template AU - Name of entity uid field + * @template AP - Repository search parameters (query criteria and options) + * + * @param {OneOrMany} [uid] - Entity uid or array of uids + * @param {boolean} [should_exist] - Throw if any entities don't exist + * @param {MangoCacheRepo} [cache] - Current cache + * @param {MingoOptions} [mingo_options] - `mingo` options + * @param {typeof MINGO} [mingo] - MongoDB query language client + * @return {{ cache: MangoCacheRepo, uids: UID[]}} New cache and uid array + * @throws {Exception} + */ + static delete< + AE extends ObjectPlain = ObjectUnknown, + AU extends string = DUID, + AP extends MangoSearchParams = MangoSearchParams + >( + uid: OneOrMany = [], + should_exist: boolean = false, + cache: MangoCacheRepo = { collection: [], root: {} }, + mingo_options: MingoOptions = { idKey: 'id' as AU }, + mingo: typeof MINGO = MINGO + ): { cache: MangoCacheRepo; uids: UID[] } { + let uids = Array.isArray(uid) ? uid : [uid] + + try { + if (uids.length) { + // Check if all entities exist or filter out non-existent entities + if (should_exist) { + uids.forEach(uid => { + return AbstractMangoRepository.findOneOrFail( + uid, + {} as AP, + cache.collection, + mingo_options, + mingo + ) + }) + } else { + uids = uids.filter(uid => { + return AbstractMangoRepository.findOne( + uid, + {} as AP, + cache.collection, + mingo_options, + mingo + ) + }) + } + } + + return { + cache: AbstractMangoRepository.createCache( + mingo_options.idKey, + Object.values(omit(Object.assign({}, cache.root), uids)) as AE[] + ), + uids + } + } catch (error) { + /* eslint-disable-next-line sort-keys */ + const data = { uids, should_exist } + + if (error.constructor.name === 'Exception') { + error.data = merge(error.data, data) + throw error + } + + const code = ExceptionStatusCode.INTERNAL_SERVER_ERROR + const { message, stack } = error + + throw new Exception(code, message, data, stack) + } + } + + /** + * Formats the data used to create a new entity. Data is **not** validated. + * + * If the entity does is missing a uid, it will be assigned a random string + * using the [uuid][1] module. + * + * Throws a `409 CONFLICT` error if an entity with the same uid exists. + * + * @template AF - Object field paths of `dto` + * @template AE - Entity + * @template AU - Name of entity uid field + * @template AP - Repository search parameters (query criteria and options) + * + * @param {CreateEntityDTO} dto - Data to create new entity + * @param {DocumentArray} [collection] - Document collection + * @param {MingoOptions} [mingo_options] - `mingo` options + * @param {typeof MINGO} [mingo] - MongoDB query language client + * @return {AE} Formatted `dto` casted as entity + */ + static formatCreateEntityDTO< + AF extends Path, + AE extends ObjectPlain = ObjectUnknown, + AU extends string = DUID, + AP extends MangoSearchParams = MangoSearchParams + >( + dto: CreateEntityDTO, + collection: DocumentArray = [], + mingo_options: MingoOptions = { idKey: 'id' as AU }, + mingo: typeof MINGO = MINGO + ): AE { + const euid = mingo_options.idKey + + try { + // Get entity uid + let uid = dto[euid as string] + if (typeof uid === 'string') uid = uid.trim() + + // Assign uid if uid is missing or empty string + if (!uid || uid === '') uid = uuid() + + // Merge dto with formatted entity uid + const data = merge({}, dto, { [euid]: uid }) as AE + + // Check if another entity with the same uid already exists + const existing_entity = AbstractMangoRepository.findOne( + uid, + {} as AP, + collection, + mingo_options, + mingo + ) + + // Throw 409 CONFLICT error if existing entity is found + if (existing_entity) { + const uidstr = typeof uid === 'number' ? uid : `"${uid}"` + + const message = `Entity with ${euid} ${uidstr} already exists` + const edata = { dto: data, errors: { [euid]: uid } } + + throw new Exception(ExceptionStatusCode.CONFLICT, message, edata) + } + + return data + } catch (error) { + if (error.constructor.name === 'Exception') throw error + + const code = ExceptionStatusCode.INTERNAL_SERVER_ERROR + const { message, stack } = error + + throw new Exception(code, message, { dto }, stack) + } + } + + /** + * Formats the data used to patch an entity. Data is **not** validated. + * + * The entity's uid field property cannot be updated. + * + * Throws if the entity isn't found. + * + * @template AF - Object field paths of `dto` + * @template AE - Entity + * @template AU - Name of entity uid field + * @template AP - Repository search parameters (query criteria and options) + * + * @param {UID} uid - Entity uid + * @param {PatchEntityDTO} dto - Data to patch entity + * @param {string[]} [rfields] - Additional readonly fields + * @param {DocumentArray} [collection] - Document collection + * @param {MingoOptions} [mingo_options] - `mingo` options + * @param {typeof MINGO} [mingo] - MongoDB query language client + * @return {AE} Formatted `dto` casted as entity + * @throws {Exception} + */ + static formatPatchEntityDTO< + AF extends Path, + AE extends ObjectPlain = ObjectUnknown, + AU extends string = DUID, + AP extends MangoSearchParams = MangoSearchParams + >( + uid: UID, + dto: PatchEntityDTO, + rfields: string[] = [], + collection: DocumentArray = [], + mingo_options: MingoOptions = { idKey: 'id' as AU }, + mingo: typeof MINGO = MINGO + ): AE { + // Make sure entity exists + const entity = AbstractMangoRepository.findOneOrFail( + uid, + {} as AP, + collection, + mingo_options, + mingo + ) + + try { + // Get readonly properties + rfields = uniq([mingo_options.idKey as string].concat(rfields)) + + // Return entity merged with dto + return merge({}, entity, { ...omit(dto, rfields) }) as AE + } catch (error) { + const code = ExceptionStatusCode.INTERNAL_SERVER_ERROR + const { message, stack } = error + + /* eslint-disable-next-line sort-keys */ + throw new Exception(code, message, { uid, dto, rfields }, stack) + } + } + + /** + * Clears all data from the repository. + * + * @return {OrPromise} `true` when data is cleared + */ + clear(): OrPromise { + const uid = this.options.mingo.idKey + + // @ts-expect-error updating caches (mango plugin and repository) + this.cache = AbstractMangoRepository.createCache(uid, []) + + return true + } + + /** + * Deletes a single entity or group of entities. + * + * If {@param should_exist} is `true`, a `404 NOT_FOUND` error will be thrown + * if the entity or one of the entities doesn't exist. + * + * @param {OneOrMany} [uid] - Entity uid or array of uids + * @param {boolean} [should_exist] - Throw if any entities don't exist + * @return {OrPromise} Array of uids + * @throws {Exception} + */ + delete(uid?: OneOrMany, should_exist?: boolean): OrPromise { + // Delete entities and create new cache + const { cache, uids } = AbstractMangoRepository.delete( + uid, + should_exist, + this.cache, + this.options.mingo, + this.mingo + ) + + // @ts-expect-error updating caches (mango plugin and repository) + this.cache = cache + + // Return entities that were deleted + return uids + } + + /** + * Updates the repository's the data cache. + * + * @param {E[]} [collection] - Entities to insert into cache + * @return {OrPromise>} Copy of updated repository cache + * @throws {Exception} + */ + setCache(collection?: E[]): OrPromise> { + // @ts-expect-error resetting caches (mango plugin and repository) + this.cache = AbstractMangoRepository.createCache( + this.options.mingo.idKey, + collection + ) + + return Object.assign({}, this.cache) + } + + /** + * @abstract + * @template F - Object field paths of `dto` + * @param {CreateEntityDTO} dto - Data to create new entity + * @return {OrPromise} New entity + * @throws {Exception} + */ + abstract create>(dto: CreateEntityDTO): OrPromise + + /** + * @abstract + * @template F - Object field paths of `dto` + * @param {UID} uid - Entity uid + * @param {PatchEntityDTO} dto - Data to patch entity + * @param {string[]} [rfields] - Additional readonly fields + * @return {Promise} Updated entity + * @throws {Exception} + */ + abstract patch>( + uid: UID, + dto: PatchEntityDTO, + rfields?: string[] + ): OrPromise + + /** + * @abstract + * @template F - Object field paths of `dto` + * @param {OneOrMany>} dto - Entities to upsert + * @return {Promise} New or updated entities + */ + abstract save>( + dto: OneOrMany> + ): OrPromise +} diff --git a/src/interfaces/abstract-mango-repo.interface.ts b/src/interfaces/abstract-mango-repo.interface.ts index 5b98f74..6aace77 100644 --- a/src/interfaces/abstract-mango-repo.interface.ts +++ b/src/interfaces/abstract-mango-repo.interface.ts @@ -40,7 +40,7 @@ export interface IAbstractMangoRepository< clear(): OrPromise create>(dto: CreateEntityDTO): OrPromise - delete(uid: OneOrMany, should_exist?: boolean): OrPromise + delete(uid?: OneOrMany, should_exist?: boolean): OrPromise patch>( uid: UID, dto: PatchEntityDTO, diff --git a/src/mixins/__tests__/mango-parser.mixin.spec.ts b/src/mixins/__tests__/mango-parser.mixin.spec.ts index 2f8ae69..470d160 100644 --- a/src/mixins/__tests__/mango-parser.mixin.spec.ts +++ b/src/mixins/__tests__/mango-parser.mixin.spec.ts @@ -85,8 +85,10 @@ describe('unit:mixins/MangoParser', () => { } // Expect - expect(exception.code).toBe(ExceptionStatusCode.BAD_REQUEST) - expect(exception.data).toMatchObject({ parser_options: {}, query }) + expect(exception.toJSON()).toMatchObject({ + code: ExceptionStatusCode.BAD_REQUEST, + data: { parser_options: {}, query } + }) }) }) diff --git a/src/mixins/__tests__/mango-validator.mixin.spec.ts b/src/mixins/__tests__/mango-validator.mixin.spec.ts index 96167c3..b6f9614 100644 --- a/src/mixins/__tests__/mango-validator.mixin.spec.ts +++ b/src/mixins/__tests__/mango-validator.mixin.spec.ts @@ -116,13 +116,12 @@ describe('unit:mixins/MangoValidator', () => { const exception = Subject.handleError(ERROR) // Expect - expect(exception.code).toBe(ExceptionStatusCode.INTERNAL_SERVER_ERROR) - expect(exception.message).toBe(ERROR.message) - expect(exception.data).toMatchObject({ - model_name: Subject.model_name, - options: Subject.tvo + expect(exception).toMatchObject({ + code: ExceptionStatusCode.INTERNAL_SERVER_ERROR, + data: { model_name: Subject.model_name, options: Subject.tvo }, + errors: null, + message: ERROR.message }) - expect(exception.errors).toBeNull() }) it('should convert ValidationError[] into Exception', () => { @@ -133,14 +132,14 @@ describe('unit:mixins/MangoValidator', () => { const exception = Subject.handleError(VALIDATION_ERRORS) // Expect - expect(exception.code).toBe(ExceptionStatusCode.BAD_REQUEST) + expect(exception.message).toMatch(new RegExp(mpattern)) - expect(exception.data).toMatchObject({ - model_name: Subject.model_name, - options: Subject.tvo - }) expect(exception.errors).toBeArray() expect(exception.errors).toIncludeSameMembers(VALIDATION_ERRORS) + expect(exception).toMatchObject({ + code: ExceptionStatusCode.BAD_REQUEST, + data: { model_name: Subject.model_name, options: Subject.tvo } + }) }) }) }) diff --git a/src/plugins/__tests__/mango-finder-async.plugin.spec.ts b/src/plugins/__tests__/mango-finder-async.plugin.spec.ts index 1482acd..13e3e91 100644 --- a/src/plugins/__tests__/mango-finder-async.plugin.spec.ts +++ b/src/plugins/__tests__/mango-finder-async.plugin.spec.ts @@ -5,10 +5,7 @@ import type { CarUID, ICar } from '@tests/fixtures/cars.fixture' -import { - CARS_FINDER_OPTIONS as OPTIONS, - CARS_UID -} from '@tests/fixtures/cars.fixture' +import { CARS_MANGO_OPTIONS as OPTIONS } from '@tests/fixtures/cars.fixture' import TestSubject from '../mango-finder-async.plugin' /** @@ -23,7 +20,7 @@ describe('unit:plugins/MangoFinderAsync', () => { const Subject = new TestSubject(OPTIONS) const DOCUMENT = Object.assign({}, Subject.cache.collection[0]) - const UID = DOCUMENT[CARS_UID] + const UID = DOCUMENT[OPTIONS.mingo?.idKey as string] describe('#aggregate', () => { it('should run aggregation pipeline', async () => { diff --git a/src/plugins/__tests__/mango-finder.plugin.spec.ts b/src/plugins/__tests__/mango-finder.plugin.spec.ts index ebe3bde..c3eda0c 100644 --- a/src/plugins/__tests__/mango-finder.plugin.spec.ts +++ b/src/plugins/__tests__/mango-finder.plugin.spec.ts @@ -5,10 +5,7 @@ import type { CarUID, ICar } from '@tests/fixtures/cars.fixture' -import { - CARS_FINDER_OPTIONS as OPTIONS, - CARS_UID -} from '@tests/fixtures/cars.fixture' +import { CARS_MANGO_OPTIONS as OPTIONS } from '@tests/fixtures/cars.fixture' import TestSubject from '../mango-finder.plugin' /** @@ -23,7 +20,7 @@ describe('unit:plugins/MangoFinder', () => { const Subject = new TestSubject(OPTIONS) const DOCUMENT = Object.assign({}, Subject.cache.collection[0]) - const UID = DOCUMENT[CARS_UID] + const UID = DOCUMENT[OPTIONS.mingo?.idKey as string] describe('#aggregate', () => { it('should run aggregation pipeline', () => { diff --git a/src/types/index.ts b/src/types/index.ts index 7ec51bb..0f6f121 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -4,7 +4,7 @@ */ export * from './document.types' -export * from './mango-finder-plugin.types' +export * from './mango-finder.types' export * from './mingo.types' export * from './repository.types' export * from './utils.types' diff --git a/src/types/mango-finder-plugin.types.ts b/src/types/mango-finder.types.ts similarity index 96% rename from src/types/mango-finder-plugin.types.ts rename to src/types/mango-finder.types.ts index 12da766..7a865e7 100644 --- a/src/types/mango-finder-plugin.types.ts +++ b/src/types/mango-finder.types.ts @@ -5,7 +5,7 @@ import type { QueryCriteria } from './mingo.types' /** * @file Type Definitions - MangoFinder - * @module types/mango-plugin + * @module types/mango-finder */ /**