object-deep-compare
Version:
A type-safe collection of comparison methods for objects and arrays in TypeScript/JavaScript
350 lines (321 loc) • 12.7 kB
text/typescript
import { ComparisonOptions, DetailedDifference } from '../types';
import { CircularReferenceError } from '../core/errors';
import { ValidateObjectsAgainstSchemas } from '../core/schema-validation';
import { shouldComparePath } from '../core/path-filtering';
import { handleDepthComparison } from '../core/value-comparison';
import { areValuesEqual, isObject } from '../core/utils';
import { Memoize, compareValuesWithDetailedDifferencesKeyFn } from '../utils/memoization';
/**
* Compares two objects and returns detailed information about differences
*
* @param firstObject - First object to compare
* @param secondObject - Second object to compare
* @param pathOfConflict - Starting path for conflict (optional)
* @param options - Optional comparison options (strict, circularReferences, pathFilter)
* @returns Array of detailed differences
*/
export const CompareValuesWithDetailedDifferences = <T extends Record<string, any>, U extends Record<string, any>>(
firstObject: T,
secondObject: U,
pathOfConflict: string = '',
options: ComparisonOptions = {}
): DetailedDifference[] => {
// Perform schema validation if specified
if (options.schemaValidation) {
ValidateObjectsAgainstSchemas(firstObject, secondObject, options.schemaValidation);
}
return _CompareValuesWithDetailedDifferences(
firstObject,
secondObject,
pathOfConflict,
options
);
};
/**
* Internal implementation of CompareValuesWithDetailedDifferences
* This is separated to allow for memoization
*/
const _CompareValuesWithDetailedDifferences = <T extends Record<string, any>, U extends Record<string, any>>(
firstObject: T,
secondObject: U,
pathOfConflict: string = '',
options: ComparisonOptions = {}
): DetailedDifference[] => {
// Extract options
const { circularReferences = 'error', pathFilter } = options;
// If the objects are the same reference, there are no differences
if (Object.is(firstObject, secondObject)) {
return [];
}
// Check for obvious circular references in the top-level objects
if (circularReferences === 'error') {
// Look for direct self-references in both objects
for (const key in firstObject) {
if (firstObject[key] === firstObject) {
throw new CircularReferenceError(key);
}
}
for (const key in secondObject) {
if (secondObject[key] === secondObject) {
throw new CircularReferenceError(key);
}
}
}
// Handle path filtering specially for include mode
if (pathFilter && pathFilter.mode === 'include') {
// For include mode, we'll do a more direct comparison
const differences: DetailedDifference[] = [];
// Special handling for array patterns
const hasArrayPatterns = pathFilter.patterns.some(p => p.startsWith('['));
// Function to compare properties based on path patterns
const comparePropertiesRecursively = (
first: any,
second: any,
currentPath: string
) => {
// Special case for top-level array with [*] patterns
if (currentPath === '' && Array.isArray(first) && Array.isArray(second) && hasArrayPatterns) {
for (let i = 0; i < Math.max(first.length, second.length); i++) {
const elemPath = `[${i}]`;
if (i >= first.length) {
// Handle added elements
for (const pattern of pathFilter.patterns) {
if (pattern.startsWith('[*]')) {
const propPattern = pattern.substring(3); // Remove '[*]'
if (propPattern) {
// Check properties inside array element
if (typeof second[i] === 'object' && second[i] !== null) {
const propKey = propPattern.startsWith('.') ? propPattern.substring(1) : propPattern;
if (propKey in second[i]) {
differences.push({
path: `[${i}]${propPattern}`,
type: 'added',
oldValue: undefined,
newValue: second[i][propKey]
});
}
}
} else {
// Match the entire element
differences.push({
path: elemPath,
type: 'added',
oldValue: undefined,
newValue: second[i]
});
}
}
}
} else if (i >= second.length) {
// Handle removed elements
for (const pattern of pathFilter.patterns) {
if (pattern.startsWith('[*]')) {
const propPattern = pattern.substring(3); // Remove '[*]'
if (propPattern) {
// Check properties inside array element
if (typeof first[i] === 'object' && first[i] !== null) {
const propKey = propPattern.startsWith('.') ? propPattern.substring(1) : propPattern;
if (propKey in first[i]) {
differences.push({
path: `[${i}]${propPattern}`,
type: 'removed',
oldValue: first[i][propKey],
newValue: undefined
});
}
}
} else {
// Match the entire element
differences.push({
path: elemPath,
type: 'removed',
oldValue: first[i],
newValue: undefined
});
}
}
}
} else {
// Compare existing elements
for (const pattern of pathFilter.patterns) {
if (pattern.startsWith('[*]')) {
const propPattern = pattern.substring(3); // Remove '[*]'
if (propPattern) {
// Check properties inside array element
const propKey = propPattern.startsWith('.') ? propPattern.substring(1) : propPattern;
if (typeof first[i] === 'object' && first[i] !== null &&
typeof second[i] === 'object' && second[i] !== null) {
if (propKey in first[i] && propKey in second[i]) {
if (!areValuesEqual(first[i][propKey], second[i][propKey], options.strict)) {
differences.push({
path: `[${i}]${propPattern}`,
type: 'changed',
oldValue: first[i][propKey],
newValue: second[i][propKey]
});
}
} else if (propKey in first[i]) {
differences.push({
path: `[${i}]${propPattern}`,
type: 'removed',
oldValue: first[i][propKey],
newValue: undefined
});
} else if (propKey in second[i]) {
differences.push({
path: `[${i}]${propPattern}`,
type: 'added',
oldValue: undefined,
newValue: second[i][propKey]
});
}
}
} else {
// Match the entire element
if (!areValuesEqual(first[i], second[i], options.strict)) {
differences.push({
path: elemPath,
type: 'changed',
oldValue: first[i],
newValue: second[i]
});
}
}
}
}
}
}
return;
}
// Skip if we're not at a pattern that should be included
if (!shouldComparePath(currentPath, pathFilter)) {
// But check if any children would match before skipping
let matchesChild = false;
for (const pattern of pathFilter.patterns) {
if (pattern.startsWith(currentPath + '.') ||
(currentPath === '' && !pattern.startsWith('.'))) {
matchesChild = true;
break;
}
}
if (!matchesChild) {
return;
}
}
// For simple types, compare directly
if (typeof first !== 'object' || first === null ||
typeof second !== 'object' || second === null) {
if (!areValuesEqual(first, second, options.strict)) {
differences.push({
path: currentPath,
type: 'changed',
oldValue: first,
newValue: second
});
}
return;
}
// Handle arrays
if (Array.isArray(first) && Array.isArray(second)) {
for (let i = 0; i < Math.max(first.length, second.length); i++) {
const elemPath = `${currentPath}[${i}]`;
if (i >= first.length) {
// Element added in second array
if (shouldComparePath(elemPath, pathFilter)) {
differences.push({
path: elemPath,
type: 'added',
oldValue: undefined,
newValue: second[i]
});
}
} else if (i >= second.length) {
// Element removed in second array
if (shouldComparePath(elemPath, pathFilter)) {
differences.push({
path: elemPath,
type: 'removed',
oldValue: first[i],
newValue: undefined
});
}
} else {
// Compare elements
comparePropertiesRecursively(first[i], second[i], elemPath);
}
}
return;
}
// Handle objects
const allKeys = new Set([...Object.keys(first), ...Object.keys(second)]);
for (const key of allKeys) {
const propPath = currentPath ? `${currentPath}.${key}` : key;
if (!(key in first)) {
// Property added in second object
if (shouldComparePath(propPath, pathFilter)) {
differences.push({
path: propPath,
type: 'added',
oldValue: undefined,
newValue: second[key]
});
}
} else if (!(key in second)) {
// Property removed in second object
if (shouldComparePath(propPath, pathFilter)) {
differences.push({
path: propPath,
type: 'removed',
oldValue: first[key],
newValue: undefined
});
}
} else {
// Compare properties
comparePropertiesRecursively(first[key], second[key], propPath);
}
}
};
// Start the recursive comparison
comparePropertiesRecursively(firstObject, secondObject, pathOfConflict);
return differences;
}
try {
// For exclude mode, use the unified depth handling function
const differences = handleDepthComparison(firstObject, secondObject, pathOfConflict, options, false, true);
if (!Array.isArray(differences)) {
return [];
}
// Type assertion because we know the differences will be DetailedDifference objects due to detailed=true parameter
const detailedDifferences = differences as DetailedDifference[];
// Filter the differences based on the path filter settings
if (pathFilter && pathFilter.patterns && pathFilter.patterns.length > 0) {
return detailedDifferences.filter(diff => {
// Skip undefined paths (shouldn't happen, but just in case)
if (!diff.path) {
return false;
}
// For 'exclude' mode: keep if NOT matching any pattern
const matchesPattern = !shouldComparePath(diff.path, pathFilter);
return !matchesPattern;
});
}
return detailedDifferences;
} catch (error) {
if (error instanceof CircularReferenceError) {
if (circularReferences === 'error') {
throw error;
}
// If circularReferences is 'ignore' and we're getting an error, return empty array
return [];
}
throw error;
}
};
/**
* Memoized version of CompareValuesWithDetailedDifferences
*/
export const MemoizedCompareValuesWithDetailedDifferences = Memoize(
CompareValuesWithDetailedDifferences,
compareValuesWithDetailedDifferencesKeyFn
);