Home Reference Source Test Repository

src/ObjectUtil.js

/**
 * Provides common object manipulation utilities including depth traversal, obtaining accessors, safely setting values /
 * equality tests, and validation.
 *
 * Support for typhonjs-plugin-manager is enabled.
 */
export default class ObjectUtil
{
   /**
    * Performs a naive depth traversal of an object / array. The data structure _must not_ have circular references.
    * The result of the callback function is used to modify in place the given data.
    *
    * @param {object|Array}   data - An object or array.
    *
    * @param {function}       func - A callback function to process leaf values in children arrays or object members.
    *
    * @param {boolean}        modify - If true then the result of the callback function is used to modify in place
    *                                  the given data.
    *
    * @returns {*}
    */
   static depthTraverse(data, func, modify = false)
   {
      /* istanbul ignore if */
      if (typeof data !== 'object') { throw new TypeError('depthTraverse error: \'data\' is not an \'object\'.'); }

      /* istanbul ignore if */
      if (typeof func !== 'function') { throw new TypeError('depthTraverse error: \'func\' is not a \'function\'.'); }

      return _depthTraverse(data, func, modify);
   }

   /**
    * Returns a list of accessor keys by traversing the given object.
    *
    * @param {object}   data - An object to traverse for accessor keys.
    *
    * @returns {Array}
    */
   static getAccessorList(data)
   {
      if (typeof data !== 'object') { throw new TypeError(`getAccessorList error: 'data' is not an 'object'.`); }

      return _getAccessorList(data);
   }

   /**
    * Provides a way to safely access an objects data / entries given an accessor string which describes the
    * entries to walk. To access deeper entries into the object format the accessor string with `.` between entries
    * to walk.
    *
    * @param {object}   data - An object to access entry data.
    *
    * @param {string}   accessor - A string describing the entries to access.
    *
    * @param {*}        defaultValue - (Optional) A default value to return if an entry for accessor is not found.
    *
    * @returns {*}
    */
   static safeAccess(data, accessor, defaultValue = void 0)
   {
      if (typeof data !== 'object') { return defaultValue; }
      if (typeof accessor !== 'string') { return defaultValue; }

      const access = accessor.split('.');

      // Walk through the given object by the accessor indexes.
      for (let cntr = 0; cntr < access.length; cntr++)
      {
         // If the next level of object access is undefined or null then return the empty string.
         if (typeof data[access[cntr]] === 'undefined' || data[access[cntr]] === null) { return defaultValue; }

         data = data[access[cntr]];
      }

      return data;
   }

   /**
    * Provides a way to safely batch set an objects data / entries given an array of accessor strings which describe the
    * entries to walk. To access deeper entries into the object format the accessor string with `.` between entries
    * to walk. If value is an object the accessor will be used to access a target value from `value` which is
    * subsequently set to `data` by the given operation. If `value` is not an object it will be used as the target
    * value to set across all accessors.
    *
    * @param {object}         data - An object to access entry data.
    *
    * @param {Array<string>}  accessors - A string describing the entries to access.
    *
    * @param {object|*}       value - A new value to set if an entry for accessor is found.
    *
    * @param {string}         [operation='set'] - Operation to perform including: 'add', 'div', 'mult', 'set',
    *                                             'set-undefined', 'sub'.
    *
    * @param {object|*}       [defaultAccessValue=0] - A new value to set if an entry for accessor is found.
    *
    *
    * @param {boolean}  [createMissing=true] - If true missing accessor entries will be created as objects
    *                                          automatically.
    */
   static safeBatchSet(data, accessors, value, operation = 'set', defaultAccessValue = 0, createMissing = true)
   {
      if (typeof data !== 'object') { throw new TypeError(`safeBatchSet Error: 'data' is not an 'object'.`); }
      if (!Array.isArray(accessors)) { throw new TypeError(`safeBatchSet Error: 'accessors' is not an 'array'.`); }

      if (typeof value === 'object')
      {
         accessors.forEach((accessor) =>
         {
            const targetValue = ObjectUtil.safeAccess(value, accessor, defaultAccessValue);
            ObjectUtil.safeSet(data, accessor, targetValue, operation, createMissing);
         });
      }
      else
      {
         accessors.forEach((accessor) =>
         {
            ObjectUtil.safeSet(data, accessor, value, operation, createMissing);
         });
      }
   }

   /**
    * Compares a source object and values of entries against a target object. If the entries in the source object match
    * the target object then `true` is returned otherwise `false`. If either object is undefined or null then false
    * is returned.
    *
    * @param {object}   source - Source object.
    *
    * @param {object}   target - Target object.
    *
    * @returns {boolean}
    */
   static safeEqual(source, target)
   {
      if (typeof source === 'undefined' || source === null || typeof target === 'undefined' || target === null)
      {
         return false;
      }

      const sourceAccessors = ObjectUtil.getAccessorList(source);

      for (let cntr = 0; cntr < sourceAccessors.length; cntr++)
      {
         const accessor = sourceAccessors[cntr];

         const sourceObjectValue = ObjectUtil.safeAccess(source, accessor);
         const targetObjectValue = ObjectUtil.safeAccess(target, accessor);

         if (sourceObjectValue !== targetObjectValue) { return false; }
      }

      return true;
   }

   /**
    * Provides a way to safely set an objects data / entries given an accessor string which describes the
    * entries to walk. To access deeper entries into the object format the accessor string with `.` between entries
    * to walk.
    *
    * @param {object}   data - An object to access entry data.
    *
    * @param {string}   accessor - A string describing the entries to access.
    *
    * @param {*}        value - A new value to set if an entry for accessor is found.
    *
    * @param {string}   [operation='set'] - Operation to perform including: 'add', 'div', 'mult', 'set',
    *                                       'set-undefined', 'sub'.
    *
    * @param {boolean}  [createMissing=true] - If true missing accessor entries will be created as objects
    *                                          automatically.
    *
    * @returns {boolean} True if successful.
    */
   static safeSet(data, accessor, value, operation = 'set', createMissing = true)
   {
      if (typeof data !== 'object') { throw new TypeError(`safeSet Error: 'data' is not an 'object'.`); }
      if (typeof accessor !== 'string') { throw new TypeError(`safeSet Error: 'accessor' is not a 'string'.`); }

      const access = accessor.split('.');

      // Walk through the given object by the accessor indexes.
      for (let cntr = 0; cntr < access.length; cntr++)
      {
         // If data is an array perform validation that the accessor is a positive integer otherwise quit.
         if (Array.isArray(data))
         {
            const number = (+access[cntr]);

            if (!Number.isInteger(number) || number < 0) { return false; }
         }

         if (cntr === access.length - 1)
         {
            switch (operation)
            {
               case 'add':
                  data[access[cntr]] += value;
                  break;

               case 'div':
                  data[access[cntr]] /= value;
                  break;

               case 'mult':
                  data[access[cntr]] *= value;
                  break;

               case 'set':
                  data[access[cntr]] = value;
                  break;

               case 'set-undefined':
                  if (typeof data[access[cntr]] === 'undefined') { data[access[cntr]] = value; }
                  break;

               case 'sub':
                  data[access[cntr]] -= value;
                  break;
            }
         }
         else
         {
            // If createMissing is true and the next level of object access is undefined then create a new object entry.
            if (createMissing && typeof data[access[cntr]] === 'undefined') { data[access[cntr]] = {}; }

            // Abort if the next level is null or not an object and containing a value.
            if (data[access[cntr]] === null || typeof data[access[cntr]] !== 'object') { return false; }

            data = data[access[cntr]];
         }
      }

      return true;
   }

   /**
    * Performs bulk setting of values to the given data object.
    *
    * @param {object}            data - The data object to set data.
    *
    * @param {object<string, *>} accessorValues - Object of accessor keys to values to set.
    *
    * @param {string}            [operation='set'] - Operation to perform including: 'add', 'div', 'mult', 'set', 'sub';
    *                                                default (`set`).
    *
    * @param {boolean}           [createMissing=true] - If true missing accessor entries will be created as objects
    *                                                   automatically.
    */
   static safeSetAll(data, accessorValues, operation = 'set', createMissing = true)
   {
      if (typeof data !== 'object') { throw new TypeError(`'data' is not an 'object'.`); }
      if (typeof accessorValues !== 'object') { throw new TypeError(`'accessorValues' is not an 'object'.`); }

      for (const accessor of Object.keys(accessorValues))
      {
         if (!accessorValues.hasOwnProperty(accessor)) { continue; }

         ObjectUtil.safeSet(data, accessor, accessorValues[accessor], operation, createMissing);
      }
   }

   /**
    * Performs bulk validation of data given an object, `validationData`, which describes all entries to test.
    *
    * @param {object}                           data - The data object to test.
    *
    * @param {object<string, ValidationEntry>}  validationData - Key is the accessor / value is a validation entry.
    *
    * @param {string}                           [dataName='data'] - Optional name of data.
    *
    * @returns {boolean} True if validation passes otherwise an exception is thrown.
    */
   static validate(data, validationData = {}, dataName = 'data')
   {
      if (typeof data !== 'object') { throw new TypeError(`'${dataName}' is not an 'object'.`); }
      if (typeof validationData !== 'object') { throw new TypeError(`'validationData' is not an 'object'.`); }

      let result;

      for (const key of Object.keys(validationData))
      {
         if (!validationData.hasOwnProperty(key)) { continue; }

         const entry = validationData[key];

         switch (entry.test)
         {
            case 'array':
               result = ObjectUtil.validateArray(data, key, entry, dataName);
               break;

            case 'entry':
               result = ObjectUtil.validateEntry(data, key, entry, dataName);
               break;

            case 'entry|array':
               result = ObjectUtil.validateEntryOrArray(data, key, entry, dataName);
               break;
         }
      }

      return result;
   }

   // TODO: add docs after upgrading to latest WebStorm / better object destructuring support.
   // /**
   // * Validates all array entries against potential type and expected tests.
   // *
   // * @param {object}            data - The data object to test.
   // *
   // * @param {string}            accessor - A string describing the entries to access.
   // *
   // * @param {string}            [type] - Tests with a typeof check.
   // *
   // * @param {function|Set<*>}   [expected] - Optional function or set of expected values to test against.
   // *
   // * @param {string}            [message] - Optional message to include.
   // *
   // * @param {boolean}           [required] - When false if the accessor is missing validation is skipped.
   // *
   // * @param {boolean}           [error=true] - When true and error is thrown otherwise a boolean is returned.
   // *
   // * @param {string}            [dataName='data'] - Optional name of data.
   // *
   // * @returns {boolean} True if validation passes otherwise an exception is thrown.
   // */
   static validateArray(data, accessor, { type = void 0, expected = void 0, message = void 0, required = true,
    error = true } = {}, dataName = 'data')
   {
      const dataArray = ObjectUtil.safeAccess(data, accessor);

      // A non-required entry is missing so return without validation.
      if (!required && typeof dataArray === 'undefined') { return true; }

      if (!Array.isArray(dataArray))
      {
         if (error)
         {
            throw _validateError(TypeError, `'${dataName}.${accessor}' is not an 'array'.`);
         }
         else
         {
            return false;
         }
      }

      if (typeof type === 'string')
      {
         for (let cntr = 0; cntr < dataArray.length; cntr++)
         {
            if (!(typeof dataArray[cntr] === type))
            {
               if (error)
               {
                  const dataEntryString = typeof dataArray[cntr] === 'object' ? JSON.stringify(dataArray[cntr]) :
                   dataArray[cntr];

                  throw _validateError(TypeError,
                   `'${dataName}.${accessor}[${cntr}]': '${dataEntryString}' is not a '${type}'.`);
               }
               else
               {
                  return false;
               }
            }
         }
      }

      // If expected is a function then test all array entries against the test function. If expected is a Set then
      // test all array entries for inclusion in the set. Otherwise if expected is a string then test that all array
      // entries as a `typeof` test against expected.
      if (Array.isArray(expected))
      {
         for (let cntr = 0; cntr < dataArray.length; cntr++)
         {
            if (expected.indexOf(dataArray[cntr]) < 0)
            {
               if (error)
               {
                  const dataEntryString = typeof dataArray[cntr] === 'object' ? JSON.stringify(dataArray[cntr]) :
                   dataArray[cntr];

                  throw _validateError(Error, `'${dataName}.${accessor}[${cntr}]': '${
                   dataEntryString}' is not an expected value: ${JSON.stringify(expected)}.`);
               }
               else
               {
                  return false;
               }
            }
         }
      }
      else if (expected instanceof Set)
      {
         for (let cntr = 0; cntr < dataArray.length; cntr++)
         {
            if (!expected.has(dataArray[cntr]))
            {
               if (error)
               {
                  const dataEntryString = typeof dataArray[cntr] === 'object' ? JSON.stringify(dataArray[cntr]) :
                   dataArray[cntr];

                  throw _validateError(Error, `'${dataName}.${accessor}[${cntr}]': '${
                   dataEntryString}' is not an expected value: ${JSON.stringify(expected)}.`);
               }
               else
               {
                  return false;
               }
            }
         }
      }
      else if (typeof expected === 'function')
      {
         for (let cntr = 0; cntr < dataArray.length; cntr++)
         {
            try
            {
               const result = expected(dataArray[cntr]);

               if (typeof result === 'undefined' || !result) { throw new Error(message); }
            }
            catch (err)
            {
               if (error)
               {
                  const dataEntryString = typeof dataArray[cntr] === 'object' ? JSON.stringify(dataArray[cntr]) :
                   dataArray[cntr];

                  throw _validateError(Error, `'${dataName}.${accessor}[${cntr}]': '${
                   dataEntryString}' failed validation: ${err.message}.`);
               }
               else
               {
                  return false;
               }
            }
         }
      }

      return true;
   }

   // TODO: add docs after upgrading to latest WebStorm / better object destructuring support.
   // /**
   // * Validates data entry with a typeof check and potentially tests against the values in any given expected set.
   // *
   // * @param {object}            data - The object data to validate.
   // *
   // * @param {string}            accessor - A string describing the entries to access.
   // *
   // * @param {string}            [type] - Tests with a typeof check.
   // *
   // * @param {function|Set<*>}   [expected] - Optional function or set of expected values to test against.
   // *
   // * @param {string}            [message] - Optional message to include.
   // *
   // * @param {boolean}           [required=true] - When false if the accessor is missing validation is skipped.
   // *
   // * @param {boolean}           [error=true] - When true and error is thrown otherwise a boolean is returned.
   // *
   // * @param {string}            [dataName='data'] - Optional name of data.
   // *
   // * @returns {boolean} True if validation passes otherwise an exception is thrown.
   // */
   static validateEntry(data, accessor, { type = void 0, expected = void 0, message = void 0, required = true,
    error = true } = {}, dataName = 'data')
   {
      const dataEntry = ObjectUtil.safeAccess(data, accessor);

      // A non-required entry is missing so return without validation.
      if (!required && typeof dataEntry === 'undefined') { return true; }

      if (type && typeof dataEntry !== type)
      {
         if (error)
         {
            throw _validateError(TypeError, `'${dataName}.${accessor}' is not a '${type}'.`);
         }
         else
         {
            return false;
         }
      }

      if ((expected instanceof Set && !expected.has(dataEntry)) ||
       (Array.isArray(expected) && expected.indexOf(dataEntry) < 0))
      {
         if (error)
         {
            const dataEntryString = typeof dataEntry === 'object' ? JSON.stringify(dataEntry) : dataEntry;

            throw _validateError(Error, `'${dataName}.${accessor}': '${dataEntryString}' is not an expected value: ${
             JSON.stringify(expected)}.`);
         }
         else
         {
            return false;
         }
      }
      else if (typeof expected === 'function')
      {
         try
         {
            const result = expected(dataEntry);

            if (typeof result === 'undefined' || !result) { throw new Error(message); }
         }
         catch (err)
         {
            if (error)
            {
               const dataEntryString = typeof dataEntry === 'object' ? JSON.stringify(dataEntry) : dataEntry;

               throw _validateError(Error, `'${dataName}.${accessor}': '${dataEntryString}' failed to validate: ${
                err.message}.`);
            }
            else
            {
               return false;
            }
         }
      }

      return true;
   }

   /**
    * Dispatches validation of data entry to string or array validation depending on data entry type.
    *
    * @param {object}            data - The data object to test.
    *
    * @param {string}            accessor - A string describing the entries to access.
    *
    * @param {ValidationEntry}   [entry] - A validation entry.
    *
    * @param {string}            [dataName='data'] - Optional name of data.
    *
    * @returns {boolean} True if validation passes otherwise an exception is thrown.
    */
   static validateEntryOrArray(data, accessor, entry, dataName = 'data')
   {
      const dataEntry = ObjectUtil.safeAccess(data, accessor);

      let result;

      if (Array.isArray(dataEntry))
      {
         result = ObjectUtil.validateArray(data, accessor, entry, dataName);
      }
      else
      {
         result = ObjectUtil.validateEntry(data, accessor, entry, dataName);
      }

      return result;
   }
}

/**
 * Wires up ObjectUtil on the plugin eventbus. The following event bindings are available:
 *
 * `typhonjs:object:util:depth:traverse`: Invokes `depthTraverse`.
 * `typhonjs:object:util:get:accessor:list`: Invokes `getAccessorList`.
 * `typhonjs:object:util:safe:access`: Invokes `safeAccess`.
 * `typhonjs:object:util:safe:equal`: Invokes `safeEqual`.
 * `typhonjs:object:util:safe:set`: Invokes `safeSet`.
 * `typhonjs:object:util:safe:set:all`: Invokes `safeSetAll`.
 * `typhonjs:object:util:validate`: Invokes `validate`.
 * `typhonjs:object:util:validate:array`: Invokes `validateArray`.
 * `typhonjs:object:util:validate:entry`: Invokes `validateEntry`.
 *
 * @param {PluginEvent} ev - The plugin event.
 * @ignore
 */
export function onPluginLoad(ev)
{
   const eventbus = ev.eventbus;

   eventbus.on('typhonjs:object:util:depth:traverse', ObjectUtil.depthTraverse, ObjectUtil);
   eventbus.on('typhonjs:object:util:get:accessor:list', ObjectUtil.getAccessorList, ObjectUtil);
   eventbus.on('typhonjs:object:util:safe:access', ObjectUtil.safeAccess, ObjectUtil);
   eventbus.on('typhonjs:object:util:safe:batch:set', ObjectUtil.safeBatchSet, ObjectUtil);
   eventbus.on('typhonjs:object:util:safe:equal', ObjectUtil.safeEqual, ObjectUtil);
   eventbus.on('typhonjs:object:util:safe:set', ObjectUtil.safeSet, ObjectUtil);
   eventbus.on('typhonjs:object:util:safe:set:all', ObjectUtil.safeSetAll, ObjectUtil);
   eventbus.on('typhonjs:object:util:validate', ObjectUtil.validate, ObjectUtil);
   eventbus.on('typhonjs:object:util:validate:array', ObjectUtil.validateArray, ObjectUtil);
   eventbus.on('typhonjs:object:util:validate:entry', ObjectUtil.validateEntry, ObjectUtil);
   eventbus.on('typhonjs:object:util:validate:entry|array', ObjectUtil.validateEntryOrArray, ObjectUtil);
}

// Module private ---------------------------------------------------------------------------------------------------

/**
 * Creates a new error of type `clazz` adding the field `_objectValidateError` set to true.
 *
 * @param {Error}    clazz - Error class to instantiate.
 *
 * @param {string}   message - An error message.
 *
 * @returns {*}
 * @ignore
 * @private
 */
function _validateError(clazz, message = void 0)
{
   const error = new clazz(message);
   error._objectValidateError = true;
   return error;
}

/**
 * Private implementation of depth traversal.
 *
 * @param {object|Array}   data - An object or array.
 *
 * @param {function}       func - A callback function to process leaf values in children arrays or object members.
 *
 * @param {boolean}        modify - If true then the result of the callback function is used to modify in place
 *                                  the given data.
 * @returns {*}
 * @ignore
 * @private
 */
function _depthTraverse(data, func, modify)
{
   if (modify)
   {
      if (Array.isArray(data))
      {
         for (let cntr = 0; cntr < data.length; cntr++)
         {
            data[cntr] = _depthTraverse(data[cntr], func, modify);
         }
      }
      else if (typeof data === 'object')
      {
         for (const key in data)
         {
            if (data.hasOwnProperty(key)) { data[key] = _depthTraverse(data[key], func, modify); }
         }
      }
      else
      {
         data = func(data);
      }
   }
   else
   {
      if (Array.isArray(data))
      {
         for (let cntr = 0; cntr < data.length; cntr++) { _depthTraverse(data[cntr], func, modify); }
      }
      else if (typeof data === 'object')
      {
         for (const key in data) { if (data.hasOwnProperty(key)) { _depthTraverse(data[key], func, modify); } }
      }
      else
      {
         func(data);
      }
   }

   return data;
}

/**
 * Private implementation of `getAccessorList`.
 *
 * @param {object}   data - An object to traverse.
 *
 * @returns {Array}
 * @ignore
 * @private
 */
function _getAccessorList(data)
{
   const accessors = [];

   for (const key in data)
   {
      if (data.hasOwnProperty(key))
      {
         if (typeof data[key] === 'object')
         {
            const childKeys = _getAccessorList(data[key]);

            childKeys.forEach((childKey) =>
            {
               accessors.push(Array.isArray(childKey) ? `${key}.${childKey.join('.')}` : `${key}.${childKey}`);
            });
         }
         else
         {
            accessors.push(key);
         }
      }
   }

   return accessors;
}