diff --git a/src/AbstractFirestoreRepository.ts b/src/AbstractFirestoreRepository.ts index 166077a..57fd85e 100644 --- a/src/AbstractFirestoreRepository.ts +++ b/src/AbstractFirestoreRepository.ts @@ -5,6 +5,7 @@ import { CollectionReference, Transaction, } from '@google-cloud/firestore'; +import { serializeKey } from './Decorators/Serialize'; import { ValidationError } from './Errors/ValidationError'; import { @@ -114,6 +115,40 @@ export abstract class AbstractFirestoreRepository }); }; + protected initializeSerializedObjects(entity: T) { + Object.keys(entity).forEach(propertyKey => { + if (Reflect.getMetadata(serializeKey, entity, propertyKey) !== undefined) { + const constructor = Reflect.getMetadata(serializeKey, entity, propertyKey); + const data = entity as unknown as { [k: string]: unknown }; + const subData = data[propertyKey] as { [k: string]: unknown }; + + if (Array.isArray(subData)) { + (entity as unknown as { [key: string]: unknown })[propertyKey] = subData.map(value => { + const subEntity = new constructor(); + + for (const i in value) { + subEntity[i] = value[i]; + } + + this.initializeSerializedObjects(subEntity); + + return subEntity; + }); + } else { + const subEntity = new constructor(); + + for (const i in subData) { + subEntity[i] = subData[i]; + } + + this.initializeSerializedObjects(subEntity); + + (entity as unknown as { [key: string]: unknown })[propertyKey] = subEntity; + } + } + }); + } + protected extractTFromDocSnap = ( doc: DocumentSnapshot, tran?: Transaction, @@ -125,6 +160,7 @@ export abstract class AbstractFirestoreRepository }) as T; this.initializeSubCollections(entity, tran, tranRefStorage); + this.initializeSerializedObjects(entity); return entity; }; diff --git a/src/BaseFirestoreRepository.spec.ts b/src/BaseFirestoreRepository.spec.ts index e4a64b7..97a40b7 100644 --- a/src/BaseFirestoreRepository.spec.ts +++ b/src/BaseFirestoreRepository.spec.ts @@ -5,6 +5,8 @@ import { Coordinates, FirestoreDocumentReference, AlbumImage, + Agent, + Website, } from '../test/fixture'; import { BaseFirestoreRepository } from './BaseFirestoreRepository'; import { Band } from '../test/BandCollection'; @@ -67,7 +69,7 @@ describe('BaseFirestoreRepository', () => { it('must not throw any exceptions if a query with no results is limited', async () => { const oldBands = await bandRepository - .whereLessOrEqualThan('formationYear', 1930) + .whereLessOrEqualThan('formationYear', 1688) .limit(4) .find(); expect(oldBands.length).toEqual(0); @@ -91,7 +93,7 @@ describe('BaseFirestoreRepository', () => { describe('orderByAscending', () => { it('must order repository objects', async () => { const bands = await bandRepository.orderByAscending('formationYear').find(); - expect(bands[0].id).toEqual('pink-floyd'); + expect(bands[0].id).toEqual('the-speckled-band'); }); it('must order the objects in a subcollection', async () => { @@ -112,7 +114,7 @@ describe('BaseFirestoreRepository', () => { }); it('must be chainable with limit', async () => { - const bands = await bandRepository.orderByAscending('formationYear').limit(2).find(); + const bands = await bandRepository.orderByAscending('formationYear').limit(3).find(); const lastBand = bands[bands.length - 1]; expect(lastBand.id).toEqual('red-hot-chili-peppers'); }); @@ -432,7 +434,7 @@ describe('BaseFirestoreRepository', () => { it('must filter with whereNotEqualTo', async () => { const list = await bandRepository.whereNotEqualTo('name', 'Porcupine Tree').find(); - expect(list.length).toEqual(1); + expect(list.length).toEqual(2); expect(list[0].formationYear).toEqual(1983); }); @@ -449,12 +451,12 @@ describe('BaseFirestoreRepository', () => { it('must filter with whereLessThan', async () => { const list = await bandRepository.whereLessThan('formationYear', 1983).find(); - expect(list.length).toEqual(1); + expect(list.length).toEqual(2); }); it('must filter with whereLessOrEqualThan', async () => { const list = await bandRepository.whereLessOrEqualThan('formationYear', 1983).find(); - expect(list.length).toEqual(2); + expect(list.length).toEqual(3); }); it('must filter with whereArrayContains', async () => { @@ -863,4 +865,16 @@ describe('BaseFirestoreRepository', () => { expect(possibleDocWithoutId).not.toBeUndefined(); }); }); + + describe('deserialization', () => { + it('should correctly initialize a repository with an entity', async () => { + const bandRepositoryWithPath = new BandRepository(Band); + const band = await bandRepositoryWithPath.findById('the-speckled-band'); + expect(band.name).toEqual('the Speckled Band'); + expect(band.agents[0]).toBeInstanceOf(Agent); + expect(band.agents[0].name).toEqual('Mycroft Holmes'); + expect(band.agents[0].website).toBeInstanceOf(Website); + expect(band.agents[0].website.url).toEqual('en.wikipedia.org/wiki/Mycroft_Holmes'); + }); + }); }); diff --git a/src/Decorators/Serialize.spec.ts b/src/Decorators/Serialize.spec.ts new file mode 100644 index 0000000..2d4f784 --- /dev/null +++ b/src/Decorators/Serialize.spec.ts @@ -0,0 +1,22 @@ +import { Serialize, serializeKey } from './Serialize'; + +describe('IgnoreDecorator', () => { + it('should decorate properties', () => { + class Address { + streetName: string; + zipcode: string; + } + + class Band { + id: string; + name: string; + @Serialize(Address) + address: Address; + } + + const band = new Band(); + + expect(Reflect.getMetadata(serializeKey, band, 'name')).toBe(undefined); + expect(Reflect.getMetadata(serializeKey, band, 'address')).toBe(Address); + }); +}); diff --git a/src/Decorators/Serialize.ts b/src/Decorators/Serialize.ts new file mode 100644 index 0000000..51476bb --- /dev/null +++ b/src/Decorators/Serialize.ts @@ -0,0 +1,7 @@ +import 'reflect-metadata'; +import { Constructor } from '../types'; +export const serializeKey = Symbol('Serialize'); + +export function Serialize(entityConstructor: Constructor) { + return Reflect.metadata(serializeKey, entityConstructor); +} diff --git a/src/Decorators/index.ts b/src/Decorators/index.ts index 669cf23..5922b41 100644 --- a/src/Decorators/index.ts +++ b/src/Decorators/index.ts @@ -1,4 +1,5 @@ export * from './Collection'; export * from './CustomRepository'; -export * from './SubCollection'; export * from './Ignore'; +export * from './Serialize'; +export * from './SubCollection'; diff --git a/src/Transaction/BaseFirestoreTransactionRepository.spec.ts b/src/Transaction/BaseFirestoreTransactionRepository.spec.ts index e95c1bd..2afc2f0 100644 --- a/src/Transaction/BaseFirestoreTransactionRepository.spec.ts +++ b/src/Transaction/BaseFirestoreTransactionRepository.spec.ts @@ -316,14 +316,14 @@ describe('BaseFirestoreTransactionRepository', () => { await bandRepository.runTransaction(async tran => { const list = await tran.whereLessThan('formationYear', 1983).find(); - expect(list.length).toEqual(1); + expect(list.length).toEqual(2); }); }); it('must filter with whereLessOrEqualThan', async () => { await bandRepository.runTransaction(async tran => { - const list = await tran.whereLessOrEqualThan('formationYear', 1983).find(); - expect(list.length).toEqual(2); + const list = await tran.whereLessOrEqualThan('formationYear', 1900).find(); + expect(list.length).toEqual(1); }); }); diff --git a/src/utils.spec.ts b/src/utils.spec.ts index 3fd674e..d756ba3 100644 --- a/src/utils.spec.ts +++ b/src/utils.spec.ts @@ -1,4 +1,4 @@ -import { Ignore } from './Decorators/Ignore'; +import { Ignore, Serialize } from './Decorators'; import { IEntity } from './types'; import { extractAllGetters, serializeEntity } from './utils'; @@ -75,5 +75,67 @@ describe('Utils', () => { expect(serializeEntity(rhcp, [])).toHaveProperty('name'); expect(serializeEntity(rhcp, [])).not.toHaveProperty('temporaryName'); }); + + it('should serialize object properties with the @Serialize() decorator', () => { + class Address { + streetName: string; + number: number; + numberAddition: string; + } + + class Band implements IEntity { + id: string; + name: string; + @Serialize(Address) + address: Address; + } + + const address = new Address(); + address.streetName = 'Baker St.'; + address.number = 211; + address.numberAddition = 'B'; + + const band = new Band(); + band.name = 'the Speckled Band'; + band.address = address; + + expect(serializeEntity(band, [])).toHaveProperty('name'); + expect(serializeEntity(band, []).address).not.toBeInstanceOf(Address); + expect(serializeEntity(band, []).address['number']).toBe(211); + }); + + it('should serialize object array properties with the @Serialize() decorator', () => { + class Address { + streetName: string; + number: number; + numberAddition: string; + } + + class Band implements IEntity { + id: string; + name: string; + @Serialize(Address) + addresses: Address[]; + } + + const address = new Address(); + address.streetName = 'Baker St.'; + address.number = 211; + address.numberAddition = 'B'; + + const address2 = new Address(); + address2.streetName = 'Baker St.'; + address2.number = 211; + address2.numberAddition = 'C'; + + const band = new Band(); + band.name = 'the Speckled Band'; + band.addresses = [address, address2]; + + expect(serializeEntity(band, [])).toHaveProperty('name'); + expect(serializeEntity(band, []).addresses[0]).not.toBeInstanceOf(Address); + expect(serializeEntity(band, []).addresses[0]['numberAddition']).toBe('B'); + expect(serializeEntity(band, []).addresses[1]['numberAddition']).toBe('C'); + }); }); }); diff --git a/src/utils.ts b/src/utils.ts index 128e75c..c3ccdbd 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,4 @@ -import { ignoreKey } from './Decorators/Ignore'; +import { ignoreKey, serializeKey } from './Decorators'; import { SubCollectionMetadata } from './MetadataStorage'; import { IEntity } from '.'; @@ -61,6 +61,21 @@ export function serializeEntity( } }); + Object.entries(serializableObj).forEach(([propertyKey, propertyValue]) => { + if (Reflect.getMetadata(serializeKey, obj, propertyKey) !== undefined) { + if (Array.isArray(propertyValue)) { + (serializableObj as { [key: string]: unknown })[propertyKey] = propertyValue.map(element => + serializeEntity(element, []) + ); + } else { + (serializableObj as { [key: string]: unknown })[propertyKey] = serializeEntity( + propertyValue as Partial, + [] + ); + } + } + }); + return serializableObj; } diff --git a/test/BandCollection.ts b/test/BandCollection.ts index 60c8164..4057a34 100644 --- a/test/BandCollection.ts +++ b/test/BandCollection.ts @@ -1,5 +1,7 @@ import { Collection, SubCollection } from '../src/Decorators'; +import { Serialize } from '../src/Decorators/Serialize'; import { + Agent, Album as AlbumEntity, AlbumImage as AlbumImageEntity, Coordinates, @@ -49,6 +51,9 @@ export class Band { @Type(() => FirestoreDocumentReference) relatedBand?: FirestoreDocumentReference; + @Serialize(Agent) + agents: Agent[]; + getLastShowYear() { return this.lastShow.getFullYear(); } diff --git a/test/fixture.ts b/test/fixture.ts index 1ad09b6..7660fd8 100644 --- a/test/fixture.ts +++ b/test/fixture.ts @@ -1,4 +1,5 @@ import { IEntity } from '../src'; +import { Serialize } from '../src/Decorators/Serialize'; export class Coordinates { latitude: number; @@ -22,6 +23,16 @@ export class Album { comment?: string; } +export class Website { + url: string; +} + +export class Agent { + name: string; + @Serialize(Website) + website: Website; +} + export class Band { id: string; name: string; @@ -181,6 +192,28 @@ export const getInitialData = () => { }, ], }, + { + id: 'the-speckled-band', + name: 'the Speckled Band', + formationYear: 1892, + lastShow: null, + genres: [], + albums: [], + agents: [ + { + name: 'Mycroft Holmes', + website: { + url: 'en.wikipedia.org/wiki/Mycroft_Holmes', + }, + }, + { + name: 'Arthur Conan Doyle', + website: { + url: 'en.wikipedia.org/wiki/Arthur_Conan_Doyle', + }, + }, + ], + }, ]; }; diff --git a/test/functional/8-serialized-properties.spec.ts b/test/functional/8-serialized-properties.spec.ts new file mode 100644 index 0000000..386564c --- /dev/null +++ b/test/functional/8-serialized-properties.spec.ts @@ -0,0 +1,92 @@ +import { Collection, getRepository } from '../../src'; +import { Serialize } from '../../src/Decorators/Serialize'; +import { Band as BandEntity } from '../fixture'; +import { getUniqueColName } from '../setup'; + +describe('Integration test: Serialized properties', () => { + class Website { + url: string; + } + + class Manager { + name: string; + @Serialize(Website) + website: Website; + } + + @Collection(getUniqueColName('band-serialized-repository')) + class Band extends BandEntity { + @Serialize(Website) + website: Website; + } + + @Collection(getUniqueColName('band-serialized-repository')) + class DeepBand extends BandEntity { + @Serialize(Manager) + manager: Manager; + } + + @Collection(getUniqueColName('band-serialized-repository')) + 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 dt = new Band(); + dt.name = 'DreamTheater'; + dt.formationYear = 1985; + dt.genres = ['progressive-metal', 'progressive-rock']; + dt.website = new Website(); + dt.website.url = 'www.dreamtheater.net'; + + await bandRepository.create(dt); + + const retrievedBand = await bandRepository.findById(dt.id); + + expect(retrievedBand.website).toBeInstanceOf(Website); + expect(retrievedBand.website.url).toEqual('www.dreamtheater.net'); + }); + + test('should instantiate serialized objects with the correct class upon retrieval recursively', async () => { + const bandRepository = getRepository(DeepBand); + const sb = new DeepBand(); + sb.name = 'the Speckled Band'; + sb.formationYear = 1931; + sb.genres = ['justice', 'karma']; + sb.manager = new Manager(); + sb.manager.name = 'Mycroft Holmes'; + sb.manager.website = new Website(); + sb.manager.website.url = 'en.wikipedia.org/wiki/Mycroft_Holmes'; + + await bandRepository.create(sb); + + const retrievedBand = await bandRepository.findById(sb.id); + + expect(retrievedBand.manager).toBeInstanceOf(Manager); + expect(retrievedBand.manager.name).toEqual('Mycroft Holmes'); + expect(retrievedBand.manager.website).toBeInstanceOf(Website); + expect(retrievedBand.manager.website.url).toEqual('en.wikipedia.org/wiki/Mycroft_Holmes'); + }); + + test('should instantiate serialized objects arrays with the correct class upon retrieval', async () => { + const bandRepository = getRepository(FancyBand); + const dt = new FancyBand(); + dt.name = 'DreamTheater'; + dt.formationYear = 1985; + dt.genres = ['progressive-metal', 'progressive-rock']; + dt.websites = [new Website(), new Website()]; + dt.websites[0].url = 'http://www.dreamtheater.net'; + dt.websites[1].url = 'https://www.dreamtheater.net'; + + await bandRepository.create(dt); + + const retrievedBand = await bandRepository.findById(dt.id); + + expect(retrievedBand.websites[0]).toBeInstanceOf(Website); + expect(retrievedBand.websites[1]).toBeInstanceOf(Website); + expect(retrievedBand.websites[0].url).toEqual('http://www.dreamtheater.net'); + expect(retrievedBand.websites[1].url).toEqual('https://www.dreamtheater.net'); + }); +});