You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
568 lines
16 KiB
568 lines
16 KiB
/* |
|
MIT License http://www.opensource.org/licenses/mit-license.php |
|
Author Tobias Koppers @sokra |
|
*/ |
|
|
|
"use strict"; |
|
|
|
/** @type {WeakMap<object, WeakMap<object, object>>} */ |
|
const mergeCache = new WeakMap(); |
|
/** @type {WeakMap<object, Map<string, Map<string|number|boolean, object>>>} */ |
|
const setPropertyCache = new WeakMap(); |
|
const DELETE = Symbol("DELETE"); |
|
const DYNAMIC_INFO = Symbol("cleverMerge dynamic info"); |
|
|
|
/** |
|
* Merges two given objects and caches the result to avoid computation if same objects passed as arguments again. |
|
* @template T |
|
* @template O |
|
* @example |
|
* // performs cleverMerge(first, second), stores the result in WeakMap and returns result |
|
* cachedCleverMerge({a: 1}, {a: 2}) |
|
* {a: 2} |
|
* // when same arguments passed, gets the result from WeakMap and returns it. |
|
* cachedCleverMerge({a: 1}, {a: 2}) |
|
* {a: 2} |
|
* @param {T} first first object |
|
* @param {O} second second object |
|
* @returns {T & O | T | O} merged object of first and second object |
|
*/ |
|
const cachedCleverMerge = (first, second) => { |
|
if (second === undefined) return first; |
|
if (first === undefined) return second; |
|
if (typeof second !== "object" || second === null) return second; |
|
if (typeof first !== "object" || first === null) return first; |
|
|
|
let innerCache = mergeCache.get(first); |
|
if (innerCache === undefined) { |
|
innerCache = new WeakMap(); |
|
mergeCache.set(first, innerCache); |
|
} |
|
const prevMerge = innerCache.get(second); |
|
if (prevMerge !== undefined) return prevMerge; |
|
const newMerge = _cleverMerge(first, second, true); |
|
innerCache.set(second, newMerge); |
|
return newMerge; |
|
}; |
|
|
|
/** |
|
* @template T |
|
* @param {Partial<T>} obj object |
|
* @param {string} property property |
|
* @param {string|number|boolean} value assignment value |
|
* @returns {T} new object |
|
*/ |
|
const cachedSetProperty = (obj, property, value) => { |
|
let mapByProperty = setPropertyCache.get(obj); |
|
|
|
if (mapByProperty === undefined) { |
|
mapByProperty = new Map(); |
|
setPropertyCache.set(obj, mapByProperty); |
|
} |
|
|
|
let mapByValue = mapByProperty.get(property); |
|
|
|
if (mapByValue === undefined) { |
|
mapByValue = new Map(); |
|
mapByProperty.set(property, mapByValue); |
|
} |
|
|
|
let result = mapByValue.get(value); |
|
|
|
if (result) return result; |
|
|
|
result = { |
|
...obj, |
|
[property]: value |
|
}; |
|
mapByValue.set(value, result); |
|
|
|
return result; |
|
}; |
|
|
|
/** |
|
* @typedef {Object} ObjectParsedPropertyEntry |
|
* @property {any | undefined} base base value |
|
* @property {string | undefined} byProperty the name of the selector property |
|
* @property {Map<string, any>} byValues value depending on selector property, merged with base |
|
*/ |
|
|
|
/** |
|
* @typedef {Object} ParsedObject |
|
* @property {Map<string, ObjectParsedPropertyEntry>} static static properties (key is property name) |
|
* @property {{ byProperty: string, fn: Function } | undefined} dynamic dynamic part |
|
*/ |
|
|
|
/** @type {WeakMap<object, ParsedObject>} */ |
|
const parseCache = new WeakMap(); |
|
|
|
/** |
|
* @param {object} obj the object |
|
* @returns {ParsedObject} parsed object |
|
*/ |
|
const cachedParseObject = obj => { |
|
const entry = parseCache.get(obj); |
|
if (entry !== undefined) return entry; |
|
const result = parseObject(obj); |
|
parseCache.set(obj, result); |
|
return result; |
|
}; |
|
|
|
/** |
|
* @param {object} obj the object |
|
* @returns {ParsedObject} parsed object |
|
*/ |
|
const parseObject = obj => { |
|
const info = new Map(); |
|
let dynamicInfo; |
|
const getInfo = p => { |
|
const entry = info.get(p); |
|
if (entry !== undefined) return entry; |
|
const newEntry = { |
|
base: undefined, |
|
byProperty: undefined, |
|
byValues: undefined |
|
}; |
|
info.set(p, newEntry); |
|
return newEntry; |
|
}; |
|
for (const key of Object.keys(obj)) { |
|
if (key.startsWith("by")) { |
|
const byProperty = key; |
|
const byObj = obj[byProperty]; |
|
if (typeof byObj === "object") { |
|
for (const byValue of Object.keys(byObj)) { |
|
const obj = byObj[byValue]; |
|
for (const key of Object.keys(obj)) { |
|
const entry = getInfo(key); |
|
if (entry.byProperty === undefined) { |
|
entry.byProperty = byProperty; |
|
entry.byValues = new Map(); |
|
} else if (entry.byProperty !== byProperty) { |
|
throw new Error( |
|
`${byProperty} and ${entry.byProperty} for a single property is not supported` |
|
); |
|
} |
|
entry.byValues.set(byValue, obj[key]); |
|
if (byValue === "default") { |
|
for (const otherByValue of Object.keys(byObj)) { |
|
if (!entry.byValues.has(otherByValue)) |
|
entry.byValues.set(otherByValue, undefined); |
|
} |
|
} |
|
} |
|
} |
|
} else if (typeof byObj === "function") { |
|
if (dynamicInfo === undefined) { |
|
dynamicInfo = { |
|
byProperty: key, |
|
fn: byObj |
|
}; |
|
} else { |
|
throw new Error( |
|
`${key} and ${dynamicInfo.byProperty} when both are functions is not supported` |
|
); |
|
} |
|
} else { |
|
const entry = getInfo(key); |
|
entry.base = obj[key]; |
|
} |
|
} else { |
|
const entry = getInfo(key); |
|
entry.base = obj[key]; |
|
} |
|
} |
|
return { |
|
static: info, |
|
dynamic: dynamicInfo |
|
}; |
|
}; |
|
|
|
/** |
|
* @param {Map<string, ObjectParsedPropertyEntry>} info static properties (key is property name) |
|
* @param {{ byProperty: string, fn: Function } | undefined} dynamicInfo dynamic part |
|
* @returns {object} the object |
|
*/ |
|
const serializeObject = (info, dynamicInfo) => { |
|
const obj = {}; |
|
// Setup byProperty structure |
|
for (const entry of info.values()) { |
|
if (entry.byProperty !== undefined) { |
|
const byObj = (obj[entry.byProperty] = obj[entry.byProperty] || {}); |
|
for (const byValue of entry.byValues.keys()) { |
|
byObj[byValue] = byObj[byValue] || {}; |
|
} |
|
} |
|
} |
|
for (const [key, entry] of info) { |
|
if (entry.base !== undefined) { |
|
obj[key] = entry.base; |
|
} |
|
// Fill byProperty structure |
|
if (entry.byProperty !== undefined) { |
|
const byObj = (obj[entry.byProperty] = obj[entry.byProperty] || {}); |
|
for (const byValue of Object.keys(byObj)) { |
|
const value = getFromByValues(entry.byValues, byValue); |
|
if (value !== undefined) byObj[byValue][key] = value; |
|
} |
|
} |
|
} |
|
if (dynamicInfo !== undefined) { |
|
obj[dynamicInfo.byProperty] = dynamicInfo.fn; |
|
} |
|
return obj; |
|
}; |
|
|
|
const VALUE_TYPE_UNDEFINED = 0; |
|
const VALUE_TYPE_ATOM = 1; |
|
const VALUE_TYPE_ARRAY_EXTEND = 2; |
|
const VALUE_TYPE_OBJECT = 3; |
|
const VALUE_TYPE_DELETE = 4; |
|
|
|
/** |
|
* @param {any} value a single value |
|
* @returns {VALUE_TYPE_UNDEFINED | VALUE_TYPE_ATOM | VALUE_TYPE_ARRAY_EXTEND | VALUE_TYPE_OBJECT | VALUE_TYPE_DELETE} value type |
|
*/ |
|
const getValueType = value => { |
|
if (value === undefined) { |
|
return VALUE_TYPE_UNDEFINED; |
|
} else if (value === DELETE) { |
|
return VALUE_TYPE_DELETE; |
|
} else if (Array.isArray(value)) { |
|
if (value.lastIndexOf("...") !== -1) return VALUE_TYPE_ARRAY_EXTEND; |
|
return VALUE_TYPE_ATOM; |
|
} else if ( |
|
typeof value === "object" && |
|
value !== null && |
|
(!value.constructor || value.constructor === Object) |
|
) { |
|
return VALUE_TYPE_OBJECT; |
|
} |
|
return VALUE_TYPE_ATOM; |
|
}; |
|
|
|
/** |
|
* Merges two objects. Objects are deeply clever merged. |
|
* Arrays might reference the old value with "...". |
|
* Non-object values take preference over object values. |
|
* @template T |
|
* @template O |
|
* @param {T} first first object |
|
* @param {O} second second object |
|
* @returns {T & O | T | O} merged object of first and second object |
|
*/ |
|
const cleverMerge = (first, second) => { |
|
if (second === undefined) return first; |
|
if (first === undefined) return second; |
|
if (typeof second !== "object" || second === null) return second; |
|
if (typeof first !== "object" || first === null) return first; |
|
|
|
return _cleverMerge(first, second, false); |
|
}; |
|
|
|
/** |
|
* Merges two objects. Objects are deeply clever merged. |
|
* @param {object} first first object |
|
* @param {object} second second object |
|
* @param {boolean} internalCaching should parsing of objects and nested merges be cached |
|
* @returns {object} merged object of first and second object |
|
*/ |
|
const _cleverMerge = (first, second, internalCaching = false) => { |
|
const firstObject = internalCaching |
|
? cachedParseObject(first) |
|
: parseObject(first); |
|
const { static: firstInfo, dynamic: firstDynamicInfo } = firstObject; |
|
|
|
// If the first argument has a dynamic part we modify the dynamic part to merge the second argument |
|
if (firstDynamicInfo !== undefined) { |
|
let { byProperty, fn } = firstDynamicInfo; |
|
const fnInfo = fn[DYNAMIC_INFO]; |
|
if (fnInfo) { |
|
second = internalCaching |
|
? cachedCleverMerge(fnInfo[1], second) |
|
: cleverMerge(fnInfo[1], second); |
|
fn = fnInfo[0]; |
|
} |
|
const newFn = (...args) => { |
|
const fnResult = fn(...args); |
|
return internalCaching |
|
? cachedCleverMerge(fnResult, second) |
|
: cleverMerge(fnResult, second); |
|
}; |
|
newFn[DYNAMIC_INFO] = [fn, second]; |
|
return serializeObject(firstObject.static, { byProperty, fn: newFn }); |
|
} |
|
|
|
// If the first part is static only, we merge the static parts and keep the dynamic part of the second argument |
|
const secondObject = internalCaching |
|
? cachedParseObject(second) |
|
: parseObject(second); |
|
const { static: secondInfo, dynamic: secondDynamicInfo } = secondObject; |
|
/** @type {Map<string, ObjectParsedPropertyEntry>} */ |
|
const resultInfo = new Map(); |
|
for (const [key, firstEntry] of firstInfo) { |
|
const secondEntry = secondInfo.get(key); |
|
const entry = |
|
secondEntry !== undefined |
|
? mergeEntries(firstEntry, secondEntry, internalCaching) |
|
: firstEntry; |
|
resultInfo.set(key, entry); |
|
} |
|
for (const [key, secondEntry] of secondInfo) { |
|
if (!firstInfo.has(key)) { |
|
resultInfo.set(key, secondEntry); |
|
} |
|
} |
|
return serializeObject(resultInfo, secondDynamicInfo); |
|
}; |
|
|
|
/** |
|
* @param {ObjectParsedPropertyEntry} firstEntry a |
|
* @param {ObjectParsedPropertyEntry} secondEntry b |
|
* @param {boolean} internalCaching should parsing of objects and nested merges be cached |
|
* @returns {ObjectParsedPropertyEntry} new entry |
|
*/ |
|
const mergeEntries = (firstEntry, secondEntry, internalCaching) => { |
|
switch (getValueType(secondEntry.base)) { |
|
case VALUE_TYPE_ATOM: |
|
case VALUE_TYPE_DELETE: |
|
// No need to consider firstEntry at all |
|
// second value override everything |
|
// = second.base + second.byProperty |
|
return secondEntry; |
|
case VALUE_TYPE_UNDEFINED: |
|
if (!firstEntry.byProperty) { |
|
// = first.base + second.byProperty |
|
return { |
|
base: firstEntry.base, |
|
byProperty: secondEntry.byProperty, |
|
byValues: secondEntry.byValues |
|
}; |
|
} else if (firstEntry.byProperty !== secondEntry.byProperty) { |
|
throw new Error( |
|
`${firstEntry.byProperty} and ${secondEntry.byProperty} for a single property is not supported` |
|
); |
|
} else { |
|
// = first.base + (first.byProperty + second.byProperty) |
|
// need to merge first and second byValues |
|
const newByValues = new Map(firstEntry.byValues); |
|
for (const [key, value] of secondEntry.byValues) { |
|
const firstValue = getFromByValues(firstEntry.byValues, key); |
|
newByValues.set( |
|
key, |
|
mergeSingleValue(firstValue, value, internalCaching) |
|
); |
|
} |
|
return { |
|
base: firstEntry.base, |
|
byProperty: firstEntry.byProperty, |
|
byValues: newByValues |
|
}; |
|
} |
|
default: { |
|
if (!firstEntry.byProperty) { |
|
// The simple case |
|
// = (first.base + second.base) + second.byProperty |
|
return { |
|
base: mergeSingleValue( |
|
firstEntry.base, |
|
secondEntry.base, |
|
internalCaching |
|
), |
|
byProperty: secondEntry.byProperty, |
|
byValues: secondEntry.byValues |
|
}; |
|
} |
|
let newBase; |
|
const intermediateByValues = new Map(firstEntry.byValues); |
|
for (const [key, value] of intermediateByValues) { |
|
intermediateByValues.set( |
|
key, |
|
mergeSingleValue(value, secondEntry.base, internalCaching) |
|
); |
|
} |
|
if ( |
|
Array.from(firstEntry.byValues.values()).every(value => { |
|
const type = getValueType(value); |
|
return type === VALUE_TYPE_ATOM || type === VALUE_TYPE_DELETE; |
|
}) |
|
) { |
|
// = (first.base + second.base) + ((first.byProperty + second.base) + second.byProperty) |
|
newBase = mergeSingleValue( |
|
firstEntry.base, |
|
secondEntry.base, |
|
internalCaching |
|
); |
|
} else { |
|
// = first.base + ((first.byProperty (+default) + second.base) + second.byProperty) |
|
newBase = firstEntry.base; |
|
if (!intermediateByValues.has("default")) |
|
intermediateByValues.set("default", secondEntry.base); |
|
} |
|
if (!secondEntry.byProperty) { |
|
// = first.base + (first.byProperty + second.base) |
|
return { |
|
base: newBase, |
|
byProperty: firstEntry.byProperty, |
|
byValues: intermediateByValues |
|
}; |
|
} else if (firstEntry.byProperty !== secondEntry.byProperty) { |
|
throw new Error( |
|
`${firstEntry.byProperty} and ${secondEntry.byProperty} for a single property is not supported` |
|
); |
|
} |
|
const newByValues = new Map(intermediateByValues); |
|
for (const [key, value] of secondEntry.byValues) { |
|
const firstValue = getFromByValues(intermediateByValues, key); |
|
newByValues.set( |
|
key, |
|
mergeSingleValue(firstValue, value, internalCaching) |
|
); |
|
} |
|
return { |
|
base: newBase, |
|
byProperty: firstEntry.byProperty, |
|
byValues: newByValues |
|
}; |
|
} |
|
} |
|
}; |
|
|
|
/** |
|
* @param {Map<string, any>} byValues all values |
|
* @param {string} key value of the selector |
|
* @returns {any | undefined} value |
|
*/ |
|
const getFromByValues = (byValues, key) => { |
|
if (key !== "default" && byValues.has(key)) { |
|
return byValues.get(key); |
|
} |
|
return byValues.get("default"); |
|
}; |
|
|
|
/** |
|
* @param {any} a value |
|
* @param {any} b value |
|
* @param {boolean} internalCaching should parsing of objects and nested merges be cached |
|
* @returns {any} value |
|
*/ |
|
const mergeSingleValue = (a, b, internalCaching) => { |
|
const bType = getValueType(b); |
|
const aType = getValueType(a); |
|
switch (bType) { |
|
case VALUE_TYPE_DELETE: |
|
case VALUE_TYPE_ATOM: |
|
return b; |
|
case VALUE_TYPE_OBJECT: { |
|
return aType !== VALUE_TYPE_OBJECT |
|
? b |
|
: internalCaching |
|
? cachedCleverMerge(a, b) |
|
: cleverMerge(a, b); |
|
} |
|
case VALUE_TYPE_UNDEFINED: |
|
return a; |
|
case VALUE_TYPE_ARRAY_EXTEND: |
|
switch ( |
|
aType !== VALUE_TYPE_ATOM |
|
? aType |
|
: Array.isArray(a) |
|
? VALUE_TYPE_ARRAY_EXTEND |
|
: VALUE_TYPE_OBJECT |
|
) { |
|
case VALUE_TYPE_UNDEFINED: |
|
return b; |
|
case VALUE_TYPE_DELETE: |
|
return b.filter(item => item !== "..."); |
|
case VALUE_TYPE_ARRAY_EXTEND: { |
|
const newArray = []; |
|
for (const item of b) { |
|
if (item === "...") { |
|
for (const item of a) { |
|
newArray.push(item); |
|
} |
|
} else { |
|
newArray.push(item); |
|
} |
|
} |
|
return newArray; |
|
} |
|
case VALUE_TYPE_OBJECT: |
|
return b.map(item => (item === "..." ? a : item)); |
|
default: |
|
throw new Error("Not implemented"); |
|
} |
|
default: |
|
throw new Error("Not implemented"); |
|
} |
|
}; |
|
|
|
/** |
|
* @template T |
|
* @param {T} obj the object |
|
* @returns {T} the object without operations like "..." or DELETE |
|
*/ |
|
const removeOperations = obj => { |
|
const newObj = /** @type {T} */ ({}); |
|
for (const key of Object.keys(obj)) { |
|
const value = obj[key]; |
|
const type = getValueType(value); |
|
switch (type) { |
|
case VALUE_TYPE_UNDEFINED: |
|
case VALUE_TYPE_DELETE: |
|
break; |
|
case VALUE_TYPE_OBJECT: |
|
newObj[key] = removeOperations(value); |
|
break; |
|
case VALUE_TYPE_ARRAY_EXTEND: |
|
newObj[key] = value.filter(i => i !== "..."); |
|
break; |
|
default: |
|
newObj[key] = value; |
|
break; |
|
} |
|
} |
|
return newObj; |
|
}; |
|
|
|
/** |
|
* @template T |
|
* @template {string} P |
|
* @param {T} obj the object |
|
* @param {P} byProperty the by description |
|
* @param {...any} values values |
|
* @returns {Omit<T, P>} object with merged byProperty |
|
*/ |
|
const resolveByProperty = (obj, byProperty, ...values) => { |
|
if (typeof obj !== "object" || obj === null || !(byProperty in obj)) { |
|
return obj; |
|
} |
|
const { [byProperty]: _byValue, ..._remaining } = /** @type {object} */ (obj); |
|
const remaining = /** @type {T} */ (_remaining); |
|
const byValue = /** @type {Record<string, T> | function(...any[]): T} */ ( |
|
_byValue |
|
); |
|
if (typeof byValue === "object") { |
|
const key = values[0]; |
|
if (key in byValue) { |
|
return cachedCleverMerge(remaining, byValue[key]); |
|
} else if ("default" in byValue) { |
|
return cachedCleverMerge(remaining, byValue.default); |
|
} else { |
|
return /** @type {T} */ (remaining); |
|
} |
|
} else if (typeof byValue === "function") { |
|
const result = byValue.apply(null, values); |
|
return cachedCleverMerge( |
|
remaining, |
|
resolveByProperty(result, byProperty, ...values) |
|
); |
|
} |
|
}; |
|
|
|
exports.cachedSetProperty = cachedSetProperty; |
|
exports.cachedCleverMerge = cachedCleverMerge; |
|
exports.cleverMerge = cleverMerge; |
|
exports.resolveByProperty = resolveByProperty; |
|
exports.removeOperations = removeOperations; |
|
exports.DELETE = DELETE;
|
|
|