shared/deepAssign.js

/**
 * @module shared/deepAssign
 */

/**
 * @typedef {'merge' | 'shallowMerge' | 'concat' | 'overwrite'} DeepAssignArrayMode
 * @enum {string}
 * @parent module:shared/deepAssign
 */

/**
 * @typedef {Object} DeepAssignOptions
 * @property {DeepAssignArrayMode} [arrayMode='merge']
 * Defines how array assignments should be handled.
 * @parent module:shared/deepAssign
 */

/**
 * A function that makes a deep merge (and copy) of a list of objects and/or arrays.
 *
 * @callback DeepAssignFn
 * @param {...*} targets  The objects to merge; if one of them is not an object nor an
 *                        array, it will be ignored.
 * @returns {Object | Array}
 * @parent module:shared/deepAssign
 */

/**
 * It allows for deep merge (and copy) of objects and arrays using native spread syntax.
 *
 * This class exists just to scope the different functionalities and options needed for
 * {@link DeepAssign#assign} method to work.
 *
 * @parent module:shared/deepAssign
 */
class DeepAssign {
  /**
   * @param {Partial<DeepAssignOptions>} options  Custom options for how
   *                                              {@link DeepAssign#assign}
   *                                              it's going to work.
   * @throws {Error} If `options.arrayMode` is not a valid {@link DeepAssignArrayMode}.
   */
  constructor(options = {}) {
    if (
      options.arrayMode &&
      !['merge', 'concat', 'overwrite', 'shallowMerge'].includes(options.arrayMode)
    ) {
      throw new Error(`Invalid array mode received: \`${options.arrayMode}\``);
    }
    /**
     * The options that define how {@link DeepAssign#assign} works.
     *
     * @type {DeepAssignOptions}
     * @access protected
     * @ignore
     */
    this._options = {
      arrayMode: 'merge',
      ...options,
    };
    /**
     * @ignore
     */
    this.assign = this.assign.bind(this);
  }
  /**
   * Makes a deep merge of a list of objects and/or arrays.
   *
   * @param {...*} targets  The objects to merge; if one of them is not an object nor an
   *                        array, it will be ignored.
   * @returns {Object | Array}
   */
  assign(...targets) {
    let result;
    if (targets.length) {
      result = targets
        .filter((target) => this._isValidItem(target))
        .reduce(
          (acc, target) =>
            acc === null
              ? this._resolveFromEmpty(target, true)
              : this._resolve(acc, target, true),
          null,
        );
    } else {
      result = {};
    }

    return result;
  }
  /**
   * The options that define how {@link DeepAssign#assign} works.
   *
   * @type {Readonly<DeepAssignOptions>}
   */
  get options() {
    return { ...this._options };
  }
  /**
   * Checks if an object is a plain `Object` and not an instance of some class.
   *
   * @param {*} obj  The object to validate.
   * @returns {boolean}
   * @access protected
   * @ignore
   */
  _isPlainObject(obj) {
    return obj !== null && Object.getPrototypeOf(obj).constructor.name === 'Object';
  }
  /**
   * Checks if an object can be used on a merge: only arrays and plain objects are
   * supported.
   *
   * @param {*} obj  The object to validate.
   * @returns {boolean}
   * @access protected
   * @ignore
   */
  _isValidItem(obj) {
    return Array.isArray(obj) || this._isPlainObject(obj);
  }
  /**
   * Merges two arrays into a new one. If the `concatArrays` option was set to `true` on
   * the constructor, the result will just be a concatenation with new references for the
   * items; but if the option was set to `false`, then the arrays will be merged over
   * their indexes.
   *
   * @param {Array}               source  The base array.
   * @param {Array}               target  The array that will be merged on top of
   *                                      `source`.
   * @param {DeepAssignArrayMode} mode    The assignment strategy.
   * @returns {Array}
   * @access protected
   * @ignore
   */
  _mergeArrays(source, target, mode) {
    let result;
    if (mode === 'concat') {
      result = [...source, ...target].map((targetItem) =>
        this._resolveFromEmpty(targetItem),
      );
    } else if (mode === 'overwrite') {
      result = target.slice().map((targetItem) => this._resolveFromEmpty(targetItem));
    } else if (mode === 'shallowMerge') {
      result = source.slice();
      target.forEach((targetItem, index) => {
        const resolved = this._resolveFromEmpty(targetItem);
        if (index < result.length) {
          result[index] = resolved;
        } else {
          result.push(this._resolveFromEmpty(targetItem));
        }
      });
    } else {
      result = source.slice();
      target.forEach((targetItem, index) => {
        if (index < result.length) {
          result[index] = this._resolve(result[index], targetItem);
        } else {
          result.push(this._resolveFromEmpty(targetItem));
        }
      });
    }

    return result;
  }
  /**
   * Merges two plain objects and their children.
   *
   * @param {Object} source  The base object.
   * @param {Object} target  The object which properties will be merged in top of
   *                         `source`.
   * @returns {Object}
   * @access protected
   * @ignore
   */
  _mergeObjects(source, target) {
    const keys = [...Object.getOwnPropertySymbols(target), ...Object.keys(target)];

    const subMerge = keys.reduce(
      (acc, key) => ({
        ...acc,
        [key]: this._resolve(source[key], target[key]),
      }),
      {},
    );

    return { ...source, ...target, ...subMerge };
  }
  /**
   * This is the method the class calls when it has to merge two objects and it doesn't
   * know which types they are; the method takes care of validating compatibility and
   * calling either {@link DeepAssign#_mergeObjects} or {@link DeepAssign#_mergeArrays}.
   * If the objects are not compatible, or `source` is not defined, it will return a copy
   * of `target`.
   *
   * @param {*}       source                   The base object.
   * @param {*}       target                   The object that will be merged in top of
   *                                           `source`.
   * @param {boolean} [ignoreArrayMode=false]  Whether or not to ignore the option that
   *                                           tells the class how array assignments
   *                                           should be handled. This parameter exists
   *                                           because, when called directly from
   *                                           {@link DeepAssign#_assign}, it doesn't make
   *                                           sense to use a strategy different than
   *                                           'merge'.
   * @returns {*}
   * @access protected
   * @ignore
   */
  _resolve(source, target, ignoreArrayMode = false) {
    let result;
    const targetIsUndefined = typeof target === 'undefined';
    const sourceIsUndefined = typeof source === 'undefined';
    if (!targetIsUndefined && !sourceIsUndefined) {
      if (Array.isArray(target) && Array.isArray(source)) {
        const { arrayMode } = this._options;
        const useMode =
          ignoreArrayMode && !['merge', 'shallowMerge'].includes(arrayMode)
            ? 'merge'
            : arrayMode;
        result = this._mergeArrays(source, target, useMode);
      } else if (this._isPlainObject(target) && this._isPlainObject(source)) {
        result = this._mergeObjects(source, target);
      } else {
        result = target;
      }
    } else if (!targetIsUndefined) {
      result = this._resolveFromEmpty(target);
    }

    return result;
  }
  /**
   * This method is a helper for {@link DeepAssign#_resolve}, and it's used for when the
   * class has the `target` but not the `source`: depending on the type of the `target`,
   * it calls resolves with an empty object of the same type; if the `target` can't be
   * merged, it just returns it as it was received, which means that is a type that
   * doesn't hold references.
   *
   * @param {*}       target                   The target to copy.
   * @param {boolean} [ignoreArrayMode=false]  Whether or not to ignore the option that
   *                                           tells the class how array assignments
   *                                           should be handled. This parameter exists
   *                                           because, when called directly from
   *                                           {@link DeepAssign#_assign}, it doesn't make
   *                                           sense to use a strategy different than
   *                                           'merge'.
   * @returns {*}
   * @access protected
   * @ignore
   */
  _resolveFromEmpty(target, ignoreArrayMode = false) {
    let result;
    if (Array.isArray(target)) {
      result = this._resolve([], target, ignoreArrayMode);
    } else if (this._isPlainObject(target)) {
      result = this._resolve({}, target);
    } else {
      result = target;
    }

    return result;
  }
}

/**
 * Shortcut method for `new DeepAssign().assign(...)`.
 *
 * @type {DeepAssignFn}
 * @see {@link DeepAssign#assign} .
 */
const deepAssign = new DeepAssign().assign;
/**
 * Shortcut method for `new DeepAssign({ arrayMode: 'concat' }).assign(...)`.
 *
 * @type {DeepAssignFn}
 * @see {@link DeepAssign#assign} .
 */
const deepAssignWithConcat = new DeepAssign({ arrayMode: 'concat' }).assign;
/**
 * Shortcut method for `new DeepAssign({ arrayMode: 'overwrite' }).assign(...)`.
 *
 * @type {DeepAssignFn}
 * @see {@link DeepAssign#assign} .
 */
const deepAssignWithOverwrite = new DeepAssign({ arrayMode: 'overwrite' }).assign;
/**
 * Shortcut method for `new DeepAssign({ arrayMode: 'shallowMerge' }).assign(...)`.
 *
 * @type {DeepAssignFn}
 * @see {@link DeepAssign#assign} .
 */
const deepAssignWithShallowMerge = new DeepAssign({ arrayMode: 'shallowMerge' }).assign;

module.exports.DeepAssign = DeepAssign;
module.exports.deepAssign = deepAssign;
module.exports.deepAssignWithConcat = deepAssignWithConcat;
module.exports.deepAssignWithOverwrite = deepAssignWithOverwrite;
module.exports.deepAssignWithShallowMerge = deepAssignWithShallowMerge;