Skip to content

Commit ea1ba79

Browse files
committed
feat(formula): implement formula expression evaluator and compiler
1 parent 277943c commit ea1ba79

2 files changed

Lines changed: 494 additions & 0 deletions

File tree

packages/objectql/src/engine.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,48 @@ import { CoreServiceName, StorageNameMapping } from '@objectstack/spec/system';
1515
import { IRealtimeService, RealtimeEventPayload } from '@objectstack/spec/contracts';
1616
import { pluralToSingular } from '@objectstack/spec/shared';
1717
import { SchemaRegistry, computeFQN } from './registry.js';
18+
import { compileFormula, evaluateFormula } from './formula.js';
19+
20+
interface FormulaPlanEntry { name: string; expression: string; }
21+
22+
function planFormulaProjection(
23+
schema: any,
24+
requestedFields: string[] | undefined
25+
): { plan: FormulaPlanEntry[]; projected?: string[] } {
26+
if (!schema?.fields || !Array.isArray(requestedFields) || requestedFields.length === 0) {
27+
return { plan: [] };
28+
}
29+
const plan: FormulaPlanEntry[] = [];
30+
const projected = new Set<string>();
31+
for (const f of requestedFields) {
32+
const def = (schema.fields as any)[f];
33+
if (def?.type === 'formula' && def.expression) {
34+
plan.push({ name: f, expression: def.expression });
35+
try {
36+
for (const dep of compileFormula(def.expression).dependencies) {
37+
if ((schema.fields as any)[dep]) projected.add(dep);
38+
}
39+
} catch {
40+
// ignore broken formulas at planning stage
41+
}
42+
} else {
43+
projected.add(f);
44+
}
45+
}
46+
if (plan.length === 0) return { plan: [] };
47+
if (!projected.has('id')) projected.add('id');
48+
return { plan, projected: Array.from(projected) };
49+
}
50+
51+
function applyFormulaPlan(plan: FormulaPlanEntry[], records: any[]): void {
52+
if (!plan.length) return;
53+
for (const rec of records) {
54+
if (rec == null) continue;
55+
for (const fp of plan) {
56+
rec[fp.name] = evaluateFormula(fp.expression, rec);
57+
}
58+
}
59+
}
1860

1961
export type HookHandler = (context: HookContext) => Promise<void> | void;
2062

@@ -939,6 +981,13 @@ export class ObjectQL implements IDataEngine {
939981
}
940982
delete (ast as any).top;
941983

984+
// Plan formula projection: rewrite ast.fields to drop virtual formula
985+
// names and inject their dependencies, so the driver returns the raw
986+
// fields needed to compute the formulas after fetch.
987+
const _findSchema = this._registry.getObject(object);
988+
const _findFormula = planFormulaProjection(_findSchema, ast.fields as string[] | undefined);
989+
if (_findFormula.projected) ast.fields = _findFormula.projected;
990+
942991
const opCtx: OperationContext = {
943992
object,
944993
operation: 'find',
@@ -961,6 +1010,9 @@ export class ObjectQL implements IDataEngine {
9611010
try {
9621011
let result = await driver.find(object, hookContext.input.ast as QueryAST, hookContext.input.options as any);
9631012

1013+
// Post-process: evaluate formula virtual fields against the raw rows
1014+
if (Array.isArray(result)) applyFormulaPlan(_findFormula.plan, result);
1015+
9641016
// Post-process: expand related records if expand is requested
9651017
if (ast.expand && Object.keys(ast.expand).length > 0 && Array.isArray(result)) {
9661018
result = await this.expandRelatedRecords(object, result, ast.expand, 0);
@@ -989,6 +1041,12 @@ export class ObjectQL implements IDataEngine {
9891041
delete (ast as any).context;
9901042
delete (ast as any).top;
9911043

1044+
// Plan formula projection (same as find): rewrite ast.fields so the driver
1045+
// returns the raw dependency fields, then evaluate formulas after fetch.
1046+
const _findOneSchema = this._registry.getObject(objectName);
1047+
const _findOneFormula = planFormulaProjection(_findOneSchema, ast.fields as string[] | undefined);
1048+
if (_findOneFormula.projected) ast.fields = _findOneFormula.projected;
1049+
9921050
const opCtx: OperationContext = {
9931051
object: objectName,
9941052
operation: 'findOne',
@@ -1000,6 +1058,9 @@ export class ObjectQL implements IDataEngine {
10001058
await this.executeWithMiddleware(opCtx, async () => {
10011059
let result = await driver.findOne(objectName, opCtx.ast as QueryAST);
10021060

1061+
// Post-process: evaluate formula virtual fields against the raw row
1062+
if (result != null) applyFormulaPlan(_findOneFormula.plan, [result]);
1063+
10031064
// Post-process: expand related records if expand is requested
10041065
if (ast.expand && Object.keys(ast.expand).length > 0 && result != null) {
10051066
const expanded = await this.expandRelatedRecords(objectName, [result], ast.expand, 0);

0 commit comments

Comments
 (0)