diff --git a/src/utils/diff-array-compare-key.spec.ts b/src/utils/diff-array-compare-key.spec.ts new file mode 100644 index 0000000..7bfe24b --- /dev/null +++ b/src/utils/diff-array-compare-key.spec.ts @@ -0,0 +1,668 @@ +import { DifferOptions, UndefinedBehavior } from '../differ'; +import diffArrayCompareKey, { allObjectsHaveCompareKey } from './diff-array-compare-key'; + +describe('Utility function: diff-array-compare-key', () => { + const defaultOptions: DifferOptions = { + compareKey: 'id', + showModifications: true, + undefinedBehavior: UndefinedBehavior.stringify, + }; + + const createOptions = (overrides: Partial = {}): DifferOptions => ({ + ...defaultOptions, + ...overrides, + }); + + describe('allObjectsHaveCompareKey', () => { + it('should return true when all objects have the compare key', () => { + const arr = [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + { id: 3, name: 'Charlie' }, + ]; + expect(allObjectsHaveCompareKey(arr, 'id')).toBe(true); + }); + + it('should return false when some objects are missing the compare key', () => { + const arr = [ + { id: 1, name: 'Alice' }, + { name: 'Bob' }, // missing id + { id: 3, name: 'Charlie' }, + ]; + expect(allObjectsHaveCompareKey(arr, 'id')).toBe(false); + }); + + it('should return true for nested arrays with compare key', () => { + const arr = [ + { id: 1, items: [{ id: 11, value: 'a' }] }, + { id: 2, items: [{ id: 22, value: 'b' }] }, + ]; + expect(allObjectsHaveCompareKey(arr, 'id')).toBe(true); + }); + + it('should return false for nested arrays missing compare key', () => { + const arr = [ + { id: 1, items: [{ value: 'a' }] }, // nested object missing id + { id: 2, items: [{ id: 22, value: 'b' }] }, + ]; + expect(allObjectsHaveCompareKey(arr, 'id')).toBe(false); + }); + + it('should handle empty arrays', () => { + expect(allObjectsHaveCompareKey([], 'id')).toBe(true); + }); + + it('should handle arrays with non-object items', () => { + const arr = [{ id: 1, name: 'Alice' }, 'string item', 123]; + expect(allObjectsHaveCompareKey(arr, 'id')).toBe(true); + }); + }); + + describe('diffArrayCompareKey - Basic functionality', () => { + it('should handle identical arrays', () => { + const left = [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ]; + const right = [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ]; + + const [linesLeft] = diffArrayCompareKey(left, right, '', '', 0, createOptions()); + + expect(linesLeft).toEqual([ + { level: 0, type: 'equal', text: '[' }, + { level: 1, type: 'equal', text: '{' }, + { level: 2, type: 'equal', text: '"id": 1' }, + { level: 2, type: 'equal', text: '"name": "Alice"' }, + { level: 1, type: 'equal', text: '}' }, + { level: 1, type: 'equal', text: '{' }, + { level: 2, type: 'equal', text: '"id": 2' }, + { level: 2, type: 'equal', text: '"name": "Bob"' }, + { level: 1, type: 'equal', text: '}' }, + { level: 0, type: 'equal', text: ']' }, + ]); + }); + + it('should match objects by compareKey regardless of order', () => { + const left = [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ]; + const right = [ + { id: 2, name: 'Bob' }, + { id: 1, name: 'Alice' }, + ]; + + const [linesLeft, linesRight] = diffArrayCompareKey(left, right, '', '', 0, createOptions()); + + // Should match id=1 with id=1 and id=2 with id=2, regardless of position + expect(linesLeft.filter((line) => line.type === 'equal')).toHaveLength(linesLeft.length); + expect(linesRight.filter((line) => line.type === 'equal')).toHaveLength(linesRight.length); + }); + + it('should detect modifications in matched objects', () => { + const left = [{ id: 1, name: 'Alice', age: 25 }]; + const right = [{ id: 1, name: 'Alice', age: 26 }]; + + const [linesLeft, linesRight] = diffArrayCompareKey(left, right, '', '', 0, createOptions()); + + // Should have equal parts for id and name, but modified age + expect(linesLeft.some((line) => line.text === '"age": 25' && line.type === 'modify')).toBe(true); + expect(linesRight.some((line) => line.text === '"age": 26' && line.type === 'modify')).toBe(true); + }); + }); + + describe('diffArrayCompareKey - Add/Remove operations', () => { + it('should handle added items', () => { + const left = [{ id: 1, name: 'Alice' }]; + const right = [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ]; + + const [, linesRight] = diffArrayCompareKey(left, right, '', '', 0, createOptions()); + + // Should have added content for Bob + expect(linesRight.some((line) => line.text.includes('Bob') && line.type === 'add')).toBe(true); + }); + + it('should handle removed items', () => { + const left = [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ]; + const right = [{ id: 1, name: 'Alice' }]; + + const [linesLeft] = diffArrayCompareKey(left, right, '', '', 0, createOptions()); + + // Should have removed content for Bob + expect(linesLeft.some((line) => line.text.includes('Bob') && line.type === 'remove')).toBe(true); + }); + + it('should handle completely different arrays', () => { + const left = [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ]; + const right = [ + { id: 3, name: 'Charlie' }, + { id: 4, name: 'Dave' }, + ]; + + const [linesLeft, linesRight] = diffArrayCompareKey(left, right, '', '', 0, createOptions()); + + // Should have all items as removed from left and added to right + expect(linesLeft.some((line) => line.text.includes('Alice') && line.type === 'remove')).toBe(true); + expect(linesLeft.some((line) => line.text.includes('Bob') && line.type === 'remove')).toBe(true); + expect(linesRight.some((line) => line.text.includes('Charlie') && line.type === 'add')).toBe(true); + expect(linesRight.some((line) => line.text.includes('Dave') && line.type === 'add')).toBe(true); + }); + }); + + describe('diffArrayCompareKey - Nested structures', () => { + it('should handle nested arrays in objects', () => { + const left = [{ id: 1, items: [1, 2, 3] }]; + const right = [{ id: 1, items: [1, 3, 4] }]; + + const [linesLeft, linesRight] = diffArrayCompareKey(left, right, '', '', 0, createOptions()); + + // Should recursively diff the nested arrays - verify array structure + expect(linesLeft).toContainEqual(expect.objectContaining({ text: '[' })); + expect(linesRight).toContainEqual(expect.objectContaining({ text: '[' })); + + // Should show specific changes in the nested array content + // Array [1, 2, 3] vs [1, 3, 4] compared by position: + // - Position 0: 1 vs 1 → equal + // - Position 1: 2 vs 3 → modify + // - Position 2: 3 vs 4 → modify + + // Verify equal element + expect(linesLeft.some((line) => line.text === '1' && line.type === 'equal')).toBe(true); + expect(linesRight.some((line) => line.text === '1' && line.type === 'equal')).toBe(true); + + // Verify modifications (position-based comparison) + expect(linesLeft.some((line) => line.text === '2' && line.type === 'modify')).toBe(true); + expect(linesRight.some((line) => line.text === '3' && line.type === 'modify')).toBe(true); + expect(linesLeft.some((line) => line.text === '3' && line.type === 'modify')).toBe(true); + expect(linesRight.some((line) => line.text === '4' && line.type === 'modify')).toBe(true); + + // Verify we have both equal and modify types (showing it did process the nested array) + const leftTypes = new Set(linesLeft.map((line) => line.type)); + const rightTypes = new Set(linesRight.map((line) => line.type)); + expect(leftTypes.has('equal')).toBe(true); + expect(leftTypes.has('modify')).toBe(true); + expect(rightTypes.has('equal')).toBe(true); + expect(rightTypes.has('modify')).toBe(true); + }); + + it('should handle nested objects in arrays', () => { + const left = [{ id: 1, profile: { name: 'Alice', age: 25 } }]; + const right = [{ id: 1, profile: { name: 'Alice', age: 26 } }]; + + const [linesLeft, linesRight] = diffArrayCompareKey(left, right, '', '', 0, createOptions()); + + // Should show modification in nested object - check for age field modification + expect(linesLeft.some((line) => line.text.includes('25') && line.type === 'modify')).toBe(true); + expect(linesRight.some((line) => line.text.includes('26') && line.type === 'modify')).toBe(true); + }); + + it('should handle mixed types in array values', () => { + const left = [{ id: 1, data: [1, 2] }]; + const right = [{ id: 1, data: { count: 2 } }]; + + const [linesLeft, linesRight] = diffArrayCompareKey(left, right, '', '', 0, createOptions()); + + // Should handle type change from array to object + expect(linesLeft.some((line) => line.type === 'modify' || line.type === 'remove')).toBe(true); + expect(linesRight.some((line) => line.type === 'modify' || line.type === 'add')).toBe(true); + }); + }); + + describe('diffArrayCompareKey - Options handling', () => { + it('should respect showModifications option', () => { + const left = [{ id: 1, name: 'Alice' }]; + const right = [{ id: 1, name: 'Bob' }]; + + // With showModifications: true + const [linesLeft1, linesRight1] = diffArrayCompareKey( + left, + right, + '', + '', + 0, + createOptions({ showModifications: true }) + ); + expect(linesLeft1.some((line) => line.type === 'modify')).toBe(true); + expect(linesRight1.some((line) => line.type === 'modify')).toBe(true); + + // With showModifications: false + const [linesLeft2, linesRight2] = diffArrayCompareKey( + left, + right, + '', + '', + 0, + createOptions({ showModifications: false }) + ); + expect(linesLeft2.some((line) => line.type === 'remove')).toBe(true); + expect(linesRight2.some((line) => line.type === 'add')).toBe(true); + }); + + it('should respect maxDepth option', () => { + const left = [{ id: 1, deep: { nested: { value: 1 } } }]; + const right = [{ id: 1, deep: { nested: { value: 2 } } }]; + + const [linesLeft, linesRight] = diffArrayCompareKey(left, right, '', '', 0, createOptions({ maxDepth: 2 })); + + // Should stop at maxDepth and show placeholder + expect(linesLeft.some((line) => line.text === '...')).toBe(true); + expect(linesRight.some((line) => line.text === '...')).toBe(true); + }); + + it('should handle missing compareKey option', () => { + const left = [{ id: 1, name: 'Alice' }]; + const right = [{ id: 1, name: 'Bob' }]; + + // Should fallback to diffArrayNormal when compareKey is not provided + const [linesLeft, linesRight] = diffArrayCompareKey( + left, + right, + '', + '', + 0, + createOptions({ compareKey: undefined }) + ); + + // Should still produce valid output (though using different algorithm) + expect(linesLeft).toHaveLength(linesRight.length); + expect(linesLeft[0]).toEqual({ level: 0, type: 'equal', text: '[' }); + }); + }); + + describe('diffArrayCompareKey - Edge cases', () => { + it('should handle empty arrays', () => { + const [linesLeft, linesRight] = diffArrayCompareKey([], [], '', '', 0, createOptions()); + + expect(linesLeft).toEqual([ + { level: 0, type: 'equal', text: '[' }, + { level: 0, type: 'equal', text: ']' }, + ]); + expect(linesRight).toEqual([ + { level: 0, type: 'equal', text: '[' }, + { level: 0, type: 'equal', text: ']' }, + ]); + }); + + it('should handle arrays with primitive values', () => { + const left = [1, 2, 3]; + const right = [1, 3, 4]; + + // Should fallback to diffArrayNormal for primitive arrays + const [linesLeft, linesRight] = diffArrayCompareKey(left, right, '', '', 0, createOptions()); + + expect(linesLeft).toHaveLength(linesRight.length); + }); + + it('should handle arrays with mixed object and non-object items', () => { + const left = [{ id: 1, name: 'Alice' }, 'string', 123]; + const right = [{ id: 1, name: 'Alice' }, 'string', 456]; + + // Should fallback to diffArrayNormal for mixed arrays + const [linesLeft, linesRight] = diffArrayCompareKey(left, right, '', '', 0, createOptions()); + + expect(linesLeft).toHaveLength(linesRight.length); + }); + + it('should handle objects without compareKey', () => { + const left = [{ name: 'Alice' }, { id: 2, name: 'Bob' }]; + const right = [{ name: 'Alice' }, { id: 2, name: 'Bob' }]; + + // Should fallback to diffArrayNormal when objects missing compareKey + const [linesLeft, linesRight] = diffArrayCompareKey(left, right, '', '', 0, createOptions()); + + expect(linesLeft).toHaveLength(linesRight.length); + }); + + it('should handle duplicate compareKey values', () => { + const left = [ + { id: 1, name: 'Alice' }, + { id: 1, name: 'Alice2' }, + ]; + const right = [{ id: 1, name: 'Alice' }]; + + const [linesLeft] = diffArrayCompareKey(left, right, '', '', 0, createOptions()); + + // Should match first occurrence and mark second as removed + expect(linesLeft.some((line) => line.type === 'remove')).toBe(true); + }); + + it('should handle complex nested structures', () => { + const left = [ + { + id: 1, + profile: { + personal: { name: 'Alice', age: 25 }, + hobbies: ['reading', 'coding'], + friends: [{ id: 10, name: 'Bob' }], + }, + }, + ]; + + const right = [ + { + id: 1, + profile: { + personal: { name: 'Alice', age: 26 }, + hobbies: ['reading', 'gaming'], + friends: [{ id: 10, name: 'Robert' }], + }, + }, + ]; + + const [linesLeft, linesRight] = diffArrayCompareKey(left, right, '', '', 0, createOptions()); + + // Should handle deep nested changes + expect(linesLeft.some((line) => line.text.includes('25') && line.type === 'modify')).toBe(true); + expect(linesRight.some((line) => line.text.includes('26') && line.type === 'modify')).toBe(true); + }); + }); + + describe('diffArrayCompareKey - Array keys handling', () => { + it('should handle arrays with keyLeft and keyRight', () => { + const left = [{ id: 1, name: 'Alice' }]; + const right = [{ id: 1, name: 'Alice' }]; + + const [linesLeft, linesRight] = diffArrayCompareKey(left, right, 'users', 'users', 0, createOptions()); + + expect(linesLeft[0]).toEqual({ level: 0, type: 'equal', text: '"users": [' }); + expect(linesRight[0]).toEqual({ level: 0, type: 'equal', text: '"users": [' }); + }); + + it('should handle different array keys', () => { + const left = [{ id: 1, name: 'Alice' }]; + const right = [{ id: 1, name: 'Alice' }]; + + const [linesLeft, linesRight] = diffArrayCompareKey(left, right, 'oldUsers', 'newUsers', 0, createOptions()); + + expect(linesLeft[0]).toEqual({ level: 0, type: 'equal', text: '"oldUsers": [' }); + expect(linesRight[0]).toEqual({ level: 0, type: 'equal', text: '"newUsers": [' }); + }); + }); + + describe('diffArrayCompareKey - Type safety', () => { + it('should handle null and undefined values', () => { + const left = [{ id: 1, value: null }]; + const right = [{ id: 1, value: undefined }]; + + const [linesLeft, linesRight] = diffArrayCompareKey(left, right, '', '', 0, createOptions()); + + expect(linesLeft.some((line) => line.text.includes('null'))).toBe(true); + expect(linesRight.some((line) => line.text.includes('undefined'))).toBe(true); + }); + + it('should handle special numeric values', () => { + const left = [{ id: 1, value: NaN }]; + const right = [{ id: 1, value: Infinity }]; + + const [linesLeft, linesRight] = diffArrayCompareKey(left, right, '', '', 0, createOptions()); + + expect(linesLeft.some((line) => line.text.includes('NaN'))).toBe(true); + expect(linesRight.some((line) => line.text.includes('Infinity'))).toBe(true); + }); + }); + + // Large-scale scenario tests to validate compare by key logic with many JSON objects + describe('diffArrayCompareKey - Large-scale validation', () => { + it('should handle dozens of JSON objects correctly with various structures', () => { + // Create arrays with dozens of objects to validate large-scale handling + const createLargeArray = (size: number, baseId = 0) => { + return Array.from({ length: size }, (_, i) => ({ + id: baseId + i + 1, + name: `Item ${baseId + i + 1}`, + type: ['user', 'admin', 'guest'][i % 3], + metadata: { + created: `2024-01-${String(i % 28 + 1).padStart(2, '0')}`, + tags: [`tag${i % 5}`, `category${i % 3}`], + settings: { + enabled: i % 2 === 0, + priority: i % 10, + config: { + theme: ['light', 'dark'][i % 2], + locale: ['en', 'es', 'fr'][i % 3], + }, + }, + }, + data: Array.from({ length: i % 3 + 1 }, (_, j) => ({ + id: (i + 1) * 10 + j, + value: `value-${i}-${j}`, + })), + })); + }; + + const leftArray = createLargeArray(50); + const rightArray = [ + ...createLargeArray(30), // First 30 items unchanged + ...createLargeArray(15, 35).map((item) => ({ // Items 36-50, but modified + ...item, + name: `Modified ${item.name}`, + metadata: { + ...item.metadata, + updated: '2024-06-01', + }, + })), + ...createLargeArray(10, 60), // New items 61-70 + ]; + + const options = createOptions(); + const [linesLeft, linesRight] = diffArrayCompareKey(leftArray, rightArray, '', '', 0, options); + + // Verify the diff results + expect(linesLeft).toBeDefined(); + expect(linesRight).toBeDefined(); + expect(linesLeft.length).toBeGreaterThan(0); + expect(linesRight.length).toBeGreaterThan(0); + + // Should have proper array structure brackets + expect(linesLeft.length).toBeGreaterThan(0); + expect(linesRight.length).toBeGreaterThan(0); + + // Both sides should start and end with array brackets + expect(linesLeft[0].text).toBe('['); + expect(linesLeft[linesLeft.length - 1].text).toBe(']'); + expect(linesRight[0].text).toBe('['); + expect(linesRight[linesRight.length - 1].text).toBe(']'); + + // Verify that items 1-30 are matched correctly (should appear as equal or modify) + const item1Left = linesLeft.find((line) => line.text.includes('"name": "Item 1"')); + const item1Right = linesRight.find((line) => line.text.includes('"name": "Item 1"')); + expect(item1Left).toBeDefined(); + expect(item1Right).toBeDefined(); + + // Verify that items 31-50 are removed (only in left) + const removedItems = linesLeft.filter((line) => line.type === 'remove' && line.text.includes('"name": "Item 3')); + expect(removedItems.length).toBeGreaterThan(0); + + // Verify that items 61-70 are added (only in right) + const addedItems = linesRight.filter((line) => line.type === 'add' && line.text.includes('"name": "Item 6')); + expect(addedItems.length).toBeGreaterThan(0); + }); + + it('should maintain performance with large arrays and mixed operations', () => { + const startTime = process.hrtime.bigint(); + + // Create much larger arrays to test real-world performance - 1000+ objects + const createComplexArray = (size: number) => { + return Array.from({ length: size }, (_, i) => ({ + id: `complex-${i}`, + data: { + values: Array.from({ length: 5 }, (_, j) => ({ // Reduced nested array size for 1k objects + id: `val-${i}-${j}`, + content: `Content for item ${i}, value ${j}`, + nested: { + deep: { + props: [`prop${j}`, `attr${i % 5}`], + }, + }, + })), + metadata: { + created: Date.now() + i * 1000, + tags: Array.from({ length: i % 3 + 1 }, (_, k) => `tag-${k}`), // Reduced tag array size + }, + }, + })); + }; + + const leftLarge = createComplexArray(1000); // 1000 objects + const rightLarge = [ + ...createComplexArray(600), // First 600 unchanged + ...createComplexArray(200).map((item, i) => ({ // 200 modified items + ...item, + id: `complex-${i + 600}`, + data: { + ...item.data, + metadata: { + ...item.data.metadata, + updated: true, + }, + }, + })), + ...createComplexArray(300).map((item, i) => ({ // 300 new items + ...item, + id: `complex-new-${i}`, + })), + ]; + + const options = createOptions(); + const [linesLeft, linesRight] = diffArrayCompareKey(leftLarge, rightLarge, '', '', 0, options); + + const endTime = process.hrtime.bigint(); + const executionTime = Number(endTime - startTime) / 1_000_000; // Convert to milliseconds + + // Verify results are correct + expect(linesLeft).toBeDefined(); + expect(linesRight).toBeDefined(); + expect(linesLeft.length).toBeGreaterThan(0); + expect(linesRight.length).toBeGreaterThan(0); + + // Performance should be reasonable (1000+ objects should complete in under 1 second) + expect(executionTime).toBeLessThan(1000); + + // Verify structural integrity - both sides should have proper array structure + expect(linesLeft[0].text).toBe('['); + expect(linesLeft[linesLeft.length - 1].text).toBe(']'); + expect(linesRight[0].text).toBe('['); + expect(linesRight[linesRight.length - 1].text).toBe(']'); + }); + + it('should correctly handle complex JSON structures without corruption', () => { + // Test with one type of complex structure - algorithm is generic anyway + const complexObjects = [ + { + id: 'obj1', + data: { + name: 'Test Object 1', + nested: { + values: [1, 2, 3], + config: { enabled: true, priority: 5 }, + }, + }, + }, + { + id: 'obj2', + data: { + name: 'Test Object 2', + nested: { + values: [4, 5], + config: { enabled: false, priority: 1 }, + }, + }, + }, + { + id: 'obj3', + data: { + name: 'Test Object 3', + nested: { + values: [], + config: { enabled: true, priority: 10 }, + }, + }, + }, + ]; + + const modifiedObjects = [ + { + id: 'obj1', + data: { + name: 'Modified Test Object 1', // Modified + nested: { + values: [1, 2, 3, 4], // Modified + config: { enabled: true, priority: 5 }, + }, + }, + }, + { + id: 'obj2', + data: { + name: 'Test Object 2', + nested: { + values: [4, 5], + config: { enabled: false, priority: 1 }, + }, + }, + }, + // obj3 removed + { + id: 'obj4', // New object + data: { + name: 'New Test Object 4', + nested: { + values: [7, 8, 9], + config: { enabled: true, priority: 3 }, + }, + }, + }, + ]; + + const options = createOptions(); + const [linesLeft, linesRight] = diffArrayCompareKey(complexObjects, modifiedObjects, '', '', 0, options); + + // Verify no corruption in output structure + expect(linesLeft).toBeDefined(); + expect(linesRight).toBeDefined(); + + // Should have proper array brackets + expect(linesLeft[0].text).toBe('['); + expect(linesLeft[linesLeft.length - 1].text).toBe(']'); + expect(linesRight[0].text).toBe('['); + expect(linesRight[linesRight.length - 1].text).toBe(']'); + + // Verify that modifications are detected correctly + const nameModified = linesLeft.some((line) => line.text.includes('"name": "Test Object 1"')) && + linesRight.some((line) => line.text.includes('"name": "Modified Test Object 1"')); + expect(nameModified).toBe(true); + + // Verify that removals are handled correctly + const objRemoved = linesLeft.some((line) => line.type === 'remove' && line.text.includes('obj3')); + expect(objRemoved).toBe(true); + + // Verify that additions are handled correctly + const objAdded = linesRight.some((line) => line.type === 'add' && line.text.includes('obj4')); + expect(objAdded).toBe(true); + + // Ensure no lines are corrupted (all should have valid text) + linesLeft.forEach((line) => { + expect(typeof line.text).toBe('string'); + expect(line.level).toBeGreaterThanOrEqual(0); + expect(['equal', 'add', 'remove', 'modify']).toContain(line.type); + }); + + linesRight.forEach((line) => { + expect(typeof line.text).toBe('string'); + expect(line.level).toBeGreaterThanOrEqual(0); + expect(['equal', 'add', 'remove', 'modify']).toContain(line.type); + }); + }); + }); +}); diff --git a/src/utils/diff-array-compare-key.ts b/src/utils/diff-array-compare-key.ts index 5b678af..68eeaa3 100644 --- a/src/utils/diff-array-compare-key.ts +++ b/src/utils/diff-array-compare-key.ts @@ -9,6 +9,131 @@ import stringify from './stringify'; import diffArrayNormal from './diff-array-normal'; import { addArrayClosingBrackets, addArrayOpeningBrackets, addMaxDepthPlaceholder } from './array-bracket-utils'; +// Helper function to check if an item is an object with the required compare key +function isObjectWithCompareKey(item: any, compareKey: string): boolean { + return getType(item) === 'object' && compareKey in item; +} + +// Helper function to create formatted result lines +function createFormattedLine( + level: number, + type: 'equal' | 'add' | 'remove' | 'modify', + item: any, + options: DifferOptions +): DiffResult { + return { + level, + type, + text: formatValue(item, undefined, undefined, options.undefinedBehavior), + }; +} + +// Helper function to process matched objects +function processMatchedObjects( + leftItem: any, + rightItem: any, + level: number, + options: DifferOptions, + linesLeft: DiffResult[], + linesRight: DiffResult[] +): [DiffResult[], DiffResult[]] { + const leftType = getType(leftItem); + const rightType = getType(rightItem); + + if (leftType !== rightType) { + prettyAppendLines(linesLeft, linesRight, '', '', leftItem, rightItem, level + 1, options); + } else if (leftType === 'object') { + // Always recurse into diffObject for aligned objects + linesLeft.push({ level: level + 1, type: 'equal', text: '{' }); + linesRight.push({ level: level + 1, type: 'equal', text: '{' }); + + const keys = Array.from(new Set([...Object.keys(leftItem), ...Object.keys(rightItem)])); + const leftArraysToConcat: DiffResult[][] = []; + const rightArraysToConcat: DiffResult[][] = []; + + for (const key of keys) { + const lVal = leftItem[key]; + const rVal = rightItem[key]; + if (Array.isArray(lVal) && Array.isArray(rVal)) { + // Recursively diff arrays + const [arrL, arrR] = diffArrayRecursive(lVal, rVal, '', '', level + 2, options, [], []); + leftArraysToConcat.push(arrL); + rightArraysToConcat.push(arrR); + } else if (Array.isArray(lVal) || Array.isArray(rVal)) { + // If only one side is array, treat as modification + prettyAppendLines(linesLeft, linesRight, key, key, lVal, rVal, level + 2, options); + } else { + // Use diffObject for non-array values + const [leftLines, rightLines] = diffObject( + { [key]: lVal }, + { [key]: rVal }, + level + 2, + options, + diffArrayRecursive + ); + leftArraysToConcat.push(leftLines); + rightArraysToConcat.push(rightLines); + } + } + + // Concatenate all collected arrays at once for better performance + if (leftArraysToConcat.length > 0) { + linesLeft.push(...leftArraysToConcat.flat()); + } + if (rightArraysToConcat.length > 0) { + linesRight.push(...rightArraysToConcat.flat()); + } + linesLeft.push({ level: level + 1, type: 'equal', text: '}' }); + linesRight.push({ level: level + 1, type: 'equal', text: '}' }); + } else if (leftType === 'array') { + // For nested arrays, recursively apply the same logic + const [resLeft, resRight] = diffArrayRecursive(leftItem, rightItem, '', '', level + 1, options, [], []); + linesLeft = concat(linesLeft, resLeft); + linesRight = concat(linesRight, resRight); + } else if (isEqual(leftItem, rightItem, options)) { + linesLeft.push(createFormattedLine(level + 1, 'equal', leftItem, options)); + linesRight.push(createFormattedLine(level + 1, 'equal', rightItem, options)); + } else { + if (options.showModifications) { + linesLeft.push(createFormattedLine(level + 1, 'modify', leftItem, options)); + linesRight.push(createFormattedLine(level + 1, 'modify', rightItem, options)); + } else { + linesLeft.push(createFormattedLine(level + 1, 'remove', leftItem, options)); + linesLeft.push({ level: level + 1, type: 'equal', text: '' }); + linesRight.push({ level: level + 1, type: 'equal', text: '' }); + linesRight.push(createFormattedLine(level + 1, 'add', rightItem, options)); + } + } + + return [linesLeft, linesRight]; +} + +// Optimized validation function that combines type checking and compare key validation in single pass +function validateArrayForCompareKey(arr: any[], compareKey: string): boolean { + let hasValidObjects = false; + + for (const item of arr) { + const type = getType(item); + if (type === 'object') { + hasValidObjects = true; + if (!(compareKey in item)) return false; + // Check nested arrays in object values + for (const value of Object.values(item)) { + if (Array.isArray(value) && !allObjectsHaveCompareKey(value, compareKey)) { + return false; + } + } + } else if (Array.isArray(item)) { + if (!allObjectsHaveCompareKey(item, compareKey)) return false; + } else { + // Non-object, non-array items make the array invalid for compareKey strategy + return false; + } + } + + return hasValidObjects; +} + // Recursively checks if all objects (including in nested arrays) have the compare key function allObjectsHaveCompareKey(arr: any[], compareKey: string): boolean { for (const item of arr) { @@ -37,18 +162,18 @@ function diffArrayRecursive( level: number, options: DifferOptions, linesLeft: DiffResult[] = [], - linesRight: DiffResult[] = [], + linesRight: DiffResult[] = [] ): [DiffResult[], DiffResult[]] { if (!options.compareKey) { // Fallback to normal diff if no compare key is specified return diffArrayNormal(arrLeft, arrRight, keyLeft, keyRight, level, options, linesLeft, linesRight); } - // If arrays are not of objects, or not all objects have the compare key (including nested), fallback to unordered LCS diff - const isObjectArray = (arr: any[]) => arr.every(item => getType(item) === 'object'); - if (!isObjectArray(arrLeft) || !isObjectArray(arrRight) || - !allObjectsHaveCompareKey(arrLeft, options.compareKey) || - !allObjectsHaveCompareKey(arrRight, options.compareKey)) { + // Early validation with single pass - combine type checking and compare key validation + const leftValidation = validateArrayForCompareKey(arrLeft, options.compareKey); + const rightValidation = validateArrayForCompareKey(arrRight, options.compareKey); + + if (!leftValidation || !rightValidation) { // Use unordered LCS for arrays of primitives, mixed types, or missing compare key return diffArrayNormal(arrLeft, arrRight, keyLeft, keyRight, level, options, linesLeft, linesRight); } @@ -60,174 +185,82 @@ function diffArrayRecursive( } else { const leftProcessed = new Set(); const rightProcessed = new Set(); - + + // For small arrays, use simple O(n²) approach to avoid Map overhead + const useMapOptimization = arrLeft.length > 10 || arrRight.length > 10; + let rightKeyMap: Map | null = null; + + if (useMapOptimization) { + // Build a map of compareKey values to right array items for O(n) matching + rightKeyMap = new Map(); + + for (let j = 0; j < arrRight.length; j++) { + const rightItem = arrRight[j]; + if (isObjectWithCompareKey(rightItem, options.compareKey)) { + const rightKeyValue = rightItem[options.compareKey]; + if (!rightKeyMap.has(rightKeyValue)) { + rightKeyMap.set(rightKeyValue, []); + } + rightKeyMap.get(rightKeyValue)!.push({ item: rightItem, index: j }); + } + } + } + // First pass: find matching objects by compareKey for (let i = 0; i < arrLeft.length; i++) { const leftItem = arrLeft[i]; if (leftProcessed.has(i)) continue; - + // Skip if left item is not an object or doesn't have the compare key - if (getType(leftItem) !== 'object' || !(options.compareKey in leftItem)) { + if (!isObjectWithCompareKey(leftItem, options.compareKey)) { continue; } - + const leftKeyValue = leftItem[options.compareKey]; - - // Find matching item in right array let matchIndex = -1; - for (let j = 0; j < arrRight.length; j++) { - if (rightProcessed.has(j)) continue; - - const rightItem = arrRight[j]; - if (getType(rightItem) !== 'object' || !(options.compareKey in rightItem)) { - continue; + + if (useMapOptimization && rightKeyMap) { + // Use map for O(1) lookup + const candidates = rightKeyMap.get(leftKeyValue); + if (candidates) { + // Find the first unprocessed candidate + for (const candidate of candidates) { + if (!rightProcessed.has(candidate.index)) { + matchIndex = candidate.index; + break; + } + } } - - const rightKeyValue = rightItem[options.compareKey]; - - // Compare key values - if (isEqual(leftKeyValue, rightKeyValue, options)) { - matchIndex = j; - break; + } else { + // Use simple O(n) search for small arrays + for (let j = 0; j < arrRight.length; j++) { + if (rightProcessed.has(j)) continue; + const rightItem = arrRight[j]; + if (isObjectWithCompareKey(rightItem, options.compareKey)) { + const rightKeyValue = rightItem[options.compareKey]; + if (isEqual(leftKeyValue, rightKeyValue, options)) { + matchIndex = j; + break; + } + } } } - + if (matchIndex !== -1) { - // Found a match, compare the objects + // Found a match, process the matched objects const rightItem = arrRight[matchIndex]; - const leftType = getType(leftItem); - const rightType = getType(rightItem); - - if (leftType !== rightType) { - prettyAppendLines( - linesLeft, - linesRight, - '', - '', - leftItem, - rightItem, - level + 1, - options, - ); - } else if (leftType === 'object') { - // Always recurse into diffObject for aligned objects, regardless of recursiveEqual/isEqual - linesLeft.push({ level: level + 1, type: 'equal', text: '{' }); - linesRight.push({ level: level + 1, type: 'equal', text: '{' }); - // For each key, if value is array, apply recursive diff logic - const keys = Array.from(new Set([...Object.keys(leftItem), ...Object.keys(rightItem)])); - for (const key of keys) { - const lVal = leftItem[key]; - const rVal = rightItem[key]; - if (Array.isArray(lVal) && Array.isArray(rVal)) { - // Recursively diff arrays - const [arrL, arrR] = diffArrayRecursive(lVal, rVal, '', '', level + 2, options, [], []); - linesLeft = concat(linesLeft, arrL); - linesRight = concat(linesRight, arrR); - } else if (Array.isArray(lVal) || Array.isArray(rVal)) { - // If only one side is array, treat as modification - prettyAppendLines( - linesLeft, - linesRight, - key, - key, - lVal, - rVal, - level + 2, - options, - ); - } else { - // Use diffObject for non-array values - const [leftLines, rightLines] = diffObject( - { [key]: lVal }, - { [key]: rVal }, - level + 2, - options, - diffArrayRecursive - ); - linesLeft = concat(linesLeft, leftLines); - linesRight = concat(linesRight, rightLines); - } - } - linesLeft.push({ level: level + 1, type: 'equal', text: '}' }); - linesRight.push({ level: level + 1, type: 'equal', text: '}' }); - } else if (leftType === 'array') { - // For nested arrays, recursively apply the same logic - const [resLeft, resRight] = diffArrayRecursive(leftItem, rightItem, '', '', level + 1, options, [], []); - linesLeft = concat(linesLeft, resLeft); - linesRight = concat(linesRight, resRight); - } else if (isEqual(leftItem, rightItem, options)) { - linesLeft.push({ - level: level + 1, - type: 'equal', - text: formatValue(leftItem, undefined, undefined, options.undefinedBehavior), - }); - linesRight.push({ - level: level + 1, - type: 'equal', - text: formatValue(rightItem, undefined, undefined, options.undefinedBehavior), - }); - } else { - if (options.showModifications) { - linesLeft.push({ - level: level + 1, - type: 'modify', - text: formatValue(leftItem, undefined, undefined, options.undefinedBehavior), - }); - linesRight.push({ - level: level + 1, - type: 'modify', - text: formatValue(rightItem, undefined, undefined, options.undefinedBehavior), - }); - } else { - linesLeft.push({ - level: level + 1, - type: 'remove', - text: formatValue(leftItem, undefined, undefined, options.undefinedBehavior), - }); - linesLeft.push({ level: level + 1, type: 'equal', text: '' }); - linesRight.push({ level: level + 1, type: 'equal', text: '' }); - linesRight.push({ - level: level + 1, - type: 'add', - text: formatValue(rightItem, undefined, undefined, options.undefinedBehavior), - }); - } - } - + [linesLeft, linesRight] = processMatchedObjects(leftItem, rightItem, level, options, linesLeft, linesRight); leftProcessed.add(i); rightProcessed.add(matchIndex); } } - - // Second pass: handle remaining items (unmatched) - for (let i = 0; i < arrLeft.length; i++) { - if (leftProcessed.has(i)) continue; - - const leftItem = arrLeft[i]; - const removedLines = stringify(leftItem, undefined, 1, undefined, options.undefinedBehavior).split('\n'); - for (let j = 0; j < removedLines.length; j++) { - linesLeft.push({ - level: level + 1 + (removedLines[j].match(/^\s+/)?.[0]?.length || 0), - type: 'remove', - text: removedLines[j].replace(/^\s+/, '').replace(/,$/g, ''), - }); - linesRight.push({ level: level + 1, type: 'equal', text: '' }); - } + + // Second pass: handle remaining items (unmatched) with potential early exit + if (leftProcessed.size < arrLeft.length) { + [linesLeft, linesRight] = processRemovedItems(arrLeft, leftProcessed, level, options, linesLeft, linesRight); } - - for (let i = 0; i < arrRight.length; i++) { - if (rightProcessed.has(i)) continue; - - const rightItem = arrRight[i]; - const addedLines = stringify(rightItem, undefined, 1, undefined, options.undefinedBehavior).split('\n'); - for (let j = 0; j < addedLines.length; j++) { - linesLeft.push({ level: level + 1, type: 'equal', text: '' }); - linesRight.push({ - level: level + 1 + (addedLines[j].match(/^\s+/)?.[0]?.length || 0), - type: 'add', - text: addedLines[j].replace(/^\s+/, '').replace(/,$/g, ''), - }); - } + if (rightProcessed.size < arrRight.length) { + [linesLeft, linesRight] = processAddedItems(arrRight, rightProcessed, level, options, linesLeft, linesRight); } } @@ -235,7 +268,59 @@ function diffArrayRecursive( return [linesLeft, linesRight]; } +// Helper function to process unmatched items (removed from left) +function processRemovedItems( + arr: any[], + processed: Set, + level: number, + options: DifferOptions, + linesLeft: DiffResult[], + linesRight: DiffResult[] +): [DiffResult[], DiffResult[]] { + for (let i = 0; i < arr.length; i++) { + if (processed.has(i)) continue; + + const item = arr[i]; + const removedLines = stringify(item, undefined, 1, undefined, options.undefinedBehavior).split('\n'); + for (let j = 0; j < removedLines.length; j++) { + linesLeft.push({ + level: level + 1 + (removedLines[j].match(/^\s+/)?.[0]?.length || 0), + type: 'remove', + text: removedLines[j].replace(/^\s+/, '').replace(/,$/g, ''), + }); + linesRight.push({ level: level + 1, type: 'equal', text: '' }); + } + } + return [linesLeft, linesRight]; +} + +// Helper function to process unmatched items (added to right) +function processAddedItems( + arr: any[], + processed: Set, + level: number, + options: DifferOptions, + linesLeft: DiffResult[], + linesRight: DiffResult[] +): [DiffResult[], DiffResult[]] { + for (let i = 0; i < arr.length; i++) { + if (processed.has(i)) continue; + + const item = arr[i]; + const addedLines = stringify(item, undefined, 1, undefined, options.undefinedBehavior).split('\n'); + for (let j = 0; j < addedLines.length; j++) { + linesLeft.push({ level: level + 1, type: 'equal', text: '' }); + linesRight.push({ + level: level + 1 + (addedLines[j].match(/^\s+/)?.[0]?.length || 0), + type: 'add', + text: addedLines[j].replace(/^\s+/, '').replace(/,$/g, ''), + }); + } + } + return [linesLeft, linesRight]; +} + const diffArrayCompareKey = diffArrayRecursive; export default diffArrayCompareKey; -export { allObjectsHaveCompareKey }; \ No newline at end of file +export { allObjectsHaveCompareKey };