diff --git a/android/src/main/java/io/invertase/firebase/ErrorUtils.java b/android/src/main/java/io/invertase/firebase/ErrorUtils.java new file mode 100644 index 00000000..85d4fdb0 --- /dev/null +++ b/android/src/main/java/io/invertase/firebase/ErrorUtils.java @@ -0,0 +1,27 @@ +package io.invertase.firebase; + +public class ErrorUtils { + /** + * Wrap a message string with the specified service name e.g. 'Database' + * + * @param message + * @param service + * @param fullCode + * @return + */ + public static String getMessageWithService(String message, String service, String fullCode) { + // Service: Error message (service/code). + return service + ": " + message + " (" + fullCode.toLowerCase() + ")."; + } + + /** + * Generate a service error code string e.g. 'DATABASE/PERMISSION-DENIED' + * + * @param service + * @param code + * @return + */ + public static String getCodeWithService(String service, String code) { + return service.toLowerCase() + "/" + code.toLowerCase(); + } +} diff --git a/android/src/main/java/io/invertase/firebase/database/RNFirebaseDatabase.java b/android/src/main/java/io/invertase/firebase/database/RNFirebaseDatabase.java index 07a91893..6612f491 100644 --- a/android/src/main/java/io/invertase/firebase/database/RNFirebaseDatabase.java +++ b/android/src/main/java/io/invertase/firebase/database/RNFirebaseDatabase.java @@ -26,6 +26,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import io.invertase.firebase.ErrorUtils; import io.invertase.firebase.Utils; @@ -522,30 +523,6 @@ public class RNFirebaseDatabase extends ReactContextBaseJavaModule { return existingRef; } - /** - * Wrap a message string with the specified service name e.g. 'Database' - * - * @param message - * @param service - * @param fullCode - * @return - */ - private static String getMessageWithService(String message, String service, String fullCode) { - // Service: Error message (service/code). - return service + ": " + message + " (" + fullCode.toLowerCase() + ")."; - } - - /** - * Generate a service error code string e.g. 'DATABASE/PERMISSION-DENIED' - * - * @param service - * @param code - * @return - */ - private static String getCodeWithService(String service, String code) { - return service.toLowerCase() + "/" + code.toLowerCase(); - } - /** * Convert as firebase DatabaseError instance into a writable map * with the correct web-like error codes. @@ -564,56 +541,56 @@ public class RNFirebaseDatabase extends ReactContextBaseJavaModule { switch (nativeError.getCode()) { case DatabaseError.DATA_STALE: - code = getCodeWithService(service, "data-stale"); - message = getMessageWithService("The transaction needs to be run again with current data.", service, code); + code = ErrorUtils.getCodeWithService(service, "data-stale"); + message = ErrorUtils.getMessageWithService("The transaction needs to be run again with current data.", service, code); break; case DatabaseError.OPERATION_FAILED: - code = getCodeWithService(service, "failure"); - message = getMessageWithService("The server indicated that this operation failed.", service, code); + code = ErrorUtils.getCodeWithService(service, "failure"); + message = ErrorUtils.getMessageWithService("The server indicated that this operation failed.", service, code); break; case DatabaseError.PERMISSION_DENIED: - code = getCodeWithService(service, "permission-denied"); - message = getMessageWithService("Client doesn't have permission to access the desired data.", service, code); + code = ErrorUtils.getCodeWithService(service, "permission-denied"); + message = ErrorUtils.getMessageWithService("Client doesn't have permission to access the desired data.", service, code); break; case DatabaseError.DISCONNECTED: - code = getCodeWithService(service, "disconnected"); - message = getMessageWithService("The operation had to be aborted due to a network disconnect.", service, code); + code = ErrorUtils.getCodeWithService(service, "disconnected"); + message = ErrorUtils.getMessageWithService("The operation had to be aborted due to a network disconnect.", service, code); break; case DatabaseError.EXPIRED_TOKEN: - code = getCodeWithService(service, "expired-token"); - message = getMessageWithService("The supplied auth token has expired.", service, code); + code = ErrorUtils.getCodeWithService(service, "expired-token"); + message = ErrorUtils.getMessageWithService("The supplied auth token has expired.", service, code); break; case DatabaseError.INVALID_TOKEN: - code = getCodeWithService(service, "invalid-token"); - message = getMessageWithService("The supplied auth token was invalid.", service, code); + code = ErrorUtils.getCodeWithService(service, "invalid-token"); + message = ErrorUtils.getMessageWithService("The supplied auth token was invalid.", service, code); break; case DatabaseError.MAX_RETRIES: - code = getCodeWithService(service, "max-retries"); - message = getMessageWithService("The transaction had too many retries.", service, code); + code = ErrorUtils.getCodeWithService(service, "max-retries"); + message = ErrorUtils.getMessageWithService("The transaction had too many retries.", service, code); break; case DatabaseError.OVERRIDDEN_BY_SET: - code = getCodeWithService(service, "overridden-by-set"); - message = getMessageWithService("The transaction was overridden by a subsequent set.", service, code); + code = ErrorUtils.getCodeWithService(service, "overridden-by-set"); + message = ErrorUtils.getMessageWithService("The transaction was overridden by a subsequent set.", service, code); break; case DatabaseError.UNAVAILABLE: - code = getCodeWithService(service, "unavailable"); - message = getMessageWithService("The service is unavailable.", service, code); + code = ErrorUtils.getCodeWithService(service, "unavailable"); + message = ErrorUtils.getMessageWithService("The service is unavailable.", service, code); break; case DatabaseError.USER_CODE_EXCEPTION: - code = getCodeWithService(service, "user-code-exception"); - message = getMessageWithService("User code called from the Firebase Database runloop threw an exception.", service, code); + code = ErrorUtils.getCodeWithService(service, "user-code-exception"); + message = ErrorUtils.getMessageWithService("User code called from the Firebase Database runloop threw an exception.", service, code); break; case DatabaseError.NETWORK_ERROR: - code = getCodeWithService(service, "network-error"); - message = getMessageWithService("The operation could not be performed due to a network error.", service, code); + code = ErrorUtils.getCodeWithService(service, "network-error"); + message = ErrorUtils.getMessageWithService("The operation could not be performed due to a network error.", service, code); break; case DatabaseError.WRITE_CANCELED: - code = getCodeWithService(service, "write-cancelled"); - message = getMessageWithService("The write was canceled by the user.", service, code); + code = ErrorUtils.getCodeWithService(service, "write-cancelled"); + message = ErrorUtils.getMessageWithService("The write was canceled by the user.", service, code); break; default: - code = getCodeWithService(service, "unknown"); - message = getMessageWithService("An unknown error occurred.", service, code); + code = ErrorUtils.getCodeWithService(service, "unknown"); + message = ErrorUtils.getMessageWithService("An unknown error occurred.", service, code); } errorMap.putString("code", code); diff --git a/android/src/main/java/io/invertase/firebase/database/RNFirebaseDatabaseReference.java b/android/src/main/java/io/invertase/firebase/database/RNFirebaseDatabaseReference.java index e6c2216b..60a6cbe5 100644 --- a/android/src/main/java/io/invertase/firebase/database/RNFirebaseDatabaseReference.java +++ b/android/src/main/java/io/invertase/firebase/database/RNFirebaseDatabaseReference.java @@ -29,8 +29,8 @@ class RNFirebaseDatabaseReference { private String appName; private ReactContext reactContext; private static final String TAG = "RNFirebaseDBReference"; - private HashMap childEventListeners; - private HashMap valueEventListeners; + private HashMap childEventListeners = new HashMap<>(); + private HashMap valueEventListeners = new HashMap<>(); /** * RNFirebase wrapper around FirebaseDatabaseReference, @@ -47,8 +47,6 @@ class RNFirebaseDatabaseReference { query = null; appName = app; reactContext = context; - childEventListeners = new HashMap<>(); - valueEventListeners = new HashMap<>(); buildDatabaseQueryAtPathAndModifiers(refPath, modifiersArray); } diff --git a/android/src/main/java/io/invertase/firebase/firestore/RNFirebaseFirestore.java b/android/src/main/java/io/invertase/firebase/firestore/RNFirebaseFirestore.java index 968f5403..19c0f21b 100644 --- a/android/src/main/java/io/invertase/firebase/firestore/RNFirebaseFirestore.java +++ b/android/src/main/java/io/invertase/firebase/firestore/RNFirebaseFirestore.java @@ -11,12 +11,14 @@ import com.facebook.react.bridge.ReactMethod; import com.facebook.react.bridge.ReadableArray; import com.facebook.react.bridge.ReadableMap; import com.facebook.react.bridge.WritableArray; +import com.facebook.react.bridge.WritableMap; import com.google.android.gms.tasks.OnCompleteListener; import com.google.android.gms.tasks.Task; import com.google.firebase.FirebaseApp; import com.google.firebase.firestore.DocumentReference; import com.google.firebase.firestore.FieldValue; import com.google.firebase.firestore.FirebaseFirestore; +import com.google.firebase.firestore.FirebaseFirestoreException; import com.google.firebase.firestore.SetOptions; import com.google.firebase.firestore.WriteBatch; @@ -24,12 +26,14 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import io.invertase.firebase.ErrorUtils; import io.invertase.firebase.Utils; public class RNFirebaseFirestore extends ReactContextBaseJavaModule { private static final String TAG = "RNFirebaseFirestore"; - // private HashMap references = new HashMap<>(); + private HashMap collectionReferences = new HashMap<>(); + private HashMap documentReferences = new HashMap<>(); // private SparseArray transactionHandlers = new SparseArray<>(); RNFirebaseFirestore(ReactApplicationContext reactContext) { @@ -94,7 +98,7 @@ public class RNFirebaseFirestore extends ReactContextBaseJavaModule { promise.resolve(result); } else { Log.e(TAG, "set:onComplete:failure", task.getException()); - RNFirebaseFirestore.promiseRejectException(promise, task.getException()); + RNFirebaseFirestore.promiseRejectException(promise, (FirebaseFirestoreException)task.getException()); } } }); @@ -129,6 +133,22 @@ public class RNFirebaseFirestore extends ReactContextBaseJavaModule { // Not supported on Android out of the box } + @ReactMethod + public void documentOffSnapshot(String appName, String path, int listenerId) { + RNFirebaseFirestoreDocumentReference ref = getCachedDocumentForAppPath(appName, path); + ref.offSnapshot(listenerId); + + if (!ref.hasListeners()) { + clearCachedDocumentForAppPath(appName, path); + } + } + + @ReactMethod + public void documentOnSnapshot(String appName, String path, int listenerId) { + RNFirebaseFirestoreDocumentReference ref = getCachedDocumentForAppPath(appName, path); + ref.onSnapshot(listenerId); + } + @ReactMethod public void documentSet(String appName, String path, ReadableMap data, ReadableMap options, final Promise promise) { RNFirebaseFirestoreDocumentReference ref = getDocumentForAppPath(appName, path); @@ -151,12 +171,11 @@ public class RNFirebaseFirestore extends ReactContextBaseJavaModule { * @param exception Exception Exception normally from a task result. * @param promise Promise react native promise */ - static void promiseRejectException(Promise promise, Exception exception) { - // TODO - // WritableMap jsError = getJSError(exception); + static void promiseRejectException(Promise promise, FirebaseFirestoreException exception) { + WritableMap jsError = getJSError(exception); promise.reject( - "TODO", // jsError.getString("code"), - exception.getMessage(), // jsError.getString("message"), + jsError.getString("code"), + jsError.getString("message"), exception ); } @@ -188,6 +207,35 @@ public class RNFirebaseFirestore extends ReactContextBaseJavaModule { return new RNFirebaseFirestoreCollectionReference(appName, path, filters, orders, options); } + /** + * Get a cached document reference for a specific app and path + * + * @param appName + * @param path + * @return + */ + private RNFirebaseFirestoreDocumentReference getCachedDocumentForAppPath(String appName, String path) { + String key = appName + "/" + path; + RNFirebaseFirestoreDocumentReference ref = documentReferences.get(key); + if (ref == null) { + ref = getDocumentForAppPath(appName, path); + documentReferences.put(key, ref); + } + return ref; + } + + /** + * Clear a cached document reference for a specific app and path + * + * @param appName + * @param path + * @return + */ + private void clearCachedDocumentForAppPath(String appName, String path) { + String key = appName + "/" + path; + documentReferences.remove(key); + } + /** * Get a document reference for a specific app and path * @@ -196,7 +244,99 @@ public class RNFirebaseFirestore extends ReactContextBaseJavaModule { * @return */ private RNFirebaseFirestoreDocumentReference getDocumentForAppPath(String appName, String path) { - return new RNFirebaseFirestoreDocumentReference(appName, path); + return new RNFirebaseFirestoreDocumentReference(this.getReactApplicationContext(), appName, path); + } + + /** + * Convert as firebase DatabaseError instance into a writable map + * with the correct web-like error codes. + * + * @param nativeException + * @return + */ + static WritableMap getJSError(FirebaseFirestoreException nativeException) { + WritableMap errorMap = Arguments.createMap(); + errorMap.putInt("nativeErrorCode", nativeException.getCode().value()); + errorMap.putString("nativeErrorMessage", nativeException.getMessage()); + + String code; + String message; + String service = "Firestore"; + + // TODO: Proper error mappings + switch (nativeException.getCode()) { + case OK: + code = ErrorUtils.getCodeWithService(service, "ok"); + message = ErrorUtils.getMessageWithService("Ok.", service, code); + break; + case CANCELLED: + code = ErrorUtils.getCodeWithService(service, "cancelled"); + message = ErrorUtils.getMessageWithService("Cancelled.", service, code); + break; + case UNKNOWN: + code = ErrorUtils.getCodeWithService(service, "unknown"); + message = ErrorUtils.getMessageWithService("An unknown error occurred.", service, code); + break; + case INVALID_ARGUMENT: + code = ErrorUtils.getCodeWithService(service, "invalid-argument"); + message = ErrorUtils.getMessageWithService("Invalid argument.", service, code); + break; + case NOT_FOUND: + code = ErrorUtils.getCodeWithService(service, "not-found"); + message = ErrorUtils.getMessageWithService("Not found.", service, code); + break; + case ALREADY_EXISTS: + code = ErrorUtils.getCodeWithService(service, "already-exists"); + message = ErrorUtils.getMessageWithService("Already exists.", service, code); + break; + case PERMISSION_DENIED: + code = ErrorUtils.getCodeWithService(service, "permission-denied"); + message = ErrorUtils.getMessageWithService("Permission denied.", service, code); + break; + case RESOURCE_EXHAUSTED: + code = ErrorUtils.getCodeWithService(service, "resource-exhausted"); + message = ErrorUtils.getMessageWithService("Resource exhausted.", service, code); + break; + case FAILED_PRECONDITION: + code = ErrorUtils.getCodeWithService(service, "failed-precondition"); + message = ErrorUtils.getMessageWithService("Failed precondition.", service, code); + break; + case ABORTED: + code = ErrorUtils.getCodeWithService(service, "aborted"); + message = ErrorUtils.getMessageWithService("Aborted.", service, code); + break; + case OUT_OF_RANGE: + code = ErrorUtils.getCodeWithService(service, "out-of-range"); + message = ErrorUtils.getMessageWithService("Out of range.", service, code); + break; + case UNIMPLEMENTED: + code = ErrorUtils.getCodeWithService(service, "unimplemented"); + message = ErrorUtils.getMessageWithService("Unimplemented.", service, code); + break; + case INTERNAL: + code = ErrorUtils.getCodeWithService(service, "internal"); + message = ErrorUtils.getMessageWithService("Internal.", service, code); + break; + case UNAVAILABLE: + code = ErrorUtils.getCodeWithService(service, "unavailable"); + message = ErrorUtils.getMessageWithService("Unavailable.", service, code); + break; + case DATA_LOSS: + code = ErrorUtils.getCodeWithService(service, "data-loss"); + message = ErrorUtils.getMessageWithService("Data loss.", service, code); + break; + case UNAUTHENTICATED: + code = ErrorUtils.getCodeWithService(service, "unauthenticated"); + message = ErrorUtils.getMessageWithService("Unauthenticated.", service, code); + break; + default: + code = ErrorUtils.getCodeWithService(service, "unknown"); + message = ErrorUtils.getMessageWithService("An unknown error occurred.", service, code); + } + + errorMap.putString("code", code); + errorMap.putString("message", message); + return errorMap; } /** diff --git a/android/src/main/java/io/invertase/firebase/firestore/RNFirebaseFirestoreCollectionReference.java b/android/src/main/java/io/invertase/firebase/firestore/RNFirebaseFirestoreCollectionReference.java index 9f41ab6a..9cba86be 100644 --- a/android/src/main/java/io/invertase/firebase/firestore/RNFirebaseFirestoreCollectionReference.java +++ b/android/src/main/java/io/invertase/firebase/firestore/RNFirebaseFirestoreCollectionReference.java @@ -10,6 +10,7 @@ import com.facebook.react.bridge.ReadableMap; import com.facebook.react.bridge.WritableMap; import com.google.android.gms.tasks.OnCompleteListener; import com.google.android.gms.tasks.Task; +import com.google.firebase.firestore.FirebaseFirestoreException; import com.google.firebase.firestore.Query; import com.google.firebase.firestore.QuerySnapshot; @@ -47,7 +48,7 @@ public class RNFirebaseFirestoreCollectionReference { promise.resolve(data); } else { Log.e(TAG, "get:onComplete:failure", task.getException()); - RNFirebaseFirestore.promiseRejectException(promise, task.getException()); + RNFirebaseFirestore.promiseRejectException(promise, (FirebaseFirestoreException)task.getException()); } } }); diff --git a/android/src/main/java/io/invertase/firebase/firestore/RNFirebaseFirestoreDocumentReference.java b/android/src/main/java/io/invertase/firebase/firestore/RNFirebaseFirestoreDocumentReference.java index 6d5bfed1..ffe442a0 100644 --- a/android/src/main/java/io/invertase/firebase/firestore/RNFirebaseFirestoreDocumentReference.java +++ b/android/src/main/java/io/invertase/firebase/firestore/RNFirebaseFirestoreDocumentReference.java @@ -5,14 +5,19 @@ import android.util.Log; import com.facebook.react.bridge.Arguments; import com.facebook.react.bridge.Promise; +import com.facebook.react.bridge.ReactContext; import com.facebook.react.bridge.ReadableMap; import com.facebook.react.bridge.WritableMap; import com.google.android.gms.tasks.OnCompleteListener; import com.google.android.gms.tasks.Task; import com.google.firebase.firestore.DocumentReference; import com.google.firebase.firestore.DocumentSnapshot; +import com.google.firebase.firestore.EventListener; +import com.google.firebase.firestore.FirebaseFirestoreException; +import com.google.firebase.firestore.ListenerRegistration; import com.google.firebase.firestore.SetOptions; +import java.util.HashMap; import java.util.Map; import io.invertase.firebase.Utils; @@ -22,11 +27,14 @@ public class RNFirebaseFirestoreDocumentReference { private static final String TAG = "RNFBFSDocumentReference"; private final String appName; private final String path; + private ReactContext reactContext; private final DocumentReference ref; + private Map documentSnapshotListeners = new HashMap<>(); - RNFirebaseFirestoreDocumentReference(String appName, String path) { + RNFirebaseFirestoreDocumentReference(ReactContext reactContext, String appName, String path) { this.appName = appName; this.path = path; + this.reactContext = reactContext; this.ref = RNFirebaseFirestore.getFirestoreForApp(appName).document(path); } @@ -49,7 +57,7 @@ public class RNFirebaseFirestoreDocumentReference { promise.resolve(Arguments.createMap()); } else { Log.e(TAG, "delete:onComplete:failure", task.getException()); - RNFirebaseFirestore.promiseRejectException(promise, task.getException()); + RNFirebaseFirestore.promiseRejectException(promise, (FirebaseFirestoreException)task.getException()); } } }); @@ -65,12 +73,40 @@ public class RNFirebaseFirestoreDocumentReference { promise.resolve(data); } else { Log.e(TAG, "get:onComplete:failure", task.getException()); - RNFirebaseFirestore.promiseRejectException(promise, task.getException()); + RNFirebaseFirestore.promiseRejectException(promise, (FirebaseFirestoreException)task.getException()); } } }); } + public void offSnapshot(final int listenerId) { + ListenerRegistration listenerRegistration = documentSnapshotListeners.remove(listenerId); + if (listenerRegistration != null) { + listenerRegistration.remove(); + } + } + + public void onSnapshot(final int listenerId) { + if (!documentSnapshotListeners.containsKey(listenerId)) { + final EventListener listener = new EventListener() { + @Override + public void onEvent(DocumentSnapshot documentSnapshot, FirebaseFirestoreException exception) { + if (exception == null) { + handleDocumentSnapshotEvent(listenerId, documentSnapshot); + } else { + ListenerRegistration listenerRegistration = documentSnapshotListeners.remove(listenerId); + if (listenerRegistration != null) { + listenerRegistration.remove(); + } + handleDocumentSnapshotError(listenerId, exception); + } + } + }; + ListenerRegistration listenerRegistration = this.ref.addSnapshotListener(listener); + documentSnapshotListeners.put(listenerId, listenerRegistration); + } + } + public void set(final ReadableMap data, final ReadableMap options, final Promise promise) { Map map = Utils.recursivelyDeconstructReadableMap(data); Task task; @@ -90,7 +126,7 @@ public class RNFirebaseFirestoreDocumentReference { promise.resolve(Arguments.createMap()); } else { Log.e(TAG, "set:onComplete:failure", task.getException()); - RNFirebaseFirestore.promiseRejectException(promise, task.getException()); + RNFirebaseFirestore.promiseRejectException(promise, (FirebaseFirestoreException)task.getException()); } } }); @@ -108,9 +144,52 @@ public class RNFirebaseFirestoreDocumentReference { promise.resolve(Arguments.createMap()); } else { Log.e(TAG, "update:onComplete:failure", task.getException()); - RNFirebaseFirestore.promiseRejectException(promise, task.getException()); + RNFirebaseFirestore.promiseRejectException(promise, (FirebaseFirestoreException)task.getException()); } } }); } + + /* + * INTERNALS/UTILS + */ + + public boolean hasListeners() { + return !documentSnapshotListeners.isEmpty(); + } + + /** + * Handles documentSnapshot events. + * + * @param listenerId + * @param documentSnapshot + */ + private void handleDocumentSnapshotEvent(int listenerId, DocumentSnapshot documentSnapshot) { + WritableMap event = Arguments.createMap(); + WritableMap data = FirestoreSerialize.snapshotToWritableMap(documentSnapshot); + + event.putString("appName", appName); + event.putString("path", path); + event.putInt("listenerId", listenerId); + event.putMap("document", data); + + Utils.sendEvent(reactContext, "firestore_document_sync_event", event); + } + + /** + * Handles a documentSnapshot error event + * + * @param listenerId + * @param exception + */ + private void handleDocumentSnapshotError(int listenerId, FirebaseFirestoreException exception) { + WritableMap event = Arguments.createMap(); + + event.putString("appName", appName); + event.putString("path", path); + event.putInt("listenerId", listenerId); + event.putMap("error", RNFirebaseFirestore.getJSError(exception)); + + Utils.sendEvent(reactContext, "firestore_document_sync_event", event); + } } diff --git a/lib/modules/firestore/DocumentReference.js b/lib/modules/firestore/DocumentReference.js index f569f3d1..a061ca18 100644 --- a/lib/modules/firestore/DocumentReference.js +++ b/lib/modules/firestore/DocumentReference.js @@ -19,6 +19,9 @@ export type WriteResult = { writeTime: string, } +// track all event registrations +let listeners = 0; + /** * @class DocumentReference */ @@ -89,8 +92,30 @@ export default class DocumentReference { throw new Error(INTERNALS.STRINGS.ERROR_UNSUPPORTED_CLASS_METHOD('DocumentReference', 'getCollections')); } - onSnapshot(onNext: () => any, onError?: () => any): () => void { - // TODO + onSnapshot(onNext: Function, onError?: Function): () => void { + // TODO: Validation + const listenerId = listeners++; + + // Listen to snapshot events + this._firestore.on( + this._firestore._getAppEventName(`onDocumentSnapshot:${listenerId}`), + onNext, + ); + + // Listen for snapshot error events + if (onError) { + this._firestore.on( + this._firestore._getAppEventName(`onDocumentSnapshotError:${listenerId}`), + onError, + ); + } + + // Add the native listener + this._firestore._native + .documentOnSnapshot(this.path, listenerId); + + // Return an unsubscribe method + return this._offDocumentSnapshot.bind(this, listenerId, onNext); } set(data: { [string]: any }, writeOptions?: WriteOptions): Promise { @@ -105,16 +130,11 @@ export default class DocumentReference { } /** - * INTERNALS + * Remove auth change listener + * @param listener */ - - /** - * Generate a string that uniquely identifies this DocumentReference - * - * @return {string} - * @private - */ - _getDocumentKey() { - return `$${this._firestore._appName}$/${this.path}`; + _offDocumentSnapshot(listenerId: number, listener: Function) { + this._firestore.log.info('Removing onDocumentSnapshot listener'); + this._firestore.removeListener(this._firestore._getAppEventName(`onDocumentSnapshot:${listenerId}`), listener); } } diff --git a/lib/modules/firestore/Query.js b/lib/modules/firestore/Query.js index 4ec64817..e415ff0b 100644 --- a/lib/modules/firestore/Query.js +++ b/lib/modules/firestore/Query.js @@ -200,7 +200,7 @@ export default class Query { } stream(): Stream { - + throw new Error(INTERNALS.STRINGS.ERROR_UNSUPPORTED_CLASS_METHOD('Query', 'stream')); } where(fieldPath: string, opStr: Operator, value: any): Query { diff --git a/lib/modules/firestore/index.js b/lib/modules/firestore/index.js index b5849ece..e50f0482 100644 --- a/lib/modules/firestore/index.js +++ b/lib/modules/firestore/index.js @@ -16,6 +16,14 @@ import INTERNALS from './../../internals'; const unquotedIdentifier_ = '(?:[A-Za-z_][A-Za-z_0-9]*)'; const UNQUOTED_IDENTIFIER_REGEX = new RegExp(`^${unquotedIdentifier_}$`); +type DocumentSyncEvent = { + appName: string, + document?: DocumentSnapshot, + error?: Object, + listenerId: number, + path: string, +} + /** * @class Firestore */ @@ -28,6 +36,20 @@ export default class Firestore extends ModuleBase { constructor(firebaseApp: Object, options: Object = {}) { super(firebaseApp, options, true); this._referencePath = new Path([]); + + this.addListener( + // sub to internal native event - this fans out to + // public event name: onCollectionSnapshot + this._getAppEventName('firestore_collection_sync_event'), + this._onCollectionSyncEvent.bind(this), + ); + + this.addListener( + // sub to internal native event - this fans out to + // public event name: onDocumentSnapshot + this._getAppEventName('firestore_document_sync_event'), + this._onDocumentSyncEvent.bind(this), + ); } batch(): WriteBatch { @@ -107,6 +129,37 @@ export default class Firestore extends ModuleBase { return fieldPath; } + + /** + * INTERNALS + */ + + /** + * Internal collection sync listener + * @param event + * @private + */ + _onCollectionSyncEvent(event: DocumentSyncEvent) { + if (event.error) { + this.emit(this._getAppEventName(`onCollectionSnapshotError:${event.listenerId}`, event.error)); + } else { + this.emit(this._getAppEventName(`onCollectionSnapshot:${event.listenerId}`, event.document)); + } + } + + /** + * Internal document sync listener + * @param event + * @private + */ + _onDocumentSyncEvent(event: DocumentSyncEvent) { + if (event.error) { + this.emit(this._getAppEventName(`onDocumentSnapshotError:${event.listenerId}`), event.error); + } else { + const snapshot = new DocumentSnapshot(this, event.document); + this.emit(this._getAppEventName(`onDocumentSnapshot:${event.listenerId}`), snapshot); + } + } } export const statics = { diff --git a/lib/utils/ModuleBase.js b/lib/utils/ModuleBase.js index 127050f9..e8c63ef4 100644 --- a/lib/utils/ModuleBase.js +++ b/lib/utils/ModuleBase.js @@ -29,6 +29,10 @@ const NATIVE_MODULE_EVENTS = { 'database_transaction_event', // 'database_server_offset', // TODO ], + Firestore: [ + 'firestore_collection_sync_event', + 'firestore_document_sync_event', + ], }; const DEFAULTS = { diff --git a/tests/src/tests/firestore/documentReferenceTests.js b/tests/src/tests/firestore/documentReferenceTests.js index 5242583b..f8b9afd6 100644 --- a/tests/src/tests/firestore/documentReferenceTests.js +++ b/tests/src/tests/firestore/documentReferenceTests.js @@ -1,3 +1,5 @@ +import sinon from 'sinon'; +import 'should-sinon'; import should from 'should'; function collectionReferenceTests({ describe, it, context, firebase }) { @@ -26,6 +28,221 @@ function collectionReferenceTests({ describe, it, context, firebase }) { }); }); + context('onSnapshot()', () => { + it('calls callback with the initial data and then when value changes', () => { + return new Promise(async (resolve) => { + const docRef = firebase.native.firestore().doc('document-tests/doc1'); + const currentDataValue = { name: 'doc1' }; + const newDataValue = { name: 'updated' }; + + const callback = sinon.spy(); + + // Test + + let unsubscribe; + await new Promise((resolve2) => { + unsubscribe = docRef.onSnapshot((snapshot) => { + callback(snapshot.data()); + resolve2(); + }); + }); + + callback.should.be.calledWith(currentDataValue); + + await docRef.set(newDataValue); + + await new Promise((resolve2) => { + setTimeout(() => resolve2(), 5); + }); + + // Assertions + + callback.should.be.calledWith(newDataValue); + callback.should.be.calledTwice(); + + // Tear down + + unsubscribe(); + + resolve(); + }); + }); + }); + + context('onSnapshot()', () => { + it('doesn\'t call callback when the ref is updated with the same value', async () => { + return new Promise(async (resolve) => { + const docRef = firebase.native.firestore().doc('document-tests/doc1'); + const currentDataValue = { name: 'doc1' }; + + const callback = sinon.spy(); + + // Test + + let unsubscribe; + await new Promise((resolve2) => { + unsubscribe = docRef.onSnapshot((snapshot) => { + callback(snapshot.data()); + resolve2(); + }); + }); + + callback.should.be.calledWith(currentDataValue); + + await docRef.set(currentDataValue); + + await new Promise((resolve2) => { + setTimeout(() => resolve2(), 5); + }); + + // Assertions + + callback.should.be.calledOnce(); // Callback is not called again + + // Tear down + + unsubscribe(); + + resolve(); + }); + }); + }); + + context('onSnapshot()', () => { + it('allows binding multiple callbacks to the same ref', () => { + return new Promise(async (resolve) => { + // Setup + const docRef = firebase.native.firestore().doc('document-tests/doc1'); + const currentDataValue = { name: 'doc1' }; + const newDataValue = { name: 'updated' }; + + const callbackA = sinon.spy(); + const callbackB = sinon.spy(); + + // Test + let unsubscribeA; + let unsubscribeB; + await new Promise((resolve2) => { + unsubscribeA = docRef.onSnapshot((snapshot) => { + callbackA(snapshot.data()); + resolve2(); + }); + }); + + await new Promise((resolve2) => { + unsubscribeB = docRef.onSnapshot((snapshot) => { + callbackB(snapshot.data()); + resolve2(); + }); + }); + + callbackA.should.be.calledWith(currentDataValue); + callbackA.should.be.calledOnce(); + + callbackB.should.be.calledWith(currentDataValue); + callbackB.should.be.calledOnce(); + + await docRef.set(newDataValue); + + await new Promise((resolve2) => { + setTimeout(() => resolve2(), 5); + }); + + callbackA.should.be.calledWith(newDataValue); + callbackB.should.be.calledWith(newDataValue); + + callbackA.should.be.calledTwice(); + callbackB.should.be.calledTwice(); + + // Tear down + + unsubscribeA(); + unsubscribeB(); + + resolve(); + }); + }); + }); + + context('onSnapshot()', () => { + it('listener stops listening when unsubscribed', () => { + return new Promise(async (resolve) => { + // Setup + const docRef = firebase.native.firestore().doc('document-tests/doc1'); + const currentDataValue = { name: 'doc1' }; + const newDataValue = { name: 'updated' }; + + const callbackA = sinon.spy(); + const callbackB = sinon.spy(); + + // Test + let unsubscribeA; + let unsubscribeB; + await new Promise((resolve2) => { + unsubscribeA = docRef.onSnapshot((snapshot) => { + callbackA(snapshot.data()); + resolve2(); + }); + }); + + await new Promise((resolve2) => { + unsubscribeB = docRef.onSnapshot((snapshot) => { + callbackB(snapshot.data()); + resolve2(); + }); + }); + + callbackA.should.be.calledWith(currentDataValue); + callbackA.should.be.calledOnce(); + + callbackB.should.be.calledWith(currentDataValue); + callbackB.should.be.calledOnce(); + + await docRef.set(newDataValue); + + await new Promise((resolve2) => { + setTimeout(() => resolve2(), 5); + }); + + callbackA.should.be.calledWith(newDataValue); + callbackB.should.be.calledWith(newDataValue); + + callbackA.should.be.calledTwice(); + callbackB.should.be.calledTwice(); + + // Unsubscribe A + + unsubscribeA(); + + await docRef.set(currentDataValue); + + await new Promise((resolve2) => { + setTimeout(() => resolve2(), 5); + }); + + callbackB.should.be.calledWith(currentDataValue); + + callbackA.should.be.calledTwice(); + callbackB.should.be.calledThrice(); + + // Unsubscribe B + + unsubscribeB(); + + await docRef.set(newDataValue); + + await new Promise((resolve2) => { + setTimeout(() => resolve2(), 5); + }); + + callbackA.should.be.calledTwice(); + callbackB.should.be.calledThrice(); + + resolve(); + }); + }); + }); + context('set()', () => { it('should create Document', () => { return firebase.native.firestore()