Skip to content

feat: add caseInsensitive option #20

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Sep 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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:
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/areEqual.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' };
Expand Down
51 changes: 3 additions & 48 deletions src/__tests__/normalizer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({});
Expand All @@ -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));
});
});
});
});
});
118 changes: 117 additions & 1 deletion src/__tests__/options.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});

Expand All @@ -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);
});
});
});
Expand Down
25 changes: 0 additions & 25 deletions src/__tests__/set.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
});
});
});
});
18 changes: 17 additions & 1 deletion src/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { stringify } from '../utils';
import { chain, stringify } from '../utils';

describe('utils.ts', () => {
describe('#stringify', () => {
Expand All @@ -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);
});
});
});
26 changes: 23 additions & 3 deletions src/normalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -18,15 +19,32 @@ export type Normalized<T> = HashedObject | T;
*/
export class Normalizer<K, V, TxK, TxV> {
private readonly objectHashOptions: ObjectHashOptions;
private readonly caseInsensitive: boolean;
private readonly keyTransformer: TransformFunction<K, TxK>;
private readonly valueTransformer: TransformFunction<V, TxV>;

constructor(options: Options<K, V, TxK, TxV> = {}) {
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 = <T>(val: T): T =>
typeof val === 'string' ? (val.toLowerCase() as T) : val;
const { replacer } = this.objectHashOptions;
this.objectHashOptions.replacer = replacer
? chain([caseInsensitiveReplacer, replacer])
: caseInsensitiveReplacer;
}
}

/**
Expand All @@ -50,6 +68,8 @@ export class Normalizer<K, V, TxK, TxV> {
private normalizeHelper<Input>(input: Input): Normalized<Input> {
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;
Expand Down
8 changes: 6 additions & 2 deletions src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,13 @@ interface DeepEqualityDataStructuresOptions<K, V, TxK, TxV> {

/**
* 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<K, V, TxK, TxV> = ObjectHashOptions & DeepEqualityDataStructuresOptions<K, V, TxK, TxV>;
Expand All @@ -43,6 +46,7 @@ export function getOptionsWithDefaults<K, V, TxK, TxV>(
transformer: Transformers.identity,
mapValueTransformer: Transformers.identity,
useToJsonTransform: false,
caseInsensitive: false,
// Supplied options
...options,
};
Expand Down
Loading