From 6a9b54124a51072f961a2ee3dfe54d1131f2907b Mon Sep 17 00:00:00 2001 From: Salakar Date: Thu, 2 Mar 2017 11:40:08 +0000 Subject: [PATCH] RNFirebase - database - js --- lib/modules/database/index.js | 229 +++++++++++++++++++++ lib/modules/database/query.js | 55 +++++ lib/modules/database/reference.js | 331 ++++++++++++++++++++++++++++++ lib/modules/database/snapshot.js | 86 ++++++++ 4 files changed, 701 insertions(+) create mode 100644 lib/modules/database/index.js create mode 100644 lib/modules/database/query.js create mode 100644 lib/modules/database/reference.js create mode 100644 lib/modules/database/snapshot.js diff --git a/lib/modules/database/index.js b/lib/modules/database/index.js new file mode 100644 index 00000000..1b3e77c9 --- /dev/null +++ b/lib/modules/database/index.js @@ -0,0 +1,229 @@ +/** + * @flow + * Database representation wrapper + */ +import { NativeModules, NativeEventEmitter } from 'react-native'; + +import { Base } from './../base'; +import Snapshot from './snapshot.js'; +import Reference from './reference.js'; +import { promisify } from './../../utils'; + +const FirebaseDatabase = NativeModules.FirebaseDatabase; +const FirebaseDatabaseEvt = new NativeEventEmitter(FirebaseDatabase); + +/** + * @class Database + */ +export default class Database extends Base { + constructor(firebase: Object, options: Object = {}) { + super(firebase, options); + this.subscriptions = {}; + this.errorSubscriptions = {}; + this.serverTimeOffset = 0; + this.persistenceEnabled = false; + this.namespace = 'firebase:database'; + + if (firebase.options.persistence === true) { + this._setPersistence(true); + } + + this.successListener = FirebaseDatabaseEvt.addListener( + 'database_event', + event => this._handleDatabaseEvent(event) + ); + + this.errorListener = FirebaseDatabaseEvt.addListener( + 'database_error', + err => this._handleDatabaseError(err) + ); + + this.offsetRef = this.ref('.info/serverTimeOffset'); + + this.offsetRef.on('value', (snapshot) => { + this.serverTimeOffset = snapshot.val() || this.serverTimeOffset; + }); + + this.log.debug('Created new Database instance', this.options); + } + + /** + * https://firebase.google.com/docs/reference/js/firebase.database.ServerValue + * @returns {{TIMESTAMP: (*|{[.sv]: string})}} + * @constructor + */ + get ServerValue(): Object { + return { + TIMESTAMP: FirebaseDatabase.serverValueTimestamp || { '.sv': 'timestamp' }, + }; + } + + /** + * Returns a new firebase reference instance + * @param path + * @returns {Reference} + */ + ref(path: string) { + return new Reference(this, path); + } + + /** + * + * @param path + * @param modifiersString + * @param modifiers + * @param eventName + * @param cb + * @param errorCb + * @returns {*} + */ + on(path: string, modifiersString: string, modifiers: Array, eventName: string, cb: () => void, errorCb: () => void) { + const handle = this._handle(path, modifiersString); + this.log.debug('adding on listener', handle); + + if (!this.subscriptions[handle]) this.subscriptions[handle] = {}; + if (!this.subscriptions[handle][eventName]) this.subscriptions[handle][eventName] = []; + this.subscriptions[handle][eventName].push(cb); + if (errorCb) { + if (!this.errorSubscriptions[handle]) this.errorSubscriptions[handle] = []; + this.errorSubscriptions[handle].push(errorCb); + } + + return promisify('on', FirebaseDatabase)(path, modifiersString, modifiers, eventName); + } + + /** + * + * @param path + * @param modifiersString + * @param eventName + * @param origCB + * @returns {*} + */ + off(path: string, modifiersString: string, eventName?: string, origCB?: () => void) { + const handle = this._handle(path, modifiersString); + this.log.debug('off() : ', handle, eventName); + + if (!this.subscriptions[handle] || (eventName && !this.subscriptions[handle][eventName])) { + this.log.warn('off() called, but not currently listening at that location (bad path)', handle, eventName); + return Promise.resolve(); + } + + if (eventName && origCB) { + const i = this.subscriptions[handle][eventName].indexOf(origCB); + + if (i === -1) { + this.log.warn('off() called, but the callback specified is not listening at that location (bad path)', handle, eventName); + return Promise.resolve(); + } + + this.subscriptions[handle][eventName].splice(i, 1); + if (this.subscriptions[handle][eventName].length > 0) return Promise.resolve(); + } else if (eventName) { + this.subscriptions[handle][eventName] = []; + } else { + this.subscriptions[handle] = {}; + } + this.errorSubscriptions[handle] = []; + return promisify('off', FirebaseDatabase)(path, modifiersString, eventName); + } + + /** + * Removes all event handlers and their native subscriptions + * @returns {Promise.<*>} + */ + cleanup() { + const promises = []; + Object.keys(this.subscriptions).forEach((handle) => { + Object.keys(this.subscriptions[handle]).forEach((eventName) => { + const separator = handle.indexOf('|'); + const path = handle.substring(0, separator); + const modifiersString = handle.substring(separator + 1); + promises.push(this.off(path, modifiersString, eventName)); + }); + }); + + return Promise.all(promises); + } + + goOnline() { + FirebaseDatabase.goOnline(); + } + + goOffline() { + FirebaseDatabase.goOffline(); + } + + /** + * INTERNALS + */ + _getServerTime() { + return new Date().getTime() + this.serverTimeOffset; + } + + /** + * Enabled / disable database persistence + * @param enable + * @returns {*} + * @private + */ + _setPersistence(enable: boolean = true) { + if (this.persistenceEnabled !== enable) { + this.log.debug(`${enable ? 'Enabling' : 'Disabling'} persistence`); + this.persistenceEnabled = enable; + return this.whenReady(promisify('enablePersistence', FirebaseDatabase)(enable)); + } + + return this.whenReady(Promise.resolve({ status: 'Already enabled' })); + } + + /** + * + * @param path + * @param modifiersString + * @returns {string} + * @private + */ + _handle(path: string = '', modifiersString: string = '') { + return `${path}|${modifiersString}`; + } + + + /** + * + * @param event + * @private + */ + _handleDatabaseEvent(event: Object) { + const body = event.body || {}; + const { path, modifiersString, eventName, snapshot } = body; + const handle = this._handle(path, modifiersString); + + this.log.debug('_handleDatabaseEvent: ', handle, eventName, snapshot && snapshot.key); + + if (this.subscriptions[handle] && this.subscriptions[handle][eventName]) { + this.subscriptions[handle][eventName].forEach((cb) => { + cb(new Snapshot(new Reference(this, path, modifiersString.split('|')), snapshot), body); + }); + } else { + FirebaseDatabase.off(path, modifiersString, eventName, () => { + this.log.debug('_handleDatabaseEvent: No JS listener registered, removed native listener', handle, eventName); + }); + } + } + + /** + * + * @param err + * @private + */ + _handleDatabaseError(err: Object) { + const body = err.body || {}; + const { path, modifiersString, eventName, msg } = body; + const handle = this._handle(path, modifiersString); + + this.log.debug('_handleDatabaseError ->', handle, eventName, err); + + if (this.errorSubscriptions[handle]) this.errorSubscriptions[handle].forEach((cb) => cb(new Error(msg))); + } +} diff --git a/lib/modules/database/query.js b/lib/modules/database/query.js new file mode 100644 index 00000000..f45087c2 --- /dev/null +++ b/lib/modules/database/query.js @@ -0,0 +1,55 @@ +/** + * @flow + */ + +import { ReferenceBase } from './../base'; +import Reference from './reference.js'; + +/** + * @class Query + */ +export default class Query extends ReferenceBase { + static ref: Reference; + + static modifiers: Array; + + ref: Reference; + + constructor(ref: Reference, path: string, existingModifiers?: Array) { + super(ref.db, path); + this.log.debug('creating Query ', path, existingModifiers); + this.ref = ref; + this.modifiers = existingModifiers ? [...existingModifiers] : []; + } + + setOrderBy(name: string, key?: string) { + if (key) { + this.modifiers.push(`${name}:${key}`); + } else { + this.modifiers.push(name); + } + } + + setLimit(name: string, limit: number) { + this.modifiers.push(`${name}:${limit}`); + } + + setFilter(name: string, value: any, key?:string) { + if (key) { + this.modifiers.push(`${name}:${value}:${typeof value}:${key}`); + } else { + this.modifiers.push(`${name}:${value}:${typeof value}`); + } + } + + getModifiers(): Array { + return [...this.modifiers]; + } + + getModifiersString(): string { + if (!this.modifiers || !Array.isArray(this.modifiers)) { + return ''; + } + return this.modifiers.join('|'); + } +} diff --git a/lib/modules/database/reference.js b/lib/modules/database/reference.js new file mode 100644 index 00000000..806dc82b --- /dev/null +++ b/lib/modules/database/reference.js @@ -0,0 +1,331 @@ +/** + * @flow + */ +import { NativeModules } from 'react-native'; + +import Query from './query.js'; +import Snapshot from './snapshot'; +import Disconnect from './disconnect'; +import { ReferenceBase } from './../base'; +import { promisify, isFunction, isObject, tryJSONParse, tryJSONStringify, generatePushID } from './../../utils'; + +const FirebaseDatabase = NativeModules.FirebaseDatabase; + +// https://firebase.google.com/docs/reference/js/firebase.database.Reference + +/** + * @class Reference + */ +export default class Reference extends ReferenceBase { + + db: FirebaseDatabase; + query: Query; + + constructor(db: FirebaseDatabase, path: string, existingModifiers?: Array) { + super(db.firebase, path); + this.db = db; + this.namespace = 'firebase:db:ref'; + this.query = new Query(this, path, existingModifiers); + this.log.debug('Created new Reference', this.db._handle(path, existingModifiers)); + } + + /** + * + * @param bool + * @returns {*} + */ + keepSynced(bool: boolean) { + const path = this._dbPath(); + return promisify('keepSynced', FirebaseDatabase)(path, bool); + } + + /** + * + * @param value + * @returns {*} + */ + set(value: any) { + const path = this._dbPath(); + const _value = this._serializeAnyType(value); + return promisify('set', FirebaseDatabase)(path, _value); + } + + /** + * + * @param val + * @returns {*} + */ + update(val: Object) { + const path = this._dbPath(); + const value = this._serializeObject(val); + return promisify('update', FirebaseDatabase)(path, value); + } + + /** + * + * @returns {*} + */ + remove() { + return promisify('remove', FirebaseDatabase)(this._dbPath()); + } + + /** + * + * @param value + * @param onComplete + * @returns {*} + */ + push(value: any, onComplete: Function) { + if (value === null || value === undefined) { + const _path = this.path + '/' + generatePushID(this.db.serverTimeOffset); + return new Reference(this.db, _path); + } + + const path = this._dbPath(); + const _value = this._serializeAnyType(value); + return promisify('push', FirebaseDatabase)(path, _value) + .then(({ ref }) => { + const newRef = new Reference(this.db, ref); + if (isFunction(onComplete)) return onComplete(null, newRef); + return newRef; + }).catch((e) => { + if (isFunction(onComplete)) return onComplete(e, null); + return e; + }); + } + + on(eventName: string, cb: () => any, errorCb: () => any) { + if (!isFunction(cb)) throw new Error('The specified callback must be a function'); + if (errorCb && !isFunction(errorCb)) throw new Error('The specified error callback must be a function'); + const path = this._dbPath(); + const modifiers = this.query.getModifiers(); + const modifiersString = this.query.getModifiersString(); + this.log.debug('adding reference.on', path, modifiersString, eventName); + return this.db.on(path, modifiersString, modifiers, eventName, cb, errorCb); + } + + once(eventName: string = 'once', cb: (snapshot: Object) => void) { + const path = this._dbPath(); + const modifiers = this.query.getModifiers(); + const modifiersString = this.query.getModifiersString(); + return promisify('onOnce', FirebaseDatabase)(path, modifiersString, modifiers, eventName) + .then(({ snapshot }) => new Snapshot(this, snapshot)) + .then((snapshot) => { + if (isFunction(cb)) cb(snapshot); + return snapshot; + }); + } + + off(eventName?: string = '', origCB?: () => any) { + const path = this._dbPath(); + const modifiersString = this.query.getModifiersString(); + this.log.debug('ref.off(): ', path, modifiersString, eventName); + return this.db.off(path, modifiersString, eventName, origCB); + } + + /** + * MODIFIERS + */ + + /** + * + * @returns {Reference} + */ + orderByKey(): Reference { + return this.orderBy('orderByKey'); + } + + /** + * + * @returns {Reference} + */ + orderByPriority(): Reference { + return this.orderBy('orderByPriority'); + } + + /** + * + * @returns {Reference} + */ + orderByValue(): Reference { + return this.orderBy('orderByValue'); + } + + /** + * + * @param key + * @returns {Reference} + */ + orderByChild(key: string): Reference { + return this.orderBy('orderByChild', key); + } + + /** + * + * @param name + * @param key + * @returns {Reference} + */ + orderBy(name: string, key?: string): Reference { + const newRef = new Reference(this.db, this.path, this.query.getModifiers()); + newRef.query.setOrderBy(name, key); + return newRef; + } + + /** + * LIMITS + */ + + /** + * + * @param limit + * @returns {Reference} + */ + limitToLast(limit: number): Reference { + return this.limit('limitToLast', limit); + } + + /** + * + * @param limit + * @returns {Reference} + */ + limitToFirst(limit: number): Reference { + return this.limit('limitToFirst', limit); + } + + /** + * + * @param name + * @param limit + * @returns {Reference} + */ + limit(name: string, limit: number): Reference { + const newRef = new Reference(this.db, this.path, this.query.getModifiers()); + newRef.query.setLimit(name, limit); + return newRef; + } + + /** + * FILTERS + */ + + /** + * + * @param value + * @param key + * @returns {Reference} + */ + equalTo(value: any, key?: string): Reference { + return this.filter('equalTo', value, key); + } + + /** + * + * @param value + * @param key + * @returns {Reference} + */ + endAt(value: any, key?: string): Reference { + return this.filter('endAt', value, key); + } + + /** + * + * @param value + * @param key + * @returns {Reference} + */ + startAt(value: any, key?: string): Reference { + return this.filter('startAt', value, key); + } + + /** + * + * @param name + * @param value + * @param key + * @returns {Reference} + */ + filter(name: string, value: any, key?: string): Reference { + const newRef = new Reference(this.db, this.path, this.query.getModifiers()); + newRef.query.setFilter(name, value, key); + return newRef; + } + + onDisconnect() { + return new Disconnect(this.path); + } + + child(path: string) { + return new Reference(this.db, this.path + '/' + path); + } + + toString(): string { + return this._dbPath(); + } + + /** + * GETTERS + */ + + /** + * Returns the parent ref of the current ref i.e. a ref of /foo/bar would return a new ref to '/foo' + * @returns {*} + */ + get parent(): Reference|null { + if (this.path === '/') return null; + return new Reference(this.db, this.path.substring(0, this.path.lastIndexOf('/'))); + } + + + /** + * Returns a ref to the root of db - '/' + * @returns {Reference} + */ + get root(): Reference { + return new Reference(this.db, '/'); + } + + /** + * INTERNALS + */ + + _dbPath(): string { + return this.path; + } + + /** + * + * @param obj + * @returns {Object} + * @private + */ + _serializeObject(obj: Object) { + if (!isObject(obj)) return obj; + + // json stringify then parse it calls toString on Objects / Classes + // that support it i.e new Date() becomes a ISO string. + return tryJSONParse(tryJSONStringify(obj)); + } + + /** + * + * @param value + * @returns {*} + * @private + */ + _serializeAnyType(value: any) { + if (isObject(value)) { + return { + type: 'object', + value: this._serializeObject(value), + }; + } + + return { + type: typeof value, + value, + }; + } +} diff --git a/lib/modules/database/snapshot.js b/lib/modules/database/snapshot.js new file mode 100644 index 00000000..55dfbf09 --- /dev/null +++ b/lib/modules/database/snapshot.js @@ -0,0 +1,86 @@ +/** + * @flow + */ +import Reference from './reference.js'; +import { isObject, deepGet, deepExists } from './../../utils'; + +export default class Snapshot { + static key: String; + static value: Object; + static exists: boolean; + static hasChildren: boolean; + static childKeys: String[]; + + ref: Object; + key: string; + value: any; + exists: boolean; + priority: any; + childKeys: Array; + + constructor(ref: Reference, snapshot: Object) { + this.ref = ref; + this.key = snapshot.key; + this.value = snapshot.value; + this.exists = snapshot.exists || true; + this.priority = snapshot.priority === undefined ? null : snapshot.priority; + this.childKeys = snapshot.childKeys || []; + } + + /* + * DEFAULT API METHODS + */ + + val() { + return this.value; + } + + child(path: string) { + const value = deepGet(this.value, path); + const childRef = this.ref.child(path); + return new Snapshot(childRef, { + value, + key: childRef.key, + exists: value !== null, + childKeys: isObject(value) ? Object.keys(value) : [], + }); + } + + exists() { + return this.value !== null; + } + + forEach(fn: (key: any) => any) { + return this.childKeys.forEach((key, i) => fn(this.child(key), i)); + } + + getPriority() { + return this.priority; + } + + hasChild(path: string) { + return deepExists(this.value, path); + } + + hasChildren() { + return this.numChildren() > 0; + } + + numChildren() { + if (!isObject(this.value)) return 0; + return Object.keys(this.value).length; + } + + /* + * EXTRA API METHODS + */ + map(fn: (key: string) => mixed) { + const arr = []; + this.forEach((item, i) => arr.push(fn(item, i))); + return arr; + } + + reverseMap(fn: (key: string) => mixed) { + return this.map(fn).reverse(); + } +}