Source: management-system/src/shared-frontend-backend/helpers/javascriptHelpers.js

/**
 * Compares two values
 *
 * normal comparison for fundamental data types (number, string etc)
 * element wise comparison for objects and arrays
 * recursive handling for nested objects and arrays
 *
 * @param {Any} a some value
 * @param {Any} b some value
 * @returns {Boolean} - if the two values are equal
 */
function deepEquals(a, b) {
  // early exit if types don't match
  if (typeof a !== typeof b) {
    return false;
  }

  if (Array.isArray(a) && Array.isArray(b)) {
    if (a.length !== b.length) {
      return false;
    }

    let index = 0;
    // can't break early from forEach
    for (let value of a) {
      // recursively compare the values from both arrays
      if (!deepEquals(value, b[index])) {
        return false;
      }
      ++index;
    }

    return true;
  }

  // the values to compare are not arrays but might be objects
  if (typeof a === 'object' && a !== null && b !== null) {
    let aKeys = Object.keys(a);
    let bKeys = Object.keys(b);
    // objects can't be equal with differing keys
    if (aKeys.length !== bKeys.length || aKeys.some((key) => !bKeys.includes(key))) {
      return false;
    }

    for (let key of aKeys) {
      if (!deepEquals(a[key], b[key])) {
        return false;
      }
    }

    return true;
  }

  return a === b;
}

/**
 * A function that checks if an object or array contains only the entries contained in some other array or object
 *
 * the other object(|array) might contain additional entries
 *
 * (set doesn't mean that the arrays can contain a value only once in this case)
 *
 * @param {Object|Array} set the object or array we want to compare against
 * @param {Object|Array} candidate the object we want to check
 * @returns {Boolean} if the candidate contains only elements of the original object
 * @throws Will throw an error if the two given values are not of the same type or if they are not of the specified type (null is also not allowed)
 */
function isSubset(set, candidate) {
  // we want to only compare object typed values
  if (typeof set !== 'object' || typeof candidate !== 'object') {
    throw new Error(`Expected two objects but got ${typeof set} and ${typeof candidate}`);
  }
  // null has object type so we have to handle it seperately
  if (set === null || candidate === null) {
    throw new Error('Got illegal null value');
  }

  // we don't want to compare arrays with general object
  if (
    (Array.isArray(set) && !Array.isArray(candidate)) ||
    (!Array.isArray(set) && Array.isArray(candidate))
  ) {
    const typeString = (el) => (Array.isArray(el) ? 'array' : 'object');
    throw new Error(
      `Expected both to be either array or object but got ${typeString(set)} and ${typeString(
        candidate
      )}`
    );
  }

  // check if we have to handle an object or array
  if (Array.isArray(set)) {
    // early exit if the candidate is longer than the original set
    if (set.length < candidate.length) {
      return false;
    }

    let index = 0;
    for (let entry of candidate) {
      if (!deepEquals(entry, set[index])) {
        return false;
      }
      ++index;
    }
  } else {
    // check if all keys of the candidate occur in the original object with the same values
    const cKeys = Object.keys(candidate);

    for (let key of cKeys) {
      // check if the original object has the specific key
      if (!set.hasOwnProperty(key)) {
        return false;
      }

      if (!deepEquals(set[key], candidate[key])) {
        return false;
      }
    }

    return true;
  }

  return true;
}

function isObject(candidate) {
  return !!candidate && typeof candidate === 'object' && !Array.isArray(candidate);
}

/**
 * Function that allows overwriting entries in an object with values given in another object
 *
 * @param {Object} target the object to merge into
 * @param {Object} toMerge the object containing the new values
 * @param {Boolean} deepMerge if nested objects are supposed to be merged recursively (else they are just overwritten)
 * @param {Boolean|String} noNewValues flag to disallow new entries being added to the target object ('strict' for error, true for silent ignore)
 * @param {Boolean|String} typesafe if entries are not allowed to change their type ('strict' for error, true for silent ignore)
 * @returns {Object} object containing the values that were actually changed (some changes might be silently ignored due to flags)
 */
function mergeIntoObject(target, toMerge, deepMerge, noNewValues, typesafe) {
  if (!isObject(target)) {
    throw new Error('Tried to merge into something that is not an object');
  }

  if (!isObject(toMerge)) {
    throw new Error('Tried to merge something that is not an object');
  }

  const changedEntries = {};

  Object.entries(toMerge).forEach(([key, value]) => {
    // handle if adding entries is not allowed and target doesn't contain the current key
    if (noNewValues && !{}.propertyIsEnumerable.call(target, key)) {
      if (noNewValues === 'strict') {
        // throw in strict mode
        throw new Error('Tried to add new values to target object!');
      } else {
        // silently ignore
        return;
      }
    }

    // do nothing if the given key exists but its value has a different type from the one in the target and typesafe is true
    if (
      typesafe &&
      {}.propertyIsEnumerable.call(target, key) &&
      typeof value !== typeof target[key]
    ) {
      if (typesafe === 'strict') {
        throw new Error(`Tried changing the type of entry ${key}!`);
      } else {
        return;
      }
    }

    // recursively merge objects if flag is set
    if (deepMerge && isObject(value) && isObject(target[key])) {
      changedEntries[key] = mergeIntoObject(target[key], value, deepMerge, noNewValues, typesafe);
    } else {
      target[key] = value;
      changedEntries[key] = value;
    }
  });

  return changedEntries;
}

/**
 * A function called for all functions of an array
 * @callback asyncArrayCallback
 * @param {*} entry The current entry of the array
 * @param {Number} index The index of the current entry inside the array
 */

/**
 * Executes an async callback to map every entry in an array and will resolve with the results when all callbacks resolved
 *
 * @param {Array} array the entries which are supposed to be mapped
 * @param {asyncArrayCallback} cb the async mapping function
 */
async function asyncMap(array, cb) {
  const mappingCallbacks = array.map(async (entry, index) => await cb(entry, index));

  const mappedValues = await Promise.all(mappingCallbacks);

  return mappedValues;
}

/**
 * Executes an async callback for every entry in an array and will resolve when all callbacks resolved
 *
 * @param {Array} array the array for which the async forEach is supposed to be executed
 * @param {asyncArrayCallback} cb the function to execute for every entry
 */
async function asyncForEach(array, cb) {
  await asyncMap(array, cb);
}

module.exports = { deepEquals, isSubset, mergeIntoObject, asyncMap, asyncForEach };