diff --git a/.vscode/launch.json b/.vscode/launch.json index 53d3f9bd..54803aa6 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -18,11 +18,16 @@ "type": "node", "request": "launch", "name": "Launch current integration test w/ jest", - "runtimeArgs": ["--inspect-brk", "${workspaceRoot}/node_modules/.bin/jest", "--runInBand"], + "runtimeArgs": [ + "--inspect-brk", + "${workspaceRoot}/node_modules/.bin/jest", + "--runInBand", + "--testTimeout=60000" + ], "args": [ "-c", "test/jest.integration.js", - "test/functional/4-transactions.spec.ts", + "test/functional/8-snapshots.spec.ts", "--verbose" ], "console": "integratedTerminal", diff --git a/docgen/Realtime_Updates.md b/docgen/Realtime_Updates.md new file mode 100644 index 00000000..7073c176 --- /dev/null +++ b/docgen/Realtime_Updates.md @@ -0,0 +1,44 @@ +# Realtime Updates + +Fireorm can subscribe to change to a firestore collection. This is done by the [watch](Classes/BaseFirestoreRepository.md#watch), it receives a callback (listener) and fireorm will pass the entites that were added/changed as the first callback. + +```typescript +const bandSnapshotUnsubscribe = await bandRepository + .whereArrayContains(a => a.genres, 'progressive-metal') + .watch(bands => { + // Will be triggered when any band with progressive-metal genre are added/edited + }); +``` + +## Unsubscribing from updates + +The `watch` method of fireorm's [repositories] return an unsubscribe function. When we want to stop listening collection updates we can call this function. + +```typescript +const bandSnapshotUnsubscribe = await bandRepository + .whereArrayContains(a => a.genres, 'progressive-metal') + .watch(bands => { + // Will be triggered when any band with progressive-metal genre are added/edited + }); + +// We no longer need to listen real time updates, so we unsubscribe. +bandSnapshotUnsubscribe(); +``` + +## Disabling empty updates + +Firestore triggers the listener every single time something is edited in a collection, even though nothing has changed. To skip this, you can pas a `ignoreEmptyUpdate` boolean option to fireorm and it'll skip empty changes. This can option can be passed in the `initialize` (will affect every subscription) or as a second parameter of `watch` (will only affect this subscription). + +```typescript +import { initialize } from 'fireorm'; + +initialize(firestore, { + ignoreEmptyUpdates: true, +}); +``` + +```typescript +const bandSnapshotUnsubscribe = await bandRepository + .whereArrayContains(a => a.genres, 'progressive-metal') + .watch(handleBandsUpdate, { ignoreEmptyUpdates: true }); +``` diff --git a/docgen/Validation.md b/docgen/Validation.md index 5ac6dc18..244d79a2 100644 --- a/docgen/Validation.md +++ b/docgen/Validation.md @@ -1,8 +1,8 @@ # Validation -FireORM supports [class-validator](https://github.com/typestack/class-validator) validation decorators in any collection. +Fireorm supports [class-validator](https://github.com/typestack/class-validator) validation decorators in any collection. -As `class-validator` requires a single install per project, FireORM opts not to depend on it explicitly (doing so may result in conflicting versions). It is up to you to install it with `npm i -S class-validator`. +As `class-validator` requires a single install per project, Fireorm opts not to depend on it explicitly (doing so may result in conflicting versions). It is up to you to install it with `npm i -S class-validator`. Once installed correctly, you can write your collections like so: @@ -21,10 +21,12 @@ Use this in the same way that you would your other collections and it will valid ## Disabling validation -Model validation is not enabled by default. It can be enable by initializing FireORM with the `validateModels: true` option. +Model validation is not enabled by default. It can be enable by initializing Fireorm with the `validateModels: true` option. ```typescript +import { initialize } from 'fireorm'; + initialize(firestore, { - validateModels: true -}) + validateModels: true, +}); ``` diff --git a/docgen/sidebar.md b/docgen/sidebar.md index 92b4b526..984f1530 100644 --- a/docgen/sidebar.md +++ b/docgen/sidebar.md @@ -12,3 +12,4 @@ - [Batches](Batches.md) - [Custom Repositories](Custom_Repositories.md) - [Validation](Validation.md) + - [Realtime Updates](Realtime_Updates.md) diff --git a/src/AbstractFirestoreRepository.ts b/src/AbstractFirestoreRepository.ts index 0d6b033d..187298ed 100644 --- a/src/AbstractFirestoreRepository.ts +++ b/src/AbstractFirestoreRepository.ts @@ -23,7 +23,11 @@ import { import { isDocumentReference, isGeoPoint, isObject, isTimestamp } from './TypeGuards'; import { getMetadataStorage } from './MetadataUtils'; -import { MetadataStorageConfig, FullCollectionMetadata } from './MetadataStorage'; +import type { + MetadataStorageConfig, + FullCollectionMetadata, + SnapshotConfig, +} from './MetadataStorage'; import { BaseRepository } from './BaseRepository'; import QueryBuilder from './QueryBuilder'; @@ -354,6 +358,16 @@ export abstract class AbstractFirestoreRepository extends Bas return new QueryBuilder(this).findOne(); } + /** + * Execute the query and watch for changes on that query + * + * @returns {Function} An unsubscribe function that can be called to cancel the snapshot listener + * @memberof AbstractFirestoreRepository + */ + watch(callback: (documents: T[]) => void, config?: SnapshotConfig): Promise<() => void> { + return new QueryBuilder(this).watch(callback, config); + } + /** * Uses class-validator to validate an entity using decorators set in the collection class * @@ -374,7 +388,7 @@ export abstract class AbstractFirestoreRepository extends Bas } catch (error) { if (error.code === 'MODULE_NOT_FOUND') { throw new Error( - 'It looks like class-validator is not installed. Please run `npm i -S class-validator` to fix this error, or initialize FireORM with `validateModels: false` to disable validation.' + 'It looks like class-validator is not installed. Please run `npm i -S class-validator` to fix this error, or initialize Fireorm with `validateModels: false` to disable validation.' ); } @@ -401,8 +415,12 @@ export abstract class AbstractFirestoreRepository extends Bas queries: IFireOrmQueryLine[], limitVal?: number, orderByObj?: IOrderByParams, - single?: boolean - ): Promise; + single?: boolean, + snapshot?: { + onUpdate: (documents: T[]) => void; + config?: SnapshotConfig; + } + ): Promise void)>; /** * Retrieve a document with the specified id. diff --git a/src/BaseFirestoreRepository.ts b/src/BaseFirestoreRepository.ts index 40939aab..4578d134 100644 --- a/src/BaseFirestoreRepository.ts +++ b/src/BaseFirestoreRepository.ts @@ -1,6 +1,6 @@ import 'reflect-metadata'; -import { Query, WhereFilterOp } from '@google-cloud/firestore'; +import { Query, QuerySnapshot, WhereFilterOp } from '@google-cloud/firestore'; import { IRepository, @@ -12,8 +12,10 @@ import { } from './types'; import { getMetadataStorage } from './MetadataUtils'; + import { AbstractFirestoreRepository } from './AbstractFirestoreRepository'; import { FirestoreBatch } from './Batch/FirestoreBatch'; +import type { SnapshotConfig } from './MetadataStorage'; export class BaseFirestoreRepository extends AbstractFirestoreRepository implements IRepository { @@ -91,8 +93,12 @@ export class BaseFirestoreRepository extends AbstractFirestor queries: Array, limitVal?: number, orderByObj?: IOrderByParams, - single?: boolean - ): Promise { + single?: boolean, + snapshot?: { + onUpdate: (documents: T[]) => void; + config?: SnapshotConfig; + } + ): Promise void)> { let query = queries.reduce((acc, cur) => { const op = cur.operator as WhereFilterOp; return acc.where(cur.prop, op, cur.val); @@ -108,6 +114,19 @@ export class BaseFirestoreRepository extends AbstractFirestor query = query.limit(limitVal); } + const ignoreEmptyUpdates = + snapshot?.config?.ignoreEmptyUpdates || this.config.ignoreEmptyUpdates; + + if (snapshot?.onUpdate) { + return query.onSnapshot((snap: QuerySnapshot) => { + if (ignoreEmptyUpdates && snap.empty) { + return; + } + + return snapshot.onUpdate(this.extractTFromColSnap(snap)); + }); + } + return query.get().then(this.extractTFromColSnap); } } diff --git a/src/Batch/FirestoreBatchUnit.ts b/src/Batch/FirestoreBatchUnit.ts index 8f88d4a1..75cff5dc 100644 --- a/src/Batch/FirestoreBatchUnit.ts +++ b/src/Batch/FirestoreBatchUnit.ts @@ -90,7 +90,7 @@ export class FirestoreBatchUnit { } catch (error) { if (error.code === 'MODULE_NOT_FOUND') { throw new Error( - 'It looks like class-validator is not installed. Please run `npm i -S class-validator` to fix this error, or initialize FireORM with `validateModels: false` to disable validation.' + 'It looks like class-validator is not installed. Please run `npm i -S class-validator` to fix this error, or initialize Fireorm with `validateModels: false` to disable validation.' ); } diff --git a/src/MetadataStorage.ts b/src/MetadataStorage.ts index 6a71107e..f59916f9 100644 --- a/src/MetadataStorage.ts +++ b/src/MetadataStorage.ts @@ -30,16 +30,22 @@ export interface RepositoryMetadata { entity: IEntityConstructor; } -export interface MetadataStorageConfig { +export interface SnapshotConfig { + ignoreEmptyUpdates: boolean; +} +export interface ValidationConfig { validateModels: boolean; } +export type MetadataStorageConfig = SnapshotConfig & ValidationConfig; + export class MetadataStorage { readonly collections: Array = []; protected readonly repositories: Map = new Map(); public config: MetadataStorageConfig = { validateModels: false, + ignoreEmptyUpdates: false, }; public getCollection = (pathOrConstructor: string | IEntityConstructor) => { diff --git a/src/QueryBuilder.ts b/src/QueryBuilder.ts index 5040afe0..9e8e0da9 100644 --- a/src/QueryBuilder.ts +++ b/src/QueryBuilder.ts @@ -1,4 +1,5 @@ import { getPath } from 'ts-object-path'; +import type { SnapshotConfig } from './MetadataStorage'; import { IQueryBuilder, @@ -171,17 +172,24 @@ export default class QueryBuilder implements IQueryBuilder return this; } - find() { - return this.executor.execute(this.queries, this.limitVal, this.orderByObj); + find(): Promise { + return this.executor.execute(this.queries, this.limitVal, this.orderByObj) as Promise; } - async findOne() { - const queryResult = await this.executor.execute( + watch(onUpdate: (documents: T[]) => void, config?: SnapshotConfig) { + return this.executor.execute(this.queries, this.limitVal, this.orderByObj, false, { + onUpdate, + config, + }) as Promise<() => void>; + } + + async findOne(): Promise { + const queryResult = (await this.executor.execute( this.queries, this.limitVal, this.orderByObj, true - ); + )) as T[]; return queryResult.length ? queryResult[0] : null; } diff --git a/src/Transaction/BaseFirestoreTransactionRepository.ts b/src/Transaction/BaseFirestoreTransactionRepository.ts index c3c3bece..7f73e300 100644 --- a/src/Transaction/BaseFirestoreTransactionRepository.ts +++ b/src/Transaction/BaseFirestoreTransactionRepository.ts @@ -23,7 +23,7 @@ export class TransactionRepository extends AbstractFirestoreR this.tranRefStorage = tranRefStorage; } - async execute(queries: IFireOrmQueryLine[]): Promise { + async execute(queries: IFireOrmQueryLine[]): Promise void)> { const query = queries.reduce((acc, cur) => { const op = cur.operator as WhereFilterOp; return acc.where(cur.prop, op, cur.val); diff --git a/src/types.ts b/src/types.ts index f4346b72..d2d3ed77 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,5 @@ import { OrderByDirection, DocumentReference } from '@google-cloud/firestore'; +import type { SnapshotConfig } from './MetadataStorage'; export type PartialBy = Omit & Partial>; export type PartialWithRequiredBy = Pick & Partial>; @@ -49,6 +50,7 @@ export interface IQueryable { whereNotIn(prop: IWherePropParam, val: IFirestoreVal[]): IQueryBuilder; find(): Promise; findOne(): Promise; + watch(handler: (documents: T[]) => void, config?: SnapshotConfig): Promise<() => void>; } export interface IOrderable { @@ -67,8 +69,12 @@ export interface IQueryExecutor { queries: IFireOrmQueryLine[], limitVal?: number, orderByObj?: IOrderByParams, - single?: boolean - ): Promise; + single?: boolean, + snapshot?: { + onUpdate: (documents: T[]) => void; + config?: SnapshotConfig; + } + ): Promise void)>; } export interface IBatchRepository { diff --git a/test/functional/8-snapshots.spec.ts b/test/functional/8-snapshots.spec.ts new file mode 100644 index 00000000..efd708e7 --- /dev/null +++ b/test/functional/8-snapshots.spec.ts @@ -0,0 +1,74 @@ +import { getRepository, Collection } from '../../src'; +import { Band as BandEntity } from '../fixture'; +import { getUniqueColName } from '../setup'; + +describe('Integration test: Simple Repository', () => { + @Collection(getUniqueColName('band-snapshot-repository')) + class Band extends BandEntity { + extra?: { website: string }; + } + + test('should do crud operations', async () => { + const bandRepository = getRepository(Band); + + // Create snapshot listener + let executionIndex = 1; + const handleBandsUpdate = (bands: Band[]) => { + if (executionIndex == 1) { + expect(bands.length).toEqual(1); + } else if (executionIndex == 2) { + expect(bands.length).toEqual(2); + } else if (executionIndex == 3) { + expect(bands.length).toEqual(2); + + bands.forEach(band => { + if (band.id == 'dream-theater') { + expect(band.name).toEqual('Dream Theatre'); + } + }); + } + executionIndex++; + }; + + const bandSnapshotUnsubscribe = await bandRepository + .whereArrayContains(a => a.genres, 'progressive-metal') + .watch(handleBandsUpdate, { ignoreEmptyUpdates: true }); + + const dt = { + id: 'dream-theater', + name: 'DreamTheater', + formationYear: 1985, + genres: ['progressive-metal', 'progressive-rock'], + lastShow: null, + }; + + // First execution + await bandRepository.create(dt); + + // Second execution + await bandRepository.create({ + name: 'Devin Townsend Project', + formationYear: 2009, + genres: ['progressive-metal', 'extreme-metal'], + lastShow: null, + }); + + // Third execution + await bandRepository.create({ + id: 'porcupine-tree', + name: 'Porcupine Tree', + formationYear: 2009, + genres: ['psychedelic-rock', 'progressive-rock', 'progressive-metal'], + lastShow: null, + }); + + // Update a band (fourth execution) + dt.name = 'Dream Theater'; + + // Fourth execution + await bandRepository.update(dt); + + // Unsubscribe from snapshot + bandSnapshotUnsubscribe(); + }); +});