You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

181 lines
6.1 KiB
JavaScript

"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
// TODO: Add an option for removing deleted keys (rather than just setting them to `undefined`)
// FIXME: Make sure to explain the purpose of this in the documentation (multi-step merging, like in zapdb)
let DeleteValue = Symbol("DeleteValue");
let Template = Symbol("Template");
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 (Template in rule) {
// This is another merge-by-template merger, the user is attempting to compose them together, so we will just apply the template directly
return mergeValue(rule[Template], a, b, path, options);
} else {
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
Recursive: function (mergeFunc) {
return {
__mergeByTemplateOperation: "recursive",
mergeFunc: mergeFunc
};
},
createMerger: function createMerger(_template, _options) {
let [ template, options ] = validateArguments(arguments, {
template: [],
options: [ optionalObject, {
consumeDeleteNodes: [ defaultTo(true), isBoolean ]
}]
});
let merger = 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]);
};
merger[Template] = template;
return merger;
},
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 ];
});
};
}
};