mirror of
https://github.com/docmost/docmost.git
synced 2026-08-29 18:14:58 +08:00
feat(base-formula): add name-to-id resolver with dependency extraction
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { parseRaw } from "@docmost/base-formula/server";
|
import { parseRaw, resolve } from "@docmost/base-formula/server";
|
||||||
import type { RawFormulaAST } from "@docmost/base-formula/server";
|
import type { RawFormulaAST } from "@docmost/base-formula/server";
|
||||||
|
|
||||||
describe("parseRaw", () => {
|
describe("parseRaw", () => {
|
||||||
@@ -112,3 +112,52 @@ describe("parseRaw", () => {
|
|||||||
expect(() => parseRaw("prop()")).toThrow();
|
expect(() => parseRaw("prop()")).toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("resolve", () => {
|
||||||
|
const names = new Map([
|
||||||
|
["Price", "prop_price"],
|
||||||
|
["Qty", "prop_qty"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
it("replaces propName with prop (id)", () => {
|
||||||
|
const raw = parseRaw('prop("Price")');
|
||||||
|
const out = resolve(raw, names);
|
||||||
|
expect(out.ast).toEqual({ t: "prop", id: "prop_price" });
|
||||||
|
expect(out.dependencies).toEqual(["prop_price"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("walks into nested structures", () => {
|
||||||
|
const raw = parseRaw('prop("Price") * prop("Qty")');
|
||||||
|
const out = resolve(raw, names);
|
||||||
|
expect(out.ast).toEqual({
|
||||||
|
t: "op", op: "*", args: [
|
||||||
|
{ t: "prop", id: "prop_price" },
|
||||||
|
{ t: "prop", id: "prop_qty" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect([...out.dependencies].sort()).toEqual(["prop_price", "prop_qty"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dedupes dependencies", () => {
|
||||||
|
const raw = parseRaw('prop("Price") + prop("Price")');
|
||||||
|
const out = resolve(raw, names);
|
||||||
|
expect(out.dependencies).toEqual(["prop_price"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws UNKNOWN_PROPERTY with a span", () => {
|
||||||
|
const raw = parseRaw('prop("Nope")');
|
||||||
|
try {
|
||||||
|
resolve(raw, names);
|
||||||
|
fail("expected throw");
|
||||||
|
} catch (e: any) {
|
||||||
|
expect(e.errors[0].code).toBe("UNKNOWN_PROPERTY");
|
||||||
|
expect(e.errors[0].message).toContain("Nope");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles if/and/or/call", () => {
|
||||||
|
const raw = parseRaw('if(prop("Price") > 0, prop("Qty"), 0)');
|
||||||
|
const out = resolve(raw, names);
|
||||||
|
expect(out.ast.t).toBe("if");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ export * from "./types";
|
|||||||
export * from "./error";
|
export * from "./error";
|
||||||
export * from "./tokenizer";
|
export * from "./tokenizer";
|
||||||
export * from "./parser";
|
export * from "./parser";
|
||||||
|
export * from "./resolver";
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ export * from "./types";
|
|||||||
export * from "./error";
|
export * from "./error";
|
||||||
export * from "./tokenizer";
|
export * from "./tokenizer";
|
||||||
export * from "./parser";
|
export * from "./parser";
|
||||||
|
export * from "./resolver";
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// packages/base-formula/src/resolver.ts
|
||||||
|
import { FormulaParseError } from "./error";
|
||||||
|
import type { FormulaAST, RawFormulaAST } from "./ast";
|
||||||
|
|
||||||
|
export type ResolveResult = {
|
||||||
|
ast: FormulaAST;
|
||||||
|
dependencies: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolve(
|
||||||
|
raw: RawFormulaAST,
|
||||||
|
nameToId: ReadonlyMap<string, string>,
|
||||||
|
): ResolveResult {
|
||||||
|
const deps = new Set<string>();
|
||||||
|
const ast = walk(raw, nameToId, deps);
|
||||||
|
return { ast, dependencies: Array.from(deps).sort() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function walk(
|
||||||
|
node: RawFormulaAST,
|
||||||
|
nameToId: ReadonlyMap<string, string>,
|
||||||
|
deps: Set<string>,
|
||||||
|
): FormulaAST {
|
||||||
|
switch (node.t) {
|
||||||
|
case "num": case "str": case "bool": case "null":
|
||||||
|
return node as FormulaAST;
|
||||||
|
case "propName": {
|
||||||
|
const id = nameToId.get(node.name);
|
||||||
|
if (!id) {
|
||||||
|
throw new FormulaParseError([{
|
||||||
|
code: "UNKNOWN_PROPERTY",
|
||||||
|
message: `Unknown property '${node.name}'`,
|
||||||
|
span: { start: 0, end: 0 }, // parser carries real spans; resolver is post-parse
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
deps.add(id);
|
||||||
|
return { t: "prop", id };
|
||||||
|
}
|
||||||
|
case "op":
|
||||||
|
return {
|
||||||
|
t: "op",
|
||||||
|
op: (node as any).op,
|
||||||
|
args: (node as any).args.map((a: RawFormulaAST) => walk(a, nameToId, deps)),
|
||||||
|
};
|
||||||
|
case "if":
|
||||||
|
return {
|
||||||
|
t: "if",
|
||||||
|
cond: walk((node as any).cond, nameToId, deps),
|
||||||
|
then: walk((node as any).then, nameToId, deps),
|
||||||
|
else: walk((node as any).else, nameToId, deps),
|
||||||
|
};
|
||||||
|
case "and":
|
||||||
|
return {
|
||||||
|
t: "and",
|
||||||
|
args: (node as any).args.map((a: RawFormulaAST) => walk(a, nameToId, deps)),
|
||||||
|
};
|
||||||
|
case "or":
|
||||||
|
return {
|
||||||
|
t: "or",
|
||||||
|
args: (node as any).args.map((a: RawFormulaAST) => walk(a, nameToId, deps)),
|
||||||
|
};
|
||||||
|
case "call":
|
||||||
|
return {
|
||||||
|
t: "call",
|
||||||
|
fn: (node as any).fn,
|
||||||
|
args: (node as any).args.map((a: RawFormulaAST) => walk(a, nameToId, deps)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user