Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 64 additions & 15 deletions packages/diff/src/Diff.spec.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,67 @@

import { describe, test, expect } from 'vitest';
import { Change, Diff } from './Diff.js';
import { Path } from '@finnair/path';
import { Diff } from './Diff.js';
import { Node, Path } from '@finnair/path';
import { Change } from './DiffNode.js';

describe('Diff', () => {
const defaultDiff = new Diff();

describe('allPaths', () => {
const object = { object: { string: "string"}, array: [0], 'undefined': undefined, 'null': null };
test('all paths with default filter (without undefined values)', () => {
const paths = defaultDiff.allPaths(object);
expect(paths).toEqual(new Set([ '$.object.string', '$.array[0]', '$.null']));
describe('helpers', () => {
const object = {
object: { string: "string"},
array: [0],
'undefined': undefined,
'null': null
};
describe('allPaths', () => {
test('with default filter (without undefined values)', () => {
const paths = defaultDiff.allPaths(object);
expect(paths).toEqual(new Set([ '$.object.string', '$.array[0]', '$.null']));
});
test('including undefined paths', () => {
const paths = new Diff({ filter: () => true }).allPaths(object);
expect(paths).toEqual(new Set([ '$.object.string', '$.array[0]', '$.undefined', '$.null']));
});
test('including objects', () => {
const paths = new Diff({ includeObjects: true}).allPaths(object);
expect(paths).toEqual(new Set([ '$.object', '$.object.string', '$.array', '$.array[0]', '$.null']));
});
test('array', () => {
const paths = new Diff().allPaths([object]);
expect(paths).toEqual(new Set([ '$[0].object.string', '$[0].array[0]', '$[0].null' ]));
});
});
test('all, including undefined paths', () => {
const paths = new Diff({ filter: () => true }).allPaths(object);
expect(paths).toEqual(new Set([ '$.object.string', '$.array[0]', '$.undefined', '$.null']));

describe('pathsAndValues', () => {
test('all pathsAndValues with default filter (without undefined values)', () => {
const pathsAndValues = defaultDiff.pathsAndValues(object);
expect(pathsAndValues).toEqual(new Map<string, Node>([
['$.object.string', { path: Path.of('object', 'string'), value: 'string' }],
['$.array[0]', { path: Path.of('array', 0), value: 0 }],
['$.null', { path: Path.of('null'), value: null }],
]));
});
test('all, including undefined pathsAndValues', () => {
const pathsAndValues = new Diff({ filter: () => true }).pathsAndValues(object);
expect(pathsAndValues).toEqual(new Map<string, Node>([
['$.object.string', { path: Path.of('object', 'string'), value: 'string' }],
['$.array[0]', { path: Path.of('array', 0), value: 0 }],
['$.undefined', { path: Path.of('undefined'), value: undefined }],
['$.null', { path: Path.of('null'), value: null }],
]));
});
test('including objects', () => {
const pathsAndValues = new Diff({ includeObjects: true}).pathsAndValues(object);
expect(pathsAndValues).toEqual(new Map<string, Node>([
['$', { path: Path.ROOT, value: {} }],
['$.object', { path: Path.of('object'), value: {} }],
['$.object.string', { path: Path.of('object', 'string'), value: 'string' }],
['$.array', { path: Path.of('array'), value: [] }],
['$.array[0]', { path: Path.of('array', 0), value: 0 }],
['$.null', { path: Path.of('null'), value: null }],
]));
});
});
});

Expand All @@ -33,34 +80,36 @@ describe('Diff', () => {
object: {
name: 'Alexis',
},
array: [0]
array: [
{ name:'Foo' }
]
};
const newObject = {};

describe('remove nested object', () => {
test('with includeObjects: false', () => {
const paths = defaultDiff.changedPaths(oldObject, newObject);
const expected = new Set(['$.object.name', '$.array[0]']);
const expected = new Set(['$.object.name', '$.array[0].name']);
expect(paths).toEqual(expected);
});

test('with includeObjects: true', () => {
const paths = new Diff({ includeObjects: true }).changedPaths(oldObject, newObject);
const expected = new Set(['$.object', '$.object.name', '$.array', '$.array[0]']);
const expected = new Set(['$.object', '$.object.name', '$.array', '$.array[0]', '$.array[0].name']);
expect(paths).toEqual(expected);
});
});

describe('add nested object', () => {
test('with includeObjects: false', () => {
const paths = defaultDiff.changedPaths(newObject, oldObject);
const expected = new Set(['$.object.name', '$.array[0]']);
const expected = new Set(['$.object.name', '$.array[0].name']);
expect(paths).toEqual(expected);
});

test('with includeObjects: true', () => {
const diff = new Diff({ includeObjects: true }).changedPaths(newObject, oldObject);
const expected = new Set(['$.object', '$.object.name', '$.array', '$.array[0]']);
const expected = new Set(['$.object', '$.object.name', '$.array', '$.array[0]', '$.array[0].name']);
expect(diff).toEqual(expected);
});
});
Expand Down
143 changes: 40 additions & 103 deletions packages/diff/src/Diff.ts
Original file line number Diff line number Diff line change
@@ -1,127 +1,64 @@
import { Node, Path } from '@finnair/path';
import { Node } from '@finnair/path';
import { arrayOrPlainObject, Change, DiffNode, DiffNodeConfig } from './DiffNode';

export const defaultDiffFilter = (_path: Path, value: any) => value !== undefined;

export interface DiffConfig {
readonly filter?: DiffFilter;
readonly isPrimitive?: (value: any, path: Path) => boolean;
readonly isEqual?: (a: any, b: any, path: Path) => boolean;
export interface DiffConfig extends DiffNodeConfig {
readonly includeObjects?: boolean;
}

export interface DiffFilter {
(path: Path, value: any): boolean;
}

export interface Change {
readonly path: Path;
readonly oldValue?: any;
readonly newValue?: any;
}

const OBJECT = Object.freeze({});
const ARRAY = Object.freeze([]);

const primitiveTypes: any = {
'boolean': true,
'number': true,
'string': true,
'bigint': true,
'symbol': true,
};

function isPrimitive(value: any) {
return value === null || value === undefined || !!primitiveTypes[typeof value];
}

export class Diff {
constructor(private readonly config?: DiffConfig) {}
constructor(public readonly config?: DiffConfig) {}

allPaths(value: any) {
const paths = new Set<string>();
this.collectPathsAndValues(
value,
(node: Node) => {
paths.add(node.path.toJSON());
},
);
return paths;
return Diff.allPaths(value, this.config);
}

changedPaths<T>(a: T, b: T) {
return new Set(this.changeset(a, b).keys());
changedPaths<T>(oldValue: T, newValue: T) {
return Diff.changedPaths(oldValue, newValue, this.config);
}

changeset<T>(a: T, b: T): Map<string, Change> {
const changeset = new Map<string, Change>();
const aMap = this.pathsAndValues(a);
this.collectPathsAndValues(
b,
(bNode: Node) => {
const path = bNode.path;
const bValue = bNode.value;
const pathStr = path.toJSON();
if (aMap.has(pathStr)) {
const aNode = aMap.get(pathStr);
const aValue = aNode?.value;
aMap.delete(pathStr);
if (!this.isEqual(aValue, bValue, path)) {
changeset.set(pathStr, { path, oldValue: aValue, newValue: this.getNewValue(bValue) });
}
} else {
changeset.set(pathStr, { path, newValue: this.getNewValue(bValue) });
}
},
Path.ROOT
);
aMap.forEach((node, pathStr) => changeset.set(pathStr, { path: node.path, oldValue: node.value }));
return changeset;
changeset<T>(oldValue: T, newValue: T): Map<string, Change> {
return Diff.changeset(oldValue, newValue, this.config);
}

pathsAndValues(value: any) {
const map: Map<string, Node> = new Map();
this.collectPathsAndValues(value, (node: Node) => map.set(node.path.toJSON(), <Node>{ path: node.path, value: node.value }), Path.ROOT);
return map;
pathsAndValues(value: any): Map<string, Node> {
return Diff.pathsAndValues(value, this.config);
}

private collectPathsAndValues(value: any, collector: (node: Node) => void, path: Path = Path.ROOT) {
if ((this.config?.filter ?? defaultDiffFilter)(path, value)) {
if (isPrimitive(value) || this.config?.isPrimitive?.(value, path)) {
collector({ path, value });
} else if (typeof value === 'object') {
if (Array.isArray(value)) {
if (this.config?.includeObjects) {
collector({ path, value: ARRAY });
}
value.forEach((element, index) => this.collectPathsAndValues(element, collector, path.index(index)));
} else if (value.constructor === Object) {
if (this.config?.includeObjects) {
collector({ path, value: OBJECT });
}
Object.keys(value).forEach((key) =>
this.collectPathsAndValues(value[key], collector, path.property(key))
);
} else {
throw new Error(`only primitives, arrays and plain objects are supported, got "${value.constructor.name}"`);
}
}
static allPaths(value: any, config?: DiffConfig) {
return Diff.changedPaths(getBaseValue(value), value, config);
}
static changedPaths<T>(oldValue: T, newValue: T, config?: DiffConfig) {
const paths = new Set<string>();
const diffNode = new DiffNode({ oldValue, newValue }, config);
for (const path of diffNode.getChangedPaths(config?.includeObjects)) {
paths.add(path.toJSON());
}
return paths;
}

private isEqual(a: any, b: any, path: Path): boolean {
if (a === b) {
return true;
static changeset<T>(oldValue: T, newValue: T, config?: DiffConfig): Map<string, Change> {
const changeset = new Map<string, Change>();
const diffNode = new DiffNode({ oldValue, newValue }, config);
for (const change of diffNode.getScalarChanges(config?.includeObjects)) {
changeset.set(change.path.toJSON(), change);
}
return !!this.config?.isEqual?.(a, b, path);
return changeset;
}

private getNewValue(value: any) {
if (value === OBJECT) {
return {};
}
if (value === ARRAY) {
return [];
static pathsAndValues(value: any, config?: DiffConfig): Map<string, Node> {
const map = new Map<string, Node>();
const diffNode = new DiffNode({ newValue: value }, config);
for (const change of diffNode.getScalarChanges(config?.includeObjects)) {
map.set(change.path.toJSON(), { path: change.path, value: change.newValue });
}
return value;
return map;
}
}

function getBaseValue(value: any) {
switch (arrayOrPlainObject(value)) {
case 'object': return {};
case 'array': return [];
default: return undefined;
}
}
Loading