[js][android] database sync tree implementation with adjusted tests

This commit is contained in:
Salakar 2017-08-15 21:29:50 +01:00
parent 67985f8e90
commit e4d27029b9
14 changed files with 647 additions and 233 deletions

View File

@ -77,20 +77,21 @@ public class Utils {
}
/**
* @param name
* @param path
* @param dataSnapshot
* @param refId
* @param registration
* @param previousChildName
* @return
*/
public static WritableMap snapshotToMap(String name, String path, DataSnapshot dataSnapshot, @Nullable String previousChildName, int refId) {
public static WritableMap snapshotToMap(DataSnapshot dataSnapshot, @Nullable String previousChildName) {
WritableMap result = Arguments.createMap();
WritableMap snapshot = Arguments.createMap();
WritableMap eventMap = Arguments.createMap();
snapshot.putString("key", dataSnapshot.getKey());
snapshot.putBoolean("exists", dataSnapshot.exists());
snapshot.putBoolean("hasChildren", dataSnapshot.hasChildren());
snapshot.putDouble("childrenCount", dataSnapshot.getChildrenCount());
snapshot.putArray("childKeys", Utils.getChildKeys(dataSnapshot));
mapPutValue("priority", dataSnapshot.getPriority(), snapshot);
if (!dataSnapshot.hasChildren()) {
mapPutValue("value", dataSnapshot.getValue(), snapshot);
@ -103,16 +104,50 @@ public class Utils {
}
}
snapshot.putArray("childKeys", Utils.getChildKeys(dataSnapshot));
mapPutValue("priority", dataSnapshot.getPriority(), snapshot);
eventMap.putInt("refId", refId);
eventMap.putString("path", path);
eventMap.putMap("snapshot", snapshot);
eventMap.putString("eventName", name);
eventMap.putString("previousChildName", previousChildName);
result.putMap("snapshot", snapshot);
result.putString("previousChildName", previousChildName);
return result;
}
return eventMap;
/**
*
* @param map
* @return
*/
public static WritableMap readableMapToWritableMap(ReadableMap map) {
WritableMap writableMap = Arguments.createMap();
ReadableMapKeySetIterator iterator = map.keySetIterator();
while (iterator.hasNextKey()) {
String key = iterator.nextKey();
ReadableType type = map.getType(key);
switch (type) {
case Null:
writableMap.putNull(key);
break;
case Boolean:
writableMap.putBoolean(key, map.getBoolean(key));
break;
case Number:
writableMap.putDouble(key, map.getDouble(key));
break;
case String:
writableMap.putString(key, map.getString(key));
break;
case Map:
writableMap.putMap(key, readableMapToWritableMap(map.getMap(key)));
break;
case Array:
// TODO writableMap.putArray(key, readableArrayToWritableArray(map.getArray(key)));
break;
default:
throw new IllegalArgumentException("Could not convert object with key: " + key + ".");
}
}
return writableMap;
}
/**

View File

@ -35,7 +35,7 @@ public class RNFirebaseDatabase extends ReactContextBaseJavaModule {
private static final String TAG = "RNFirebaseDatabase";
private HashMap<String, ChildEventListener> childEventListeners;
private HashMap<String, ValueEventListener> valueEventListeners;
private SparseArray<RNFirebaseDatabaseReference> references = new SparseArray<>();
private HashMap<String, RNFirebaseDatabaseReference> references = new HashMap<String, RNFirebaseDatabaseReference>();
private SparseArray<RNFirebaseTransactionHandler> transactionHandlers = new SparseArray<>();
RNFirebaseDatabase(ReactApplicationContext reactContext) {
@ -103,10 +103,8 @@ public class RNFirebaseDatabase extends ReactContextBaseJavaModule {
* @param state
*/
@ReactMethod
public void keepSynced(String appName, int id, String path, ReadableArray modifiers, Boolean state) {
getInternalReferenceForApp(appName, id, path, modifiers, false)
.getQuery()
.keepSynced(state);
public void keepSynced(String appName, String key, String path, ReadableArray modifiers, Boolean state) {
getInternalReferenceForApp(appName, key, path, modifiers).getQuery().keepSynced(state);
}
@ -410,34 +408,36 @@ public class RNFirebaseDatabase extends ReactContextBaseJavaModule {
* Subscribe once to a firebase reference.
*
* @param appName
* @param refId
* @param key
* @param path
* @param modifiers
* @param eventName
* @param promise
*/
@ReactMethod
public void once(String appName, int refId, String path, ReadableArray modifiers, String eventName, Promise promise) {
getInternalReferenceForApp(appName, refId, path, modifiers, false).once(eventName, promise);
public void once(String appName, String key, String path, ReadableArray modifiers, String eventName, Promise promise) {
getInternalReferenceForApp(appName, key, path, modifiers).once(eventName, promise);
}
/**
* Subscribe to real time events for the specified database path + modifiers
*
* @param appName String
* @param args ReadableMap
* @param props ReadableMap
*/
@ReactMethod
public void on(String appName, ReadableMap args) {
RNFirebaseDatabaseReference ref = getInternalReferenceForApp(
appName,
args.getInt("id"),
args.getString("path"),
args.getArray("modifiers"),
true
public void on(String appName, ReadableMap props) {
getInternalReferenceForApp(appName, props).on(
this,
props.getString("eventType"),
props.getMap("registration")
);
}
@ReactMethod
public void off(String appName, ReadableMap args) {
ref.on(this, args.getString("eventType"), args.getString("eventQueryKey"));
}
/*
@ -511,25 +511,49 @@ public class RNFirebaseDatabase extends ReactContextBaseJavaModule {
* Return an existing or create a new RNFirebaseDatabaseReference instance.
*
* @param appName
* @param refId
* @param key
* @param path
* @param modifiers
* @param keep
* @return
*/
private RNFirebaseDatabaseReference getInternalReferenceForApp(String appName, int refId, String path, ReadableArray modifiers, Boolean keep) {
RNFirebaseDatabaseReference existingRef = references.get(refId);
private RNFirebaseDatabaseReference getInternalReferenceForApp(String appName, String key, String path, ReadableArray modifiers) {
RNFirebaseDatabaseReference existingRef = references.get(key);
if (existingRef == null) {
existingRef = new RNFirebaseDatabaseReference(
getReactApplicationContext(),
appName,
refId,
key,
path,
modifiers
);
}
return existingRef;
}
/**
* @param appName
* @param props
* @return
*/
private RNFirebaseDatabaseReference getInternalReferenceForApp(String appName, ReadableMap props) {
String key = props.getString("key");
String path = props.getString("path");
ReadableArray modifiers = props.getArray("modifiers");
RNFirebaseDatabaseReference existingRef = references.get(key);
if (existingRef == null) {
existingRef = new RNFirebaseDatabaseReference(
getReactApplicationContext(),
appName,
key,
path,
modifiers
);
if (keep) references.put(refId, existingRef);
references.put(key, existingRef);
}
return existingRef;

View File

@ -1,16 +1,14 @@
package io.invertase.firebase.database;
import java.util.HashMap;
import java.util.Map;
import java.util.List;
import android.util.Log;
import android.support.annotation.Nullable;
import android.util.SparseArray;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.WritableArray;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.ReadableMap;
import com.facebook.react.bridge.WritableMap;
import com.facebook.react.bridge.ReactContext;
import com.facebook.react.bridge.ReadableArray;
@ -24,10 +22,10 @@ import com.google.firebase.database.ValueEventListener;
import io.invertase.firebase.Utils;
public class RNFirebaseDatabaseReference {
class RNFirebaseDatabaseReference {
private static final String TAG = "RNFirebaseDBReference";
private int refId;
private String key;
private Query query;
private String path;
private String appName;
@ -41,12 +39,12 @@ public class RNFirebaseDatabaseReference {
/**
* @param context
* @param app
* @param id
* @param refKey
* @param refPath
* @param modifiersArray
*/
RNFirebaseDatabaseReference(ReactContext context, String app, int id, String refPath, ReadableArray modifiersArray) {
refId = id;
RNFirebaseDatabaseReference(ReactContext context, String app, String refKey, String refPath, ReadableArray modifiersArray) {
key = refKey;
appName = app;
path = refPath;
reactContext = context;
@ -62,7 +60,7 @@ public class RNFirebaseDatabaseReference {
ValueEventListener onceValueEventListener = new ValueEventListener() {
@Override
public void onDataChange(DataSnapshot dataSnapshot) {
WritableMap data = Utils.snapshotToMap("value", path, dataSnapshot, null, refId);
WritableMap data = Utils.snapshotToMap(dataSnapshot, null);
promise.resolve(data);
}
@ -74,7 +72,7 @@ public class RNFirebaseDatabaseReference {
query.addListenerForSingleValueEvent(onceValueEventListener);
Log.d(TAG, "Added OnceValueEventListener for refId: " + refId);
Log.d(TAG, "Added OnceValueEventListener for key: " + key);
}
/**
@ -89,7 +87,7 @@ public class RNFirebaseDatabaseReference {
public void onChildAdded(DataSnapshot dataSnapshot, String previousChildName) {
if ("child_added".equals(eventName)) {
query.removeEventListener(this);
WritableMap data = Utils.snapshotToMap("child_added", path, dataSnapshot, previousChildName, refId);
WritableMap data = Utils.snapshotToMap(dataSnapshot, previousChildName);
promise.resolve(data);
}
}
@ -98,7 +96,7 @@ public class RNFirebaseDatabaseReference {
public void onChildChanged(DataSnapshot dataSnapshot, String previousChildName) {
if ("child_changed".equals(eventName)) {
query.removeEventListener(this);
WritableMap data = Utils.snapshotToMap("child_changed", path, dataSnapshot, previousChildName, refId);
WritableMap data = Utils.snapshotToMap(dataSnapshot, previousChildName);
promise.resolve(data);
}
}
@ -107,7 +105,7 @@ public class RNFirebaseDatabaseReference {
public void onChildRemoved(DataSnapshot dataSnapshot) {
if ("child_removed".equals(eventName)) {
query.removeEventListener(this);
WritableMap data = Utils.snapshotToMap("child_removed", path, dataSnapshot, null, refId);
WritableMap data = Utils.snapshotToMap(dataSnapshot, null);
promise.resolve(data);
}
}
@ -116,7 +114,7 @@ public class RNFirebaseDatabaseReference {
public void onChildMoved(DataSnapshot dataSnapshot, String previousChildName) {
if ("child_moved".equals(eventName)) {
query.removeEventListener(this);
WritableMap data = Utils.snapshotToMap("child_moved", path, dataSnapshot, previousChildName, refId);
WritableMap data = Utils.snapshotToMap(dataSnapshot, previousChildName);
promise.resolve(data);
}
}
@ -135,13 +133,14 @@ public class RNFirebaseDatabaseReference {
/**
* Handles a React Native JS 'on' request and initializes listeners.
*
* @param eventName
* @param database
* @param registration
*/
void on(RNFirebaseDatabase database, String eventName, String queryKey) {
if (eventName.equals("value")) {
addValueEventListener(queryKey, database);
void on(RNFirebaseDatabase database, String eventType, ReadableMap registration) {
if (eventType.equals("value")) {
addValueEventListener(registration, database);
} else {
addChildEventListener(queryKey, database, eventName);
addChildEventListener(registration, eventType, database);
}
}
@ -161,94 +160,100 @@ public class RNFirebaseDatabaseReference {
/**
* @param queryKey
* @param eventName
* @param registration
* @param eventType
* @param database
*/
private void addChildEventListener(final String queryKey, final RNFirebaseDatabase database, final String eventName) {
if (!database.hasChildEventListener(queryKey)) {
private void addChildEventListener(final ReadableMap registration, final String eventType, final RNFirebaseDatabase database) {
final String eventRegistrationKey = registration.getString("eventRegistrationKey");
final String registrationCancellationKey = registration.getString("registrationCancellationKey");
if (!database.hasChildEventListener(eventRegistrationKey)) {
ChildEventListener childEventListener = new ChildEventListener() {
@Override
public void onChildAdded(DataSnapshot dataSnapshot, String previousChildName) {
if ("child_added".equals(eventName)) {
handleDatabaseEvent("child_added", queryKey, dataSnapshot, previousChildName);
if ("child_added".equals(eventType)) {
handleDatabaseEvent("child_added", registration, dataSnapshot, previousChildName);
}
}
@Override
public void onChildChanged(DataSnapshot dataSnapshot, String previousChildName) {
if ("child_changed".equals(eventName)) {
handleDatabaseEvent("child_changed", queryKey, dataSnapshot, previousChildName);
if ("child_changed".equals(eventType)) {
handleDatabaseEvent("child_changed", registration, dataSnapshot, previousChildName);
}
}
@Override
public void onChildRemoved(DataSnapshot dataSnapshot) {
if ("child_removed".equals(eventName)) {
handleDatabaseEvent("child_removed", queryKey, dataSnapshot, null);
if ("child_removed".equals(eventType)) {
handleDatabaseEvent("child_removed", registration, dataSnapshot, null);
}
}
@Override
public void onChildMoved(DataSnapshot dataSnapshot, String previousChildName) {
if ("child_moved".equals(eventName)) {
handleDatabaseEvent("child_moved", queryKey, dataSnapshot, previousChildName);
if ("child_moved".equals(eventType)) {
handleDatabaseEvent("child_moved", registration, dataSnapshot, previousChildName);
}
}
@Override
public void onCancelled(DatabaseError error) {
query.removeEventListener(this);
database.removeChildEventListener(queryKey);
handleDatabaseError(queryKey, error);
database.removeChildEventListener(eventRegistrationKey);
handleDatabaseError(registration, error);
}
};
database.addChildEventListener(queryKey, childEventListener);
database.addChildEventListener(eventRegistrationKey, childEventListener);
query.addChildEventListener(childEventListener);
}
}
/**
* @param queryKey
* @param registration
*/
private void addValueEventListener(final String queryKey, final RNFirebaseDatabase database) {
if (!database.hasValueEventListener(queryKey)) {
private void addValueEventListener(final ReadableMap registration, final RNFirebaseDatabase database) {
final String eventRegistrationKey = registration.getString("eventRegistrationKey");
final String registrationCancellationKey = registration.getString("registrationCancellationKey");
if (!database.hasValueEventListener(eventRegistrationKey)) {
ValueEventListener valueEventListener = new ValueEventListener() {
@Override
public void onDataChange(DataSnapshot dataSnapshot) {
handleDatabaseEvent("value", queryKey, dataSnapshot, null);
handleDatabaseEvent("value", registration, dataSnapshot, null);
}
@Override
public void onCancelled(DatabaseError error) {
query.removeEventListener(this);
database.removeValueEventListener(queryKey);
handleDatabaseError(queryKey, error);
database.removeValueEventListener(eventRegistrationKey);
handleDatabaseError(registration, error);
}
};
database.addValueEventListener(queryKey, valueEventListener);
database.addValueEventListener(eventRegistrationKey, valueEventListener);
query.addValueEventListener(valueEventListener);
}
}
/**
* @param name
* @param eventType
* @param dataSnapshot
* @param previousChildName
*/
private void handleDatabaseEvent(final String name, String queryKey, final DataSnapshot dataSnapshot, @Nullable String previousChildName) {
WritableMap evt = Arguments.createMap();
WritableMap data = Utils.snapshotToMap(name, path, dataSnapshot, previousChildName, refId);
private void handleDatabaseEvent(String eventType, ReadableMap registration, DataSnapshot dataSnapshot, @Nullable String previousChildName) {
WritableMap event = Arguments.createMap();
WritableMap data = Utils.snapshotToMap(dataSnapshot, previousChildName);
evt.putMap("body", data);
evt.putInt("refId", refId);
evt.putString("eventName", name);
evt.putString("appName", appName);
evt.putString("queryKey", queryKey);
event.putMap("data", data);
event.putString("key", key);
event.putString("eventType", eventType);
event.putMap("registration", Utils.readableMapToWritableMap(registration));
Utils.sendEvent(reactContext, "database_on_event", evt);
Utils.sendEvent(reactContext, "database_sync_event", event);
}
/**
@ -256,13 +261,14 @@ public class RNFirebaseDatabaseReference {
*
* @param error
*/
private void handleDatabaseError(String queryKey, DatabaseError error) {
WritableMap errMap = RNFirebaseDatabase.getJSError(error);
errMap.putInt("refId", refId);
errMap.putString("path", path);
errMap.putString("appName", appName);
errMap.putString("queryKey", queryKey);
Utils.sendEvent(reactContext, "database_cancel_event", errMap);
private void handleDatabaseError(ReadableMap registration, DatabaseError error) {
WritableMap event = Arguments.createMap();
event.putString("key", key);
event.putMap("error", RNFirebaseDatabase.getJSError(error));
event.putMap("registration", Utils.readableMapToWritableMap(registration));
Utils.sendEvent(reactContext, "database_sync_event", event);
}

View File

@ -131,6 +131,7 @@ export default class FirebaseApp {
Object.assign(getInstance, statics, {
nativeModuleExists: !!NativeModules[`RNFirebase${capitalizeFirstLetter(name)}`],
});
return getInstance;
}
}

View File

@ -1,5 +1,5 @@
import EventEmitter from 'EventEmitter';
import { Platform } from 'react-native';
import { Platform, NativeModules } from 'react-native';
const DEFAULT_APP_NAME = Platform.OS === 'ios' ? '__FIRAPP_DEFAULT' : '[DEFAULT]';
@ -54,7 +54,7 @@ export default {
SharedEventEmitter: new EventEmitter(),
SyncTree: NativeModules.RNFirebaseDatabase ? new SyncTree(NativeModules.RNFirebaseDatabase) : null,
// internal utils
deleteApp(name: String) {

View File

@ -5,10 +5,8 @@
import { NativeModules } from 'react-native';
import Reference from './reference';
import Snapshot from './snapshot';
import TransactionHandler from './transaction';
import ModuleBase from './../../utils/ModuleBase';
import { nativeToJSError } from './../../utils';
/**
* @class Database
@ -16,8 +14,6 @@ import { nativeToJSError } from './../../utils';
export default class Database extends ModuleBase {
constructor(firebaseApp: Object, options: Object = {}) {
super(firebaseApp, options, 'Database', true);
this._references = {};
this._serverTimeOffset = 0;
this._transactionHandler = new TransactionHandler(this);
if (this._options.persistence) {
@ -25,68 +21,7 @@ export default class Database extends ModuleBase {
}
// todo serverTimeOffset event/listener - make ref natively and switch to events
// todo use nativeToJSError for on/off error events
this.addListener(
this._getAppEventName('database_cancel_event'),
this._handleCancelEvent.bind(this),
);
this.addListener(
this._getAppEventName('database_on_event'),
this._handleOnEvent.bind(this),
);
}
/**
* Routes native database 'on' events to their js equivalent counterpart.
* If there is no longer any listeners remaining for this event we internally
* call the native unsub method to prevent further events coming through.
*
* @param event
* @private
*/
_handleOnEvent(event) {
const { queryKey, body, refId } = event;
const { snapshot, previousChildName } = body;
const remainingListeners = this.listeners(queryKey);
if (!remainingListeners || !remainingListeners.length) {
this._database._native.off(
_refId,
queryKey,
);
delete this._references[refId];
} else {
const ref = this._references[refId];
if (!ref) {
this._database._native.off(
_refId,
queryKey,
);
} else {
this.emit(queryKey, new Snapshot(ref, snapshot), previousChildName);
}
}
}
/**
* Routes native database query listener cancellation events to their js counterparts.
*
* @param event
* @private
*/
_handleCancelEvent(event) {
const { queryKey, code, message, path, refId, appName } = event;
const remainingListeners = this.listeners(`${queryKey}:cancelled`);
if (remainingListeners && remainingListeners.length) {
const error = nativeToJSError(code, message, { path, queryKey, refId, appName });
this.emit(`${queryKey}:cancelled`, error);
}
this._serverTimeOffset = 0; // TODO ----^
}
/**

View File

@ -5,8 +5,8 @@
import Query from './query.js';
import Snapshot from './snapshot';
import Disconnect from './disconnect';
import INTERNALS from './../../internals';
import ReferenceBase from './../../utils/ReferenceBase';
import {
promiseOrCallback,
isFunction,
@ -17,8 +17,9 @@ import {
generatePushID,
} from './../../utils';
// Unique Reference ID for native events
let refId = 1;
import INTERNALS from './../../internals';
// track all event registrations by path
/**
* Enum for event types
@ -62,7 +63,6 @@ const ReferenceEventTypes = {
*/
export default class Reference extends ReferenceBase {
_refId: number;
_refListeners: { [listenerId: number]: DatabaseListener };
_database: Object;
_query: Query;
@ -70,13 +70,12 @@ export default class Reference extends ReferenceBase {
constructor(database: Object, path: string, existingModifiers?: Array<DatabaseModifier>) {
super(path, database);
this._promise = null;
this._refId = refId++;
this._listeners = 0;
this._refListeners = {};
this._database = database;
this._query = new Query(this, path, existingModifiers);
this.log = this._database.log;
this.log.debug('Created new Reference', this._refId, this.path);
this.log.debug('Created new Reference', this._getRefKey());
}
/**
@ -90,7 +89,7 @@ export default class Reference extends ReferenceBase {
* @returns {*}
*/
keepSynced(bool: boolean) {
return this._database._native.keepSynced(this._refId, this.path, this._query.getModifiers(), bool);
return this._database._native.keepSynced(this._getRefKey(), this.path, this._query.getModifiers(), bool);
}
/**
@ -225,7 +224,7 @@ export default class Reference extends ReferenceBase {
cancelOrContext: (error: FirebaseError) => void,
context?: Object,
) {
return this._database._native.once(this._refId, this.path, this._query.getModifiers(), eventName)
return this._database._native.once(this._getRefKey(), this.path, this._query.getModifiers(), eventName)
.then(({ snapshot }) => {
const _snapshot = new Snapshot(this, snapshot);
@ -513,11 +512,23 @@ export default class Reference extends ReferenceBase {
*/
/**
* Generate a unique key based on this refs path and query modifiers
* Generate a unique registration key.
*
* @return {string}
*/
makeQueryKey() {
return `$${this.path}$${this._query.queryIdentifier()}$${this._refId}$${this._listeners}`;
_getRegistrationKey(eventType) {
return `$${this._database._appName}$${this.path}$${this._query.queryIdentifier()}$${this._listeners}$${eventType}`;
}
/**
* Generate a string that uniquely identifies this
* combination of path and query modifiers
*
* @return {string}
* @private
*/
_getRefKey() {
return `$${this._database._appName}$${this.path}$${this._query.queryIdentifier()}`;
}
/**
@ -571,17 +582,32 @@ export default class Reference extends ReferenceBase {
};
}
/**
* Register a listener for data changes at the current ref's location.
* The primary method of reading data from a Database.
*
* @param eventType
* @param callback
* @param cancelCallbackOrContext
* @param context
* @return {*}
* Listeners can be unbound using {@link off}.
*
* Event Types:
*
* - value: {@link callback}.
* - child_added: {@link callback}
* - child_removed: {@link callback}
* - child_changed: {@link callback}
* - child_moved: {@link callback}
*
* @param {ReferenceEventType} eventType - Type of event to attach a callback for.
* @param {ReferenceEventCallback} callback - Function that will be called
* when the event occurs with the new data.
* @param {cancelCallbackOrContext=} cancelCallbackOrContext - Optional callback that is called
* if the event subscription fails. {@link cancelCallbackOrContext}
* @param {*=} context - Optional object to bind the callbacks to when calling them.
* @returns {ReferenceEventCallback} callback function, unmodified (unbound), for
* convenience if you want to pass an inline function to on() and store it later for
* removing using off().
*
* {@link https://firebase.google.com/docs/reference/js/firebase.database.Reference#on}
*/
// todo:context shouldn't be needed - confirm
// todo refId should no longer be required - update native to work without it then remove from js internals
on(eventType: string, callback: () => any, cancelCallbackOrContext?: () => any, context?: Object): Function {
if (!eventType) {
throw new Error('Query.on failed: Function called with 0 arguments. Expects at least 2.');
@ -607,38 +633,88 @@ export default class Reference extends ReferenceBase {
throw new Error('Query.on failed: Function called with 4 arguments, but third optional argument `cancelCallbackOrContext` was not a function.');
}
const eventQueryKey = `${this.makeQueryKey()}$${eventType}`;
const eventRegistrationKey = this._getRegistrationKey(eventType);
const registrationCancellationKey = `${eventRegistrationKey}$cancelled`;
const _context = (cancelCallbackOrContext && !isFunction(cancelCallbackOrContext)) ? cancelCallbackOrContext : context;
INTERNALS.SharedEventEmitter.addListener(eventQueryKey, _context ? callback.bind(_context) : callback);
this._syncTree.addRegistration(
{
eventType,
ref: this,
path: this.path,
key: this._getRefKey(),
appName: this._database._appName,
registration: eventRegistrationKey,
},
_context ? callback.bind(_context) : callback,
);
if (isFunction(cancelCallbackOrContext)) {
INTERNALS.SharedEventEmitter.once(`${eventQueryKey}:cancelled`, _context ? cancelCallbackOrContext.bind(_context) : cancelCallbackOrContext);
// cancellations have their own separate registration
// as these are one off events, and they're not guaranteed
// to occur either, only happens on failure to register on native
this._syncTree.addRegistration(
{
ref: this,
once: true,
path: this.path,
key: this._getRefKey(),
appName: this._database._appName,
eventType: `${eventType}$cancelled`,
registration: registrationCancellationKey,
},
_context ? cancelCallbackOrContext.bind(_context) : cancelCallbackOrContext,
);
}
// initialise the native listener if not already listening
this._database._native.on({
eventType,
eventQueryKey,
id: this._refId, // todo remove
path: this.path,
key: this._getRefKey(),
appName: this._database._appName,
modifiers: this._query.getModifiers(),
registration: {
eventRegistrationKey,
registrationCancellationKey,
hasCancellationCallback: isFunction(cancelCallbackOrContext),
},
});
if (!this._database._references[this._refId]) {
this._database._references[this._refId] = this;
}
// increment number of listeners - just s short way of making
// every registration unique per .on() call
this._listeners = this._listeners + 1;
// return original unbound successCallback for
// the purposes of calling .off(eventType, callback) at a later date
return callback;
}
/**
* Detaches a callback previously attached with on().
*
* Detach a callback previously attached with on(). Note that if on() was called
* multiple times with the same eventType and callback, the callback will be called
* multiple times for each event, and off() must be called multiple times to
* remove the callback. Calling off() on a parent listener will not automatically
* remove listeners registered on child nodes, off() must also be called on any
* child listeners to remove the callback.
*
* If a callback is not specified, all callbacks for the specified eventType will be removed.
* Similarly, if no eventType or callback is specified, all callbacks for the Reference will be removed.
* @param eventType
* @param originalCallback
*/
off(eventType?: string = '', originalCallback?: () => any) {
if (!arguments.length) {
// Firebase Docs:
// if no eventType or callback is specified, all callbacks for the Reference will be removed.
return this._syncTree.removeListenersForRegistrations(this._syncTree.getRegistrationsByPath(this.path));
}
/*
* VALIDATE ARGS
*/
if (eventType && (!isString(eventType) || !ReferenceEventTypes[eventType])) {
throw new Error(`Query.off failed: First argument must be a valid string event type: "${Object.keys(ReferenceEventTypes).join(', ')}"`);
}
@ -647,34 +723,36 @@ export default class Reference extends ReferenceBase {
throw new Error('Query.off failed: Function called with 2 arguments, but second optional argument was not a function.');
}
if (eventType) {
const eventQueryKey = `${this.makeQueryKey()}$${eventType}`;
// Firebase Docs:
// Note that if on() was called
// multiple times with the same eventType and callback, the callback will be called
// multiple times for each event, and off() must be called multiple times to
// remove the callback.
// Remove only a single registration
if (eventType && originalCallback) {
const registrations = this._syncTree.getRegistrationsByPathEvent(this.path, eventType);
if (originalCallback) {
INTERNALS.SharedEventEmitter.removeListener(eventQueryKey, originalCallback);
} else {
INTERNALS.SharedEventEmitter.removeAllListeners(eventQueryKey);
INTERNALS.SharedEventEmitter.removeAllListeners(`${this.makeQueryKey()}:cancelled`);
// remove the paired cancellation registration if any exist
this._syncTree.removeListenersForRegistrations([`${registrations[0]}$cancelled`]);
// remove only the first registration to match firebase web sdk
// call multiple times to remove multiple registrations
return this._syncTree.removeListenerRegistrations(originalCallback, [registrations[0]]);
}
// check if there's any listeners remaining in the js thread
// if there's isn't then call the native .off method which
// will unsubscribe from the native firebase listeners
const remainingListeners = INTERNALS.SharedEventEmitter.listeners(eventQueryKey);
// Firebase Docs:
// If a callback is not specified, all callbacks for the specified eventType will be removed.
const registrations = this._syncTree.getRegistrationsByPathEvent(this.path, eventType);
if (!remainingListeners || !remainingListeners.length) {
this._database._native.off(
this._refId, // todo remove
eventQueryKey,
this._syncTree.removeListenersForRegistrations(
this._syncTree.getRegistrationsByPathEvent(this.path, `${eventType}$cancelled`),
);
// remove straggling cancellation listeners
INTERNALS.SharedEventEmitter.removeAllListeners(`${this.makeQueryKey()}:cancelled`);
}
} else {
// todo remove all associated event subs if no event type && no orignalCb
}
return this._syncTree.removeListenersForRegistrations(registrations);
}
get _syncTree() {
return INTERNALS.SyncTree;
}
}

View File

@ -25,8 +25,8 @@ const NATIVE_MODULE_EVENTS = {
'onAuthStateChanged',
],
Database: [
'database_on_event',
'database_cancel_event',
// 'database_on_event',
// 'database_cancel_event',
'database_transaction_event',
// 'database_server_offset', // TODO
],
@ -47,11 +47,11 @@ export default class ModuleBase {
* @param withEventEmitter
*/
constructor(firebaseApp, options, moduleName, withEventEmitter = false) {
this._options = Object.assign({}, DEFAULTS[moduleName] || {}, options);
this._module = moduleName;
this._firebaseApp = firebaseApp;
this._appName = firebaseApp._name;
this._namespace = `${this._appName}:${this._module}`;
this._options = Object.assign({}, DEFAULTS[moduleName] || {}, options);
// check if native module exists as all native
// modules are now optionally part of build

277
lib/utils/SyncTree.js Normal file
View File

@ -0,0 +1,277 @@
import { NativeEventEmitter } from 'react-native';
import INTERNALS from './../internals';
import DatabaseSnapshot from './../modules/database/snapshot';
import DatabaseReference from './../modules/database/reference';
import { isString, nativeToJSError } from './../utils';
type Registration = {
key: String,
path: String,
once?: Boolean,
appName: String,
eventType: String,
registration: String,
ref: DatabaseReference,
}
/**
* Internally used to manage firebase database realtime event
* subscriptions and keep the listeners in sync in js vs native.
*/
export default class SyncTree {
constructor(databaseNative) {
this._tree = {};
this._reverseLookup = {};
this._databaseNative = databaseNative;
this._nativeEmitter = new NativeEventEmitter(databaseNative);
this._nativeEmitter.addListener(
'database_sync_event',
this._handleSyncEvent.bind(this),
);
}
/**
*
* @param event
* @private
*/
_handleSyncEvent(event) {
if (event.error) {
this._handleErrorEvent(event);
} else {
this._handleValueEvent(event);
}
}
/**
* Routes native database 'on' events to their js equivalent counterpart.
* If there is no longer any listeners remaining for this event we internally
* call the native unsub method to prevent further events coming through.
*
* @param event
* @private
*/
_handleValueEvent(event) {
const { eventRegistrationKey } = event.registration;
const registration = this.getRegistration(eventRegistrationKey);
if (!registration) {
// registration previously revoked
// notify native that the registration
// no longer exists so it can remove
// the native listeners
return this._databaseNative.off({
key: event.key,
eventRegistrationKey,
});
}
const { snapshot, previousChildName } = event.data;
// forward on to users .on(successCallback <-- listener
return INTERNALS.SharedEventEmitter.emit(
eventRegistrationKey,
new DatabaseSnapshot(registration.ref, snapshot),
previousChildName,
);
}
/**
* Routes native database query listener cancellation events to their js counterparts.
*
* @param event
* @private
*/
_handleErrorEvent(event) {
const { code, message } = event.error;
const { eventRegistrationKey, registrationCancellationKey } = event.registration;
const registration = this.getRegistration(registrationCancellationKey);
if (registration) {
// build a new js error - we additionally attach
// the ref as a property for easier debugging
const error = nativeToJSError(code, message, { ref: registration.ref });
// forward on to users .on(successCallback, cancellationCallback <-- listener
INTERNALS.SharedEventEmitter.emit(registrationCancellationKey, error);
// remove the paired event registration - if we received a cancellation
// event then it's guaranteed that they'll be no further value events
this.removeRegistration(eventRegistrationKey);
}
}
/**
* Returns registration information such as appName, ref, path and registration keys.
*
* @param registration
* @return {null}
*/
getRegistration(registration): Registration | null {
return this._reverseLookup[registration] ? Object.assign({}, this._reverseLookup[registration]) : null;
}
/**
* Removes all listeners for the specified registration keys.
*
* @param registrations
* @return {number}
*/
removeListenersForRegistrations(registrations) {
if (isString(registrations)) {
this.removeRegistration(registrations);
INTERNALS.SharedEventEmitter.removeAllListeners(registrations);
return 1;
}
if (!Array.isArray(registrations)) return 0;
for (let i = 0, len = registrations.length; i < len; i++) {
this.removeRegistration(registrations[i]);
INTERNALS.SharedEventEmitter.removeAllListeners(registrations[i]);
}
return registrations.length;
}
/**
* Removes a specific listener from the specified registrations.
*
* @param listener
* @param registrations
* @return {Array} array of registrations removed
*/
removeListenerRegistrations(listener, registrations) {
if (!Array.isArray(registrations)) return [];
const removed = [];
for (let i = 0, len = registrations.length; i < len; i++) {
const registration = registrations[i];
const subscriptions = INTERNALS.SharedEventEmitter._subscriber.getSubscriptionsForType(registration);
if (subscriptions) {
for (let j = 0, l = subscriptions.length; j < l; j++) {
const subscription = subscriptions[j];
// The subscription may have been removed during this event loop.
// its listener matches the listener in method parameters
if (subscription && subscription.listener === listener) {
subscription.remove();
removed.push(registration);
this.removeRegistration(registration);
}
}
}
}
return removed;
}
/**
* Returns an array of all registration keys for the specified path.
*
* @param path
* @return {Array}
*/
getRegistrationsByPath(path): Array {
const out = [];
const eventKeys = Object.keys(this._tree[path] || {});
for (let i = 0, len = eventKeys.length; i < len; i++) {
Array.prototype.push.apply(out, Object.keys(this._tree[path][eventKeys[i]]));
}
return out;
}
/**
* Returns an array of all registration keys for the specified path and eventType.
*
* @param path
* @param eventType
* @return {Array}
*/
getRegistrationsByPathEvent(path, eventType): Array {
if (!this._tree[path]) return [];
if (!this._tree[path][eventType]) return [];
return Object.keys(this._tree[path][eventType]);
}
/**
* Register a new listener.
*
* @param parameters
* @param listener
* @return {String}
*/
addRegistration(parameters: Registration, listener): String {
const { path, eventType, registration, once } = parameters;
if (!this._tree[path]) this._tree[path] = {};
if (!this._tree[path][eventType]) this._tree[path][eventType] = {};
this._tree[path][eventType][registration] = 0;
this._reverseLookup[registration] = Object.assign({}, parameters);
if (once) INTERNALS.SharedEventEmitter.once(registration, this._onOnceRemoveRegistration(registration, listener));
else INTERNALS.SharedEventEmitter.addListener(registration, listener);
return registration;
}
/**
* Remove a registration, if it's not a `once` registration then instructs native
* to also remove the underlying database query listener.
*
* @param registration
* @return {boolean}
*/
removeRegistration(registration: String): Boolean {
if (!this._reverseLookup[registration]) return false;
const { path, eventType, once } = this._reverseLookup[registration];
if (!this._tree[path]) {
delete this._reverseLookup[registration];
return false;
}
if (!this._tree[path][eventType]) {
delete this._reverseLookup[registration];
return false;
}
// we don't want `once` events to notify native as they're already
// automatically unsubscribed on native when the first event is sent
const registrationObj = this._reverseLookup[registration];
if (registrationObj && !once) {
this._databaseNative.off({
key: registrationObj.key,
eventType: registrationObj.eventType,
eventRegistrationKey: registration,
});
}
delete this._tree[path][eventType][registration];
delete this._reverseLookup[registration];
return !!registrationObj;
}
/**
* Wraps a `once` listener with a new function that self de-registers.
*
* @param registration
* @param listener
* @return {function(...[*])}
* @private
*/
_onOnceRemoveRegistration(registration, listener) {
return (...args) => {
this.removeRegistration(registration);
listener(...args);
};
}
}

View File

@ -231,6 +231,14 @@ export function map(
}, () => cb && cb(result));
}
/**
*
* @param string
* @return {string}
*/
export function capitalizeFirstLetter(string: String) {
return `${string.charAt(0).toUpperCase()}${string.slice(1)}`;
}
// timestamp of last push, used to prevent local collisions if you push twice in one ms.
let lastPushTime = 0;
@ -319,6 +327,11 @@ export function nativeWithApp(appName, NativeModule) {
return native;
}
/**
*
* @param object
* @return {string}
*/
export function objectToUniqueId(object: Object): String {
if (!isObject(object) || object === null) return JSON.stringify(object);

30
tests/src/playground.js Normal file
View File

@ -0,0 +1,30 @@
import React, { Component } from 'react';
import { View, Text, Button } from 'react-native';
export default class HomeScreen extends Component {
constructor(props) {
super(props);
this.state = {
bgColor: '#cb2600',
};
}
clickMe = () => {
if (this.state.bgColor === '#a8139f') {
this.setState({ bgColor: '#cb2600' });
} else {
this.setState({ bgColor: '#a8139f' });
}
};
render() {
return (
<View style={{ backgroundColor: this.state.bgColor }}>
<Text style={{ color: '#fff' }}>Hello</Text>
<Text style={{ color: '#22ff31' }}>World</Text>
<Button title="Change to pink" onPress={this.clickMe} />
</View>
);
}
}

View File

@ -26,8 +26,8 @@ const testGroups = [
pushTests, onTests, onValueTests, onChildAddedTests, onceTests, updateTests,
removeTests, setTests, transactionTests, queryTests, refTests, isEqualTests,
priorityTests,
onValueTests, onChildAddedTests, // offTests, // TODO remove for now, until i can fix, want to see the others working first
// onTests,
onValueTests, onChildAddedTests,
offTests,
];
function registerTestSuite(testSuite) {

View File

@ -89,7 +89,13 @@ function offTests({ describe, it, xcontext, context, firebase }) {
// Check childAddedCallback is really attached
await ref.push(DatabaseContents.DEFAULT.number);
valueCallback.should.be.callCount(2);
// stinky test fix - it's all async now so it's not returned within same event loop
await new Promise((resolve) => {
setTimeout(() => resolve(), 15);
});
valueCallback.should.be.calledTwice();
childAddedCallback.should.be.callCount(arrayLength + 1);
// Returns nothing
@ -117,10 +123,9 @@ function offTests({ describe, it, xcontext, context, firebase }) {
});
context('that is invalid', () => {
it('does nothing', () => {
it('throws an exception', () => {
const ref = firebase.native.database().ref('tests/types/array');
should(ref.off('invalid'), undefined);
(() => ref.off('invalid')).should.throw('Query.off failed: First argument must be a valid string event type: "value, child_added, child_removed, child_changed, child_moved"');
});
});
@ -225,7 +230,7 @@ function offTests({ describe, it, xcontext, context, firebase }) {
});
context('that has been added multiple times', () => {
it('must be called as many times completely remove', async () => {
it('must be called as many times to completely remove', async () => {
// Setup
const spyA = sinon.spy();
@ -249,7 +254,7 @@ function offTests({ describe, it, xcontext, context, firebase }) {
await new Promise((resolve) => {
setTimeout(() => {
resolve();
}, 1000);
}, 15);
});
spyA.should.be.calledTwice();
@ -261,9 +266,16 @@ function offTests({ describe, it, xcontext, context, firebase }) {
// Trigger the event the callback is listening to
await ref.set(DatabaseContents.DEFAULT.number);
// Add a delay to ensure that the .set() has had time to be registered
await new Promise((resolve) => {
setTimeout(() => {
resolve();
}, 15);
});
// Callback should have been called only once because one of the attachments
// has been removed
spyA.should.be.callCount(3);
spyA.should.be.calledThrice();
// Undo the second attachment
const resp2 = await ref.off('value', callbackA);
@ -272,8 +284,15 @@ function offTests({ describe, it, xcontext, context, firebase }) {
// Trigger the event the callback is listening to
await ref.set(DatabaseContents.DEFAULT.number);
// Add a delay to ensure that the .set() has had time to be registered
await new Promise((resolve) => {
setTimeout(() => {
resolve();
}, 15);
});
// Callback should not have been called any more times
spyA.should.be.callCount(3);
spyA.should.be.calledThrice();
});
});
});

View File

@ -60,23 +60,19 @@ function pushTests({ describe, it, firebase }) {
return ref.once('value')
.then((snapshot) => {
console.log('first once');
originalListValue = snapshot.val();
return ref.push(valueToAddToList);
})
.then((pushRef) => {
console.log('after push');
newItemRef = pushRef;
return newItemRef.once('value');
})
.then((snapshot) => {
console.log('second once');
newItemValue = snapshot.val();
newItemValue.should.eql(valueToAddToList);
return firebase.native.database().ref('tests/types/array').once('value');
})
.then((snapshot) => {
console.log('third once');
newListValue = snapshot.val();
const originalListAsObject = originalListValue.reduce((memo, value, index) => {
memo[index] = value;