diff --git a/.eslintrc.js b/.eslintrc.js index 016f619..bf39de4 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -6,6 +6,7 @@ module.exports = { plugins: ['jest'], extends: ['plugin:jest/all'], rules: { + 'jest/expect-expect': ['error', { assertFunctionNames: ['expect*'] }], 'jest/prefer-expect-assertions': 'off', 'jest/prefer-lowercase-title': 'off', 'jest/max-expects': ['error', { max: 8 }], diff --git a/src/__tests__/map.test.ts b/src/__tests__/map.test.ts index cd99f7d..428b7f4 100644 --- a/src/__tests__/map.test.ts +++ b/src/__tests__/map.test.ts @@ -1,4 +1,6 @@ +import { Comparable } from '../comparable'; import { DeepMap } from '../map'; +import { Options } from '../options'; import { TestObjectField, TestObject } from './common/test.utils'; describe('DeepMap', () => { @@ -358,5 +360,67 @@ describe('DeepMap', () => { expect([...differenceMap.entries()]).toStrictEqual([[new K('1'), new V('1')]]); }); }); + + describe('Options checksum validation', () => { + type TestOptions = Options; + type ComparableOperation = keyof Comparable; + + const operationTypes: ComparableOperation[] = ['equals', 'contains', 'union', 'intersection', 'difference']; + const errorMsg = 'Structures must use same options for Comparable interface operations'; + + const optionsA1: TestOptions = {}; + const optionsA2: TestOptions = { useToJsonTransform: true }; + const optionsA3: TestOptions = { transformer: (n) => n + 1 }; + const optionsA4: TestOptions = { transformer: (k) => k + 1 }; + const optionsA5: TestOptions = { transformer: (k) => k + 1, unorderedSets: true }; + const optionsA6: TestOptions = { unorderedSets: true }; + const optionsA7: TestOptions = undefined as unknown as TestOptions; + + const optionsB1: TestOptions = {}; + const optionsB2: TestOptions = { useToJsonTransform: true }; + const optionsB3: TestOptions = { transformer: (n) => n + 1 }; + const optionsB4: TestOptions = { transformer: (k) => k + 1 }; + const optionsB5: TestOptions = { transformer: (k) => k + 1, unorderedSets: true }; + const optionsB6: TestOptions = { unorderedSets: true }; + const optionsB7: TestOptions = undefined as unknown as TestOptions; + + function getMapWithOptions(options: TestOptions): DeepMap { + return new DeepMap([[1, 1]], options); + } + + function expectOptionsError(opts1: TestOptions, opts2: TestOptions, operation: ComparableOperation): void { + expect(() => getMapWithOptions(opts1)[operation](getMapWithOptions(opts2))).toThrow(errorMsg); + } + + function expectNoOptionsError( + opts1: TestOptions, + opts2: TestOptions, + operation: ComparableOperation + ): void { + expect(() => getMapWithOptions(opts1)[operation](getMapWithOptions(opts2))).not.toThrow(); + } + + it.each(operationTypes)('error when attempting %s operation with different options', async (operation) => { + expectOptionsError(optionsA1, optionsA2, operation); + expectOptionsError(optionsA2, optionsA3, operation); + expectOptionsError(optionsA3, optionsA4, operation); + expectOptionsError(optionsA4, optionsA5, operation); + expectOptionsError(optionsA5, optionsA6, operation); + expectOptionsError(optionsA6, optionsA7, operation); + }); + + it.each(operationTypes)( + 'no error when attempting %s operation with identical options', + async (operation) => { + expectNoOptionsError(optionsA1, optionsB1, operation); + expectNoOptionsError(optionsA2, optionsB2, operation); + expectNoOptionsError(optionsA3, optionsB3, operation); + expectNoOptionsError(optionsA4, optionsB4, operation); + expectNoOptionsError(optionsA5, optionsB5, operation); + expectNoOptionsError(optionsA6, optionsB6, operation); + expectNoOptionsError(optionsA7, optionsB7, operation); + } + ); + }); }); }); diff --git a/src/areEqual.ts b/src/areEqual.ts index 81acbab..c63c1b0 100644 --- a/src/areEqual.ts +++ b/src/areEqual.ts @@ -1,3 +1,4 @@ +import { DeepEqualityDataStructuresError } from './errors'; import { Options } from './options'; import { DeepSet } from './set'; @@ -10,7 +11,7 @@ import { DeepSet } from './set'; */ export function areEqual(values: V[], options?: Options): boolean { if (values.length === 0) { - throw new Error('Empty values list passed to areEqual function'); + throw new DeepEqualityDataStructuresError('Empty values list passed to areEqual function'); } const set = new DeepSet(values, options); return set.size === 1; diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..dff3b1c --- /dev/null +++ b/src/errors.ts @@ -0,0 +1 @@ +export class DeepEqualityDataStructuresError extends Error {} diff --git a/src/map.bi-directional.ts b/src/map.bi-directional.ts index 44ec373..05159d3 100644 --- a/src/map.bi-directional.ts +++ b/src/map.bi-directional.ts @@ -1,3 +1,4 @@ +import { DeepEqualityDataStructuresError } from './errors'; import { DeepMap } from './map'; import { Normalized } from './normalizer'; import { Options } from './options'; @@ -27,7 +28,7 @@ export class BiDirectionalDeepMap extends DeepMap extends Map implements Compar * @returns true if the entries of `other` are the same as this map */ equals(other: this): boolean { + this.validateUsingSameOptionsAs(other); return this.size === other.size && this.contains(other); } @@ -137,6 +139,7 @@ export class DeepMap extends Map implements Compar * @returns true if the entries of `other` are all contained in this map */ contains(other: this): boolean { + this.validateUsingSameOptionsAs(other); return [...other.entries()].every(([key, val]) => this.keyValuePairIsPresentIn(key, val, this)); } @@ -147,6 +150,7 @@ export class DeepMap extends Map implements Compar * NOTE: If both maps prescribe the same key, the key-value pair from `this` will be retained. */ union(other: this): DeepMap { + this.validateUsingSameOptionsAs(other); return new DeepMap([...other.entries(), ...this.entries()], this.options); } @@ -155,6 +159,8 @@ export class DeepMap extends Map implements Compar * @returns a new map containing all key-value pairs in `this` that are also present in `other`. */ intersection(other: this): DeepMap { + this.validateUsingSameOptionsAs(other); + const intersectingPairs = [...this.entries()].filter(([key, val]) => this.keyValuePairIsPresentIn(key, val, other) ); @@ -166,6 +172,8 @@ export class DeepMap extends Map implements Compar * @returns a new map containing all key-value pairs in `this` that are not present in `other`. */ difference(other: this): DeepMap { + this.validateUsingSameOptionsAs(other); + const differencePairs = [...this.entries()].filter( ([key, val]) => !this.keyValuePairIsPresentIn(key, val, other) ); @@ -182,6 +190,14 @@ export class DeepMap extends Map implements Compar return this.normalizer.normalizeValue(input); } + private validateUsingSameOptionsAs(other: this): void { + if (this.normalizer.getOptionsChecksum() !== other['normalizer'].getOptionsChecksum()) { + throw new DeepEqualityDataStructuresError( + 'Structures must use same options for Comparable interface operations' + ); + } + } + /** * @returns true if the key is present in the provided map w/ the specified value */ diff --git a/src/normalizer.ts b/src/normalizer.ts index 8576d26..ba04d60 100644 --- a/src/normalizer.ts +++ b/src/normalizer.ts @@ -22,8 +22,11 @@ export class Normalizer { private readonly caseInsensitive: boolean; private readonly keyTransformer: TransformFunction; private readonly valueTransformer: TransformFunction; + private readonly optionsChecksum: string; constructor(options: Options = {}) { + this.optionsChecksum = hash(options); + const { transformer, mapValueTransformer, useToJsonTransform, caseInsensitive, ...objectHashOptions } = getOptionsWithDefaults(options); this.objectHashOptions = objectHashOptions; @@ -47,6 +50,13 @@ export class Normalizer { } } + /** + * @returns the checksum for the options passed to this Normalizer + */ + getOptionsChecksum(): string { + return this.optionsChecksum; + } + /** * Normalize the input by transforming and then hashing the result (if an object) * @param input the input to normalize diff --git a/src/set.ts b/src/set.ts index f4f79b3..9f7aa4f 100644 --- a/src/set.ts +++ b/src/set.ts @@ -103,6 +103,7 @@ export class DeepSet extends Set implements Comparable extends Set implements Comparable extends Set implements Comparable { return this.getSetFromMapKeys(this.map.union(other['map'])); } /** + * @param other the set to compare against * @returns a new set containing all values in `this` that are also in `other`. */ intersection(other: this): DeepSet { @@ -133,6 +135,7 @@ export class DeepSet extends Set implements Comparable {