Skip to content
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

Deep utils (deepMerge, deepEquals, deepHash) #11

Merged
merged 11 commits into from
Jan 28, 2024
Binary file modified bun.lockb
Binary file not shown.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"@types/react": "18.0.0",
"react": "18.0.0",
"bun-types": "1.0.21",
"typescript": "^5.3.3"
"typescript": "^5.3.3",
"fast-check": "^3.15.0"
mausworks marked this conversation as resolved.
Show resolved Hide resolved
}
}
4 changes: 2 additions & 2 deletions src/plugin-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export type SyncOptions<T extends AnyState, F extends SyncFunction<T>> = {
*
* Uses `JSON.stringify` by default.
*/
hash?: (state: T, ...extra: ExtraArgs<F>) => string;
hash?: (state: T, ...extra: ExtraArgs<F>) => string | number;
};

/** Configure how to pull a new state into the store. */
Expand All @@ -67,7 +67,7 @@ export type PullOptions<P extends PullFunction<any>> = {
*
* Uses `JSON.stringify` by default.
*/
hash?: (...args: Parameters<P>) => string;
hash?: (...args: Parameters<P>) => string | number;
};

/** Extracts a record of the user-defined sync functions from a setup object. */
Expand Down
19 changes: 11 additions & 8 deletions src/util-cache.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
/** A function that returns a value. */
export type ValueFactory<T> = () => T;

/** A key to store a value under in the cache. */
export type CacheKey = string | number;

/** A function that returns a value from the cache. */
export type CacheGetter<T> = {
/** Gets the value from the cache, or `null` if it is not found. */
(key: string): T | null;
(key: CacheKey): T | null;
/**
* Gets the value from the cache, or creates (and stores) it if it is not found.
* @param key The key of the value to get.
* @param create A factory function that returns a value.
* @param lifetime (Optionally) How long to keep the value in the cache, in milliseconds.
*/
(key: string, create: ValueFactory<T>, lifetime?: number): T;
(key: CacheKey, create: ValueFactory<T>, lifetime?: number): T;
};

/** A cache of values that can be either transient or permanent. */
Expand All @@ -28,15 +31,15 @@ export type Cache<T> = {
* @param input A value, or a tuple of a value and a lifetime in milliseconds.
* @param lifetime (Optionally) How long to keep the value in the cache, in milliseconds.
*/
set: (key: string, input: T, lifetime?: number) => T;
set: (key: CacheKey, input: T, lifetime?: number) => T;
/**
* Removes a value from the cache, optionally after a delay.
* @param key The key of the value to evict.
* @param delay (Optionally) How long to wait before evicting the value, in milliseconds.
* If set to zero or less, evicts the value immediately (default)
* If set to `Infinity`, makes the function no-op.
*/
evict: (key: string, delay?: number) => void;
evict: (key: CacheKey, delay?: number) => void;
};

/** Configure how to create a cache. */
Expand Down Expand Up @@ -64,10 +67,10 @@ export type CacheOptions = {
* ```
*/
export default function createCache<T>(options: CacheOptions = {}): Cache<T> {
const entries = new Map<string, T>();
const entries = new Map<CacheKey, T>();
const defaultLifetime = options.lifetime ?? Infinity;

const set = (key: string, value: T, lifetime = defaultLifetime) => {
const set = (key: CacheKey, value: T, lifetime = defaultLifetime) => {
if (lifetime > 0) {
entries.set(key, value);
evict(key, lifetime);
Expand All @@ -76,15 +79,15 @@ export default function createCache<T>(options: CacheOptions = {}): Cache<T> {
return value;
};

const get = ((key: string, create?: ValueFactory<T>, lifetime?: number) => {
const get = ((key: CacheKey, create?: ValueFactory<T>, lifetime?: number) => {
const entry = entries.get(key);

if (entry) return entry;
else if (!create) return null;
else return set(key, create(), lifetime);
}) as CacheGetter<T>;

const evict = (key: string, delay?: number) => {
const evict = (key: CacheKey, delay?: number) => {
if (delay === Infinity) return;
if (!delay || delay < 0) entries.delete(key);
else setTimeout(() => entries.delete(key), delay);
Expand Down
4 changes: 2 additions & 2 deletions src/util-dedupe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export type DedupeOptions<T extends AsyncFunction> = {
*
* Uses `JSON.stringify` by default.
*/
hash?: (...args: Parameters<T>) => string;
hash?: (...args: Parameters<T>) => string | number;
};

/**
Expand All @@ -44,7 +44,7 @@ export default function dedupe<T extends AsyncFunction<any>>(
fn: T,
options: DedupeOptions<T> = {}
): T {
let oldHash: string | undefined;
let oldHash: string | number | undefined;
let promise: Promise<any> | undefined;
let handle: any;

Expand Down
80 changes: 80 additions & 0 deletions src/util-deep-equals.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { test, expect, describe } from "bun:test";
import deepEquals from "./util-deep-equals";

describe("value equality", () => {
test("null", () => expect(deepEquals(null, null)).toBe(true));
test("undefined", () => expect(deepEquals(undefined, undefined)).toBe(true));
test("string", () => expect(deepEquals("a", "a")).toBe(true));
test("number", () => expect(deepEquals(1, 1)).toBe(true));
test("boolean", () => expect(deepEquals(true, true)).toBe(true));
test("empty array", () => expect(deepEquals([], [])).toBe(true));
test("empty object", () => expect(deepEquals({}, {})).toBe(true));
});

describe("value inequality", () => {
test("null vs undefined", () =>
expect(deepEquals(null, undefined)).toBe(false));
test("null vs string", () => expect(deepEquals(null, "a")).toBe(false));
test("null vs number", () => expect(deepEquals(null, 1)).toBe(false));
test("null vs boolean", () => expect(deepEquals(null, true)).toBe(false));
test("null vs array", () => expect(deepEquals(null, [])).toBe(false));
test("null vs object", () => expect(deepEquals(null, {})).toBe(false));
test("undefined vs string", () =>
expect(deepEquals(undefined, "a")).toBe(false));
test("undefined vs number", () =>
expect(deepEquals(undefined, 1)).toBe(false));
test("undefined vs boolean", () =>
expect(deepEquals(undefined, true)).toBe(false));
test("undefined vs array", () =>
expect(deepEquals(undefined, [])).toBe(false));
test("undefined vs object", () =>
expect(deepEquals(undefined, {})).toBe(false));
test("string vs number", () => expect(deepEquals("a", 1)).toBe(false));
test("string vs boolean", () => expect(deepEquals("a", true)).toBe(false));
test("string vs array", () => expect(deepEquals("a", [])).toBe(false));
test("string vs object", () => expect(deepEquals("a", {})).toBe(false));
test("number vs boolean", () => expect(deepEquals(1, true)).toBe(false));
test("number vs array", () => expect(deepEquals(1, [])).toBe(false));
test("number vs object", () => expect(deepEquals(1, {})).toBe(false));
test("boolean vs array", () => expect(deepEquals(true, [])).toBe(false));
test("boolean vs object", () => expect(deepEquals(true, {})).toBe(false));
test("array vs object", () => expect(deepEquals([], {})).toBe(false));
});

describe("array equality", () => {
test("single element", () => expect(deepEquals([1], [1])).toBe(true));
test("multiple elements", () =>
expect(deepEquals([1, 2, 3], [1, 2, 3])).toBe(true));
test("nested arrays", () =>
expect(deepEquals([1, [2, 3], 4], [1, [2, 3], 4])).toBe(true));
});

describe("array inequality", () => {
test("different lengths", () =>
expect(deepEquals([1, 2], [1, 2, 3])).toBe(false));
test("different elements", () =>
expect(deepEquals([1, 2, 3], [1, 2, 4])).toBe(false));
test("different nested arrays", () =>
expect(deepEquals([1, [2, 3], 4], [1, [2, 4], 4])).toBe(false));
});

describe("object equality", () => {
test("single key", () => expect(deepEquals({ a: 1 }, { a: 1 })).toBe(true));
test("multiple keys", () =>
expect(deepEquals({ a: 1, b: 2 }, { a: 1, b: 2 })).toBe(true));
test("nested objects", () =>
expect(deepEquals({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 2 } })).toBe(
true
));
});

describe("object inequality", () => {
test("different keys", () =>
expect(deepEquals({ a: 1 }, { b: 1 })).toBe(false));
test("different values", () =>
expect(deepEquals({ a: 1 }, { a: 2 })).toBe(false));
test("different nested objects", () =>
expect(deepEquals({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 3 } })).toBe(
false
));
});
60 changes: 60 additions & 0 deletions src/util-deep-equals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import isObject from "./util-is-object";

/** An object that is either a plain array or plain object. */
type Indexed = { [key: string | number]: any };

/**
* Checks if the given values are equal.
*
* Note: Only plain objects and arrays are compared recursively.
* Classes and other objects are compared with `Object.is`.
* @param left The left value to compare.
* @param right The right value to compare.
* @example
* ```ts
* // Both true:
* deepEquals(
* { a: 1 },
* { a: 1 }
* )
* deepEquals(
* { a: 1, b: { c: 2 } },
* { a: 1, b: { c: 2 } }
* )
*
* // Both false:
* deepEquals(
* { a: 1 },
* { a: 2, b: 2 }
* )
* deepEquals(
* { a: 1, b: { c: [] } },
* { a: 1, b: { c: [] } }
* )
* ```
*/
const deepEquals = <T>(left: T, right: unknown): right is T => {
if (Object.is(left, right)) return true;
if (!isIndexed(left) || !isIndexed(right)) return false;
if (!isSamePrototype(left, right)) return false;

const leftKeys = Object.keys(left);
const rightKeys = Object.keys(right);

if (leftKeys.length !== rightKeys.length) return false;

for (const key of leftKeys) {
if (!(key in right)) return false;
if (!deepEquals(left[key], right[key])) return false;
}

return true;
};

const isIndexed = (value: unknown): value is Indexed =>
Array.isArray(value) || isObject(value);

const isSamePrototype = <T>(left: T, right: unknown): right is T =>
Object.getPrototypeOf(left) === Object.getPrototypeOf(right);

export default deepEquals;
137 changes: 137 additions & 0 deletions src/util-deep-hash.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { expect, test } from "bun:test";
import fc from "fast-check";
import deepHash from "./util-deep-hash";
import { deepEquals } from "bun";

const VERBOSE = false;
const COLLISION_TEST = true;
const ITERATIONS = process.env.CI ? 10_000 : 100_000;

test("clones have the same hash", () => {
fc.assert(
fc.property(
fc.anything(),
(value) => deepHash(value) === deepHash(structuredClone(value))
)
);
});

test("true !== false", () => {
expect(deepHash(true)).not.toBe(deepHash(false));
});

test("null !== undefined", () => {
expect(deepHash(null)).not.toBe(deepHash(undefined));
});

test("increment changes", () => {
for (let i = -10000000; i < 10000000; i += 2) {
expect(deepHash(i)).not.toBe(deepHash(i + 1));
}
});

test.if(!process.env.CI)("16-bit range", () => {
const map = new Map<number, number>();

for (let i = -32768; i < -32767; i++) {
const hash = deepHash(i);
expect(map.has(hash)).toBe(false);
map.set(hash, i);
}
});

const fuzzTest = (arb: fc.Arbitrary<any>, count = 1) => {
return (name: string, runs: number) => {
test.skipIf(!COLLISION_TEST)(`hash ${name} (x${runs})`, () => {
let collisions = 0;
let identical = 0;

fc.assert(
fc.property(
fc.array(arb, { minLength: count, maxLength: count }),
fc.array(arb, { minLength: count, maxLength: count }),
(left, right) => {
if (deepEquals(left, right)) {
identical++;
if (VERBOSE) {
console.log("Identical:", left, "=", right);
}
return;
}

if (VERBOSE) {
console.log("Test:", left, "!==", right);
}

const leftHash = deepHash(...left);
const rightHash = deepHash(...right);

if (leftHash === rightHash) {
console.error("FAIL:", left, "===", right);
collisions++;
}
}
),
{
numRuns: runs,
seed: Date.now(),
ignoreEqualValues: true,
}
);

console.log(
`Collisions: ${collisions}/${runs - identical}. ` +
`(${identical} identical)`
);
expect(collisions).toBe(0);
});
};
};

fuzzTest(fc.integer({ min: -25_000_000, max: 25_000_000 }))(
"reasonable integers",
ITERATIONS
);

fuzzTest(fc.double({ min: -25_000_000, max: 25_000_000 }))(
"reasonable doubles",
ITERATIONS
);

fuzzTest(fc.string({ minLength: 1, maxLength: 32 }), 10)(
"short strings",
ITERATIONS
);

fuzzTest(fc.string({ minLength: 32, maxLength: 256 }), 10)(
"long strings",
ITERATIONS
);

fuzzTest(fc.unicodeString({ minLength: 1, maxLength: 32 }), 10)(
"short unicode strings",
ITERATIONS
);

fuzzTest(fc.unicodeString({ minLength: 32, maxLength: 256 }), 10)(
"long unicode strings",
ITERATIONS
);

fuzzTest(
fc.record({
id: fc.uuid(),
email: fc.emailAddress({ size: "small" }),
username: fc.option(fc.asciiString({ minLength: 1, maxLength: 12 })),
})
)("user states", ITERATIONS);

fuzzTest(
fc.array(
fc.record({
id: fc.uuid(),
text: fc.string({ minLength: 1, maxLength: 32 }),
completed: fc.boolean(),
})
)
)("todo lists", ITERATIONS);
Loading
Loading