lib/apiScenario/diffUtils.ts (312 lines of code) (raw):
import { default as stableStringify } from "fast-json-stable-stringify";
import * as jsonPointer from "json-pointer";
import { cloneDeep } from "@azure-tools/openapi-tools-common";
import { SequenceMatcher } from "difflib";
import {
JsonPatchOp,
JsonPatchOpAdd,
JsonPatchOpCopy,
JsonPatchOpMove,
JsonPatchOpRemove,
JsonPatchOpReplace,
JsonPatchOpTest,
} from "./apiScenarioTypes";
interface PatchContext {
root: any;
obj: any;
propertyName: string;
arrIdx: number;
}
export interface DiffPatchOptions {
includeOldValue?: boolean;
minimizeDiff?: boolean;
}
const rootName = "ROOT";
const getCtx = (obj: any, path: string): PatchContext => {
if (path === "/") {
path = "";
}
const pathSegments = jsonPointer.parse(path);
pathSegments.unshift(rootName);
const propertyName = pathSegments.pop()!;
const target = jsonPointer.get(obj, jsonPointer.compile(pathSegments));
const result: PatchContext = {
root: obj,
obj: target,
propertyName,
arrIdx: -1,
};
if (Array.isArray(obj)) {
result.arrIdx = parseInt(propertyName);
}
return result;
};
const patchAdd = ({ obj, propertyName, arrIdx }: PatchContext, op: JsonPatchOpAdd) => {
if (Array.isArray(obj)) {
obj.splice(arrIdx, 0, op.value);
} else {
obj[propertyName] = op.value;
}
};
const patchRemove = ({ obj, propertyName, arrIdx }: PatchContext) => {
if (Array.isArray(obj)) {
obj.splice(arrIdx, 1);
} else {
delete obj[propertyName];
}
};
const patchReplace = ({ obj, propertyName }: PatchContext, op: JsonPatchOpReplace) => {
obj[propertyName] = op.value;
};
const patchCopy = ({ root, obj, propertyName }: PatchContext, op: JsonPatchOpCopy) => {
const val = cloneDeep(obj[propertyName]);
jsonPointer.set(root, `/${rootName}${op.copy}`, val);
};
const patchMove = (ctx: PatchContext, op: JsonPatchOpMove) => {
const { propertyName, obj, root } = ctx;
const val = obj[propertyName];
patchRemove(ctx);
jsonPointer.set(root, `/${rootName}${op.move}`, val);
};
const patchTest = ({ obj, propertyName }: PatchContext, op: JsonPatchOpTest) => {
const val = obj[propertyName];
const factStr = stableStringify(val);
const expectStr = stableStringify(op.value);
if (factStr !== expectStr) {
throw new Error(
`JsonPatch Test failed for path: ${op.test}\nExpect: ${factStr}\nActual: ${factStr}`
);
}
};
const jsonPatchApplyOp = (obj: any, op: JsonPatchOp) => {
if ("add" in op) {
return patchAdd(getCtx(obj, op.add), op);
}
if ("remove" in op) {
return patchRemove(getCtx(obj, op.remove));
}
if ("replace" in op) {
return patchReplace(getCtx(obj, op.replace), op);
}
if ("copy" in op) {
return patchCopy(getCtx(obj, op.from), op);
}
if ("move" in op) {
return patchMove(getCtx(obj, op.from), op);
}
if ("test" in op) {
return patchTest(getCtx(obj, op.test), op);
}
throw new Error(`Unknown jsonPatchOp: ${JSON.stringify(op)}`);
};
export const jsonPatchApply = (obj: any, ops: JsonPatchOp[]): any => {
const rootObj = {
[rootName]: obj,
};
for (const op of ops) {
jsonPatchApplyOp(rootObj, op);
}
return rootObj[rootName];
};
export const getJsonPatchDiff = (
from: any,
to: any,
opts: DiffPatchOptions = {}
): JsonPatchOp[] => {
const patches = calcDiff(from, to, [], opts);
if (opts.includeOldValue) {
for (const patch of patches) {
const p = patch as JsonPatchOpReplace & JsonPatchOpRemove;
const oldPath = p.remove ?? p.replace;
if (oldPath !== undefined) {
p.oldValue = getObjValueFromPointer(from, oldPath);
}
}
}
return patches;
};
export const getObjValueFromPointer = (obj: any, pointer: string) => {
return jsonPointer.get(obj, pointer === "/" ? "" : pointer);
};
const calcDiff = (from: any, to: any, path: string[], opts: DiffPatchOptions): JsonPatchOp[] => {
if (from === to) {
return [];
}
const replaceOp: JsonPatchOp = {
replace: getJsonPointer(path),
value: to,
};
const fromType = typeof from;
const toType = typeof to;
if (fromType !== toType || fromType !== "object" || from === null || to === null) {
return [replaceOp];
}
const isFromArray = Array.isArray(from);
const isToArray = Array.isArray(to);
if (isFromArray !== isToArray) {
return [replaceOp];
}
const diffOps: JsonPatchOp[] = isFromArray
? calcArrayDiff(from, to, path, opts)
: calcObjDiff(from, to, path, opts);
if (diffOps.length === 0 || !opts.minimizeDiff) {
return diffOps;
}
const diffLen = JSON.stringify(diffOps).length;
const replaceLen = JSON.stringify(replaceOp).length + 4;
return diffLen < replaceLen ? diffOps : [replaceOp];
};
const calcObjDiff = (from: any, to: any, path: string[], opts: DiffPatchOptions) => {
const result: JsonPatchOp[] = [];
for (const key of Object.keys(from)) {
if (from[key] === undefined) {
continue;
}
if (to[key] === undefined) {
result.push({
remove: getJsonPointer(path, key),
});
} else {
const diff = calcDiff(from[key], to[key], path.concat([key]), opts);
if (diff.length > 0) {
result.push(...diff);
}
}
}
for (const key of Object.keys(to)) {
if (to[key] === undefined || from[key] !== undefined) {
continue;
}
result.push({
add: getJsonPointer(path, key),
value: to[key],
});
}
return result;
};
const calcArrayDiff = (
from: any[],
to: any[],
path: string[],
opts: DiffPatchOptions
): JsonPatchOp[] => {
let matchSeq = calcArrayDiffWithIndex(from, to);
let isKeyMatch = true;
if (matchSeq === undefined) {
const matcher = new SequenceMatcher(null, from, to);
matchSeq = matcher.getOpcodes();
isKeyMatch = false;
}
const addReplaceOps: JsonPatchOp[] = [];
const removeOps: JsonPatchOp[] = [];
for (const [op, i0, i1, j0, j1] of matchSeq) {
switch (op) {
case "equal":
if (isKeyMatch) {
for (let ix = i0, jx = j0; ix < i1 && jx < j1; ++ix, ++jx) {
const diff = calcDiff(from[ix], to[jx], path.concat([jx.toString()]), opts);
addReplaceOps.push(...diff);
}
}
break;
case "insert":
for (let idx = j0; idx < j1; ++idx) {
addReplaceOps.push({
add: getJsonPointer(path, idx.toString()),
value: to[idx],
});
}
break;
case "delete":
for (let idx = i0; idx < i1; ++idx) {
removeOps.push({
remove: getJsonPointer(path.concat([idx.toString()])),
});
}
break;
case "replace": {
let ix = i0;
let jx = j0;
for (; ix < i1 && jx < j1; ++ix, ++jx) {
if (isKeyMatch) {
addReplaceOps.push({
replace: getJsonPointer(path, jx.toString()),
value: to[jx],
});
} else {
const diff = calcDiff(from[ix], to[jx], path.concat([jx.toString()]), opts);
addReplaceOps.push(...diff);
}
}
for (; ix < i1; ++ix) {
removeOps.push({
remove: getJsonPointer(path, ix.toString()),
});
}
for (; jx < j1; ++jx) {
addReplaceOps.push({
add: getJsonPointer(path, jx.toString()),
value: to[jx],
});
}
}
}
}
return removeOps.reverse().concat(addReplaceOps);
};
const calcArrayDiffWithIndex = (
from: any[],
to: any[]
): ReturnType<SequenceMatcher<any>["getOpcodes"]> | undefined => {
const fromKeys = new Array(from.length);
const toKeys = new Array(to.length);
let hasKey = false;
for (let idx = 0; idx < from.length; ++idx) {
const key = getObjKey(from[idx]);
if (key !== undefined) {
hasKey = true;
}
fromKeys[idx] = key;
}
for (let idx = 0; idx < to.length; ++idx) {
const key = getObjKey(to[idx]);
if (key !== undefined) {
hasKey = true;
}
toKeys[idx] = key;
}
if (!hasKey) {
return undefined;
}
const matcher = new SequenceMatcher(null, fromKeys, toKeys);
const matchSeq = matcher.getOpcodes();
return matchSeq;
};
const getObjKey = (item: any) => {
if (item === undefined || item === null || typeof item !== "object") {
return undefined;
}
return item.id ?? item.name ?? probeObjectKey(item);
};
const probeObjectKey = (item: any) => {
for (const key of Object.keys(item)) {
if (typeof item[key] !== "string") {
continue;
}
const lowerKey = key.toLowerCase();
if (lowerKey.endsWith("id") || lowerKey.endsWith("name")) {
return item[key];
}
}
return undefined;
};
export const getJsonPointer = (input: string[], additional?: string) => {
let result = jsonPointer.compile(input);
if (additional !== undefined) {
result = `${result}/${jsonPointer.escape(additional)}`;
}
if (result === "") {
result = "/";
}
return result;
};