UNPKG

objtools

Version:

Various utility functions for working with object, including object merging, inheritance, deep copying, etc.

555 lines 18.9 kB
// Copyright 2016 Zipscene, LLC // Licensed under the Apache License, Version 2.0 // http://www.apache.org/licenses/LICENSE-2.0 import * as objtools from './index.js'; import _ from 'lodash'; /** * This class represents a mask, or whitelist, of fields on an object. Such * a mask is stored in a format that looks like this: * * { foo: true, bar: { baz: true } } * * This mask applies to the properties "foo" and "bar.baz" on an object. * Wilcards can also be used: * * { foo: false, bar: false, _: true } * * This will allow all fields but foo and bar. The use of arrays with * a single element is equivalent to the use of wildcards, as arrays in * the masked object are treated as objects with numeric keys. These * two masks are equivalent: * * { foo: [ { bar: true, baz: true } ] } * * { foo: { _: { bar: true, baz: true } } } * * Note: array-type wildcards are converted to underscores on instantiation * * @class ObjectMask * @constructor * @param {Object} mask - The data for the mask */ export class ObjectMask { mask; constructor(mask) { this.mask = underscorizeArrays(_.cloneDeep(mask)); Object.defineProperty(this, '_isObjectMask', { value: true }); } /** * Creates a structured mask given a list of fields that should be included in the mask. * * @method createMaskFromFieldList * @static * @param {String[]} fields - Array of fields to include * @return {ObjectMask} - The created mask */ static createMaskFromFieldList(fields) { let ret = {}; let field; // We sort fields by length, long to short, to avoid more specific fields clobbering // less specific fields. fields = fields.slice(0).sort((a, b) => b.length - a.length); for (field of fields) { objtools.setPath(ret, field, true); } return new ObjectMask(ret); } /** * Combines two or more masks such that the result mask matches fields matched by * any of the combined masks. * * @method addMasks * @static * @param {ObjectMask|Object} mask1 * @param {ObjectMask|Object} mask2... * @return {ObjectMask} - The result of adding together the component masks */ static addMasks(...masks) { let resultMask = false; for (let curArg of masks) { if (ObjectMask.isObjectMask(curArg)) { curArg = curArg.toObject(); } resultMask = addMask(resultMask, curArg); if (resultMask === true) return new ObjectMask(true); } return new ObjectMask(resultMask || false); } /** * Combines two or more masks such that the result mask matches fields matched by * the first mask but not the second * * @method subtractMasks * @static * @param {ObjectMask|Object} min - the minuend * @param {ObjectMask|Object} sub - the subtrahend * @return {ObjectMask} - The result of subtracting the second mask from the first */ static subtractMasks(min, sub) { const mask = (ObjectMask.isObjectMask(min)) ? min.clone().toObject() : objtools.deepCopy(min); return new ObjectMask(mask).subtractMask(sub); } /** * Inverts a mask. The resulting mask disallows all fields previously allowed, * and allows all fields previously disallowed. * @static * @param {ObjectMask} mask - the mask to invert * @returns {ObjectMask} - the inverted mask */ static invertMask(mask) { if (ObjectMask.isObjectMask(mask)) mask = mask.mask; return new ObjectMask(invert(mask)); } /** * Check if an object is an ObjectMask * * @static * @param {Object} obj - the object to determine if is an ObjectMask * @return {Boolean} true if obj is an ObjectMask, false otherwise */ static isObjectMask(obj) { return !!(obj && obj._isObjectMask); } /** * Subtracts a mask * * @method subtractMask * @throws {Error} throws on (deep) attempt to subtract non-boolean scalars * @param {ObjectMask|Object} mask - the mask to subtract * @return {ObjectMask} - returns new mask */ subtractMask(mask) { this.mask = subtract(this.mask, ObjectMask.isObjectMask(mask) ? mask.mask : mask); return this; } /** * Adds a field to a filter. If the filter already matches, the method is a no-op. * * @method addField * @param {String} path - the dotted path to the field to add * @return {Object} - returns self */ addField(path) { if (!this.checkFields(objtools.setPath({}, path, true))) { objtools.setPath(this.mask, path, true); } return this; } /** * Removes a field from a filter. If the mask already does not match, the method is a no-op. * * @method removeField * @param {String} path - the dotted path to the field to remove * @throws {Error} on attempt to remove wildcard * @return {Object} - returns self */ removeField(path) { let submask, subpaths, nextSubpath, nextSubmask; if (path === '_' || path.slice(-2) === '._') { throw new Error('Attempt to remove wildcard'); } else if (this.checkFields(objtools.setPath({}, path, true))) { submask = this.mask; subpaths = path.split('.'); while (submask) { nextSubpath = subpaths.shift(); nextSubmask = submask[nextSubpath]; if (typeof nextSubmask === 'object') { submask = nextSubmask; } else if (submask._ && !nextSubmask) { submask = submask[nextSubpath] = _.cloneDeep(submask._); } else { while (subpaths.length) { submask = submask[nextSubpath] = { _: true }; nextSubpath = subpaths.shift(); } submask = submask[nextSubpath] = false; } } } return this; } /** * Returns a copy of the given object, but only including the fields allowed by * the mask. If the maskedOutHook function is provided, it is called for * each field disallowed by the mask (at the highest level it is disallowed). * * @method filterObject * @param {Object} obj - Object to filter * @param {Function} [maskedOutHook] - Function to call for fields disallowed * by the mask * @param {String} maskedOutHook.path - Path on the object that was masked out * @return {Object} - The object after removing masked out fields. Note that * the returned object may still contain references to the original object. * Fields that are not masked out are copied by reference. */ filterObject(obj, maskedOutHook = null) { return filterDeep(obj, this.mask, '', maskedOutHook); } /** * Returns a subsection of a mask given a dot-separated path to the subsection. * * @method getSubMask * @param {String} path - Dot-separated path to submask to fetch * @return {ObjectMask} - Mask component corresponding to the path */ getSubMask(path) { let key, mask = this.mask, subpaths = path.split('.'); while (subpaths.length && !objtools.isScalar(mask)) { key = subpaths.shift(); mask = key in mask ? mask[key] : mask._; } return new ObjectMask(mask || false); } /** * Returns true if the given path is allowed by the mask. false otherwise. * * @method checkMaskPath * @param {String} path - Dot-separated path * @return {Boolean} - Whether or not the given path is allowed */ checkPath(path) { return this.getSubMask(path).mask === true; } /** * Make a deep copy of the mask. * * @method clone * @return {ObjectMask} */ clone() { return new ObjectMask(objtools.deepCopy(this.mask)); } /** * Returns the internal object that represents this mask. * * @method toObject * @return {Object} - Object representation of this mask */ toObject() { return this.mask; } /** * Adds a set of masks together, but using a logical AND instead of a logical OR (as in addMasks). * IE, a field must be allowed in all given masks to be in the result mask. * * @method andMasks * @static * @param {ObjectMask|Object} mask1 * @param {ObjectMask|Object} mask2... * @return {ObjectMask} - The result of ANDing together the component masks */ static andMasks(...masks) { let resultMask = true; for (let curArg of masks) { if (ObjectMask.isObjectMask(curArg)) { curArg = curArg.toObject(); } resultMask = andMask(resultMask, curArg); if (resultMask === false) return new ObjectMask(false); } return new ObjectMask(resultMask || false); } /** * Check if a mask is valid in strict form (ie, it only contains objects and booleans) * * @method validate * @return {Boolean} - Whether or not the mask is strictly valid */ validate() { return valWhitelist(this.mask); } /** * Returns an array of fields in the given object which are restricted by the given mask * * @method getMaskedOutFields * @param {Object} obj - The object to check against * @return {String[]} - Paths to fields that are restricted by the mask */ getMaskedOutFields(obj) { let maskedOut = []; this.filterObject(obj, (path) => { maskedOut.push(path); }); return maskedOut; } /** * Given a dot-notation mapping from fields to values, remove all fields that are not * allowed by the mask. * * @method filterDottedObject * @param {Object} dottedObj - Map from dotted paths to values, such as { "foo.bar": "baz" } * @param {Function} [maskedOutHook] - Function to call for removed fields * @param {String} maskedOutHook.path - Path of the masked out field * @return {Object} - The result */ filterDottedObject(dottedObj, maskedOutHook = null) { let resultObj = {}; for (let key in dottedObj) { if (!this.checkPath(key)) { if (maskedOutHook) { maskedOutHook(key); } } else { resultObj[key] = dottedObj[key]; } } return resultObj; } /** * Returns an array of fields in the given object which are restricted by the given mask. The * object is in dotted notation as in filterDottedObject() * * @method getDottedMaskedOutFields * @param {Object} obj - The object to check against * @return {String[]} - Paths to fields that are restricted by the mask */ getDottedMaskedOutFields(obj) { let maskedOut = []; this.filterDottedObject(obj, (path) => { maskedOut.push(path); }); return maskedOut; } /** * Given a structured document, ensures that * all fields are allowed by the given mask. Returns true or false. * * @method checkFields * @param {Object} obj * @return {Boolean} */ checkFields(obj) { return this.getMaskedOutFields(obj).length === 0; } /** * Given a dot-notation mapping from fields to values (only 1 level deep is checked), * ensure that all fields are in the (structured) mask. * * @method checkDottedFields * @param {Object} dottedObj - Mapping from dot-separated paths to values * @return {Boolean} */ checkDottedFields(dottedObj) { return Object.keys(dottedObj).every((path) => this.checkPath(path)); } /** * Returns a function that filters object fields based on a structured mask/whitelist. * * @method createFilterFunc * @static * @return {Function} - A function(obj) that is the equivalent of calling filterObject() * on obj */ createFilterFunc() { return (obj) => this.filterObject(obj); } } function underscorizeArrays(mask) { let subpath, submask; for (subpath in mask) { submask = mask[subpath]; if (_.isArray(submask)) { mask[subpath] = { _: underscorizeArrays(submask[0]) }; } else if (_.isObject(submask)) { mask[subpath] = underscorizeArrays(submask); } } return mask; } // shallowly removes falsey keys if obj does not have a wildcard function sanitizeFalsies(obj) { if (!obj._) { delete obj._; for (let key in obj) { if (obj[key] === false) delete obj[key]; } } return obj; } function invert(mask) { // base case: if (objtools.isScalar(mask)) return !mask; let key, result = {}; for (key in mask) result[key] = invert(mask[key]); if (!('_' in result)) { result._ = true; } else if (!result._) { delete result._; } return result; } function subtract(a, b) { let key; // base cases: if (a === true) return invert(b); if (a === false) return false; if (!a) return invert(b); if (!b) return a; if (b === true || _.isEqual(a, b)) return false; if (objtools.isScalar(a) || objtools.isScalar(b)) { throw new Error('Cannot subtract non-boolean scalars'); } if ('_' in b) { for (key in a) { if (!(key in b) || key === '_') { a[key] = subtract(a[key], b._); } } } if ('_' in a && '_' in b) { // both have _ for (key in b) { if (key !== '_') { a._[key] = subtract(a[key], b[key]); } } } else { for (key in b) { a[key] = subtract(a[key], b[key]); } } return sanitizeFalsies(a); } function filterDeep(obj, mask, path, maskedOutHook) { let resultIsArray, resultObj, key, maskVal, resultVal; if (mask === true) return obj; if (mask && !objtools.isScalar(obj) && !objtools.isScalar(mask)) { resultIsArray = _.isArray(obj); resultObj = resultIsArray ? [] : {}; for (key in obj) { maskVal = (key in mask) ? mask[key] : mask._; resultVal = filterDeep(obj[key], maskVal || false, path ? (path + '.' + key) : key, maskedOutHook); if (resultVal !== undefined) { if (resultIsArray) resultObj.push(resultVal); else resultObj[key] = resultVal; } } return resultObj; } else if (maskedOutHook) { maskedOutHook(path); } } // Adds a single mask (fromMask) into the resultMask mask in-place. toMask should be an object. // If the resulting mask is a boolean true, this function returns true. Otherwise, it returns toMask. function addMask(resultMask, newMask) { // base cases if (resultMask === true || newMask === true) return true; if (objtools.isScalar(newMask)) return resultMask; if (objtools.isScalar(resultMask)) return objtools.deepCopy(newMask); let key; // If there are keys that exist in result but not in the newMask, // and the result mask has a _ key (wildcard), combine // the wildcard mask with the new mask, because in the existing // result mask, that key has the wildcard permissions if ('_' in newMask) { for (key in resultMask) { if (key !== '_' && !(key in newMask)) resultMask[key] = addMask(resultMask[key], newMask._); } } // same here ... also, copy over or merge fields for (key in newMask) { if (key !== '_') { if (key in resultMask) { resultMask[key] = addMask(resultMask[key], newMask[key]); } else if ('_' in resultMask) { resultMask[key] = addMask(objtools.deepCopy(newMask[key]), resultMask._); } else { resultMask[key] = objtools.deepCopy(newMask[key]); } } } // fill in the _ key that we skipped earlier if ('_' in newMask) { resultMask._ = ('_' in resultMask) ? addMask(resultMask._, newMask._) : objtools.deepCopy(newMask._); } // If there is a wildcard, remove any keys that are set to the same thing as the wildcard // This isn't strictly necessary, but removes redundant data if ('_' in resultMask) { for (key in resultMask) { if (key !== '_' && objtools.deepEquals(resultMask[key], resultMask._)) { delete resultMask[key]; } } } return resultMask || false; } function andMask(resultMask, newMask) { // base cases if (resultMask === true) return objtools.deepCopy(newMask); if (newMask === true) return resultMask; if (objtools.isScalar(resultMask) || objtools.isScalar(newMask)) return false; let key; // Handle keys that exist in both masks, excepting _ for (key in newMask) { if (key !== '_' && key in resultMask) { resultMask[key] = andMask(resultMask[key], newMask[key]); } } // Handle keys that exist in resultMask but not in newMask for (key in resultMask) { if (key !== '_' && !(key in newMask)) { resultMask[key] = ('_' in newMask) ? andMask(resultMask[key], newMask._) : false; } } // Handle keys that exist in newMask but not resultMask for (key in newMask) { if (key !== '_' && !(key in resultMask)) { resultMask[key] = ('_' in resultMask) ? andMask(objtools.deepCopy(newMask[key]), resultMask._) : false; } } // Handle _ (wildcard fields) if ('_' in newMask && '_' in resultMask) { resultMask._ = andMask(resultMask._, newMask._); } else { delete resultMask._; } resultMask = sanitizeFalsies(resultMask); // If there are no keys left in resultMask, condense to false return Object.keys(resultMask).length ? resultMask : false; } function valWhitelist(whitelist) { let key; if (whitelist !== true && whitelist !== false && objtools.isScalar(whitelist)) return false; if (typeof whitelist === 'object') { for (key in whitelist) { if (!valWhitelist(whitelist[key])) return false; } } return true; } //# sourceMappingURL=object-mask.js.map