Skip to content

Commit 160663e

Browse files
authored
feat!: validate consistent options used during comparison operations (#23)
1 parent 6ffb500 commit 160663e

File tree

8 files changed

+101
-4
lines changed

8 files changed

+101
-4
lines changed

.eslintrc.js

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ module.exports = {
66
plugins: ['jest'],
77
extends: ['plugin:jest/all'],
88
rules: {
9+
'jest/expect-expect': ['error', { assertFunctionNames: ['expect*'] }],
910
'jest/prefer-expect-assertions': 'off',
1011
'jest/prefer-lowercase-title': 'off',
1112
'jest/max-expects': ['error', { max: 8 }],

src/__tests__/map.test.ts

+64
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import { Comparable } from '../comparable';
12
import { DeepMap } from '../map';
3+
import { Options } from '../options';
24
import { TestObjectField, TestObject } from './common/test.utils';
35

46
describe('DeepMap', () => {
@@ -358,5 +360,67 @@ describe('DeepMap', () => {
358360
expect([...differenceMap.entries()]).toStrictEqual([[new K('1'), new V('1')]]);
359361
});
360362
});
363+
364+
describe('Options checksum validation', () => {
365+
type TestOptions = Options<number, number, number, number>;
366+
type ComparableOperation = keyof Comparable<number>;
367+
368+
const operationTypes: ComparableOperation[] = ['equals', 'contains', 'union', 'intersection', 'difference'];
369+
const errorMsg = 'Structures must use same options for Comparable interface operations';
370+
371+
const optionsA1: TestOptions = {};
372+
const optionsA2: TestOptions = { useToJsonTransform: true };
373+
const optionsA3: TestOptions = { transformer: (n) => n + 1 };
374+
const optionsA4: TestOptions = { transformer: (k) => k + 1 };
375+
const optionsA5: TestOptions = { transformer: (k) => k + 1, unorderedSets: true };
376+
const optionsA6: TestOptions = { unorderedSets: true };
377+
const optionsA7: TestOptions = undefined as unknown as TestOptions;
378+
379+
const optionsB1: TestOptions = {};
380+
const optionsB2: TestOptions = { useToJsonTransform: true };
381+
const optionsB3: TestOptions = { transformer: (n) => n + 1 };
382+
const optionsB4: TestOptions = { transformer: (k) => k + 1 };
383+
const optionsB5: TestOptions = { transformer: (k) => k + 1, unorderedSets: true };
384+
const optionsB6: TestOptions = { unorderedSets: true };
385+
const optionsB7: TestOptions = undefined as unknown as TestOptions;
386+
387+
function getMapWithOptions(options: TestOptions): DeepMap<number, number> {
388+
return new DeepMap([[1, 1]], options);
389+
}
390+
391+
function expectOptionsError(opts1: TestOptions, opts2: TestOptions, operation: ComparableOperation): void {
392+
expect(() => getMapWithOptions(opts1)[operation](getMapWithOptions(opts2))).toThrow(errorMsg);
393+
}
394+
395+
function expectNoOptionsError(
396+
opts1: TestOptions,
397+
opts2: TestOptions,
398+
operation: ComparableOperation
399+
): void {
400+
expect(() => getMapWithOptions(opts1)[operation](getMapWithOptions(opts2))).not.toThrow();
401+
}
402+
403+
it.each(operationTypes)('error when attempting %s operation with different options', async (operation) => {
404+
expectOptionsError(optionsA1, optionsA2, operation);
405+
expectOptionsError(optionsA2, optionsA3, operation);
406+
expectOptionsError(optionsA3, optionsA4, operation);
407+
expectOptionsError(optionsA4, optionsA5, operation);
408+
expectOptionsError(optionsA5, optionsA6, operation);
409+
expectOptionsError(optionsA6, optionsA7, operation);
410+
});
411+
412+
it.each(operationTypes)(
413+
'no error when attempting %s operation with identical options',
414+
async (operation) => {
415+
expectNoOptionsError(optionsA1, optionsB1, operation);
416+
expectNoOptionsError(optionsA2, optionsB2, operation);
417+
expectNoOptionsError(optionsA3, optionsB3, operation);
418+
expectNoOptionsError(optionsA4, optionsB4, operation);
419+
expectNoOptionsError(optionsA5, optionsB5, operation);
420+
expectNoOptionsError(optionsA6, optionsB6, operation);
421+
expectNoOptionsError(optionsA7, optionsB7, operation);
422+
}
423+
);
424+
});
361425
});
362426
});

src/areEqual.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { DeepEqualityDataStructuresError } from './errors';
12
import { Options } from './options';
23
import { DeepSet } from './set';
34

@@ -10,7 +11,7 @@ import { DeepSet } from './set';
1011
*/
1112
export function areEqual<V, TxV = V>(values: V[], options?: Options<V, null, TxV, null>): boolean {
1213
if (values.length === 0) {
13-
throw new Error('Empty values list passed to areEqual function');
14+
throw new DeepEqualityDataStructuresError('Empty values list passed to areEqual function');
1415
}
1516
const set = new DeepSet(values, options);
1617
return set.size === 1;

src/errors.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export class DeepEqualityDataStructuresError extends Error {}

src/map.bi-directional.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { DeepEqualityDataStructuresError } from './errors';
12
import { DeepMap } from './map';
23
import { Normalized } from './normalizer';
34
import { Options } from './options';
@@ -27,7 +28,7 @@ export class BiDirectionalDeepMap<K, V, TxK = K, TxV = V> extends DeepMap<K, V,
2728
// Enforce 1-to-1: Don't allow writing a value which is already present in the map for a different key
2829
const preexistingValueKey = this.getKeyByValue(val);
2930
if (preexistingValueKey !== undefined && this.normalizeKey(preexistingValueKey) !== this.normalizeKey(key)) {
30-
throw new Error(
31+
throw new DeepEqualityDataStructuresError(
3132
`Could not set key='${stringify(key)}': The value='${stringify(
3233
val
3334
)}' is already associated with key='${stringify(preexistingValueKey)}'`

src/map.ts

+16
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Comparable } from './comparable';
2+
import { DeepEqualityDataStructuresError } from './errors';
23
import { Normalized, Normalizer } from './normalizer';
34
import { Options } from './options';
45

@@ -129,6 +130,7 @@ export class DeepMap<K, V, TxK = K, TxV = V> extends Map<K, V> implements Compar
129130
* @returns true if the entries of `other` are the same as this map
130131
*/
131132
equals(other: this): boolean {
133+
this.validateUsingSameOptionsAs(other);
132134
return this.size === other.size && this.contains(other);
133135
}
134136

@@ -137,6 +139,7 @@ export class DeepMap<K, V, TxK = K, TxV = V> extends Map<K, V> implements Compar
137139
* @returns true if the entries of `other` are all contained in this map
138140
*/
139141
contains(other: this): boolean {
142+
this.validateUsingSameOptionsAs(other);
140143
return [...other.entries()].every(([key, val]) => this.keyValuePairIsPresentIn(key, val, this));
141144
}
142145

@@ -147,6 +150,7 @@ export class DeepMap<K, V, TxK = K, TxV = V> extends Map<K, V> implements Compar
147150
* NOTE: If both maps prescribe the same key, the key-value pair from `this` will be retained.
148151
*/
149152
union(other: this): DeepMap<K, V, TxK, TxV> {
153+
this.validateUsingSameOptionsAs(other);
150154
return new DeepMap([...other.entries(), ...this.entries()], this.options);
151155
}
152156

@@ -155,6 +159,8 @@ export class DeepMap<K, V, TxK = K, TxV = V> extends Map<K, V> implements Compar
155159
* @returns a new map containing all key-value pairs in `this` that are also present in `other`.
156160
*/
157161
intersection(other: this): DeepMap<K, V, TxK, TxV> {
162+
this.validateUsingSameOptionsAs(other);
163+
158164
const intersectingPairs = [...this.entries()].filter(([key, val]) =>
159165
this.keyValuePairIsPresentIn(key, val, other)
160166
);
@@ -166,6 +172,8 @@ export class DeepMap<K, V, TxK = K, TxV = V> extends Map<K, V> implements Compar
166172
* @returns a new map containing all key-value pairs in `this` that are not present in `other`.
167173
*/
168174
difference(other: this): DeepMap<K, V, TxK, TxV> {
175+
this.validateUsingSameOptionsAs(other);
176+
169177
const differencePairs = [...this.entries()].filter(
170178
([key, val]) => !this.keyValuePairIsPresentIn(key, val, other)
171179
);
@@ -182,6 +190,14 @@ export class DeepMap<K, V, TxK = K, TxV = V> extends Map<K, V> implements Compar
182190
return this.normalizer.normalizeValue(input);
183191
}
184192

193+
private validateUsingSameOptionsAs(other: this): void {
194+
if (this.normalizer.getOptionsChecksum() !== other['normalizer'].getOptionsChecksum()) {
195+
throw new DeepEqualityDataStructuresError(
196+
'Structures must use same options for Comparable interface operations'
197+
);
198+
}
199+
}
200+
185201
/**
186202
* @returns true if the key is present in the provided map w/ the specified value
187203
*/

src/normalizer.ts

+10
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,11 @@ export class Normalizer<K, V, TxK, TxV> {
2222
private readonly caseInsensitive: boolean;
2323
private readonly keyTransformer: TransformFunction<K, TxK>;
2424
private readonly valueTransformer: TransformFunction<V, TxV>;
25+
private readonly optionsChecksum: string;
2526

2627
constructor(options: Options<K, V, TxK, TxV> = {}) {
28+
this.optionsChecksum = hash(options);
29+
2730
const { transformer, mapValueTransformer, useToJsonTransform, caseInsensitive, ...objectHashOptions } =
2831
getOptionsWithDefaults(options);
2932
this.objectHashOptions = objectHashOptions;
@@ -47,6 +50,13 @@ export class Normalizer<K, V, TxK, TxV> {
4750
}
4851
}
4952

53+
/**
54+
* @returns the checksum for the options passed to this Normalizer
55+
*/
56+
getOptionsChecksum(): string {
57+
return this.optionsChecksum;
58+
}
59+
5060
/**
5161
* Normalize the input by transforming and then hashing the result (if an object)
5262
* @param input the input to normalize

src/set.ts

+5-2
Original file line numberDiff line numberDiff line change
@@ -103,36 +103,39 @@ export class DeepSet<V, TxV = V> extends Set<V> implements Comparable<DeepSet<V,
103103
}
104104

105105
/**
106+
* @param other the set to compare against
106107
* @returns true if the values of `other` are the same as this set
107108
*/
108109
equals(other: this): boolean {
109110
return this.map.equals(other['map']);
110111
}
111112

112113
/**
114+
* @param other the set to compare against
113115
* @returns true if the values of `other` are all contained in this set
114116
*/
115117
contains(other: this): boolean {
116118
return this.map.contains(other['map']);
117119
}
118120

119121
/**
122+
* @param other the set to compare against
120123
* @returns a new set whose values are the union of `this` and `other`.
121-
*
122-
* NOTE: If both maps prescribe the same key, the value from `other` will be retained.
123124
*/
124125
union(other: this): DeepSet<V, TxV> {
125126
return this.getSetFromMapKeys(this.map.union(other['map']));
126127
}
127128

128129
/**
130+
* @param other the set to compare against
129131
* @returns a new set containing all values in `this` that are also in `other`.
130132
*/
131133
intersection(other: this): DeepSet<V, TxV> {
132134
return this.getSetFromMapKeys(this.map.intersection(other['map']));
133135
}
134136

135137
/**
138+
* @param other the set to compare against
136139
* @returns a new set containing all values in `this` that are not also in `other`.
137140
*/
138141
difference(other: this): DeepSet<V, TxV> {

0 commit comments

Comments
 (0)