diff --git a/README.md b/README.md index 2eff0ce..487249a 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ The `options` argument is a superset of the options defined for [object-hash](ht - `useToJsonTransform` - if true, only use JSON-serializable properties when computing hashes, equality, etc. (default: false) - > _NOTE: This setting overrides both `transformer` and `mapValueTransformer`_ + > _NOTE: This transform will always be applied BEFORE `transformer` and `mapValueTransformer`, if applicable._ ```typescript class A { @@ -140,6 +140,20 @@ The `options` argument is a superset of the options defined for [object-hash](ht set.size; // 1 ``` +- `caseInsensitive` - If true, all string values--including keys/values within objects and arrays--will be evaluated as case-insensitive. (default: false) + + > _NOTE: This transform will always be applied AFTER `transformer` and `mapValueTransformer`, if applicable. For objects, it will be applied before `replacer` (from object-hash options)._ + + ```typescript + const a = { key: 'value' }; + const b = { key: 'VALUE' }; + + const set = new DeepSet([a, b]); + set.size; // 2 + const set = new DeepSet([a, b], { caseInsensitive: true }); + set.size; // 1 + ``` + ## Bi-Directional DeepMap This library also exposes a `BiDirectionalDeepMap` class, which supports O(1) lookups by both keys and values. It provides the following extended API: diff --git a/src/__tests__/areEqual.test.ts b/src/__tests__/areEqual.test.ts index 5e9bb55..7c49d5b 100644 --- a/src/__tests__/areEqual.test.ts +++ b/src/__tests__/areEqual.test.ts @@ -45,6 +45,7 @@ describe('areEqual', () => { }); describe('Using options', () => { + // Just sanity check that we can pass options to this function type MyObject = { key: string; other: string }; const a = { key: 'value', other: 'a' }; const b = { key: 'value', other: 'b' }; diff --git a/src/__tests__/normalizer.test.ts b/src/__tests__/normalizer.test.ts index a776d3c..990cb20 100644 --- a/src/__tests__/normalizer.test.ts +++ b/src/__tests__/normalizer.test.ts @@ -34,7 +34,9 @@ describe('../normalizer.ts', () => { expect(n.normalizeKey(undefined)).toBeUndefined(); }); - describe('Configurable options', () => { + describe('Unobservable options', () => { + // Just testing pass-thru of object-hash library options, since we can't validate some things + // based on the result describe('options.algorithm', () => { it('Uses MD5 as default algorithm', async () => { n.normalizeKey({}); @@ -47,53 +49,6 @@ describe('../normalizer.ts', () => { expect(hash).toHaveBeenCalledWith({}, { algorithm: 'sha1' }); }); }); - - describe('options.transformer', () => { - it('Can define a transformer function', () => { - type Blah = { val: number }; - const a: Blah = { val: 1 }; - const b: Blah = { val: 3 }; - expect(n.normalizeKey(a)).not.toBe(n.normalizeKey(b)); - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - const transformer = (obj: Blah) => { - return { val: obj.val % 2 }; - }; - const withTransformer = new Normalizer({ transformer }); - expect(withTransformer.normalizeKey(a)).toBe(withTransformer.normalizeKey(b)); - }); - }); - - describe('options.mapValueTransformer', () => { - it('Can define a mapValueTransformer function', () => { - type Blah = { val: number }; - const a: Blah = { val: 1 }; - const b: Blah = { val: 3 }; - expect(n.normalizeValue(a)).not.toBe(n.normalizeValue(b)); - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - const mapValueTransformer = (obj: Blah) => { - return { val: obj.val % 2 }; - }; - const withTransformer = new Normalizer({ mapValueTransformer }); - expect(withTransformer.normalizeValue(a)).toBe(withTransformer.normalizeValue(b)); - }); - }); - - describe('options.useToJsonTransform', () => { - it('Can specify useToJsonTransform setting', () => { - class A { - constructor(public x: number) {} - } - class B { - constructor(public x: number) {} - } - const a = new A(45); - const b = new B(45); - expect(n.normalizeKey(a)).not.toBe(n.normalizeKey(b)); - const withToJson = new Normalizer({ useToJsonTransform: true }); - expect(withToJson.normalizeKey(a)).toBe(withToJson.normalizeKey(b)); - expect(withToJson.normalizeValue(a)).toBe(withToJson.normalizeValue(b)); - }); - }); }); }); }); diff --git a/src/__tests__/options.test.ts b/src/__tests__/options.test.ts index c01de8a..5df1ec9 100644 --- a/src/__tests__/options.test.ts +++ b/src/__tests__/options.test.ts @@ -1,14 +1,17 @@ +import { areEqual } from '../areEqual'; +import { DeepMap } from '../map'; import { getOptionsWithDefaults } from '../options'; import { Transformers } from '../transformers'; describe('options.ts', () => { - describe('getOptionsWithDefaults', () => { + describe('#getOptionsWithDefaults', () => { it('baseline/default values', async () => { expect(getOptionsWithDefaults({})).toStrictEqual({ algorithm: 'md5', transformer: Transformers.identity, mapValueTransformer: Transformers.identity, useToJsonTransform: false, + caseInsensitive: false, }); }); @@ -17,12 +20,125 @@ describe('options.ts', () => { getOptionsWithDefaults({ algorithm: 'sha1', useToJsonTransform: true, + caseInsensitive: true, }) ).toStrictEqual({ algorithm: 'sha1', transformer: Transformers.identity, mapValueTransformer: Transformers.identity, useToJsonTransform: true, + caseInsensitive: true, + }); + }); + }); + + describe('All Options', () => { + // NOTE: For clarity/succinctness, we'll just test everything using areEqual(...) + + describe('#transformer', () => { + type MyObject = { key: string; other: string }; + const a = { key: 'value', other: 'a' }; + const b = { key: 'value', other: 'b' }; + const c = { key: 'value', other: 'c' }; + const opts = { + transformer: (obj: MyObject): string => obj.key, + }; + + it('Without transformer', async () => { + expect(areEqual([a, b])).toBe(false); + expect(areEqual([a, b, c])).toBe(false); + }); + + it('With transformer', async () => { + expect(areEqual([a, b], opts)).toBe(true); + expect(areEqual([a, b, c], opts)).toBe(true); + }); + }); + + describe('#mapValueTransformer', () => { + // NOTE: areEqual(...) uses a DeepSet under the covers, so need to use DeepMap explicitly for these tests + type MyValueObject = { mapValue: number }; + type MyMapEntries = Array<[number, MyValueObject]>; + const mapEntries1 = [[1, { mapValue: 4 }]] as MyMapEntries; + const mapEntries2 = [[1, { mapValue: 6 }]] as MyMapEntries; + const opts = { + mapValueTransformer: (obj: MyValueObject): number => obj.mapValue % 2, + }; + + it('Without mapValueTransformer', async () => { + const map1 = new DeepMap(mapEntries1); + const map2 = new DeepMap(mapEntries2); + expect(map1.equals(map2)).toBe(false); + }); + + it('With mapValueTransformer', async () => { + const map1 = new DeepMap(mapEntries1, opts); + const map2 = new DeepMap(mapEntries2, opts); + expect(map1.equals(map2)).toBe(true); + }); + }); + + describe('#useToJsonTransform', () => { + class A { + constructor(public a: number) {} + } + class B extends A {} + class C extends A {} + + const b = new B(45); + const c = new C(45); + + it('Without useToJsonTransform', async () => { + expect(areEqual([b, c])).toBe(false); + }); + + it('With useToJsonTransform', async () => { + expect(areEqual([b, c], { useToJsonTransform: true })).toBe(true); + }); + }); + + describe('#caseInsensitive', () => { + it('Without caseInsensitive', async () => { + expect(areEqual(['hi', 'HI'])).toBe(false); + expect(areEqual([['hi'], ['HI']])).toBe(false); + expect(areEqual([{ prop: 'hi' }, { Prop: 'HI' }])).toBe(false); + expect(areEqual([{ prop: ['hi'] }, { Prop: ['HI'] }])).toBe(false); + }); + + it('With caseInsensitive', async () => { + const opts = { caseInsensitive: true }; + expect(areEqual(['hi', 'HI'], opts)).toBe(true); + expect(areEqual([['hi'], ['HI']], opts)).toBe(true); + expect(areEqual([{ prop: 'hi' }, { Prop: 'HI' }], opts)).toBe(true); + expect(areEqual([{ prop: ['hi'] }, { Prop: ['HI'] }], opts)).toBe(true); + }); + }); + + describe('Using multiple options simultaneously', () => { + it('caseInsensitive + replacer', () => { + // NOTE: caseInsensitive leverages the replacer option under the covers + const opts = { + caseInsensitive: true, + // eslint-disable-next-line jest/no-conditional-in-test + replacer: (val: unknown) => (typeof val === 'string' ? val.trimEnd() : val), + }; + expect(areEqual([{ word: 'blah' }, { WORD: 'Blah ' }], opts)).toBe(true); + }); + + it('useToJsonTransform + transformer', () => { + // NOTE: useToJsonTransform leverages the transformer option under the covers + class A { + constructor(public val: number) {} + } + class B { + constructor(public val: number) {} + } + const opts = { + useToJsonTransform: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + transformer: (val: any) => val.constructor.name, + }; + expect(areEqual([new A(1), new B(2)], opts)).toBe(true); }); }); }); diff --git a/src/__tests__/set.test.ts b/src/__tests__/set.test.ts index b8e7c80..83780ec 100644 --- a/src/__tests__/set.test.ts +++ b/src/__tests__/set.test.ts @@ -264,29 +264,4 @@ describe('DeepSet', () => { }); }); }); - - describe('Normalizer Options', () => { - describe('useToJsonTransform', () => { - class A { - constructor(public a: number) {} - } - class B extends A {} - class C extends A {} - - const b = new B(45); - const c = new C(45); - - it('useToJsonTransform=false', async () => { - const set = new DeepSet([b, c]); - expect(set.size).toBe(2); - }); - - it('useToJsonTransform=true', async () => { - const set = new DeepSet([b, c], { useToJsonTransform: true }); - expect(set.size).toBe(1); - // Last one in wins - expect([...set.values()]).toStrictEqual([c]); - }); - }); - }); }); diff --git a/src/__tests__/utils.test.ts b/src/__tests__/utils.test.ts index 3c4c258..aabd559 100644 --- a/src/__tests__/utils.test.ts +++ b/src/__tests__/utils.test.ts @@ -1,4 +1,4 @@ -import { stringify } from '../utils'; +import { chain, stringify } from '../utils'; describe('utils.ts', () => { describe('#stringify', () => { @@ -20,4 +20,20 @@ describe('utils.ts', () => { expect(stringify(new RegExp('some_regexp', 'g'))).toBe('/some_regexp/g'); }); }); + + describe('#chain', () => { + it('invokes each function in the list with the return value of the previous', async () => { + const chained = chain([ + (arg: number) => arg + 100, + (arg: number) => arg * -1, + (arg: number) => `Hello, ${arg}!`, + ]); + expect(chained(4)).toBe('Hello, -104!'); + }); + + it('a single function is invoked as is', async () => { + const chained = chain([(arg: number) => arg + 100]); + expect(chained(4)).toBe(104); + }); + }); }); diff --git a/src/normalizer.ts b/src/normalizer.ts index c055471..8576d26 100644 --- a/src/normalizer.ts +++ b/src/normalizer.ts @@ -2,6 +2,7 @@ import hash, { NormalOption as ObjectHashOptions } from 'object-hash'; import { getOptionsWithDefaults, Options } from './options'; import { Transformers, TransformFunction } from './transformers'; +import { chain } from './utils'; /** * Result of object-hash hashing function @@ -18,15 +19,32 @@ export type Normalized = HashedObject | T; */ export class Normalizer { private readonly objectHashOptions: ObjectHashOptions; + private readonly caseInsensitive: boolean; private readonly keyTransformer: TransformFunction; private readonly valueTransformer: TransformFunction; constructor(options: Options = {}) { - const { transformer, mapValueTransformer, useToJsonTransform, ...objectHashOptions } = + const { transformer, mapValueTransformer, useToJsonTransform, caseInsensitive, ...objectHashOptions } = getOptionsWithDefaults(options); - this.keyTransformer = useToJsonTransform ? Transformers.jsonSerializeDeserialize : transformer; - this.valueTransformer = useToJsonTransform ? Transformers.jsonSerializeDeserialize : mapValueTransformer; this.objectHashOptions = objectHashOptions; + this.caseInsensitive = caseInsensitive; + this.keyTransformer = useToJsonTransform + ? chain([Transformers.jsonSerializeDeserialize, transformer]) + : transformer; + this.valueTransformer = useToJsonTransform + ? chain([Transformers.jsonSerializeDeserialize, mapValueTransformer]) + : mapValueTransformer; + + if (caseInsensitive) { + // NOTE: This block ensures case-insensitivity inside objects only. + // See normalizeHelper() for logic which handles primitive strings + const caseInsensitiveReplacer = (val: T): T => + typeof val === 'string' ? (val.toLowerCase() as T) : val; + const { replacer } = this.objectHashOptions; + this.objectHashOptions.replacer = replacer + ? chain([caseInsensitiveReplacer, replacer]) + : caseInsensitiveReplacer; + } } /** @@ -50,6 +68,8 @@ export class Normalizer { private normalizeHelper(input: Input): Normalized { if (Normalizer.isObject(input)) { return hash(input, this.objectHashOptions); + } else if (this.caseInsensitive && typeof input === 'string') { + return input.toLowerCase(); } else { // Primitive value, don't hash return input; diff --git a/src/options.ts b/src/options.ts index 126b253..50c387a 100644 --- a/src/options.ts +++ b/src/options.ts @@ -23,10 +23,13 @@ interface DeepEqualityDataStructuresOptions { /** * If true, objects will be JSON-serialized/deserialized into "plain" objects prior to hashing. - * - * NOTE: This setting overrides `transformer` and `mapValuesTransformer`. */ useToJsonTransform?: boolean; + + /** + * If true, all string values (including keys/values within objects and arrays) will use case-insensitive equality comparisons. + */ + caseInsensitive?: boolean; } export type Options = ObjectHashOptions & DeepEqualityDataStructuresOptions; @@ -43,6 +46,7 @@ export function getOptionsWithDefaults( transformer: Transformers.identity, mapValueTransformer: Transformers.identity, useToJsonTransform: false, + caseInsensitive: false, // Supplied options ...options, }; diff --git a/src/utils.ts b/src/utils.ts index 196c6c1..012c9ba 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -16,3 +16,34 @@ export function stringify(value: unknown): string { return String(value); } } + +/** + * Chain a list of functions + * @returns a function that accepts the args of the first function in the functions list. Each subsequent function is invoked with + * the return value of the previous function. + */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +export function chain( + functions: [(...args: TArgs) => T1] // prettier nudge +): (...args: TArgs) => T1; +export function chain( + functions: [(...args: TArgs) => T1, (arg: T1) => T2] // prettier nudge +): (...args: TArgs) => T2; +export function chain( + functions: [(...args: TArgs) => T1, (arg: T1) => T2, (arg: T2) => T3] +): (...args: TArgs) => T3; +export function chain( + functions: [(...args: TArgs) => T1, (arg: T1) => T2, (arg: T2) => T3, (arg: T3) => T4] +): (...args: TArgs) => T4; +export function chain( + functions: [(...args: TArgs) => T1, (arg: T1) => T2, (arg: T2) => T3, (arg: T3) => T4, (arg: T4) => T5] +): (...args: TArgs) => T5; +export function chain( + functions: [(...args: TArgs) => any, ...Array<(arg: any) => any>] +): (...args: TArgs) => any { + const [head, ...tail] = functions; + return (...args: TArgs) => { + return tail.reduce((acc, fn) => fn(acc), head(...args)); + }; +} +/* eslint-enable @typescript-eslint/no-explicit-any */