diff --git a/package.json b/package.json index 7600cac..a304047 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "build": "pnpm run --filter @heliosgraphics/fractures build", "test": "NODE_ENV=test vitest run", + "test:bench": "pnpm run --filter @heliosgraphics/benchmark bench", "test:u": "NODE_ENV=test vitest run --update", "test:watch": "NODE_ENV=test vitest", "test:coverage": "NODE_ENV=test vitest run --coverage", diff --git a/packages/benchmarks/__mocks__/objects.ts b/packages/benchmarks/__mocks__/objects.ts new file mode 100644 index 0000000..ff7f57e --- /dev/null +++ b/packages/benchmarks/__mocks__/objects.ts @@ -0,0 +1,94 @@ +type SimpleObject = { + a: number + b: number +} + +type NestedObject = { + a: number + b: string + c: { + d: number + e: Array + f: { + g: boolean + h: number + } + } +} + +type ArrayObject = { + numbers: Array + strings: Array + mixed: Array +} + +export const MOCK_SIMPLE_OBJECTS = { + first: { a: 1, b: 2 }, + second: { a: 1, b: 2 }, + third: { a: 1, b: 3 }, +} satisfies Record + +export const MOCK_NESTED_OBJECTS = { + first: { + a: 1, + b: "test", + c: { + d: 123, + e: ["a", "b", "c"], + f: { + g: true, + h: 456, + }, + }, + }, + second: { + a: 1, + b: "test", + c: { + d: 123, + e: ["a", "b", "c"], + f: { + g: true, + h: 457, + }, + }, + }, +} satisfies Record + +export const MOCK_ARRAY_OBJECTS = { + first: { + numbers: [1, 2, 3, 4, 5], + strings: ["a", "b", "c"], + mixed: [1, "two", 3, "four"], + }, + second: { + numbers: [1, 2, 3, 4, 5], + strings: ["a", "b", "c"], + mixed: [1, "two", 3, "four"], + }, +} satisfies Record + +export const MOCK_LARGE_OBJECTS = { + first: Array.from({ length: 1000 }, (_, i) => ({ + id: i, + value: Math.random(), + text: `Item ${i}`, + active: i % 2 === 0, + metadata: { + created: new Date().toISOString(), + modified: new Date().toISOString(), + tags: [`tag${i}`, `category${i % 10}`], + }, + })), + second: Array.from({ length: 1000 }, (_, i) => ({ + id: i, + value: Math.random(), + text: `Item ${i}`, + active: i % 2 === 0, + metadata: { + created: new Date().toISOString(), + modified: new Date().toISOString(), + tags: [`tag${i}`, `category${i % 10}`], + }, + })), +} diff --git a/packages/benchmarks/equals.ts b/packages/benchmarks/equals.ts new file mode 100644 index 0000000..e27059d --- /dev/null +++ b/packages/benchmarks/equals.ts @@ -0,0 +1,78 @@ +import benchmark from "benchmark" +import { deepEqual } from "../utils/equals" +import _ from "lodash" +import { MOCK_ARRAY_OBJECTS, MOCK_LARGE_OBJECTS, MOCK_NESTED_OBJECTS, MOCK_SIMPLE_OBJECTS } from "./__mocks__/objects" + +type BenchmarkResult = { + "Test Name": string + "Operations/sec": string + Margin: string + Runs: number +} + +const suite = new benchmark.Suite() + +const testNames: readonly string[] = [ + "Simple Objects - Identical (Fractures)", + "Simple Objects - Identical (Lodash)", + "Simple Objects - Different (Fractures)", + "Simple Objects - Different (Lodash)", + "Nested Objects (Fractures)", + "Nested Objects (Lodash)", + "Array Objects (Fractures)", + "Array Objects (Lodash)", + "Large Objects (Fractures)", + "Large Objects (Lodash)", +] + +const maxNameLength: number = Math.max(...testNames.map((name) => name.length)) +const _padName = (name: string): string => name.padEnd(maxNameLength, " ") +const allResults: BenchmarkResult[] = [] + +suite + .add("Simple Objects - Identical (Fractures)", () => { + deepEqual(MOCK_SIMPLE_OBJECTS.first, MOCK_SIMPLE_OBJECTS.second) + }) + .add("Simple Objects - Identical (Lodash)", () => { + _.isEqual(MOCK_SIMPLE_OBJECTS.first, MOCK_SIMPLE_OBJECTS.second) + }) + .add("Simple Objects - Different (Fractures)", () => { + deepEqual(MOCK_SIMPLE_OBJECTS.first, MOCK_SIMPLE_OBJECTS.third) + }) + .add("Simple Objects - Different (Lodash)", () => { + _.isEqual(MOCK_SIMPLE_OBJECTS.first, MOCK_SIMPLE_OBJECTS.third) + }) + .add("Nested Objects (Fractures)", () => { + deepEqual(MOCK_NESTED_OBJECTS.first, MOCK_NESTED_OBJECTS.second) + }) + .add("Nested Objects (Lodash)", () => { + _.isEqual(MOCK_NESTED_OBJECTS.first, MOCK_NESTED_OBJECTS.second) + }) + .add("Array Objects (Fractures)", () => { + deepEqual(MOCK_ARRAY_OBJECTS.first, MOCK_ARRAY_OBJECTS.second) + }) + .add("Array Objects (Lodash)", () => { + _.isEqual(MOCK_ARRAY_OBJECTS.first, MOCK_ARRAY_OBJECTS.second) + }) + .add("Large Objects (Fractures)", () => { + deepEqual(MOCK_LARGE_OBJECTS.first, MOCK_LARGE_OBJECTS.second) + }) + .add("Large Objects (Lodash)", () => { + _.isEqual(MOCK_LARGE_OBJECTS.first, MOCK_LARGE_OBJECTS.second) + }) + .on("cycle", function (event: { target: benchmark.Target }) { + const benchmark: benchmark.Target = event.target + + const result: BenchmarkResult = { + "Test Name": _padName(benchmark?.name || "a mistery function"), + "Operations/sec": Math.floor(benchmark?.hz ?? 0).toLocaleString(), + Margin: `±${benchmark?.stats?.rme.toFixed(2)}%`, + Runs: benchmark?.stats?.sample?.length ?? 0, + } + + allResults.push(result) + }) + .on("complete", function () { + console.table(allResults) + }) + .run({ async: true }) diff --git a/packages/benchmarks/package.json b/packages/benchmarks/package.json new file mode 100644 index 0000000..633e1b4 --- /dev/null +++ b/packages/benchmarks/package.json @@ -0,0 +1,22 @@ +{ + "name": "@heliosgraphics/benchmark", + "version": "0.0.0", + "type": "module", + "author": "03b8 <03b8@helios.graphics>", + "license": "MIT", + "private": false, + "description": "benchmark", + "scripts": { + "bench": "bun run *.ts" + }, + "engines": { + "node": ">=20.15.0" + }, + "devDependencies": { + "@types/uuid": "latest", + "@types/benchmark": "latest", + "@types/lodash": "latest", + "benchmark": "latest", + "lodash": "latest" + } +} diff --git a/packages/utils/equals.spec.ts b/packages/utils/equals.spec.ts new file mode 100644 index 0000000..b3e1a96 --- /dev/null +++ b/packages/utils/equals.spec.ts @@ -0,0 +1,123 @@ +import { deepEqual, areSetsEqual } from "./equals" +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" + +describe("deepEqual", () => { + it("primitives", () => { + expect(deepEqual(1, 1)).toBe(true) + expect(deepEqual("hello", "hello")).toBe(true) + expect(deepEqual(true, true)).toBe(true) + expect(deepEqual(null, null)).toBe(true) + expect(deepEqual(undefined, undefined)).toBe(true) + expect(deepEqual(1, 2)).toBe(false) + expect(deepEqual("hello", "world")).toBe(false) + expect(deepEqual(true, false)).toBe(false) + expect(deepEqual(null, undefined)).toBe(false) + }) + + it("arrays", () => { + expect(deepEqual([], [])).toBe(true) + expect(deepEqual([1, 2, 3], [1, 2, 3])).toBe(true) + expect(deepEqual([1, [2, 3]], [1, [2, 3]])).toBe(true) + expect(deepEqual([1, 2], [1, 2, 3])).toBe(false) + expect(deepEqual([1, 2, 3], [1, 3, 2])).toBe(false) + }) + + it("objects", () => { + expect(deepEqual({}, {})).toBe(true) + expect(deepEqual({ a: 1, b: 2 }, { a: 1, b: 2 })).toBe(true) + expect(deepEqual({ a: { b: 2 } }, { a: { b: 2 } })).toBe(true) + expect(deepEqual({ a: 1, b: 2 }, { b: 2, a: 1 })).toBe(true) + expect(deepEqual({ a: 1 }, { a: 1, b: 2 })).toBe(false) + expect(deepEqual({ a: 1 }, { a: 2 })).toBe(false) + }) + + it("dates", () => { + const date1 = new Date("2024-01-01") + const date2 = new Date("2024-01-01") + const date3 = new Date("2024-01-02") + + expect(deepEqual(date1, date2)).toBe(true) + expect(deepEqual(date1, date3)).toBe(false) + }) + + it("sets", () => { + const set1 = new Set([1, 2, 3]) + const set2 = new Set([1, 2, 3]) + const set3 = new Set([1, 2, 4]) + + expect(deepEqual(set1, set2)).toBe(true) + expect(deepEqual(set1, set3)).toBe(false) + }) + + it("maps", () => { + const map1 = new Map([ + ["a", 1], + ["b", 2], + ]) + const map2 = new Map([ + ["a", 1], + ["b", 2], + ]) + const map3 = new Map([ + ["a", 1], + ["b", 3], + ]) + + expect(deepEqual(map1, map2)).toBe(true) + expect(deepEqual(map1, map3)).toBe(false) + }) + + it("circular references", () => { + const obj1: any = { a: 1 } + const obj2: any = { a: 1 } + obj1.self = obj1 + obj2.self = obj2 + + expect(deepEqual(obj1, obj2)).toBe(true) + }) +}) + +describe("areSetsEqual", () => { + it("simple sets without deep comparison", () => { + const set1 = new Set([1, 2, 3]) + const set2 = new Set([1, 2, 3]) + const set3 = new Set([1, 2, 4]) + + expect(areSetsEqual(set1, set2)).toBe(true) + expect(areSetsEqual(set1, set3)).toBe(false) + }) + + it("sets with objects using deep comparison", () => { + const set1 = new Set([{ a: 1 }, { b: 2 }]) + const set2 = new Set([{ a: 1 }, { b: 2 }]) + const set3 = new Set([{ a: 1 }, { b: 3 }]) + + expect(areSetsEqual(set1, set2, true)).toBe(true) + expect(areSetsEqual(set1, set3, true)).toBe(false) + }) + + it("sets with different sizes", () => { + const set1 = new Set([1, 2, 3]) + const set2 = new Set([1, 2]) + + expect(areSetsEqual(set1, set2)).toBe(false) + expect(areSetsEqual(set1, set2, true)).toBe(false) + }) + + it("sets with nested structures", () => { + const set1 = new Set([{ a: { b: 2 } }, [1, 2, 3]]) + const set2 = new Set([{ a: { b: 2 } }, [1, 2, 3]]) + const set3 = new Set([{ a: { b: 3 } }, [1, 2, 3]]) + + expect(areSetsEqual(set1, set2, true)).toBe(true) + expect(areSetsEqual(set1, set3, true)).toBe(false) + }) + + it("empty sets", () => { + const set1 = new Set() + const set2 = new Set() + + expect(areSetsEqual(set1, set2)).toBe(true) + expect(areSetsEqual(set1, set2, true)).toBe(true) + }) +}) diff --git a/packages/utils/equals.ts b/packages/utils/equals.ts new file mode 100644 index 0000000..b61e869 --- /dev/null +++ b/packages/utils/equals.ts @@ -0,0 +1,79 @@ +export const deepEqual = (a: unknown, b: unknown, seen = new WeakMap()): boolean => { + if (a === b) return true + if (a == null || b == null) return false + if (typeof a !== "object" || typeof b !== "object") return false + + if (seen.has(a as object)) return seen.get(a as object) === b + + seen.set(a as object, b) + + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false + + return a.every((item, index) => deepEqual(item, b[index], seen)) + } + + if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime() + + if (a instanceof Set && b instanceof Set) { + if (a.size !== b.size) return false + + return areSetsEqual(a, b, true) + } + + if (a instanceof Map && b instanceof Map) { + if (a.size !== b.size) return false + for (const [key, value] of a.entries()) { + if (!b.has(key) || !deepEqual(value, b.get(key), seen)) { + return false + } + } + + return true + } + + const keysA = Object.keys(a as object) + const keysB = Object.keys(b as object) + + if (keysA.length !== keysB.length) return false + + return keysA.every( + (key) => Object.prototype.hasOwnProperty.call(b, key) && deepEqual((a as any)[key], (b as any)[key], seen), + ) +} + +export const areSetsEqual = (setA: Set, setB: Set, deep = false): boolean => { + if (setA.size !== setB.size) { + return false + } + + if (!deep) { + for (const elem of setA) { + if (!setB.has(elem)) { + return false + } + } + return true + } + + const arrayA = Array.from(setA) + const arrayB = Array.from(setB) + + const usedIndices = new Set() + + for (const elemA of arrayA) { + let found = false + for (let i = 0; i < arrayB.length; i++) { + if (usedIndices.has(i)) continue + + if (deepEqual(elemA, arrayB[i])) { + usedIndices.add(i) + found = true + break + } + } + if (!found) return false + } + + return true +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 12071a9..babb292 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,6 +45,24 @@ importers: specifier: 2.1.8 version: 2.1.8(@types/node@22.10.2)(jsdom@25.0.1) + packages/benchmarks: + devDependencies: + '@types/benchmark': + specifier: latest + version: 2.1.5 + '@types/lodash': + specifier: latest + version: 4.17.13 + '@types/uuid': + specifier: latest + version: 10.0.0 + benchmark: + specifier: latest + version: 2.1.4 + lodash: + specifier: latest + version: 4.17.21 + packages/fractures: devDependencies: csstype: @@ -566,18 +584,27 @@ packages: cpu: [x64] os: [win32] + '@types/benchmark@2.1.5': + resolution: {integrity: sha512-cKio2eFB3v7qmKcvIHLUMw/dIx/8bhWPuzpzRT4unCPRTD8VdA9Zb0afxpcxOqR4PixRS7yT42FqGS8BYL8g1w==} + '@types/estree@1.0.6': resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/lodash@4.17.13': + resolution: {integrity: sha512-lfx+dftrEZcdBPczf9d0Qv0x+j/rfNCMuC6OcfXmO8gkfeNAY88PgKUbvG56whcN23gc27yenwF6oJZXGFpYxg==} + '@types/node@22.10.2': resolution: {integrity: sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==} '@types/react@19.0.2': resolution: {integrity: sha512-USU8ZI/xyKJwFTpjSVIrSeHBVAGagkHQKPNbxeWwql/vDmnTIBgx+TJnhFnj1NXgz8XfprU0egV2dROLGpsBEg==} + '@types/uuid@10.0.0': + resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + '@types/uuid@9.0.7': resolution: {integrity: sha512-WUtIVRUZ9i5dYXefDEAI7sh9/O7jGvHg7Df/5O/gtH3Yabe5odI3UWopVR1qbPXQtvOxWu3mM4XxlYeZtMWF4g==} @@ -740,6 +767,9 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + benchmark@2.1.4: + resolution: {integrity: sha512-l9MlfN4M1K/H2fbhfMy3B7vJd6AGKJVQn2h6Sg/Yx+KckoUA7ewS5Vv6TjSq18ooE1kS9hhAlQRH3AkXIh/aOQ==} + brace-expansion@1.1.11: resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} @@ -1126,6 +1156,9 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + loupe@3.1.2: resolution: {integrity: sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==} @@ -1238,6 +1271,9 @@ packages: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} + platform@1.3.6: + resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==} + postcss@8.4.49: resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==} engines: {node: ^10 || ^12 || >=14} @@ -1906,10 +1942,14 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.27.4': optional: true + '@types/benchmark@2.1.5': {} + '@types/estree@1.0.6': {} '@types/json-schema@7.0.15': {} + '@types/lodash@4.17.13': {} + '@types/node@22.10.2': dependencies: undici-types: 6.20.0 @@ -1918,6 +1958,8 @@ snapshots: dependencies: csstype: 3.1.3 + '@types/uuid@10.0.0': {} + '@types/uuid@9.0.7': {} '@typescript-eslint/eslint-plugin@8.18.2(@typescript-eslint/parser@6.15.0(eslint@9.17.0)(typescript@5.7.2))(eslint@9.17.0)(typescript@5.7.2)': @@ -2121,6 +2163,11 @@ snapshots: balanced-match@1.0.2: {} + benchmark@2.1.4: + dependencies: + lodash: 4.17.21 + platform: 1.3.6 + brace-expansion@1.1.11: dependencies: balanced-match: 1.0.2 @@ -2569,6 +2616,8 @@ snapshots: lodash.merge@4.6.2: {} + lodash@4.17.21: {} + loupe@3.1.2: {} lru-cache@10.4.3: {} @@ -2666,6 +2715,8 @@ snapshots: picomatch@2.3.1: {} + platform@1.3.6: {} + postcss@8.4.49: dependencies: nanoid: 3.3.7