diff --git a/src/index.ts b/src/index.ts index 3106bc9d0..c9eaada7b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -98,6 +98,7 @@ export { TableWithSelectableRows } from './tables/TableWithSelectableRows' export { cleanString } from './utils/cleanString' export { customDayjs } from './utils/customDayjs' export { getCoordinates, coordinatesAreDistinct } from './utils/coordinates' +export { getHashFromCollection } from './utils/getHashFromCollection' export { getLocalizedDayjs } from './utils/getLocalizedDayjs' export { getOptionsFromIdAndName } from './utils/getOptionsFromIdAndName' export { getOptionsFromLabelledEnum } from './utils/getOptionsFromLabelledEnum' diff --git a/src/utils/__tests__/getHashFromCollection.test.ts b/src/utils/__tests__/getHashFromCollection.test.ts new file mode 100644 index 000000000..e2502bc51 --- /dev/null +++ b/src/utils/__tests__/getHashFromCollection.test.ts @@ -0,0 +1,71 @@ +import { expect } from '@jest/globals' + +import { getHashFromCollection } from '../getHashFromCollection' + +describe('utils/getHashFromCollection()', () => { + it('should return consistent hashes for collections with the same content', () => { + const firstCollection = [ + { id: 1, name: 'Item A' }, + { id: 2, name: 'Item B' } + ] + const secondCollection = [ + { id: 1, name: 'Item A' }, + { id: 2, name: 'Item B' } + ] + + const firstResult = getHashFromCollection(firstCollection) + const secondResult = getHashFromCollection(secondCollection) + + expect(firstResult).toBe(secondResult) + }) + + it('should return different hashes for collections with different content', () => { + const firstCollection = [{ id: 1, name: 'Item A' }] + const secondCollection = [{ id: 2, name: 'Item B' }] + + const firstResult = getHashFromCollection(firstCollection) + const secondResult = getHashFromCollection(secondCollection) + + expect(firstResult).not.toBe(secondResult) + }) + + it('should return consistent hashes regardless of item order when using `sortingKey`', () => { + const firstCollection = [ + { id: 2, name: 'Item B' }, + { id: 1, name: 'Item A' } + ] + const secondCollection = [ + { id: 1, name: 'Item A' }, + { id: 2, name: 'Item B' } + ] + + const firstResult = getHashFromCollection(firstCollection, 'id') + const secondResult = getHashFromCollection(secondCollection, 'id') + + expect(firstResult).toBe(secondResult) + }) + + it('should return different hashes based on item order when no `sortingKey` is provided', () => { + const firstCollection = [ + { id: 2, name: 'Item B' }, + { id: 1, name: 'Item A' } + ] + const secondCollection = [ + { id: 1, name: 'Item A' }, + { id: 2, name: 'Item B' } + ] + + const firstResult = getHashFromCollection(firstCollection) + const secondResult = getHashFromCollection(secondCollection) + + expect(firstResult).not.toBe(secondResult) + }) + + it('should return "0" for an empty collection', () => { + const collection = [] + + const result = getHashFromCollection(collection) + + expect(result).toBe('0') + }) +}) diff --git a/src/utils/getHashFromCollection.ts b/src/utils/getHashFromCollection.ts new file mode 100644 index 000000000..31f434410 --- /dev/null +++ b/src/utils/getHashFromCollection.ts @@ -0,0 +1,52 @@ +/* eslint-disable no-bitwise */ + +import { sortBy } from 'lodash/fp' + +/** + * Generates a simple hash from a collection. + * + * @description + * The function converts the collection into a string representation (JSON) to generate a hash for that string. + * + * In order to keep the hash generation independant from the collection order, you should provide a sorting key. + * + * Note: This hash is designed for simplicity and speed, it can't be used for cryptographic security. + * + * @param collection An array of objects + * @param sortingKey A sortable object key to keep the hash consistent regardless of the collection order + * @returns A string representation of the hash + */ +export function getHashFromCollection = Record>( + collection: T[], + sortingKey?: keyof T +): string { + // If the collection is empty, simply return "0" + if (collection.length === 0) { + return '0' + } + + const sortedCollection = sortingKey ? sortBy([sortingKey], collection) : collection + + const collectionAsJson = JSON.stringify(sortedCollection) + + // Initialize hash value + let hashAsNumber = 0 + + let characterAsUnicodeValue: number + + // Iterate over each character in the string representation of the collection + for (let index = 0; index < collectionAsJson.length; index += 1) { + characterAsUnicodeValue = collectionAsJson.charCodeAt(index) + + // Modify the hash value based on the current character's code. + // The operation `(hashAsNumber << 5) - hashAsNumber` rapidly increases the hash value, + // and adding the character code `+ characterAsUnicodeValue` further modifies it. + // This produces a wide range of hash values for different inputs. + hashAsNumber = (hashAsNumber << 5) - hashAsNumber + characterAsUnicodeValue + + // This operation ensures we get a 32-bit integer, effectively wrapping the hash value if it exceeds 32 bit + hashAsNumber |= 0 + } + + return hashAsNumber.toString() +}