Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/formula-plan-eval-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@objectstack/objectql": patch
---

fix(objectql): thread execution context into read-time formula evaluation

`applyFormulaPlan` — which computes `Field.formula` virtual fields after a `find`/`findOne` — evaluated each expression with only `{ record }`. So a formula using `now()`/`today()` ran against a fresh wall-clock read on every evaluation (no determinism), and a formula referencing the caller (`os.user.id`, `os.org.id`) faulted and fell back to `null` because the user/org were never in scope.

It now builds the eval context the same way `applyFieldDefaults` already does: a `now` snapshot **pinned once per operation** (every row and every formula field in one read observes the same instant) plus `os.user` / `os.org` resolved from the `ExecutionContext`. Read-time formulas behave consistently with default-value expressions, and computed fields can reference the caller.

This is independent of timezone; it is the read-path prerequisite for ADR-0053 Phase 2 (#1980 will additionally thread `timezone` here once `ExecutionContext.timezone` exists).
59 changes: 59 additions & 0 deletions packages/objectql/src/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,65 @@ describe('ObjectQL Engine', () => {
expect(result[0].response_rate).toBe(70);
});

it('evaluates read-time formula fields with execution-context user/org (#1979)', async () => {
// Regression: applyFormulaPlan used to pass only `{ record }`, so a
// computed field referencing the caller (`os.user.id` / `os.org.id`)
// faulted and fell back to null. The context now threads through.
vi.mocked(SchemaRegistry.getObject).mockReturnValue({
name: 'memo',
fields: {
id: { type: 'text' },
body: { type: 'text' },
author_ref: {
type: 'formula',
expression: { dialect: 'cel', source: 'os.user.id' },
},
org_ref: {
type: 'formula',
expression: { dialect: 'cel', source: 'os.org.id' },
},
},
} as any);

vi.mocked(mockDriver.find).mockResolvedValueOnce([
{ id: 'm1', body: 'hello' },
{ id: 'm2', body: 'world' },
]);

const result = await engine.find(
'memo',
{ fields: ['id', 'author_ref', 'org_ref'] } as any,
{ context: { userId: 'u-42', tenantId: 'org-7', roles: ['admin'] } as any },
);

expect(result.map((r: any) => r.author_ref)).toEqual(['u-42', 'u-42']);
expect(result.map((r: any) => r.org_ref)).toEqual(['org-7', 'org-7']);
});

it('pins `now` once per find so every row sees the same instant (#1979)', async () => {
vi.mocked(SchemaRegistry.getObject).mockReturnValue({
name: 'ping',
fields: {
id: { type: 'text' },
ts: {
type: 'formula',
expression: { dialect: 'cel', source: 'now()' },
},
},
} as any);

vi.mocked(mockDriver.find).mockResolvedValueOnce([
{ id: 'a' }, { id: 'b' }, { id: 'c' },
]);

const result = await engine.find('ping', { fields: ['id', 'ts'] } as any);

// Determinism: a single operation snapshots one `now`, shared across
// every row — not a fresh wall-clock read per evaluation.
expect(result[0].ts).toEqual(result[1].ts);
expect(result[1].ts).toEqual(result[2].ts);
});

it('should handle null values gracefully during expand', async () => {
vi.mocked(SchemaRegistry.getObject).mockImplementation((name) => {
if (name === 'task') return {
Expand Down
31 changes: 27 additions & 4 deletions packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,35 @@ function planFormulaProjection(
return { plan };
}

function applyFormulaPlan(plan: FormulaPlanEntry[], records: any[]): void {
/**
* Evaluate read-time formula virtual fields against the raw rows.
*
* The eval context mirrors `applyFieldDefaults` so formula and default
* expressions see the same shape: a `now` pinned ONCE per operation (every row
* and every formula field in one `find()` observes the same instant —
* determinism, and no per-eval `new Date()` drift), plus `os.user` / `os.org`
* resolved from the execution context (so a computed field can reference the
* caller, e.g. `os.user.id`). Previously this passed only `{ record }`, so
* `now()`/`today()` ran against live wall-clock and user/org were unreachable.
*
* (ADR-0053 Phase 2 will additionally thread `timezone` here once
* `ExecutionContext.timezone` exists — see #1980; this change is independent
* of timezone.)
*/
function applyFormulaPlan(
plan: FormulaPlanEntry[],
records: any[],
execCtx?: ExecutionContext,
nowSnapshot?: Date,
): void {
if (!plan.length) return;
const now = nowSnapshot ?? new Date();
const user = execCtx?.userId ? { id: String(execCtx.userId), role: execCtx?.roles?.[0] } : undefined;
const org = execCtx?.tenantId ? { id: String(execCtx.tenantId) } : undefined;
for (const rec of records) {
if (rec == null) continue;
for (const fp of plan) {
const r = ExpressionEngine.evaluate(fp.expression, { record: rec });
const r = ExpressionEngine.evaluate(fp.expression, { now, user, org, record: rec });
rec[fp.name] = r.ok ? r.value : null;
}
}
Expand Down Expand Up @@ -1866,7 +1889,7 @@ export class ObjectQL implements IDataEngine {
let result = await driver.find(object, hookContext.input.ast as QueryAST, hookContext.input.options as any);

// Post-process: evaluate formula virtual fields against the raw rows
if (Array.isArray(result)) applyFormulaPlan(_findFormula.plan, result);
if (Array.isArray(result)) applyFormulaPlan(_findFormula.plan, result, opCtx.context);

// Post-process: expand related records if expand is requested
if (ast.expand && Object.keys(ast.expand).length > 0 && Array.isArray(result)) {
Expand Down Expand Up @@ -1932,7 +1955,7 @@ export class ObjectQL implements IDataEngine {
let result = await driver.findOne(objectName, opCtx.ast as QueryAST, findOneOpts);

// Post-process: evaluate formula virtual fields against the raw row
if (result != null) applyFormulaPlan(_findOneFormula.plan, [result]);
if (result != null) applyFormulaPlan(_findOneFormula.plan, [result], opCtx.context);

// Post-process: expand related records if expand is requested
if (ast.expand && Object.keys(ast.expand).length > 0 && result != null) {
Expand Down
Loading