Skip to content

Commit

Permalink
Merge 818b8b1 into e41cee7
Browse files Browse the repository at this point in the history
  • Loading branch information
hbbio authored Apr 8, 2024
2 parents e41cee7 + 818b8b1 commit f639d1b
Show file tree
Hide file tree
Showing 9 changed files with 300 additions and 19 deletions.
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

0 comments on commit f639d1b

Please sign in to comment.