datum-merge
Version:
Simplified diff and merging for deeply nested objects
587 lines (547 loc) • 18.9 kB
text/typescript
export { type Diff };
export { type PreFilterFunction };
export { type PreFilterObject };
export { type Accumulator };
export { type Observer };
export { type DiffPath };
export { diff };
export { orderIndependentDiff };
export { accumulateDiff };
export { observableDiff };
export { orderIndependentDeepDiff };
export { applyChange, revertChange };
export { applyDiff }; //prefer merge
//for tests
export { realTypeOf };
export { getOrderIndependentHash };
type DiffNew<RHS> = {
readonly kind: "N";
readonly path: any[];
readonly rhs: RHS;
};
type DiffDeleted<LHS> = {
readonly kind: "D";
readonly path: any[];
readonly lhs: LHS;
};
type DiffEdit<LHS, RHS = LHS> = {
readonly kind: "E";
readonly path: any[];
readonly lhs: LHS;
readonly rhs: RHS;
};
type DiffArray<LHS, RHS = LHS> = {
readonly kind: "A";
readonly path: any[];
readonly index: number;
readonly item: Diff<LHS, RHS>;
};
type Diff<LHS, RHS = LHS> = DiffNew<RHS> | DiffDeleted<LHS> | DiffEdit<LHS, RHS> | DiffArray<LHS, RHS>;
type DiffKind = Diff<any, any>["kind"];
type PreFilterFunction = (path: any[], key: any) => boolean;
type PreFilterObject<LHS, RHS = LHS> = {
prefilter?(path: any[], key: any): boolean;
normalize?(currentPath: any, key: any, lhs: LHS, rhs: RHS): [LHS, RHS] | false | undefined;
};
type PreFilter<LHS, RHS = LHS> = PreFilterFunction | PreFilterObject<LHS, RHS>;
type Filter<LHS, RHS = LHS> = (target: LHS, source: RHS, change: Diff<LHS, RHS>) => boolean;
type Accumulator<LHS, RHS = LHS> = {
push(diff: Diff<LHS, RHS>): void;
length: number;
};
type Observer<LHS, RHS = LHS> = (diff: Diff<LHS, RHS>) => void;
type PathKey = string | number | symbol;
type DiffPath = PathKey[];
type StackItem<LHS, RHS = LHS> = { lhs: LHS, rhs: RHS };
const typeNormalizer: PreFilterObject<any, any> = {
normalize: function (currentPath: any, key: any, lhs: any, rhs: any): [any, any] {
if (realTypeOf(lhs) === 'regexp' && realTypeOf(rhs) === 'regexp') {
lhs = lhs.toString();
rhs = rhs.toString();
}
if (realTypeOf(lhs) === 'date' && realTypeOf(rhs) === 'date') {
lhs = (lhs as Date).valueOf();
rhs = (rhs as Date).valueOf();
}
return [lhs, rhs];
}
};
//-----------------------------------------------------------------------------
function diff<LHS, RHS = LHS>(
lhs: LHS,
rhs: RHS,
prefilter?: PreFilter<LHS, RHS>,
): readonly Diff<LHS, RHS>[] | undefined {
const changes: Diff<LHS, RHS>[] = [];
deepDiff(lhs, rhs, changes, prefilter);
return changes?.length ? changes : undefined;
}
function orderIndependentDiff<LHS, RHS = LHS>(
lhs: LHS,
rhs: RHS,
prefilter?: PreFilter<LHS, RHS>,
): readonly Diff<LHS, RHS>[] | undefined {
const changes = observableDiff(lhs, rhs, undefined, prefilter, true);
return changes?.length ? changes : undefined;
}
function observableDiff<LHS, RHS = LHS>(
lhs: LHS,
rhs: RHS,
observer?: Observer<LHS, RHS>,
prefilter?: PreFilter<LHS, RHS>,
orderIndependent?: boolean,
): Array<Diff<LHS, RHS>> {
const changes: Diff<LHS, RHS>[] = [];
deepDiff(lhs, rhs, changes, prefilter, undefined, undefined, undefined, orderIndependent);
if (observer) {
changes.forEach((c) => observer(c));
}
return changes;
}
function accumulateDiff<LHS, RHS = LHS>(
lhs: LHS,
rhs: RHS,
prefilter?: PreFilter<LHS, RHS>,
accum?: Accumulator<LHS, RHS>,
orderIndependent?: boolean,
): Accumulator<LHS, RHS> | Diff<LHS, RHS>[] | undefined {
const observer = (accum) ?
function (difference: Diff<LHS, RHS>) {
if (difference) {
accum.push(difference);
}
} : undefined;
const changes = observableDiff(lhs, rhs, observer, prefilter, orderIndependent);
return accum ? accum : (changes.length) ? changes : undefined;
}
//-----------------------------------------------------------------------------
function orderIndependentDeepDiff<LHS, RHS = LHS>(
lhs: LHS,
rhs: RHS,
changes: Array<Diff<LHS, RHS>>,
prefilter?: PreFilter<LHS, RHS>,
path?: any[],
key?: any,
stack?: any[],
): void {
deepDiff(lhs, rhs, changes, prefilter, path, key, stack, true);
}
function deepDiff<LHS, RHS = LHS>(
lhs: LHS,
rhs: RHS,
changes: Diff<LHS, RHS>[],
prefilter?: PreFilter<LHS, RHS>,
path?: any[],
key?: any,
stack?: any[],
orderIndependent: boolean = false,
): void {
changes = changes || [];
path = path || [];
stack = stack || [];
const currentPath = path.slice(0);
if (typeof key !== 'undefined' && key !== null) {
if (prefilter) {
if (typeof (prefilter) === 'function' && prefilter(currentPath, key)) {
return;
} else if (typeof (prefilter) === 'object') {
if (prefilter.prefilter && prefilter.prefilter(currentPath, key)) {
return;
}
if (prefilter.normalize) {
const alt = prefilter.normalize(currentPath, key, lhs, rhs);
if (alt) {
lhs = alt[0];
rhs = alt[1];
}
}
}
}
currentPath.push(key);
}
// Use string comparison for regexes
if (realTypeOf(lhs) === 'regexp' && realTypeOf(rhs) === 'regexp') {
lhs = (lhs as RegExp).toString() as LHS;
rhs = (rhs as RegExp).toString() as RHS;
}
const ltype = typeof lhs;
const rtype = typeof rhs;
const ldefined = ltype !== 'undefined' ||
(stack && (stack.length > 0) && stack[stack.length - 1].lhs &&
Object.getOwnPropertyDescriptor(stack[stack.length - 1].lhs, key));
const rdefined = rtype !== 'undefined' ||
(stack && (stack.length > 0) && stack[stack.length - 1].rhs &&
Object.getOwnPropertyDescriptor(stack[stack.length - 1].rhs, key));
//simple cases
if (!ldefined && rdefined) {
changes.push({
kind: 'N',
path: currentPath,
rhs
} as DiffNew<RHS>);
return;
} else if (!rdefined && ldefined) {
changes.push({
kind: 'D',
path: currentPath,
lhs
} as DiffDeleted<LHS>);
return;
} else if (realTypeOf(lhs) !== realTypeOf(rhs)) {
changes.push({
kind: 'E',
path: currentPath,
lhs,
rhs
} as DiffEdit<LHS, RHS>);
return;
}
if (realTypeOf(lhs) === 'date'
&& ((lhs as Date).valueOf() - (rhs as Date).valueOf()) !== 0) {
changes.push({
kind: 'E',
path: currentPath,
lhs,
rhs
} as DiffEdit<LHS, RHS>);
return;
}
if (ltype === 'object' && lhs !== null && rhs !== null) {
let other = false;
for (let i = stack.length - 1; i > -1; --i) {
if (stack[i].lhs === lhs) {
other = true;
break;
}
}
if (!other) {
stack.push({ lhs: lhs, rhs: rhs });
if (Array.isArray(lhs) && Array.isArray(rhs)) {
let lArr = lhs as any[];
let rArr = rhs as any[];
// If order doesn't matter, we need to sort our arrays
if (orderIndependent) {
lArr = lArr.slice(0).sort(function (a, b) {
return getOrderIndependentHash(a) - getOrderIndependentHash(b);
});
rArr = rArr.slice(0).sort(function (a, b) {
return getOrderIndependentHash(a) - getOrderIndependentHash(b);
});
}
let i = rArr.length - 1;
let j = lArr.length - 1;
while (i > j) {
changes.push({
kind: 'A',
path: currentPath,
index: i,
item: { kind: 'N', rhs: rArr[i--], path: undefined! } as DiffNew<RHS>,
} as DiffArray<LHS, RHS>);
}
while (j > i) {
changes.push({
kind: 'A',
path: currentPath,
index: j,
item: { kind: 'D', lhs: lArr[j--], path: undefined! } as DiffDeleted<LHS>,
} as DiffArray<LHS, RHS>);
}
for (; i >= 0; --i) {
deepDiff(lArr[i], rArr[i], changes, prefilter, currentPath, i, stack, orderIndependent);
}
} else {
const lObj = lhs as any;
const rObj = rhs as any;
const akeys = [...Object.keys(lObj), ...Object.getOwnPropertySymbols(lObj)];
const pkeys = [...Object.keys(rObj), ...Object.getOwnPropertySymbols(rObj)];
for (let i = 0; i < akeys.length; ++i) {
const k = akeys[i]!;
const ki = pkeys.indexOf(k);
if (ki >= 0) {
deepDiff(lObj[k], rObj[k], changes, prefilter, currentPath, k, stack, orderIndependent);
pkeys[ki] = null!;
} else {
deepDiff(lObj[k], undefined, changes, prefilter, currentPath, k, stack, orderIndependent);
}
}
for (let i = 0; i < pkeys.length; ++i) {
const k = pkeys[i];
if (k) {
deepDiff(undefined, rObj[k], changes, prefilter, currentPath, k, stack, orderIndependent);
}
}
}
stack.pop();
} else if ((lhs as any) !== (rhs as any)) {
// lhs contains a cycle at this element and it differs from rhs
changes.push({
kind: 'E',
path: currentPath,
lhs,
rhs
} as DiffEdit<LHS, RHS>);
}
} else if ((lhs as any) !== (rhs as any)) {
if (!(ltype === 'number' && isNaN(lhs as number) && isNaN(rhs as number))) {
changes.push({
kind: 'E',
path: currentPath,
lhs,
rhs
} as DiffEdit<LHS, RHS>);
}
}
}
//-----------------------------------------------------------------------------
function applyDiff<LHS, RHS = LHS>(
target: LHS,
source: RHS,
filter?: Filter<LHS, RHS>,
): LHS {
if (!target || !source) {
return target;
}
const onChange = function (change: Diff<LHS, any>): void {
if (!filter || filter(target, source, change)) {
applyChange(target, undefined, change);
}
};
observableDiff(target, source, onChange);
return target;
}
function applyChange<LHS>(
target: LHS,
unused: any,
change: Diff<LHS, any>,
): void {
if (!target || !change || !change.kind) {
return;
}
let it: any = target;
const rootPath = !change.path?.length;
const last = rootPath ? 0 : change.path.length - 1;
let i = -1;
while (++i < last) {
if (typeof it[change.path[i]] === 'undefined') {
it[change.path[i]] = (
typeof change.path[i + 1] !== 'undefined'
&& typeof change.path[i + 1] === 'number'
) ? [] : {};
}
it = it[change.path[i]];
}
switch (change.kind) {
case 'A':
if (!rootPath && typeof it[change.path[i]] === 'undefined') {
it[change.path[i]] = [];
}
applyArrayChange(rootPath ? it : it[change.path[i]], change.index, change.item);
break;
case 'D':
delete it[change.path[i]];
break;
case 'E':
case 'N':
it[change.path[i]] = change.rhs;
break;
}
}
function applyArrayChange<LHS>(
arr: any[],
index: number,
change: Diff<LHS, any>,
): any[] {
if (change.path && change.path.length > 0) {
const last = change.path.length - 1;
let it = arr[index];
let i: number;
for (i = 0; i < last; i++) {
it = it[change.path[i]];
}
switch (change.kind) {
case 'A':
applyArrayChange(it[change.path[i]], change.index, change.item);
break;
case 'D':
delete it[change.path[i]];
break;
case 'E':
case 'N':
it[change.path[i]] = change.rhs;
break;
}
} else {
switch (change.kind) {
case 'A':
applyArrayChange(arr[index], change.index, change.item);
break;
case 'D':
arr = arrayRemove(arr, index);
break;
case 'E':
case 'N':
arr[index] = change.rhs;
break;
}
}
return arr;
}
function revertChange<LHS>(
target: LHS,
unused: any,
change: Diff<LHS, any>,
): void {
if (!target || !change || !change.kind) {
return;
}
let it: any = target;
const rootPath = !change.path?.length;
const last = rootPath ? 0 : change.path.length - 1;
let i: number;
for (i = 0; i < last; i++) {
if (typeof it[change.path[i]] === 'undefined') {
it[change.path[i]] = {};
//here be dragons
}
it = it[change.path[i]];
}
switch (change.kind) {
case 'A':
// Array was modified...
// it will be an array...
revertArrayChange(rootPath ? it : it[change.path[i]], change.index, change.item);
break;
case 'D':
// Item was deleted...
it[change.path[i]] = change.lhs;
break;
case 'E':
// Item was edited...
it[change.path[i]] = change.lhs;
break;
case 'N':
// Item is new...
delete it[change.path[i]];
break;
}
}
function revertArrayChange<LHS>(
arr: any[],
index: number,
change: Diff<LHS, any>,
): any[] {
if (change.path && change.path.length > 0) {
// the structure of the object at the index has changed...
const last = change.path.length - 1;
let it = arr[index];
let i: number;
for (i = 0; i < last; i++) {
it = it[change.path[i]];
}
switch (change.kind) {
case 'A':
revertArrayChange(it[change.path[i]], change.index, change.item);
break;
case 'D':
it[change.path[i]] = change.lhs;
break;
case 'E':
it[change.path[i]] = change.lhs;
break;
case 'N':
delete it[change.path[i]];
break;
}
} else {
// the array item is different...
switch (change.kind) {
case 'A':
revertArrayChange(arr[index], change.index, change.item);
break;
case 'D':
arr[index] = change.lhs;
break;
case 'E':
arr[index] = change.lhs;
break;
case 'N':
arr = arrayRemove(arr, index);
break;
}
}
return arr;
}
//-----------------------------------------------------------------------------
function arrayRemove(arr: any[], index: number): any[] {
index = index < 0 ? arr.length + index : index;
arr.splice(index, 1);
return arr;
}
// function arrayRemove(
// arr: any[],
// from: number,
// to?: number
// ): any[] {
// const rest = arr.slice((to || from) + 1 || arr.length);
// arr.length = from < 0 ? arr.length + from : from;
// arr.push.apply(arr, rest);
// return arr;
// }
function realTypeOf(val: any): string {
const type = typeof val;
if (type !== 'object') {
return type;
}
if (val === Math) {
return 'math';
} else if (val === null) {
return 'null';
} else if (Array.isArray(val)) {
return 'array';
} else if (Object.prototype.toString.call(val) === '[object Date]') {
return 'date';
} else if (typeof val.toString === 'function' && /^\/.*\//.test(val.toString())) {
return 'regexp';
}
return 'object';
}
// Gets a hash of the given object in an array order-independent fashion
// also object key order independent (easier since they can be alphabetized)
function getOrderIndependentHash(val: any): number {
let accum = 0;
const type = realTypeOf(val);
if (type === 'array') {
val.forEach(function (item: any) {
// Addition is commutative so this is order indep
accum += getOrderIndependentHash(item);
});
const arrayString = `[type: array, hash: ${accum}]`;
return accum + hashThisString(arrayString);
}
if (type === 'object') {
for (let key in val) {
if (val.hasOwnProperty(key)) {
const keyValueHash = getOrderIndependentHash(val[key]);
const keyValueString = `[ type: object, key: ${key}, value hash: ${keyValueHash}]`;
accum += hashThisString(keyValueString);
}
}
return accum;
}
// Non object, non array...should be good?
const stringToHash = `[ type: ${type} ; value: ${val}]`;
return accum + hashThisString(stringToHash);
}
// http://werxltd.com/wp/2010/05/13/javascript-implementation-of-javas-string-hashcode-method/
function hashThisString(str: string): number {
let hash = 0;
if (str.length === 0) {
return hash;
}
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32bit integer
}
return hash;
}