diff --git a/packages/firestore/__tests__/firestore.test.ts b/packages/firestore/__tests__/firestore.test.ts index ae4a74731a..c20904faf7 100644 --- a/packages/firestore/__tests__/firestore.test.ts +++ b/packages/firestore/__tests__/firestore.test.ts @@ -1314,6 +1314,90 @@ describe('Firestore', function () { ); }); + it('FirestoreDocumentSnapshot.data() respects SnapshotOptions.serverTimestamps', function () { + const firestore = getFirestore(); + const snapshot = new FirestoreDocumentSnapshot( + // @ts-expect-error calling a private constructor directly which expects FirestoreInternal type + firestore, + { + data: { createdAt: [3] }, + dataEstimate: { createdAt: [13, [123, 456]] }, + dataPrevious: { createdAt: [13, [42, 0]] }, + dataNone: { createdAt: [3] }, + metadata: [false, true], + path: 'foo/bar', + exists: true, + }, + null, + ); + + expect(snapshot.data()).toEqual({ createdAt: null }); + expect(snapshot.data({ serverTimestamps: 'estimate' })?.createdAt).toBeInstanceOf(Timestamp); + expect(snapshot.data({ serverTimestamps: 'estimate' })?.createdAt).toMatchObject({ + seconds: 123, + nanoseconds: 456, + }); + expect(snapshot.data({ serverTimestamps: 'previous' })?.createdAt).toMatchObject({ + seconds: 42, + nanoseconds: 0, + }); + expect(snapshot.data({ serverTimestamps: 'none' })).toEqual({ createdAt: null }); + }); + + it('FirestoreDocumentSnapshot.get() respects SnapshotOptions.serverTimestamps', function () { + const firestore = getFirestore(); + const snapshot = new FirestoreDocumentSnapshot( + // @ts-expect-error calling a private constructor directly which expects FirestoreInternal type + firestore, + { + data: { nested: [16, { createdAt: [3] }] }, + dataEstimate: { nested: [16, { createdAt: [13, [123, 456]] }] }, + dataPrevious: { nested: [16, { createdAt: [13, [42, 0]] }] }, + dataNone: { nested: [16, { createdAt: [3] }] }, + metadata: [false, true], + path: 'foo/bar', + exists: true, + }, + null, + ); + + expect(snapshot.get('nested.createdAt')).toBeNull(); + expect(snapshot.get('nested.createdAt', { serverTimestamps: 'estimate' })).toMatchObject({ + seconds: 123, + nanoseconds: 456, + }); + expect(snapshot.get('nested.createdAt', { serverTimestamps: 'previous' })).toMatchObject({ + seconds: 42, + nanoseconds: 0, + }); + expect(snapshot.get('nested.createdAt', { serverTimestamps: 'none' })).toBeNull(); + }); + + it('FirestoreDocumentSnapshot.data() passes SnapshotOptions through converter snapshots', function () { + const firestore = getFirestore(); + const snapshot = new FirestoreDocumentSnapshot( + // @ts-expect-error calling a private constructor directly which expects FirestoreInternal type + firestore, + { + data: { createdAt: [3] }, + dataEstimate: { createdAt: [13, [123, 456]] }, + metadata: [false, true], + path: 'foo/bar', + exists: true, + }, + { + toFirestore: data => data, + fromFirestore: converterSnapshot => + converterSnapshot.data({ serverTimestamps: 'estimate' }), + }, + ); + + expect(snapshot.data()?.createdAt).toMatchObject({ + seconds: 123, + nanoseconds: 456, + }); + }); + describe('FieldValue', function () { it('FieldValue.delete()', function () { const fieldValue = firestore.FieldValue; diff --git a/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestoreSerialize.java b/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestoreSerialize.java index 75a8593981..485b9e3630 100644 --- a/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestoreSerialize.java +++ b/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestoreSerialize.java @@ -78,6 +78,9 @@ public class ReactNativeFirebaseFirestoreSerialize { // Keys private static final String TYPE = "type"; private static final String KEY_DATA = "data"; + private static final String KEY_DATA_ESTIMATE = "dataEstimate"; + private static final String KEY_DATA_PREVIOUS = "dataPrevious"; + private static final String KEY_DATA_NONE = "dataNone"; private static final String KEY_PATH = "path"; private static final String KEY_EXISTS = "exists"; private static final String KEY_META = "metadata"; @@ -118,15 +121,39 @@ static WritableMap snapshotToWritableMap( getServerTimestampBehavior(appName, databaseId); if (documentSnapshot.exists()) { - if (documentSnapshot.getData(timestampBehavior) != null) { - documentMap.putMap( - KEY_DATA, objectMapToWritable(documentSnapshot.getData(timestampBehavior))); + Map data = documentSnapshot.getData(timestampBehavior); + putSnapshotData(documentMap, KEY_DATA, data); + + if (timestampBehavior != DocumentSnapshot.ServerTimestampBehavior.ESTIMATE) { + putSnapshotData( + documentMap, + KEY_DATA_ESTIMATE, + documentSnapshot.getData(DocumentSnapshot.ServerTimestampBehavior.ESTIMATE)); + } + if (timestampBehavior != DocumentSnapshot.ServerTimestampBehavior.PREVIOUS) { + putSnapshotData( + documentMap, + KEY_DATA_PREVIOUS, + documentSnapshot.getData(DocumentSnapshot.ServerTimestampBehavior.PREVIOUS)); + } + if (timestampBehavior != DocumentSnapshot.ServerTimestampBehavior.NONE) { + putSnapshotData( + documentMap, + KEY_DATA_NONE, + documentSnapshot.getData(DocumentSnapshot.ServerTimestampBehavior.NONE)); } } return documentMap; } + private static void putSnapshotData( + WritableMap documentMap, String key, @Nullable Map data) { + if (data != null) { + documentMap.putMap(key, objectMapToWritable(data)); + } + } + /** * Convert a Firestore QuerySnapshot instance to a RN serializable WritableMap type map * diff --git a/packages/firestore/consumer-type-test.ts b/packages/firestore/consumer-type-test.ts index f8bbcbb209..36c897e745 100644 --- a/packages/firestore/consumer-type-test.ts +++ b/packages/firestore/consumer-type-test.ts @@ -348,9 +348,12 @@ nsQuery.get().then((snap: FirebaseFirestoreTypes.QuerySnapshot) => { nsDocRef.get().then((snap: FirebaseFirestoreTypes.DocumentSnapshot) => { if (snap.exists()) { const d = snap.data(); + const estimate = snap.data({ serverTimestamps: 'estimate' }); void d; + void estimate; } void snap.get('field'); + void snap.get('field', { serverTimestamps: 'previous' }); void snap.metadata.isEqual(snap.metadata); }); @@ -638,6 +641,8 @@ getAggregateFromServer(modQuery1, aggSpec).then( // ----- getDoc, getDocFromCache, getDocFromServer ----- getDoc(modDoc).then(snap => snap.data()); +getDoc(modDoc).then(snap => snap.data({ serverTimestamps: 'estimate' })); +getDoc(modDoc).then(snap => snap.get('field', { serverTimestamps: 'previous' })); getDocFromCache(modDoc).then(snap => snap.data()); getDocFromServer(modDoc).then(snap => snap.data()); diff --git a/packages/firestore/e2e/withConverter.e2e.js b/packages/firestore/e2e/withConverter.e2e.js index 9658e6dd8c..5091313bec 100644 --- a/packages/firestore/e2e/withConverter.e2e.js +++ b/packages/firestore/e2e/withConverter.e2e.js @@ -25,11 +25,14 @@ const { addDoc, setDoc, getDoc, + onSnapshot, query, where, getDocs, writeBatch, increment, + serverTimestamp, + Timestamp, initializeFirestore, } = firestoreModular; @@ -590,6 +593,52 @@ describe('firestore.withConverter', function () { }); }); + it("passes data() serverTimestamps options through converter snapshots", function () { + const timestampConverter = { + toFirestore() { + return { + createdAt: serverTimestamp(), + updatedAt: serverTimestamp(), + }; + }, + fromFirestore(snapshot) { + return snapshot.data({ serverTimestamps: 'estimate' }); + }, + }; + + return withTestCollection(async coll => { + const ref = doc(coll, 'timestampConverter').withConverter(timestampConverter); + await new Promise((resolve, reject) => { + const unsubscribe = onSnapshot( + ref, + { includeMetadataChanges: true }, + snapshot => { + try { + if (!snapshot.exists() || !snapshot.metadata.hasPendingWrites) { + return; + } + + const data = snapshot.data(); + data.createdAt.should.be.an.instanceOf(Timestamp); + data.updatedAt.should.be.an.instanceOf(Timestamp); + unsubscribe(); + resolve(); + } catch (error) { + unsubscribe(); + reject(error); + } + }, + reject, + ); + + setDoc(ref, {}).catch(error => { + unsubscribe(); + reject(error); + }); + }); + }); + }); + it('supports partials with merge', async function () { return withTestCollection(async coll => { const ref = doc(coll, 'post').withConverter(postConverterMerge); diff --git a/packages/firestore/ios/RNFBFirestore/RNFBFirestoreSerialize.m b/packages/firestore/ios/RNFBFirestore/RNFBFirestoreSerialize.m index 70dd25e085..26f97108e8 100644 --- a/packages/firestore/ios/RNFBFirestore/RNFBFirestoreSerialize.m +++ b/packages/firestore/ios/RNFBFirestore/RNFBFirestoreSerialize.m @@ -26,6 +26,9 @@ @implementation RNFBFirestoreSerialize static NSString *const KEY_PATH = @"path"; static NSString *const KEY_DATA = @"data"; +static NSString *const KEY_DATA_ESTIMATE = @"dataEstimate"; +static NSString *const KEY_DATA_PREVIOUS = @"dataPrevious"; +static NSString *const KEY_DATA_NONE = @"dataNone"; static NSString *const KEY_EXISTS = @"exists"; static NSString *const KEY_CHANGES = @"changes"; static NSString *const KEY_METADATA = @"metadata"; @@ -188,6 +191,14 @@ + (NSDictionary *)documentChangeToDictionary:(FIRDocumentChange *)documentChange return changeMap; } ++ (void)putSnapshotData:(NSMutableDictionary *)documentMap + key:(NSString *)key + data:(NSDictionary *)data { + if (data != nil) { + documentMap[key] = [self serializeDictionary:data]; + } +} + // Native DocumentSnapshot -> NSDictionary (for JS) + (NSDictionary *)documentSnapshotToDictionary:(FIRDocumentSnapshot *)snapshot firestoreKey:(NSString *)firestoreKey { @@ -218,7 +229,25 @@ + (NSDictionary *)documentSnapshotToDictionary:(FIRDocumentSnapshot *)snapshot } NSDictionary *data = [snapshot dataWithServerTimestampBehavior:serverTimestampBehavior]; - documentMap[KEY_DATA] = [self serializeDictionary:data]; + [self putSnapshotData:documentMap key:KEY_DATA data:data]; + + if (serverTimestampBehavior != FIRServerTimestampBehaviorEstimate) { + NSDictionary *estimateData = + [snapshot dataWithServerTimestampBehavior:FIRServerTimestampBehaviorEstimate]; + [self putSnapshotData:documentMap key:KEY_DATA_ESTIMATE data:estimateData]; + } + + if (serverTimestampBehavior != FIRServerTimestampBehaviorPrevious) { + NSDictionary *previousData = + [snapshot dataWithServerTimestampBehavior:FIRServerTimestampBehaviorPrevious]; + [self putSnapshotData:documentMap key:KEY_DATA_PREVIOUS data:previousData]; + } + + if (serverTimestampBehavior != FIRServerTimestampBehaviorNone) { + NSDictionary *noneData = + [snapshot dataWithServerTimestampBehavior:FIRServerTimestampBehaviorNone]; + [self putSnapshotData:documentMap key:KEY_DATA_NONE data:noneData]; + } } return documentMap; diff --git a/packages/firestore/lib/FirestoreDocumentChange.ts b/packages/firestore/lib/FirestoreDocumentChange.ts index 3a3a4304bc..556329b583 100644 --- a/packages/firestore/lib/FirestoreDocumentChange.ts +++ b/packages/firestore/lib/FirestoreDocumentChange.ts @@ -28,7 +28,15 @@ const TYPE_MAP: Record = { export interface DocumentChangeNativeData { type: string; - doc: { path: string; data?: unknown; metadata?: [boolean, boolean]; exists?: boolean }; + doc: { + path: string; + data?: unknown; + dataEstimate?: unknown; + dataPrevious?: unknown; + dataNone?: unknown; + metadata?: [boolean, boolean]; + exists?: boolean; + }; ni: number; oi: number; isMetadataChange?: boolean; diff --git a/packages/firestore/lib/FirestoreDocumentSnapshot.ts b/packages/firestore/lib/FirestoreDocumentSnapshot.ts index 6f1d558a99..376d0d62bf 100644 --- a/packages/firestore/lib/FirestoreDocumentSnapshot.ts +++ b/packages/firestore/lib/FirestoreDocumentSnapshot.ts @@ -38,6 +38,9 @@ import type { export interface DocumentSnapshotNativeData { path: string; data?: unknown; + dataEstimate?: unknown; + dataPrevious?: unknown; + dataNone?: unknown; metadata?: [boolean, boolean]; exists?: boolean; } @@ -49,6 +52,9 @@ export default class DocumentSnapshot< _firestore: FirestoreInternal; _nativeData: DocumentSnapshotNativeData; _data: Record | undefined; + _dataEstimate: Record | undefined; + _dataPrevious: Record | undefined; + _dataNone: Record | undefined; _metadata: SnapshotMetadata; _ref: DocumentReference; _exists: boolean; @@ -63,6 +69,18 @@ export default class DocumentSnapshot< this._nativeData = nativeData; this._converter = converter; this._data = parseNativeMap(firestore, nativeData.data as Record | undefined); + this._dataEstimate = parseNativeMap( + firestore, + nativeData.dataEstimate as Record | undefined, + ); + this._dataPrevious = parseNativeMap( + firestore, + nativeData.dataPrevious as Record | undefined, + ); + this._dataNone = parseNativeMap( + firestore, + nativeData.dataNone as Record | undefined, + ); this._metadata = new SnapshotMetadata(nativeData.metadata ?? [false, false]); this._ref = new DocumentReference( firestore, @@ -72,6 +90,20 @@ export default class DocumentSnapshot< this._exists = nativeData.exists ?? false; } + _dataForOptions(options?: SnapshotOptions): Record | undefined { + // Older native payloads only include `data`; fall back to it if an option-specific map is absent. + switch (options?.serverTimestamps) { + case 'estimate': + return this._dataEstimate ?? this._data; + case 'previous': + return this._dataPrevious ?? this._data; + case 'none': + return this._dataNone ?? this._data; + default: + return this._data; + } + } + get id(): string { return this._ref.id; } @@ -105,10 +137,10 @@ export default class DocumentSnapshot< ); } } - return this._data as AppModelType | undefined; + return this._dataForOptions(options) as AppModelType | undefined; } - get(fieldPath: string | FieldPath, _options?: SnapshotOptions): DocumentFieldValueInternal { + get(fieldPath: string | FieldPath, options?: SnapshotOptions): DocumentFieldValueInternal { if (!isString(fieldPath) && !(fieldPath instanceof FieldPath)) { throw new Error( "firebase.firestore() DocumentSnapshot.get(*) 'fieldPath' expected type string or FieldPath.", @@ -129,7 +161,10 @@ export default class DocumentSnapshot< path = fieldPath; } - return extractFieldPathData(this._data, path._segments) as DocumentFieldValueInternal; + return extractFieldPathData( + this._dataForOptions(options), + path._segments, + ) as DocumentFieldValueInternal; } isEqual(other: DocumentSnapshot): boolean { diff --git a/packages/firestore/lib/FirestoreQuerySnapshot.ts b/packages/firestore/lib/FirestoreQuerySnapshot.ts index 16e5f2769f..0199053f6d 100644 --- a/packages/firestore/lib/FirestoreQuerySnapshot.ts +++ b/packages/firestore/lib/FirestoreQuerySnapshot.ts @@ -35,7 +35,15 @@ export interface QuerySnapshotNativeData { excludesMetadataChanges?: boolean; changes: Array<{ type: string; - doc: { path: string; data?: unknown; metadata?: [boolean, boolean]; exists?: boolean }; + doc: { + path: string; + data?: unknown; + dataEstimate?: unknown; + dataPrevious?: unknown; + dataNone?: unknown; + metadata?: [boolean, boolean]; + exists?: boolean; + }; ni: number; oi: number; isMetadataChange?: boolean; @@ -43,6 +51,9 @@ export interface QuerySnapshotNativeData { documents: Array<{ path: string; data?: unknown; + dataEstimate?: unknown; + dataPrevious?: unknown; + dataNone?: unknown; metadata?: [boolean, boolean]; exists?: boolean; }>; diff --git a/packages/firestore/lib/types/internal.ts b/packages/firestore/lib/types/internal.ts index 46dde1f175..e67304210c 100644 --- a/packages/firestore/lib/types/internal.ts +++ b/packages/firestore/lib/types/internal.ts @@ -386,6 +386,9 @@ export interface FirestoreDocumentSnapshotDataInternal { path: string; exists: boolean; data?: Record; + dataEstimate?: Record; + dataPrevious?: Record; + dataNone?: Record; metadata: [boolean, boolean]; } diff --git a/packages/firestore/lib/types/namespaced.ts b/packages/firestore/lib/types/namespaced.ts index 4aaac6f448..6cd4b04339 100644 --- a/packages/firestore/lib/types/namespaced.ts +++ b/packages/firestore/lib/types/namespaced.ts @@ -642,7 +642,7 @@ export namespace FirebaseFirestoreTypes { * console.log('User', user.data()); * ``` */ - data(): AppModelType | undefined; + data(options?: SnapshotOptions): AppModelType | undefined; /** * Retrieves the field specified by fieldPath. Returns undefined if the document or field doesn't exist. @@ -659,6 +659,7 @@ export namespace FirebaseFirestoreTypes { */ get( fieldPath: keyof AppModelType | string | FieldPath, + options?: SnapshotOptions, ): fieldType; /** @@ -708,7 +709,7 @@ export namespace FirebaseFirestoreTypes { * } * ``` */ - data(): AppModelType; + data(options?: SnapshotOptions): AppModelType; } /** diff --git a/packages/firestore/lib/web/RNFBFirestoreModule.ts b/packages/firestore/lib/web/RNFBFirestoreModule.ts index 8b7f6c10a1..fd85bf828c 100644 --- a/packages/firestore/lib/web/RNFBFirestoreModule.ts +++ b/packages/firestore/lib/web/RNFBFirestoreModule.ts @@ -65,13 +65,18 @@ function rejectWithCodeAndMessage(code: string, message: string): Promise type DocumentSnapshotLike = { exists(): boolean; ref: { path: string }; - data(): Record | undefined; + data( + options?: { serverTimestamps?: 'estimate' | 'previous' | 'none' }, + ): Record | undefined; }; function documentSnapshotToObject(snapshot: DocumentSnapshotLike): { path: string; exists: boolean; data?: unknown; + dataEstimate?: unknown; + dataPrevious?: unknown; + dataNone?: unknown; metadata: [boolean, boolean]; } { const exists = snapshot.exists(); @@ -79,6 +84,9 @@ function documentSnapshotToObject(snapshot: DocumentSnapshotLike): { path: string; exists: boolean; data?: unknown; + dataEstimate?: unknown; + dataPrevious?: unknown; + dataNone?: unknown; metadata: [boolean, boolean]; } = { metadata: [false, false], @@ -87,6 +95,13 @@ function documentSnapshotToObject(snapshot: DocumentSnapshotLike): { }; if (exists) { out.data = objectToWriteable(snapshot.data() ?? {}); + out.dataEstimate = objectToWriteable( + snapshot.data({ serverTimestamps: 'estimate' }) ?? {}, + ); + out.dataPrevious = objectToWriteable( + snapshot.data({ serverTimestamps: 'previous' }) ?? {}, + ); + out.dataNone = objectToWriteable(snapshot.data({ serverTimestamps: 'none' }) ?? {}); } return out; } diff --git a/packages/firestore/type-test.ts b/packages/firestore/type-test.ts index 6f6d06558b..a4b636c874 100644 --- a/packages/firestore/type-test.ts +++ b/packages/firestore/type-test.ts @@ -342,9 +342,12 @@ nsQuery.get().then((snap: FirebaseFirestoreTypes.QuerySnapshot) => { nsDocRef.get().then((snap: FirebaseFirestoreTypes.DocumentSnapshot) => { if (snap.exists()) { const d = snap.data(); + const estimate = snap.data({ serverTimestamps: 'estimate' }); console.log(d); + console.log(estimate); } console.log(snap.get('field')); + console.log(snap.get('field', { serverTimestamps: 'previous' })); console.log(snap.metadata.isEqual(snap.metadata)); }); @@ -642,6 +645,8 @@ getAggregateFromServer(modQuery1, aggSpec).then( // ----- getDoc, getDocFromCache, getDocFromServer ----- getDoc(modDoc).then(snap => snap.data()); +getDoc(modDoc).then(snap => snap.data({ serverTimestamps: 'estimate' })); +getDoc(modDoc).then(snap => snap.get('field', { serverTimestamps: 'previous' })); getDocFromCache(modDoc).then(snap => snap.data()); getDocFromServer(modDoc).then(snap => snap.data());