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

Object and other helpers (follow) #28

Closed
wants to merge 15 commits into from
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ node_modules
coverage
dist
*.dot
*.png
src/*.dot
src/*.png
README.html
coverage
.DS_Store
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion src/cell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,9 @@ export class Cell<
get name(): string {
return this._sheet.name(this.id);
}
get fullName(): string {
return this._sheet.name(this.id, true);
}

/**
* get the immediate cell value. or pointed value
Expand Down Expand Up @@ -522,7 +525,7 @@ export class Cell<
// Invalidation for outdated computation
if (computationRank < this._valueRank) {
DEV &&
console.log(
console.warn(
`Cell ${this.name}: `,
`setting to ${newValue} has been invalidated`,
{
Expand Down
33 changes: 32 additions & 1 deletion src/cellify.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { expect, test } from "vitest";

import { Cellified, Uncellified, _cellify, _uncellify } from "./cellify";
import {
Cellified,
Uncellified,
_cellify,
_uncellify,
follow
} from "./cellify";
import { SheetProxy } from "./proxy";
import { Sheet } from "./sheet";

Expand Down Expand Up @@ -45,3 +51,28 @@ test("_cellify one", async () => {
const cell = await res.get();
await expect(cell.a.get()).resolves.toBe(1);
});

test("follow", async () => {
const sheet = new Sheet();
const proxy = new SheetProxy(sheet);
const v = { a: [1, 2, 3], b: { c: { foo: 1, bar: 1 } } };
const cv = _cellify(proxy, v);
const f = follow(proxy, cv, ["a", 1]);
await expect(f.get()).resolves.toBe(2);
expect(sheet.stats).toEqual({ size: 12, count: 12 });

// update the cell directly
(await (await cv.get()).a.get())[1].set(4);
await expect(f.get()).resolves.toBe(4);
expect(sheet.stats).toEqual({ size: 12, count: 12 }); // unchanged

// prepend a new cell in array
(await cv.get()).a.update((l) => [proxy.new(0), ...l]);
await expect(f.get()).resolves.toBe(1);
expect(sheet.stats).toEqual({ size: 13, count: 14 }); // one new cell, update one pointer

// delete path, cell is error
(await cv.get()).a.set([]);
await expect(f.get()).resolves.toBeInstanceOf(Error);
expect(sheet.stats).toEqual({ size: 13, count: 14 }); // unchanged
});
50 changes: 41 additions & 9 deletions src/cellify.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { AnyCell, Cell, ValueCell } from "./cell";
import { AnyCell, Cell, MapCell, ValueCell } from "./cell";
import { collector } from "./gc";
import { SheetProxy } from "./proxy";

// Cellified computes a cellified type.
Expand All @@ -21,6 +22,13 @@ export type Uncellified<T> = T extends AnyCell<infer U>
: U
: T;

// @todo is type only if true
// exclude classes
export const isObject = <K extends string | number | symbol>(
v: unknown
): v is Record<K, unknown> =>
typeof v === "object" && v !== null && v.constructor?.name === "Object";

/**
* cellify converts any value to a Cellified value where each array or record
* becomes a Cell in canonical form.
Expand All @@ -34,9 +42,7 @@ export const _cellify = <T>(proxy: SheetProxy, v: T): Cellified<T> => {
return proxy.new(
Array.isArray(v)
? v.map((vv) => _cellify(proxy, vv), "cellify.[]")
: typeof v === "object" &&
v !== null &&
v.constructor.prototype === Object.prototype // exclude classes
: isObject(v)
? Object.fromEntries(
Object.entries(v).map(
([k, vv]) => [k, _cellify(proxy, vv)],
Expand All @@ -62,11 +68,7 @@ export const _uncellify = async <T>(
return Promise.all(
value.map(async (_element) => await _uncellify(_element))
) as Promise<Uncellified<T>>;
if (
typeof value === "object" &&
value !== null &&
value.constructor.prototype === Object.prototype // exclude classes
)
if (isObject(value))
return Object.fromEntries(
await Promise.all(
Object.entries(value).map(async ([k, vv]) => [k, await _uncellify(vv)])
Expand All @@ -75,3 +77,33 @@ export const _uncellify = async <T>(
// Classes, null or base types (string, number, ...)
return value as Uncellified<T>;
};

export type Path = (string | number)[];

/**
* follow a static path for a Cellified value.
*/
export const follow = (
proxy: SheetProxy,
v: Cellified<unknown>,
path: Path,
name = "follow"
) => {
const aux = (v: Cellified<unknown>, path: Path, name: string) => {
// @todo multi collector?
const coll = collector<MapCell<unknown, false>>(proxy);
return proxy.map(
[v],
(_v) => {
const key = path[0];
const isContainer = Array.isArray(_v) || isObject(_v);
if (isContainer && _v[key])
return coll(aux(_v[key], path.slice(1), `${name}.${key}`));
if (isContainer) throw new Error(`path not found: ${key}`);
return _v; // pointer
},
name
);
};
return aux(v, path, name);
};
10 changes: 10 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,16 @@ export {
reduce,
sort
} from "./array";
export {
_cellify,
_uncellify,
follow,
isObject,
type Cellified,
type Path,
type Uncellified
} from "./cellify";
export { Debugger } from "./debug";
export { jsonStringify } from "./json";
export { nextSubscriber } from "./next";
export { asyncReduce, mapObject, reduceObject } from "./object";
91 changes: 91 additions & 0 deletions src/object.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { writeFileSync } from "fs";
import { expect, test } from "vitest";

import { _cellify, _uncellify } from "./cellify";
import { Debugger } from "./debug";
import { isEqual } from "./isEqual.test";
import { asyncReduce, mapObject, reduceObject } from "./object";
import { delayed } from "./promise";
import { SheetProxy } from "./proxy";
import { Sheet } from "./sheet";

test("mapObject", async () => {
const sheet = new Sheet();
const proxy = new SheetProxy(sheet);

const obj = _cellify(proxy, { a: 1, b: "foo", c: "bar" });
expect(sheet.stats).toEqual({ count: 4, size: 4 });
const m = mapObject(proxy, obj, (_k: string, _v: unknown): number =>
typeof _v === "string" ? _v.length : (_v as number)
);

// initial value
await expect(_uncellify(m)).resolves.toEqual({ a: 1, b: 3, c: 3 });
expect(sheet.stats).toEqual({ count: 8, size: 8 });

// update a field
(await (await obj.get()).a).set(4);
await expect(_uncellify(m)).resolves.toEqual({ a: 4, b: 3, c: 3 });
expect(sheet.stats).toEqual({ count: 8, size: 8 });

// add a field
obj.update((rec) => ({ ...rec, h: proxy.new("hello") }));
await expect(_uncellify(obj)).resolves.toEqual({
a: 4,
b: "foo",
c: "bar",
h: "hello"
});
console.log(await _uncellify(m));
await expect(_uncellify(m)).resolves.toEqual({ a: 4, b: 3, c: 3, h: 5 });
expect(sheet.stats).toEqual({ count: 10, size: 10 });

// delete a field
obj.update((rec) => {
const copy = { ...rec };
// biome-ignore lint/performance/noDelete: we don't want an undefined field
delete copy.a;
return copy;
});
await expect(_uncellify(m)).resolves.toEqual({ b: 3, c: 3, h: 5 });
expect(sheet.stats).toEqual({ count: 10, size: 9 }); // gc works
});

test("asyncReduce", async () => {
await expect(
asyncReduce([1, 2, 3], (acc, v) => delayed(acc + v, 10), 0)
).resolves.toBe(6);
});

test(
"reduceObject",
async () => {
const sheet = new Sheet();
const debug = new Debugger(sheet);
const proxy = new SheetProxy(sheet);

const l = _cellify(proxy, { a: 1, b: 2, c: 3 });
const v = reduceObject(
proxy,
l,
async (acc, _key, v) => delayed(acc + (v as number), 1),
0
);
writeFileSync("reduceObject1.dot", debug.dot("reduceObject before"));
await expect(v.consolidatedValue).resolves.toBe(6);
expect(sheet.stats).toEqual({ count: 6, size: 6 }); // 3+1 array +1 sum +1 pointer

// update one cell
await (await l.get()).a.set(4);
await expect(v.consolidatedValue).resolves.toBe(9);
writeFileSync("reduceObject2.dot", debug.dot("reduceObject after update"));
expect(sheet.stats).toEqual({ count: 6, size: 6 }); // unchanged

// add one cell
l.update((obj) => ({ ...obj, d: proxy.new(5, "n:5") }));
await expect(v.consolidatedValue).resolves.toBe(14);
writeFileSync("reduceObject3.dot", debug.dot("reduceObject after add"));
expect(sheet.stats).toEqual({ count: 8, size: 7 }); // +1 cell, update pointer
},
{ timeout: 1000 }
);
106 changes: 106 additions & 0 deletions src/object.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import type { AnyCell, MapCell } from "./cell";
import { collector } from "./gc";
import type { SheetProxy } from "./proxy";

// @todo introduce a type variable that is a sum type of all possible field types?
export type CellObject<T> = AnyCell<Record<string, AnyCell<T>>>;

/**
* mapObject applies a function to a CellObject.
*/
export const mapObject = <NF extends boolean = false>(
proxy: SheetProxy,
obj: CellObject<unknown>,
// @todo return type
fn: (
key: string,
value: unknown,
valueCell: AnyCell<unknown>
) => unknown | Promise<unknown>,
name = "mapObject",
nf?: NF
): MapCell<Record<string, AnyCell<unknown>>, NF> =>
proxy.map(
[obj],
(cells, prev) => {
const set = new Set(Object.keys(prev || {}));
const res = Object.fromEntries(
Object.entries(cells).map(([k, v]) => {
// we reuse a previous cell if the key is the same and still maps to same v
const reuse =
(prev?.[k] && prev[k].dependencies?.[0] === v.id) || false;
if (reuse) set.delete(k);
// console.log({ k, reuse, prev: prev?.[k]?.id });
return [
k,
reuse ? prev[k] : proxy.map([v], (_v) => fn(k, _v, v), `[${k}]µ`)
];
})
);
// collect unused previously mapped cells
proxy._sheet.collect(...[...set].map((k) => prev[k]));
return res;
},
name,
nf
);

export const asyncReduce = async <T, U>(
array: T[],
reducer: (
accumulator: U,
currentValue: T,
currentIndex: number,
array: T[]
) => U | Promise<U>,
initialValue: U
): Promise<U> => {
let acc: U = initialValue;
for (let index = 0; index < array.length; index++) {
acc = await reducer(acc, array[index], index, array);
}
return acc;
};

/**
* reduceObject applies the reducer function `fn` for each
* element in `obj`, starting from `init` value.
*/
export const reduceObject = <T, R, NF extends boolean = false>(
proxy: SheetProxy,
obj: CellObject<T>,
fn: (
acc: R,
key: string,
value: T,
cell?: AnyCell<T>,
index?: number
) => R | Promise<R>,
init: R,
name = "reduceObject",
nf?: NF
): MapCell<R, NF> => {
const coll = collector<MapCell<R, NF>>(proxy);
return proxy.mapNoPrevious(
[obj],
(cells) => {
const keys = Object.keys(cells);
const values = Object.values(cells);
// console.log({ reduce: keys, name, count: proxy._sheet.stats.count });
return coll(
proxy.mapNoPrevious(
values,
(..._cells) =>
asyncReduce(
_cells,
(acc, _cell, i) => fn(acc, keys[i], _cell, values[i], i),
init
),
"_reduce"
)
);
},
name,
nf
);
};
Loading
Loading