packages/libs/common/src/merging/merging.ts (170 lines of code) (raw):
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
/* eslint-disable @typescript-eslint/no-use-before-define */
/* eslint-disable no-prototype-builtins */
import { JsonPath, PathMapping } from "@azure-tools/datastore";
import { walk } from "@azure-tools/json";
import { Stringify, YamlNode, walkYamlAst } from "@azure-tools/yaml";
/**
* Merge a and b by adding new properties of b into a. It will fail if a and b have the same property and the value is different.
* @param a Object 1 to merge
* @param b Object 2 to merge
* @param path current path of the merge.
*/
export function strictMerge(a: any, b: any, path: JsonPath = []): any {
if (a === null || b === null) {
throw new Error(`Argument cannot be null ('${Stringify(path)}')`);
}
// trivial case
if (a === b || JSON.stringify(a) === JSON.stringify(b)) {
return a;
}
// mapping nodes
if (typeof a === "object" && typeof b === "object") {
if (a instanceof Array && b instanceof Array) {
if (a.length === 0) {
return b;
}
if (b.length === 0) {
return a;
}
// both sides gave a sequence, and they are not identical.
// this is currently not a good thing.
throw new Error(`'${Stringify(path)}' has two arrays that are incompatible (${Stringify(a)}, ${Stringify(b)}).`);
} else {
// object nodes - iterate all members
const result: any = {};
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
for (const key of keys) {
const subpath = path.concat(key);
// forward if only present in one of the nodes
if (a[key] === undefined) {
result[key] = b[key];
continue;
}
if (b[key] === undefined) {
result[key] = a[key];
continue;
}
// try merge objects otherwise
const aMember = a[key];
const bMember = b[key];
result[key] = strictMerge(aMember, bMember, subpath);
}
return result;
}
}
throw new Error(`'${Stringify(path)}' has incompatible values (${Stringify(a)}, ${Stringify(b)}).`);
}
// Note: I am not convinced this works precisely as it should
// but it works well enough for my needs right now
// I will revisit it later.
const macroRegEx = () => /\$\(([a-zA-Z0-9_-]*)\)/gi;
/**
* Resolve the expanded value by interpolating any
* @param value Value to interpolate.
* @param propertyName Name of the property.
* @param higherPriority Higher priority context to resolve the interpolation values.
* @param lowerPriority Lower priority context to resolve the interpolation values.
* @param jsAware
*/
export function resolveRValue(
value: any,
propertyName: string,
higherPriority: any,
lowerPriority: any,
jsAware = 0,
): any {
if (value) {
// resolves the actual macro value.
const resolve = (macroExpression: string, macroKey: string) => {
// if the original set has it, use that.
if (higherPriority && higherPriority[macroKey]) {
return resolveRValue(higherPriority[macroKey], macroKey, lowerPriority, null, jsAware - 1);
}
if (lowerPriority) {
// check to see if the value is in the overrides set before the key itself.
const keys = Object.getOwnPropertyNames(lowerPriority);
const macroKeyLocation = keys.indexOf(macroKey);
if (macroKeyLocation > -1) {
if (macroKeyLocation < keys.indexOf(propertyName)) {
// the macroKey is in the overrides, and it precedes the propertyName itself
return resolveRValue(lowerPriority[macroKey], macroKey, higherPriority, lowerPriority, jsAware - 1);
}
}
}
// can't find the macro. maybe later.
return macroExpression;
};
// resolve the macro value for strings
if (typeof value === "string") {
const match = macroRegEx().exec(value.trim());
if (match) {
if (match[0] === match.input) {
// the target value should be the result without string twiddling
if (jsAware > 0) {
return JSON.stringify(resolve(match[0], match[1]));
}
return resolve(match[0], match[1]);
}
// it looks like we should do a string replace.
return value.replace(macroRegEx(), resolve);
}
}
// resolve macro values for array values
if (value instanceof Array) {
// since we're not naming the parameter,
// if there isn't a higher priority,
// we can fall back to a wide-lookup in lowerPriority.
return value.map((x) => resolveRValue(x, "", higherPriority || lowerPriority, null));
}
}
if (jsAware > 0) {
return JSON.stringify(value);
}
return value;
}
export type ArrayMergingStrategy = "high-pri-first" | "low-pri-first";
export interface MergeOptions {
interpolationContext?: any;
arrayMergeStrategy?: ArrayMergingStrategy;
concatListPathFilter?: (path: JsonPath) => boolean;
}
const defaultOptions: Omit<Required<MergeOptions>, "interpolationContext"> = {
arrayMergeStrategy: "high-pri-first",
concatListPathFilter: () => false,
};
export function mergeOverwriteOrAppend(
higherPriority: any,
lowerPriority: any,
options: MergeOptions = {},
path: JsonPath = [],
): any {
if (higherPriority === null || lowerPriority === null) {
return null;
}
const computedOptions = {
...defaultOptions,
...options,
interpolationContext: options.interpolationContext ?? higherPriority,
};
// if (higherPriority === true && typeof lowerPriority.extensions) {
// console.log("Merge", higherPriority, lowerPriority);
// }
// Take care of the case where an option is enable via a flag `--az` and then nested config under it don't work(az.extensions)
if (higherPriority === true && typeof lowerPriority === "object") {
return lowerPriority;
}
// scalars/arrays involved
if (
typeof higherPriority !== "object" ||
higherPriority instanceof Array ||
typeof lowerPriority !== "object" ||
lowerPriority instanceof Array
) {
return mergeArray(higherPriority, lowerPriority, path, computedOptions);
}
// object nodes - iterate all members
const result: any = {};
const keys = getKeysInOrder(higherPriority, lowerPriority, computedOptions);
for (const key of keys) {
const subpath = path.concat(key);
// forward if only present in one of the nodes
if (higherPriority[key] === undefined) {
result[key] = resolveRValue(lowerPriority[key], key, computedOptions.interpolationContext, lowerPriority);
continue;
}
if (lowerPriority[key] === undefined) {
result[key] = resolveRValue(higherPriority[key], key, null, computedOptions.interpolationContext);
continue;
}
// try merge objects otherwise
const aMember = resolveRValue(higherPriority[key], key, lowerPriority, computedOptions.interpolationContext);
const bMember = resolveRValue(lowerPriority[key], key, computedOptions.interpolationContext, lowerPriority);
result[key] = mergeOverwriteOrAppend(
aMember,
bMember,
{ ...computedOptions, interpolationContext: computedOptions.interpolationContext[key] },
subpath,
);
}
return result;
}
/**
*
* @param higherPriority Higher priority object
* @param lowerPriority Lower priority object
* @param options Merge options.
* @returns List of unique keys used in both object in the order defined in the options.
*/
function getKeysInOrder(higherPriority: any, lowerPriority: any, options: MergeOptions): string[] {
const lowPriKeys = Object.getOwnPropertyNames(lowerPriority);
const highPriKeys = Object.getOwnPropertyNames(higherPriority);
return [
...new Set(
options.arrayMergeStrategy === "low-pri-first" ? lowPriKeys.concat(highPriKeys) : highPriKeys.concat(lowPriKeys),
),
];
}
function mergeArray(
higherPriority: unknown,
lowerPriority: unknown,
path: JsonPath,
{ concatListPathFilter, arrayMergeStrategy }: Required<MergeOptions>,
) {
if (!(higherPriority instanceof Array) && !(lowerPriority instanceof Array) && !concatListPathFilter(path)) {
return higherPriority;
}
const higherPriorityArray = higherPriority instanceof Array ? higherPriority : [higherPriority];
const lowerPriorityArray = lowerPriority instanceof Array ? lowerPriority : [lowerPriority];
if (arrayMergeStrategy === "high-pri-first") {
return [...new Set(higherPriorityArray.concat(lowerPriority))];
} else {
return [...new Set(lowerPriorityArray.concat(higherPriority))];
}
}