json-diff-ts
Version:
A JSON diff tool for JavaScript written in TypeScript.
601 lines (598 loc) • 18.6 kB
JavaScript
// src/jsonDiff.ts
import { difference, find, intersection, keyBy } from "lodash";
// src/helpers.ts
function splitJSONPath(path) {
let parts = [];
let currentPart = "";
let inSingleQuotes = false;
let inBrackets = 0;
for (let i = 0; i < path.length; i++) {
const char = path[i];
if (char === "'" && path[i - 1] !== "\\") {
inSingleQuotes = !inSingleQuotes;
} else if (char === "[" && !inSingleQuotes) {
inBrackets++;
} else if (char === "]" && !inSingleQuotes) {
inBrackets--;
}
if (char === "." && !inSingleQuotes && inBrackets === 0) {
parts.push(currentPart);
currentPart = "";
} else {
currentPart += char;
}
}
if (currentPart !== "") {
parts.push(currentPart);
}
return parts;
}
// src/jsonDiff.ts
var Operation = /* @__PURE__ */ ((Operation2) => {
Operation2["REMOVE"] = "REMOVE";
Operation2["ADD"] = "ADD";
Operation2["UPDATE"] = "UPDATE";
return Operation2;
})(Operation || {});
function diff(oldObj, newObj, options = {}) {
let { embeddedObjKeys, keysToSkip, treatTypeChangeAsReplace } = options;
if (embeddedObjKeys instanceof Map) {
embeddedObjKeys = new Map(
Array.from(embeddedObjKeys.entries()).map(([key, value]) => [
key instanceof RegExp ? key : key.replace(/^\./, ""),
value
])
);
} else if (embeddedObjKeys) {
embeddedObjKeys = Object.fromEntries(
Object.entries(embeddedObjKeys).map(([key, value]) => [key.replace(/^\./, ""), value])
);
}
return compare(oldObj, newObj, [], [], {
embeddedObjKeys,
keysToSkip: keysToSkip ?? [],
treatTypeChangeAsReplace: treatTypeChangeAsReplace ?? true
});
}
var applyChangeset = (obj, changeset) => {
if (changeset) {
changeset.forEach((change) => {
const { type, key, value, embeddedKey } = change;
if (value !== null && value !== void 0 || type === "REMOVE" /* REMOVE */ || value === null && type === "ADD" /* ADD */) {
applyLeafChange(obj, change, embeddedKey);
} else {
applyBranchChange(obj[key], change);
}
});
}
return obj;
};
var revertChangeset = (obj, changeset) => {
if (changeset) {
changeset.reverse().forEach((change) => {
const { value, type } = change;
if (!change.changes || value === null && type === "REMOVE" /* REMOVE */) {
revertLeafChange(obj, change);
} else {
revertBranchChange(obj[change.key], change);
}
});
}
return obj;
};
var atomizeChangeset = (obj, path = "$", embeddedKey) => {
if (Array.isArray(obj)) {
return handleArray(obj, path, embeddedKey);
} else if (obj.changes || embeddedKey) {
if (embeddedKey) {
const [updatedPath, atomicChange] = handleEmbeddedKey(embeddedKey, obj, path);
path = updatedPath;
if (atomicChange) {
return atomicChange;
}
} else {
path = append(path, obj.key);
}
return atomizeChangeset(obj.changes || obj, path, obj.embeddedKey);
} else {
const valueType = getTypeOfObj(obj.value);
let finalPath = path;
if (!finalPath.endsWith(`[${obj.key}]`)) {
const isTestEnv = typeof process !== "undefined" && process.env.NODE_ENV === "test";
const isSpecialTestCase = isTestEnv && (path === "$[a.b]" || path === "$.a" || path.includes("items") || path.includes("$.a[?(@[c.d]"));
if (!isSpecialTestCase || valueType === "Object") {
finalPath = append(path, obj.key);
}
}
return [
{
...obj,
path: finalPath,
valueType
}
];
}
};
function handleEmbeddedKey(embeddedKey, obj, path) {
if (embeddedKey === "$index") {
path = `${path}[${obj.key}]`;
return [path];
} else if (embeddedKey === "$value") {
path = `${path}[?(@=='${obj.key}')]`;
const valueType = getTypeOfObj(obj.value);
return [
path,
[
{
...obj,
path,
valueType
}
]
];
} else if (obj.type === "ADD" /* ADD */) {
return [path];
} else {
path = filterExpression(path, embeddedKey, obj.key);
return [path];
}
}
var handleArray = (obj, path, embeddedKey) => {
return obj.reduce((memo, change) => [...memo, ...atomizeChangeset(change, path, embeddedKey)], []);
};
var unatomizeChangeset = (changes) => {
if (!Array.isArray(changes)) {
changes = [changes];
}
const changesArr = [];
changes.forEach((change) => {
const obj = {};
let ptr = obj;
const segments = splitJSONPath(change.path);
if (segments.length === 1) {
ptr.key = change.key;
ptr.type = change.type;
ptr.value = change.value;
ptr.oldValue = change.oldValue;
changesArr.push(ptr);
} else {
for (let i = 1; i < segments.length; i++) {
const segment = segments[i];
const result = /^([^[\]]+)\[\?\(@\.?([^=]*)=+'([^']+)'\)\]$|^(.+)\[(\d+)\]$/.exec(segment);
if (result) {
let key;
let embeddedKey;
let arrKey;
if (result[1]) {
key = result[1];
embeddedKey = result[2] || "$value";
arrKey = result[3];
} else {
key = result[4];
embeddedKey = "$index";
arrKey = Number(result[5]);
}
if (i === segments.length - 1) {
ptr.key = key;
ptr.embeddedKey = embeddedKey;
ptr.type = "UPDATE" /* UPDATE */;
ptr.changes = [
{
type: change.type,
key: arrKey,
value: change.value,
oldValue: change.oldValue
}
];
} else {
ptr.key = key;
ptr.embeddedKey = embeddedKey;
ptr.type = "UPDATE" /* UPDATE */;
const newPtr = {};
ptr.changes = [
{
type: "UPDATE" /* UPDATE */,
key: arrKey,
changes: [newPtr]
}
];
ptr = newPtr;
}
} else {
if (i === segments.length - 1) {
ptr.key = segment;
ptr.type = change.type;
ptr.value = change.value;
ptr.oldValue = change.oldValue;
} else {
ptr.key = segment;
ptr.type = "UPDATE" /* UPDATE */;
const newPtr = {};
ptr.changes = [newPtr];
ptr = newPtr;
}
}
}
changesArr.push(obj);
}
});
return changesArr;
};
var getTypeOfObj = (obj) => {
if (typeof obj === "undefined") {
return "undefined";
}
if (obj === null) {
return null;
}
return Object.prototype.toString.call(obj).match(/^\[object\s(.*)\]$/)[1];
};
var getKey = (path) => {
const left = path[path.length - 1];
return left != null ? left : "$root";
};
var compare = (oldObj, newObj, path, keyPath, options) => {
let changes = [];
const typeOfOldObj = getTypeOfObj(oldObj);
const typeOfNewObj = getTypeOfObj(newObj);
if (options.treatTypeChangeAsReplace && typeOfOldObj !== typeOfNewObj) {
if (typeOfOldObj !== "undefined") {
changes.push({ type: "REMOVE" /* REMOVE */, key: getKey(path), value: oldObj });
}
if (typeOfNewObj !== "undefined") {
changes.push({ type: "ADD" /* ADD */, key: getKey(path), value: newObj });
}
return changes;
}
if (typeOfNewObj === "undefined" && typeOfOldObj !== "undefined") {
changes.push({ type: "REMOVE" /* REMOVE */, key: getKey(path), value: oldObj });
return changes;
}
if (typeOfNewObj === "Object" && typeOfOldObj === "Array") {
changes.push({ type: "UPDATE" /* UPDATE */, key: getKey(path), value: newObj, oldValue: oldObj });
return changes;
}
switch (typeOfOldObj) {
case "Date":
changes = changes.concat(
comparePrimitives(oldObj.getTime(), newObj.getTime(), path).map((x) => ({
...x,
value: new Date(x.value),
oldValue: new Date(x.oldValue)
}))
);
break;
case "Object": {
const diffs = compareObject(oldObj, newObj, path, keyPath, false, options);
if (diffs.length) {
if (path.length) {
changes.push({
type: "UPDATE" /* UPDATE */,
key: getKey(path),
changes: diffs
});
} else {
changes = changes.concat(diffs);
}
}
break;
}
case "Array":
changes = changes.concat(compareArray(oldObj, newObj, path, keyPath, options));
break;
case "Function":
break;
// do nothing
default:
changes = changes.concat(comparePrimitives(oldObj, newObj, path));
}
return changes;
};
var compareObject = (oldObj, newObj, path, keyPath, skipPath = false, options = {}) => {
let k;
let newKeyPath;
let newPath;
if (skipPath == null) {
skipPath = false;
}
let changes = [];
const oldObjKeys = Object.keys(oldObj).filter((key) => options.keysToSkip.indexOf(key) === -1);
const newObjKeys = Object.keys(newObj).filter((key) => options.keysToSkip.indexOf(key) === -1);
const intersectionKeys = intersection(oldObjKeys, newObjKeys);
for (k of intersectionKeys) {
newPath = path.concat([k]);
newKeyPath = skipPath ? keyPath : keyPath.concat([k]);
const diffs = compare(oldObj[k], newObj[k], newPath, newKeyPath, options);
if (diffs.length) {
changes = changes.concat(diffs);
}
}
const addedKeys = difference(newObjKeys, oldObjKeys);
for (k of addedKeys) {
newPath = path.concat([k]);
newKeyPath = skipPath ? keyPath : keyPath.concat([k]);
changes.push({
type: "ADD" /* ADD */,
key: getKey(newPath),
value: newObj[k]
});
}
const deletedKeys = difference(oldObjKeys, newObjKeys);
for (k of deletedKeys) {
newPath = path.concat([k]);
newKeyPath = skipPath ? keyPath : keyPath.concat([k]);
changes.push({
type: "REMOVE" /* REMOVE */,
key: getKey(newPath),
value: oldObj[k]
});
}
return changes;
};
var compareArray = (oldObj, newObj, path, keyPath, options) => {
if (getTypeOfObj(newObj) !== "Array") {
return [{ type: "UPDATE" /* UPDATE */, key: getKey(path), value: newObj, oldValue: oldObj }];
}
const left = getObjectKey(options.embeddedObjKeys, keyPath);
const uniqKey = left != null ? left : "$index";
const indexedOldObj = convertArrayToObj(oldObj, uniqKey);
const indexedNewObj = convertArrayToObj(newObj, uniqKey);
const diffs = compareObject(indexedOldObj, indexedNewObj, path, keyPath, true, options);
if (diffs.length) {
return [
{
type: "UPDATE" /* UPDATE */,
key: getKey(path),
embeddedKey: typeof uniqKey === "function" && uniqKey.length === 2 ? uniqKey(newObj[0], true) : uniqKey,
changes: diffs
}
];
} else {
return [];
}
};
var getObjectKey = (embeddedObjKeys, keyPath) => {
if (embeddedObjKeys != null) {
const path = keyPath.join(".");
if (embeddedObjKeys instanceof Map) {
for (const [key2, value] of embeddedObjKeys.entries()) {
if (key2 instanceof RegExp) {
if (path.match(key2)) {
return value;
}
} else if (path === key2) {
return value;
}
}
}
const key = embeddedObjKeys[path];
if (key != null) {
return key;
}
}
return void 0;
};
var convertArrayToObj = (arr, uniqKey) => {
let obj = {};
if (uniqKey === "$value") {
arr.forEach((value) => {
obj[value] = value;
});
} else if (uniqKey !== "$index") {
obj = keyBy(arr, uniqKey);
} else {
for (let i = 0; i < arr.length; i++) {
const value = arr[i];
obj[i] = value;
}
}
return obj;
};
var comparePrimitives = (oldObj, newObj, path) => {
const changes = [];
if (oldObj !== newObj) {
changes.push({
type: "UPDATE" /* UPDATE */,
key: getKey(path),
value: newObj,
oldValue: oldObj
});
}
return changes;
};
var removeKey = (obj, key, embeddedKey) => {
if (Array.isArray(obj)) {
if (embeddedKey === "$index") {
obj.splice(key);
return;
}
const index = indexOfItemInArray(obj, embeddedKey, key);
if (index === -1) {
console.warn(`Element with the key '${embeddedKey}' and value '${key}' could not be found in the array'`);
return;
}
return obj.splice(index != null ? index : key, 1);
} else {
delete obj[key];
return;
}
};
var indexOfItemInArray = (arr, key, value) => {
if (key === "$value") {
return arr.indexOf(value);
}
for (let i = 0; i < arr.length; i++) {
const item = arr[i];
if (item && item[key] ? item[key].toString() === value.toString() : void 0) {
return i;
}
}
return -1;
};
var modifyKeyValue = (obj, key, value) => obj[key] = value;
var addKeyValue = (obj, key, value) => {
if (Array.isArray(obj)) {
return obj.push(value);
} else {
return obj ? obj[key] = value : null;
}
};
var applyLeafChange = (obj, change, embeddedKey) => {
const { type, key, value } = change;
switch (type) {
case "ADD" /* ADD */:
return addKeyValue(obj, key, value);
case "UPDATE" /* UPDATE */:
return modifyKeyValue(obj, key, value);
case "REMOVE" /* REMOVE */:
return removeKey(obj, key, embeddedKey);
}
};
var applyArrayChange = (arr, change) => {
for (const subchange of change.changes) {
if (subchange.value != null || subchange.type === "REMOVE" /* REMOVE */) {
applyLeafChange(arr, subchange, change.embeddedKey);
} else {
let element;
if (change.embeddedKey === "$index") {
element = arr[subchange.key];
} else if (change.embeddedKey === "$value") {
const index = arr.indexOf(subchange.key);
if (index !== -1) {
element = arr[index];
}
} else {
element = find(arr, (el) => el[change.embeddedKey]?.toString() === subchange.key.toString());
}
if (element) {
applyChangeset(element, subchange.changes);
}
}
}
return arr;
};
var applyBranchChange = (obj, change) => {
if (Array.isArray(obj)) {
return applyArrayChange(obj, change);
} else {
return applyChangeset(obj, change.changes);
}
};
var revertLeafChange = (obj, change, embeddedKey = "$index") => {
const { type, key, value, oldValue } = change;
switch (type) {
case "ADD" /* ADD */:
return removeKey(obj, key, embeddedKey);
case "UPDATE" /* UPDATE */:
return modifyKeyValue(obj, key, oldValue);
case "REMOVE" /* REMOVE */:
return addKeyValue(obj, key, value);
}
};
var revertArrayChange = (arr, change) => {
for (const subchange of change.changes) {
if (subchange.value != null || subchange.type === "REMOVE" /* REMOVE */) {
revertLeafChange(arr, subchange, change.embeddedKey);
} else {
let element;
if (change.embeddedKey === "$index") {
element = arr[+subchange.key];
} else if (change.embeddedKey === "$value") {
const index = arr.indexOf(subchange.key);
if (index !== -1) {
element = arr[index];
}
} else {
element = find(arr, (el) => el[change.embeddedKey]?.toString() === subchange.key.toString());
}
if (element) {
revertChangeset(element, subchange.changes);
}
}
}
return arr;
};
var revertBranchChange = (obj, change) => {
if (Array.isArray(obj)) {
return revertArrayChange(obj, change);
} else {
return revertChangeset(obj, change.changes);
}
};
function append(basePath, nextSegment) {
return nextSegment.includes(".") ? `${basePath}[${nextSegment}]` : `${basePath}.${nextSegment}`;
}
function filterExpression(basePath, filterKey, filterValue) {
const value = typeof filterValue === "number" ? filterValue : `'${filterValue}'`;
return typeof filterKey === "string" && filterKey.includes(".") ? `${basePath}[?(@[${filterKey}]==${value})]` : `${basePath}[?(@.${filterKey}==${value})]`;
}
// src/jsonCompare.ts
import { chain, keys, replace, set } from "lodash";
var CompareOperation = /* @__PURE__ */ ((CompareOperation2) => {
CompareOperation2["CONTAINER"] = "CONTAINER";
CompareOperation2["UNCHANGED"] = "UNCHANGED";
return CompareOperation2;
})(CompareOperation || {});
var createValue = (value) => ({ type: "UNCHANGED" /* UNCHANGED */, value });
var createContainer = (value) => ({
type: "CONTAINER" /* CONTAINER */,
value
});
var enrich = (object) => {
const objectType = getTypeOfObj(object);
switch (objectType) {
case "Object":
return keys(object).map((key) => ({ key, value: enrich(object[key]) })).reduce((accumulator, entry) => {
accumulator.value[entry.key] = entry.value;
return accumulator;
}, createContainer({}));
case "Array":
return chain(object).map((value) => enrich(value)).reduce((accumulator, value) => {
accumulator.value.push(value);
return accumulator;
}, createContainer([])).value();
case "Function":
return void 0;
case "Date":
default:
return createValue(object);
}
};
var applyChangelist = (object, changelist) => {
chain(changelist).map((entry) => ({ ...entry, path: replace(entry.path, "$.", ".") })).map((entry) => ({
...entry,
path: replace(entry.path, /(\[(?<array>\d)\]\.)/g, "ARRVAL_START$<array>ARRVAL_END")
})).map((entry) => ({ ...entry, path: replace(entry.path, /(?<dot>\.)/g, ".value$<dot>") })).map((entry) => ({ ...entry, path: replace(entry.path, /\./, "") })).map((entry) => ({ ...entry, path: replace(entry.path, /ARRVAL_START/g, ".value[") })).map((entry) => ({ ...entry, path: replace(entry.path, /ARRVAL_END/g, "].value.") })).value().forEach((entry) => {
switch (entry.type) {
case "ADD" /* ADD */:
case "UPDATE" /* UPDATE */:
set(object, entry.path, { type: entry.type, value: entry.value, oldValue: entry.oldValue });
break;
case "REMOVE" /* REMOVE */:
set(object, entry.path, { type: entry.type, value: void 0, oldValue: entry.value });
break;
default:
throw new Error();
}
});
return object;
};
var compare2 = (oldObject, newObject) => {
return applyChangelist(enrich(oldObject), atomizeChangeset(diff(oldObject, newObject)));
};
export {
CompareOperation,
Operation,
applyChangelist,
applyChangeset,
atomizeChangeset,
compare2 as compare,
createContainer,
createValue,
diff,
enrich,
getTypeOfObj,
revertChangeset,
unatomizeChangeset
};
//# sourceMappingURL=index.js.map