diff --git a/packages/d2ts/src/utils.ts b/packages/d2ts/src/utils.ts index e458d98..093e396 100644 --- a/packages/d2ts/src/utils.ts +++ b/packages/d2ts/src/utils.ts @@ -72,17 +72,46 @@ export function chunkedArrayPush(array: unknown[], other: unknown[]) { const hashCache = new WeakMap() +/** + * Replacer function for JSON.stringify that converts unsupported types to strings + */ +function hashReplacer(_key: string, value: any): any { + if (typeof value === 'bigint') { + return String(value) + } else if (typeof value === 'symbol') { + return String(value) + } else if (typeof value === 'function') { + return String(value) + } else if (value === undefined) { + return 'undefined' + } else if (value instanceof Map) { + return `Map(${JSON.stringify(Array.from(value.entries()), hashReplacer)})` + } else if (value instanceof Set) { + return `Set(${JSON.stringify(Array.from(value.values()), hashReplacer)})` + } + return value +} + /** * A hash method that caches the hash of a value in a week map */ export function hash(data: any): string | number { - if (data === null || data === undefined || typeof data !== 'object') { - return JSON.stringify(data) + if ( + data === null || + data === undefined || + (typeof data !== 'object' && typeof data !== 'function') + ) { + // Can't be cached in the weak map because it's not an object + const serialized = JSON.stringify(data, hashReplacer) + return murmurhash.murmur3(serialized) } if (hashCache.has(data)) { return hashCache.get(data) } - return murmurhash.murmur3(JSON.stringify(JSON.stringify(data))) + const serialized = JSON.stringify(data, hashReplacer) + const hashValue = murmurhash.murmur3(JSON.stringify(serialized)) + hashCache.set(data, hashValue) + return hashValue } diff --git a/packages/d2ts/tests/utils.test.ts b/packages/d2ts/tests/utils.test.ts index ac95d1c..ee563fb 100644 --- a/packages/d2ts/tests/utils.test.ts +++ b/packages/d2ts/tests/utils.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { DefaultMap, WeakRefMap } from '../src/utils.js' +import { DefaultMap, WeakRefMap, hash } from '../src/utils.js' describe('DefaultMap', () => { it('should return default value for missing keys', () => { @@ -76,3 +76,321 @@ describe('WeakRefMap', () => { // } // }) }) + +describe('hash', () => { + describe('primitive types', () => { + it('should hash null', () => { + const result = hash(null) + expect(typeof result).toBe('number') + }) + + it('should hash undefined', () => { + const result = hash(undefined) + expect(typeof result).toBe('number') + }) + + it('should hash strings', () => { + const result1 = hash('hello') + const result2 = hash('') + const result3 = hash('test with spaces') + const result4 = hash('special\nchars\t"') + + expect(typeof result1).toBe('number') + expect(typeof result2).toBe('number') + expect(typeof result3).toBe('number') + expect(typeof result4).toBe('number') + + // Same strings should have same hash + expect(hash('hello')).toBe(result1) + }) + + it('should hash numbers', () => { + const result1 = hash(42) + const result2 = hash(0) + const result3 = hash(-1) + const result4 = hash(3.14159) + const result5 = hash(Infinity) + const result6 = hash(-Infinity) + const result7 = hash(NaN) + + expect(typeof result1).toBe('number') + expect(typeof result2).toBe('number') + expect(typeof result3).toBe('number') + expect(typeof result4).toBe('number') + expect(typeof result5).toBe('number') + expect(typeof result6).toBe('number') + expect(typeof result7).toBe('number') + + // Same numbers should have same hash + expect(hash(42)).toBe(result1) + }) + + it('should hash booleans', () => { + const result1 = hash(true) + const result2 = hash(false) + + expect(typeof result1).toBe('number') + expect(typeof result2).toBe('number') + expect(result1).not.toBe(result2) + + // Same booleans should have same hash + expect(hash(true)).toBe(result1) + expect(hash(false)).toBe(result2) + }) + + it('should hash bigint', () => { + const result1 = hash(123n) + const result2 = hash(456n) + const result3 = hash(123n) + + expect(typeof result1).toBe('number') + expect(typeof result2).toBe('number') + expect(typeof result3).toBe('number') + expect(result1).toBe(result3) // Same bigint should have same hash + expect(result1).not.toBe(result2) // Different bigints should have different hash + }) + + it('should hash symbols', () => { + const sym1 = Symbol('test') + const sym2 = Symbol('test') + const sym3 = Symbol('different') + + const result1 = hash(sym1) + const result2 = hash(sym2) + const result3 = hash(sym3) + + expect(typeof result1).toBe('number') + expect(typeof result2).toBe('number') + expect(typeof result3).toBe('number') + // Note: Different symbol instances with same description have same string representation + expect(result1).toBe(result2) + expect(result1).not.toBe(result3) + }) + }) + + describe('object types', () => { + it('should hash plain objects', () => { + const obj1 = { a: 1, b: 2 } + const obj2 = { b: 2, a: 1 } // Different key order + + const hash1 = hash(obj1) + const hash2 = hash(obj2) + + expect(typeof hash1).toBe('number') + expect(typeof hash2).toBe('number') + // Note: Different key orders might produce different hashes depending on JSON.stringify behavior + }) + + it('should hash arrays', () => { + const arr1 = [1, 2, 3] + const arr2 = [1, 2, 3] + const arr3 = [3, 2, 1] + + const hash1 = hash(arr1) + const hash2 = hash(arr2) + const hash3 = hash(arr3) + + expect(typeof hash1).toBe('number') + expect(hash1).toBe(hash2) // Same content should have same hash + expect(hash1).not.toBe(hash3) // Different content should have different hash + }) + + it('should hash Date objects', () => { + const date1 = new Date('2023-01-01') + const date2 = new Date('2023-01-01') + const date3 = new Date('2023-01-02') + + const hash1 = hash(date1) + const hash2 = hash(date2) + const hash3 = hash(date3) + + expect(typeof hash1).toBe('number') + expect(hash1).toBe(hash2) // Same date should have same hash + expect(hash1).not.toBe(hash3) // Different dates should have different hash + }) + + it('should hash RegExp objects', () => { + const regex1 = /test/g + const regex2 = /test/g + const regex3 = /different/i + + const hash1 = hash(regex1) + const hash2 = hash(regex2) + const hash3 = hash(regex3) + + expect(typeof hash1).toBe('number') + expect(hash1).toBe(hash2) // Same regex should have same hash + // Note: RegExp objects serialize to empty objects {}, so they all produce the same hash + expect(hash1).toBe(hash3) // All RegExp objects have the same hash + }) + + it('should hash nested objects', () => { + const nested1 = { a: { b: { c: 1 } } } + const nested2 = { a: { b: { c: 1 } } } + const nested3 = { a: { b: { c: 2 } } } + + const hash1 = hash(nested1) + const hash2 = hash(nested2) + const hash3 = hash(nested3) + + expect(typeof hash1).toBe('number') + expect(hash1).toBe(hash2) + expect(hash1).not.toBe(hash3) + }) + + it('should hash functions', () => { + const func1 = function test() { return 1 } + const func2 = function test() { return 1 } + const func3 = function different() { return 2 } + + const hash1 = hash(func1) + const hash2 = hash(func2) + const hash3 = hash(func3) + + expect(typeof hash1).toBe('number') + expect(typeof hash2).toBe('number') + expect(typeof hash3).toBe('number') + expect(hash1).toBe(hash2) // Same function definition should have same hash + expect(hash1).not.toBe(hash3) // Different function should have different hash + }) + + it('should hash Set objects', () => { + const set1 = new Set([1, 2, 3]) + const set2 = new Set([1, 2, 3]) + const set3 = new Set([1, 2, 3, 4]) + + const hash1 = hash(set1) + const hash2 = hash(set2) + const hash3 = hash(set3) + + expect(typeof hash1).toBe('number') + expect(hash1).toBe(hash2) // Same content should have same hash + expect(hash1).not.toBe(hash3) // Different content should have different hash + }) + + it('should hash Map objects', () => { + const map1 = new Map([['a', 1], ['b', 2]]) + const map2 = new Map([['a', 1], ['b', 2]]) + const map3 = new Map([['a', 1], ['b', 2], ['c', 3]]) + + const hash1 = hash(map1) + const hash2 = hash(map2) + const hash3 = hash(map3) + + expect(typeof hash1).toBe('number') + expect(hash1).toBe(hash2) // Same content should have same hash + expect(hash1).not.toBe(hash3) // Different content should have different hash + }) + + it('should hash Maps and Sets with unsupported types', () => { + // Map with BigInt values + const mapWithBigInt1 = new Map([['a', 123n], ['b', 456n]]) + const mapWithBigInt2 = new Map([['a', 123n], ['b', 456n]]) + const mapWithBigInt3 = new Map([['a', 123n], ['b', 789n]]) + + const hash1 = hash(mapWithBigInt1) + const hash2 = hash(mapWithBigInt2) + const hash3 = hash(mapWithBigInt3) + + expect(typeof hash1).toBe('number') + expect(hash1).toBe(hash2) // Same BigInt content should have same hash + expect(hash1).not.toBe(hash3) // Different BigInt content should have different hash + + // Set with Symbol values + const sym1 = Symbol('test') + const sym2 = Symbol('different') + const setWithSymbols1 = new Set([sym1, sym2]) + const setWithSymbols2 = new Set([sym1, sym2]) + const setWithSymbols3 = new Set([sym1]) + + const hash4 = hash(setWithSymbols1) + const hash5 = hash(setWithSymbols2) + const hash6 = hash(setWithSymbols3) + + expect(typeof hash4).toBe('number') + expect(hash4).toBe(hash5) // Same Symbol content should have same hash + expect(hash4).not.toBe(hash6) // Different Symbol content should have different hash + }) + }) + + describe('caching behavior', () => { + it('should cache hash values for objects', () => { + const obj = { test: 'value' } + + const hash1 = hash(obj) + const hash2 = hash(obj) + + expect(hash1).toBe(hash2) + expect(typeof hash1).toBe('number') + }) + + it('should return cached values on subsequent calls', () => { + const obj = { complex: { nested: { data: [1, 2, 3] } } } + + // First call should compute and cache + const hash1 = hash(obj) + + // Second call should return cached value + const hash2 = hash(obj) + + expect(hash1).toBe(hash2) + expect(typeof hash1).toBe('number') + }) + + it('should not cache primitive values', () => { + // Primitives should not be cached as they use JSON.stringify directly + const hash1 = hash('test') + const hash2 = hash('test') + + expect(hash1).toBe(hash2) + expect(typeof hash1).toBe('number') + }) + }) + + describe('edge cases', () => { + it('should handle empty objects and arrays', () => { + expect(typeof hash({})).toBe('number') + expect(typeof hash([])).toBe('number') + expect(hash({})).not.toBe(hash([])) + }) + + it('should handle objects with null and undefined values', () => { + const obj1 = { a: null, b: undefined } + const obj2 = { a: null, b: undefined } + + const hash1 = hash(obj1) + const hash2 = hash(obj2) + + expect(hash1).toBe(hash2) + expect(typeof hash1).toBe('number') + }) + + it('should handle mixed type arrays', () => { + const mixedArray = [1, 'string', true, null, { key: 'value' }] + const sameArray = [1, 'string', true, null, { key: 'value' }] + + const hash1 = hash(mixedArray) + const hash2 = hash(sameArray) + + expect(hash1).toBe(hash2) + expect(typeof hash1).toBe('number') + }) + + it('should produce consistent hashes for same content', () => { + const obj = { + string: 'test', + number: 42, + boolean: true, + array: [1, 2, 3], + nested: { inner: 'value' } + } + + // Multiple calls should return the same hash + const hashes = Array.from({ length: 5 }, () => hash(obj)) + const firstHash = hashes[0] + + expect(hashes.every(h => h === firstHash)).toBe(true) + expect(typeof firstHash).toBe('number') + }) + }) +})