Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: Native document reference support! #238

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
30 changes: 29 additions & 1 deletion src/AbstractFirestoreRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
QuerySnapshot,
CollectionReference,
Transaction,
Firestore,
} from '@google-cloud/firestore';
import { ValidationError } from './Errors/ValidationError';

Expand All @@ -30,12 +31,19 @@ import QueryBuilder from './QueryBuilder';
import { serializeEntity } from './utils';
import { NoMetadataError } from './Errors';

// Does this has to be done here?
interface MutableIEntity extends IEntity {
__fireorm_internal_path?: string;
__fireorm_internal_saved?: boolean;
}

export abstract class AbstractFirestoreRepository<T extends IEntity> extends BaseRepository
implements IRepository<T> {
protected readonly colMetadata: FullCollectionMetadata;
protected readonly path: string;
protected readonly config: MetadataStorageConfig;
protected readonly firestoreColRef: CollectionReference;
protected readonly firestoreRef: Firestore;

constructor(pathOrConstructor: string | IEntityConstructor) {
super();
Expand All @@ -56,10 +64,17 @@ export abstract class AbstractFirestoreRepository<T extends IEntity> extends Bas
this.colMetadata = colMetadata;
this.path = typeof pathOrConstructor === 'string' ? pathOrConstructor : this.colMetadata.name;
this.firestoreColRef = firestoreRef.collection(this.path);
this.firestoreRef = firestoreRef;
}

protected toSerializableObject = (obj: T): Record<string, unknown> =>
serializeEntity(obj, this.colMetadata.subCollections);
serializeEntity(obj, this.colMetadata, this.firestoreRef);

protected initPath = (obj: MutableIEntity): T => {
obj.__fireorm_internal_path = `${this.path}/${obj.id}`;
obj.__fireorm_internal_saved = true;
return obj as T;
};

protected transformFirestoreTypes = (obj: Record<string, unknown>) => {
Object.keys(obj).forEach(key => {
Expand Down Expand Up @@ -121,6 +136,7 @@ export abstract class AbstractFirestoreRepository<T extends IEntity> extends Bas
...this.transformFirestoreTypes(doc.data() || {}),
}) as T;

this.initPath(entity);
this.initializeSubCollections(entity, tran, tranRefStorage);

return entity;
Expand Down Expand Up @@ -354,6 +370,18 @@ export abstract class AbstractFirestoreRepository<T extends IEntity> extends Bas
return new QueryBuilder<T>(this).findOne();
}

/**
* Returns the firestore path/ref for the given item
*
* @returns {string | null} If entity is saved in firestore
* this will return the firestore path, null otherwise.
*
* @memberof AbstractFirestoreRepository
*/
getPath(item: T): string | null {
return item.__fireorm_internal_path || null;
}

/**
* Uses class-validator to validate an entity using decorators set in the collection class
*
Expand Down
120 changes: 114 additions & 6 deletions src/BaseFirestoreRepository.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -479,11 +479,11 @@ describe('BaseFirestoreRepository', () => {
expect(list[0].id).toEqual('red-hot-chili-peppers');
});

it('must support document references in where methods', async () => {
it.skip('must support document references in where methods', async () => {
const docRef = firestore.collection('bands').doc('steven-wilson');

const band = await bandRepository.findById('porcupine-tree');
band.relatedBand = docRef;
// band.relatedBand = docRef;
await bandRepository.update(band);

const byReference = await bandRepository.whereEqualTo(b => b.relatedBand, docRef).find();
Expand Down Expand Up @@ -533,19 +533,86 @@ describe('BaseFirestoreRepository', () => {
expect(pt.lastShowCoordinates.longitude).toEqual(-0.1795547);
});

it('should correctly parse references', async () => {
it.skip('should correctly parse references', async () => {
const docRef = firestore.collection('bands').doc('opeth');

const band = await bandRepository.findById('porcupine-tree');
band.relatedBand = docRef;
// band.relatedBand = docRef;
await bandRepository.update(band);

const foundBand = await bandRepository.findById('porcupine-tree');

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 return the path of a document', async () => {
const band = new Band();
band.id = '30stm';
band.name = '30 Seconds To Mars';
band.formationYear = 1998;
band.genres = ['alternative-rock'];

await bandRepository.create(band);

const firstAlbum = new Album();
firstAlbum.id = '30stm';
firstAlbum.name = '30 Seconds to Mars (Album)';
firstAlbum.releaseDate = new Date('2002-07-22');

const secondAlbum = new Album();
secondAlbum.id = 'abl';
secondAlbum.name = 'A Beautiful Lie';
secondAlbum.releaseDate = new Date('2005-08-30');

const album1 = await band.albums.create(firstAlbum);
const album2 = await band.albums.create(secondAlbum);

const ig1a1 = new AlbumImage();
ig1a1.id = 'ig1a1';
ig1a1.url = 'http://image1.com';

const ig2a1 = new AlbumImage();
ig2a1.id = 'ig2a1';
ig2a1.url = 'http://image2.com';

const ig1a2 = new AlbumImage();
ig1a2.id = 'ig1a2';
ig1a2.url = 'http://image1.com';

const ig2a2 = new AlbumImage();
ig2a2.id = 'ig2a2';
ig2a2.url = 'http://image2.com';

await album1.images.create(ig1a1);
await album1.images.create(ig2a1);
await album2.images.create(ig1a2);
await album2.images.create(ig2a2);

const b = await bandRepository.findById('30stm');
const a = await band.albums.find();
const ia1 = await album1.images.find();
const ia2 = await album2.images.find();

expect(album1.images.getPath(ig1a1)).toEqual('bands/30stm/albums/30stm/images/ig1a1');
expect(album1.images.getPath(ig2a1)).toEqual('bands/30stm/albums/30stm/images/ig2a1');
expect(album1.images.getPath(ia1[0])).toEqual('bands/30stm/albums/30stm/images/ig1a1');
expect(album1.images.getPath(ia1[1])).toEqual('bands/30stm/albums/30stm/images/ig2a1');

expect(album2.images.getPath(ig1a2)).toEqual('bands/30stm/albums/abl/images/ig1a2');
expect(album2.images.getPath(ig2a2)).toEqual('bands/30stm/albums/abl/images/ig2a2');
expect(album2.images.getPath(ia2[0])).toEqual('bands/30stm/albums/abl/images/ig1a2');
expect(album2.images.getPath(ia2[1])).toEqual('bands/30stm/albums/abl/images/ig2a2');

expect(band.albums.getPath(album1)).toEqual('bands/30stm/albums/30stm');
expect(band.albums.getPath(album2)).toEqual('bands/30stm/albums/abl');
expect(band.albums.getPath(a[0])).toEqual('bands/30stm/albums/30stm');
expect(band.albums.getPath(a[1])).toEqual('bands/30stm/albums/abl');

expect(bandRepository.getPath(band)).toEqual('bands/30stm');
expect(bandRepository.getPath(b)).toEqual('bands/30stm');
});
});

Expand Down Expand Up @@ -795,4 +862,45 @@ describe('BaseFirestoreRepository', () => {
expect(possibleDocWithoutId).not.toBeUndefined();
});
});

describe('references', () => {
it.skip('should correctly parse references', async () => {
const band = await bandRepository.findById('porcupine-tree');
expect(band.relatedBand).toBeInstanceOf(Band);
expect((band.relatedBand as Band).name).toEqual('Pink Floyd');
});

it('should correctly create entities with T references', async () => {
const sw = new Band();
sw.id = 'steven-wilson';
sw.name = 'Steven Wilson';
sw.formationYear = 1987;
sw.genres = ['progressive-rock', 'progressive-metal', 'psychedelic-rock'];

const nm = new Band();
nm.id = 'no-man';
nm.name = 'No Man';
nm.formationYear = 1987;
nm.genres = ['art-rock', 'ambient', 'trip-hop', 'dream-pop'];

sw.relatedBand = nm;

await bandRepository.create(sw);

const savedNM = await bandRepository.findById('no-man');
const savedSW = await bandRepository.findById('steven-wilson');

expect(savedNM.id).toEqual(nm.id);
expect(savedNM.name).toEqual(nm.name);
expect(savedNM.formationYear).toEqual(nm.formationYear);
expect(savedNM.genres).toEqual(nm.genres);

expect((savedSW.relatedBand as Band).id).toEqual(nm.id);
expect((sw.relatedBand as Band).id).toEqual(nm.id);
});

it.todo('should correctly create entities with string references');
it.todo('should correctly update references');
it.todo('should correctly delete references');
});
});
35 changes: 33 additions & 2 deletions src/BaseFirestoreRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import { getMetadataStorage } from './MetadataUtils';
import { AbstractFirestoreRepository } from './AbstractFirestoreRepository';
import { FirestoreBatch } from './Batch/FirestoreBatch';
import { isEntity } from './TypeGuards';

export class BaseFirestoreRepository<T extends IEntity> extends AbstractFirestoreRepository<T>
implements IRepository<T> {
Expand Down Expand Up @@ -42,13 +43,43 @@ export class BaseFirestoreRepository<T extends IEntity> extends AbstractFirestor

const doc = item.id ? this.firestoreColRef.doc(item.id) : this.firestoreColRef.doc();

if (!item.id) {
item.id = doc.id;
// Check references and see if they need to be created first
// TODO: inside transaction

type TCreatedReferences = {
propertyKey: string;
path: string;
};

const refs: TCreatedReferences[] = [];

for (const { propertyKey, target } of this.colMetadata.references) {
const { getRepository } = await import('./helpers');
const ref = (item as Record<string, unknown>)[propertyKey];

if (isEntity(ref) && !ref.__fireorm_internal_saved) {
const repository = getRepository(target);

const savedReference = await repository.create(ref);

const path = repository.getPath(savedReference);

if (!path) {
throw new Error(`Couldn't get reference path`);
}

refs.push({ propertyKey, path });
}
}

await doc.set(this.toSerializableObject(item as T));

if (!item.id) {
item.id = doc.id;
}

this.initializeSubCollections(item as T);
this.initPath(item as T);

return item as T;
}
Expand Down
3 changes: 2 additions & 1 deletion src/Batch/FirestoreBatchUnit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ export class FirestoreBatchUnit {
}
}

const serialized = serializeEntity(op.item, op.collectionMetadata.subCollections);
// todo pass a wrapReference function that takes a path an return a firestore batch docref type
const serialized = serializeEntity(op.item, op.collectionMetadata);

switch (op.type) {
case 'create':
Expand Down
55 changes: 55 additions & 0 deletions src/Decorators/DocumentReference.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { SubCollection } from './SubCollection';
import { Reference } from './DocumentReference';
import { IEntityReference, ISubCollection } from '../types';
import { Collection } from './Collection';

const setReference = jest.fn();
const setCollection = jest.fn();
jest.mock('../MetadataUtils', () => ({
getMetadataStorage: () => ({
setCollection,
setReference,
}),
}));

describe('DocumentReferenceDecorator', () => {
beforeEach(() => {
jest.resetAllMocks();
});

it('should register references', () => {
class EntityReferenced {
public id: string;
}

class SubEntity {
public id: string;

@Reference(EntityReferenced)
subReference: IEntityReference<EntityReferenced>;
}

@Collection()
class Entity {
id: string;

@Reference(EntityReferenced)
reference: IEntityReference<EntityReferenced>;

@SubCollection(SubEntity)
subentity: ISubCollection<SubEntity>;
}

expect(setReference).toHaveBeenNthCalledWith(1, {
origin: SubEntity,
target: EntityReferenced,
propertyKey: 'subReference',
});

expect(setReference).toHaveBeenNthCalledWith(2, {
origin: Entity,
target: EntityReferenced,
propertyKey: 'reference',
});
});
});
12 changes: 12 additions & 0 deletions src/Decorators/DocumentReference.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { getMetadataStorage } from '../MetadataUtils';
import type { IEntity, IEntityConstructor } from '../types';

export function Reference(target: IEntityConstructor) {
return function (parentEntity: IEntity, propertyKey: string) {
getMetadataStorage().setReference({
origin: parentEntity.constructor as IEntityConstructor,
target,
propertyKey,
});
};
}
1 change: 1 addition & 0 deletions src/Decorators/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './Collection';
export * from './CustomRepository';
export * from './SubCollection';
export * from './DocumentReference';
8 changes: 8 additions & 0 deletions src/Errors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,11 @@ export class NoMetadataError extends Error {
);
}
}

export class DuplicatedReference extends Error {
constructor(propertyKey: string, entityName: string) {
super(
`Reference in field ${propertyKey} in ${entityName} collection has already been registered`
);
}
}