Last active
May 3, 2018 19:50
-
-
Save samthecodingman/389102e50c0d48f314d03695acb17866 to your computer and use it in GitHub Desktop.
Atomic Write Helper for the Firebase Realtime Database
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/*! Copyright (c) 2017 Samuel Jones | MIT License | github.com/samthecodingman */ | |
/* Contains contributions by @benweier and @rhalff */ | |
/** | |
* A Reference represents a specific location in the Database and can be used | |
* for reading or writing data to that Database location. | |
* @typedef fbReference | |
* @type {firebase.database.Reference} | |
*/ | |
/** | |
* Defines a helper class to combine multiple operations into a single | |
* "all-or-nothing" database update. | |
* @type {Object} | |
*/ | |
class PendingUpdate { | |
/** | |
* Creates a new PendingUpdate instance. | |
* @param {Object} db - the firebase database instance to use | |
* @param {boolean} suppressChecks - if true, skip path checks | |
*/ | |
constructor (db, suppressChecks) { | |
// Nifty snippet from github.com/benweier/connect-session-firebase [MIT] | |
if (db.ref) { | |
this.db = db | |
} else if (db.database) { | |
this.db = db.database() | |
} else { | |
throw new Error('Invalid Firebase reference') | |
} | |
this.data = {} | |
this.suppressChecks = Boolean(suppressChecks) | |
} | |
/** | |
* Removes all entries that are children of the given location. | |
* @param {(string|fbReference)} ref - path/reference to operation location | |
* @return {PendingUpdate} for chaining | |
*/ | |
remove (ref) { | |
if (typeof ref === 'string') ref = this.db.ref(ref) | |
let path = _getRefPath(ref) | |
this.data = this.data.filter((entryPath) => !entryPath.startsWith(path)) | |
return this | |
} | |
/** | |
* Adds a SET operation to the update that will remove the data at the given | |
* location. | |
* | |
* Unless configured otherwise, the operation will be ignored unless the | |
* operation occurs on a non-root key. | |
* @param {(string|fbReference)} ref - path/reference to operation location | |
* @param {boolean} [allowRootKey] - skips the path check if truthy | |
* @return {PendingUpdate} for chaining | |
*/ | |
addRemove (ref, allowRootKey) { | |
if (typeof ref === 'string') ref = this.db.ref(ref) | |
let path = _getRefPath(ref) | |
if (this.suppressChecks || allowRootKey || (path && path.lastIndexOf('/') > 0)) { | |
this.data[path] = null | |
} | |
return this | |
} | |
/** | |
* Adds a SET operation to the update | |
* | |
* Unless configured otherwise, the operation will be ignored unless the | |
* operation occurs on a non-root key. | |
* @param {(string|fbReference)} ref - path/reference to operation location | |
* @param {*} data - the data to set | |
* @param {boolean} [allowRootKey] - skips the path check if truthy | |
* @return {PendingUpdate} for chaining | |
*/ | |
addSet (ref, data, allowRootKey) { | |
if (typeof ref === 'string') ref = this.db.ref(ref) | |
let path = _getRefPath(ref) | |
if (this.suppressChecks || allowRootKey || (path && path.lastIndexOf('/') > 0)) { | |
this.data[path] = data | |
} | |
return this | |
} | |
/** | |
* Adds an UPDATE operation to the update | |
* | |
* Unless configured otherwise, the operation will be ignored unless the | |
* operation occurs on a non-root key. | |
* @param {(string|fbReference)} ref - path/reference to operation location | |
* @param {*} data - the data to set | |
* @param {boolean} [allowRootKey] - skips the path check if truthy | |
* @return {PendingUpdate} for chaining | |
*/ | |
addUpdate (ref, data, allowRootKey) { | |
if (typeof ref === 'string') ref = this.db.ref(ref) | |
if ((isArrayOrObject(data) && ( | |
(isObject(data) && !isEmptyObject(data)) || | |
(Array.isArray(data) && (data.length !== 0)) | |
))) { | |
Object.keys(data).forEach((key) => { | |
this.addSet(ref.child(key), data[key], allowRootKey) | |
}); | |
return this | |
} else { | |
return this.addSet(ref.child(key), data[key], allowRootKey) | |
} | |
} | |
/** | |
* Adds individual SET operations for each value in the given object. | |
* | |
* Unless configured otherwise, the operation will be ignored unless the | |
* operation occurs on a non-root key. | |
* @param {(string|fbReference)} ref - path/reference to operation location | |
* @param {*} data - the data to set | |
* @param {boolean} [allowRootKey] - skips the path check if truthy | |
* @return {PendingUpdate} for chaining | |
*/ | |
addRecursiveSet (ref, data, allowRootKey) { | |
if (typeof ref === 'string') ref = this.db.ref(ref) | |
let path = _getRefPath(ref) | |
if (this.suppressChecks || allowRootKey || (path && path.lastIndexOf('/') > 0)) { | |
objectToPathMap(data, this.data, [path]) | |
} else { | |
Object.keys(data).forEach((key) => { | |
if ((isArrayOrObject(data[key]) && ( | |
(isObject(data[key]) && !isEmptyObject(data[key])) || | |
(Array.isArray(data[key]) && (data[key].length !== 0)) | |
))) { | |
return this.addRecursiveSet(ref.child(key), data[key], allowRootKey) | |
} else { | |
return this.addSet(ref.child(key), data[key], allowRootKey) | |
} | |
}) | |
} | |
return this | |
} | |
/** | |
* Commits the changes to the database. | |
* @return {Promise} resolves when the write has completed. | |
*/ | |
commit () { | |
return this.db.ref().update(this.data).then(() => this.data.length) | |
} | |
} | |
module.exports = PendingUpdate | |
/** | |
* Returns a string path of the given reference relative to the database root. | |
* @param {fbReference} ref - the Reference object | |
* @return {string} - the string path | |
*/ | |
function _getRefPath (ref) { | |
return ref.toString().substring(ref.root.toString().length) | |
} | |
/*! Imported from rhalff/dot-object */ | |
/*! Copyright (c) 2013 Rob Halff | MIT License */ | |
/* eslint-disable require-jsdoc */ | |
function isArrayOrObject (val) { | |
return Object(val) === val | |
} | |
function isObject (val) { | |
return Object.prototype.toString.call(val) === '[object Object]' | |
} | |
function isEmptyObject (val) { | |
return Object.keys(val).length === 0 | |
} | |
/** | |
* | |
* Convert object to path/value pair | |
* | |
* @param {Object} obj source object | |
* @param {Object} tgt target object | |
* @param {Array} path path array (internal) | |
* @param {Boolean} keepArray indicates if arrays should be preserved | |
* @return {Object} reference to tgt | |
*/ | |
function objectToPathMap (obj, tgt, path, keepArray) { | |
tgt = tgt || {} | |
path = path || [] | |
Object.keys(obj).forEach((key) => { | |
if ((isArrayOrObject(obj[key]) && ( | |
(isObject(obj[key]) && !isEmptyObject(obj[key])) || | |
(Array.isArray(obj[key]) && (!keepArray && (obj[key].length !== 0))) | |
))) { | |
return objectToPathMap(obj[key], tgt, path.concat(key), keepArray) | |
} else { | |
tgt[path.concat(key).join('/')] = obj[key] | |
} | |
}) | |
return tgt | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
All-or-Nothing / Atomic Write Helper for the Firebase Realtime Database
Purpose
Sometimes you need to update many locations in the Firebase Realtime Database at once. Aside from manually throwing paths into an
update()
operation, this isn't overly intuitive. It also hurts readability depending on what you are trying to do with the update. To fix this, I devised this helper class that allows you to addset()
andremove()
operations that will either all succeed at once or all fail. Furthermore, it also allows you do a recursiveset()
that truly merges the changes of every sub-property rather than replacing the child properties as objects (see Issue #134 offirebase/firebase-js-sdk
for more information).This is particularly useful for lengthy data clean-up tasks or large data merges that should complete as a single operation. An example of this helper class in use is at the bottom of this documentation comment or in the documentation comment of
adminTaskFunction.js
.Initialization
Imports
Import the required modules:
Constructor
db
is the firebase database instance to be used for the update.suppressChecks
determines if the built-in data safety checks (see below) should be used. Passing intrue
will disable the checks and improve performance merginally but opens up the possibility of corrupting your database.Default:
false
.Operations
Each operation will return the handle to it's own
PendingUpdate
instance. This allows you to chain operations if desired.The first argument of each of the following methods is the location of the operation. They can be any of the following:
this.db.ref(string)
firebase.database.Reference
admin.database.Reference
The last argument of each of the following methods is optional and specifies whether the root-access protections should be lifted for that operation (see Data Safety Features below).
Set
To add a
set()
operation to the batch:Remove
To add a
remove()
operation to the batch:which is equivalent to:
Update
To add a traditional
update()
operation to the batch:which is equivalent to:
Note: This operation adds individual
set()
operations for each top-level enumerable property in the value, replicating the behaviour ofupdate()
from the client and admin javascript SDKs. If you want a true merge, see the Merge operation below.Merge
The traditional
update()
operation only createsset()
operations for the top level enumerable properties as discussed in Issue #134 offirebase/firebase-js-sdk
. This variant will add set operations for each enumerable property and their sub-properties recursively.which is equivalent to:
Commit Changes
Nothing will change in your database until you call
commit()
. Don't forget to call it!This will call a multi-location update using the given database instance that will change all your data atomically in an all-or-nothing fashion.
returnedPromise
will resolve with the number of paths changed.Note: Once a
PendingUpdate
has been committed, you should consider it's state invalid and cease further use of thatPendingUpdate
instance.Data Safety Features
Disclaimer
These protections are not going to be infallible. But they should at least help protect your database somewhat. I take no responsibility for lost data.
Introduction
When you create a new instance of
PendingUpdate
or use one of it's methods, the last optional argument determines if the helper class will silently ignore operations affecting the top-level keys in the root of your database.To create a protected
PendingUpdate
instance:To create an unprotected
PendingUpdate
instance:To create an protected
PendingUpdate
instance, but override the protection for a single call:The protections prevent usage like so:
Protection Example: Attempting to delete
/users
By default, calling
addRemove()
for the path/users
will result in nothing happening.To override this behaviour for the entire
PendingUpdate
instance, supplytrue
as the second argument of the constructor.To override this on a per-call basis, initialize the
PendingUpdate
instance as normal, but providetrue
as the last parameter of the call toaddRemove()
.Example