feat(formulas): add restricted decimal engine
This commit is contained in:
@@ -0,0 +1,574 @@
|
||||
import Decimal from "decimal.js";
|
||||
|
||||
import type {
|
||||
EvaluatedField,
|
||||
FormulaAnalysis,
|
||||
FormulaEngine,
|
||||
FormulaError,
|
||||
FormulaErrorCode,
|
||||
FormulaEvaluation,
|
||||
FormulaField,
|
||||
FormulaOptions,
|
||||
} from "./types";
|
||||
|
||||
const DeterministicDecimal = Decimal.clone({
|
||||
precision: 100,
|
||||
rounding: Decimal.ROUND_HALF_UP,
|
||||
toExpNeg: -1_000,
|
||||
toExpPos: 1_000,
|
||||
});
|
||||
|
||||
type TokenType =
|
||||
| "number"
|
||||
| "reference"
|
||||
| "plus"
|
||||
| "minus"
|
||||
| "multiply"
|
||||
| "divide"
|
||||
| "left-paren"
|
||||
| "right-paren"
|
||||
| "percent"
|
||||
| "eof";
|
||||
|
||||
interface Token {
|
||||
type: TokenType;
|
||||
lexeme: string;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
type Expression =
|
||||
| { type: "number"; value: string }
|
||||
| { type: "reference"; key: string }
|
||||
| { type: "unary"; operator: "+" | "-"; operand: Expression }
|
||||
| { type: "percent"; operand: Expression }
|
||||
| {
|
||||
type: "binary";
|
||||
operator: "+" | "-" | "*" | "/";
|
||||
left: Expression;
|
||||
right: Expression;
|
||||
};
|
||||
|
||||
class FormulaFailure extends Error {
|
||||
constructor(
|
||||
readonly code: FormulaErrorCode,
|
||||
message: string,
|
||||
readonly reference?: string,
|
||||
readonly causeCode?: FormulaErrorCode,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "FormulaFailure";
|
||||
}
|
||||
}
|
||||
|
||||
function isIdentifierStart(character: string): boolean {
|
||||
return /[\p{L}_]/u.test(character);
|
||||
}
|
||||
|
||||
function isIdentifierPart(character: string): boolean {
|
||||
return /[\p{L}\p{M}\p{N}_]/u.test(character);
|
||||
}
|
||||
|
||||
function tokenize(source: string): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
let offset = 0;
|
||||
|
||||
while (offset < source.length) {
|
||||
const character = source[offset];
|
||||
|
||||
if (/\s/u.test(character)) {
|
||||
offset += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/\d/u.test(character) || (character === "." && /\d/u.test(source[offset + 1] ?? ""))) {
|
||||
const start = offset;
|
||||
let sawDecimalPoint = false;
|
||||
|
||||
while (offset < source.length) {
|
||||
const next = source[offset];
|
||||
if (/\d/u.test(next)) {
|
||||
offset += 1;
|
||||
continue;
|
||||
}
|
||||
if (next === "." && !sawDecimalPoint) {
|
||||
sawDecimalPoint = true;
|
||||
offset += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const lexeme = source.slice(start, offset);
|
||||
if (lexeme.endsWith(".")) {
|
||||
throw new FormulaFailure(
|
||||
"parse-error",
|
||||
`Decimal literal at position ${start + 1} is incomplete.`,
|
||||
);
|
||||
}
|
||||
tokens.push({ type: "number", lexeme, offset: start });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isIdentifierStart(character)) {
|
||||
const start = offset;
|
||||
offset += 1;
|
||||
while (
|
||||
offset < source.length &&
|
||||
isIdentifierPart(source[offset] ?? "")
|
||||
) {
|
||||
offset += 1;
|
||||
}
|
||||
tokens.push({
|
||||
type: "reference",
|
||||
lexeme: source.slice(start, offset),
|
||||
offset: start,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const operators: Record<string, TokenType> = {
|
||||
"+": "plus",
|
||||
"-": "minus",
|
||||
"*": "multiply",
|
||||
"/": "divide",
|
||||
"(": "left-paren",
|
||||
")": "right-paren",
|
||||
"%": "percent",
|
||||
};
|
||||
const type = operators[character];
|
||||
|
||||
if (!type) {
|
||||
throw new FormulaFailure(
|
||||
"parse-error",
|
||||
`Unsupported token “${character}” at position ${offset + 1}.`,
|
||||
);
|
||||
}
|
||||
|
||||
tokens.push({ type, lexeme: character, offset });
|
||||
offset += 1;
|
||||
}
|
||||
|
||||
tokens.push({ type: "eof", lexeme: "", offset: source.length });
|
||||
return tokens;
|
||||
}
|
||||
|
||||
class Parser {
|
||||
private cursor = 0;
|
||||
|
||||
constructor(private readonly tokens: readonly Token[]) {}
|
||||
|
||||
parse(): Expression {
|
||||
const expression = this.parseAdditive();
|
||||
const trailing = this.peek();
|
||||
if (trailing.type !== "eof") {
|
||||
throw new FormulaFailure(
|
||||
"parse-error",
|
||||
`Unexpected token “${trailing.lexeme}” at position ${trailing.offset + 1}.`,
|
||||
);
|
||||
}
|
||||
return expression;
|
||||
}
|
||||
|
||||
private parseAdditive(): Expression {
|
||||
let expression = this.parseMultiplicative();
|
||||
|
||||
while (this.matches("plus", "minus")) {
|
||||
const operator = this.previous().lexeme as "+" | "-";
|
||||
expression = {
|
||||
type: "binary",
|
||||
operator,
|
||||
left: expression,
|
||||
right: this.parseMultiplicative(),
|
||||
};
|
||||
}
|
||||
|
||||
return expression;
|
||||
}
|
||||
|
||||
private parseMultiplicative(): Expression {
|
||||
let expression = this.parseUnary();
|
||||
|
||||
while (this.matches("multiply", "divide")) {
|
||||
const operator = this.previous().lexeme as "*" | "/";
|
||||
expression = {
|
||||
type: "binary",
|
||||
operator,
|
||||
left: expression,
|
||||
right: this.parseUnary(),
|
||||
};
|
||||
}
|
||||
|
||||
return expression;
|
||||
}
|
||||
|
||||
private parseUnary(): Expression {
|
||||
if (this.matches("plus", "minus")) {
|
||||
return {
|
||||
type: "unary",
|
||||
operator: this.previous().lexeme as "+" | "-",
|
||||
operand: this.parseUnary(),
|
||||
};
|
||||
}
|
||||
|
||||
return this.parsePercent();
|
||||
}
|
||||
|
||||
private parsePercent(): Expression {
|
||||
let expression = this.parsePrimary();
|
||||
while (this.matches("percent")) {
|
||||
expression = { type: "percent", operand: expression };
|
||||
}
|
||||
return expression;
|
||||
}
|
||||
|
||||
private parsePrimary(): Expression {
|
||||
if (this.matches("number")) {
|
||||
return { type: "number", value: this.previous().lexeme };
|
||||
}
|
||||
|
||||
if (this.matches("reference")) {
|
||||
const reference = this.previous();
|
||||
if (this.peek().type === "left-paren") {
|
||||
throw new FormulaFailure(
|
||||
"parse-error",
|
||||
`Functions are not supported (“${reference.lexeme}” at position ${reference.offset + 1}).`,
|
||||
);
|
||||
}
|
||||
return { type: "reference", key: reference.lexeme };
|
||||
}
|
||||
|
||||
if (this.matches("left-paren")) {
|
||||
const expression = this.parseAdditive();
|
||||
this.consume("right-paren", "Expected a closing parenthesis.");
|
||||
return expression;
|
||||
}
|
||||
|
||||
const token = this.peek();
|
||||
throw new FormulaFailure(
|
||||
"parse-error",
|
||||
token.type === "eof"
|
||||
? "Expected a number, field reference, or parenthesized expression."
|
||||
: `Unexpected token “${token.lexeme}” at position ${token.offset + 1}.`,
|
||||
);
|
||||
}
|
||||
|
||||
private consume(type: TokenType, message: string): Token {
|
||||
if (this.peek().type === type) return this.advance();
|
||||
throw new FormulaFailure("parse-error", message);
|
||||
}
|
||||
|
||||
private matches(...types: TokenType[]): boolean {
|
||||
if (!types.includes(this.peek().type)) return false;
|
||||
this.advance();
|
||||
return true;
|
||||
}
|
||||
|
||||
private advance(): Token {
|
||||
if (this.peek().type !== "eof") this.cursor += 1;
|
||||
return this.previous();
|
||||
}
|
||||
|
||||
private peek(): Token {
|
||||
return this.tokens[this.cursor] ?? this.tokens[this.tokens.length - 1];
|
||||
}
|
||||
|
||||
private previous(): Token {
|
||||
return this.tokens[Math.max(0, this.cursor - 1)];
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeExpression(expression: string): string {
|
||||
const trimmed = expression.trim();
|
||||
return trimmed.startsWith("=") ? trimmed.slice(1).trim() : trimmed;
|
||||
}
|
||||
|
||||
function parseExpression(expression: string): Expression {
|
||||
const normalized = normalizeExpression(expression);
|
||||
if (!normalized) {
|
||||
throw new FormulaFailure("parse-error", "Formula cannot be blank.");
|
||||
}
|
||||
return new Parser(tokenize(normalized)).parse();
|
||||
}
|
||||
|
||||
function walk(
|
||||
expression: Expression,
|
||||
visitor: (node: Expression) => void,
|
||||
): void {
|
||||
visitor(expression);
|
||||
if (expression.type === "binary") {
|
||||
walk(expression.left, visitor);
|
||||
walk(expression.right, visitor);
|
||||
} else if (expression.type === "unary" || expression.type === "percent") {
|
||||
walk(expression.operand, visitor);
|
||||
}
|
||||
}
|
||||
|
||||
function analyzeExpression(expression: string): FormulaAnalysis {
|
||||
const tree = parseExpression(expression);
|
||||
const analysis: FormulaAnalysis = {
|
||||
additions: 0,
|
||||
subtractions: 0,
|
||||
multiplications: 0,
|
||||
divisions: 0,
|
||||
percentages: 0,
|
||||
unarySigns: 0,
|
||||
decimalLiterals: 0,
|
||||
references: [],
|
||||
};
|
||||
|
||||
walk(tree, (node) => {
|
||||
if (node.type === "number" && node.value.includes(".")) {
|
||||
analysis.decimalLiterals += 1;
|
||||
} else if (node.type === "reference") {
|
||||
if (!analysis.references.includes(node.key)) {
|
||||
analysis.references.push(node.key);
|
||||
}
|
||||
} else if (node.type === "percent") {
|
||||
analysis.percentages += 1;
|
||||
} else if (node.type === "unary") {
|
||||
analysis.unarySigns += 1;
|
||||
} else if (node.type === "binary") {
|
||||
if (node.operator === "+") analysis.additions += 1;
|
||||
if (node.operator === "-") analysis.subtractions += 1;
|
||||
if (node.operator === "*") analysis.multiplications += 1;
|
||||
if (node.operator === "/") analysis.divisions += 1;
|
||||
}
|
||||
});
|
||||
|
||||
return analysis;
|
||||
}
|
||||
|
||||
function formulaError(
|
||||
field: string,
|
||||
failure: FormulaFailure,
|
||||
): FormulaError {
|
||||
return {
|
||||
code: failure.code,
|
||||
field,
|
||||
reference: failure.reference,
|
||||
cause: failure.causeCode,
|
||||
message: failure.message,
|
||||
};
|
||||
}
|
||||
|
||||
function publicSafeError(field: string, error: FormulaError): EvaluatedField {
|
||||
return {
|
||||
key: field,
|
||||
canonical: null,
|
||||
display: null,
|
||||
dependencies: [],
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
function formatDecimal(value: Decimal, precision: number): string {
|
||||
return value.toDecimalPlaces(precision, Decimal.ROUND_HALF_UP).toFixed(precision);
|
||||
}
|
||||
|
||||
function assertPrecision(value: number): number {
|
||||
if (!Number.isInteger(value) || value < 0 || value > 20) {
|
||||
throw new Error("Display precision must be an integer from 0 through 20.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export class RestrictedFormulaEngine implements FormulaEngine {
|
||||
evaluate(
|
||||
sourceFields: readonly FormulaField[],
|
||||
options: FormulaOptions = {},
|
||||
): FormulaEvaluation {
|
||||
const defaultPrecision = assertPrecision(options.defaultDisplayPrecision ?? 2);
|
||||
const fields = new Map<string, FormulaField>();
|
||||
|
||||
for (const field of sourceFields) {
|
||||
if (!field.key || fields.has(field.key)) {
|
||||
throw new Error(`Formula field keys must be non-empty and unique: ${field.key}`);
|
||||
}
|
||||
fields.set(field.key, field);
|
||||
}
|
||||
|
||||
const results = new Map<string, EvaluatedField>();
|
||||
const decimals = new Map<string, Decimal>();
|
||||
const parsed = new Map<string, Expression>();
|
||||
const visiting = new Set<string>();
|
||||
const stack: string[] = [];
|
||||
const order: string[] = [];
|
||||
|
||||
const resolveReference = (reference: string): string =>
|
||||
options.referenceAliases?.[reference] ?? reference;
|
||||
|
||||
const evaluateTree = (
|
||||
tree: Expression,
|
||||
currentField: string,
|
||||
dependencies: Set<string>,
|
||||
): Decimal => {
|
||||
if (tree.type === "number") {
|
||||
try {
|
||||
return new DeterministicDecimal(tree.value);
|
||||
} catch {
|
||||
throw new FormulaFailure(
|
||||
"invalid-number",
|
||||
`“${tree.value}” is not a valid decimal number.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (tree.type === "reference") {
|
||||
const reference = resolveReference(tree.key);
|
||||
dependencies.add(reference);
|
||||
if (!fields.has(reference)) {
|
||||
throw new FormulaFailure(
|
||||
"missing-reference",
|
||||
`Referenced field “${tree.key}” does not exist.`,
|
||||
tree.key,
|
||||
);
|
||||
}
|
||||
|
||||
evaluateField(reference);
|
||||
const dependency = results.get(reference);
|
||||
const value = decimals.get(reference);
|
||||
|
||||
if (!dependency || dependency.error || !value) {
|
||||
const cause = dependency?.error?.code ?? "blank-reference";
|
||||
throw new FormulaFailure(
|
||||
cause === "blank-reference"
|
||||
? "blank-reference"
|
||||
: "dependency-error",
|
||||
cause === "blank-reference"
|
||||
? `Referenced field “${tree.key}” is blank.`
|
||||
: `Referenced field “${tree.key}” could not be calculated.`,
|
||||
tree.key,
|
||||
cause,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
if (tree.type === "unary") {
|
||||
const value = evaluateTree(tree.operand, currentField, dependencies);
|
||||
return tree.operator === "-" ? value.negated() : value;
|
||||
}
|
||||
|
||||
if (tree.type === "percent") {
|
||||
return evaluateTree(tree.operand, currentField, dependencies).dividedBy(100);
|
||||
}
|
||||
|
||||
const left = evaluateTree(tree.left, currentField, dependencies);
|
||||
const right = evaluateTree(tree.right, currentField, dependencies);
|
||||
|
||||
if (tree.operator === "+") return left.plus(right);
|
||||
if (tree.operator === "-") return left.minus(right);
|
||||
if (tree.operator === "*") return left.times(right);
|
||||
if (right.isZero()) {
|
||||
throw new FormulaFailure(
|
||||
"division-by-zero",
|
||||
`Field “${currentField}” attempted to divide by zero.`,
|
||||
);
|
||||
}
|
||||
return left.dividedBy(right);
|
||||
};
|
||||
|
||||
const evaluateField = (key: string): void => {
|
||||
if (results.has(key)) return;
|
||||
|
||||
if (visiting.has(key)) {
|
||||
const cycleStart = stack.indexOf(key);
|
||||
const cycle = stack.slice(cycleStart);
|
||||
for (const cycleKey of cycle) {
|
||||
results.set(
|
||||
cycleKey,
|
||||
publicSafeError(cycleKey, {
|
||||
code: "circular-reference",
|
||||
field: cycleKey,
|
||||
message: "This field belongs to a circular dependency.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const field = fields.get(key);
|
||||
if (!field) return;
|
||||
visiting.add(key);
|
||||
stack.push(key);
|
||||
const dependencies = new Set<string>();
|
||||
|
||||
try {
|
||||
let value: Decimal;
|
||||
const expression = field.formula?.trim();
|
||||
|
||||
if (expression) {
|
||||
let tree = parsed.get(key);
|
||||
if (!tree) {
|
||||
tree = parseExpression(expression);
|
||||
parsed.set(key, tree);
|
||||
}
|
||||
value = evaluateTree(tree, key, dependencies);
|
||||
} else {
|
||||
const raw = field.value?.trim();
|
||||
if (!raw) {
|
||||
throw new FormulaFailure(
|
||||
"blank-reference",
|
||||
`Field “${key}” is blank.`,
|
||||
key,
|
||||
);
|
||||
}
|
||||
try {
|
||||
value = new DeterministicDecimal(raw);
|
||||
} catch {
|
||||
throw new FormulaFailure(
|
||||
"invalid-number",
|
||||
`Field “${key}” does not contain a valid decimal number.`,
|
||||
key,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// A cycle may have been recorded while resolving a child reference.
|
||||
if (!results.has(key)) {
|
||||
const precision = assertPrecision(
|
||||
field.displayPrecision ?? defaultPrecision,
|
||||
);
|
||||
decimals.set(key, value);
|
||||
results.set(key, {
|
||||
key,
|
||||
canonical: value.toString(),
|
||||
display: formatDecimal(value, precision),
|
||||
dependencies: [...dependencies],
|
||||
});
|
||||
order.push(key);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!results.has(key)) {
|
||||
const failure =
|
||||
error instanceof FormulaFailure
|
||||
? error
|
||||
: new FormulaFailure(
|
||||
"parse-error",
|
||||
error instanceof Error ? error.message : "Formula evaluation failed.",
|
||||
);
|
||||
const result = publicSafeError(key, formulaError(key, failure));
|
||||
result.dependencies = [...dependencies];
|
||||
results.set(key, result);
|
||||
}
|
||||
} finally {
|
||||
stack.pop();
|
||||
visiting.delete(key);
|
||||
}
|
||||
};
|
||||
|
||||
for (const key of fields.keys()) evaluateField(key);
|
||||
|
||||
return {
|
||||
fields: Object.fromEntries(results),
|
||||
order,
|
||||
valid: [...results.values()].every((field) => !field.error),
|
||||
};
|
||||
}
|
||||
|
||||
analyze(expression: string): FormulaAnalysis {
|
||||
return analyzeExpression(expression);
|
||||
}
|
||||
}
|
||||
|
||||
export const formulaEngine: FormulaEngine = new RestrictedFormulaEngine();
|
||||
Reference in New Issue
Block a user