diff --git a/src/utils/collections/set.ts b/src/utils/collections/set.ts new file mode 100644 index 0000000..3b7e6f4 --- /dev/null +++ b/src/utils/collections/set.ts @@ -0,0 +1,230 @@ +import { EqualityComparer, createDefaultEqualityComparer } from "@/utils/comparison"; +import { $i } from "./iterable"; + +/** + * Checks if a given value is an instance of a {@link Set}-like object. + * + * @template T - The element type of the `Set`-like object. + * + * @param value - The value to be checked. + * + * @returns A boolean indicating whether the value is a `Set`-like object or not. + */ +export function isSet(value: unknown): value is Set { + if (value instanceof Set) { + return true; + } + + const set = value as Set; + return ( + !!set && + typeof set.values === "function" && + typeof set.add === "function" && + typeof set.delete === "function" && + typeof set.has === "function" && + typeof set[Symbol.iterator] === "function" + ); +} + +/** + * Checks if a given value is an instance of a {@link ReadOnlySet}-like object. + * + * @template T - The element type of the `ReadOnlySet`-like object. + * + * @param value - The value to be checked. + * + * @returns A boolean indicating whether the value is a `ReadOnlySet`-like object or not. + */ +export function isReadOnlySet(value: unknown): value is ReadonlySet { + if (value instanceof Set) { + return true; + } + + const set = value as ReadonlySet; + return ( + !!set && + typeof set.values === "function" && + typeof set.has === "function" && + typeof set[Symbol.iterator] === "function" + ); +} + +/** + * Implements {@link Set} using an array under the hood. + * + * @template T - The type of values in the Map. + * + * @remarks + * + * Recommended for small collections and/or for occasions when you need to provide a custom equality comparer. + */ +export class ArraySet implements Set { + /** + * The array of values. + */ + private readonly _values: T[]; + + /** + * The equality comparer used to compare values. + */ + private readonly _comparer: EqualityComparer; + + /** + * Constructs an empty {@link ArraySet}. + * + * @param comparer - The equality comparer to use for comparing values. + */ + constructor(comparer?: EqualityComparer); + + /** + * Constructs an {@link ArraySet} from an iterable of values. + * + * @param values - The iterable of values. + * @param comparer - The equality comparer to use for comparing values. + */ + constructor(values: Iterable, comparer?: EqualityComparer); + + /** + * Constructs an {@link ArraySet} from either an iterable of values or an equality comparer. + * + * @param valuesOrComparer - The iterable of values or the equality comparer to use for comparing values. + * @param comparer - The equality comparer to use for comparing values (if `valuesOrComparer` is an iterable). + */ + constructor(valuesOrComparer?: Iterable | EqualityComparer, comparer?: EqualityComparer) { + // If valuesOrComparer is a function, it must be the comparer, so use it. + // Otherwise, use the default comparer. + comparer ??= typeof valuesOrComparer === "function" ? valuesOrComparer : createDefaultEqualityComparer(); + + // If valuesOrComparer is undefined or is in fact a comparer, create an empty array of values. + const values = valuesOrComparer && valuesOrComparer !== comparer ? valuesOrComparer as Iterable : []; + + this._values = []; + this._comparer = comparer; + for (const value of values) { + this.add(value); + } + } + + /** + * Returns the number of elements in the set. + */ + get size(): number { + return this._values.length; + } + + /** + * Adds a value to the set. + * + * @param value - The value to add to the set. + * + * @returns The set object, for chaining purposes. + */ + add(value: T): this { + const i = $i(this._values).indexOf(value, this._comparer); + if (i === -1) { + this._values.push(value); + } else { + this._values[i] = value; + } + return this; + } + + /** + * Returns a boolean indicating whether a value exists in the set or not. + * + * @param value - The value to search for in the set. + * + * @returns `true` if the given value exists in the set; otherwise, `false`. + */ + has(value: T): boolean { + return $i(this._values).includes(value, this._comparer); + } + + /** + * Removes the value from the set. + * + * @param value - The value to remove from the set. + * + * @returns `true` if the value was found and removed; otherwise, `false`. + */ + delete(value: T): boolean { + const i = $i(this._values).indexOf(value, this._comparer); + if (i === -1) { + return false; + } + + this._values.splice(i, 1); + return true; + } + + /** + * Removes all values from the set. + */ + clear(): void { + this._values.splice(0); + } + + /** + * Funnily enough, returns an iterator over the values in the set. + * + * @remarks + * + * This method exists because somebody thought that we need to keep + * `Set`'s and `Map`'s APIs similar for some reason. + */ + keys(): IterableIterator { + return this._values[Symbol.iterator](); + } + + /** + * Returns an iterator over the values in the set. + */ + values(): IterableIterator { + return this._values[Symbol.iterator](); + } + + /** + * Returns a new {@link Iterator} object that contains an array of `[value, value]` + * for each element in the {@link ArraySet} object, in insertion order. + * + * @remarks + * + * This method exists because somebody thought that we need to keep + * `Set`'s and `Map`'s APIs similar for some reason. + */ + *entries(): IterableIterator<[T, T]> { + const values = this._values; + for (let i = 0; i < values.length; ++i) { + yield [values[i], values[i]]; + } + } + + /** + * Executes a provided function once per each value in the set. + * + * @param callbackFn - Function to execute for each value in the set. + * @param thisArg - Object to use as `this` when executing `callbackFn`. + */ + forEach(callbackFn: (value: T, theSameValueAgain: T, set: ArraySet) => void, thisArg?: any): void { + callbackFn = thisArg === undefined ? callbackFn : callbackFn.bind(thisArg); + const values = this._values; + + for (let i = 0; i < values.length; ++i) { + callbackFn(values[i], values[i], this); + } + } + + /** + * Returns an iterator over the values in the set. + */ + [Symbol.iterator](): IterableIterator { + return this._values[Symbol.iterator](); + } + + /** + * Returns a string representation of this object. + */ + get [Symbol.toStringTag](): string { + return "Set"; + } +} diff --git a/tests/unit/utils/collections/set.spec.ts b/tests/unit/utils/collections/set.spec.ts new file mode 100644 index 0000000..a9058fe --- /dev/null +++ b/tests/unit/utils/collections/set.spec.ts @@ -0,0 +1,252 @@ +import { IGNORE_CASE_EQUALITY_COMPARER } from "@/utils/comparison/string-equality-comparer"; +import { isSet, isReadOnlySet, ArraySet } from "@/utils/collections/set"; + +const readOnlySetLike = { + keys: () => {}, + values: () => {}, + entries: () => {}, + has: () => {}, + [Symbol.iterator]: () => {} +}; + +const setLike = { + ...readOnlySetLike, + add: () => {}, + delete: () => {}, +}; + +describe("isSet", () => { + test("returns true for Set instances", () => { + expect(isSet(new Set())).toBe(true); + }); + + test("returns true for Set-like objects", () => { + expect(isSet(setLike)).toBe(true); + }); + + test("returns false for non-Set-like objects", () => { + expect(isSet({})).toBe(false); + }); + + test("returns false for null and undefined", () => { + expect(isSet(null)).toBe(false); + expect(isSet(undefined)).toBe(false); + }); +}); + +describe("isReadOnlySet", () => { + test("returns true for Set instances", () => { + expect(isReadOnlySet(new Set())).toBe(true); + }); + + test("returns true for ReadOnlySet-like objects", () => { + expect(isReadOnlySet(readOnlySetLike)).toBe(true); + }); + + test("returns false for non-ReadOnlySet-like objects", () => { + expect(isReadOnlySet({})).toBe(false); + }); + + test("returns false for null and undefined", () => { + expect(isReadOnlySet(null)).toBe(false); + expect(isReadOnlySet(undefined)).toBe(false); + }); +}); + +describe("ArraySet", () => { + describe("constructor", () => { + test("creates an empty set when no parameters are provided", () => { + const set = new ArraySet(); + + expect(set.size).toBe(0); + }); + + test("creates a set from an iterable of values", () => { + const set = new ArraySet(["one", "two"]); + + expect(set.size).toBe(2); + expect(Array.from(set)).toEqual(["one", "two"]) + }); + + test("creates a set from an iterable of entries and a custom comparer", () => { + const set = new ArraySet(["one", "two"], IGNORE_CASE_EQUALITY_COMPARER); + + expect(set.size).toBe(2); + expect(set.has("ONE")).toBe(true); + expect(set.has("TWO")).toBe(true); + }); + + test("creates a set from an iterable of entries, and eliminates duplicates", () => { + const set = new ArraySet([1, 2, 1]); + + expect(set.size).toBe(2); + expect(set.has(1)).toBe(true); + expect(set.has(2)).toBe(true); + }); + + test("creates a set from an iterable of entries and a custom comparer, and eliminates duplicates", () => { + const set = new ArraySet(["one", "two", "ONE"], IGNORE_CASE_EQUALITY_COMPARER); + + expect(set.size).toBe(2); + expect(set.has("ONE")).toBe(true); + expect(set.has("TWO")).toBe(true); + expect(Array.from(set)).toEqual(["ONE", "two"]); + }); + }); + + describe("add", () => { + test("adds a new key-value pair if the key is not present", () => { + const set = new ArraySet(); + + set.add(1); + expect(set.has(1)).toBe(true); + expect(set.size).toBe(1); + }); + + test("respects custom comparer when setting value by key", () => { + const set = new ArraySet(["one"], IGNORE_CASE_EQUALITY_COMPARER); + + set.add("Two"); + expect(set.has("two")).toBe(true); + + set.add("TWO"); + expect(set.has("two")).toBe(true); + + expect(set.size).toBe(2); + expect(Array.from(set)).toEqual(["one", "TWO"]); + }); + + test("updates the value if the key is already present", () => { + const set = new ArraySet(["one"]); + + set.add("one"); + expect(set.has("one")).toBe(true); + expect(set.size).toBe(1); + expect(Array.from(set)).toEqual(["one"]); + }); + }); + + describe("has", () => { + test("returns true if the key is present", () => { + const set = new ArraySet([1]); + + expect(set.has(1)).toBe(true); + }); + + test("respects custom comparer when checking for key presence", () => { + const set = new ArraySet(["one"], IGNORE_CASE_EQUALITY_COMPARER); + + expect(set.has("one")).toBe(true); + expect(set.has("One")).toBe(true); + expect(set.has("ONE")).toBe(true); + }); + + test("returns false if the key is not present", () => { + const set = new ArraySet(); + + expect(set.has(1)).toBe(false); + }); + }); + + describe("delete", () => { + test("removes the entry with the specified key", () => { + const set = new ArraySet([1, 2]); + + expect(set.delete(1)).toBe(true); + expect(set.has(1)).toBe(false); + expect(set.size).toBe(1); + }); + + test("respects custom comparer when deleting by key", () => { + const set = new ArraySet(["one"], IGNORE_CASE_EQUALITY_COMPARER); + + expect(set.delete("One")).toBe(true); + expect(set.has("one")).toBe(false); + expect(set.delete("ONE")).toBe(false); + }); + + test("returns false if the key is not present", () => { + const set = new ArraySet(); + + expect(set.delete(1)).toBe(false); + }); + }); + + describe("clear", () => { + test("removes all entries", () => { + const set = new ArraySet([1, 2]); + set.clear(); + + expect(set.size).toBe(0); + expect(set.has(1)).toBe(false); + }); + }); + + describe("keys", () => { + test("returns an iterator over the values (ironically)", () => { + const set = new ArraySet([1, 2]); + const keys = Array.from(set.keys()); + + expect(keys).toEqual([1, 2]); + }); + }); + + describe("values", () => { + test("returns an iterator over the values", () => { + const set = new ArraySet([1, 2]); + + const values = Array.from(set.values()); + + expect(values).toEqual([1, 2]); + }); + }); + + describe("entries", () => { + test("returns an iterator over the value-value pairs", () => { + const set = new ArraySet([1, 2]); + const entries = Array.from(set.entries()); + + expect(entries).toEqual([[1, 1], [2, 2]]); + }); + }); + + describe("forEach", () => { + test("calls the specified callback function for each value", () => { + const set = new ArraySet(["one", "two"]); + const callback = jest.fn(); + + set.forEach(callback); + + expect(callback).toHaveBeenCalledTimes(2); + expect(callback).toHaveBeenNthCalledWith(1, "one", "one", set); + expect(callback).toHaveBeenNthCalledWith(2, "two", "two", set); + }); + + test("binds the callback function to the provided thisArg", () => { + const set = new ArraySet([1, 2]); + const thisArg = {}; + + set.forEach(function (this: typeof thisArg) { + expect(this).toBe(thisArg); + }, thisArg); + }); + }); + + describe("[Symbol.iterator]", () => { + test("returns an iterator over the values", () => { + const set = new ArraySet(["one", "two"]); + + const values = Array.from(set[Symbol.iterator]()); + + expect(values).toEqual(["one", "two"]); + }); + }); + + describe("[Symbol.toStringTag]", () => { + test("returns 'Set'", () => { + const set = new ArraySet(); + + expect(set[Symbol.toStringTag]).toBe("Set"); + }); + }); +});