mirror of
https://github.com/docmost/docmost.git
synced 2026-06-10 01:52:43 +08:00
feat(base-formula): add tree-walking evaluator
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
parseRaw, resolve, evaluate, registry, isErrorCell, DEFAULT_MAX_DEPTH,
|
||||
} from "@docmost/base-formula/server";
|
||||
import "@docmost/base-formula/server"; // ensure functions/index.ts loaded (side-effect)
|
||||
import type { EvalContext, PropertyLookup, Value } from "@docmost/base-formula/server";
|
||||
|
||||
const mkCtx = (props: Record<string, any>): EvalContext => ({
|
||||
registry,
|
||||
properties: new Map<string, PropertyLookup>(
|
||||
Object.keys(props).map((k) => [k, { id: k, type: "number", typeOptions: {} }]),
|
||||
),
|
||||
depth: 0,
|
||||
maxDepth: DEFAULT_MAX_DEPTH,
|
||||
memo: new Map(),
|
||||
});
|
||||
|
||||
const run = (src: string, cells: Record<string, Value>): Value => {
|
||||
const names = new Map(Object.keys(cells).map((k) => [k.replace(/^prop_/, ""), k]));
|
||||
const { ast } = resolve(parseRaw(src), names);
|
||||
return evaluate(ast, cells, mkCtx(cells));
|
||||
};
|
||||
|
||||
describe("evaluate", () => {
|
||||
it("evaluates arithmetic", () => {
|
||||
expect(run('prop("a") + prop("b")', { prop_a: 2, prop_b: 3 })).toBe(5);
|
||||
});
|
||||
it("evaluates comparisons", () => {
|
||||
expect(run('prop("a") > 0', { prop_a: 1 })).toBe(true);
|
||||
});
|
||||
it("short-circuits if/then", () => {
|
||||
expect(run('if(prop("a") > 0, "pos", "neg")', { prop_a: 5 })).toBe("pos");
|
||||
expect(run('if(prop("a") > 0, "pos", "neg")', { prop_a: -1 })).toBe("neg");
|
||||
});
|
||||
it("short-circuits and/or", () => {
|
||||
expect(run('and(true, false)', {})).toBe(false);
|
||||
expect(run('or(false, true)', {})).toBe(true);
|
||||
});
|
||||
it("returns null for null arithmetic", () => {
|
||||
expect(run('prop("a") + 1', { prop_a: null })).toBe(null);
|
||||
});
|
||||
it("returns DIV_BY_ZERO for 1/0", () => {
|
||||
const v = run('1 / 0', {});
|
||||
expect(isErrorCell(v)).toBe(true);
|
||||
if (isErrorCell(v)) expect(v.__err).toBe("DIV_BY_ZERO");
|
||||
});
|
||||
});
|
||||
|
||||
// TODO: unskip after Task 15 wires the function registry with round/concat.
|
||||
describe.skip("evaluate with registered functions", () => {
|
||||
it("invokes registered functions", () => {
|
||||
expect(run('round(1.6)', {})).toBe(2);
|
||||
expect(run('concat("a", "b", "c")', {})).toBe("abc");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
// packages/base-formula/src/eval.ts
|
||||
import { makeErrorCell, isErrorCell } from "./error";
|
||||
import type { FormulaAST, OpCode } from "./ast";
|
||||
import type { Value, EvalContext } from "./types";
|
||||
|
||||
export function evaluate(
|
||||
ast: FormulaAST,
|
||||
row: Record<string, unknown>,
|
||||
ctx: EvalContext,
|
||||
): Value {
|
||||
switch (ast.t) {
|
||||
case "num": return ast.v;
|
||||
case "str": return ast.v;
|
||||
case "bool": return ast.v;
|
||||
case "null": return null;
|
||||
case "prop": return evalProp(ast.id, row, ctx);
|
||||
case "op": return evalOp(ast.op, ast.args, row, ctx);
|
||||
case "if": {
|
||||
const c = evaluate(ast.cond, row, ctx);
|
||||
if (isErrorCell(c)) return c;
|
||||
return evaluate(c === true ? ast.then : ast.else, row, ctx);
|
||||
}
|
||||
case "and": {
|
||||
for (const a of ast.args) {
|
||||
const v = evaluate(a, row, ctx);
|
||||
if (isErrorCell(v)) return v;
|
||||
if (v === false) return false;
|
||||
if (v == null) return null;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case "or": {
|
||||
for (const a of ast.args) {
|
||||
const v = evaluate(a, row, ctx);
|
||||
if (isErrorCell(v)) return v;
|
||||
if (v === true) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
case "call": {
|
||||
const fn = ctx.registry.get(ast.fn);
|
||||
if (!fn) return makeErrorCell("MISSING_PROP", `unknown function ${ast.fn}`);
|
||||
const args = ast.args.map((a) => evaluate(a, row, ctx));
|
||||
for (const v of args) if (isErrorCell(v)) return { ...v, __err: "DEPENDENCY_ERROR" };
|
||||
try { return fn.eval(args, ctx); }
|
||||
catch (e) { return makeErrorCell("TYPE_MISMATCH", (e as Error).message); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function evalProp(id: string, row: Record<string, unknown>, ctx: EvalContext): Value {
|
||||
if (ctx.memo.has(id)) return ctx.memo.get(id)!;
|
||||
const prop = ctx.properties.get(id);
|
||||
if (!prop) return makeErrorCell("MISSING_PROP", `missing property ${id}`);
|
||||
if (prop.type !== "formula") return normalize(row[id] ?? null);
|
||||
// Nested formula: recurse with depth tracking.
|
||||
if (ctx.depth >= ctx.maxDepth) return makeErrorCell("DEPTH_EXCEEDED", `max depth ${ctx.maxDepth}`);
|
||||
const opts: any = prop.typeOptions;
|
||||
const nested: EvalContext = { ...ctx, depth: ctx.depth + 1, memo: ctx.memo };
|
||||
const v = evaluate(opts.ast, row, nested);
|
||||
ctx.memo.set(id, v);
|
||||
return v;
|
||||
}
|
||||
|
||||
function normalize(v: unknown): Value {
|
||||
if (v === undefined) return null;
|
||||
if (v === null) return null;
|
||||
if (typeof v === "number" || typeof v === "string" || typeof v === "boolean") return v;
|
||||
if (isErrorCell(v)) return v;
|
||||
return null;
|
||||
}
|
||||
|
||||
function evalOp(
|
||||
op: OpCode,
|
||||
args: FormulaAST[],
|
||||
row: Record<string, unknown>,
|
||||
ctx: EvalContext,
|
||||
): Value {
|
||||
const vs = args.map((a) => evaluate(a, row, ctx));
|
||||
for (const v of vs) if (isErrorCell(v)) return { ...v, __err: "DEPENDENCY_ERROR" };
|
||||
const [a, b] = vs;
|
||||
switch (op) {
|
||||
case "+":
|
||||
if (typeof a === "string" || typeof b === "string") return (a == null ? "" : String(a)) + (b == null ? "" : String(b));
|
||||
if (a == null || b == null) return null;
|
||||
return Number(a) + Number(b);
|
||||
case "-": return a == null || b == null ? null : Number(a) - Number(b);
|
||||
case "*": return a == null || b == null ? null : Number(a) * Number(b);
|
||||
case "/":
|
||||
if (a == null || b == null) return null;
|
||||
if (Number(b) === 0) return makeErrorCell("DIV_BY_ZERO", "division by zero");
|
||||
return Number(a) / Number(b);
|
||||
case "%":
|
||||
if (a == null || b == null) return null;
|
||||
if (Number(b) === 0) return makeErrorCell("DIV_BY_ZERO", "modulo by zero");
|
||||
return Number(a) % Number(b);
|
||||
case "==": return a === b;
|
||||
case "!=": return a !== b;
|
||||
case ">": return a != null && b != null && (a as any) > (b as any);
|
||||
case "<": return a != null && b != null && (a as any) < (b as any);
|
||||
case ">=": return a != null && b != null && (a as any) >= (b as any);
|
||||
case "<=": return a != null && b != null && (a as any) <= (b as any);
|
||||
case "neg": return a == null ? null : -Number(a);
|
||||
case "not": return a == null ? null : !Boolean(a);
|
||||
}
|
||||
}
|
||||
@@ -10,3 +10,4 @@ export * from "./format";
|
||||
export { registry, register } from "./functions/registry";
|
||||
export type { FormulaFn } from "./functions/registry";
|
||||
export * from "./graph";
|
||||
export * from "./eval";
|
||||
|
||||
Reference in New Issue
Block a user