diff --git a/src/utils/comparison/composite-equality-comparer.ts b/src/utils/comparison/composite-equality-comparer.ts new file mode 100644 index 0000000..72087ae --- /dev/null +++ b/src/utils/comparison/composite-equality-comparer.ts @@ -0,0 +1,137 @@ +import { CALL, makeCallable } from "@/utils/functions/callable"; +import { andEqualityComparers, negateEqualityComparer, orEqualityComparers } from "./equality-comparer.utils"; +import { EqualityComparer } from "./equality-comparer"; + +/** + * A class that represents a composite equality comparer. + * + * @template T - The type of the elements to compare. + */ +export interface CompositeEqualityComparer<T> extends EqualityComparer<T> { + /** + * Compares two values for equality. + * + * @param x - The first value to compare. + * @param y - The second value to compare. + * + * @returns `true` if the values are equal; otherwise, `false`. + */ + (x: T, y: T): boolean; +} + +/** + * A class that represents a composite equality comparer. + * + * @template T - The type of the elements to compare. + */ +export class CompositeEqualityComparer<T> { + /** + * The underlying comparer function used for comparison. + */ + private readonly _comparer: EqualityComparer<T>; + + /** + * The negated version of this comparer. + */ + private _negated?: CompositeEqualityComparer<T>; + + /** + * Creates a new instance of {@link CompositeEqualityComparer}. + * + * @param comparer - An underlying comparer that should be used for comparison. + * @param inverted - A cached inverted {@link CompositeEqualityComparer} instance, if any. + * + * @remarks + * + * Should **not** be called directly. Use {@link create}, or {@link createInternal} instead. + */ + private constructor(comparer: EqualityComparer<T>, inverted: CompositeEqualityComparer<T>) { + this._comparer = comparer; + this._negated = inverted; + } + + /** + * Creates a new instance of {@link CompositeEqualityComparer}. + * + * @template T - The type of the elements to compare. + * @param comparer - An underlying comparer that should be used for comparison. + * @param inverted - A cached inverted {@link CompositeEqualityComparer} instance, if any. + * + * @returns A new instance of {@link CompositeEqualityComparer}. + */ + private static createInternal<T>(comparer: EqualityComparer<T>, inverted?: CompositeEqualityComparer<T>): CompositeEqualityComparer<T> { + return makeCallable(new CompositeEqualityComparer(comparer, inverted)); + } + + /** + * Creates a new instance of {@link CompositeEqualityComparer}. + * + * @template T - The type of the elements to compare. + * @param comparer - An underlying comparer that should be used for comparison. + * + * @returns A new instance of {@link CompositeEqualityComparer}. + */ + static create<T>(comparer: EqualityComparer<T>): CompositeEqualityComparer<T> { + return CompositeEqualityComparer.createInternal(comparer); + } + + /** + * Compares two values for equality. + * + * @param x - The first value to compare. + * @param y - The second value to compare. + * + * @returns `true` if the values are equal; otherwise, `false`. + */ + equals(x: T, y: T): boolean { + return this._comparer(x, y); + } + + /** + * Compares two values for equality. + * + * @param x - The first value to compare. + * @param y - The second value to compare. + * + * @returns `true` if the values are equal; otherwise, `false`. + */ + [CALL](x: T, y: T): boolean { + return this._comparer(x, y); + } + + /** + * Combines this comparer with another using the logical OR operator. + * + * @param comparer - The other comparer to combine with. + * + * @returns A new composite equality comparer representing the combination of this and the other comparer. + */ + or(comparer: EqualityComparer<T>): CompositeEqualityComparer<T> { + const unwrappedComparer = comparer instanceof CompositeEqualityComparer ? comparer._comparer : comparer; + const combinedComparer = orEqualityComparers(this._comparer, unwrappedComparer); + return CompositeEqualityComparer.createInternal(combinedComparer); + } + + /** + * Combines this comparer with another using the logical AND operator. + * + * @param comparer - The other comparer to combine with. + * + * @returns A new composite equality comparer representing the combination of this and the other comparer. + */ + and(comparer: EqualityComparer<T>): CompositeEqualityComparer<T> { + const unwrappedComparer = comparer instanceof CompositeEqualityComparer ? comparer._comparer : comparer; + const combinedComparer = andEqualityComparers(this._comparer, unwrappedComparer); + return CompositeEqualityComparer.createInternal(combinedComparer); + } + + /** + * Negates this comparer using the logical NOT operator. + * + * @returns A new composite equality comparer representing the negation of this comparer. + */ + negate(): CompositeEqualityComparer<T> { + this._negated ??= CompositeEqualityComparer.createInternal(negateEqualityComparer(this._comparer), this); + return this._negated; + } +} diff --git a/src/utils/comparison/equality-comparer.ts b/src/utils/comparison/equality-comparer.ts new file mode 100644 index 0000000..aeea0bd --- /dev/null +++ b/src/utils/comparison/equality-comparer.ts @@ -0,0 +1,52 @@ +import { CompositeEqualityComparer } from "./composite-equality-comparer"; + +/** + * A function that compares two values for equality. + * + * @template T - The type of values being compared. + */ +export interface EqualityComparer<T> { + /** + * Compares two values for equality. + * + * @param x - The first value to compare. + * @param y - The second value to compare. + * + * @returns `true` if the values are equal; otherwise, `false`. + */ + (x: T, y: T): boolean; +} + +/** + * Creates a composite equality comparer from the specified function. + * + * @template T - The type of values being compared. + * + * @param comparer - The equality comparer function to use as the base comparer. + * + * @returns A new {@link CompositeEqualityComparer} object. + */ +export function createEqualityComparer<T>(comparer: EqualityComparer<T>): CompositeEqualityComparer<T> { + return CompositeEqualityComparer.create(comparer); +} + +// These functions were moved to a different file because of problems with circular references. +export { + negateEqualityComparer, + orEqualityComparers, + andEqualityComparers, +} from "./equality-comparer.utils"; + +/** + * The default equality comparer that uses strict equality (`===`) to compare values. + */ +const DEFAULT_EQUALITY_COMPARER = createEqualityComparer<unknown>((x, y) => x === y); + +/** + * Creates a composite equality comparer that uses strict equality (`===`) to compare values. + * + * @template T - The type of values being compared. + */ +export function createDefaultEqualityComparer<T>(): CompositeEqualityComparer<T> { + return DEFAULT_EQUALITY_COMPARER as CompositeEqualityComparer<T>; +} diff --git a/src/utils/comparison/equality-comparer.utils.ts b/src/utils/comparison/equality-comparer.utils.ts new file mode 100644 index 0000000..d7868c5 --- /dev/null +++ b/src/utils/comparison/equality-comparer.utils.ts @@ -0,0 +1,42 @@ +import { EqualityComparer } from "./equality-comparer"; + +/** + * Returns a new equality comparer that is the negation of the specified comparer. + * + * @template T - The type of values being compared. + * + * @param comparer - The equality comparer to negate. + * + * @returns A new equality comparer that returns `true` when the specified comparer returns `false`, and vice versa. + */ +export function negateEqualityComparer<T>(comparer: EqualityComparer<T>): EqualityComparer<T> { + return (x, y) => !comparer(x, y); +} + +/** + * Combines two equality comparers using the logical OR operator. + * + * @template T - The type of values being compared. + * + * @param left - The first equality comparer to use in the OR operation. + * @param right - The second equality comparer to use in the OR operation. + * + * @returns A new equality comparer that returns `true` if either the `left` or `right` comparer returns `true`. + */ +export function orEqualityComparers<T>(left: EqualityComparer<T>, right: EqualityComparer<T>): EqualityComparer<T> { + return (x, y) => left(x, y) || right(x, y); +} + +/** + * Combines two equality comparers using the logical AND operator. + * + * @template T - The type of values being compared. + * + * @param left - The first equality comparer to use in the AND operation. + * @param right - The second equality comparer to use in the AND operation. + * + * @returns A new equality comparer that returns `true` if both the `left` and `right` comparers return `true`. + */ +export function andEqualityComparers<T>(left: EqualityComparer<T>, right: EqualityComparer<T>): EqualityComparer<T> { + return (x, y) => left(x, y) && right(x, y); +} diff --git a/tests/unit/utils/comparison/composite-equality-comparer.spec.ts b/tests/unit/utils/comparison/composite-equality-comparer.spec.ts new file mode 100644 index 0000000..c284c1a --- /dev/null +++ b/tests/unit/utils/comparison/composite-equality-comparer.spec.ts @@ -0,0 +1,96 @@ +import { CompositeEqualityComparer } from "@/utils/comparison/composite-equality-comparer"; + +describe("CompositeEqualityComparer", () => { + describe("create", () => { + test("creates a new instance from the given equality comparer", () => { + const comparer = CompositeEqualityComparer.create((a: number, b: number) => a === b); + + expect(comparer).toBeInstanceOf(CompositeEqualityComparer); + }); + }); + + describe("equals", () => { + test("returns true when values are equal", () => { + const comparer = CompositeEqualityComparer.create((a: number, b: number) => a === b); + + expect(comparer.equals(0, 0)).toBe(true); + expect(comparer.equals(1, 1)).toBe(true); + expect(comparer.equals(2, 2)).toBe(true); + }); + + test("returns false when values are not equal", () => { + const comparer = CompositeEqualityComparer.create((a: number, b: number) => a === b); + + expect(comparer.equals(2, 0)).toBe(false); + expect(comparer.equals(0, 1)).toBe(false); + expect(comparer.equals(1, 2)).toBe(false); + }); + }); + + describe("__invoke__", () => { + test("can be used as a function", () => { + const comparer = CompositeEqualityComparer.create((a: number, b: number) => a === b); + + expect(comparer(0, 0)).toBe(true); + expect(comparer(1, 1)).toBe(true); + expect(comparer(2, 2)).toBe(true); + expect(comparer(2, 0)).toBe(false); + expect(comparer(0, 1)).toBe(false); + expect(comparer(1, 2)).toBe(false); + }); + }); + + describe("or", () => { + test("returns true when either comparer returns true", () => { + const comparerA = (x: number, y: number) => x === y; + const comparerB = (x: number, y: number) => x < y; + + const comparer = CompositeEqualityComparer.create(comparerA).or(comparerB); + + expect(comparer.equals(1, 2)).toBe(true); + }); + + test("returns false when both comparers return false", () => { + const comparerA = (x: number, y: number) => x === y; + const comparerB = (x: number, y: number) => x < y; + + const comparer = CompositeEqualityComparer.create(comparerA).or(comparerB); + + expect(comparer.equals(2, 1)).toBe(false); + }); + }); + + describe("and", () => { + test("returns true when both comparers return true", () => { + const comparerA = (x: number, y: number) => x === y; + const comparerB = (x: number, y: number) => x % 2 === y; + + const comparer = CompositeEqualityComparer.create(comparerA).and(comparerB); + + expect(comparer.equals(1, 1)).toBe(true); + }); + + test("returns false when either comparer returns false", () => { + const comparerA = (x: number, y: number) => x === y; + const comparerB = (x: number, y: number) => x % 2 === y; + + const comparer = CompositeEqualityComparer.create(comparerA).and(comparerB); + + expect(comparer.equals(2, 2)).toBe(false); + }); + }); + + describe("negate", () => { + test("returns true when original comparer returns false", () => { + const comparer = CompositeEqualityComparer.create((a: number, b: number) => a === b).negate(); + + expect(comparer.equals(1, 2)).toBe(true); + }); + + test("returns false when original comparer returns true", () => { + const comparer = CompositeEqualityComparer.create((a: number, b: number) => a === b).negate(); + + expect(comparer.equals(1, 1)).toBe(false); + }); + }); +}); diff --git a/tests/unit/utils/comparison/equality-comparer.spec.ts b/tests/unit/utils/comparison/equality-comparer.spec.ts new file mode 100644 index 0000000..f8bde7d --- /dev/null +++ b/tests/unit/utils/comparison/equality-comparer.spec.ts @@ -0,0 +1,95 @@ +import { CompositeEqualityComparer } from "@/utils/comparison/composite-equality-comparer"; +import { + andEqualityComparers, + createDefaultEqualityComparer, + createEqualityComparer, + negateEqualityComparer, + orEqualityComparers, +} from "@/utils/comparison/equality-comparer"; + +describe("createEqualityComparer", () => { + test("creates a new CompositeEqualityComparer instance from the given equality comparer", () => { + const comparer = createEqualityComparer((a: number, b: number) => a === b); + + expect(comparer).toBeInstanceOf(CompositeEqualityComparer); + }); +}); + +describe("orEqualityComparers", () => { + test("returns true when either comparer returns true", () => { + const comparerA = (x: number, y: number) => x === y; + const comparerB = (x: number, y: number) => x < y; + + const comparer = orEqualityComparers(comparerA, comparerB); + + expect(comparer(1, 2)).toBe(true); + }); + + test("returns false when both comparers return false", () => { + const comparerA = (x: number, y: number) => x === y; + const comparerB = (x: number, y: number) => x < y; + + const comparer = orEqualityComparers(comparerA, comparerB); + + expect(comparer(2, 1)).toBe(false); + }); +}); + +describe("andEqualityComparers", () => { + test("returns true when both comparers return true", () => { + const comparerA = (x: number, y: number) => x === y; + const comparerB = (x: number, y: number) => x % 2 === y; + + const comparer = andEqualityComparers(comparerA, comparerB); + + expect(comparer(1, 1)).toBe(true); + }); + + test("returns false when either comparer returns false", () => { + const comparerA = (x: number, y: number) => x === y; + const comparerB = (x: number, y: number) => x % 2 === y; + + const comparer = andEqualityComparers(comparerA, comparerB); + + expect(comparer(2, 2)).toBe(false); + }); +}); + +describe("negateEqualityComparer", () => { + test("returns true when original comparer returns false", () => { + const comparer = negateEqualityComparer((a: number, b: number) => a === b); + + expect(comparer(1, 2)).toBe(true); + }); + + test("returns false when original comparer returns true", () => { + const comparer = negateEqualityComparer((a: number, b: number) => a === b); + + expect(comparer(1, 1)).toBe(false); + }); +}); + +describe("createDefaultEqualityComparer", () => { + test("returns true for strictly equal values", () => { + const comparer = createDefaultEqualityComparer(); + + const sameRef = {}; + expect(comparer(1, 1)).toBe(true); + expect(comparer("test", "test")).toBe(true); + expect(comparer(sameRef, sameRef)).toBe(true); + expect(comparer(Symbol.toStringTag, Symbol.toStringTag)).toBe(true); + expect(comparer(null, null)).toBe(true); + expect(comparer(undefined, undefined)).toBe(true); + }); + + test("returns false for not strictly equal values", () => { + const comparer = createDefaultEqualityComparer(); + + expect(comparer(1, 2)).toBe(false); + expect(comparer(1, "1")).toBe(false); + expect(comparer("test", "tset")).toBe(false); + expect(comparer({}, {})).toBe(false); + expect(comparer(Symbol("Symbol"), Symbol("Symbol"))).toBe(false); + expect(comparer(null, undefined)).toBe(false); + }); +});