Skip to content

Commit

Permalink
Deep utils (deepMerge, deepEquals, deepHash) (#11)
Browse files Browse the repository at this point in the history
  • Loading branch information
mausworks authored Jan 28, 2024
1 parent ce5e9e6 commit 96fef36
Show file tree
Hide file tree
Showing 13 changed files with 516 additions and 13 deletions.
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"
}
}
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

0 comments on commit 96fef36

Please sign in to comment.