Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
44 changes: 44 additions & 0 deletions docgen/Realtime_Updates.md
Original file line number Diff line number Diff line change
@@ -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 });
```
12 changes: 7 additions & 5 deletions docgen/Validation.md
Original file line number Diff line number Diff line change
@@ -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:

Expand All @@ -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,
});
```
1 change: 1 addition & 0 deletions docgen/sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@
- [Batches](Batches.md)
- [Custom Repositories](Custom_Repositories.md)
- [Validation](Validation.md)
- [Realtime Updates](Realtime_Updates.md)
26 changes: 22 additions & 4 deletions src/AbstractFirestoreRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -354,6 +358,16 @@ export abstract class AbstractFirestoreRepository<T extends IEntity> extends Bas
return new QueryBuilder<T>(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<T>(this).watch(callback, config);
}

/**
* Uses class-validator to validate an entity using decorators set in the collection class
*
Expand All @@ -374,7 +388,7 @@ export abstract class AbstractFirestoreRepository<T extends IEntity> 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.'
);
}

Expand All @@ -401,8 +415,12 @@ export abstract class AbstractFirestoreRepository<T extends IEntity> extends Bas
queries: IFireOrmQueryLine[],
limitVal?: number,
orderByObj?: IOrderByParams,
single?: boolean
): Promise<T[]>;
single?: boolean,
snapshot?: {
onUpdate: (documents: T[]) => void;
config?: SnapshotConfig;
}
): Promise<T[] | (() => void)>;

/**
* Retrieve a document with the specified id.
Expand Down
25 changes: 22 additions & 3 deletions src/BaseFirestoreRepository.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import 'reflect-metadata';

import { Query, WhereFilterOp } from '@google-cloud/firestore';
import { Query, QuerySnapshot, WhereFilterOp } from '@google-cloud/firestore';

import {
IRepository,
Expand All @@ -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<T extends IEntity> extends AbstractFirestoreRepository<T>
implements IRepository<T> {
Expand Down Expand Up @@ -91,8 +93,12 @@ export class BaseFirestoreRepository<T extends IEntity> extends AbstractFirestor
queries: Array<IFireOrmQueryLine>,
limitVal?: number,
orderByObj?: IOrderByParams,
single?: boolean
): Promise<T[]> {
single?: boolean,
snapshot?: {
onUpdate: (documents: T[]) => void;
config?: SnapshotConfig;
}
): Promise<T[] | (() => void)> {
let query = queries.reduce<Query>((acc, cur) => {
const op = cur.operator as WhereFilterOp;
return acc.where(cur.prop, op, cur.val);
Expand All @@ -108,6 +114,19 @@ export class BaseFirestoreRepository<T extends IEntity> 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);
}
}
2 changes: 1 addition & 1 deletion src/Batch/FirestoreBatchUnit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.'
);
}

Expand Down
8 changes: 7 additions & 1 deletion src/MetadataStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CollectionMetadataWithSegments> = [];
protected readonly repositories: Map<IEntityConstructor, RepositoryMetadata> = new Map();

public config: MetadataStorageConfig = {
validateModels: false,
ignoreEmptyUpdates: false,
};

public getCollection = (pathOrConstructor: string | IEntityConstructor) => {
Expand Down
18 changes: 13 additions & 5 deletions src/QueryBuilder.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getPath } from 'ts-object-path';
import type { SnapshotConfig } from './MetadataStorage';

import {
IQueryBuilder,
Expand Down Expand Up @@ -171,17 +172,24 @@ export default class QueryBuilder<T extends IEntity> implements IQueryBuilder<T>
return this;
}

find() {
return this.executor.execute(this.queries, this.limitVal, this.orderByObj);
find(): Promise<T[]> {
return this.executor.execute(this.queries, this.limitVal, this.orderByObj) as Promise<T[]>;
}

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<T | null> {
const queryResult = (await this.executor.execute(
this.queries,
this.limitVal,
this.orderByObj,
true
);
)) as T[];

return queryResult.length ? queryResult[0] : null;
}
Expand Down
2 changes: 1 addition & 1 deletion src/Transaction/BaseFirestoreTransactionRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export class TransactionRepository<T extends IEntity> extends AbstractFirestoreR
this.tranRefStorage = tranRefStorage;
}

async execute(queries: IFireOrmQueryLine[]): Promise<T[]> {
async execute(queries: IFireOrmQueryLine[]): Promise<T[] | (() => void)> {
const query = queries.reduce<Query>((acc, cur) => {
const op = cur.operator as WhereFilterOp;
return acc.where(cur.prop, op, cur.val);
Expand Down
10 changes: 8 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { OrderByDirection, DocumentReference } from '@google-cloud/firestore';
import type { SnapshotConfig } from './MetadataStorage';

export type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
export type PartialWithRequiredBy<T, K extends keyof T> = Pick<T, K> & Partial<Omit<T, K>>;
Expand Down Expand Up @@ -49,6 +50,7 @@ export interface IQueryable<T extends IEntity> {
whereNotIn(prop: IWherePropParam<T>, val: IFirestoreVal[]): IQueryBuilder<T>;
find(): Promise<T[]>;
findOne(): Promise<T | null>;
watch(handler: (documents: T[]) => void, config?: SnapshotConfig): Promise<() => void>;
}

export interface IOrderable<T extends IEntity> {
Expand All @@ -67,8 +69,12 @@ export interface IQueryExecutor<T> {
queries: IFireOrmQueryLine[],
limitVal?: number,
orderByObj?: IOrderByParams,
single?: boolean
): Promise<T[]>;
single?: boolean,
snapshot?: {
onUpdate: (documents: T[]) => void;
config?: SnapshotConfig;
}
): Promise<T[] | (() => void)>;
}

export interface IBatchRepository<T extends IEntity> {
Expand Down
74 changes: 74 additions & 0 deletions test/functional/8-snapshots.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});