diff --git a/packages/firebase-server/src/lib/firestore/driver.accessor.batch.ts b/packages/firebase-server/src/lib/firestore/driver.accessor.batch.ts index e17c05480..84ab0b20b 100644 --- a/packages/firebase-server/src/lib/firestore/driver.accessor.batch.ts +++ b/packages/firebase-server/src/lib/firestore/driver.accessor.batch.ts @@ -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 /** @@ -40,6 +41,10 @@ export class WriteBatchFirestoreDocumentDataAccessor implements FirestoreDocu return Promise.resolve(); } + increment(data: FirestoreAccessorIncrementUpdate, params?: FirestoreDocumentUpdateParams): Promise { + return this.update(firestoreServerIncrementUpdateToUpdateData(data), params); + } + update(data: UpdateData, params?: FirestoreDocumentUpdateParams): Promise { if (params?.precondition != null) { this.batch.update(this.documentRef, data as FirebaseFirestore.UpdateData, params?.precondition); diff --git a/packages/firebase-server/src/lib/firestore/driver.accessor.default.ts b/packages/firebase-server/src/lib/firestore/driver.accessor.default.ts index 9e5191f50..ed7b8a2af 100644 --- a/packages/firebase-server/src/lib/firestore/driver.accessor.default.ts +++ b/packages/firebase-server/src/lib/firestore/driver.accessor.default.ts @@ -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 implements FirestoreDocumentDataAccessor { @@ -34,6 +35,10 @@ export class DefaultFirestoreDocumentDataAccessor implements FirestoreDocumen return options ? this.documentRef.set(data as Partial, options) : this.documentRef.set(data as T); } + increment(data: FirestoreAccessorIncrementUpdate, params?: FirestoreDocumentUpdateParams): Promise { + return this.update(firestoreServerIncrementUpdateToUpdateData(data), params); + } + update(data: UpdateData, params?: FirestoreDocumentUpdateParams): Promise { return params?.precondition ? this.documentRef.update(data as FirebaseFirestore.UpdateData, params.precondition) : this.documentRef.update(data as FirebaseFirestore.UpdateData); } diff --git a/packages/firebase-server/src/lib/firestore/driver.accessor.transaction.ts b/packages/firebase-server/src/lib/firestore/driver.accessor.transaction.ts index 1a3b32041..c82048c60 100644 --- a/packages/firebase-server/src/lib/firestore/driver.accessor.transaction.ts +++ b/packages/firebase-server/src/lib/firestore/driver.accessor.transaction.ts @@ -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 /** @@ -40,6 +41,10 @@ export class TransactionFirestoreDocumentDataAccessor implements FirestoreDoc return Promise.resolve(); } + increment(data: FirestoreAccessorIncrementUpdate, params?: FirestoreDocumentUpdateParams): Promise { + return this.update(firestoreServerIncrementUpdateToUpdateData(data), params); + } + update(data: UpdateData, params?: FirestoreDocumentUpdateParams): Promise { if (params?.precondition) { this.transaction.update(this.documentRef, data as FirebaseFirestore.UpdateData, params?.precondition); diff --git a/packages/firebase-server/src/lib/firestore/increment.ts b/packages/firebase-server/src/lib/firestore/increment.ts new file mode 100644 index 000000000..5de66c183 --- /dev/null +++ b/packages/firebase-server/src/lib/firestore/increment.ts @@ -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(input: FirestoreAccessorIncrementUpdate): UpdateData { + return mapObjectMap(input, (incrementValue) => { + return FieldValue.increment(incrementValue ?? 0); + }) as UpdateData; +} diff --git a/packages/firebase-server/src/lib/firestore/index.ts b/packages/firebase-server/src/lib/firestore/index.ts index e87d56545..17835d03b 100644 --- a/packages/firebase-server/src/lib/firestore/index.ts +++ b/packages/firebase-server/src/lib/firestore/index.ts @@ -3,3 +3,4 @@ export * from './driver.accessor'; export * from './driver.query'; export * from './firestore'; export * from './firestore.nest'; +export * from './increment'; diff --git a/packages/firebase/src/lib/client/firestore/driver.accessor.default.ts b/packages/firebase/src/lib/client/firestore/driver.accessor.default.ts index 294d836f9..49c87835a 100644 --- a/packages/firebase/src/lib/client/firestore/driver.accessor.default.ts +++ b/packages/firebase/src/lib/client/firestore/driver.accessor.default.ts @@ -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 implements FirestoreDocumentDataAccessor { @@ -35,6 +36,10 @@ export class DefaultFirestoreDocumentDataAccessor implements FirestoreDocumen return setDoc(this.documentRef, data, options as SetOptions); } + increment(data: FirestoreAccessorIncrementUpdate): Promise { + return this.update(firestoreClientIncrementUpdateToUpdateData(data)); + } + update(data: UpdateData): Promise { assertFirestoreUpdateHasData(data); return updateDoc(this.documentRef, data); diff --git a/packages/firebase/src/lib/client/firestore/driver.accessor.transaction.ts b/packages/firebase/src/lib/client/firestore/driver.accessor.transaction.ts index c3d32c8e6..6f358f4d9 100644 --- a/packages/firebase/src/lib/client/firestore/driver.accessor.transaction.ts +++ b/packages/firebase/src/lib/client/firestore/driver.accessor.transaction.ts @@ -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 /** @@ -40,6 +41,10 @@ export class TransactionFirestoreDocumentDataAccessor implements FirestoreDoc return Promise.resolve(); } + increment(data: FirestoreAccessorIncrementUpdate): Promise { + return this.update(firestoreClientIncrementUpdateToUpdateData(data)); + } + update(data: UpdateData): Promise { assertFirestoreUpdateHasData(data); this.transaction.update(this.documentRef, data); diff --git a/packages/firebase/src/lib/client/firestore/increment.ts b/packages/firebase/src/lib/client/firestore/increment.ts new file mode 100644 index 000000000..4747f46b2 --- /dev/null +++ b/packages/firebase/src/lib/client/firestore/increment.ts @@ -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(input: FirestoreAccessorIncrementUpdate): UpdateData { + return mapObjectMap(input, (incrementValue) => { + return increment(incrementValue ?? 0); + }) as UpdateData; +} diff --git a/packages/firebase/src/lib/client/firestore/index.ts b/packages/firebase/src/lib/client/firestore/index.ts index 5a9468626..4074634dc 100644 --- a/packages/firebase/src/lib/client/firestore/index.ts +++ b/packages/firebase/src/lib/client/firestore/index.ts @@ -2,3 +2,4 @@ export * from './driver'; export * from './driver.accessor'; export * from './driver.query'; export * from './firestore'; +export * from './increment'; diff --git a/packages/firebase/src/lib/common/firestore/accessor/accessor.ts b/packages/firebase/src/lib/common/firestore/accessor/accessor.ts index b16487724..9c8f4bc63 100644 --- a/packages/firebase/src/lib/common/firestore/accessor/accessor.ts +++ b/packages/firebase/src/lib/common/firestore/accessor/accessor.ts @@ -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; @@ -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 = Partial | number>, number>>; + /** * Firestore database accessor instance used to retrieve and make changes to items in the database. */ @@ -59,6 +67,15 @@ export interface FirestoreDocumentDataAccessor extends Docu * @param data */ update(data: UpdateData, params?: FirestoreDocumentUpdateParams): Promise; + /** + * 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, params?: FirestoreDocumentUpdateParams): Promise; } export type FirestoreDocumentDataAccessorCreateFunction = (data: WithFieldValue) => Promise; diff --git a/packages/firebase/src/lib/common/firestore/accessor/accessor.wrap.ts b/packages/firebase/src/lib/common/firestore/accessor/accessor.wrap.ts index 50bc5a34c..722058e7f 100644 --- a/packages/firebase/src/lib/common/firestore/accessor/accessor.wrap.ts +++ b/packages/firebase/src/lib/common/firestore/accessor/accessor.wrap.ts @@ -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 /** @@ -47,6 +47,10 @@ export abstract class AbstractFirestoreDocumentDataAccessorWrapper, params?: FirestoreDocumentUpdateParams): Promise { return this.accessor.update(data, params); } + + increment(data: FirestoreAccessorIncrementUpdate, params?: FirestoreDocumentUpdateParams): Promise { + return this.accessor.increment(data, params); + } } // MARK: Factory diff --git a/packages/firebase/src/lib/common/firestore/accessor/document.ts b/packages/firebase/src/lib/common/firestore/accessor/document.ts index 532a37670..a41fed5c9 100644 --- a/packages/firebase/src/lib/common/firestore/accessor/document.ts +++ b/packages/firebase/src/lib/common/firestore/accessor/document.ts @@ -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 extends FirestoreDataConverterRef, DocumentReferenceRef, CollectionReferenceRef, FirestoreModelIdentityRef, FirestoreModelTypeRef>, FirestoreCollectionNameRef>, FirestoreModelKeyRef, FirestoreModelIdRef { readonly accessor: FirestoreDocumentDataAccessor; @@ -23,6 +24,7 @@ export interface FirestoreDocument; update(data: Partial): Promise; createOrUpdate(data: Partial): Promise; + increment(data: FirestoreAccessorIncrementUpdate): Promise; } /** @@ -131,6 +133,15 @@ export abstract class AbstractFirestoreDocument): Promise { return createOrUpdateWithAccessorSet(this.accessor)(data); } + + /** + * Updates the document using the accessor's increment functionality. + * + * @param data + */ + increment(data: FirestoreAccessorIncrementUpdate): Promise { + return incrementUpdateWithAccessorFunction(this.accessor)(data); + } } export interface LimitedFirestoreDocumentAccessorRef = FirestoreDocument, A extends LimitedFirestoreDocumentAccessor = LimitedFirestoreDocumentAccessor> { diff --git a/packages/firebase/src/lib/common/firestore/accessor/increment.ts b/packages/firebase/src/lib/common/firestore/accessor/increment.ts new file mode 100644 index 000000000..a5cd6659a --- /dev/null +++ b/packages/firebase/src/lib/common/firestore/accessor/increment.ts @@ -0,0 +1,22 @@ +import { filterFalsyAnyEmptyValues, objectHasNoKeys } from '@dereekb/util'; +import { WriteResult } from '../types'; +import { FirestoreAccessorIncrementUpdate, FirestoreDocumentDataAccessor } from './accessor'; + +export type IncrementUpdateWithAccessorFunction = (data: FirestoreAccessorIncrementUpdate) => Promise; + +/** + * https://cloud.google.com/firestore/docs/samples/firestore-data-set-numeric-increment + * + * @param accessor + * @returns + */ +export function incrementUpdateWithAccessorFunction(accessor: FirestoreDocumentDataAccessor): IncrementUpdateWithAccessorFunction { + return async (data: FirestoreAccessorIncrementUpdate) => { + const updateData = filterFalsyAnyEmptyValues(data); + + // Only update + if (!objectHasNoKeys(updateData)) { + return accessor.increment(updateData); + } + }; +} diff --git a/packages/firebase/src/lib/common/firestore/accessor/index.ts b/packages/firebase/src/lib/common/firestore/accessor/index.ts index 23e3a9646..b89d484ca 100644 --- a/packages/firebase/src/lib/common/firestore/accessor/index.ts +++ b/packages/firebase/src/lib/common/firestore/accessor/index.ts @@ -11,3 +11,4 @@ export * from './context'; export * from './document'; export * from './document.utility'; export * from './document.rxjs'; +export * from './increment'; diff --git a/packages/firebase/test/src/lib/common/firestore/test.driver.accessor.ts b/packages/firebase/test/src/lib/common/firestore/test.driver.accessor.ts index 18675560f..f13114db4 100644 --- a/packages/firebase/test/src/lib/common/firestore/test.driver.accessor.ts +++ b/packages/firebase/test/src/lib/common/firestore/test.driver.accessor.ts @@ -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', () => { diff --git a/packages/firebase/test/src/lib/common/mock/mock.item.ts b/packages/firebase/test/src/lib/common/mock/mock.item.ts index 26a33f7e4..704cc54d2 100644 --- a/packages/firebase/test/src/lib/common/mock/mock.item.ts +++ b/packages/firebase/test/src/lib/common/mock/mock.item.ts @@ -43,6 +43,10 @@ export interface MockItem { * Optional date value */ date?: Maybe; + /** + * Optional number value + */ + number?: Maybe; /** * List of tags. */ @@ -84,6 +88,7 @@ export const mockItemConverter = snapshotConverterFunctions