Skip to content

Commit

Permalink
feat: added Firestore Increment support
Browse files Browse the repository at this point in the history
  • Loading branch information
dereekb committed Aug 9, 2022
1 parent 989c9d5 commit d4dc97b
Show file tree
Hide file tree
Showing 17 changed files with 182 additions and 8 deletions.
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { DocumentReference, WriteBatch as GoogleCloudWriteBatch, DocumentSnapshot } from '@google-cloud/firestore';
import { from, Observable } from 'rxjs';
import { WithFieldValue, FirestoreDocumentContext, FirestoreDocumentContextType, FirestoreDocumentDataAccessor, FirestoreDocumentDataAccessorFactory, FirestoreDocumentDeleteParams, FirestoreDocumentUpdateParams, UpdateData, DocumentData, FirestoreDataConverter } from '@dereekb/firebase';
import { WithFieldValue, FirestoreDocumentContext, FirestoreDocumentContextType, FirestoreDocumentDataAccessor, FirestoreDocumentDataAccessorFactory, FirestoreDocumentDeleteParams, FirestoreDocumentUpdateParams, UpdateData, DocumentData, FirestoreDataConverter, FirestoreAccessorIncrementUpdate } from '@dereekb/firebase';
import { firestoreServerIncrementUpdateToUpdateData } from './increment';

// MARK: Accessor
/**
Expand Down Expand Up @@ -40,6 +41,10 @@ export class WriteBatchFirestoreDocumentDataAccessor<T> implements FirestoreDocu
return Promise.resolve();
}

increment(data: FirestoreAccessorIncrementUpdate<T>, params?: FirestoreDocumentUpdateParams): Promise<void> {
return this.update(firestoreServerIncrementUpdateToUpdateData(data), params);
}

update(data: UpdateData<T>, params?: FirestoreDocumentUpdateParams): Promise<void> {
if (params?.precondition != null) {
this.batch.update(this.documentRef, data as FirebaseFirestore.UpdateData<T>, params?.precondition);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { DocumentReference, WriteResult as GoogleCloudWriteResult, DocumentSnapshot } from '@google-cloud/firestore';
import { Observable } from 'rxjs';
import { WithFieldValue, UpdateData, FirestoreDocumentContext, FirestoreDocumentContextType, FirestoreDocumentDataAccessor, FirestoreDocumentDataAccessorFactory, FirestoreDocumentDeleteParams, FirestoreDocumentUpdateParams, SetOptions, streamFromOnSnapshot, FirestoreDataConverter, DocumentData } from '@dereekb/firebase';
import { WithFieldValue, UpdateData, FirestoreDocumentContext, FirestoreDocumentContextType, FirestoreDocumentDataAccessor, FirestoreDocumentDataAccessorFactory, FirestoreDocumentDeleteParams, FirestoreDocumentUpdateParams, SetOptions, streamFromOnSnapshot, FirestoreDataConverter, DocumentData, FirestoreAccessorIncrementUpdate } from '@dereekb/firebase';
import { firestoreServerIncrementUpdateToUpdateData } from './increment';

// MARK: Accessor
export class DefaultFirestoreDocumentDataAccessor<T> implements FirestoreDocumentDataAccessor<T> {
Expand Down Expand Up @@ -34,6 +35,10 @@ export class DefaultFirestoreDocumentDataAccessor<T> implements FirestoreDocumen
return options ? this.documentRef.set(data as Partial<T>, options) : this.documentRef.set(data as T);
}

increment(data: FirestoreAccessorIncrementUpdate<T>, params?: FirestoreDocumentUpdateParams): Promise<GoogleCloudWriteResult> {
return this.update(firestoreServerIncrementUpdateToUpdateData(data), params);
}

update(data: UpdateData<T>, params?: FirestoreDocumentUpdateParams): Promise<GoogleCloudWriteResult> {
return params?.precondition ? this.documentRef.update(data as FirebaseFirestore.UpdateData<T>, params.precondition) : this.documentRef.update(data as FirebaseFirestore.UpdateData<T>);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { DocumentReference, DocumentSnapshot, Transaction as GoogleCloudTransaction, SetOptions } from '@google-cloud/firestore';
import { from, Observable } from 'rxjs';
import { WithFieldValue, UpdateData, FirestoreDocumentDataAccessor, FirestoreDocumentDataAccessorFactory, FirestoreDocumentContext, FirestoreDocumentContextType, FirestoreDocumentUpdateParams, FirestoreDataConverter, DocumentData } from '@dereekb/firebase';
import { WithFieldValue, UpdateData, FirestoreDocumentDataAccessor, FirestoreDocumentDataAccessorFactory, FirestoreDocumentContext, FirestoreDocumentContextType, FirestoreDocumentUpdateParams, FirestoreDataConverter, DocumentData, FirestoreAccessorIncrementUpdate } from '@dereekb/firebase';
import { firestoreServerIncrementUpdateToUpdateData } from './increment';

// MARK: Accessor
/**
Expand Down Expand Up @@ -40,6 +41,10 @@ export class TransactionFirestoreDocumentDataAccessor<T> implements FirestoreDoc
return Promise.resolve();
}

increment(data: FirestoreAccessorIncrementUpdate<T>, params?: FirestoreDocumentUpdateParams): Promise<void> {
return this.update(firestoreServerIncrementUpdateToUpdateData(data), params);
}

update(data: UpdateData<T>, params?: FirestoreDocumentUpdateParams): Promise<void> {
if (params?.precondition) {
this.transaction.update(this.documentRef, data as FirebaseFirestore.UpdateData<T>, params?.precondition);
Expand Down
15 changes: 15 additions & 0 deletions packages/firebase-server/src/lib/firestore/increment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { mapObjectMap } from '@dereekb/util';
import { FieldValue } from '@google-cloud/firestore';
import { UpdateData, FirestoreAccessorIncrementUpdate } from '@dereekb/firebase';

/**
* Creates UpdateData corresponding to the input increment update.
*
* @param input
* @returns
*/
export function firestoreServerIncrementUpdateToUpdateData<T>(input: FirestoreAccessorIncrementUpdate<T>): UpdateData<T> {
return mapObjectMap(input, (incrementValue) => {
return FieldValue.increment(incrementValue ?? 0);
}) as UpdateData<T>;
}
1 change: 1 addition & 0 deletions packages/firebase-server/src/lib/firestore/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * from './driver.accessor';
export * from './driver.query';
export * from './firestore';
export * from './firestore.nest';
export * from './increment';
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { onSnapshot, DocumentReference, DocumentSnapshot, UpdateData, WithFieldValue, getDoc, deleteDoc, setDoc, updateDoc } from '@firebase/firestore';
import { Observable } from 'rxjs';
import { assertFirestoreUpdateHasData, DocumentData, FirestoreDataConverter, FirestoreDocumentContext, FirestoreDocumentContextType, FirestoreDocumentDataAccessor, FirestoreDocumentDataAccessorFactory, SetOptions, streamFromOnSnapshot } from '../../common/firestore';
import { assertFirestoreUpdateHasData, DocumentData, FirestoreAccessorIncrementUpdate, FirestoreDataConverter, FirestoreDocumentContext, FirestoreDocumentContextType, FirestoreDocumentDataAccessor, FirestoreDocumentDataAccessorFactory, SetOptions, streamFromOnSnapshot, WriteResult } from '../../common/firestore';
import { createWithAccessor } from './driver.accessor.create';
import { firestoreClientIncrementUpdateToUpdateData } from './increment';

// MARK: Accessor
export class DefaultFirestoreDocumentDataAccessor<T> implements FirestoreDocumentDataAccessor<T> {
Expand Down Expand Up @@ -35,6 +36,10 @@ export class DefaultFirestoreDocumentDataAccessor<T> implements FirestoreDocumen
return setDoc(this.documentRef, data, options as SetOptions);
}

increment(data: FirestoreAccessorIncrementUpdate<T>): Promise<void | WriteResult> {
return this.update(firestoreClientIncrementUpdateToUpdateData(data));
}

update(data: UpdateData<unknown>): Promise<void> {
assertFirestoreUpdateHasData(data);
return updateDoc(this.documentRef, data);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { DocumentReference, DocumentSnapshot, Transaction as FirebaseFirestoreTransaction, UpdateData, WithFieldValue } from '@firebase/firestore';
import { from, Observable } from 'rxjs';
import { FirestoreDocumentDataAccessor, FirestoreDocumentDataAccessorFactory, FirestoreDocumentContext, FirestoreDocumentContextType, SetOptions, DocumentData, FirestoreDataConverter, assertFirestoreUpdateHasData } from '../../common/firestore';
import { FirestoreDocumentDataAccessor, FirestoreDocumentDataAccessorFactory, FirestoreDocumentContext, FirestoreDocumentContextType, SetOptions, DocumentData, FirestoreDataConverter, assertFirestoreUpdateHasData, AddPrefixToKeys, FirestoreDocumentUpdateParams, WriteResult, FirestoreAccessorIncrementUpdate } from '../../common/firestore';
import { createWithAccessor } from './driver.accessor.create';
import { firestoreClientIncrementUpdateToUpdateData } from './increment';

// MARK: Accessor
/**
Expand Down Expand Up @@ -40,6 +41,10 @@ export class TransactionFirestoreDocumentDataAccessor<T> implements FirestoreDoc
return Promise.resolve();
}

increment(data: FirestoreAccessorIncrementUpdate<T>): Promise<void | WriteResult> {
return this.update(firestoreClientIncrementUpdateToUpdateData(data));
}

update(data: UpdateData<unknown>): Promise<void> {
assertFirestoreUpdateHasData(data);
this.transaction.update(this.documentRef, data);
Expand Down
15 changes: 15 additions & 0 deletions packages/firebase/src/lib/client/firestore/increment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { mapObjectMap } from '@dereekb/util';
import { UpdateData, increment } from '@firebase/firestore';
import { FirestoreAccessorIncrementUpdate } from '../../common/firestore/accessor/accessor';

/**
* Creates UpdateData corresponding to the input increment update.
*
* @param input
* @returns
*/
export function firestoreClientIncrementUpdateToUpdateData<T>(input: FirestoreAccessorIncrementUpdate<T>): UpdateData<T> {
return mapObjectMap(input, (incrementValue) => {
return increment(incrementValue ?? 0);
}) as UpdateData<T>;
}
1 change: 1 addition & 0 deletions packages/firebase/src/lib/client/firestore/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './driver';
export * from './driver.accessor';
export * from './driver.query';
export * from './firestore';
export * from './increment';
19 changes: 18 additions & 1 deletion packages/firebase/src/lib/common/firestore/accessor/accessor.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { filterMaybe } from '@dereekb/rxjs';
import { filterUndefinedValues, Maybe, objectHasNoKeys } from '@dereekb/util';
import { filterUndefinedValues, KeyValueTransformMap, Maybe, objectHasNoKeys } from '@dereekb/util';
import { WriteResult, SnapshotOptions, DocumentReference, DocumentSnapshot, UpdateData, WithFieldValue, PartialWithFieldValue, SetOptions, Precondition, DocumentData, FirestoreDataConverter } from '../types';
import { map, Observable, OperatorFunction } from 'rxjs';
import { DocumentReferenceRef } from '../reference';
import { PickProperties } from 'ts-essentials';

export interface FirestoreDocumentDeleteParams {
precondition?: Precondition;
Expand All @@ -12,6 +13,13 @@ export interface FirestoreDocumentUpdateParams {
precondition?: Precondition;
}

/**
* Used for performing increment updates.
*
* Is an object that contains the amount to increment.
*/
export type FirestoreAccessorIncrementUpdate<T> = Partial<KeyValueTransformMap<PickProperties<T, Maybe<number> | number>, number>>;

/**
* Firestore database accessor instance used to retrieve and make changes to items in the database.
*/
Expand Down Expand Up @@ -59,6 +67,15 @@ export interface FirestoreDocumentDataAccessor<T, D = DocumentData> extends Docu
* @param data
*/
update(data: UpdateData<D>, params?: FirestoreDocumentUpdateParams): Promise<WriteResult | void>;
/**
* Directly updates the data in the database using the input increment, skipping the use of the converter, etc.
*
* If the input data is undefined or an empty object, it will fail.
* If the document doesn't exist, it will fail.
*
* @param data
*/
increment(data: FirestoreAccessorIncrementUpdate<T>, params?: FirestoreDocumentUpdateParams): Promise<WriteResult | void>;
}

export type FirestoreDocumentDataAccessorCreateFunction<T> = (data: WithFieldValue<T>) => Promise<void | WriteResult>;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Observable } from 'rxjs';
import { DocumentData, DocumentReference, DocumentSnapshot, FirestoreDataConverter, PartialWithFieldValue, SetOptions, UpdateData, WithFieldValue, WriteResult } from '../types';
import { FirestoreDocumentDataAccessor, FirestoreDocumentDataAccessorFactory, FirestoreDocumentDeleteParams, FirestoreDocumentUpdateParams } from './accessor';
import { FirestoreAccessorIncrementUpdate, FirestoreDocumentDataAccessor, FirestoreDocumentDataAccessorFactory, FirestoreDocumentDeleteParams, FirestoreDocumentUpdateParams } from './accessor';

// MARK: Abstract Wrapper
/**
Expand Down Expand Up @@ -47,6 +47,10 @@ export abstract class AbstractFirestoreDocumentDataAccessorWrapper<T, D = Docume
update(data: UpdateData<D>, params?: FirestoreDocumentUpdateParams): Promise<void | WriteResult> {
return this.accessor.update(data, params);
}

increment(data: FirestoreAccessorIncrementUpdate<T>, params?: FirestoreDocumentUpdateParams): Promise<WriteResult | void> {
return this.accessor.increment(data, params);
}
}

// MARK: Factory
Expand Down
13 changes: 12 additions & 1 deletion packages/firebase/src/lib/common/firestore/accessor/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ import { Observable } from 'rxjs';
import { FirestoreAccessorDriverRef } from '../driver/accessor';
import { FirestoreCollectionNameRef, FirestoreModelId, FirestoreModelIdentityCollectionName, FirestoreModelIdentityModelType, FirestoreModelIdentityRef, FirestoreModelIdRef, FirestoreModelKey, FirestoreModelKeyRef } from './../collection/collection';
import { DocumentReference, CollectionReference, Transaction, WriteBatch, DocumentSnapshot, SnapshotOptions, WriteResult, FirestoreDataConverter } from '../types';
import { createOrUpdateWithAccessorSet, dataFromSnapshotStream, FirestoreDocumentDataAccessor, FirestoreDocumentUpdateParams, updateWithAccessorSet, updateWithAccessorUpdateAndConverterFunction } from './accessor';
import { FirestoreAccessorIncrementUpdate, createOrUpdateWithAccessorSet, dataFromSnapshotStream, FirestoreDocumentDataAccessor, FirestoreDocumentUpdateParams, updateWithAccessorSet, updateWithAccessorUpdateAndConverterFunction } from './accessor';
import { CollectionReferenceRef, DocumentReferenceRef, FirestoreContextReference, FirestoreDataConverterRef } from '../reference';
import { FirestoreDocumentContext } from './context';
import { build, Maybe } from '@dereekb/util';
import { FirestoreModelTypeRef, FirestoreModelIdentity, FirestoreModelTypeModelIdentityRef } from '../collection/collection';
import { InterceptAccessorFactoryFunction } from './accessor.wrap';
import { incrementUpdateWithAccessorFunction } from './increment';

export interface FirestoreDocument<T, I extends FirestoreModelIdentity = FirestoreModelIdentity> extends FirestoreDataConverterRef<T>, DocumentReferenceRef<T>, CollectionReferenceRef<T>, FirestoreModelIdentityRef<I>, FirestoreModelTypeRef<FirestoreModelIdentityModelType<I>>, FirestoreCollectionNameRef<FirestoreModelIdentityCollectionName<I>>, FirestoreModelKeyRef, FirestoreModelIdRef {
readonly accessor: FirestoreDocumentDataAccessor<T>;
Expand All @@ -23,6 +24,7 @@ export interface FirestoreDocument<T, I extends FirestoreModelIdentity = Firesto
create(data: T): Promise<WriteResult | void>;
update(data: Partial<T>): Promise<WriteResult | void>;
createOrUpdate(data: Partial<T>): Promise<WriteResult | void>;
increment(data: FirestoreAccessorIncrementUpdate<T>): Promise<WriteResult | void>;
}

/**
Expand Down Expand Up @@ -131,6 +133,15 @@ export abstract class AbstractFirestoreDocument<T, D extends AbstractFirestoreDo
createOrUpdate(data: Partial<T>): Promise<WriteResult | void> {
return createOrUpdateWithAccessorSet(this.accessor)(data);
}

/**
* Updates the document using the accessor's increment functionality.
*
* @param data
*/
increment(data: FirestoreAccessorIncrementUpdate<T>): Promise<WriteResult | void> {
return incrementUpdateWithAccessorFunction<T>(this.accessor)(data);
}
}

export interface LimitedFirestoreDocumentAccessorRef<T, D extends FirestoreDocument<T> = FirestoreDocument<T>, A extends LimitedFirestoreDocumentAccessor<T, D> = LimitedFirestoreDocumentAccessor<T, D>> {
Expand Down
22 changes: 22 additions & 0 deletions packages/firebase/src/lib/common/firestore/accessor/increment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { filterFalsyAnyEmptyValues, objectHasNoKeys } from '@dereekb/util';
import { WriteResult } from '../types';
import { FirestoreAccessorIncrementUpdate, FirestoreDocumentDataAccessor } from './accessor';

export type IncrementUpdateWithAccessorFunction<T> = (data: FirestoreAccessorIncrementUpdate<T>) => Promise<WriteResult | void>;

/**
* https://cloud.google.com/firestore/docs/samples/firestore-data-set-numeric-increment
*
* @param accessor
* @returns
*/
export function incrementUpdateWithAccessorFunction<T>(accessor: FirestoreDocumentDataAccessor<T>): IncrementUpdateWithAccessorFunction<T> {
return async (data: FirestoreAccessorIncrementUpdate<T>) => {
const updateData = filterFalsyAnyEmptyValues(data);

// Only update
if (!objectHasNoKeys(updateData)) {
return accessor.increment(updateData);
}
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ export * from './context';
export * from './document';
export * from './document.utility';
export * from './document.rxjs';
export * from './increment';
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,47 @@ export function describeFirestoreAccessorDriverTests(f: MockItemCollectionFixtur
loadDocumentForTransaction: (transaction, ref) => f.instance.firestoreCollection.documentAccessorForTransaction(transaction).loadDocument(ref!),
loadDocumentForWriteBatch: (writeBatch, ref) => f.instance.firestoreCollection.documentAccessorForWriteBatch(writeBatch).loadDocument(ref!)
}));

describe('increment()', () => {
it(`should increase the item's value`, async () => {
let data = await itemDocument.snapshotData();

expect(data?.number).toBe(undefined);

const update = { number: 3 };
await itemDocument.increment(update);

data = await itemDocument.snapshotData();
expect(data?.number).toBe(update.number);
});

it(`should decrease the item's value`, async () => {
let data = await itemDocument.snapshotData();

expect(data?.number).toBe(undefined);

const update = { number: -3 };
await itemDocument.increment(update);

data = await itemDocument.snapshotData();
expect(data?.number).toBe(update.number);
});

it(`should increase and decrease the item's value`, async () => {
let data = await itemDocument.snapshotData();

expect(data?.number).toBe(undefined);

const update = { number: 3 };
await itemDocument.increment(update);

const update2 = { number: -6 };
await itemDocument.increment(update2);

data = await itemDocument.snapshotData();
expect(data?.number).toBe(update.number + update2.number);
});
});
});

describe('Subcollections', () => {
Expand Down
5 changes: 5 additions & 0 deletions packages/firebase/test/src/lib/common/mock/mock.item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ export interface MockItem {
* Optional date value
*/
date?: Maybe<Date>;
/**
* Optional number value
*/
number?: Maybe<number>;
/**
* List of tags.
*/
Expand Down Expand Up @@ -84,6 +88,7 @@ export const mockItemConverter = snapshotConverterFunctions<MockItem, MockItemDa
value: optionalFirestoreString(),
tags: optionalFirestoreArray(),
date: optionalFirestoreDate(),
number: optionalFirestoreNumber(),
test: firestoreBoolean({ default: true })
}
});
Expand Down
16 changes: 16 additions & 0 deletions packages/util/src/lib/object/object.filter.pojo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,14 @@ export const filterOnlyUndefinedValues: GeneralFilterFromPOJOFunction = filterFr
*/
export const filterNullAndUndefinedValues: GeneralFilterFromPOJOFunction = filterFromPOJOFunction({ copy: true, filter: { valueFilter: KeyValueTypleValueFilter.NULL } }) as GeneralFilterFromPOJOFunction;

/**
* Returns a copy of the input object with all falsy and empty filtered from it.
*
* @param obj
* @returns
*/
export const filterFalsyAnyEmptyValues: GeneralFilterFromPOJOFunction = filterFromPOJOFunction({ copy: true, filter: { valueFilter: KeyValueTypleValueFilter.FALSY_AND_EMPTY } }) as GeneralFilterFromPOJOFunction;

/**
* Returns all keys that are not associated with an undefined value.
*
Expand All @@ -133,6 +141,14 @@ export const filterNullAndUndefinedValues: GeneralFilterFromPOJOFunction = filte
*/
export const allNonUndefinedKeys: GeneralFindPOJOKeysFunction = findPOJOKeysFunction({ valueFilter: KeyValueTypleValueFilter.UNDEFINED });

/**
* Returns all keys that are not associated with a falsy or empty value.
*
* @param obj
* @returns
*/
export const allFalsyOrEmptyKeys: GeneralFindPOJOKeysFunction = findPOJOKeysFunction({ valueFilter: KeyValueTypleValueFilter.FALSY_AND_EMPTY });

/**
* Returns all keys that are not associated with a null/undefined value.
*
Expand Down

0 comments on commit d4dc97b

Please sign in to comment.