Merge pull request #91 from greena13/dev_ref_on

[Database] Make Reference.on() more consistent with Web API
This commit is contained in:
Michael Diarmid 2017-05-08 09:01:30 +01:00 committed by GitHub
commit da2a7ec3a3
7 changed files with 649 additions and 158 deletions

View File

@ -76,6 +76,12 @@ export class ReferenceBase extends Base {
this.path = path || '/';
}
/**
* The last part of a Reference's path (after the last '/')
* The key of a root Reference is null.
* @type {String}
* {@link https://firebase.google.com/docs/reference/js/firebase.database.Reference#key}
*/
get key(): string|null {
return this.path === '/' ? null : this.path.substring(this.path.lastIndexOf('/') + 1);
}

View File

@ -14,8 +14,44 @@ const FirebaseDatabase = NativeModules.RNFirebaseDatabase;
let refId = 1;
/**
* Enum for event types
* @readonly
* @enum {String}
*/
const ReferenceEventTypes = {
value: 'value',
child_added: 'child_added',
child_removed: 'child_removed',
child_changed: 'child_changed',
child_moved: 'child_moved',
};
/**
* @typedef {String} ReferenceLocation - Path to location in the database, relative
* to the root reference. Consists of a path where segments are separated by a
* forward slash (/) and ends in a ReferenceKey - except the root location, which
* has no ReferenceKey.
*
* @example
* // root reference location: '/'
* // non-root reference: '/path/to/referenceKey'
*/
/**
* @typedef {String} ReferenceKey - Identifier for each location that is unique to that
* location, within the scope of its parent. The last part of a ReferenceLocation.
*/
/**
* Represents a specific location in your Database that can be used for
* reading or writing data.
*
* You can reference the root using firebase.database().ref() or a child location
* by calling firebase.database().ref("child/path").
*
* @link https://firebase.google.com/docs/reference/js/firebase.database.Reference
* @class Reference
* @extends ReferenceBase
*/
export default class Reference extends ReferenceBase {
@ -99,23 +135,152 @@ export default class Reference extends ReferenceBase {
}
/**
* Called once with the initial data at the specified location and then once each
* time the data changes.
*
* @param eventName
* @param successCallback
* @param failureCallback
* @param context TODO
* @returns {*}
* @callback onValueCallback
* @param {!DataSnapshot} dataSnapshot - Snapshot representing data at the location
* specified by the current ref. It won't trigger (.val() won't return a value)
* until the entire contents have been synchronized. If location has no data, .val()
* will return null.
*/
on(eventName: string, successCallback: () => any, failureCallback: () => any) {
if (!isFunction(successCallback)) throw new Error('The specified callback must be a function');
if (failureCallback && !isFunction(failureCallback)) throw new Error('The specified error callback must be a function');
this.log.debug('adding reference.on', this.refId, eventName);
/**
* Called once for each initial child at the specified location and then again
* every time a new child is added.
*
* @callback onChildAddedCallback
* @param {!DataSnapshot} dataSnapshot - Snapshot reflecting the data for the
* relevant child.
* @param {?ReferenceKey} previousChildKey - For ordering purposes, the key
* of the previous sibling child by sort order, or null if it is the first child.
*/
/**
* Called once every time a child is removed.
*
* A child will get removed when either:
* - remove() is explicitly called on a child or one of its ancestors
* - set(null) is called on that child or one of its ancestors
* - a child has all of its children removed
* - there is a query in effect which now filters out the child (because it's sort
* order changed or the max limit was hit)
*
* @callback onChildRemovedCallback
* @param {!DataSnapshot} dataSnapshot - Snapshot reflecting the old data for
* the child that was removed.
*/
/**
* Called when a child (or any of its descendants) changes.
*
* A single child_changed event may represent multiple changes to the child.
*
* @callback onChildChangedCallback
* @param {!DataSnapshot} dataSnapshot - Snapshot reflecting new child contents.
* @param {?ReferenceKey} previousChildKey - For ordering purposes, the key
* of the previous sibling child by sort order, or null if it is the first child.
*/
/**
* Called when a child's sort order changes, i.e. its position relative to its
* siblings changes.
*
* @callback onChildMovedCallback
* @param {!DataSnapshot} dataSnapshot - Snapshot reflecting the data of the moved
* child.
* @param {?ReferenceKey} previousChildKey - For ordering purposes, the key
* of the previous sibling child by sort order, or null if it is the first child.
*/
/**
* @typedef (onValueCallback|onChildAddedCallback|onChildRemovedCallback|onChildChangedCallback|onChildMovedCallback) ReferenceEventCallback
*/
/**
* Called if the event subscription is cancelled because the client does
* not have permission to read this data (or has lost the permission to do so).
*
* @callback onFailureCallback
* @param {Error} error - Object indicating why the failure occurred
*/
/**
* Binds callback handlers to when data changes at the current ref's location.
* The primary method of reading data from a Database.
*
* Callbacks can be unbound using {@link off}.
*
* Event Types:
*
* - value: {@link onValueCallback}.
* - child_added: {@link onChildAddedCallback}
* - child_removed: {@link onChildRemovedCallback}
* - child_changed: {@link onChildChangedCallback}
* - child_moved: {@link onChildMovedCallback}
*
* @param {ReferenceEventType} eventType - Type of event to attach a callback for.
* @param {ReferenceEventCallback} successCallback - Function that will be called
* when the event occurs with the new data.
* @param {onFailureCallback=} failureCallbackOrContext - Optional callback that is called
* if the event subscription fails. {@link onFailureCallback}
* @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}
*/
on(eventType: string, successCallback: () => any, failureCallbackOrContext: () => any, context: any) {
if (!eventType) throw new Error('Error: Query on failed: Was called with 0 arguments. Expects at least 2');
if (!ReferenceEventTypes[eventType]) throw new Error('Query.on failed: First argument must be a valid event type: "value", "child_added", "child_removed", "child_changed", or "child_moved".');
if (!successCallback) throw new Error('Query.on failed: Was called with 1 argument. Expects at least 2.');
if (!isFunction(successCallback)) throw new Error('Query.on failed: Second argument must be a valid function.');
if (arguments.length > 2 && !failureCallbackOrContext) throw new Error('Query.on failed: third argument must either be a cancel callback or a context object.');
this.log.debug('adding reference.on', this.refId, eventType);
let _failureCallback;
let _context;
if (context) {
_context = context;
_failureCallback = failureCallbackOrContext;
} else {
if (isFunction(failureCallbackOrContext)) {
_failureCallback = failureCallbackOrContext;
} else {
_context = failureCallbackOrContext;
}
}
if (_failureCallback) {
_failureCallback = (error) => {
if (error.message.startsWith('FirebaseError: permission_denied')) {
error.message = `permission_denied at /${this.path}: Client doesn\'t have permission to access the desired data.`
}
failureCallbackOrContext(error);
}
}
let _successCallback;
if (_context) {
_successCallback = successCallback.bind(_context);
} else {
_successCallback = successCallback;
}
const listener = {
listenerId: Object.keys(this.listeners).length + 1,
eventName,
successCallback,
failureCallback,
eventName: eventType,
successCallback: _successCallback,
failureCallback: _failureCallback,
};
this.listeners[listener.listenerId] = listener;
this.database.on(this, listener);
return successCallback;
@ -144,29 +309,49 @@ export default class Reference extends ReferenceBase {
}
/**
* Detaches a callback attached with on().
*
* @param eventName
* @param origCB
* @returns {*}
* Calling off() on a parent listener will not automatically remove listeners
* registered on child nodes.
*
* If on() was called multiple times with the same eventType off() must be
* called multiple times to completely remove it.
*
* If a callback is not specified, all callbacks for the specified eventType
* will be removed. If no eventType or callback is specified, all callbacks
* for the Reference will be removed.
*
* If a context is specified, it too is used as a filter parameter: a callback
* will only be detached if, when it was attached with on(), the same event type,
* callback function and context were provided.
*
* If no callbacks matching the parameters provided are found, no callbacks are
* detached.
*
* @param {('value'|'child_added'|'child_changed'|'child_removed'|'child_moved')=} eventType - Type of event to detach callback for.
* @param {Function=} originalCallback - Original callback passed to on()
* @param {*=} context - The context passed to on() when the callback was bound
*
* {@link https://firebase.google.com/docs/reference/js/firebase.database.Reference#off}
*/
off(eventName?: string = '', origCB?: () => any) {
this.log.debug('ref.off(): ', this.refId, eventName);
off(eventType?: string = '', originalCallback?: () => any) {
this.log.debug('ref.off(): ', this.refId, eventType);
// $FlowFixMe
const listeners: Array<DatabaseListener> = Object.values(this.listeners);
let listenersToRemove;
if (eventName && origCB) {
if (eventType && originalCallback) {
listenersToRemove = listeners.filter((listener) => {
return listener.eventName === eventName && listener.successCallback === origCB;
return listener.eventName === eventType && listener.successCallback === originalCallback;
});
// Only remove a single listener as per the web spec
if (listenersToRemove.length > 1) listenersToRemove = [listenersToRemove[0]];
} else if (eventName) {
} else if (eventType) {
listenersToRemove = listeners.filter((listener) => {
return listener.eventName === eventName;
return listener.eventName === eventType;
});
} else if (origCB) {
} else if (originalCallback) {
listenersToRemove = listeners.filter((listener) => {
return listener.successCallback === origCB;
return listener.successCallback === originalCallback;
});
} else {
listenersToRemove = listeners;
@ -349,9 +534,12 @@ export default class Reference extends ReferenceBase {
}
/**
* Get a specified child
* @param path
* @returns {Reference}
* Creates a Reference to a child of the current Reference, using a relative path.
* No validation is performed on the path to ensure it has a valid format.
* @param {String} path relative to current ref's location
* @returns {!Reference} A new Reference to the path provided, relative to the current
* Reference
* {@link https://firebase.google.com/docs/reference/js/firebase.database.Reference#child}
*/
child(path: string) {
return new Reference(this.database, `${this.path}/${path}`);
@ -370,6 +558,7 @@ export default class Reference extends ReferenceBase {
* same instance of firebase.app.App - multiple firebase apps not currently supported.
* @param {Reference} otherRef - Other reference to compare to this one
* @return {Boolean} Whether otherReference is equal to this one
*
* {@link https://firebase.google.com/docs/reference/js/firebase.database.Reference#isEqual}
*/
isEqual(otherRef: Reference): boolean {
@ -381,8 +570,10 @@ export default class Reference extends ReferenceBase {
*/
/**
* Returns the parent ref of the current ref i.e. a ref of /foo/bar would return a new ref to '/foo'
* @returns {*}
* The parent location of a Reference, or null for the root Reference.
* @type {Reference}
*
* {@link https://firebase.google.com/docs/reference/js/firebase.database.Reference#parent}
*/
get parent(): Reference|null {
if (this.path === '/') return null;
@ -392,6 +583,7 @@ export default class Reference extends ReferenceBase {
/**
* A reference to itself
* @type {!Reference}
*
* {@link https://firebase.google.com/docs/reference/js/firebase.database.Reference#ref}
*/
get ref(): Reference {
@ -399,8 +591,10 @@ export default class Reference extends ReferenceBase {
}
/**
* Returns a ref to the root of db - '/'
* @returns {Reference}
* Reference to the root of the database: '/'
* @type {!Reference}
*
* {@link https://firebase.google.com/docs/reference/js/firebase.database.Reference#root}
*/
get root(): Reference {
return new Reference(this.database, '/');

View File

@ -5,6 +5,7 @@ import Reference from './reference.js';
import { isObject, deepGet, deepExists } from './../../utils';
/**
* @class DataSnapshot
* @link https://firebase.google.com/docs/reference/js/firebase.database.DataSnapshot
*/
export default class Snapshot {

View File

@ -1,4 +1,5 @@
import onTests from './onTests';
import onTests from './on/onTests';
import onValueTests from './on/onValueTests';
import offTests from './offTests';
import onceTests from './onceTests';
import setTests from './setTests';
@ -19,8 +20,8 @@ import DatabaseContents from '../../support/DatabaseContents';
const testGroups = [
factoryTests, keyTests, parentTests, childTests, rootTests,
pushTests, onTests, offTests, onceTests, updateTests, removeTests, setTests,
transactionTests, queryTests, refTests, isEqualTests,
pushTests, onTests, onValueTests, offTests, onceTests, updateTests,
removeTests, setTests, transactionTests, queryTests, refTests, isEqualTests,
];
function registerTestSuite(testSuite) {

View File

@ -0,0 +1,52 @@
import 'should-sinon';
function onTests({ describe, it, firebase, context }) {
describe('ref().on()', () => {
// Observed Web API Behaviour
context('when no eventName is provided', () => {
it('then raises an error', () => {
const ref = firebase.native.database().ref('tests/types/number');
(() => { ref.on(); }).should.throw('Error: Query on failed: Was called with 0 arguments. Expects at least 2');
});
});
// Observed Web API Behaviour
context('when no callback function is provided', () => {
it('then raises an error', () => {
const ref = firebase.native.database().ref('tests/types/number');
(() => { ref.on('value'); }).should.throw('Query.on failed: Was called with 1 argument. Expects at least 2.');
});
});
// Observed Web API Behaviour
context('when an invalid eventName is provided', () => {
it('then raises an error', () => {
const ref = firebase.native.database().ref('tests/types/number');
(() => { ref.on('invalid', () => {}); }).should.throw('Query.on failed: First argument must be a valid event type: "value", "child_added", "child_removed", "child_changed", or "child_moved".');
});
});
// Observed Web API Behaviour
context('when an invalid success callback function is provided', () => {
it('then raises an error', () => {
const ref = firebase.native.database().ref('tests/types/number');
(() => { ref.on('value', 1); }).should.throw('Query.on failed: Second argument must be a valid function.');
});
});
// Observed Web API Behaviour
context('when an invalid failure callback function is provided', () => {
it('then raises an error', () => {
const ref = firebase.native.database().ref('tests/types/number');
(() => { ref.on('value', () => {}, null); }).should.throw('Query.on failed: third argument must either be a cancel callback or a context object.');
});
});
});
}
export default onTests;

View File

@ -0,0 +1,362 @@
import sinon from 'sinon';
import 'should-sinon';
import Promise from 'bluebird';
import DatabaseContents from '../../../support/DatabaseContents';
function onTests({ describe, context, it, xit, firebase, tryCatch }) {
describe('ref().on(\'value\')', () => {
// Documented Web API Behaviour
it('returns the success callback', () => {
// Setup
const successCallback = sinon.spy();
const ref = firebase.native.database().ref('tests/types/array');
// Assertions
ref.on('value', successCallback).should.eql(successCallback);
// Tear down
ref.off();
});
// Documented Web API Behaviour
it('calls callback with null if there is no data at ref', async () => {
// Setup
const ref = firebase.native.database().ref('tests/types/invalid');
const callback = sinon.spy();
// Test
await new Promise((resolve) => {
ref.on('value', (snapshot) => {
callback(snapshot.val());
resolve();
});
});
// Assertions
callback.should.be.calledWith(null);
await ref.set(1);
callback.should.be.calledWith(1);
// Teardown
ref.off();
await ref.set(null);
});
// Documented Web API Behaviour
it('calls callback with the initial data and then when value changes', () => {
return Promise.each(Object.keys(DatabaseContents.DEFAULT), async (dataRef) => {
// Setup
const ref = firebase.native.database().ref(`tests/types/${dataRef}`);
const currentDataValue = DatabaseContents.DEFAULT[dataRef];
const callback = sinon.spy();
// Test
await new Promise((resolve) => {
ref.on('value', (snapshot) => {
callback(snapshot.val());
resolve();
});
});
callback.should.be.calledWith(currentDataValue);
const newDataValue = DatabaseContents.NEW[dataRef];
await ref.set(newDataValue);
// Assertions
callback.should.be.calledWith(newDataValue);
callback.should.be.calledTwice();
// Tear down
ref.off();
await ref.set(currentDataValue);
});
});
it('calls callback when children of the ref change', async () => {
const ref = firebase.native.database().ref('tests/types/object');
const currentDataValue = DatabaseContents.DEFAULT.object;
const callback = sinon.spy();
// Test
await new Promise((resolve) => {
ref.on('value', (snapshot) => {
callback(snapshot.val());
resolve();
});
});
callback.should.be.calledWith(currentDataValue);
const newDataValue = DatabaseContents.NEW.string;
const childRef = firebase.native.database().ref('tests/types/object/foo2');
await childRef.set(newDataValue);
// Assertions
callback.should.be.calledWith({
...currentDataValue,
foo2: newDataValue,
});
callback.should.be.calledTwice();
// Tear down
ref.off();
await ref.set(currentDataValue);
});
/**
* Pending until behaviour of push is possibly fixed. Currently, causes
* an array to be converted to an object - don't think this is the intended
* behaviour
*/
xit('calls callback when child of the ref is added', async () => {
const ref = firebase.native.database().ref('tests/types/array');
const currentDataValue = DatabaseContents.DEFAULT.array;
const callback = sinon.spy();
// Test
await new Promise((resolve) => {
ref.on('value', (snapshot) => {
callback(snapshot.val());
resolve();
});
});
callback.should.be.calledWith(currentDataValue);
await ref.push(37);
// Assertions
callback.should.be.calledWith([
...currentDataValue,
37,
]);
callback.should.be.calledTwice();
// Tear down
ref.off();
await ref.set(currentDataValue);
});
it('doesn\'t call callback when the ref is updated with the same value', async () => {
const ref = firebase.native.database().ref('tests/types/object');
const currentDataValue = DatabaseContents.DEFAULT.object;
const callback = sinon.spy();
// Test
await new Promise((resolve) => {
ref.on('value', (snapshot) => {
callback(snapshot.val());
resolve();
});
});
callback.should.be.calledWith(currentDataValue);
await ref.set(currentDataValue);
// Assertions
callback.should.be.calledOnce(); // Callback is not called again
// Tear down
ref.off();
});
// Documented Web API Behaviour
it('allows binding multiple callbacks to the same ref', () => {
return Promise.each(Object.keys(DatabaseContents.DEFAULT), async (dataRef) => {
// Setup
const ref = firebase.native.database().ref(`tests/types/${dataRef}`);
const currentDataValue = DatabaseContents.DEFAULT[dataRef];
const callbackA = sinon.spy();
const callbackB = sinon.spy();
// Test
await new Promise((resolve) => {
ref.on('value', (snapshot) => {
callbackA(snapshot.val());
resolve();
});
});
await new Promise((resolve) => {
ref.on('value', (snapshot) => {
callbackB(snapshot.val());
resolve();
});
});
callbackA.should.be.calledWith(currentDataValue);
callbackA.should.be.calledOnce();
callbackB.should.be.calledWith(currentDataValue);
callbackB.should.be.calledOnce();
// Tear down
ref.off();
});
});
context('when no failure callback is provided', () => {
it('then does not call the callback for a ref to un-permitted location', () => {
const invalidRef = firebase.native.database().ref('nope');
const callback = sinon.spy();
invalidRef.on('value', callback);
/**
* As we are testing that a callback is "never" called, we just wait for
* a reasonable time before giving up.
*/
return new Promise((resolve) => {
setTimeout(() => {
callback.should.not.be.called();
invalidRef.off();
resolve();
}, 1000);
});
});
// Documented Web API Behaviour
it('then calls callback bound to the specified context with the initial data and then when value changes', () => {
return Promise.each(Object.keys(DatabaseContents.DEFAULT), async (dataRef) => {
// Setup
const ref = firebase.native.database().ref(`tests/types/${dataRef}`);
const currentDataValue = DatabaseContents.DEFAULT[dataRef];
const context = {
callCount: 0,
};
// Test
await new Promise((resolve) => {
ref.on('value', function(snapshot) {
this.value = snapshot.val();
this.callCount += 1;
resolve();
}, context);
});
context.value.should.eql(currentDataValue);
context.callCount.should.eql(1);
const newDataValue = DatabaseContents.NEW[dataRef];
await ref.set(newDataValue);
// Assertions
context.value.should.eql(newDataValue);
context.callCount.should.eql(2);
// Tear down
ref.off();
await ref.set(currentDataValue);
});
});
});
// Observed Web API Behaviour
context('when a failure callback is provided', () => {
it('then calls only the failure callback for a ref to un-permitted location', () => {
const invalidRef = firebase.native.database().ref('nope');
const callback = sinon.spy();
return new Promise((resolve, reject) => {
invalidRef.on('value', callback, tryCatch((error) => {
error.message.should.eql(
'permission_denied at /nope: Client doesn\'t have permission to access the desired data.'
);
error.name.should.eql('Error');
callback.should.not.be.called();
invalidRef.off();
resolve();
}, reject));
});
});
// Documented Web API Behaviour
it('then calls callback bound to the specified context with the initial data and then when value changes', () => {
return Promise.each(Object.keys(DatabaseContents.DEFAULT), async (dataRef) => {
// Setup
const ref = firebase.native.database().ref(`tests/types/${dataRef}`);
const currentDataValue = DatabaseContents.DEFAULT[dataRef];
const context = {
callCount: 0,
};
const failureCallback = sinon.spy();
// Test
await new Promise((resolve) => {
ref.on('value', function(snapshot) {
this.value = snapshot.val();
this.callCount += 1;
resolve();
}, failureCallback, context);
});
failureCallback.should.not.be.called();
context.value.should.eql(currentDataValue);
context.callCount.should.eql(1);
const newDataValue = DatabaseContents.NEW[dataRef];
await ref.set(newDataValue);
// Assertions
context.value.should.eql(newDataValue);
context.callCount.should.eql(2);
// Tear down
ref.off();
await ref.set(currentDataValue);
});
})
});
});
}
export default onTests;

View File

@ -1,125 +0,0 @@
import sinon from 'sinon';
import 'should-sinon';
import Promise from 'bluebird';
import DatabaseContents from '../../support/DatabaseContents';
function onTests({ describe, it, firebase, tryCatch }) {
describe('ref().on()', () => {
it('calls callback when value changes', () => {
return Promise.each(Object.keys(DatabaseContents.DEFAULT), async (dataRef) => {
// Setup
const ref = firebase.native.database().ref(`tests/types/${dataRef}`);
const currentDataValue = DatabaseContents.DEFAULT[dataRef];
const callback = sinon.spy();
// Test
await new Promise((resolve) => {
ref.on('value', (snapshot) => {
callback(snapshot.val());
resolve();
});
});
callback.should.be.calledWith(currentDataValue);
const newDataValue = DatabaseContents.NEW[dataRef];
await ref.set(newDataValue);
// Assertions
callback.should.be.calledWith(newDataValue);
// Tear down
ref.off();
});
});
it('allows binding multiple callbacks to the same ref', () => {
return Promise.each(Object.keys(DatabaseContents.DEFAULT), async (dataRef) => {
// Setup
const ref = firebase.native.database().ref(`tests/types/${dataRef}`);
const currentDataValue = DatabaseContents.DEFAULT[dataRef];
const callbackA = sinon.spy();
const callbackB = sinon.spy();
// Test
await new Promise((resolve) => {
ref.on('value', (snapshot) => {
callbackA(snapshot.val());
resolve();
});
});
await new Promise((resolve) => {
ref.on('value', (snapshot) => {
callbackB(snapshot.val());
resolve();
});
});
callbackA.should.be.calledWith(currentDataValue);
callbackB.should.be.calledWith(currentDataValue);
// Tear down
ref.off();
});
});
it('calls callback with current values', () => {
return Promise.each(Object.keys(DatabaseContents.DEFAULT), (dataRef) => {
// Setup
const dataTypeValue = DatabaseContents.DEFAULT[dataRef];
const ref = firebase.native.database().ref(`tests/types/${dataRef}`);
// Test
return ref.on('value', (snapshot) => {
// Assertion
snapshot.val().should.eql(dataTypeValue);
// Tear down
ref.off();
});
});
});
it('errors if permission denied', () => {
return new Promise((resolve, reject) => {
const successCb = tryCatch(() => {
// Assertion
reject(new Error('No permission denied error'));
}, reject);
const failureCb = tryCatch((error) => {
// Assertion
error.message.includes('permission_denied').should.be.true();
resolve();
}, reject);
// Setup
const invalidRef = firebase.native.database().ref('nope');
// Test
invalidRef.on('value', successCb, failureCb);
});
});
});
}
export default onTests;