Skip to content

feat!: validate consistent options used during comparison operations #23

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
Apr 10, 2025
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
1 change: 1 addition & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }],
Expand Down
64 changes: 64 additions & 0 deletions src/__tests__/map.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -358,5 +360,67 @@ describe('DeepMap', () => {
expect([...differenceMap.entries()]).toStrictEqual([[new K('1'), new V('1')]]);
});
});

describe('Options checksum validation', () => {
type TestOptions = Options<number, number, number, number>;
type ComparableOperation = keyof Comparable<number>;

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<number, number> {
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);
}
);
});
});
});
3 changes: 2 additions & 1 deletion src/areEqual.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { DeepEqualityDataStructuresError } from './errors';
import { Options } from './options';
import { DeepSet } from './set';

Expand All @@ -10,7 +11,7 @@ import { DeepSet } from './set';
*/
export function areEqual<V, TxV = V>(values: V[], options?: Options<V, null, TxV, null>): 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;
Expand Down
1 change: 1 addition & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export class DeepEqualityDataStructuresError extends Error {}
3 changes: 2 additions & 1 deletion src/map.bi-directional.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { DeepEqualityDataStructuresError } from './errors';
import { DeepMap } from './map';
import { Normalized } from './normalizer';
import { Options } from './options';
Expand Down Expand Up @@ -27,7 +28,7 @@ export class BiDirectionalDeepMap<K, V, TxK = K, TxV = V> extends DeepMap<K, V,
// Enforce 1-to-1: Don't allow writing a value which is already present in the map for a different key
const preexistingValueKey = this.getKeyByValue(val);
if (preexistingValueKey !== undefined && this.normalizeKey(preexistingValueKey) !== this.normalizeKey(key)) {
throw new Error(
throw new DeepEqualityDataStructuresError(
`Could not set key='${stringify(key)}': The value='${stringify(
val
)}' is already associated with key='${stringify(preexistingValueKey)}'`
Expand Down
16 changes: 16 additions & 0 deletions src/map.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Comparable } from './comparable';
import { DeepEqualityDataStructuresError } from './errors';
import { Normalized, Normalizer } from './normalizer';
import { Options } from './options';

Expand Down Expand Up @@ -129,6 +130,7 @@ export class DeepMap<K, V, TxK = K, TxV = V> extends Map<K, V> 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);
}

Expand All @@ -137,6 +139,7 @@ export class DeepMap<K, V, TxK = K, TxV = V> extends Map<K, V> 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));
}

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

Expand All @@ -155,6 +159,8 @@ export class DeepMap<K, V, TxK = K, TxV = V> extends Map<K, V> implements Compar
* @returns a new map containing all key-value pairs in `this` that are also present in `other`.
*/
intersection(other: this): DeepMap<K, V, TxK, TxV> {
this.validateUsingSameOptionsAs(other);

const intersectingPairs = [...this.entries()].filter(([key, val]) =>
this.keyValuePairIsPresentIn(key, val, other)
);
Expand All @@ -166,6 +172,8 @@ export class DeepMap<K, V, TxK = K, TxV = V> extends Map<K, V> implements Compar
* @returns a new map containing all key-value pairs in `this` that are not present in `other`.
*/
difference(other: this): DeepMap<K, V, TxK, TxV> {
this.validateUsingSameOptionsAs(other);

const differencePairs = [...this.entries()].filter(
([key, val]) => !this.keyValuePairIsPresentIn(key, val, other)
);
Expand All @@ -182,6 +190,14 @@ export class DeepMap<K, V, TxK = K, TxV = V> extends Map<K, V> 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
*/
Expand Down
10 changes: 10 additions & 0 deletions src/normalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,11 @@ export class Normalizer<K, V, TxK, TxV> {
private readonly caseInsensitive: boolean;
private readonly keyTransformer: TransformFunction<K, TxK>;
private readonly valueTransformer: TransformFunction<V, TxV>;
private readonly optionsChecksum: string;

constructor(options: Options<K, V, TxK, TxV> = {}) {
this.optionsChecksum = hash(options);

const { transformer, mapValueTransformer, useToJsonTransform, caseInsensitive, ...objectHashOptions } =
getOptionsWithDefaults(options);
this.objectHashOptions = objectHashOptions;
Expand All @@ -47,6 +50,13 @@ export class Normalizer<K, V, TxK, TxV> {
}
}

/**
* @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
Expand Down
7 changes: 5 additions & 2 deletions src/set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,36 +103,39 @@ export class DeepSet<V, TxV = V> extends Set<V> implements Comparable<DeepSet<V,
}

/**
* @param other the set to compare against
* @returns true if the values of `other` are the same as this set
*/
equals(other: this): boolean {
return this.map.equals(other['map']);
}

/**
* @param other the set to compare against
* @returns true if the values of `other` are all contained in this set
*/
contains(other: this): boolean {
return this.map.contains(other['map']);
}

/**
* @param other the set to compare against
* @returns a new set whose values are the union of `this` and `other`.
*
* NOTE: If both maps prescribe the same key, the value from `other` will be retained.
*/
union(other: this): DeepSet<V, TxV> {
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<V, TxV> {
return this.getSetFromMapKeys(this.map.intersection(other['map']));
}

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