diff --git a/lib/utils/objects.js b/lib/utils/objects.js index 0a96ab18f..6ec2f31e4 100644 --- a/lib/utils/objects.js +++ b/lib/utils/objects.js @@ -1,149 +1,153 @@ // @flow import stableStringify from 'fast-json-stable-stringify'; import invariant from 'invariant'; import _isEqual from 'lodash/fp/isEqual.js'; import _isPlainObject from 'lodash/fp/isPlainObject.js'; import stringHash from 'string-hash'; type ObjectMap = { +[key: K]: T }; type NestedObjectMap = { +[key: K]: T | NestedObjectMap }; function findMaximumDepth(obj: Object): ?{ path: string, depth: number } { let longestPath = null; let longestDepth = null; for (const key in obj) { const value = obj[key]; if (typeof value !== 'object' || !value) { if (!longestDepth) { longestPath = key; longestDepth = 1; } continue; } const childResult = findMaximumDepth(obj[key]); if (!childResult) { continue; } const { path, depth } = childResult; const ourDepth = depth + 1; if (longestDepth === null || ourDepth > longestDepth) { longestPath = `${key}.${path}`; longestDepth = ourDepth; } } if (!longestPath || !longestDepth) { return null; } return { path: longestPath, depth: longestDepth }; } function values(map: ObjectMap): T[] { return Object.values ? // https://github.com/facebook/flow/issues/2221 // $FlowFixMe - Object.values currently does not have good flow support Object.values(map) : Object.keys(map).map((key: K): T => map[key]); } function keys(map: ObjectMap): K[] { return Object.keys(map); } function entries(map: ObjectMap): [K, T][] { // $FlowFixMe - flow treats the values as mixed, but we know that they are T return Object.entries(map); } function assignValueWithKey( obj: NestedObjectMap, key: K, value: T | NestedObjectMap, ): NestedObjectMap { return { ...obj, ...Object.fromEntries([[key, value]]), }; } function hash(obj: ?Object): number { if (!obj) { return -1; } return stringHash(stableStringify(obj)); } // This function doesn't look at the order of the hashes inside of the array // e.g `combineUnorderedHashes([1,2,3]) === combineUnorderedHashes([3,1,2])` // so it should only be used if the hashes include their ordering in them // somehow (e.g. `RawThreadInfo` contains `id`) function combineUnorderedHashes(hashes: $ReadOnlyArray): number { return hashes.reduce((a, v) => a ^ v, 0); } // returns an object with properties from obj1 not included in obj2 function deepDiff( obj1: NestedObjectMap, obj2: NestedObjectMap, ): NestedObjectMap { let diff: NestedObjectMap = {}; keys(obj1).forEach((key: K) => { if (_isEqual(obj1[key], obj2[key])) { return; } if (!_isPlainObject(obj1[key]) || !_isPlainObject(obj2[key])) { diff = assignValueWithKey(diff, key, obj1[key]); return; } const nestedObj1: ObjectMap = (obj1[key]: any); const nestedObj2: ObjectMap = (obj2[key]: any); const nestedDiff = deepDiff(nestedObj1, nestedObj2); if (Object.keys(nestedDiff).length > 0) { diff = assignValueWithKey(diff, key, nestedDiff); } }); return diff; } function assertObjectsAreEqual( processedObject: ObjectMap, expectedObject: ObjectMap, message: string, ) { if (_isEqual(processedObject)(expectedObject)) { return; } const dataProcessedButNotExpected = deepDiff(processedObject, expectedObject); const dataExpectedButNotProcessed = deepDiff(expectedObject, processedObject); invariant( false, `${message}: Objects should be equal.` + ` Data processed but not expected:` + ` ${JSON.stringify(dataProcessedButNotExpected)}` + ` Data expected but not processed:` + ` ${JSON.stringify(dataExpectedButNotProcessed)}`, ); } function invertObjectToMap(obj: { +[K]: V }): Map { const invertedMap = new Map(); for (const key of Object.keys(obj)) { + invariant( + !invertedMap.has(obj[key]), + `invertObjectToMap: obj[${key}] is already in invertedMap`, + ); invertedMap.set(obj[key], key); } return invertedMap; } export { findMaximumDepth, values, hash, combineUnorderedHashes, assertObjectsAreEqual, deepDiff, entries, invertObjectToMap, }; diff --git a/lib/utils/objects.test.js b/lib/utils/objects.test.js index 1bff2ed00..90eed8dce 100644 --- a/lib/utils/objects.test.js +++ b/lib/utils/objects.test.js @@ -1,103 +1,155 @@ // @flow -import { deepDiff } from './objects.js'; +import { deepDiff, invertObjectToMap } from './objects.js'; describe('deepDiff tests', () => { it('should return an empty object if the objects are identical', () => { const obj1 = { key1: 'value1', key2: { foo: 'bar' }, }; const obj2 = { key1: 'value1', key2: { foo: 'bar' }, }; const diff = deepDiff(obj1, obj2); expect(diff).toEqual({}); }); it('should return the differences between two objects', () => { const obj1 = { key1: 'value1', key2: { prop: 'a' }, }; const obj2 = { key1: 'value2', key2: { prop: 'b' }, }; const diff = deepDiff(obj1, obj2); expect(diff).toEqual({ key1: 'value1', key2: { prop: 'a', }, }); }); it('should handle objects nested in objects', () => { const obj1 = { key1: 'value1', key2: { prop: 'a', nested: { xyz: 123 } }, }; const obj2 = { key1: 'value1', key2: { prop: 'a', nested: { xyz: 124 } }, }; const diff = deepDiff(obj1, obj2); expect(diff).toEqual({ key2: { nested: { xyz: 123, }, }, }); }); it('should handle nested objects with null and undefined values', () => { const obj1 = { key1: null, key2: { prop: undefined }, }; const obj2 = { key1: undefined, key2: { prop: null }, }; const diff = deepDiff(obj1, obj2); expect(diff).toEqual({ key1: null, key2: { prop: undefined, }, }); }); it('should handle objects with different value types', () => { const obj1 = { key1: 'value1', key2: 123, }; const obj2 = { key1: 'value1', key2: '123', }; const diff = deepDiff(obj1, obj2); expect(diff).toEqual({ key2: 123, }); }); it('should handle objects with array value types', () => { const obj1 = { key1: ['value1'], key2: ['a', 1], }; const obj2 = { key1: ['value1'], key2: ['a', 2], }; const diff = deepDiff(obj1, obj2); expect(diff).toEqual({ key2: ['a', 1], }); }); }); + +// NOTE: `invertObjectToMap` unit tests were generated by GitHub Copilot. +describe('invertObjectToMap', () => { + it('should invert an object to a map', () => { + const obj = { + key1: 'value1', + key2: 'value2', + }; + const map = new Map(); + map.set('value1', 'key1'); + map.set('value2', 'key2'); + expect(invertObjectToMap(obj)).toEqual(map); + }); + + it('should invert an object with non-string keys to a map', () => { + const obj = { + key1: 1, + key2: 2, + }; + const map = new Map(); + map.set(1, 'key1'); + map.set(2, 'key2'); + expect(invertObjectToMap(obj)).toEqual(map); + }); + + it('should invert an object with BigInt values to map with BigInt keys', () => { + const obj = { + // $FlowIssue bigint-unsupported + key1: 1n, + // $FlowIssue bigint-unsupported + key2: 2n, + }; + const map = new Map(); + // $FlowIssue bigint-unsupported + map.set(1n, 'key1'); + // $FlowIssue bigint-unsupported + map.set(2n, 'key2'); + expect(invertObjectToMap(obj)).toEqual(map); + }); + + it('should invert an object with null values to map with null keys', () => { + const obj = { + key1: null, + key2: null, + }; + const map = new Map(); + map.set(null, 'key2'); + expect(() => invertObjectToMap(obj)).toThrowError( + 'invertObjectToMap: obj[key2] is already in invertedMap', + ); + }); +});