Skip to content

Commit a27e62d

Browse files
authored
feat: add caseInsensitive option (#20)
1 parent 6e7af35 commit a27e62d

9 files changed

+213
-81
lines changed

README.md

+15-1
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ The `options` argument is a superset of the options defined for [object-hash](ht
122122
123123
- `useToJsonTransform` - if true, only use JSON-serializable properties when computing hashes, equality, etc. (default: false)
124124
125-
> _NOTE: This setting overrides both `transformer` and `mapValueTransformer`_
125+
> _NOTE: This transform will always be applied BEFORE `transformer` and `mapValueTransformer`, if applicable._
126126
127127
```typescript
128128
class A {
@@ -140,6 +140,20 @@ The `options` argument is a superset of the options defined for [object-hash](ht
140140
set.size; // 1
141141
```
142142
143+
- `caseInsensitive` - If true, all string values--including keys/values within objects and arrays--will be evaluated as case-insensitive. (default: false)
144+
145+
> _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)._
146+
147+
```typescript
148+
const a = { key: 'value' };
149+
const b = { key: 'VALUE' };
150+
151+
const set = new DeepSet([a, b]);
152+
set.size; // 2
153+
const set = new DeepSet([a, b], { caseInsensitive: true });
154+
set.size; // 1
155+
```
156+
143157
## Bi-Directional DeepMap
144158
145159
This library also exposes a `BiDirectionalDeepMap` class, which supports O(1) lookups by both keys and values. It provides the following extended API:

src/__tests__/areEqual.test.ts

+1
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ describe('areEqual', () => {
4545
});
4646

4747
describe('Using options', () => {
48+
// Just sanity check that we can pass options to this function
4849
type MyObject = { key: string; other: string };
4950
const a = { key: 'value', other: 'a' };
5051
const b = { key: 'value', other: 'b' };

src/__tests__/normalizer.test.ts

+3-48
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ describe('../normalizer.ts', () => {
3434
expect(n.normalizeKey(undefined)).toBeUndefined();
3535
});
3636

37-
describe('Configurable options', () => {
37+
describe('Unobservable options', () => {
38+
// Just testing pass-thru of object-hash library options, since we can't validate some things
39+
// based on the result
3840
describe('options.algorithm', () => {
3941
it('Uses MD5 as default algorithm', async () => {
4042
n.normalizeKey({});
@@ -47,53 +49,6 @@ describe('../normalizer.ts', () => {
4749
expect(hash).toHaveBeenCalledWith({}, { algorithm: 'sha1' });
4850
});
4951
});
50-
51-
describe('options.transformer', () => {
52-
it('Can define a transformer function', () => {
53-
type Blah = { val: number };
54-
const a: Blah = { val: 1 };
55-
const b: Blah = { val: 3 };
56-
expect(n.normalizeKey(a)).not.toBe(n.normalizeKey(b));
57-
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
58-
const transformer = (obj: Blah) => {
59-
return { val: obj.val % 2 };
60-
};
61-
const withTransformer = new Normalizer({ transformer });
62-
expect(withTransformer.normalizeKey(a)).toBe(withTransformer.normalizeKey(b));
63-
});
64-
});
65-
66-
describe('options.mapValueTransformer', () => {
67-
it('Can define a mapValueTransformer function', () => {
68-
type Blah = { val: number };
69-
const a: Blah = { val: 1 };
70-
const b: Blah = { val: 3 };
71-
expect(n.normalizeValue(a)).not.toBe(n.normalizeValue(b));
72-
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
73-
const mapValueTransformer = (obj: Blah) => {
74-
return { val: obj.val % 2 };
75-
};
76-
const withTransformer = new Normalizer({ mapValueTransformer });
77-
expect(withTransformer.normalizeValue(a)).toBe(withTransformer.normalizeValue(b));
78-
});
79-
});
80-
81-
describe('options.useToJsonTransform', () => {
82-
it('Can specify useToJsonTransform setting', () => {
83-
class A {
84-
constructor(public x: number) {}
85-
}
86-
class B {
87-
constructor(public x: number) {}
88-
}
89-
const a = new A(45);
90-
const b = new B(45);
91-
expect(n.normalizeKey(a)).not.toBe(n.normalizeKey(b));
92-
const withToJson = new Normalizer({ useToJsonTransform: true });
93-
expect(withToJson.normalizeKey(a)).toBe(withToJson.normalizeKey(b));
94-
expect(withToJson.normalizeValue(a)).toBe(withToJson.normalizeValue(b));
95-
});
96-
});
9752
});
9853
});
9954
});

src/__tests__/options.test.ts

+117-1
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
1+
import { areEqual } from '../areEqual';
2+
import { DeepMap } from '../map';
13
import { getOptionsWithDefaults } from '../options';
24
import { Transformers } from '../transformers';
35

46
describe('options.ts', () => {
5-
describe('getOptionsWithDefaults', () => {
7+
describe('#getOptionsWithDefaults', () => {
68
it('baseline/default values', async () => {
79
expect(getOptionsWithDefaults({})).toStrictEqual({
810
algorithm: 'md5',
911
transformer: Transformers.identity,
1012
mapValueTransformer: Transformers.identity,
1113
useToJsonTransform: false,
14+
caseInsensitive: false,
1215
});
1316
});
1417

@@ -17,12 +20,125 @@ describe('options.ts', () => {
1720
getOptionsWithDefaults({
1821
algorithm: 'sha1',
1922
useToJsonTransform: true,
23+
caseInsensitive: true,
2024
})
2125
).toStrictEqual({
2226
algorithm: 'sha1',
2327
transformer: Transformers.identity,
2428
mapValueTransformer: Transformers.identity,
2529
useToJsonTransform: true,
30+
caseInsensitive: true,
31+
});
32+
});
33+
});
34+
35+
describe('All Options', () => {
36+
// NOTE: For clarity/succinctness, we'll just test everything using areEqual(...)
37+
38+
describe('#transformer', () => {
39+
type MyObject = { key: string; other: string };
40+
const a = { key: 'value', other: 'a' };
41+
const b = { key: 'value', other: 'b' };
42+
const c = { key: 'value', other: 'c' };
43+
const opts = {
44+
transformer: (obj: MyObject): string => obj.key,
45+
};
46+
47+
it('Without transformer', async () => {
48+
expect(areEqual([a, b])).toBe(false);
49+
expect(areEqual([a, b, c])).toBe(false);
50+
});
51+
52+
it('With transformer', async () => {
53+
expect(areEqual([a, b], opts)).toBe(true);
54+
expect(areEqual([a, b, c], opts)).toBe(true);
55+
});
56+
});
57+
58+
describe('#mapValueTransformer', () => {
59+
// NOTE: areEqual(...) uses a DeepSet under the covers, so need to use DeepMap explicitly for these tests
60+
type MyValueObject = { mapValue: number };
61+
type MyMapEntries = Array<[number, MyValueObject]>;
62+
const mapEntries1 = [[1, { mapValue: 4 }]] as MyMapEntries;
63+
const mapEntries2 = [[1, { mapValue: 6 }]] as MyMapEntries;
64+
const opts = {
65+
mapValueTransformer: (obj: MyValueObject): number => obj.mapValue % 2,
66+
};
67+
68+
it('Without mapValueTransformer', async () => {
69+
const map1 = new DeepMap(mapEntries1);
70+
const map2 = new DeepMap(mapEntries2);
71+
expect(map1.equals(map2)).toBe(false);
72+
});
73+
74+
it('With mapValueTransformer', async () => {
75+
const map1 = new DeepMap(mapEntries1, opts);
76+
const map2 = new DeepMap(mapEntries2, opts);
77+
expect(map1.equals(map2)).toBe(true);
78+
});
79+
});
80+
81+
describe('#useToJsonTransform', () => {
82+
class A {
83+
constructor(public a: number) {}
84+
}
85+
class B extends A {}
86+
class C extends A {}
87+
88+
const b = new B(45);
89+
const c = new C(45);
90+
91+
it('Without useToJsonTransform', async () => {
92+
expect(areEqual([b, c])).toBe(false);
93+
});
94+
95+
it('With useToJsonTransform', async () => {
96+
expect(areEqual([b, c], { useToJsonTransform: true })).toBe(true);
97+
});
98+
});
99+
100+
describe('#caseInsensitive', () => {
101+
it('Without caseInsensitive', async () => {
102+
expect(areEqual(['hi', 'HI'])).toBe(false);
103+
expect(areEqual([['hi'], ['HI']])).toBe(false);
104+
expect(areEqual([{ prop: 'hi' }, { Prop: 'HI' }])).toBe(false);
105+
expect(areEqual([{ prop: ['hi'] }, { Prop: ['HI'] }])).toBe(false);
106+
});
107+
108+
it('With caseInsensitive', async () => {
109+
const opts = { caseInsensitive: true };
110+
expect(areEqual(['hi', 'HI'], opts)).toBe(true);
111+
expect(areEqual([['hi'], ['HI']], opts)).toBe(true);
112+
expect(areEqual([{ prop: 'hi' }, { Prop: 'HI' }], opts)).toBe(true);
113+
expect(areEqual([{ prop: ['hi'] }, { Prop: ['HI'] }], opts)).toBe(true);
114+
});
115+
});
116+
117+
describe('Using multiple options simultaneously', () => {
118+
it('caseInsensitive + replacer', () => {
119+
// NOTE: caseInsensitive leverages the replacer option under the covers
120+
const opts = {
121+
caseInsensitive: true,
122+
// eslint-disable-next-line jest/no-conditional-in-test
123+
replacer: (val: unknown) => (typeof val === 'string' ? val.trimEnd() : val),
124+
};
125+
expect(areEqual([{ word: 'blah' }, { WORD: 'Blah ' }], opts)).toBe(true);
126+
});
127+
128+
it('useToJsonTransform + transformer', () => {
129+
// NOTE: useToJsonTransform leverages the transformer option under the covers
130+
class A {
131+
constructor(public val: number) {}
132+
}
133+
class B {
134+
constructor(public val: number) {}
135+
}
136+
const opts = {
137+
useToJsonTransform: true,
138+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
139+
transformer: (val: any) => val.constructor.name,
140+
};
141+
expect(areEqual([new A(1), new B(2)], opts)).toBe(true);
26142
});
27143
});
28144
});

src/__tests__/set.test.ts

-25
Original file line numberDiff line numberDiff line change
@@ -264,29 +264,4 @@ describe('DeepSet', () => {
264264
});
265265
});
266266
});
267-
268-
describe('Normalizer Options', () => {
269-
describe('useToJsonTransform', () => {
270-
class A {
271-
constructor(public a: number) {}
272-
}
273-
class B extends A {}
274-
class C extends A {}
275-
276-
const b = new B(45);
277-
const c = new C(45);
278-
279-
it('useToJsonTransform=false', async () => {
280-
const set = new DeepSet([b, c]);
281-
expect(set.size).toBe(2);
282-
});
283-
284-
it('useToJsonTransform=true', async () => {
285-
const set = new DeepSet([b, c], { useToJsonTransform: true });
286-
expect(set.size).toBe(1);
287-
// Last one in wins
288-
expect([...set.values()]).toStrictEqual([c]);
289-
});
290-
});
291-
});
292267
});

src/__tests__/utils.test.ts

+17-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { stringify } from '../utils';
1+
import { chain, stringify } from '../utils';
22

33
describe('utils.ts', () => {
44
describe('#stringify', () => {
@@ -20,4 +20,20 @@ describe('utils.ts', () => {
2020
expect(stringify(new RegExp('some_regexp', 'g'))).toBe('/some_regexp/g');
2121
});
2222
});
23+
24+
describe('#chain', () => {
25+
it('invokes each function in the list with the return value of the previous', async () => {
26+
const chained = chain([
27+
(arg: number) => arg + 100,
28+
(arg: number) => arg * -1,
29+
(arg: number) => `Hello, ${arg}!`,
30+
]);
31+
expect(chained(4)).toBe('Hello, -104!');
32+
});
33+
34+
it('a single function is invoked as is', async () => {
35+
const chained = chain([(arg: number) => arg + 100]);
36+
expect(chained(4)).toBe(104);
37+
});
38+
});
2339
});

src/normalizer.ts

+23-3
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import hash, { NormalOption as ObjectHashOptions } from 'object-hash';
22

33
import { getOptionsWithDefaults, Options } from './options';
44
import { Transformers, TransformFunction } from './transformers';
5+
import { chain } from './utils';
56

67
/**
78
* Result of object-hash hashing function
@@ -18,15 +19,32 @@ export type Normalized<T> = HashedObject | T;
1819
*/
1920
export class Normalizer<K, V, TxK, TxV> {
2021
private readonly objectHashOptions: ObjectHashOptions;
22+
private readonly caseInsensitive: boolean;
2123
private readonly keyTransformer: TransformFunction<K, TxK>;
2224
private readonly valueTransformer: TransformFunction<V, TxV>;
2325

2426
constructor(options: Options<K, V, TxK, TxV> = {}) {
25-
const { transformer, mapValueTransformer, useToJsonTransform, ...objectHashOptions } =
27+
const { transformer, mapValueTransformer, useToJsonTransform, caseInsensitive, ...objectHashOptions } =
2628
getOptionsWithDefaults(options);
27-
this.keyTransformer = useToJsonTransform ? Transformers.jsonSerializeDeserialize : transformer;
28-
this.valueTransformer = useToJsonTransform ? Transformers.jsonSerializeDeserialize : mapValueTransformer;
2929
this.objectHashOptions = objectHashOptions;
30+
this.caseInsensitive = caseInsensitive;
31+
this.keyTransformer = useToJsonTransform
32+
? chain([Transformers.jsonSerializeDeserialize, transformer])
33+
: transformer;
34+
this.valueTransformer = useToJsonTransform
35+
? chain([Transformers.jsonSerializeDeserialize, mapValueTransformer])
36+
: mapValueTransformer;
37+
38+
if (caseInsensitive) {
39+
// NOTE: This block ensures case-insensitivity inside objects only.
40+
// See normalizeHelper() for logic which handles primitive strings
41+
const caseInsensitiveReplacer = <T>(val: T): T =>
42+
typeof val === 'string' ? (val.toLowerCase() as T) : val;
43+
const { replacer } = this.objectHashOptions;
44+
this.objectHashOptions.replacer = replacer
45+
? chain([caseInsensitiveReplacer, replacer])
46+
: caseInsensitiveReplacer;
47+
}
3048
}
3149

3250
/**
@@ -50,6 +68,8 @@ export class Normalizer<K, V, TxK, TxV> {
5068
private normalizeHelper<Input>(input: Input): Normalized<Input> {
5169
if (Normalizer.isObject(input)) {
5270
return hash(input, this.objectHashOptions);
71+
} else if (this.caseInsensitive && typeof input === 'string') {
72+
return input.toLowerCase();
5373
} else {
5474
// Primitive value, don't hash
5575
return input;

src/options.ts

+6-2
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,13 @@ interface DeepEqualityDataStructuresOptions<K, V, TxK, TxV> {
2323

2424
/**
2525
* If true, objects will be JSON-serialized/deserialized into "plain" objects prior to hashing.
26-
*
27-
* NOTE: This setting overrides `transformer` and `mapValuesTransformer`.
2826
*/
2927
useToJsonTransform?: boolean;
28+
29+
/**
30+
* If true, all string values (including keys/values within objects and arrays) will use case-insensitive equality comparisons.
31+
*/
32+
caseInsensitive?: boolean;
3033
}
3134

3235
export type Options<K, V, TxK, TxV> = ObjectHashOptions & DeepEqualityDataStructuresOptions<K, V, TxK, TxV>;
@@ -43,6 +46,7 @@ export function getOptionsWithDefaults<K, V, TxK, TxV>(
4346
transformer: Transformers.identity,
4447
mapValueTransformer: Transformers.identity,
4548
useToJsonTransform: false,
49+
caseInsensitive: false,
4650
// Supplied options
4751
...options,
4852
};

0 commit comments

Comments
 (0)