diff --git a/src/index.ts b/src/index.ts index 1cc280b33..47fd5ea13 100644 --- a/src/index.ts +++ b/src/index.ts @@ -436,6 +436,9 @@ export * from './util/locking/ResourceLocker'; export * from './util/locking/WrappedExpiringReadWriteLocker'; export * from './util/locking/VoidLocker'; +// Util/Map +export * from './util/map/HashMap'; + // Util/Templates export * from './util/templates/ChainedTemplateEngine'; export * from './util/templates/EjsTemplateEngine'; diff --git a/src/util/map/HashMap.ts b/src/util/map/HashMap.ts new file mode 100644 index 000000000..3bddcd44c --- /dev/null +++ b/src/util/map/HashMap.ts @@ -0,0 +1,78 @@ +import { map } from '../IterableUtil'; + +type Entry = { key: TKey; value: TVal }; + +/** + * A {@link Map} implementation that maps the Key object to a string using the provided hash function. + * This ensures that equal objects that are not the same instance are mapped to the same value. + */ +export class HashMap implements Map { + private readonly hashMap: Map>; + private readonly hashFn: (key: TKey) => string; + + public constructor(hashFn: (key: TKey) => string, iterable?: Iterable) { + this.hashFn = hashFn; + + if (iterable) { + this.hashMap = new Map(map(iterable, ([ key, value ]): [string, Entry] => + [ this.hashFn(key), { key, value }])); + } else { + this.hashMap = new Map(); + } + } + + public has(key: TKey): boolean { + return this.hashMap.has(this.hashFn(key)); + } + + public get(key: TKey): TVal | undefined { + return this.hashMap.get(this.hashFn(key))?.value; + } + + public set(key: TKey, value: TVal): this { + this.hashMap.set(this.hashFn(key), { key, value }); + return this; + } + + public delete(key: TKey): boolean { + return this.hashMap.delete(this.hashFn(key)); + } + + public clear(): void { + this.hashMap.clear(); + } + + public [Symbol.iterator](): IterableIterator<[TKey, TVal]> { + return this.entries(); + } + + public* entries(): IterableIterator<[TKey, TVal]> { + for (const [ , { key, value }] of this.hashMap) { + yield [ key, value ]; + } + } + + public* keys(): IterableIterator { + for (const [ , { key }] of this.hashMap) { + yield key; + } + } + + public* values(): IterableIterator { + for (const [ , { value }] of this.hashMap) { + yield value; + } + } + + public forEach(callbackfn: (value: TVal, key: TKey, map: Map) => void, thisArg?: any): void { + for (const [ key, value ] of this) { + callbackfn.bind(thisArg)(value, key, this); + } + } + + public get size(): number { + return this.hashMap.size; + } + + public readonly [Symbol.toStringTag] = 'HashMap'; +} diff --git a/test/unit/util/map/HashMap.test.ts b/test/unit/util/map/HashMap.test.ts new file mode 100644 index 000000000..cebad5171 --- /dev/null +++ b/test/unit/util/map/HashMap.test.ts @@ -0,0 +1,90 @@ +import { HashMap } from '../../../../src/util/map/HashMap'; + +type KeyType = { field1: string; field2: number }; +type ValueType = { field3: string; field4: number }; + +function hashFn(key: KeyType): string { + return `${key.field1}${key.field2}`; +} + +describe('A HashMap', (): void => { + const key1: KeyType = { field1: 'key', field2: 123 }; + const key1Eq: KeyType = { field1: 'key', field2: 123 }; + const key2: KeyType = { field1: 'key', field2: 321 }; + const unknownKey: KeyType = { field1: 'key', field2: 999 }; + const value1: ValueType = { field3: 'value', field4: 123 }; + const value2: ValueType = { field3: 'value', field4: 321 }; + + let map: HashMap; + + beforeEach(async(): Promise => { + map = new HashMap(hashFn); + + map.set(key1, value1); + map.set(key2, value2); + }); + + it('can check if the map has a key.', async(): Promise => { + expect(map.has(key1)).toBe(true); + expect(map.has(key1Eq)).toBe(true); + expect(map.has(key2)).toBe(true); + expect(map.has(unknownKey)).toBe(false); + }); + + it('can get the values from the map.', async(): Promise => { + expect(map.get(key1)).toBe(value1); + expect(map.get(key1Eq)).toBe(value1); + expect(map.get(key2)).toBe(value2); + expect(map.get(unknownKey)).toBeUndefined(); + }); + + it('can set values.', async(): Promise => { + map.set(key1Eq, value2); + expect(map.get(key1)).toBe(value2); + }); + + it('can set values in the constructor.', async(): Promise => { + map = new HashMap(hashFn, [[ key1, value1 ]]); + expect(map.get(key1)).toBe(value1); + expect(map.has(key2)).toBe(false); + }); + + it('can remove values.', async(): Promise => { + map.delete(key1Eq); + expect(map.has(key1)).toBe(false); + expect(map.has(key2)).toBe(true); + }); + + it('can clear the map.', async(): Promise => { + map.clear(); + expect(map.has(key1)).toBe(false); + expect(map.has(key2)).toBe(false); + }); + + it('can iterate over the map.', async(): Promise => { + expect([ ...map ]).toEqual([[ key1, value1 ], [ key2, value2 ]]); + expect([ ...map.entries() ]).toEqual([[ key1, value1 ], [ key2, value2 ]]); + expect([ ...map.keys() ]).toEqual([ key1, key2 ]); + expect([ ...map.values() ]).toEqual([ value1, value2 ]); + }); + + it('supports a forEach call.', async(): Promise => { + const result: string[] = []; + map.forEach((value): void => { + result.push(value.field3); + }); + expect(result).toEqual([ 'value', 'value' ]); + }); + + it('can return the size.', async(): Promise => { + expect(map.size).toBe(2); + map.delete(key1); + expect(map.size).toBe(1); + map.clear(); + expect(map.size).toBe(0); + }); + + it('returns a string tag.', async(): Promise => { + expect(map[Symbol.toStringTag]).toBe('HashMap'); + }); +});