"use strict"; const util = require("util"); const range = require("range").range; const fromEntries = require("fromentries"); const { validateArguments, validateValue } = require("@validatem/core"); const isArray = require("@validatem/is-array"); const isPlainObject = require("@validatem/is-plain-object"); const isBoolean = require("@validatem/is-boolean"); const defaultTo = require("@validatem/default-to"); const removeNullishItems = require("@validatem/remove-nullish-items"); const virtualProperty = require("@validatem/virtual-property"); const wrapPath = require("@validatem/wrap-path"); /* NOTE: In some cases below, we explicitly check for `undefined` only, rather than for both `undefined` and `null`. This is to allow explicitly overriding existent values with `null` during a merge. */ // FIXME: Update API to provide a mergeValue function to custom merging functions, for creating combinators (like anyProperty) // FIXME: Prevent prototype pollution // FIXME: Figure out an ergonomic way to do nested array merging (ie. flattening) // FIXME: Figure out a better way to pass around the options, that doesn't 'leak' to custom functions // FIXME: Make sure to explain the purpose of this in the documentation (multi-step merging, like in zapdb) let DeleteValue = Symbol("DeleteValue"); function wrapValidationPath(basePathSegments, lastProperty, rules) { let combinedPath = basePathSegments.concat(virtualProperty(lastProperty)); return wrapPath(combinedPath, rules); } function mapToObject(items, mapper) { return fromEntries(items.map(mapper)); } function combineKeys(...objects) { let allKeys = new Set(); for (let object of objects) { for (let key of Object.keys(object)) { allKeys.add(key); } } return Array.from(allKeys); } let optionalArray = [ defaultTo([]), isArray ]; let optionalObject = [ defaultTo({}), isPlainObject ]; function mergeArray(subTemplate, aInput, bInput, path, options) { let aItems = validateValue(aInput, wrapValidationPath(path, "a", [ optionalArray ])); let bItems = validateValue(bInput, wrapValidationPath(path, "b", [ optionalArray ])); let valueRule = subTemplate[0]; if (valueRule == null) { /* No object merging rule specified, so just concatenate the items. */ return aItems.concat(bItems); } else { /* Object merging rule specified, so we should invoke that merging rule for each pair of objects. */ let itemCount = Math.max(aItems.length, bItems.length); return range(0, itemCount).map((i) => { return mergeValue(valueRule, aItems[i], bItems[i], path.concat([ i ]), options); }); } } function mergeObject(subTemplate, aInput, bInput, path, options) { let a = validateValue(aInput, wrapValidationPath(path, "a", [ optionalObject ])); let b = validateValue(bInput, wrapValidationPath(path, "b", [ optionalObject ])); let allKeys = combineKeys(a, b, subTemplate); // FIXME: Remove keys with an `undefined` value, also for items in array return mapToObject(allKeys, (key) => { let rule = subTemplate[key]; let value = mergeValue(rule, a[key], b[key], path.concat([ key ]), options); return [ key, value ]; }); } function mergeValue(rule, a, b, path, options) { let effectiveA = (a === DeleteValue) ? undefined : a; if (b === DeleteValue) { if (options.consumeDeleteNodes) { return undefined; } else { return DeleteValue; } } else if (effectiveA === undefined && b === undefined) { // Make sure we don't invoke mergeArray/mergeObject/etc. in this case, as that would result in newly-created arrays/objects, which can interfere with eg. explicit deletions // FIXME: Verify that this doesn't break *other* usecases return undefined; } else if (rule == null) { if (b !== undefined) { return b; } else { return a; } } else if (typeof rule === "function") { if (effectiveA === undefined) { return b; } else if (b === undefined) { return a; } else { return rule(effectiveA, b, path, options); // FIXME: Do we want to pass the original A to the function instead? } } else if (typeof rule === "object") { if (Array.isArray(rule)) { return mergeArray(rule, effectiveA, b, path, options); } else { return mergeObject(rule, effectiveA, b, path, options); } } else { throw new Error(`Unrecognized rule: ${util.inspect(rule)}`); } } module.exports = { DeleteValue: DeleteValue, // FIXME: Test this createMerger: function createMerger(_template, _options) { let [ template, options ] = validateArguments(arguments, { template: [], options: [ optionalObject, { consumeDeleteNodes: [ defaultTo(true), isBoolean ] }] }); return function merge(_items, _options) { let [ items, mergeOptions ] = validateArguments(arguments, { items: [ isArray, removeNullishItems ], options: { // FIXME: Deduplicate this with the options validation above consumeDeleteNodes: [ isBoolean ] } }); let effectiveOptions = Object.assign({}, options, mergeOptions); return items.slice(1).reduce((merged, item) => { return mergeValue(template, merged, item, [], effectiveOptions); }, items[0]); }; }, anyProperty: function (template) { /* Used for cases where an object is used like a key->value map */ return function merge(aInput, bInput, path, options) { let a = validateValue(aInput, wrapValidationPath(path, "a", [ optionalObject ])); let b = validateValue(bInput, wrapValidationPath(path, "b", [ optionalObject ])); let allKeys = combineKeys(a, b); return mapToObject(allKeys, (key) => { let value = mergeValue(template, a[key], b[key], path.concat([ key ]), options); return [ key, value ]; }); }; } };