diff --git a/src/utils/comparison/comparer.ts b/src/utils/comparison/comparer.ts new file mode 100644 index 0000000..3001a38 --- /dev/null +++ b/src/utils/comparison/comparer.ts @@ -0,0 +1,93 @@ +import { CompositeComparer } from "./composite-comparer"; + +/** + * A comparator function that returns a number that represents the comparison result between two elements. + * + * - If the returned number is **negative**, it means `left` is **less** than `right`. + * - If the returned number is **zero**, it means `left` is **equal** to `right`. + * - If the returned number is **positive**, it means `left` is **greater** than `right`. + * + * @template T - The type of the elements to compare. + */ +export interface Comparer { + /** + * Compares two elements and returns a value indicating whether one element is less than, equal to, or greater than the other. + * + * @param left - The first element to compare. + * @param right - The second element to compare. + * + * @returns A number that represents the comparison result. + */ + (left: T, right: T): number; +} + +/** + * Creates a new {@link CompositeComparer} instance based on the specified `comparer`. + * + * @template T - The type of the elements being compared. + * @param comparer - The base {@link Comparer} used to create the new {@link CompositeComparer}. + * + * @returns A new {@link CompositeComparer} instance. + */ +export function createComparer(comparer: Comparer): CompositeComparer { + return CompositeComparer.create(comparer); +} + +// These functions were moved to a different file because of problems with circular references. +export { + convertComparerToEqualityComparer, + invertComparer, + combineComparers, +} from "./comparer.utils"; + +/** + * The base comparer that can compare any two values. + * + * It treats `undefined` as smaller than any other value, and `null` as smaller than any value except `undefined`. + * Any non-null and non-undefined values are considered equal. + */ +const BASE_COMPARER: CompositeComparer = createComparer((left, right) => { + if (left === undefined) { + return right === undefined ? 0 : -1; + } + + if (left === null) { + return right === undefined ? 1 : right === null ? 0 : -1; + } + + if (right === undefined || right === null) { + return 1; + } + + return 0; +}); + +/** + * The default comparer that compares two values using their natural order + * defined by the built-in `>` and `<` operators. + */ +const DEFAULT_COMPARER: CompositeComparer = BASE_COMPARER.thenBy( + (left, right) => left < right ? -1 : left > right ? 1 : 0 +); + +/** + * Creates a base comparer that can compare any two values. + * + * It treats `undefined` as smaller than any other value, and `null` as smaller than any value except `undefined`. + * Any non-null and non-undefined values are considered equal. + * + * @template T - The type of the elements being compared. + */ +export function createBaseComparer(): CompositeComparer { + return BASE_COMPARER as CompositeComparer; +} + +/** + * Creates a default comparer that compares two values using their natural order + * defined by the built-in `>` and `<` operators. + * + * @template T - The type of the elements being compared. + */ +export function createDefaultComparer(): CompositeComparer { + return DEFAULT_COMPARER as CompositeComparer; +} diff --git a/src/utils/comparison/comparer.utils.ts b/src/utils/comparison/comparer.utils.ts new file mode 100644 index 0000000..605c9b6 --- /dev/null +++ b/src/utils/comparison/comparer.utils.ts @@ -0,0 +1,43 @@ +import { Comparer } from "./comparer"; +import { EqualityComparer } from "./equality-comparer"; + +/** + * Converts a comparer function into an equality comparer function. + * The resulting equality comparer function returns `true` if the comparer returns `0`. + * + * @param comparer - The comparer function to convert. + * + * @returns An equality comparer function that returns `true` if the comparer returns `0`. + */ +export function convertComparerToEqualityComparer(comparer: Comparer): EqualityComparer { + return (x, y) => comparer(x, y) === 0; +} + +/** + * Returns a new comparer function that represents the inverted comparison result of the original comparer. + * + * @template T - The type of the elements to compare. + * @param comparer - The original comparer function. + * + * @returns A new comparer function that represents the inverted comparison result of the original comparer. + */ +export function invertComparer(comparer: Comparer): Comparer { + return (left, right) => comparer(right, left); +} + +/** + * Combines two {@link Comparer} instances in order to create a new one that sorts + * elements based on the first comparer, and then by the second one. + * + * @template T - The type of the elements being compared. + * @param left - The first comparer to use when comparing elements. + * @param right - The second comparer to use when comparing elements. + * + * @returns A new {@link Comparer} instance that sorts elements based on the first comparer, and then by the second one. + */ +export function combineComparers(left: Comparer, right: Comparer): Comparer { + return (a, b) => { + const leftResult = left(a, b); + return leftResult === 0 ? right(a, b) : leftResult; + }; +} diff --git a/src/utils/comparison/composite-comparer.ts b/src/utils/comparison/composite-comparer.ts new file mode 100644 index 0000000..22e2295 --- /dev/null +++ b/src/utils/comparison/composite-comparer.ts @@ -0,0 +1,134 @@ +import { CALL, makeCallable } from "@/utils/functions/callable"; +import { combineComparers, convertComparerToEqualityComparer, invertComparer } from "./comparer.utils"; +import { CompositeEqualityComparer } from "./composite-equality-comparer"; +import { Comparer } from "./comparer"; + +/** + * A class that represents a composite comparer. + * + * @template T - The type of the elements to compare. + */ +export interface CompositeComparer extends Comparer { + /** + * Compares two elements and returns a value indicating whether one element is less than, equal to, or greater than the other. + * + * @param left - The first element to compare. + * @param right - The second element to compare. + * + * @returns A number that represents the comparison result. + */ + (left: T, right: T): number; +} + +/** + * A class that represents a composite comparer. + * + * @template T - The type of the elements to compare. + */ +export class CompositeComparer { + /** + * The underlying comparer function used for comparison. + */ + private readonly _comparer: Comparer; + + /** + * The inverted version of this comparer. + */ + private _inverted?: CompositeComparer; + + /** + * Constructs a new instance of {@link CompositeComparer}. + * + * @param comparer - An underlying comparer that should be used for comparison. + * @param inverted - A cached inverted {@link CompositeComparer} instance, if any. + * + * @remarks + * + * Should **not** be called directly. Use {@link create}, or {@link createInternal} instead. + */ + private constructor(comparer: Comparer, inverted: CompositeComparer) { + this._comparer = comparer; + this._inverted = inverted; + } + + /** + * Creates a new instance of {@link CompositeComparer}. + * + * @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 CompositeComparer} instance, if any. + * + * @returns A new instance of {@link CompositeComparer}. + */ + private static createInternal(comparer: Comparer, inverted?: CompositeComparer): CompositeComparer { + return makeCallable(new CompositeComparer(comparer, inverted)); + } + + /** + * Creates a new instance of {@link CompositeComparer}. + * + * @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 CompositeComparer}. + */ + static create(comparer: Comparer): CompositeComparer { + return CompositeComparer.createInternal(comparer); + } + + /** + * Compares two elements and returns a value indicating whether one element is less than, equal to, or greater than the other. + * + * @param left - The first element to compare. + * @param right - The second element to compare. + * + * @returns A number that represents the comparison result. + */ + compare(left: T, right: T): number { + return this._comparer(left, right); + } + + /** + * Compares two elements and returns a value indicating whether one element is less than, equal to, or greater than the other. + * + * @param left - The first element to compare. + * @param right - The second element to compare. + * + * @returns A number that represents the comparison result. + */ + [CALL](left: T, right: T): number { + return this._comparer(left, right); + } + + /** + * Creates a new comparer which compares elements using this comparer first, and then using the `nextComparer`. + * + * @param comparer - The next comparer to use if this comparer returns equal result. + * + * @returns A new comparer which compares elements using this comparer first, and then using the `nextComparer`. + */ + thenBy(comparer: Comparer): CompositeComparer { + const unwrappedComparer = comparer instanceof CompositeComparer ? comparer._comparer : comparer; + const combinedComparer = combineComparers(this._comparer, unwrappedComparer); + return CompositeComparer.createInternal(combinedComparer); + } + + /** + * Creates a new comparer that inverts the comparison result of this comparer. + * + * @returns A new comparer that inverts the comparison result of this comparer. + */ + invert(): CompositeComparer { + this._inverted ??= CompositeComparer.createInternal(invertComparer(this._comparer), this); + return this._inverted; + } + + /** + * Converts the current {@link CompositeComparer} instance into a new {@link CompositeEqualityComparer} instance. + * + * @returns A new {@link CompositeEqualityComparer} instance that uses the underlying comparer function to compare for equality. + */ + asEqualityComparer(): CompositeEqualityComparer { + return CompositeEqualityComparer.create(convertComparerToEqualityComparer(this._comparer)); + } +} diff --git a/tests/unit/utils/comparison/comparer.spec.ts b/tests/unit/utils/comparison/comparer.spec.ts new file mode 100644 index 0000000..c34ff15 --- /dev/null +++ b/tests/unit/utils/comparison/comparer.spec.ts @@ -0,0 +1,113 @@ +import { CompositeComparer } from "@/utils/comparison/composite-comparer"; +import { + createComparer, + combineComparers, + invertComparer, + convertComparerToEqualityComparer, + createBaseComparer, + createDefaultComparer, +} from "@/utils/comparison/comparer"; + +describe("createComparer", () => { + test("creates a CompositeComparer from the given comparer", () => { + const comparer = createComparer((a: number, b: number) => a - b); + + expect(comparer).toBeInstanceOf(CompositeComparer); + }); +}); + +describe("combineComparers", () => { + test("chains comparers in the right order", () => { + const firstCompare = (a: [number, string], b: [number, string]) => a[0] - b[0]; + const secondCompare = (a: [number, string], b: [number, string]) => a[1].localeCompare(b[1]); + + const comparer = combineComparers(firstCompare, secondCompare); + + expect(comparer([1, "b"], [2, "a"])).toBeLessThan(0); + expect(comparer([2, "a"], [1, "b"])).toBeGreaterThan(0); + expect(comparer([1, "a"], [1, "b"])).toBeLessThan(0); + expect(comparer([1, "b"], [1, "a"])).toBeGreaterThan(0); + expect(comparer([1, "a"], [1, "a"])).toEqual(0); + }); +}); + +describe("invertComparer", () => { + test("inverts comparisons", () => { + const comparer = invertComparer((a: number, b:number) => a - b); + + expect(comparer(5, 3)).toBeLessThan(0); + expect(comparer(3, 5)).toBeGreaterThan(0); + expect(comparer(5, 5)).toEqual(0); + }); +}); + +describe("convertComparerToEqualityComparer", () => { + test("returns an equality comparer that returns true when the original comparer would return 0", () => { + const comparer = convertComparerToEqualityComparer((a: number, b: number) => a - b); + + expect(comparer(5, 5)).toEqual(true); + expect(comparer(3, 5)).toEqual(false); + expect(comparer(5, 3)).toEqual(false); + }); +}); + +describe("createBaseComparer", () => { + test("treats undefined as smaller than any other value", () => { + const comparer = createBaseComparer(); + + expect(comparer(undefined, null)).toBeLessThan(0); + expect(comparer(undefined, 5)).toBeLessThan(0); + expect(comparer(undefined, "test")).toBeLessThan(0); + expect(comparer(undefined, undefined)).toEqual(0); + }); + + test("treats null as smaller than any other value except undefined", () => { + const comparer = createBaseComparer(); + + expect(comparer(null, undefined)).toBeGreaterThan(0); + expect(comparer(null, null)).toEqual(0); + expect(comparer(null, 5)).toBeLessThan(0); + expect(comparer(null, "test")).toBeLessThan(0); + }); + + test("treats any non-null and non-undefined values as equal", () => { + const comparer = createBaseComparer(); + + expect(comparer(5, 5)).toEqual(0); + expect(comparer("test", "test")).toEqual(0); + expect(comparer("test", 5)).toEqual(0); + expect(comparer({}, [])).toEqual(0); + }); +}); + +describe("createDefaultComparer", () => { + test("compares two values using their natural order", () => { + const comparer = createDefaultComparer(); + + expect(comparer(5, 3)).toBeGreaterThan(0); + expect(comparer(3, 5)).toBeLessThan(0); + expect(comparer(5, 5)).toEqual(0); + + expect(comparer("b", "a")).toBeGreaterThan(0); + expect(comparer("a", "b")).toBeLessThan(0); + expect(comparer("a", "a")).toEqual(0); + }); + + test("treats undefined as smaller than any other value", () => { + const comparer = createDefaultComparer(); + + expect(comparer(undefined, null)).toBeLessThan(0); + expect(comparer(undefined, 5)).toBeLessThan(0); + expect(comparer(undefined, "test")).toBeLessThan(0); + expect(comparer(undefined, undefined)).toEqual(0); + }); + + test("treats null as smaller than any other value except undefined", () => { + const comparer = createDefaultComparer(); + + expect(comparer(null, undefined)).toBeGreaterThan(0); + expect(comparer(null, null)).toEqual(0); + expect(comparer(null, 5)).toBeLessThan(0); + expect(comparer(null, "test")).toBeLessThan(0); + }); +}); diff --git a/tests/unit/utils/comparison/composite-comparer.spec.ts b/tests/unit/utils/comparison/composite-comparer.spec.ts new file mode 100644 index 0000000..57f0aa9 --- /dev/null +++ b/tests/unit/utils/comparison/composite-comparer.spec.ts @@ -0,0 +1,66 @@ +import { CompositeComparer } from "@/utils/comparison/composite-comparer"; + +describe("CompositeComparer", () => { + describe("create", () => { + test("creates a new instance from the given comparer", () => { + const comparer = CompositeComparer.create((a: number, b: number) => a - b); + + expect(comparer).toBeInstanceOf(CompositeComparer); + }); + }); + + describe("compare", () => { + test("compares two numbers using the original comparer", () => { + const comparer = CompositeComparer.create((a: number, b: number) => a - b); + + expect(comparer.compare(5, 3)).toBeGreaterThan(0); + expect(comparer.compare(3, 5)).toBeLessThan(0); + expect(comparer.compare(5, 5)).toEqual(0); + }); + }); + + describe("__invoke__", () => { + test("can be used as a function", () => { + const comparer = CompositeComparer.create((a: number, b: number) => a - b); + + expect(comparer(5, 3)).toBeGreaterThan(0); + expect(comparer(3, 5)).toBeLessThan(0); + expect(comparer(5, 5)).toEqual(0); + }); + }); + + describe("thenBy", () => { + test("chains comparers in the right order", () => { + const firstCompare = (a: [number, string], b: [number, string]) => a[0] - b[0]; + const secondCompare = (a: [number, string], b: [number, string]) => a[1].localeCompare(b[1]); + + const comparer = CompositeComparer.create(firstCompare).thenBy(secondCompare); + + expect(comparer.compare([1, "b"], [2, "a"])).toBeLessThan(0); + expect(comparer.compare([2, "a"], [1, "b"])).toBeGreaterThan(0); + expect(comparer.compare([1, "a"], [1, "b"])).toBeLessThan(0); + expect(comparer.compare([1, "b"], [1, "a"])).toBeGreaterThan(0); + expect(comparer.compare([1, "a"], [1, "a"])).toEqual(0); + }); + }); + + describe("invert", () => { + test("inverts comparisons", () => { + const comparer = CompositeComparer.create((a: number, b:number) => a - b).invert(); + + expect(comparer.compare(5, 3)).toBeLessThan(0); + expect(comparer.compare(3, 5)).toBeGreaterThan(0); + expect(comparer.compare(5, 5)).toEqual(0); + }); + }); + + describe("asEqualityComparer", () => { + test("returns an equality comparer that returns true when the original comparer would return 0", () => { + const comparer = CompositeComparer.create((a: number, b: number) => a - b).asEqualityComparer(); + + expect(comparer(5, 5)).toEqual(true); + expect(comparer(3, 5)).toEqual(false); + expect(comparer(5, 3)).toEqual(false); + }); + }); +});