Skip to content

Commit

Permalink
feat!: Add transformer/mapValueTransformer options (#6)
Browse files Browse the repository at this point in the history
  • Loading branch information
adamhamlin authored Dec 10, 2022
1 parent d5b6468 commit 5882a58
Show file tree
Hide file tree
Showing 13 changed files with 337 additions and 113 deletions.
2 changes: 2 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ module.exports = {
'jest/prefer-expect-assertions': 'off',
'jest/prefer-lowercase-title': 'off',
'jest/max-expects': ['error', { max: 8 }],
'jest/unbound-method': ['error', { ignoreStatic: true }],
'@typescript-eslint/unbound-method': 'off', // disable in favor of jest/unbound-method
},
},
],
Expand Down
65 changes: 53 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ npm install deep-equality-data-structures
ES `Map` and `Set` only support referential equality:

```typescript
interface MyObject {
interface MyType {
a: number;
}
const set = new Set<MyObject>();
const set = new Set<MyType>();
set.add({ a: 1 });
set.add({ a: 1 });
set.size; // 2
Expand All @@ -29,18 +29,18 @@ Now, using deep equality:
```typescript
import { DeepSet } from 'deep-equality-data-structures';

interface MyObject {
interface MyType {
a: number;
}
const set = new DeepSet<MyObject>();
const set = new DeepSet<MyType>();
set.add({ a: 1 });
set.add({ a: 1 });
set.size; // 1
```

## How?

This project relies on the [object-hash](https://github.com/puleos/object-hash) library to map object types to strings.
This project relies on the [object-hash](https://github.com/puleos/object-hash) library to normalize object types to unique strings.

## Comparable Interface

Expand All @@ -58,16 +58,55 @@ set1.contains(set3); // true

## Configuration Options

The default settings should be suitable for most use cases, but behavior can be configured.

```typescript
new DeepSet(values?, options?)
new DeepMap(entries?, options?)
new DeepSet<K>(values?, options?)
new DeepMap<K,V>(entries?, options?)
```
The `options` argument is a superset of the options defined for [object-hash](https://github.com/puleos/object-hash#hashvalue-options), with the same defaults (exception: the default algoritm is `md5`).
The `options` argument is a superset of the options defined for [object-hash](https://github.com/puleos/object-hash#hashvalue-options), with the same defaults (exception: the default algoritm is `md5`). There are also library-specific options.
### Library-specific options:
- `transformer` - a custom function that transforms Map keys/Set values prior to hashing. It does not affect the values that are stored.
```typescript
type MyType = { val: number; other: number };
const a: MyType = { val: 1, other: 1 };
const b: MyType = { val: 1, other: 2 };
const transformer = (obj: MyType) => ({ val });

const set = new DeepSet([a, b]);
set.size; // 2
const set = new DeepSet([a, b], { transformer });
set.size; // 1

[...set.values()]; // [{ val: 1, other: 2 }]
```
- `mapValueTransformer` - a custom function that transforms Map values prior to hashing. This is only relevant to `Comparable` interface operations. It does not affect the values that are stored.
```typescript
type MyType = { val: number; other: number };
const a: MyType = { val: 1, other: 1 };
const b: MyType = { val: 1, other: 2 };
const mapValueTransformer = (obj: MyType) => ({ val });

const map1 = new DeepMap([[1, a]]);
const map2 = new DeepMap([[1, b]]);
map1.equals(map2); // false

const map1 = new DeepMap([[1, a]], { mapValueTransformer });
const map2 = new DeepMap([[1, b]], { mapValueTransformer });
map1.equals(map2); // true

[...map1.entries()]; // [[1, { val: 1, other: 2 }]]
```
Additional project-specific options:
- `useToJsonTransform` - if true, only use JSON-serializable properties when computing hashes, equality, etc. (default: false)
- `jsonSerializableOnly` - if true, only use JSON-serializable properties when computing hashes, equality, etc. (default: false)
> _NOTE: This setting overrides both `transformer` and `mapValueTransformer`_
```typescript
class A {
Expand All @@ -81,13 +120,15 @@ Additional project-specific options:

const set = new DeepSet([a, b]);
set.size; // 2
const set = new DeepSet([a, b], { jsonSerializableOnly: true });
const set = new DeepSet([a, b], { useToJsonTransform: true });
set.size; // 1
```
## Notes/Caveats
- Don't mutate a map key (or set value) while still using the data structure. The internal representation is not affected by this mutation, so behavior may be unexpected.
- This still supports primitive keys/values like traditional `Map`/`Set`.
- Don't mutate objects stored in the data structure. The internal representation is not affected by this mutation, so behavior may be unexpected.
- Don't mutate objects in the user-supplied `transformer` or `mapValueTransformer` functions. It will affect the stored version.
- This implementation does not explicitly "handle" key collisions. However, with the default algorithm (MD5), even if a map contained one TRILLION entries, the probability of a collision on the next insert is only 0.000000000000001. If you need better odds, use SHA1, SHA256, etc.
## CI/CD
Expand Down
37 changes: 0 additions & 37 deletions src/__tests__/hash.test.ts

This file was deleted.

99 changes: 99 additions & 0 deletions src/__tests__/normalizer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import hash from 'object-hash';

import { Normalizer } from '../normalizer';

jest.mock('object-hash', () => {
const origModule = jest.requireActual('object-hash');
return {
__esModule: true,
...origModule,
default: jest.fn((...args: unknown[]) => origModule(...args)),
};
});

describe('../normalizer.ts', () => {
const n = new Normalizer();

describe('normalize', () => {
it('object-hash sanity checks', async () => {
// Positive
expect(n.normalizeKey({})).toBe(n.normalizeKey({}));
expect(n.normalizeKey({ a: 'hi', b: 'bye' })).toBe(n.normalizeKey({ b: 'bye', a: 'hi' }));
expect(n.normalizeKey(['blah'])).toBe(n.normalizeKey(['blah']));
// Negative
expect(n.normalizeKey({ a: 'hi', b: 'bye' })).not.toBe(n.normalizeKey({ a: 'hi' }));
expect(n.normalizeKey({ a: 'hi', b: 'bye' })).not.toBe(n.normalizeKey({ a: 'hi', b: 'bye bye' }));
expect(n.normalizeKey(['bleep', 'bloop'])).not.toBe(n.normalizeKey(['bloop', 'bleep']));
});

it('primitive inputs are normalized to themselves', async () => {
expect(n.normalizeKey(5)).toBe(5);
expect(n.normalizeKey('hi')).toBe('hi');
expect(n.normalizeKey(true)).toBe(true);
expect(n.normalizeKey(null)).toBeNull();
expect(n.normalizeKey(undefined)).toBeUndefined();
});

describe('Configurable options', () => {
describe('options.algorithm', () => {
it('Uses MD5 as default algorithm', async () => {
n.normalizeKey({});
expect(hash).toHaveBeenCalledWith({}, { algorithm: 'md5' });
});

it('Uses specified algorithm', async () => {
const n = new Normalizer({ algorithm: 'sha1' });
n.normalizeKey({});
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));
});
});
});
});
});
29 changes: 29 additions & 0 deletions src/__tests__/options.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { getOptionsWithDefaults } from '../options';
import { Transformers } from '../transformers';

describe('../options.ts', () => {
describe('getOptionsWithDefaults', () => {
it('baseline/default values', async () => {
expect(getOptionsWithDefaults({})).toStrictEqual({
algorithm: 'md5',
transformer: Transformers.identity,
mapValueTransformer: Transformers.identity,
useToJsonTransform: false,
});
});

it('specified values override default values', async () => {
expect(
getOptionsWithDefaults({
algorithm: 'sha1',
useToJsonTransform: true,
})
).toStrictEqual({
algorithm: 'sha1',
transformer: Transformers.identity,
mapValueTransformer: Transformers.identity,
useToJsonTransform: true,
});
});
});
});
12 changes: 7 additions & 5 deletions src/__tests__/set.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,8 +193,8 @@ describe('DeepSet', () => {
});
});

describe('Hash Options', () => {
describe('jsonSerializableOnly', () => {
describe('Normalizer Options', () => {
describe('useToJsonTransform', () => {
class A {
constructor(public a: number) {}
}
Expand All @@ -204,14 +204,16 @@ describe('DeepSet', () => {
const b = new B(45);
const c = new C(45);

it('jsonSerializableOnly=false', async () => {
it('useToJsonTransform=false', async () => {
const set = new DeepSet([b, c]);
expect(set.size).toBe(2);
});

it('jsonSerializableOnly=true', async () => {
const set = new DeepSet([b, c], { jsonSerializableOnly: true });
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]);
});
});
});
Expand Down
32 changes: 0 additions & 32 deletions src/hash.ts

This file was deleted.

Loading

0 comments on commit 5882a58

Please sign in to comment.