diff --git a/.vscode/launch.json b/.vscode/launch.json index 53d3f9bd..91cad345 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,11 +4,11 @@ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ + { "type": "node", "request": "launch", "name": "Launch current file w/ ts-node", - "protocol": "inspector", "args": ["${relativeFile}"], "cwd": "${workspaceRoot}", "runtimeArgs": ["-r", "ts-node/register"], @@ -26,8 +26,32 @@ "--verbose" ], "console": "integratedTerminal", - "internalConsoleOptions": "neverOpen", - "port": 9229 + "internalConsoleOptions": "neverOpen" + }, + { + "type": "node", + "request": "launch", + "name": "Debug Jest Tests", + "program": "${workspaceRoot}/node_modules/.bin/jest", + "args": [ + "--runInBand" + ], + "runtimeArgs": [ + "--inspect-brk" + ], + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen" + }, + { + "type": "node", + "request": "launch", + "name": "Debug Current Jest Unit Test", + "program": "${workspaceRoot}/node_modules/.bin/jest", + "args": ["${file}"], + "runtimeArgs": ["--inspect-brk"], + "cwd": "${workspaceRoot}", + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen" } ] } diff --git a/src/AbstractFirestoreRepository.spec.ts b/src/AbstractFirestoreRepository.spec.ts new file mode 100644 index 00000000..66ac62bc --- /dev/null +++ b/src/AbstractFirestoreRepository.spec.ts @@ -0,0 +1,289 @@ +import { CollectionReference, Transaction } from '@google-cloud/firestore'; +import { getMetadataStorage } from './MetadataUtils'; +import { FullCollectionMetadata, MetadataStorageConfig } from './MetadataStorage'; +import { NoFirestoreError, NoMetadataError, NoParentPropertyKeyError } from './Errors'; +import { IEntity } from './types'; +import { AbstractFirestoreRepository } from './AbstractFirestoreRepository'; +import { FirestoreTransaction } from './Transaction/FirestoreTransaction'; + +jest.mock('./MetadataUtils'); +jest.mock('./helpers'); +jest.mock('./Transaction/FirestoreTransaction'); + +interface FirestoreGeoPoint { + latitude: number; + longitude: number; +} + +interface FirestoreDocumentReference { + id: string; + path: string; +} + +interface FirestoreData { + timestampField: { toDate: () => Date }; + geoPointField: FirestoreGeoPoint; + documentReferenceField: FirestoreDocumentReference; + nestedObject: { + timestampField: { toDate: () => Date }; + }; +} + +interface TransformedData { + timestampField: Date; + geoPointField: { latitude: number; longitude: number }; + documentReferenceField: { id: string; path: string }; + nestedObject: { + timestampField: Date; + }; +} + +class TestEntity implements IEntity { + id!: string; +} + +describe('AbstractFirestoreRepository', () => { + let collectionRefMock: jest.Mocked; + let getCollectionMock: jest.Mock; + let firestoreRefMock: any; + let getRepositoryMock: jest.Mock; + let firestoreTransactionMock: jest.Mocked; + + beforeEach(() => { + collectionRefMock = { + doc: jest.fn().mockReturnThis(), + collection: jest.fn().mockReturnThis(), + add: jest.fn().mockResolvedValue({ id: 'new-id' }), + } as unknown as jest.Mocked; + + getCollectionMock = jest.fn(); + firestoreRefMock = { + collection: jest.fn().mockReturnValue(collectionRefMock), + }; + + (getMetadataStorage as jest.Mock).mockReturnValue({ + getCollection: getCollectionMock, + config: {} as MetadataStorageConfig, + firestoreRef: firestoreRefMock, + }); + + getRepositoryMock = jest.fn(); + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const helpers = require('./helpers'); + helpers.getRepository = getRepositoryMock; + getRepositoryMock.mockReturnValue({ someMethod: jest.fn() }); + + firestoreTransactionMock = { + getRepository: jest.fn().mockReturnValue({ someMethod: jest.fn() }), + } as unknown as jest.Mocked; + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const FirestoreTransaction = require('./Transaction/FirestoreTransaction'); + (FirestoreTransaction.FirestoreTransaction as jest.Mock).mockImplementation( + () => firestoreTransactionMock + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.resetModules(); + }); + + class TestRepository extends AbstractFirestoreRepository { + execute = jest.fn(); + findById = jest.fn(); + create = jest.fn(); + update = jest.fn(); + delete = jest.fn(); + + // Expose the protected methods for testing + public transformTypes(obj: FirestoreData): TransformedData { + const transformed = this.transformFirestoreTypes(obj as unknown as Record); + return transformed as unknown as TransformedData; + } + + public initializeSubCollectionsPublic( + entity: TestEntity, + tran?: Transaction, + tranRefStorage?: any + ) { + return this.initializeSubCollections(entity, tran, tranRefStorage); + } + } + + describe('Constructor', () => { + it('should throw NoFirestoreError if firestoreRef is not set', () => { + (getMetadataStorage as jest.Mock).mockReturnValueOnce({ + getCollection: getCollectionMock, + config: {} as MetadataStorageConfig, + firestoreRef: undefined, + }); + + expect(() => new TestRepository('path', 'TestEntity')).toThrow(NoFirestoreError); + }); + + it('should throw NoMetadataError if no Metadata is not found for the specified collection', () => { + getCollectionMock.mockReturnValueOnce(undefined); + + expect(() => new TestRepository('path', 'TestEntity')).toThrow(NoMetadataError); + }); + + it('should initialize class properties correctly', () => { + const colMetadataMock = { + entityConstructor: TestEntity, + name: 'TestEntity', + segments: ['TestEntity'], + parentProps: null, + subCollections: [], + } as FullCollectionMetadata; + + getCollectionMock.mockReturnValueOnce(colMetadataMock); + + const repository = new TestRepository('path', 'TestEntity'); + + expect((repository as any).colMetadata).toBe(colMetadataMock); + expect((repository as any).path).toBe('path'); + expect((repository as any).name).toBe('TestEntity'); + expect((repository as any).firestoreColRef).toBe(collectionRefMock); + }); + }); + + describe('transformFirestoreTypes', () => { + it('should transform Firestore types correctly', () => { + const colMetadataMock = { + entityConstructor: TestEntity, + name: 'TestEntity', + segments: ['TestEntity'], + parentProps: null, + subCollections: [], + } as FullCollectionMetadata; + + getCollectionMock.mockReturnValueOnce(colMetadataMock); + + const repository = new TestRepository('path', 'TestEntity'); + + const firestoreData: FirestoreData = { + timestampField: { + toDate: () => new Date('2020-01-01T00:00:00Z'), + }, + geoPointField: { + latitude: 10, + longitude: 20, + }, + documentReferenceField: { + id: 'docId', + path: 'path/to/doc', + }, + nestedObject: { + timestampField: { + toDate: () => new Date('2020-01-01T00:00:00Z'), + }, + }, + }; + + // Explicitly cast the transformed data to the correct type + const transformedData = repository.transformTypes(firestoreData); + + expect(transformedData.timestampField).toEqual(new Date('2020-01-01T00:00:00Z')); + expect(transformedData.geoPointField).toEqual({ latitude: 10, longitude: 20 }); + expect(transformedData.documentReferenceField).toEqual({ id: 'docId', path: 'path/to/doc' }); + expect(transformedData.nestedObject.timestampField).toEqual(new Date('2020-01-01T00:00:00Z')); + }); + }); + + describe('initializeSubCollections', () => { + it('should initialize subcollections correctly', () => { + const colMetadataMock = { + entityConstructor: TestEntity, + name: 'TestEntity', + segments: ['TestEntity'], + parentProps: null, + subCollections: [ + { + name: 'subCollection', + parentProps: { + parentPropertyKey: 'subCollectionRepository', + }, + }, + ], + } as FullCollectionMetadata; + + getCollectionMock.mockReturnValueOnce(colMetadataMock); + + const repository = new TestRepository('path', 'TestEntity'); + + const entity = new TestEntity(); + entity.id = 'entityId'; + + repository.initializeSubCollectionsPublic(entity); + + expect((entity as any).subCollectionRepository).toBeDefined(); + expect(getRepositoryMock).toHaveBeenCalledWith('path/entityId/subCollection'); + }); + + it('should throw NoParentPropertyKeyError if parentPropertyKey is not defined', () => { + const colMetadataMock = { + entityConstructor: TestEntity, + name: 'TestEntity', + segments: ['TestEntity'], + parentProps: null, + subCollections: [ + { + name: 'subCollection', + parentProps: null, + }, + ], + } as FullCollectionMetadata; + + getCollectionMock.mockReturnValueOnce(colMetadataMock); + + const repository = new TestRepository('path', 'TestEntity'); + + const entity = new TestEntity(); + entity.id = 'entityId'; + + expect(() => repository.initializeSubCollectionsPublic(entity)).toThrow( + NoParentPropertyKeyError + ); + }); + + it('should initialize subcollections correctly within a transaction', () => { + const colMetadataMock = { + entityConstructor: TestEntity, + name: 'TestEntity', + segments: ['TestEntity'], + parentProps: null, + subCollections: [ + { + name: 'subCollection', + parentProps: { + parentPropertyKey: 'subCollectionRepository', + }, + }, + ], + } as FullCollectionMetadata; + + getCollectionMock.mockReturnValueOnce(colMetadataMock); + + const repository = new TestRepository('path', 'TestEntity'); + + const entity = new TestEntity(); + entity.id = 'entityId'; + + const tranRefStorageMock = { add: jest.fn() }; + + repository.initializeSubCollectionsPublic(entity, {} as Transaction, tranRefStorageMock); + + expect((entity as any).subCollectionRepository).toBeDefined(); + expect(firestoreTransactionMock.getRepository).toHaveBeenCalledWith( + 'path/entityId/subCollection' + ); + expect(tranRefStorageMock.add).toHaveBeenCalledWith({ + parentPropertyKey: 'subCollectionRepository', + path: 'path/entityId/subCollection', + entity, + }); + }); + }); +}); diff --git a/src/AbstractFirestoreRepository.ts b/src/AbstractFirestoreRepository.ts index 3268013b..fe274fe5 100644 --- a/src/AbstractFirestoreRepository.ts +++ b/src/AbstractFirestoreRepository.ts @@ -31,7 +31,7 @@ import { MetadataStorageConfig, FullCollectionMetadata } from './MetadataStorage import { BaseRepository } from './BaseRepository'; import { QueryBuilder } from './QueryBuilder'; import { serializeEntity } from './utils'; -import { NoMetadataError } from './Errors'; +import { NoFirestoreError, NoMetadataError, NoParentPropertyKeyError } from './Errors'; export abstract class AbstractFirestoreRepository extends BaseRepository @@ -39,27 +39,29 @@ export abstract class AbstractFirestoreRepository { protected readonly colMetadata: FullCollectionMetadata; protected readonly path: string; + protected readonly name: string; // TODO: Is this used? protected readonly config: MetadataStorageConfig; protected readonly firestoreColRef: CollectionReference; - constructor(pathOrConstructor: string | IEntityConstructor) { + constructor(pathOrConstructor: string | IEntityConstructor, colName: string) { super(); const { getCollection, config, firestoreRef } = getMetadataStorage(); if (!firestoreRef) { - throw new Error('Firestore must be initialized first'); + throw new NoFirestoreError('new AbstractFirestoreRepository()'); } this.config = config; - const colMetadata = getCollection(pathOrConstructor); + const colMetadata = getCollection(pathOrConstructor, colName); if (!colMetadata) { - throw new NoMetadataError(pathOrConstructor); + throw new NoMetadataError(colName); } this.colMetadata = colMetadata; this.path = typeof pathOrConstructor === 'string' ? pathOrConstructor : this.colMetadata.name; + this.name = colName; this.firestoreColRef = firestoreRef.collection(this.path); } @@ -98,19 +100,23 @@ export abstract class AbstractFirestoreRepository this.colMetadata.subCollections.forEach(subCol => { const pathWithSubCol = `${this.path}/${entity.id}/${subCol.name}`; - const { propertyKey } = subCol; + const parentPropertyKey = subCol.parentProps?.parentPropertyKey; + + if (!parentPropertyKey) { + throw new NoParentPropertyKeyError(entity); + } // If we are inside a transaction, our subcollections should also be TransactionRepositories if (tran && tranRefStorage) { const firestoreTransaction = new FirestoreTransaction(tran, tranRefStorage); const repository = firestoreTransaction.getRepository(pathWithSubCol); - tranRefStorage.add({ propertyKey, path: pathWithSubCol, entity }); + tranRefStorage.add({ parentPropertyKey, path: pathWithSubCol, entity }); Object.assign(entity, { - [propertyKey]: repository, + [parentPropertyKey]: repository, }); } else { Object.assign(entity, { - [propertyKey]: getRepository(pathWithSubCol), + [parentPropertyKey]: getRepository(pathWithSubCol), }); } }); @@ -155,9 +161,10 @@ export abstract class AbstractFirestoreRepository tran?: Transaction, tranRefStorage?: ITransactionReferenceStorage ): T => { + const transformedData = this.transformFirestoreTypes(doc.data() || {}); const entity = plainToClass(this.colMetadata.entityConstructor, { id: doc.id, - ...this.transformFirestoreTypes(doc.data() || {}), + ...transformedData, }) as T; this.initializeSubCollections(entity, tran, tranRefStorage); diff --git a/src/BaseFirestoreRepository.spec.ts b/src/BaseFirestoreRepository.spec.ts index ac75d3ff..f6bfca05 100644 --- a/src/BaseFirestoreRepository.spec.ts +++ b/src/BaseFirestoreRepository.spec.ts @@ -18,8 +18,8 @@ const MockFirebase = require('mock-cloud-firestore'); describe('BaseFirestoreRepository', () => { class BandRepository extends BaseFirestoreRepository {} - let bandRepository: BaseFirestoreRepository = null; - let firestore: Firestore = null; + let bandRepository: BaseFirestoreRepository; + let firestore: Firestore; beforeEach(() => { const fixture = Object.assign({}, getFixture()); @@ -30,24 +30,19 @@ describe('BaseFirestoreRepository', () => { firestore = firebase.firestore(); initialize(firestore); - bandRepository = new BandRepository('bands'); + bandRepository = new BandRepository(Band, 'bands'); }); describe('constructor', () => { + // TODO: test with a different path it('should correctly initialize a repository with custom path', async () => { - const bandRepositoryWithPath = new BandRepository('bands'); + const bandRepositoryWithPath = new BandRepository(Band, 'bands'); const band = await bandRepositoryWithPath.findById('porcupine-tree'); - expect(band.name).toEqual('Porcupine Tree'); - }); - - it('should correctly initialize a repository with an entity', async () => { - const bandRepositoryWithPath = new BandRepository(Band); - const band = await bandRepositoryWithPath.findById('porcupine-tree'); - expect(band.name).toEqual('Porcupine Tree'); + expect(band?.name).toEqual('Porcupine Tree'); }); it('should throw error if initialized with an invalid path', async () => { - expect(() => new BandRepository('invalidpath')).toThrowError( + expect(() => new BandRepository(Band, 'invalidpath')).toThrowError( new NoMetadataError('invalidpath') ); }); @@ -77,8 +72,8 @@ describe('BaseFirestoreRepository', () => { it('must limit subcollections', async () => { const pt = await bandRepository.findById('porcupine-tree'); - const albums = await pt.albums.limit(2).find(); - expect(albums.length).toEqual(2); + const albums = await pt?.albums?.limit(2).find(); + expect(albums?.length).toEqual(2); }); it('must throw an exception if limit call more than once', async () => { @@ -98,19 +93,23 @@ describe('BaseFirestoreRepository', () => { it('must order the objects in a subcollection', async () => { const pt = await bandRepository.findById('porcupine-tree'); - const albumsSubColl = pt.albums; - const discographyNewestFirst = await albumsSubColl.orderByAscending('releaseDate').find(); - expect(discographyNewestFirst[0].id).toEqual('lightbulb-sun'); + const albumsSubColl = pt?.albums; + const discographyNewestFirst = await albumsSubColl?.orderByAscending('releaseDate').find(); + if (discographyNewestFirst) { + expect(discographyNewestFirst[0].id).toEqual('lightbulb-sun'); + } }); it('must be chainable with where* filters', async () => { const pt = await bandRepository.findById('porcupine-tree'); - const albumsSubColl = pt.albums; + const albumsSubColl = pt?.albums; const discographyNewestFirst = await albumsSubColl - .whereGreaterOrEqualThan('releaseDate', new Date('2001-01-01')) + ?.whereGreaterOrEqualThan('releaseDate', new Date('2001-01-01')) .orderByAscending('releaseDate') .find(); - expect(discographyNewestFirst[0].id).toEqual('in-absentia'); + if (discographyNewestFirst) { + expect(discographyNewestFirst[0].id).toEqual('in-absentia'); + } }); it('must be chainable with limit', async () => { @@ -121,25 +120,25 @@ describe('BaseFirestoreRepository', () => { it('must throw an Error if an orderBy* function is called more than once in the same expression', async () => { const pt = await bandRepository.findById('porcupine-tree'); - const albumsSubColl = pt.albums; + const albumsSubColl = pt?.albums; expect(() => { - albumsSubColl.orderByAscending('releaseDate').orderByDescending('releaseDate'); + albumsSubColl?.orderByAscending('releaseDate').orderByDescending('releaseDate'); }).toThrow(); }); it('must throw an Error if an orderBy* function is called more than once in the same expression ascending', async () => { const pt = await bandRepository.findById('porcupine-tree'); - const albumsSubColl = pt.albums; + const albumsSubColl = pt?.albums; expect(() => { - albumsSubColl.orderByAscending('releaseDate').orderByAscending('releaseDate'); + albumsSubColl?.orderByAscending('releaseDate').orderByAscending('releaseDate'); }).toThrow(); }); it('must succeed when orderBy* function is called more than once in the same expression with different fields', async () => { const pt = await bandRepository.findById('porcupine-tree'); - const albumsSubColl = pt.albums; + const albumsSubColl = pt?.albums; expect(() => { - albumsSubColl.orderByAscending('releaseDate').orderByDescending('name'); + albumsSubColl?.orderByAscending('releaseDate').orderByDescending('name'); }).not.toThrow(); }); }); @@ -153,14 +152,20 @@ describe('BaseFirestoreRepository', () => { it('must order the objects in a subcollection', async () => { const pt = await bandRepository.findById('porcupine-tree'); - const albumsSubColl = pt.albums; - const discographyNewestFirst = await albumsSubColl.orderByDescending('releaseDate').find(); + const albumsSubColl = pt?.albums; + const discographyNewestFirst = await albumsSubColl?.orderByDescending('releaseDate').find(); + if (!discographyNewestFirst) { + throw new Error('Band not found'); + } expect(discographyNewestFirst[0].id).toEqual('fear-blank-planet'); }); it('must be chainable with where* filters', async () => { const pt = await bandRepository.findById('porcupine-tree'); - const albumsSubColl = pt.albums; + const albumsSubColl = pt?.albums; + if (!albumsSubColl) { + throw new Error('Band not found'); + } const discographyNewestFirst = await albumsSubColl .whereGreaterOrEqualThan('releaseDate', new Date('2001-01-01')) .orderByDescending('releaseDate') @@ -176,41 +181,41 @@ describe('BaseFirestoreRepository', () => { it('must throw an Error if an orderBy* function is called more than once in the same expression', async () => { const pt = await bandRepository.findById('porcupine-tree'); - const albumsSubColl = pt.albums; + const albumsSubColl = pt?.albums; expect(() => { - albumsSubColl.orderByAscending('releaseDate').orderByDescending('releaseDate'); + albumsSubColl?.orderByAscending('releaseDate').orderByDescending('releaseDate'); }).toThrow(); }); it('must throw an Error if an orderBy* function is called more than once in the same expression descending', async () => { const pt = await bandRepository.findById('porcupine-tree'); - const albumsSubColl = pt.albums; + const albumsSubColl = pt?.albums; expect(() => { - albumsSubColl.orderByDescending('releaseDate').orderByDescending('releaseDate'); + albumsSubColl?.orderByDescending('releaseDate').orderByDescending('releaseDate'); }).toThrow(); }); it('must succeed when orderBy* function is called more than once in the same expression with different fields', async () => { const pt = await bandRepository.findById('porcupine-tree'); - const albumsSubColl = pt.albums; + const albumsSubColl = pt?.albums; expect(() => { - albumsSubColl.orderByAscending('releaseDate').orderByDescending('name'); + albumsSubColl?.orderByAscending('releaseDate').orderByDescending('name'); }).not.toThrow(); }); it('must succeed when orderBy* function is called more than once in the same expression with different fields ascending', async () => { const pt = await bandRepository.findById('porcupine-tree'); - const albumsSubColl = pt.albums; + const albumsSubColl = pt?.albums; expect(() => { - albumsSubColl.orderByAscending('releaseDate').orderByAscending('name'); + albumsSubColl?.orderByAscending('releaseDate').orderByAscending('name'); }).not.toThrow(); }); it('must succeed when orderBy* function is called more than once in the same expression with different fields descending', async () => { const pt = await bandRepository.findById('porcupine-tree'); - const albumsSubColl = pt.albums; + const albumsSubColl = pt?.albums; expect(() => { - albumsSubColl.orderByDescending('releaseDate').orderByDescending('name'); + albumsSubColl?.orderByDescending('releaseDate').orderByDescending('name'); }).not.toThrow(); }); }); @@ -218,6 +223,9 @@ describe('BaseFirestoreRepository', () => { describe('findById', () => { it('must find by id', async () => { const pt = await bandRepository.findById('porcupine-tree'); + if (!pt) { + throw new Error('Band not found'); + } expect(pt).toBeInstanceOf(Band); expect(pt.id).toEqual('porcupine-tree'); expect(pt.name).toEqual('Porcupine Tree'); @@ -225,7 +233,7 @@ describe('BaseFirestoreRepository', () => { it('must have proper getters', async () => { const pt = await bandRepository.findById('porcupine-tree'); - expect(pt.getLastShowYear()).toEqual(2010); + expect(pt?.getLastShowYear()).toEqual(2010); }); it('return null if not found', async () => { @@ -250,7 +258,7 @@ describe('BaseFirestoreRepository', () => { it('must not validate if the validate config by default', async () => { initialize(firestore); - bandRepository = new BandRepository('bands'); + bandRepository = new BandRepository(Band, 'bands'); const entity = new Band(); entity.contactEmail = 'Not an email'; @@ -262,7 +270,7 @@ describe('BaseFirestoreRepository', () => { it('must not validate if the validateModels: false', async () => { initialize(firestore, { validateModels: false }); - bandRepository = new BandRepository('bands'); + bandRepository = new BandRepository(Band, 'bands'); const entity = new Band(); entity.contactEmail = 'Not an email'; @@ -368,7 +376,7 @@ describe('BaseFirestoreRepository', () => { const band = await bandRepository.create(entity); const foundBand = await bandRepository.findById(band.id); - expect(band.id).toEqual(foundBand.id); + expect(band.id).toEqual(foundBand?.id); }); it('throw error when trying to create objects with duplicated id', async () => { @@ -385,7 +393,10 @@ describe('BaseFirestoreRepository', () => { describe('update', () => { it('must update and return updated item', async () => { const band = await bandRepository.findById('porcupine-tree'); - const albums = band.albums; + if (!band) { + throw new Error('Band not found'); + } + const albums = band?.albums; band.name = 'Steven Wilson'; const updatedBand = await bandRepository.update(band); expect(band.name).toEqual(updatedBand.name); @@ -397,22 +408,27 @@ describe('BaseFirestoreRepository', () => { it('must not validate if the validate config property is false', async () => { initialize(firestore, { validateModels: false }); - bandRepository = new BandRepository('bands'); + bandRepository = new BandRepository(Band, 'bands'); const band = await bandRepository.findById('porcupine-tree'); + if (!band) { + throw new Error('Band not found'); + } band.contactEmail = 'Not an email'; await bandRepository.update(band); const updatedBand = await bandRepository.findById('porcupine-tree'); - expect(updatedBand.contactEmail).toEqual('Not an email'); + expect(updatedBand?.contactEmail).toEqual('Not an email'); }); it('must fail validation if an invalid class is given', async () => { initialize(firestore, { validateModels: true }); const band = await bandRepository.findById('porcupine-tree'); - + if (!band) { + throw new Error('Band not found'); + } band.contactEmail = 'Not an email'; try { @@ -426,6 +442,9 @@ describe('BaseFirestoreRepository', () => { initialize(firestore, { validateModels: true }); const band = await bandRepository.findById('porcupine-tree'); + if (!band) { + throw new Error('Band not found'); + } band.contactEmail = 'Not an Email'; try { @@ -585,6 +604,9 @@ describe('BaseFirestoreRepository', () => { const docRef = firestore.collection('bands').doc('steven-wilson'); const band = await bandRepository.findById('porcupine-tree'); + if (!band) { + throw new Error('Band not found'); + } band.relatedBand = docRef; await bandRepository.update(band); @@ -602,7 +624,7 @@ describe('BaseFirestoreRepository', () => { .whereArrayContains('genres', 'funk-rock') .findOne(); expect(result).toBeInstanceOf(Band); - expect(result.id).toEqual('red-hot-chili-peppers'); + expect(result?.id).toEqual('red-hot-chili-peppers'); }); it('must return null if not found', async () => { @@ -621,15 +643,21 @@ describe('BaseFirestoreRepository', () => { describe('miscellaneous', () => { it('should correctly parse dates', async () => { const pt = await bandRepository.findById('porcupine-tree'); - const { releaseDate } = await pt.albums.findById('deadwing'); + const dw = await pt?.albums?.findById('deadwing'); + if (!dw || !pt) { + throw new Error('Band not found'); + } expect(pt.lastShow).toBeInstanceOf(Date); expect(pt.lastShow.toISOString()).toEqual('2010-10-14T00:00:00.000Z'); - expect(releaseDate).toBeInstanceOf(Date); - expect(releaseDate.toISOString()).toEqual('2005-03-25T00:00:00.000Z'); + expect(dw.releaseDate).toBeInstanceOf(Date); + expect(dw.releaseDate.toISOString()).toEqual('2005-03-25T00:00:00.000Z'); }); it('should correctly parse geopoints', async () => { const pt = await bandRepository.findById('porcupine-tree'); + if (!pt) { + throw new Error('Band not found'); + } expect(pt.lastShowCoordinates).toBeInstanceOf(Coordinates); expect(pt.lastShowCoordinates.latitude).toEqual(51.5009088); expect(pt.lastShowCoordinates.longitude).toEqual(-0.1795547); @@ -639,15 +667,21 @@ describe('BaseFirestoreRepository', () => { const docRef = firestore.collection('bands').doc('opeth'); const band = await bandRepository.findById('porcupine-tree'); + if (!band) { + throw new Error('Band not found'); + } band.relatedBand = docRef; await bandRepository.update(band); const foundBand = await bandRepository.findById('porcupine-tree'); + if (!foundBand) { + throw new Error('Band not found'); + } expect(foundBand.relatedBand).toBeInstanceOf(FirestoreDocumentReference); - expect(foundBand.relatedBand.id).toEqual('opeth'); + expect(foundBand.relatedBand?.id).toEqual('opeth'); // firestore mock doesn't set this property, it should be bands/opeth - expect(foundBand.relatedBand.path).toEqual(undefined); + expect(foundBand.relatedBand?.path).toEqual(undefined); }); it('should correctly filter by null values', async () => { @@ -663,7 +697,7 @@ describe('BaseFirestoreRepository', () => { .whereEqualTo(a => a.formationYear, null) .findOne(); - expect(bandsWithNullFormationYear.id).toEqual(entity.id); + expect(bandsWithNullFormationYear?.id).toEqual(entity.id); }); }); @@ -671,12 +705,15 @@ describe('BaseFirestoreRepository', () => { it('should be able to open transactions', async () => { await bandRepository.runTransaction(async tran => { const band = await tran.findById('porcupine-tree'); + if (!band) { + throw new Error('Band not found'); + } band.name = 'Árbol de Puercoespín'; await tran.update(band); }); const updated = await bandRepository.findById('porcupine-tree'); - expect(updated.name).toEqual('Árbol de Puercoespín'); + expect(updated?.name).toEqual('Árbol de Puercoespín'); }); it('runTransaction should return TransactionRepository', async () => { @@ -719,10 +756,16 @@ describe('BaseFirestoreRepository', () => { describe('must handle subcollections', () => { it('should initialize nested subcollections', async () => { const pt = await bandRepository.findById('red-hot-chili-peppers'); + if (!pt) { + throw new Error('Band not found'); + } expect(pt.name).toEqual('Red Hot Chili Peppers'); - expect(pt.albums).toBeInstanceOf(BaseFirestoreRepository); + expect(pt?.albums).toBeInstanceOf(BaseFirestoreRepository); - const album = await pt.albums.findById('stadium-arcadium'); + const album = await pt?.albums?.findById('stadium-arcadium'); + if (!album) { + throw new Error('Album not found'); + } expect(album.name).toEqual('Stadium Arcadium'); expect(album.images).toBeInstanceOf(BaseFirestoreRepository); }); @@ -751,6 +794,9 @@ describe('BaseFirestoreRepository', () => { thirdAlbum.name = 'This Is War'; thirdAlbum.releaseDate = new Date('2009-12-08'); + if (!band.albums) { + throw new Error('Albums not found'); + } await band.albums.create(firstAlbum); await band.albums.create(secondAlbum); await band.albums.create(thirdAlbum); @@ -773,9 +819,9 @@ describe('BaseFirestoreRepository', () => { firstAlbum.name = '30 Seconds to Mars'; firstAlbum.releaseDate = new Date('2002-07-22'); - const album = await band.albums.create(firstAlbum); + const album = await band.albums?.create(firstAlbum); - expect(album.images).toBeInstanceOf(BaseFirestoreRepository); + expect(album?.images).toBeInstanceOf(BaseFirestoreRepository); }); it('should be able to validate subcollections on create', async () => { @@ -795,7 +841,7 @@ describe('BaseFirestoreRepository', () => { firstAlbum.releaseDate = new Date('2002-07-22'); try { - await band.albums.create(firstAlbum); + await band.albums?.create(firstAlbum); } catch (error) { expect(error[0].constraints.isLength).toEqual('Name is too long'); } @@ -803,25 +849,31 @@ describe('BaseFirestoreRepository', () => { it('should be able to update subcollections', async () => { const pt = await bandRepository.findById('porcupine-tree'); - const album = await pt.albums.findById('fear-blank-planet'); + const album = await pt?.albums?.findById('fear-blank-planet'); + if (!album) { + throw new Error('Band not found'); + } album.comment = 'Anesthethize is top 3 IMHO'; - await pt.albums.update(album); + await pt?.albums?.update(album); - const updatedAlbum = await pt.albums.findById('fear-blank-planet'); - expect(updatedAlbum.comment).toEqual('Anesthethize is top 3 IMHO'); + const updatedAlbum = await pt?.albums?.findById('fear-blank-planet'); + expect(updatedAlbum?.comment).toEqual('Anesthethize is top 3 IMHO'); }); it('should be able to validate subcollections on update', async () => { initialize(firestore, { validateModels: true }); const pt = await bandRepository.findById('porcupine-tree'); - const album = await pt.albums.findById('fear-blank-planet'); + const album = await pt?.albums?.findById('fear-blank-planet'); + if (!album) { + throw new Error('Band not found'); + } album.name = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.'; try { - await pt.albums.update(album); + await pt?.albums?.update(album); } catch (error) { expect(error[0].constraints.isLength).toEqual('Name is too long'); } @@ -829,6 +881,9 @@ describe('BaseFirestoreRepository', () => { it('should be able to update collections with subcollections', async () => { const pt = await bandRepository.findById('porcupine-tree'); + if (!pt) { + throw new Error('Band not found'); + } pt.name = 'Porcupine Tree IS THE BEST'; const updatedPt = await bandRepository.update(pt); const foundUpdatedPt = await bandRepository.update(pt); @@ -839,10 +894,10 @@ describe('BaseFirestoreRepository', () => { it('should be able to delete subcollections', async () => { const pt = await bandRepository.findById('porcupine-tree'); - await pt.albums.delete('fear-blank-planet'); + await pt?.albums?.delete('fear-blank-planet'); - const updatedBandAlbums = await pt.albums.find(); - expect(updatedBandAlbums.length).toEqual(3); + const updatedBandAlbums = await pt?.albums?.find(); + expect(updatedBandAlbums?.length).toEqual(3); }); it('should be able to update subcollections of subcollections', async () => { @@ -858,6 +913,9 @@ describe('BaseFirestoreRepository', () => { firstAlbum.id = '30-seconds-to-mars'; firstAlbum.name = '30 Seconds to Mars (Album)'; firstAlbum.releaseDate = new Date('2002-07-22'); + if (!band.albums) { + throw new Error('Band not found'); + } const album = await band.albums.create(firstAlbum); @@ -869,28 +927,37 @@ describe('BaseFirestoreRepository', () => { image2.id = 'image2'; image2.url = 'http://image2.com'; - await album.images.create(image1); - await album.images.create(image2); + await album.images?.create(image1); + await album.images?.create(image2); - const images = await album.images.find(); - expect(images.length).toEqual(2); + const images = await album.images?.find(); + expect(images?.length).toEqual(2); const foundBand = await bandRepository.findById('30-seconds-to-mars'); + if (!foundBand) { + throw new Error('Band not found'); + } expect(foundBand.name).toEqual('30 Seconds To Mars'); - const foundAlbums = await foundBand.albums.find(); + const foundAlbums = await foundBand.albums?.find(); + if (!foundAlbums) { + throw new Error('Band not found'); + } expect(foundAlbums.length).toEqual(1); expect(foundAlbums[0].name).toEqual('30 Seconds to Mars (Album)'); - const foundImages = await foundAlbums[0].images.find(); + const foundImages = await foundAlbums[0].images?.find(); + if (!foundImages) { + throw new Error('Band not found'); + } expect(foundImages.length).toEqual(2); expect(foundImages[0].id).toEqual('image1'); }); }); describe('fetching documents created w/o id inside object', () => { - let docId: string = null; + let docId: string; beforeEach(async () => { const bandWithoutId = new Band(); @@ -900,7 +967,7 @@ describe('BaseFirestoreRepository', () => { it('Get by id - entity should contain id', async () => { const band = await bandRepository.findById(docId); expect(band).toHaveProperty('id'); - expect(band.id).toEqual(docId); + expect(band?.id).toEqual(docId); }); it('Get list - all entities should contain id', async () => { @@ -916,8 +983,11 @@ describe('BaseFirestoreRepository', () => { describe('deserialization', () => { it('should correctly initialize a repository with an entity', async () => { - const bandRepositoryWithPath = new BandRepository(Band); + const bandRepositoryWithPath = new BandRepository(Band, 'bands'); const band = await bandRepositoryWithPath.findById('the-speckled-band'); + if (!band) { + throw new Error('Band not found'); + } expect(band.name).toEqual('the Speckled Band'); expect(band.agents[0]).toBeInstanceOf(Agent); expect(band.agents[0].name).toEqual('Mycroft Holmes'); diff --git a/src/BaseFirestoreRepository.ts b/src/BaseFirestoreRepository.ts index e2e38c3a..27756987 100644 --- a/src/BaseFirestoreRepository.ts +++ b/src/BaseFirestoreRepository.ts @@ -80,7 +80,7 @@ export class BaseFirestoreRepository const { runTransaction } = await import('./helpers'); return runTransaction(tran => { - const repository = tran.getRepository(this.path); + const repository = tran.getRepository(this.path, this.name); return executor(repository); }); } diff --git a/src/Batch/BaseFirestoreBatchRepository.spec.ts b/src/Batch/BaseFirestoreBatchRepository.spec.ts index 8bdfa93c..1bc9ed82 100644 --- a/src/Batch/BaseFirestoreBatchRepository.spec.ts +++ b/src/Batch/BaseFirestoreBatchRepository.spec.ts @@ -11,8 +11,8 @@ import { getRepository } from '../helpers'; const MockFirebase = require('mock-cloud-firestore'); describe('BaseFirestoreBatchRepository', () => { - let bandBatchRepository: BaseFirestoreBatchRepository = null; - let bandRepository: BaseFirestoreRepository = null; + let bandBatchRepository: BaseFirestoreBatchRepository; + let bandRepository: BaseFirestoreRepository; let firestore: Firestore; let batch: FirestoreBatchUnit; let batchStub: jest.Mocked; @@ -35,8 +35,8 @@ describe('BaseFirestoreBatchRepository', () => { initialize(firestore); batch = new FirestoreBatchUnit(firestore); - bandBatchRepository = new BaseFirestoreBatchRepository(Band, batch); - bandRepository = getRepository(Band); + bandBatchRepository = new BaseFirestoreBatchRepository(Band, batch, 'bands'); + bandRepository = getRepository(Band, 'bands'); }); describe('create', () => { @@ -127,7 +127,11 @@ describe('BaseFirestoreBatchRepository', () => { initialize(firestore, { validateModels: true }); const validationBatch = new FirestoreBatchUnit(firestore); - const validationBandRepository = new BaseFirestoreBatchRepository(Band, validationBatch); + const validationBandRepository = new BaseFirestoreBatchRepository( + Band, + validationBatch, + 'bands' + ); const entity = new Band(); entity.id = 'perfect-circle'; @@ -149,7 +153,11 @@ describe('BaseFirestoreBatchRepository', () => { initialize(firestore, { validateModels: true }); const validationBatch = new FirestoreBatchUnit(firestore); - const validationBandRepository = new BaseFirestoreBatchRepository(Band, validationBatch); + const validationBandRepository = new BaseFirestoreBatchRepository( + Band, + validationBatch, + 'bands' + ); const entity = new Band(); entity.id = 'perfect-circle'; @@ -170,7 +178,11 @@ describe('BaseFirestoreBatchRepository', () => { initialize(firestore, { validateModels: true, validatorOptions: {} }); const validationBatch = new FirestoreBatchUnit(firestore); - const validationBandRepository = new BaseFirestoreBatchRepository(Band, validationBatch); + const validationBandRepository = new BaseFirestoreBatchRepository( + Band, + validationBatch, + 'bands' + ); let entity = new Band(); entity = { @@ -189,7 +201,11 @@ describe('BaseFirestoreBatchRepository', () => { }); const validationBatch = new FirestoreBatchUnit(firestore); - const validationBandRepository = new BaseFirestoreBatchRepository(Band, validationBatch); + const validationBandRepository = new BaseFirestoreBatchRepository( + Band, + validationBatch, + 'bands' + ); let entity = new Band(); entity = { diff --git a/src/Batch/BaseFirestoreBatchRepository.ts b/src/Batch/BaseFirestoreBatchRepository.ts index b8115868..4d7c9ea9 100644 --- a/src/Batch/BaseFirestoreBatchRepository.ts +++ b/src/Batch/BaseFirestoreBatchRepository.ts @@ -12,11 +12,12 @@ export class BaseFirestoreBatchRepository implements IBatchRe constructor( protected pathOrConstructor: EntityConstructorOrPath, - protected batch: FirestoreBatchUnit + protected batch: FirestoreBatchUnit, + protected collectionName?: string ) { const { getCollection, firestoreRef, config } = getMetadataStorage(); - const colMetadata = getCollection(pathOrConstructor); + const colMetadata = getCollection(pathOrConstructor, collectionName); if (!colMetadata) { throw new NoMetadataError(pathOrConstructor); diff --git a/src/Batch/FirestoreBatch.spec.ts b/src/Batch/FirestoreBatch.spec.ts index 267da098..29ea5840 100644 --- a/src/Batch/FirestoreBatch.spec.ts +++ b/src/Batch/FirestoreBatch.spec.ts @@ -8,7 +8,7 @@ import { FirestoreBatch } from './FirestoreBatch'; const MockFirebase = require('mock-cloud-firestore'); describe('FirestoreBatch', () => { - let firestore: Firestore = undefined; + let firestore: Firestore; beforeEach(() => { const firebase = new MockFirebase(); @@ -25,7 +25,7 @@ describe('FirestoreBatch', () => { const tran = new FirestoreBatch(firestore); - const bandRepository = tran.getRepository(Entity); + const bandRepository = tran.getRepository(Entity, 'Entities'); expect(bandRepository.constructor.name).toEqual('BaseFirestoreBatchRepository'); }); }); @@ -39,7 +39,7 @@ describe('FirestoreBatch', () => { const tran = new FirestoreBatch(firestore); - const bandRepository = tran.getSingleRepository(Entity); + const bandRepository = tran.getSingleRepository(Entity, 'Entities'); expect(bandRepository.constructor.name).toEqual('FirestoreBatchSingleRepository'); }); }); diff --git a/src/Batch/FirestoreBatch.ts b/src/Batch/FirestoreBatch.ts index 70b4d6c9..1d8bc640 100644 --- a/src/Batch/FirestoreBatch.ts +++ b/src/Batch/FirestoreBatch.ts @@ -21,8 +21,11 @@ export class FirestoreBatch implements IFirestoreBatch { * @returns * @memberof FirestoreBatch */ - getRepository(pathOrConstructor: EntityConstructorOrPath) { - return new BaseFirestoreBatchRepository(pathOrConstructor, this.batch); + getRepository( + pathOrConstructor: EntityConstructorOrPath, + collectionName?: string + ) { + return new BaseFirestoreBatchRepository(pathOrConstructor, this.batch, collectionName); } /** @@ -35,8 +38,11 @@ export class FirestoreBatch implements IFirestoreBatch { * @returns * @memberof FirestoreBatch */ - getSingleRepository(pathOrConstructor: EntityConstructorOrPath) { - return new FirestoreBatchSingleRepository(pathOrConstructor, this.batch); + getSingleRepository( + pathOrConstructor: EntityConstructorOrPath, + collectionName?: string + ) { + return new FirestoreBatchSingleRepository(pathOrConstructor, this.batch, collectionName); } /** diff --git a/src/Batch/FirestoreBatchSingleRepository.ts b/src/Batch/FirestoreBatchSingleRepository.ts index 5838a1e9..76d6748a 100644 --- a/src/Batch/FirestoreBatchSingleRepository.ts +++ b/src/Batch/FirestoreBatchSingleRepository.ts @@ -19,3 +19,5 @@ export class FirestoreBatchSingleRepository await this.batch.commit(); } } + +// TODO: Does this need to exist? diff --git a/src/Decorators/Collection.spec.ts b/src/Decorators/Collection.spec.ts index 98cfe465..a7c11a8c 100644 --- a/src/Decorators/Collection.spec.ts +++ b/src/Decorators/Collection.spec.ts @@ -16,11 +16,13 @@ describe('CollectionDecorator', () => { @Collection('foo') class Entity { id: string; + parentProps: null; } expect(setCollection).toHaveBeenCalledWith({ name: 'foo', entityConstructor: Entity, + parentProps: null, }); }); @@ -33,6 +35,7 @@ describe('CollectionDecorator', () => { expect(setCollection).toHaveBeenCalledWith({ name: 'Entities', entityConstructor: Entity, + parentProps: null, }); }); }); diff --git a/src/Decorators/Collection.ts b/src/Decorators/Collection.ts index b823ca73..ce0c0b79 100644 --- a/src/Decorators/Collection.ts +++ b/src/Decorators/Collection.ts @@ -2,12 +2,54 @@ import { getMetadataStorage } from '../MetadataUtils'; import { plural } from 'pluralize'; import type { IEntityConstructor } from '../types'; -export function Collection(entityName?: string) { - return function (entityConstructor: IEntityConstructor) { - const name = entityName || plural(entityConstructor.name); +/** + * Decorator to mark a class as a Firestore collection. + * This decorator registers metadata about the collection and processes any pending subcollections. + * + * @param {string} [collectionName] - Optional custom name for the collection. If not provided, the plural form of the entity constructor's name will be used. + * @returns {Function} - A decorator function that registers the collection metadata and processes subcollections. + */ +export function Collection(collectionName?: string) { + return function (entityConstructor: IEntityConstructor, _?: any) { + entityConstructor.prototype.collectionName = collectionName || plural(entityConstructor.name); + + // process subcols recursively, to ensure that all levels of subcols get registered + const processSubcollections = (constructor: IEntityConstructor) => { + // check to see if any subcollections are set to pending for this entityConstructor + // process them if so. Do nothing if not. + if (constructor.prototype._pendingSubCollections) { + for (const subCollection of constructor.prototype._pendingSubCollections) { + getMetadataStorage().setCollection({ + entityConstructor: subCollection.entityConstructor, + name: subCollection.propertyKey, + parentProps: { + parentEntityConstructor: constructor, + parentPropertyKey: subCollection.propertyKey, + parentCollectionName: constructor.prototype.collectionName, + }, + }); + + // Check and process next level down (if it exists) + processSubcollections(subCollection.entityConstructor); + } + + // Clear the pending subcollections after processing + delete constructor.prototype._pendingSubCollections; + } + }; + + // Begin with the first subcollection level + processSubcollections(entityConstructor); + + // Register the main collection getMetadataStorage().setCollection({ - name, + name: entityConstructor.prototype.collectionName, entityConstructor, + parentProps: null, }); + + // Clear out the collectionName property from the prototype, so others collections + // with the same entityConstructor don't get the same collectionName + delete entityConstructor.prototype.collectionName; }; } diff --git a/src/Decorators/CustomRepository.spec.ts b/src/Decorators/CustomRepository.spec.ts index cf40e1b6..4311e326 100644 --- a/src/Decorators/CustomRepository.spec.ts +++ b/src/Decorators/CustomRepository.spec.ts @@ -18,12 +18,13 @@ describe('CustomRepositoryDecorator', () => { id: string; } - @CustomRepository(Entity) + @CustomRepository(Entity, 'Entities') class EntityRepo extends BaseFirestoreRepository {} expect(setRepository).toHaveBeenCalledWith({ entity: Entity, target: EntityRepo, + collectionName: 'Entities', }); }); @@ -33,7 +34,7 @@ describe('CustomRepositoryDecorator', () => { } const createInvalidRepo = () => { - @CustomRepository(Entity) + @CustomRepository(Entity, 'Entities') class InvalidRepo { id: string; } @@ -50,7 +51,7 @@ describe('CustomRepositoryDecorator', () => { id: string; } - @CustomRepository(Entity) + @CustomRepository(Entity, 'Entities') class EntityRepo1 extends BaseFirestoreRepository {} // Mock the behavior of setRepository to simulate existing repository registration @@ -59,7 +60,7 @@ describe('CustomRepositoryDecorator', () => { }); expect(() => { - @CustomRepository(Entity) + @CustomRepository(Entity, 'Entities') class EntityRepo2 extends BaseFirestoreRepository {} }).toThrowError('Cannot register a custom repository twice with two different targets'); }); diff --git a/src/Decorators/CustomRepository.ts b/src/Decorators/CustomRepository.ts index 9da896f9..077eca8f 100644 --- a/src/Decorators/CustomRepository.ts +++ b/src/Decorators/CustomRepository.ts @@ -4,19 +4,23 @@ import { getBaseFirestoreRepositoryClass } from '../LazyLoaders'; import { IEntityConstructor } from '../types'; /** - * CustomRepository decorator to register a custom repository for a specific entity type. + * CustomRepository decorator to register a custom repository for a specific entity type and collection name. * * This decorator registers the given repository class as the custom repository - * for the specified entity type in the metadata storage. The custom repository - * class must extend the `BaseFirestoreRepository` class. + * for the specified entity type and collection name in the metadata storage. + * The custom repository class must extend the `BaseFirestoreRepository` class. * * @template T - The type of the entity. * @param {Constructor} entity - The constructor function of the entity type. + * @param {string} collectionName - The name of the collection this repository is associated with. * @returns {Function} - A decorator function that takes the repository class and registers it. */ -export function CustomRepository(entity: Constructor) { +export function CustomRepository( + entity: Constructor, + collectionName: string +) { return function >( - target: U & { new (pathOrConstructor: string | IEntityConstructor): any }, + target: U & { new (pathOrConstructor: string | IEntityConstructor, colName: string): any }, _?: any ): void { const BaseFirestoreRepository = getBaseFirestoreRepositoryClass(); @@ -26,6 +30,7 @@ export function CustomRepository(entity: Constructo getMetadataStorage().setRepository({ entity, target, + collectionName, }); }; } diff --git a/src/Decorators/SubCollection.spec.ts b/src/Decorators/SubCollection.spec.ts index 074f632e..73bcb2e1 100644 --- a/src/Decorators/SubCollection.spec.ts +++ b/src/Decorators/SubCollection.spec.ts @@ -18,40 +18,24 @@ describe('SubCollectionDecorator', () => { class SubEntity { public id: string; } - @Collection() - class Entity { - id: string; - - @SubCollection(SubEntity, 'subs') - subentity: ISubCollection; - } - - expect(setCollection).toHaveBeenCalledWith({ - name: 'subs', - entityConstructor: SubEntity, - parentEntityConstructor: Entity, - propertyKey: 'subentity', - }); - }); - - it('should register collections with default name', () => { - class SubEntity { - public id: string; - } @Collection() class Entity { id: string; + // TODO: figure out how to resolve this ts error @SubCollection(SubEntity) subentity: ISubCollection; } expect(setCollection).toHaveBeenCalledWith({ - name: 'SubEntities', + name: 'subentity', entityConstructor: SubEntity, - parentEntityConstructor: Entity, - propertyKey: 'subentity', + parentProps: { + parentEntityConstructor: Entity, + parentPropertyKey: 'subentity', + parentCollectionName: 'Entities', + }, }); }); }); diff --git a/src/Decorators/SubCollection.ts b/src/Decorators/SubCollection.ts index 338e3949..3525eae4 100644 --- a/src/Decorators/SubCollection.ts +++ b/src/Decorators/SubCollection.ts @@ -1,14 +1,30 @@ -import { getMetadataStorage } from '../MetadataUtils'; -import { plural } from 'pluralize'; -import { IEntityConstructor, IEntity } from '../types'; +import { isConstructor } from '../TypeGuards'; +import { IEntityConstructor } from '../types'; -export function SubCollection(entityConstructor: IEntityConstructor, entityName?: string) { - return function (parentEntity: IEntity, propertyKey: string) { - getMetadataStorage().setCollection({ - entityConstructor, - name: entityName || plural(entityConstructor.name), - parentEntityConstructor: parentEntity.constructor as IEntityConstructor, - propertyKey, - }); +/** + * Decorator to mark a property as a subcollection within a Firestore document. + * This decorator registers metadata about the subcollection, linking it to the parent collection. + * + * @param {IEntityConstructor} entityConstructor - The constructor function of the subcollection entity. + * @returns {Function} - A decorator function that registers the subcollection metadata. + */ +export function SubCollection(entityConstructor: IEntityConstructor) { + return function (target: any, propertyKey: string | symbol) { + entityConstructor.prototype.collectionName = propertyKey; + + if (isConstructor(target.constructor)) { + const constructor = target.constructor as IEntityConstructor; + + // Store metadata temporarily + if (!constructor.prototype._pendingSubCollections) { + constructor.prototype._pendingSubCollections = []; + } + constructor.prototype._pendingSubCollections.push({ + entityConstructor, + propertyKey: propertyKey.toString(), + }); + } else { + throw new Error('Constructor not found on subcollection target'); + } }; } diff --git a/src/Errors/index.ts b/src/Errors/index.ts index 524fa934..5ce83bf0 100644 --- a/src/Errors/index.ts +++ b/src/Errors/index.ts @@ -1,11 +1,117 @@ import type { EntityConstructorOrPath, IEntity } from '../types'; export class NoMetadataError extends Error { - constructor(pathOrConstructor: EntityConstructorOrPath) { + constructor(pathOrConstructorOrCollectionName: EntityConstructorOrPath) { + let name; + if (typeof pathOrConstructorOrCollectionName === 'string') { + if (pathOrConstructorOrCollectionName.includes('/')) { + // String is a path to a subcollection + name = `subcollection named: ${pathOrConstructorOrCollectionName}`; + } else { + // String is a top level collection name + name = `collection named: ${pathOrConstructorOrCollectionName}`; + } + } else { + name = `constructor named: ${pathOrConstructorOrCollectionName.name}`; + } + super(`There is no metadata stored for "${name}"`); + } +} + +export class InvalidRepositoryIndexError extends Error { + constructor() { + super('Invalid RepositoryIndex: Must be a tuple [string, (string | null)]'); + } +} + +export class IncompleteOrInvalidPathError extends Error { + constructor(path: string) { + super(`Invalid collection path: ${path}`); + } +} + +export class InvalidCollectionOrPathError extends Error { + constructor(collectionName: string, isPath: boolean) { + if (isPath) { + super(`'${collectionName}' is not a valid path for a collection`); + } else { + throw new IncompleteOrInvalidPathError(collectionName); + } + } +} + +export class CollectionPathNotFoundError extends Error { + constructor(path: string) { + super(`Collection path not found: ${path}`); + } +} + +export class DuplicateSubCollectionError extends Error { + constructor( + entityConstructorName: string, + subCollectionName: string, + parentPropertyKey: string | undefined + ) { + super( + `SubCollection<${entityConstructorName}> with name '${subCollectionName}' and propertyKey '${parentPropertyKey}' has already been registered` + ); + } +} + +export class DuplicateCollectionError extends Error { + constructor(entityConstructorName: string, collectionName: string) { + super( + `Collection<${entityConstructorName}> with name '${collectionName}' has already been registered` + ); + } +} + +export class CustomRepositoryInheritanceError extends Error { + constructor() { super( - `There is no metadata stored for "${ - typeof pathOrConstructor === 'string' ? pathOrConstructor : pathOrConstructor.name - }"` + 'Cannot register a custom repository on a class that does not inherit from BaseFirestoreRepository' ); } } + +export class NoFirestoreError extends Error { + constructor(methodName: string) { + super( + `Firestore must be initialized before calling this method (${methodName}). Did you forget to call 'initializeFirestore'?` + ); + } +} + +export class NoCollectionNameError extends Error { + constructor() { + super( + 'Collection name was not provided, but is required when using an entity constructor rather than a collection path.' + ); + } +} + +export class NoCustomRepositoryError extends Error { + constructor(collectionName: string) { + super(`'${collectionName}' does not have a custom repository.`); + } +} + +export class NoParentCollectionError extends Error { + constructor(collectionName: string) { + super(`'${collectionName}' does not have a valid parent collection.`); + } +} + +export class NoParentPropertyKeyError extends Error { + constructor(entity: IEntity) { + super( + `Parent property key not found in registered subcollection of entity (${entity.constructor.name})` + ); + } +} + +export class InvalidInputError extends Error { + constructor(message: string) { + super(`Invalid Input: ${message}`); + } +} diff --git a/src/MetadataStorage.spec.ts b/src/MetadataStorage.spec.ts index 1bc212f0..536ece86 100644 --- a/src/MetadataStorage.spec.ts +++ b/src/MetadataStorage.spec.ts @@ -1,9 +1,15 @@ -import { MetadataStorage, CollectionMetadata, RepositoryMetadata } from './MetadataStorage'; +import { + MetadataStorage, + RepositoryMetadata, + EnforcedCollectionMetadata, + validateRepositoryIndex, +} from './MetadataStorage'; import { BaseFirestoreRepository } from './BaseFirestoreRepository'; import { IRepository, Constructor } from './types'; +import { CollectionPathNotFoundError, InvalidRepositoryIndexError } from './Errors'; describe('MetadataStorage', () => { - let metadataStorage: MetadataStorage = undefined; + let metadataStorage: MetadataStorage; class Entity { id: string; } @@ -16,23 +22,30 @@ describe('MetadataStorage', () => { public id: string; } - const col: CollectionMetadata = { + const col: EnforcedCollectionMetadata = { entityConstructor: Entity, name: 'entity', + parentProps: null, }; - const subCol: CollectionMetadata = { + const subCol: EnforcedCollectionMetadata = { entityConstructor: SubEntity, name: 'subEntity', - parentEntityConstructor: Entity, - propertyKey: 'subEntities', + parentProps: { + parentEntityConstructor: Entity, + parentPropertyKey: 'subEntities', + parentCollectionName: 'entity', + }, }; - const subSubCol: CollectionMetadata = { + const subSubCol: EnforcedCollectionMetadata = { entityConstructor: SubSubEntity, name: 'subSubEntity', - parentEntityConstructor: SubEntity, - propertyKey: 'subSubEntities', + parentProps: { + parentEntityConstructor: SubEntity, + parentPropertyKey: 'subSubEntities', + parentCollectionName: 'subEntity', + }, }; beforeEach(() => { @@ -46,54 +59,57 @@ describe('MetadataStorage', () => { metadataStorage.setCollection(col); }); - it('should get Collection by string', () => { - const entityMetadata = metadataStorage.getCollection('entity'); + it('should get Collection by entityConstructor and name', () => { + const entityMetadata = metadataStorage.getCollection(Entity, 'entity'); - expect(entityMetadata.entityConstructor).toEqual(col.entityConstructor); - expect(entityMetadata.name).toEqual(col.name); - expect(entityMetadata.segments).toEqual(['entity']); - expect(entityMetadata.subCollections.length).toEqual(1); + expect(entityMetadata?.entityConstructor).toEqual(col.entityConstructor); + expect(entityMetadata?.name).toEqual(col.name); + expect(entityMetadata?.segments).toEqual(['entity']); + expect(entityMetadata?.subCollections.length).toEqual(1); }); - it('should get Collection by constructor', () => { - const entityMetadata = metadataStorage.getCollection(Entity); - - expect(entityMetadata.entityConstructor).toEqual(col.entityConstructor); - expect(entityMetadata.name).toEqual(col.name); - expect(entityMetadata.segments).toEqual(['entity']); - expect(entityMetadata.subCollections.length).toEqual(1); - }); - - it('should get SubCollection by string', () => { + it('should get SubCollection by string and name', () => { const entityMetadata = metadataStorage.getCollection( - 'entity/entity-id/subEntity/subEntity-id/subSubEntity' + 'entity/entity-id/subEntity/subEntity-id/subSubEntity', + 'subSubEntity' + ); + + const entityMetadataByConstructor = metadataStorage.getCollection( + SubSubEntity, + 'subSubEntity' ); - expect(entityMetadata.entityConstructor).toEqual(subSubCol.entityConstructor); - expect(entityMetadata.name).toEqual(subSubCol.name); - expect(entityMetadata.segments).toEqual(['entity', 'subEntity', 'subSubEntity']); - expect(entityMetadata.subCollections.length).toEqual(0); + expect(entityMetadata?.entityConstructor).toEqual(subSubCol.entityConstructor); + expect(entityMetadataByConstructor?.entityConstructor).toEqual(subSubCol.entityConstructor); + expect(entityMetadata?.name).toEqual(subSubCol.name); + expect(entityMetadataByConstructor?.name).toEqual(subSubCol.name); + expect(entityMetadata?.segments).toEqual(['entity', 'subEntity', 'subSubEntity']); + expect(entityMetadataByConstructor?.segments).toEqual([ + 'entity', + 'subEntity', + 'subSubEntity', + ]); + expect(entityMetadata?.subCollections.length).toEqual(0); + expect(entityMetadataByConstructor?.subCollections.length).toEqual(0); }); - it('should get SubCollection by constructor', () => { + // Remove previous functionality + it('should not get SubCollection by constructor only', () => { const entityMetadata = metadataStorage.getCollection(subSubCol.entityConstructor); - expect(entityMetadata.entityConstructor).toEqual(subSubCol.entityConstructor); - expect(entityMetadata.name).toEqual(subSubCol.name); - expect(entityMetadata.segments).toEqual(['entity', 'subEntity', 'subSubEntity']); - expect(entityMetadata.subCollections.length).toEqual(0); + expect(entityMetadata?.entityConstructor).toBeUndefined(); }); - it('should return null when using invalid collection path', () => { - const entityMetadata = metadataStorage.getCollection('this_is_not_a_path'); - expect(entityMetadata).toEqual(null); + it('should throw error when using invalid collection path', () => { + expect(() => metadataStorage.getCollection('this_is_not_a_path')).toThrow( + CollectionPathNotFoundError + ); }); it('should throw error if initialized with an invalid subcollection path', () => { - const entityMetadata = metadataStorage.getCollection( - 'entity/entity-id/subEntity/subEntity-id/fake-path' - ); - expect(entityMetadata).toEqual(null); + expect(() => + metadataStorage.getCollection('entity/entity-id/subEntity/subEntity-id/fake-path') + ).toThrow(CollectionPathNotFoundError); }); it('should return null when using invalid collection constructor', () => { @@ -106,11 +122,11 @@ describe('MetadataStorage', () => { }); it('should initialize subcollection metadata', () => { - const entityMetadata = metadataStorage.getCollection('entity'); + const entityMetadata = metadataStorage.getCollection(Entity, 'entity'); - expect(entityMetadata.subCollections.length).toEqual(1); - expect(entityMetadata.subCollections[0].entityConstructor).toEqual(subCol.entityConstructor); - expect(entityMetadata.subCollections[0].segments).toEqual(['entity', 'subEntity']); + expect(entityMetadata?.subCollections.length).toEqual(1); + expect(entityMetadata?.subCollections[0].entityConstructor).toEqual(subCol.entityConstructor); + expect(entityMetadata?.subCollections[0].segments).toEqual(['entity', 'subEntity']); }); it('should throw error if initialized with an incomplete path', () => { @@ -120,6 +136,7 @@ describe('MetadataStorage', () => { }); }); + // TODO: Test that subcollections get their segments updated when a parent collection is added describe('setCollection', () => { it('should store collections', () => { metadataStorage.setCollection(col); @@ -127,17 +144,23 @@ describe('MetadataStorage', () => { c => c.entityConstructor === col.entityConstructor ); - expect(collection.entityConstructor).toEqual(col.entityConstructor); - expect(collection.name).toEqual(col.name); - expect(collection.parentEntityConstructor).toEqual(col.parentEntityConstructor); - expect(collection.propertyKey).toEqual(col.propertyKey); - expect(collection.segments).toEqual([col.name]); + expect(collection?.segments).not.toBeUndefined(); + expect(collection?.entityConstructor).toEqual(col.entityConstructor); + expect(collection?.name).toEqual(col.name); + expect(collection?.segments).toEqual([col.name]); }); it('should throw when trying to store duplicate collections', () => { metadataStorage.setCollection(col); expect(() => metadataStorage.setCollection(col)).toThrowError( - `Collection with name ${col.name} has already been registered` + `Collection with name '${col.name}' has already been registered` + ); + }); + + it('should throw when trying to store duplicate subcollections', () => { + metadataStorage.setCollection(subCol); + expect(() => metadataStorage.setCollection(subCol)).toThrowError( + `SubCollection with name '${subCol.name}' and propertyKey '${subCol.parentProps?.parentPropertyKey}' has already been registered` ); }); @@ -152,7 +175,7 @@ describe('MetadataStorage', () => { c => c.entityConstructor === subSubCol.entityConstructor ); - expect(collection.segments).toEqual([col.name, subCol.name, subSubCol.name]); + expect(collection?.segments).toEqual([col.name, subCol.name, subSubCol.name]); }); }); @@ -171,8 +194,8 @@ describe('MetadataStorage', () => { it('should get repositories', () => { const repo = metadataStorage.getRepository(Entity); - expect(repo.entity).toEqual(entityRepository.entity); - expect(repo.target).toEqual(entityRepository.target); + expect(repo?.entity).toEqual(entityRepository.entity); + expect(repo?.target).toEqual(entityRepository.target); }); it('should return null for invalid repositories', () => { @@ -195,11 +218,12 @@ describe('MetadataStorage', () => { it('should store repositories', () => { metadataStorage.setRepository(entityRepository); + const repo_index = JSON.stringify([Entity.name, null]); expect(metadataStorage.getRepositories().size).toEqual(1); - expect(metadataStorage.getRepositories().get(entityRepository.entity).entity).toEqual(Entity); + expect(metadataStorage.getRepositories().get(repo_index)?.entity).toEqual(Entity); }); - it('should throw when trying to store two repositories with the same entity class', () => { + it('should handle when two identical repositories are set', () => { class EntityRepository2 extends BaseFirestoreRepository {} const entityRepository2: RepositoryMetadata = { @@ -209,9 +233,9 @@ describe('MetadataStorage', () => { metadataStorage.setRepository(entityRepository); - expect(() => metadataStorage.setRepository(entityRepository2)).toThrowError( - 'Cannot register a custom repository twice with two different targets' - ); + expect(() => metadataStorage.setRepository(entityRepository2)).not.toThrow(); + const metadataStorageRepositories = metadataStorage.getRepositories(); + expect(metadataStorageRepositories.size).toEqual(1); }); it('should throw when trying to store repositories that dont inherit from BaseRepository', () => { @@ -232,4 +256,128 @@ describe('MetadataStorage', () => { ); }); }); + + describe('validateRepositoryIndex', () => { + it('should throw error when repository index is invalid', () => { + expect(() => validateRepositoryIndex(['string'])).toThrowError( + 'Invalid RepositoryIndex: Must be a tuple [string, (string | null)]' + ); + expect(() => validateRepositoryIndex(['string', 'string', 'string'])).toThrow( + InvalidRepositoryIndexError + ); + }); + + it('should not throw error when repository index is valid', () => { + expect(() => validateRepositoryIndex(['string', null])).not.toThrow(); + expect(() => validateRepositoryIndex(['string', 'string'])).not.toThrow(); + }); + }); + + describe('private methods', () => { + describe('isSubCollectionMetadata', () => { + it('should return true for correctly configured subcollection metadata', () => { + class TestClass { + id: string; + value: string; + constructor(value: string) { + this.value = value; + } + } + class TestClass2 { + id: string; + value: string; + constructor(value: string) { + this.value = value; + } + } + + const classInstance = new MetadataStorage(); + const subCollectioMetadata = { + entityConstructor: TestClass, + name: 'test', + parentProps: { + parentCollectionName: 'test2', + parentEntityConstructor: TestClass2, + parentPropertyKey: 'test', + }, + } as EnforcedCollectionMetadata; + + const result = (classInstance as any)['isSubCollectionMetadata'](subCollectioMetadata); + expect(result).toEqual(true); + }); + + it('should return false for collection metadata or any incorrect type', () => { + class TestClass { + id: string; + value: string; + constructor(value: string) { + this.value = value; + } + } + + const classInstance = new MetadataStorage(); + const subCollectioMetadata = { + entityConstructor: TestClass, + name: 'test', + parentProps: null, + } as EnforcedCollectionMetadata; + + const result = (classInstance as any)['isSubCollectionMetadata'](subCollectioMetadata); + expect(result).toEqual(false); + const result2 = (classInstance as any)['isSubCollectionMetadata']({}); + expect(result2).toEqual(false); + }); + }); + + describe('isSameCollection', () => { + it('should return true when inputs are equivalent values', () => { + class TestClass { + id: string; + value: string; + constructor(value: string) { + this.value = value; + } + } + + const classInstance = new MetadataStorage(); + const subCollectioMetadata = { + entityConstructor: TestClass, + name: 'test', + parentProps: null, + } as EnforcedCollectionMetadata; + const sameCollectionMetadata = subCollectioMetadata; + + const result = (classInstance as any)['isSameCollection']( + subCollectioMetadata, + sameCollectionMetadata + ); + expect(result).toEqual(true); + }); + + it('should return false for the incorrect input type', () => { + class TestClass { + id: string; + value: string; + constructor(value: string) { + this.value = value; + } + } + + const classInstance = new MetadataStorage(); + const subCollectioMetadata = { + entityConstructor: TestClass, + name: 'test', + parentProps: null, + } as EnforcedCollectionMetadata; + const sameCollectionMetadata = { ...subCollectioMetadata }; + sameCollectionMetadata.name = 'test2'; + + const result = (classInstance as any)['isSameCollection']( + subCollectioMetadata, + sameCollectionMetadata + ); + expect(result).toEqual(false); + }); + }); + }); }); diff --git a/src/MetadataStorage.ts b/src/MetadataStorage.ts index 44585c6c..3f310249 100644 --- a/src/MetadataStorage.ts +++ b/src/MetadataStorage.ts @@ -6,46 +6,72 @@ import type { IEntity, IEntityRepositoryConstructor, ValidatorOptions, + ParentProperties, } from './types'; import { arraysAreEqual } from './utils'; - -export interface CollectionMetadata { +import { + CollectionPathNotFoundError, + CustomRepositoryInheritanceError, + DuplicateCollectionError, + DuplicateSubCollectionError, + IncompleteOrInvalidPathError, + InvalidRepositoryIndexError, +} from './Errors'; + +// Unified collection metadata combines the metadata for both collections and subcollections +export interface BaseCollectionMetadata { name: string; - entityConstructor: IEntityConstructor; - parentEntityConstructor?: IEntityConstructor; - propertyKey?: string; + entityConstructor: IEntityConstructor; } -export interface SubCollectionMetadata extends CollectionMetadata { - parentEntityConstructor: IEntityConstructor; - propertyKey: string; +interface EnforcedParentProperties { + parentProps: ParentProperties | null; } -export interface CollectionMetadataWithSegments extends CollectionMetadata { - segments: string[]; -} +export interface EnforcedCollectionMetadata + extends BaseCollectionMetadata, + EnforcedParentProperties {} -export interface SubCollectionMetadataWithSegments extends SubCollectionMetadata { +export interface CollectionMetadataWithSegments + extends EnforcedCollectionMetadata { segments: string[]; } export interface FullCollectionMetadata extends CollectionMetadataWithSegments { - subCollections: SubCollectionMetadataWithSegments[]; + subCollections: CollectionMetadataWithSegments[]; } + export interface RepositoryMetadata { target: IEntityRepositoryConstructor; entity: IEntityConstructor; + // Custom Repositories have a collectionName + // TODO: Should all repositories have an assigned collectionName? + collectionName?: string; } +export type RepositoryIndex = [string, string | null]; + export interface MetadataStorageConfig { validateModels: boolean; validatorOptions?: ValidatorOptions; throwOnDuplicatedCollection?: boolean; } +// Make sure the input is a RepositoryIndex tuple +export function validateRepositoryIndex(input: any): asserts input is RepositoryIndex { + if ( + !Array.isArray(input) || + input.length !== 2 || + typeof input[0] !== 'string' || + (input[1] !== null && typeof input[1] !== 'string') + ) { + throw new InvalidRepositoryIndexError(); + } +} + export class MetadataStorage { readonly collections: Array = []; - protected readonly repositories: Map = new Map(); + protected readonly repositories: Map = new Map(); public config: MetadataStorageConfig = { validateModels: false, @@ -53,18 +79,57 @@ export class MetadataStorage { throwOnDuplicatedCollection: true, }; - public getCollection = (pathOrConstructor: string | IEntityConstructor) => { + private isSubCollectionMetadata( + collection: EnforcedCollectionMetadata + ): boolean { + return ( + !!collection.parentProps && + // Now check for validity of parentProps + collection.parentProps.parentEntityConstructor !== null && + collection.parentProps.parentPropertyKey !== null && + collection.parentProps.parentCollectionName !== null + ); + } + + private isSameCollection( + collection1: EnforcedCollectionMetadata, + collection2: EnforcedCollectionMetadata + ): boolean { + return ( + collection1.entityConstructor === collection2.entityConstructor && + collection1.name === collection2.name && + collection1.parentProps?.parentEntityConstructor === + collection2.parentProps?.parentEntityConstructor && + collection1.parentProps?.parentPropertyKey === collection2.parentProps?.parentPropertyKey && + collection1.parentProps?.parentCollectionName === + collection2.parentProps?.parentCollectionName + ); + } + + public getCollection = ( + pathOrConstructor: string | IEntityConstructor, + collectionName?: string + ): FullCollectionMetadata | null => { + // All collections have a pathOrConstructor and a name + let collection: CollectionMetadataWithSegments | undefined; - // If is a path like users/user-id/messages/message-id/senders, + // If it is a path like users/user-id/messages/message-id/senders, // take all the even segments [users/messages/senders] and // look for an entity with those segments if (typeof pathOrConstructor === 'string') { + // TODO: Refactor with getLastSegment const segments = pathOrConstructor.split('/'); + const colName = collectionName || segments[segments.length - 1]; - // Return null if incomplete segment + // Throw error if incomplete segment if (segments.length % 2 === 0) { - throw new Error(`Invalid collection path: ${pathOrConstructor}`); + throw new IncompleteOrInvalidPathError(pathOrConstructor); + } + + // Throw error if path segment doesn't exist + if (!this.collections.map(col => col.name).includes(colName)) { + throw new CollectionPathNotFoundError(pathOrConstructor); } const collectionSegments = segments.reduce( @@ -72,9 +137,14 @@ export class MetadataStorage { [] ); - collection = this.collections.find(c => arraysAreEqual(c.segments, collectionSegments)); + // TODO: Is the name check necessary? The name is included within the segments. + collection = this.collections.find( + c => arraysAreEqual(c.segments, collectionSegments) && c.name === colName + ); } else { - collection = this.collections.find(c => c.entityConstructor === pathOrConstructor); + collection = this.collections.find( + c => c.entityConstructor === pathOrConstructor && c.name === collectionName + ); } if (!collection) { @@ -82,64 +152,95 @@ export class MetadataStorage { } const subCollections = this.collections.filter( - s => s.parentEntityConstructor === collection?.entityConstructor - ) as SubCollectionMetadataWithSegments[]; + s => + this.isSubCollectionMetadata(s) && + s.parentProps?.parentEntityConstructor === collection?.entityConstructor && + s.parentProps?.parentCollectionName === collection?.name + ); - return { - ...collection, - subCollections, - }; + return { ...collection, subCollections } as FullCollectionMetadata; }; - public setCollection = (col: CollectionMetadata) => { - const existing = this.getCollection(col.entityConstructor); + public setCollection = (col: EnforcedCollectionMetadata) => { + const colIsSubCollection = this.isSubCollectionMetadata(col); + + const existing = this.collections.find(registeredCollection => + this.isSameCollection(registeredCollection, col) + ); + if (existing && this.config.throwOnDuplicatedCollection == true) { - throw new Error(`Collection with name ${existing.name} has already been registered`); + if (colIsSubCollection) { + throw new DuplicateSubCollectionError( + existing.entityConstructor.name, + existing.name, + existing.parentProps?.parentPropertyKey + ); + } else { + throw new DuplicateCollectionError(existing.entityConstructor.name, existing.name); + } } - const colToAdd = { + + const colToAdd: CollectionMetadataWithSegments = { ...col, segments: [col.name], }; this.collections.push(colToAdd); - const getWhereImParent = (p: Constructor) => - this.collections.filter(c => c.parentEntityConstructor === p); + const findSubCollectionsOf = (collectionConstructor: Constructor, name: string) => { + return this.collections.filter(registeredCollection => { + return ( + this.isSubCollectionMetadata(registeredCollection) && + registeredCollection.parentProps?.parentEntityConstructor === collectionConstructor && + registeredCollection.parentProps?.parentCollectionName === name + ); + }); + }; - const colsToUpdate = getWhereImParent(col.entityConstructor); + const colsToUpdate = findSubCollectionsOf(col.entityConstructor, col.name); // Update segments for subcollections and subcollections of subcollections while (colsToUpdate.length) { - const c = colsToUpdate.pop(); + const registeredSubCollection = colsToUpdate.pop(); - if (!c) { + if (!registeredSubCollection) { return; } - const parent = this.collections.find(p => p.entityConstructor === c.parentEntityConstructor); - c.segments = parent?.segments.concat(c.name) || []; - getWhereImParent(c.entityConstructor).forEach(col => colsToUpdate.push(col)); + const parentOfThisSubCollection = this.collections.find( + p => + p.entityConstructor === registeredSubCollection.parentProps?.parentEntityConstructor && + p.name === registeredSubCollection.parentProps?.parentCollectionName + ); + registeredSubCollection.segments = + parentOfThisSubCollection?.segments.concat(registeredSubCollection.name) || []; + findSubCollectionsOf( + registeredSubCollection.entityConstructor, + registeredSubCollection.name + ).forEach(col => colsToUpdate.push(col)); } }; - public getRepository = (param: IEntityConstructor) => { - return this.repositories.get(param) || null; + public getRepository = (entityConstructor: IEntityConstructor, collectionName?: string) => { + const repo_index = [entityConstructor.name, collectionName ? collectionName : null]; + validateRepositoryIndex(repo_index); + return this.repositories.get(JSON.stringify(repo_index)) || null; }; public setRepository = (repo: RepositoryMetadata) => { - const savedRepo = this.getRepository(repo.entity); - - if (savedRepo && repo.target !== savedRepo.target) { - throw new Error('Cannot register a custom repository twice with two different targets'); + if (!(repo.target.prototype instanceof BaseRepository)) { + throw new CustomRepositoryInheritanceError(); } - if (!(repo.target.prototype instanceof BaseRepository)) { - throw new Error( - 'Cannot register a custom repository on a class that does not inherit from BaseFirestoreRepository' - ); + const repo_index = [repo.entity.name, repo.collectionName ? repo.collectionName : null]; + validateRepositoryIndex(repo_index); + + if (this.repositories.has(JSON.stringify(repo_index))) { + // already exists with no changes + return; } - this.repositories.set(repo.entity, repo); + this.repositories.set(JSON.stringify(repo_index), repo); }; public getRepositories = () => { diff --git a/src/Transaction/BaseFirestoreTransactionRepository.spec.ts b/src/Transaction/BaseFirestoreTransactionRepository.spec.ts index 2afc2f0a..b1cfbfc0 100644 --- a/src/Transaction/BaseFirestoreTransactionRepository.spec.ts +++ b/src/Transaction/BaseFirestoreTransactionRepository.spec.ts @@ -28,7 +28,7 @@ describe('BaseFirestoreTransactionRepository', () => { firestore = firebase.firestore(); initialize(firestore); - bandRepository = new BandRepository('bands'); + bandRepository = new BandRepository(Band, 'bands'); }); describe('limit', () => { @@ -57,6 +57,9 @@ describe('BaseFirestoreTransactionRepository', () => { it('must find by id', async () => { await bandRepository.runTransaction(async tran => { const pt = await tran.findById('porcupine-tree'); + if (!pt) { + throw new Error('Band not found'); + } expect(pt).toBeInstanceOf(Band); expect(pt.id).toEqual('porcupine-tree'); expect(pt.name).toEqual('Porcupine Tree'); @@ -66,7 +69,7 @@ describe('BaseFirestoreTransactionRepository', () => { it('must have proper getters', async () => { await bandRepository.runTransaction(async tran => { const pt = await tran.findById('porcupine-tree'); - expect(pt.getLastShowYear()).toEqual(2010); + expect(pt?.getLastShowYear()).toEqual(2010); }); }); @@ -173,7 +176,7 @@ describe('BaseFirestoreTransactionRepository', () => { await bandRepository.runTransaction(async tran => { const band = await tran.create(entity); const foundBand = await tran.findById(band.id); - expect(band.id).toEqual(foundBand.id); + expect(band.id).toEqual(foundBand?.id); }); }); @@ -184,6 +187,9 @@ describe('BaseFirestoreTransactionRepository', () => { it('must update and return updated item', async () => { await bandRepository.runTransaction(async tran => { const band = await tran.findById('porcupine-tree'); + if (!band) { + throw new Error('Band not found'); + } band.name = 'Steven Wilson'; const updatedBand = await tran.update(band); expect(band.name).toEqual(updatedBand.name); @@ -193,10 +199,13 @@ describe('BaseFirestoreTransactionRepository', () => { it('must update and store updated item', async () => { await bandRepository.runTransaction(async tran => { const band = await tran.findById('porcupine-tree'); + if (!band) { + throw new Error('Band not found'); + } band.name = 'Steven Wilson'; await tran.update(band); const updatedBand = await tran.findById('porcupine-tree'); - expect(band.name).toEqual(updatedBand.name); + expect(band.name).toEqual(updatedBand?.name); }); }); @@ -205,16 +214,22 @@ describe('BaseFirestoreTransactionRepository', () => { await bandRepository.runTransaction(async tran => { const band = await tran.findById('porcupine-tree'); + if (!band) { + throw new Error('Band not found'); + } band.contactEmail = 'Not an email'; await tran.update(band); const updatedBand = await tran.findById('porcupine-tree'); - expect(updatedBand.contactEmail).toEqual('Not an email'); + expect(updatedBand?.contactEmail).toEqual('Not an email'); }); }); it('must fail validation if an invalid class is given', async () => { await bandRepository.runTransaction(async tran => { const band = await tran.findById('porcupine-tree'); + if (!band) { + throw new Error('Band not found'); + } band.contactEmail = 'Not an email'; @@ -382,7 +397,14 @@ describe('BaseFirestoreTransactionRepository', () => { it('should correctly parse dates', async () => { await bandRepository.runTransaction(async tran => { const pt = await tran.findById('porcupine-tree'); - const { releaseDate } = await pt.albums.findById('deadwing'); + if (!pt) { + throw new Error('Band not found'); + } + const album = await pt.albums?.findById('deadwing'); + if (!album) { + throw new Error('Album not found'); + } + const releaseDate = album.releaseDate; expect(releaseDate).toBeInstanceOf(Date); expect(releaseDate.toISOString()).toEqual('2005-03-25T00:00:00.000Z'); expect(pt.lastShow).toBeInstanceOf(Date); @@ -407,7 +429,7 @@ describe('BaseFirestoreTransactionRepository', () => { firstAlbum.name = '30 Seconds to Mars (Album)'; firstAlbum.releaseDate = new Date('2002-07-22'); - const album = await band.albums.create(firstAlbum); + const album = await band.albums?.create(firstAlbum); const image1 = new AlbumImage(); image1.id = 'image1'; @@ -417,16 +439,16 @@ describe('BaseFirestoreTransactionRepository', () => { image2.id = 'image2'; image2.url = 'http://image2.com'; - await album.images.create(image1); - await album.images.create(image2); + await album?.images?.create(image1); + await album?.images?.create(image2); - expect(band.albums.constructor.name).toEqual('TransactionRepository'); - expect(album.images.constructor.name).toEqual('TransactionRepository'); + expect(band?.albums?.constructor.name).toEqual('TransactionRepository'); + expect(album?.images?.constructor.name).toEqual('TransactionRepository'); }); }); it('should revert transaction repositories to normal repositories', () => { - expect(band.albums.constructor.name).toEqual('BaseFirestoreRepository'); + expect(band?.albums?.constructor.name).toEqual('BaseFirestoreRepository'); }); }); @@ -434,12 +456,15 @@ describe('BaseFirestoreTransactionRepository', () => { it('should initialize nested subcollections', async () => { await bandRepository.runTransaction(async tran => { const band = await tran.findById('red-hot-chili-peppers'); + if (!band) { + throw new Error('Band not found'); + } expect(band.name).toEqual('Red Hot Chili Peppers'); expect(band.albums).toBeInstanceOf(TransactionRepository); - const album = await band.albums.findById('stadium-arcadium'); - expect(album.name).toEqual('Stadium Arcadium'); - expect(album.images).toBeInstanceOf(TransactionRepository); + const album = await band.albums?.findById('stadium-arcadium'); + expect(album?.name).toEqual('Stadium Arcadium'); + expect(album?.images).toBeInstanceOf(TransactionRepository); }); }); @@ -468,6 +493,9 @@ describe('BaseFirestoreTransactionRepository', () => { await bandRepository.runTransaction(async tran => { await tran.create(band); const albumsRef = band.albums; + if (!albumsRef) { + throw new Error('Band not found'); + } await albumsRef.create(firstAlbum); await albumsRef.create(secondAlbum); @@ -496,7 +524,7 @@ describe('BaseFirestoreTransactionRepository', () => { const albumsRef = band.albums; try { - await albumsRef.create(firstAlbum); + await albumsRef?.create(firstAlbum); } catch (error) { expect(error[0].constraints.length).toEqual('Name is too long'); } @@ -506,29 +534,35 @@ describe('BaseFirestoreTransactionRepository', () => { it('should be able to update subcollections', async () => { await bandRepository.runTransaction(async tran => { const pt = await tran.findById('porcupine-tree'); - const albumsRef = pt.albums; + const albumsRef = pt?.albums; - const album = await albumsRef.findById('fear-blank-planet'); + const album = await albumsRef?.findById('fear-blank-planet'); + if (!album) { + throw new Error('Album not found'); + } album.comment = 'Anesthethize is top 3 IMHO'; - await albumsRef.update(album); + await albumsRef?.update(album); - const updatedAlbum = await albumsRef.findById('fear-blank-planet'); + const updatedAlbum = await albumsRef?.findById('fear-blank-planet'); - expect(updatedAlbum.comment).toEqual('Anesthethize is top 3 IMHO'); + expect(updatedAlbum?.comment).toEqual('Anesthethize is top 3 IMHO'); }); }); it('should be able to validate subcollections on update', async () => { await bandRepository.runTransaction(async tran => { const pt = await tran.findById('porcupine-tree'); - const albumsRef = pt.albums; + const albumsRef = pt?.albums; - const album = await albumsRef.findById('fear-blank-planet'); + const album = await albumsRef?.findById('fear-blank-planet'); + if (!album) { + throw new Error('Album not found'); + } album.name = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.'; try { - await albumsRef.update(album); + await albumsRef?.update(album); } catch (error) { expect(error[0].constraints.length).toEqual('Name is too long'); } @@ -538,6 +572,9 @@ describe('BaseFirestoreTransactionRepository', () => { it('should be able to update collections with subcollections', async () => { await bandRepository.runTransaction(async tran => { const pt = await tran.findById('porcupine-tree'); + if (!pt) { + throw new Error('Band not found'); + } pt.name = 'Porcupine Tree IS THE BEST'; const updatedPt = await tran.update(pt); const foundUpdatedPt = await tran.update(pt); @@ -550,11 +587,11 @@ describe('BaseFirestoreTransactionRepository', () => { it('should be able to delete subcollections', async () => { await bandRepository.runTransaction(async tran => { const pt = await tran.findById('porcupine-tree'); - const albumsRef = pt.albums; - await albumsRef.delete('fear-blank-planet'); + const albumsRef = pt?.albums; + await albumsRef?.delete('fear-blank-planet'); - const updatedBandAlbums = await albumsRef.find(); - expect(updatedBandAlbums.length).toEqual(3); + const updatedBandAlbums = await albumsRef?.find(); + expect(updatedBandAlbums?.length).toEqual(3); }); }); @@ -573,7 +610,7 @@ describe('BaseFirestoreTransactionRepository', () => { firstAlbum.name = '30 Seconds to Mars (Album)'; firstAlbum.releaseDate = new Date('2002-07-22'); - const album = await band.albums.create(firstAlbum); + const album = await band?.albums?.create(firstAlbum); const image1 = new AlbumImage(); image1.id = 'image1'; @@ -583,22 +620,28 @@ describe('BaseFirestoreTransactionRepository', () => { image2.id = 'image2'; image2.url = 'http://image2.com'; - await album.images.create(image1); - await album.images.create(image2); + await album?.images?.create(image1); + await album?.images?.create(image2); - const images = await album.images.find(); - expect(images.length).toEqual(2); + const images = await album?.images?.find(); + expect(images?.length).toEqual(2); }); await bandRepository.runTransaction(async tran => { const band = await tran.findById('30-seconds-to-mars'); - expect(band.name).toEqual('30 Seconds To Mars'); - const albums = await band.albums.find(); + expect(band?.name).toEqual('30 Seconds To Mars'); + const albums = await band?.albums?.find(); + if (!albums) { + throw new Error('Albums not found'); + } expect(albums.length).toEqual(1); expect(albums[0].name).toEqual('30 Seconds to Mars (Album)'); - const images = await albums[0].images.find(); + const images = await albums[0].images?.find(); + if (!images) { + throw new Error('Images not found'); + } expect(images.length).toEqual(2); expect(images[0].id).toEqual('image1'); }); diff --git a/src/Transaction/BaseFirestoreTransactionRepository.ts b/src/Transaction/BaseFirestoreTransactionRepository.ts index 9dcd2fb3..2b202423 100644 --- a/src/Transaction/BaseFirestoreTransactionRepository.ts +++ b/src/Transaction/BaseFirestoreTransactionRepository.ts @@ -17,10 +17,11 @@ export class TransactionRepository { constructor( pathOrConstructor: EntityConstructorOrPath, + colName: string, private transaction: Transaction, private tranRefStorage: ITransactionReferenceStorage ) { - super(pathOrConstructor); + super(pathOrConstructor, colName); this.transaction = transaction; this.tranRefStorage = tranRefStorage; } diff --git a/src/Transaction/FirestoreTransaction.spec.ts b/src/Transaction/FirestoreTransaction.spec.ts index acac2e9e..71dfc936 100644 --- a/src/Transaction/FirestoreTransaction.spec.ts +++ b/src/Transaction/FirestoreTransaction.spec.ts @@ -24,7 +24,7 @@ describe('FirestoreTransaction', () => { const innerTran = {} as Transaction; const tran = new FirestoreTransaction(innerTran, new Set()); - const bandRepository = tran.getRepository(Entity); + const bandRepository = tran.getRepository(Entity, 'Entities'); expect(bandRepository.constructor.name).toEqual('TransactionRepository'); }); }); diff --git a/src/Transaction/FirestoreTransaction.ts b/src/Transaction/FirestoreTransaction.ts index c93d1d9b..3615aaf6 100644 --- a/src/Transaction/FirestoreTransaction.ts +++ b/src/Transaction/FirestoreTransaction.ts @@ -16,11 +16,19 @@ export class FirestoreTransaction implements IFirestoreTransaction { private tranRefStorage: ITransactionReferenceStorage ) {} - getRepository(entityOrConstructor: EntityConstructorOrPath) { + getRepository( + entityOrConstructor: EntityConstructorOrPath, + colName: string + ) { if (!metadataStorage.firestoreRef) { throw new Error('Firestore must be initialized first'); } - return new TransactionRepository(entityOrConstructor, this.transaction, this.tranRefStorage); + return new TransactionRepository( + entityOrConstructor, + colName, + this.transaction, + this.tranRefStorage + ); } } diff --git a/src/TypeGuards.ts b/src/TypeGuards.ts index 833f4973..17266a77 100644 --- a/src/TypeGuards.ts +++ b/src/TypeGuards.ts @@ -1,4 +1,5 @@ import { Timestamp, GeoPoint, DocumentReference } from '@google-cloud/firestore'; +import { IEntityConstructor } from './types'; export function isTimestamp(x: unknown): x is Timestamp { return typeof x === 'object' && x !== null && 'toDate' in x; @@ -15,3 +16,7 @@ export function isDocumentReference(x: unknown): x is DocumentReference { export function isObject(x: unknown): x is Record { return typeof x === 'object'; } + +export function isConstructor(obj: any): obj is IEntityConstructor { + return obj && typeof obj === 'function' && obj.prototype; +} diff --git a/src/helpers.spec.ts b/src/helpers.spec.ts index d86ca316..a538ab8f 100644 --- a/src/helpers.spec.ts +++ b/src/helpers.spec.ts @@ -1,13 +1,44 @@ import { Collection, CustomRepository } from './Decorators'; import { BaseFirestoreRepository } from './BaseFirestoreRepository'; -import { getRepository, getBaseRepository, runTransaction, createBatch } from './helpers'; +import { + getRepository, + getBaseRepository, + runTransaction, + createBatch, + getCustomRepository, + getLastSegment, +} from './helpers'; import { initialize } from './MetadataUtils'; import { FirestoreTransaction } from './Transaction/FirestoreTransaction'; import { FirestoreBatch } from './Batch/FirestoreBatch'; +import { InvalidInputError, NoCollectionNameError, NoFirestoreError } from './Errors'; // eslint-disable-next-line @typescript-eslint/no-var-requires const MockFirebase = require('mock-cloud-firestore'); +describe('Error state', () => { + it('getRepository: should throw error if firebase is not initialized', () => { + @Collection() + class Entity { + id: string; + } + + expect(() => getRepository(Entity, 'Entities')).toThrow(NoFirestoreError); + }); + + it('runTransaction: should throw error if firebase is not initialized', async () => { + expect( + runTransaction(async () => { + const thing = 'value'; + }) + ).rejects.toThrow(NoFirestoreError); + }); + + it('createBatch: should throw error if firebase is not initialized', () => { + expect(() => createBatch()).toThrow(NoFirestoreError); + }); +}); + describe('Helpers', () => { beforeEach(() => { const firebase = new MockFirebase(); @@ -15,76 +46,145 @@ describe('Helpers', () => { initialize(firestore); }); - it('getRepository: should get custom repositories', () => { - @Collection() - class Entity { - id: string; - } + describe('getRepository', () => { + it('should get custom repositories', () => { + @Collection() + class Entity { + id: string; + } - @CustomRepository(Entity) - class EntityRepo extends BaseFirestoreRepository { - meaningOfLife() { - return 42; + @CustomRepository(Entity, 'Entities') + class EntityRepo extends BaseFirestoreRepository { + meaningOfLife() { + return 42; + } } - } - const rep = getRepository(Entity) as EntityRepo; - expect(rep).toBeInstanceOf(BaseFirestoreRepository); - expect(rep.meaningOfLife()).toEqual(42); - }); + const rep = getRepository(Entity, 'Entities') as EntityRepo; + expect(rep).toBeInstanceOf(BaseFirestoreRepository); + expect(rep.meaningOfLife()).toEqual(42); + }); - it('should get base repositories if custom are not registered', () => { - @Collection() - class Entity { - id: string; - } + it('should throw if an entity constructor is provided without a collection name', () => { + @Collection() + class Entity { + id: string; + } - const rep = getRepository(Entity); - expect(rep).toBeInstanceOf(BaseFirestoreRepository); - }); + expect(() => getRepository(Entity, '')).toThrow(NoCollectionNameError); + expect(() => getRepository(Entity)).toThrow(NoCollectionNameError); + expect(() => getRepository(Entity, undefined)).toThrow(NoCollectionNameError); + }); - it('should throw if trying to get an unexistent collection', () => { - class Entity { - id: string; - } + it('should get base repositories if custom are not registered', () => { + @Collection() + class Entity { + id: string; + } + + const rep = getRepository(Entity, 'Entities'); + expect(rep).toBeInstanceOf(BaseFirestoreRepository); + }); + + it('should throw if trying to get a nonexistent collection', () => { + class Entity { + id: string; + } - expect(() => getRepository(Entity)).toThrow("'Entity' is not a valid collection"); + expect(() => getRepository(Entity, 'Entities')).toThrow('Invalid collection path: Entities'); + }); }); - it('should get base repository even if a custom one is registered', () => { - @Collection() - class Entity { - id: string; - } + describe('getBaseRepository', () => { + it('should get base repository even if a custom one is registered', () => { + @Collection() + class Entity { + id: string; + } - @CustomRepository(Entity) - // eslint-disable-next-line @typescript-eslint/no-unused-vars - class EntityRepo extends BaseFirestoreRepository { - meaningOfLife() { - return 42; + @CustomRepository(Entity, 'Entities') + // eslint-disable-next-line @typescript-eslint/no-unused-vars + class EntityRepo extends BaseFirestoreRepository { + meaningOfLife() { + return 42; + } } - } - const rep = getBaseRepository(Entity); - expect(rep).toBeInstanceOf(BaseFirestoreRepository); - expect(rep['meaningOfLife']).toBeUndefined; + const rep = getBaseRepository(Entity, 'Entities'); + expect(rep).toBeInstanceOf(BaseFirestoreRepository); + expect(rep['meaningOfLife']).toBeUndefined; + }); + + it('should throw if no collection name is provided', () => { + @Collection() + class Entity { + id: string; + } + + expect(() => getBaseRepository(Entity, '')).toThrow(NoCollectionNameError); + }); }); - it('should throw if trying to get an unexistent collection', () => { - class Entity { - id: string; - } + describe('getCustomRepository', () => { + it('should throw if no collection name is provided', () => { + @Collection() + class Entity { + id: string; + } - expect(() => getRepository(Entity)).toThrow("'Entity' is not a valid collection"); + expect(() => getCustomRepository(Entity, '')).toThrow(NoCollectionNameError); + }); + + it('should get custom repositories', () => { + @Collection() + class Entity { + id: string; + } + + @CustomRepository(Entity, 'Entities') + class EntityRepo extends BaseFirestoreRepository { + meaningOfLife() { + return 42; + } + } + + const rep = getCustomRepository(Entity, 'Entities') as EntityRepo; + expect(rep).toBeInstanceOf(BaseFirestoreRepository); + expect(rep.meaningOfLife()).toEqual(42); + }); }); - it('runTransaction: should be able to get a transaction repository', async () => { - await runTransaction(async transaction => { - expect(transaction).toBeInstanceOf(FirestoreTransaction); + describe('runTransaction', () => { + it('should be able to get a transaction repository', async () => { + await runTransaction(async transaction => { + expect(transaction).toBeInstanceOf(FirestoreTransaction); + }); }); }); - it('createBatch: should be able to get a batch repository', () => { - expect(createBatch()).toBeInstanceOf(FirestoreBatch); + describe('createBatch', () => { + it('should be able to get a batch repository', () => { + const batch = createBatch(); + expect(batch).toBeInstanceOf(FirestoreBatch); + }); + }); + + describe('getLastSegment', () => { + it('should get the last segment of a path', () => { + const lastSegment = getLastSegment('users/user-id/messages/message-id/senders'); + expect(lastSegment).toEqual('senders'); + }); + + it('should throw if the path is incomplete', () => { + expect(() => getLastSegment('users/user-id/messages/message-id')).toThrow(InvalidInputError); + }); + + it('should throw if the path is empty', () => { + expect(() => getLastSegment('')).toThrow(InvalidInputError); + }); + + it('should throw if the last segment is empty', () => { + expect(() => getLastSegment('users/user-id/messages/message-id/')).toThrow(InvalidInputError); + }); }); }); diff --git a/src/helpers.ts b/src/helpers.ts index c8fe181a..127976ea 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -8,8 +8,14 @@ import { } from './types'; import { FirestoreTransaction } from './Transaction/FirestoreTransaction'; import { FirestoreBatch } from './Batch/FirestoreBatch'; -import { BaseRepository } from './BaseRepository'; -import { AbstractFirestoreRepository } from './AbstractFirestoreRepository'; +import { + InvalidCollectionOrPathError, + InvalidInputError, + NoCollectionNameError, + NoCustomRepositoryError, + NoFirestoreError, + NoParentCollectionError, +} from './Errors'; type RepositoryType = 'default' | 'base' | 'custom' | 'transaction'; @@ -19,49 +25,61 @@ function _getRepository< R extends BaseFirestoreRepository = BaseFirestoreRepository >( entityConstructorOrPath: EntityConstructorOrPath, + providedCollectionName?: string, repositoryType?: RepositoryType // Optional parameter ): R { const metadataStorage = getMetadataStorage(); - if (!metadataStorage.firestoreRef) { - throw new Error('Firestore must be initialized first'); + throw new NoFirestoreError('_getRepository'); } - const collection = metadataStorage.getCollection(entityConstructorOrPath); - const isPath = typeof entityConstructorOrPath === 'string'; - const collectionName = - typeof entityConstructorOrPath === 'string' - ? entityConstructorOrPath - : entityConstructorOrPath.name; + let collectionName: string; + if (isPath) { + collectionName = getLastSegment(entityConstructorOrPath); + } else if (providedCollectionName) { + collectionName = providedCollectionName; + } else { + throw new NoCollectionNameError(); + } + + const collection = metadataStorage.getCollection(entityConstructorOrPath, collectionName); if (!collection) { - const error = isPath - ? `'${collectionName}' is not a valid path for a collection` - : `'${collectionName}' is not a valid collection`; - throw new Error(error); + throw new InvalidCollectionOrPathError(collectionName, isPath); } - const repository = metadataStorage.getRepository(collection.entityConstructor); + const repository = metadataStorage.getRepository(collection.entityConstructor, collectionName); const useCustomRepository = repositoryType === 'custom' || (!repositoryType && repository); if (useCustomRepository && !repository) { - throw new Error(`'${collectionName}' does not have a custom repository.`); + throw new NoCustomRepositoryError(collectionName); } - if (collection.parentEntityConstructor) { - const parentCollection = metadataStorage.getCollection(collection.parentEntityConstructor); + // Get the parent collection if it exists + if (collection.parentProps) { + const { parentCollectionName, parentEntityConstructor } = collection.parentProps; + const parentCollection = metadataStorage.getCollection( + parentEntityConstructor, + parentCollectionName + ); if (!parentCollection) { - throw new Error(`'${collectionName}' does not have a valid parent collection.`); + throw new NoParentCollectionError(collectionName); } } const RepositoryClass = useCustomRepository - ? (repository?.target as new (pathOrConstructor: string | IEntityConstructor) => R) - : (BaseFirestoreRepository as new (pathOrConstructor: string | IEntityConstructor) => R); - - return new RepositoryClass(entityConstructorOrPath); + ? (repository?.target as new ( + pathOrConstructor: string | IEntityConstructor, + colName: string + ) => R) + : (BaseFirestoreRepository as new ( + pathOrConstructor: string | IEntityConstructor, + colName: string + ) => R); + + return new RepositoryClass(entityConstructorOrPath, collectionName); } export function getRepository< @@ -69,9 +87,10 @@ export function getRepository< R extends BaseFirestoreRepository = BaseFirestoreRepository >( entityConstructorOrPath: EntityConstructorOrPath, + collectionName?: string, repositoryType?: RepositoryType // Optional parameter ): R { - return _getRepository(entityConstructorOrPath, repositoryType); + return _getRepository(entityConstructorOrPath, collectionName, repositoryType); } /** @@ -79,19 +98,31 @@ export function getRepository< */ export const GetRepository = getRepository; -export function getCustomRepository(entityOrPath: EntityConstructorOrPath) { - return _getRepository(entityOrPath, 'custom'); +export function getCustomRepository( + entityOrPath: EntityConstructorOrPath, + collectionName: string +) { + // TODO: Add tests for calling this with both an entity and a path + if (!collectionName) { + throw new NoCollectionNameError(); + } + return _getRepository(entityOrPath, collectionName, 'custom'); } - /** * @deprecated Use getCustomRepository. This will be removed in a future version. */ export const GetCustomRepository = getCustomRepository; -export function getBaseRepository(entityOrPath: EntityConstructorOrPath) { - return _getRepository(entityOrPath, 'base'); +export function getBaseRepository( + entityOrPath: EntityConstructorOrPath, + collectionName: string +) { + // TODO: Add tests for calling this with both an entity and a path + if (!collectionName) { + throw new NoCollectionNameError(); + } + return _getRepository(entityOrPath, collectionName, 'base'); } - /** * @deprecated Use getBaseRepository. This will be removed in a future version. */ @@ -101,16 +132,16 @@ export const runTransaction = async (executor: (tran: FirestoreTransaction) = const metadataStorage = getMetadataStorage(); if (!metadataStorage.firestoreRef) { - throw new Error('Firestore must be initialized first'); + throw new NoFirestoreError('runTransaction'); } return metadataStorage.firestoreRef.runTransaction(async t => { const tranRefStorage: ITransactionReferenceStorage = new Set(); const result = await executor(new FirestoreTransaction(t, tranRefStorage)); - tranRefStorage.forEach(({ entity, path, propertyKey }) => { + tranRefStorage.forEach(({ entity, path, parentPropertyKey }) => { const record = entity as unknown as Record; - record[propertyKey] = getRepository(path); + record[parentPropertyKey] = getRepository(path); }); return result; @@ -127,8 +158,29 @@ export const createBatch = () => { const metadataStorage = getMetadataStorage(); if (!metadataStorage.firestoreRef) { - throw new Error('Firestore must be initialized first'); + throw new NoFirestoreError('createBatch'); } return new FirestoreBatch(metadataStorage.firestoreRef); }; + +export function getLastSegment(path: string): string { + if (!path || typeof path !== 'string') { + throw new InvalidInputError('Path must be a non-empty string'); + } + + const segments = path.split('/'); + const segmentCount = segments.length; + + if (segmentCount === 0 || segmentCount % 2 === 0) { + throw new InvalidInputError('Path must have an odd number of segments greater than 0'); + } + + const lastSegment = segments.pop(); + + if (!lastSegment) { + throw new InvalidInputError('Path segments cannot be empty strings'); + } + + return lastSegment; +} diff --git a/src/tsconfig.json b/src/tsconfig.json index 0dff9b64..7b15c528 100644 --- a/src/tsconfig.json +++ b/src/tsconfig.json @@ -3,7 +3,10 @@ "include": ["Decorators"], "exclude": ["**/*.spec.ts"], "compilerOptions": { - "noEmit": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "target": "ES6", + "module": "commonjs", "strict": true } } diff --git a/src/types.ts b/src/types.ts index 2fcd4061..c7ea7e69 100644 --- a/src/types.ts +++ b/src/types.ts @@ -97,7 +97,8 @@ export interface IFirestoreBatchSingleRepository extends IBat export interface IFirestoreBatch { getRepository(entity: Constructor): IBatchRepository; getSingleRepository( - pathOrConstructor: EntityConstructorOrPath + pathOrConstructor: EntityConstructorOrPath, + collectionName: string ): IFirestoreBatchSingleRepository; commit(): Promise; @@ -118,7 +119,7 @@ export type ITransactionRepository = IRepository; export interface ITransactionReference { entity: T; - propertyKey: string; + parentPropertyKey: string; path: string; } @@ -136,12 +137,12 @@ export interface IEntity { export type Constructor = { new (...args: any[]): T }; export type EntityConstructorOrPathConstructor = { new (...args: any[]): T }; -export type IEntityConstructor = Constructor; +export type IEntityConstructor = Constructor; export type IEntityRepositoryConstructor = Constructor>; export type EntityConstructorOrPath = Constructor | string; export interface IFirestoreTransaction { - getRepository(entityOrConstructor: EntityConstructorOrPath): IRepository; + getRepository(entityOrConstructor: EntityConstructorOrPath, colName: string): IRepository; } /** @@ -209,3 +210,12 @@ export interface ValidatorOptions { export type FirestoreSerializable = { [key: string]: FirebaseFirestore.FieldValue | Partial | undefined; }; + +/** + * A type that represents the parent properties of a sub-collection + */ +export type ParentProperties = { + parentEntityConstructor: IEntityConstructor; + parentPropertyKey: string; + parentCollectionName: string; +}; diff --git a/src/utils.ts b/src/utils.ts index 9ffd9faf..dc00e493 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,5 @@ import { ignoreKey, serializeKey } from './Decorators'; -import { SubCollectionMetadata } from './MetadataStorage'; +import { CollectionMetadataWithSegments } from './MetadataStorage'; import { IEntity, FirestoreSerializable } from '.'; /** @@ -44,7 +44,7 @@ export function extractAllGetters(obj: Record) { */ export function serializeEntity( obj: Partial, - subColMetadata: SubCollectionMetadata[] + subColMetadata: CollectionMetadataWithSegments[] ): FirestoreSerializable { const objectGetters = extractAllGetters(obj as Record); const serializableObj: FirestoreSerializable = {}; @@ -52,9 +52,14 @@ export function serializeEntity( // Merge original properties and getters const combinedObj = { ...obj, ...objectGetters }; - // Remove sub-collection metadata properties + // Remove properties linking to a subcollection subColMetadata.forEach(scm => { - delete combinedObj[scm.propertyKey]; + // top level collections shouldn't be in MetadataStorage.subCollections + // so this shouldn't be necessary, but it's here just in case + if (scm.parentProps === null) { + return; + } + delete combinedObj[scm.parentProps.parentPropertyKey]; }); // Process each property and ensure it fits the expected return type diff --git a/test/BandCollection.ts b/test/BandCollection.ts index 4057a345..dab8da01 100644 --- a/test/BandCollection.ts +++ b/test/BandCollection.ts @@ -25,7 +25,7 @@ export class Album extends AlbumEntity { @Length(1, 50, { message: 'Name is too long' }) name: string; - @SubCollection(AlbumImage, 'images') + @SubCollection(AlbumImage) images?: ISubCollection; } @@ -33,7 +33,7 @@ export class Album extends AlbumEntity { export class Band { id: string; name: string; - formationYear: number; + formationYear: number | null; lastShow: Date; @IsOptional() @@ -45,7 +45,7 @@ export class Band { lastShowCoordinates: Coordinates; genres: Array; - @SubCollection(Album, 'albums') + @SubCollection(Album) albums?: ISubCollection; @Type(() => FirestoreDocumentReference) diff --git a/test/functional/1-simple_repository.spec.ts b/test/functional/1-simple_repository.spec.ts index 9d948d2e..8f96e532 100644 --- a/test/functional/1-simple_repository.spec.ts +++ b/test/functional/1-simple_repository.spec.ts @@ -3,13 +3,14 @@ import { Band as BandEntity } from '../fixture'; import { getUniqueColName } from '../setup'; describe('Integration test: Simple Repository', () => { - @Collection(getUniqueColName('band-simple-repository')) + const bandCollectionName: string = getUniqueColName('band-simple-repository'); + @Collection(bandCollectionName) class Band extends BandEntity { extra?: { website: string }; } test('should do crud operations', async () => { - const bandRepository = getRepository(Band); + const bandRepository = getRepository(Band, bandCollectionName); // Create a band const dt = new Band(); dt.id = 'dream-theater'; diff --git a/test/functional/9-queries.spec.ts b/test/functional/10-queries.spec.ts similarity index 96% rename from test/functional/9-queries.spec.ts rename to test/functional/10-queries.spec.ts index a15d8897..e520bdea 100644 --- a/test/functional/9-queries.spec.ts +++ b/test/functional/10-queries.spec.ts @@ -3,14 +3,15 @@ import { Band as BandEntity } from '../fixture'; import { getUniqueColName } from '../setup'; describe('Integration test: Queries', () => { - @Collection(getUniqueColName('band-queries-test')) + const bandCollectionName = getUniqueColName('band-queries-test'); + @Collection(bandCollectionName) class Band extends BandEntity {} let bandRepository: BaseFirestoreRepository; beforeEach(async () => { // instantiate the repositories - bandRepository = getRepository(Band); + bandRepository = getRepository(Band, bandCollectionName); const ewf = new Band(); ewf.id = 'earth-wind-and-fire'; diff --git a/test/functional/2-custom_repositories.spec.ts b/test/functional/2-custom_repositories.spec.ts index 29463b7e..b989cb49 100644 --- a/test/functional/2-custom_repositories.spec.ts +++ b/test/functional/2-custom_repositories.spec.ts @@ -3,16 +3,18 @@ import { CustomRepository, BaseFirestoreRepository, getRepository, Collection } import { getUniqueColName } from '../setup'; describe('Integration test: Custom Repository', () => { - @Collection(getUniqueColName('band-custom-repository')) + const bandCollectionName: string = getUniqueColName('band-custom-repository'); + @Collection(bandCollectionName) class Band extends BandEntity {} - @Collection(getUniqueColName('bus-custom-repository')) + const busCollectionName: string = getUniqueColName('bus-custom-repository'); + @Collection(busCollectionName) class Bus extends BandEntity {} - @CustomRepository(Bus) + @CustomRepository(Bus, busCollectionName) class CustomBusRepository extends BaseFirestoreRepository {} - @CustomRepository(Band) + @CustomRepository(Band, bandCollectionName) class CustomRockBandRepository extends BaseFirestoreRepository { filterByGenre(genre: string) { return this.whereArrayContains('genres', genre); @@ -31,8 +33,8 @@ describe('Integration test: Custom Repository', () => { beforeEach(async () => { // see comment above - rockBandRepository = getRepository(Band); - tourBusRepository = getRepository(Bus); + rockBandRepository = getRepository(Band, bandCollectionName); + tourBusRepository = getRepository(Bus, busCollectionName); // Clear all documents await rockBandRepository.clear(); diff --git a/test/functional/3-subcollections.spec.ts b/test/functional/3-subcollections.spec.ts index 505e1173..ec2609b4 100644 --- a/test/functional/3-subcollections.spec.ts +++ b/test/functional/3-subcollections.spec.ts @@ -12,7 +12,8 @@ import { TransactionRepository } from '../../src/Transaction/BaseFirestoreTransa describe('Integration test: SubCollections', () => { class Album extends AlbumEntity {} - @Collection(getUniqueColName('band-with-subcollections')) + const fullBandCollectionName: string = getUniqueColName('band-with-subcollections'); + @Collection(fullBandCollectionName) class FullBand extends BandEntity { @SubCollection(Album) albums: BaseFirestoreRepository; @@ -21,7 +22,7 @@ describe('Integration test: SubCollections', () => { let fullBandRepository: BaseFirestoreRepository = null; beforeEach(async () => { - fullBandRepository = getRepository(FullBand); + fullBandRepository = getRepository(FullBand, fullBandCollectionName); const seed = getInitialData().map(({ albums, ...band }) => ({ band, albums, @@ -146,7 +147,7 @@ describe('Integration test: SubCollections', () => { class Label implements IEntity { id: string; - @SubCollection(FullBand, makeUnique('band-subcollection')) + @SubCollection(FullBand) bands: BaseFirestoreRepository; } diff --git a/test/functional/4-transactions.spec.ts b/test/functional/4-transactions.spec.ts index ae04760f..09138485 100644 --- a/test/functional/4-transactions.spec.ts +++ b/test/functional/4-transactions.spec.ts @@ -12,7 +12,8 @@ import { getUniqueColName } from '../setup'; describe('Integration test: Transactions', () => { class Album extends AlbumEntity {} - @Collection(getUniqueColName('band-with-transactions')) + const bandCollectionName: string = getUniqueColName('band-with-transactions'); + @Collection(bandCollectionName) class Band extends BandEntity { extra?: { website: string }; @@ -23,7 +24,7 @@ describe('Integration test: Transactions', () => { let bandRepository: BaseFirestoreRepository = null; beforeEach(() => { - bandRepository = getRepository(Band); + bandRepository = getRepository(Band, bandCollectionName); }); it('should do CRUD operations inside transactions in repositories', async () => { @@ -129,7 +130,7 @@ describe('Integration test: Transactions', () => { }; const savedBand = await runTransaction(async tran => { - const bandTranRepository = tran.getRepository(Band); + const bandTranRepository = tran.getRepository(Band, bandCollectionName); await bandTranRepository.create(ti); return bandTranRepository.create(dt); }); @@ -146,7 +147,7 @@ describe('Integration test: Transactions', () => { devinT.genres = ['progressive-metal', 'extreme-metal']; await runTransaction(async tran => { - const bandTranRepository = tran.getRepository(Band); + const bandTranRepository = tran.getRepository(Band, bandCollectionName); const savedBandWithoutId = await bandTranRepository.create(devinT); expect(savedBandWithoutId.name).toEqual(devinT.name); @@ -155,7 +156,7 @@ describe('Integration test: Transactions', () => { // Read a band inside transaction await runTransaction(async tran => { - const bandTranRepository = tran.getRepository(Band); + const bandTranRepository = tran.getRepository(Band, bandCollectionName); const foundBand = await bandTranRepository.findById(dt.id); expect(foundBand.id).toEqual(dt.id); @@ -164,7 +165,7 @@ describe('Integration test: Transactions', () => { // Update a band inside transaction const updatedBand = await runTransaction(async tran => { - const bandTranRepository = tran.getRepository(Band); + const bandTranRepository = tran.getRepository(Band, bandCollectionName); const dream = await bandTranRepository.findById(dt.id); @@ -181,7 +182,7 @@ describe('Integration test: Transactions', () => { // Filter a band by subfield inside transaction await runTransaction(async tran => { - const bandTranRepository = tran.getRepository(Band); + const bandTranRepository = tran.getRepository(Band, bandCollectionName); const byWebsite = await bandTranRepository .whereEqualTo(a => a.extra.website, 'www.dreamtheater.net') @@ -191,7 +192,7 @@ describe('Integration test: Transactions', () => { // Delete a band await runTransaction(async tran => { - const bandTranRepository = tran.getRepository(Band); + const bandTranRepository = tran.getRepository(Band, bandCollectionName); await bandTranRepository.delete(dt.id); }); @@ -225,7 +226,7 @@ describe('Integration test: Transactions', () => { ]; await runTransaction(async tran => { - const bandTranRepository = tran.getRepository(Band); + const bandTranRepository = tran.getRepository(Band, bandCollectionName); const created = await bandTranRepository.create(band); for (const a of albums) { diff --git a/test/functional/5-batches.spec.ts b/test/functional/5-batches.spec.ts index 0cf49755..a04fc641 100644 --- a/test/functional/5-batches.spec.ts +++ b/test/functional/5-batches.spec.ts @@ -11,7 +11,9 @@ import { getUniqueColName } from '../setup'; describe('Integration test: Batches', () => { class Album extends AlbumEntity {} - @Collection(getUniqueColName('band-in-batch')) + + const bandCollectionName: string = getUniqueColName('band-in-batch'); + @Collection(bandCollectionName) class Band extends BandEntity { extra?: { website: string }; @@ -22,7 +24,7 @@ describe('Integration test: Batches', () => { let bandRepository: BaseFirestoreRepository = null; beforeEach(() => { - bandRepository = getRepository(Band); + bandRepository = getRepository(Band, bandCollectionName); }); it('should do CRUD operations inside batches in repositories', async () => { @@ -128,7 +130,7 @@ describe('Integration test: Batches', () => { ]; const batch = createBatch(); - const bandBatchRepository = batch.getRepository(Band); + const bandBatchRepository = batch.getRepository(Band, bandCollectionName); bands.forEach(b => bandBatchRepository.create(b)); await batch.commit(); @@ -146,7 +148,7 @@ describe('Integration test: Batches', () => { // Update website for all bands with an update batch const updateBatch = createBatch(); - const bandUpdateBatch = updateBatch.getRepository(Band); + const bandUpdateBatch = updateBatch.getRepository(Band, bandCollectionName); createdBands.forEach(b => { b.extra = { website: 'https://fake.web' }; @@ -166,7 +168,7 @@ describe('Integration test: Batches', () => { // Delete bands with an delete batch const deleteBatch = createBatch(); - const bandDeleteBatch = deleteBatch.getRepository(Band); + const bandDeleteBatch = deleteBatch.getRepository(Band, bandCollectionName); createdBands.forEach(b => bandDeleteBatch.delete(b)); @@ -181,7 +183,7 @@ describe('Integration test: Batches', () => { }); it('should do CRUD operations in subcollections', async () => { - const bandRepository = getRepository(Band); + const bandRepository = getRepository(Band, bandCollectionName); const band = await bandRepository.create({ name: 'Opeth', diff --git a/test/functional/6-document-references.spec.ts b/test/functional/6-document-references.spec.ts index 8feb46f6..7d57a486 100644 --- a/test/functional/6-document-references.spec.ts +++ b/test/functional/6-document-references.spec.ts @@ -15,7 +15,7 @@ describe('Integration test: Using Document References', () => { let bandRepository: BaseFirestoreRepository = null; beforeEach(() => { - bandRepository = getRepository(Band); + bandRepository = getRepository(Band, colName); /* * Yes, this is a hack. diff --git a/test/functional/7-validations.spec.ts b/test/functional/7-validations.spec.ts index 1cbe0030..7d6d5c69 100644 --- a/test/functional/7-validations.spec.ts +++ b/test/functional/7-validations.spec.ts @@ -9,13 +9,14 @@ describe('Integration test: Validations', () => { const firestore = (global as any).firestoreRef as Firestore; initialize(firestore, { validateModels: true }); - @Collection(getUniqueColName('validations')) + const bandCollectionName: string = getUniqueColName('validations'); + @Collection(bandCollectionName) class Band extends BandEntity { @IsEmail() contactEmail: string; } - const bandRepository = getRepository(Band); + const bandRepository = getRepository(Band, bandCollectionName); it('should do crud operations with validations', async () => { // Should create a band when passing a valid email diff --git a/test/functional/8-ignore-properties.spec.ts b/test/functional/8-ignore-properties.spec.ts index 4c39121c..45e51736 100644 --- a/test/functional/8-ignore-properties.spec.ts +++ b/test/functional/8-ignore-properties.spec.ts @@ -3,14 +3,15 @@ import { Band as BandEntity } from '../fixture'; import { getUniqueColName } from '../setup'; describe('Integration test: Ignore Properties', () => { - @Collection(getUniqueColName('band-simple-repository')) + const bandCollectionName: string = getUniqueColName('band-simple-repository-ignore-properties'); + @Collection(bandCollectionName) class Band extends BandEntity { @Ignore() temporaryName: string; } test('should ignore properties decorated with Ignore()', async () => { - const bandRepository = getRepository(Band); + const bandRepository = getRepository(Band, bandCollectionName); // Create a band const dt = new Band(); dt.id = 'dream-theater'; diff --git a/test/functional/8-serialized-properties.spec.ts b/test/functional/9-serialized-properties.spec.ts similarity index 82% rename from test/functional/8-serialized-properties.spec.ts rename to test/functional/9-serialized-properties.spec.ts index 386564cc..2179e791 100644 --- a/test/functional/8-serialized-properties.spec.ts +++ b/test/functional/9-serialized-properties.spec.ts @@ -14,26 +14,29 @@ describe('Integration test: Serialized properties', () => { website: Website; } - @Collection(getUniqueColName('band-serialized-repository')) + const bandCollectionName: string = getUniqueColName('band-serialized-repository'); + @Collection(bandCollectionName) class Band extends BandEntity { @Serialize(Website) website: Website; } - @Collection(getUniqueColName('band-serialized-repository')) + const deepBandCollectionName: string = getUniqueColName('band-serialized-repository'); + @Collection(deepBandCollectionName) class DeepBand extends BandEntity { @Serialize(Manager) manager: Manager; } - @Collection(getUniqueColName('band-serialized-repository')) + const fancyBandCollectionName: string = getUniqueColName('band-serialized-repository'); + @Collection(fancyBandCollectionName) class FancyBand extends BandEntity { @Serialize(Website) websites: Website[]; } test('should instantiate serialized objects with the correct class upon retrieval', async () => { - const bandRepository = getRepository(Band); + const bandRepository = getRepository(Band, bandCollectionName); const dt = new Band(); dt.name = 'DreamTheater'; dt.formationYear = 1985; @@ -50,7 +53,7 @@ describe('Integration test: Serialized properties', () => { }); test('should instantiate serialized objects with the correct class upon retrieval recursively', async () => { - const bandRepository = getRepository(DeepBand); + const bandRepository = getRepository(DeepBand, deepBandCollectionName); const sb = new DeepBand(); sb.name = 'the Speckled Band'; sb.formationYear = 1931; @@ -71,7 +74,7 @@ describe('Integration test: Serialized properties', () => { }); test('should instantiate serialized objects arrays with the correct class upon retrieval', async () => { - const bandRepository = getRepository(FancyBand); + const bandRepository = getRepository(FancyBand, fancyBandCollectionName); const dt = new FancyBand(); dt.name = 'DreamTheater'; dt.formationYear = 1985;