UNPKG

providence

Version:

Reference a sub-structure of any data structure

547 lines (436 loc) 16.4 kB
/** * * Abstract Om-style cursors that may be adapted to data structures of any type. * This intended to be an alternative to immutable-js Cursors. * * By default, Providence cursors will be unboxed to an Immutable object type. * However, Providence will not provide the default initial root data, which * should be provided by the caller. * */ 'use strict'; var Immutable = require('immutable'); var Map = Immutable.Map; var Iterable = Immutable.Iterable; var isPlainObject = require('lodash.isplainobject'); var objGet = require('lodash.get'); var objSet = require('lodash.set'); var objHas = require('lodash.has'); var utils = require('./utils'); // TODO: make these overridable externally var valToPath = utils.valToPath; var newPath = utils.newPath; /* constants */ var NOT_SET = {}; // sentinel value var IDENTITY = function IDENTITY(x) { return x; }; var INITIAL_PATH = []; // paths var UNBOXER_PATH = ['root', 'unbox']; var BOXER_PATH = ['root', 'box']; var DATA_PATH = ['root', 'data']; var PATH_PATH = ['path']; var GETIN_PATH = ['getIn']; var SETIN_PATH = ['setIn']; var DELETEIN_PATH = ['deleteIn']; var FOREACH_PATH = ['forEach']; var REDUCE_PATH = ['reduce']; var ONUPDATE_PATH = ['onUpdate']; // Internal _onUpdate function; useful for when Providence is inherited. // Whereas, onUpdate function is used by users. var _ONUPDATE_PATH = ['_onUpdate']; // DEFAULTS is an array of 2-tuple arrays where the first element of the tuple is the key path // to be checked, and the second element is the function that will be called to generate the value // to be used to set on the key path in the case that there is no value set in the key path. var DEFAULTS = [[UNBOXER_PATH, IDENTITY], [BOXER_PATH, IDENTITY], [PATH_PATH, INITIAL_PATH], [GETIN_PATH, _default.bind(null, 'getIn')], [SETIN_PATH, _default.bind(null, 'setIn')], [DELETEIN_PATH, _default.bind(null, 'deleteIn')], [FOREACH_PATH, _default.bind(null, 'forEach')], [REDUCE_PATH, _default.bind(null, 'reduce')]]; var PATH = 0; var VALUE = 1; module.exports = Providence; /** * Create a Providence cursor given options. * Options may either be plain object or an Immutable Map. * * @param {Object | Immutable Map} options Defines the character of the providence cursor. */ function Providence() { var options = arguments.length <= 0 || arguments[0] === undefined ? NOT_SET : arguments[0]; var skipDataCheck = arguments.length <= 1 || arguments[1] === undefined ? false : arguments[1]; var skipProcessOptions = arguments.length <= 2 || arguments[2] === undefined ? false : arguments[2]; if (options === NOT_SET) { throw new Error('Expected options to be a plain object or an Immutable Map'); } // This will not support constructors that are extending Providence. // They should provide their own setup in their constructor function. if (!(this instanceof Providence)) { return new Providence(options, skipDataCheck, skipProcessOptions); } this._options = skipProcessOptions ? options : processOptions(options); // Used for caching value of the Providence object. // When the unboxed root data and this._refUnboxedRootData are equal, // then unboxed root data hasn't changed since the previous look up, and thus // this._cachedValue and value at path also hasn't changed. this._refUnboxedRootData = NOT_SET; this._cachedValue = NOT_SET; if (!skipDataCheck && this._options.getIn(DATA_PATH, NOT_SET) === NOT_SET) { throw new Error("value at path ['root', 'data'] is required!"); } } Providence.prototype.constructor = Providence; Providence.prototype.__utils = utils; /** * Returns string representation of `this.deref()`. * * @return {String} */ Providence.prototype.toString = function () { return String(this.deref()); }; /** * Dereference by unboxing the root data and getting the value at path. * If path happens to not exist, notSetValue is, instead, returned. * If notSetValue is not provided, it becomes value: void 0. * * @param {any} notSetValue * @return {any} The sub-structure value at path relative to * root data. */ Providence.prototype.valueOf = Providence.prototype.deref = function (notSetValue) { var options = this._options; // unbox root data var _unboxRootData = this.unboxRootData(); var rootData = _unboxRootData.rootData; var unboxed = _unboxRootData.unboxed; // check if value was cached if (this._refUnboxedRootData === unboxed) { var _resolvedValue = this._cachedValue; return _resolvedValue === NOT_SET ? notSetValue : _resolvedValue; } var path = options.getIn(PATH_PATH); var fetchGetIn = options.getIn(GETIN_PATH); var getIn = fetchGetIn(unboxed); var resolvedValue = getIn(path, NOT_SET); // cache this._refUnboxedRootData = unboxed; this._cachedValue = resolvedValue; return resolvedValue === NOT_SET ? notSetValue : resolvedValue; }; /** * Return true if a path exists within the unboxed root data. * * @return {Bool} */ Providence.prototype.exists = function () { return this.deref(NOT_SET) !== NOT_SET; }; /** * Returns the array representation of the path. * * @type {Array} */ Providence.prototype.path = function () { return this._options.getIn(PATH_PATH).slice(); }; /** * Returns providence cursor's options. It is safe to modify this object since * it is an Immutable Map object; and any changes will not reflect back to the * originating cursor, unless it is used as the new options. * * @return {Immutable Map} */ Providence.prototype.options = function () { return this._options; }; /** * Create a new Providence object with options via this instance. * * @param {Immutable Map | Object} newOptions * @return {Providence} */ Providence.prototype['new'] = function (newOptions) { return new this.constructor(newOptions); }; /** * When given no arguments, return itself. * * By default, this is the same behaviour as cursor() method for immutable-js * cursors: * - Returns a sub-cursor following the path keyValue starting from this cursor. * - If keyValue is not an array, an array containing keyValue is instead used. * * @param {any | array } keyValue * @return {Providence} */ Providence.prototype.cursor = function (keyValue) { if (arguments.length === 0) { return this; } // TODO: overridable valToPath; expect this to be a pure function // valToPath converts keyValue to path var subpath = valToPath(keyValue); // TODO: validateKeyPath that returns bool; expect this to be a pure function // be able to abort cursor procedure. aborting returns this instead. if (subpath.length === 0) { return this; } var options = this._options; var path = options.getIn(PATH_PATH); var newOptions = options.setIn(PATH_PATH, newPath(path, subpath)); return new this.constructor(newOptions, true, true); }; /** * Update value in the unboxed root data at path using the updater function. * If the path exists, updater is called using: * - the value at path * - unboxed root data * - boxed root data * * If path doesn't exist, notSetValue is used as the initial value. * If notSetValue is not defined, it has value void 0. * * If updater returns the same value the value at path (or notSetValue), * then no changes has truly occured, and the current cursor is instead returned. * * Otherwise, the new value is replaced at path of the unboxed root data, * and a new providence cursor is returned with the new boxed root data. * In addition, any defined functions at onUpdate and/or _onUpdate within options * will be called with the following: * - options * - cursor path * - new unboxed root data with the new value * - previous unboxed root data * * @param {any} notSetValue * @param {Function} updater * @return {Providence} */ Providence.prototype.update = function (notSetValue, updater) { if (!updater) { updater = notSetValue; notSetValue = void 0; } var options = this._options; // unbox root data var _unboxRootData2 = this.unboxRootData(); var rootData = _unboxRootData2.rootData; var unboxed = _unboxRootData2.unboxed; // fetch state at path var fetchGetIn = options.getIn(GETIN_PATH); var getIn = fetchGetIn(unboxed); var path = options.getIn(PATH_PATH); var state = getIn(path, NOT_SET); // get new state // // call updater with: // - state the object to be updated // - unboxed unboxed root data // - rootData boxed root data var newState = updater.call(null, state === NOT_SET ? notSetValue : state, unboxed, rootData); // TODO: delegate to an overridable: confirmChange(prev, next) if (state === newState) { return this; } // merge new state at path into root data var fetchSetIn = options.getIn(SETIN_PATH); var setIn = fetchSetIn(unboxed); var newRootData = setIn(path, newState); // box new root data var boxer = options.getIn(BOXER_PATH); var boxed = boxer(newRootData, rootData); callOnUpdate(options, path, newRootData, unboxed); var newOptions = options.setIn(DATA_PATH, boxed); return new this.constructor(newOptions, true, true); }; /** * Delete value at path. * * If the new unboxed root data is the same as the previous, original unboxed root data, * then the current cursor is returned. * * Otherwise, any defined functions at onUpdate and/or _onUpdate within options * will be called with the following: * - options * - cursor path * - new unboxed root data with the new value * - previous unboxed root data * * In addition, the new providence cursor containing the new unboxed root data * will be returned. * * @type {Providence} */ Providence.prototype.remove = Providence.prototype['delete'] = function () { var options = this._options; // unbox root data var _unboxRootData3 = this.unboxRootData(); var rootData = _unboxRootData3.rootData; var unboxed = _unboxRootData3.unboxed; var fetchDeleteIn = options.getIn(DELETEIN_PATH); var deleteIn = fetchDeleteIn(unboxed); var path = options.getIn(PATH_PATH); var newRootData = deleteIn(path); // TODO: delegate to an overridable: confirmChange(prev, next) if (unboxed === newRootData) { return this; } // box new root data var boxer = options.getIn(BOXER_PATH); var boxed = boxer(newRootData, rootData); callOnUpdate(options, path, newRootData, unboxed); var newOptions = options.setIn(DATA_PATH, boxed); return new this.constructor(newOptions, true, true); }; Providence.prototype.forEach = function (sideEffect) { var _this = this; var state = this.deref(NOT_SET); if (state === NOT_SET) { return; } var fetchForEach = this._options.getIn(FOREACH_PATH); var forEach = fetchForEach(state); var wrapped = function wrapped(value, key) { for (var _len2 = arguments.length, _rest = Array(_len2 > 2 ? _len2 - 2 : 0), _key2 = 2; _key2 < _len2; _key2++) { _rest[_key2 - 2] = arguments[_key2]; } var cursor = _this.cursor(key); return sideEffect.call.apply(sideEffect, [sideEffect, cursor, key].concat(_rest)); }; for (var _len = arguments.length, rest = Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) { rest[_key - 1] = arguments[_key]; } return forEach.apply(undefined, [wrapped].concat(rest)); }; Providence.prototype.reduce = function (reducer) { var _this2 = this; var state = this.deref(NOT_SET); if (state === NOT_SET) { return; } var fetchReduce = this._options.getIn(REDUCE_PATH); var reduce = fetchReduce(state); var wrapped = function wrapped(accumulator, value, key) { for (var _len4 = arguments.length, _rest = Array(_len4 > 3 ? _len4 - 3 : 0), _key4 = 3; _key4 < _len4; _key4++) { _rest[_key4 - 3] = arguments[_key4]; } var cursor = _this2.cursor(key); return reducer.call.apply(reducer, [reducer, accumulator, cursor, key].concat(_rest)); }; for (var _len3 = arguments.length, rest = Array(_len3 > 1 ? _len3 - 1 : 0), _key3 = 1; _key3 < _len3; _key3++) { rest[_key3 - 1] = arguments[_key3]; } return reduce.apply(undefined, [wrapped].concat(rest)); }; Providence.prototype.root = function () { var newOptions = this._options.setIn(PATH_PATH, []); return new this.constructor(newOptions, true, true); }; Providence.prototype.unboxRootData = function () { var options = this._options; var unboxer = options.getIn(UNBOXER_PATH); var rootData = options.getIn(DATA_PATH); return { rootData: rootData, unboxed: unboxer(rootData) }; }; /* helpers */ // Process and convert options to an Immutable Map if it isn't one already. // // processOptions is called whenever a new Providence object is created. // // We take advantage of structural sharing (a la flyweight) when a child // Providence object inherit options from its parent. function processOptions(options) { options = validateOptions(options); return options; } var __immutableSetIfNotSet = setIfNotSet.bind(null, _ImmutableHasIn, _ImmutableSetIn); var immutableValidateOptions = __validateOptions.bind(null, __immutableSetIfNotSet); var __plainSetIfNotSet = setIfNotSet.bind(null, _plainHasIn, _plainSetIn); var plainValidateOptions = __validateOptions.bind(null, __plainSetIfNotSet); function validateOptions(options) { if (Map.isMap(options)) { return immutableValidateOptions(options); } if (Iterable.isIterable(options)) { throw new Error('Expected options to be an Immutable Map!'); } if (!isPlainObject(options)) { throw new Error('Expected options to be a plain object'); } options = plainValidateOptions(options); // preserve values that Immutable.fromJS may transform; which is undesirable. var getIn = _plainGetIn(options); var setIn = _plainSetIn(options); var rootData = getIn(DATA_PATH, NOT_SET); var path = getIn(PATH_PATH, NOT_SET); // Set null values for paths so that they're not transformed by Immutable.fromJS(). // We do this in case that rootData is a deeply nested plain object. // // NOTE: This is a side-effect; we restore the values later. // // TODO: document this side-effect and potential for unintended effect // with Object.observe(...) if (rootData !== NOT_SET) { setIn(DATA_PATH, null); } if (path !== NOT_SET) { setIn(PATH_PATH, null); } options = Immutable.fromJS(options); // restore values if (rootData !== NOT_SET) { options = options.setIn(DATA_PATH, rootData); setIn(DATA_PATH, rootData); } if (path !== NOT_SET) { options = options.setIn(PATH_PATH, path); setIn(PATH_PATH, path); } return options; } function __validateOptions(_setIfNotSet, _options) { var options = _options; var n = DEFAULTS.length; while (n-- > 0) { var current = DEFAULTS[n]; options = _setIfNotSet(options, current[PATH], current[VALUE]); } return options; } function setIfNotSet(_fetchHasIn, _fetchSetIn, options, path, value) { var _hasIn = _fetchHasIn(options); if (!_hasIn(path)) { var _setIn = _fetchSetIn(options); return _setIn(path, value); } return options; } function callOnUpdate(options, path, newRoot, oldRoot) { var _onUpdate = options.getIn(_ONUPDATE_PATH, NOT_SET); if (_onUpdate !== NOT_SET) { _onUpdate.call(null, options, path.slice(), newRoot, oldRoot); } var onUpdate = options.getIn(ONUPDATE_PATH, NOT_SET); if (onUpdate !== NOT_SET) { onUpdate.call(null, options, path.slice(), newRoot, oldRoot); } } function _plainHasIn(obj) { return objHas.bind(objHas, obj); } function _plainSetIn(obj) { return objSet.bind(objSet, obj); } function _plainGetIn(obj) { return objGet.bind(objGet, obj); } function _ImmutableHasIn(obj) { return obj.hasIn.bind(obj); } function _ImmutableSetIn(obj) { return obj.setIn.bind(obj); } function _default(method, rootData) { return rootData[method].bind(rootData); }