Skip to content

Commit 2e7d7f0

Browse files
authored
feat(evaluator): route CEL-dialect component/action predicates to the canonical engine (#2664)
Converges the last predicate tier (component/action visible/disabled/hidden) onto @objectstack/formula, matching fieldRules and evalRowPredicate. A { dialect: 'cel' } envelope in ExpressionEvaluator.evaluateCondition now evaluates on the canonical engine (record.* + context scope), and toPredicateInput/useCondition preserve+forward cel envelopes so action buttons use it too. Bare strings and ${…} templates stay on the legacy JS path. Closes #2661.
1 parent 8b8b744 commit 2e7d7f0

5 files changed

Lines changed: 201 additions & 5 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"@object-ui/core": minor
3+
"@object-ui/react": minor
4+
---
5+
6+
feat(evaluator): route `{ dialect: 'cel' }` component/action predicates to the canonical CEL engine (#2661)
7+
8+
Component and action `visible` / `disabled` / `hidden` predicates were evaluated
9+
by the home-grown JS `ExpressionEvaluator`, while field rules
10+
(`visibleWhen`/`readonlyWhen`/`requiredWhen`, via `fieldRules.ts`) and row/list
11+
conditionals (via `evalRowPredicate`) already delegate to the canonical
12+
`@objectstack/formula` engine. That split meant a `{ dialect: 'cel' }` predicate
13+
in a renderer/action surface was executed as **JavaScript** — CEL-only forms
14+
(`x in list`, `has()`, typed `==`, the `today()`/`daysFromNow()` catalog) behaved
15+
differently from, or faulted against, the server's enforcement.
16+
17+
This converges the remaining tier onto the same engine:
18+
19+
- **`@object-ui/core`**`ExpressionEvaluator.evaluateCondition` now detects a
20+
`{ dialect: 'cel', source }` envelope and evaluates it on `@objectstack/formula`
21+
(via `evalFieldPredicate`), binding the `record` namespace plus the whole
22+
context bag as top-level scope (`record.*`, `features.*`, `user.*`, `app.*`).
23+
Fail-soft to visible/enabled to match the legacy default; `throwOnError`
24+
callers still fail closed on a *faulting* predicate (a genuine `false` never
25+
throws). This fixes every `SchemaRenderer` visibility/disabled read at once.
26+
- **`@object-ui/react`**`toPredicateInput` preserves a CEL envelope instead of
27+
collapsing it to a `${source}` string, and `useCondition` accepts and forwards
28+
the envelope (keyed on a stable `(dialect, source)` so it doesn't re-evaluate
29+
each render). Action buttons (`action-icon`/`group`/`bar`/`button`) therefore
30+
evaluate CEL `visible`/`enabled`/`disabled` on the canonical engine.
31+
32+
**Back-compat:** bare strings and `${…}` templates stay on the legacy JS path
33+
(deprecation window); only an explicit `{ dialect: 'cel' }` envelope is rerouted.
34+
`{ dialect: 'template' }` is unaffected.
35+
36+
Together with the `^15.1.1` alignment (#2662), a renderer CEL predicate now
37+
reaches the identical verdict as the server — including the framework's
38+
`dateField == today()` equality fix (objectstack-ai/framework#3205) once it
39+
lands in a published 15.x. The broader home-grown-vs-canonical divergence
40+
motivation is #2661.

packages/core/src/evaluator/ExpressionEvaluator.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import { ExpressionContext } from './ExpressionContext.js';
2020
import { ExpressionCache } from './ExpressionCache.js';
2121
import { FormulaFunctions } from './FormulaFunctions.js';
22+
import { evalFieldPredicate } from './fieldRules.js';
2223

2324
/**
2425
* Options for expression evaluation
@@ -208,6 +209,21 @@ export class ExpressionEvaluator {
208209
return condition;
209210
}
210211

212+
// #2661 — a CEL-dialect envelope routes to the canonical `@objectstack/formula`
213+
// engine (the one `fieldRules` / list conditionals already use), NOT the legacy
214+
// JS evaluator below. This makes a component / action `visible` / `disabled`
215+
// predicate reach the SAME verdict as server enforcement — including CEL-only
216+
// behavior like `record.due_date == today()` (framework#3205). Bare strings and
217+
// `${…}` templates stay on the legacy path (back-compat deprecation window);
218+
// only an explicit `{ dialect: 'cel' }` envelope is rerouted.
219+
if (
220+
condition && typeof condition === 'object'
221+
&& (condition as { dialect?: string }).dialect === 'cel'
222+
&& typeof (condition as { source?: string }).source === 'string'
223+
) {
224+
return this.evaluateCelCondition((condition as { source: string }).source, options);
225+
}
226+
211227
// Unwrap Expression envelope (see `evaluate` for rationale).
212228
if (condition && typeof condition === 'object' && typeof (condition as any).source === 'string') {
213229
condition = (condition as any).source as string;
@@ -250,6 +266,37 @@ export class ExpressionEvaluator {
250266
}
251267
}
252268

269+
/**
270+
* Evaluate a `{ dialect: 'cel' }` predicate on the canonical `@objectstack/formula`
271+
* engine (via `evalFieldPredicate`), binding this evaluator's context: the
272+
* `record` key as the `record` namespace and the whole context bag as top-level
273+
* scope so `record.*`, `features.*`, `user.*`, `app.*` all resolve. Fail-soft to
274+
* `true` (visible/enabled — the legacy default) unless the caller opted into
275+
* `throwOnError`, in which case a *faulting* predicate (bad field / non-CEL
276+
* syntax) throws; a genuine `false` never throws.
277+
*/
278+
private evaluateCelCondition(source: string, options: EvaluationOptions): boolean {
279+
if (!source.trim()) return true; // no predicate → visible/enabled
280+
const bag = this.context.toObject();
281+
const rec = bag.record;
282+
const record = (rec && typeof rec === 'object' && !Array.isArray(rec))
283+
? (rec as Record<string, unknown>)
284+
: (bag as Record<string, unknown>);
285+
if (!options.throwOnError) {
286+
// Fast path: one evaluation, fail-soft to visible/enabled (legacy parity).
287+
return evalFieldPredicate(source, record, true, undefined, bag);
288+
}
289+
// Fail-closed callers need to tell a genuine `false` from a fault. The
290+
// canonical helper fails soft to the fallback, so a value that tracks the
291+
// fallback in BOTH runs means the predicate faulted — then we throw.
292+
const asTrue = evalFieldPredicate(source, record, true, undefined, bag);
293+
const asFalse = evalFieldPredicate(source, record, false, undefined, bag);
294+
if (asTrue !== asFalse) {
295+
throw new Error(`CEL predicate failed to evaluate: ${source}`);
296+
}
297+
return asTrue;
298+
}
299+
253300
/**
254301
* Update the context with new data
255302
*/

packages/core/src/evaluator/__tests__/ExpressionEvaluator.test.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,11 +94,73 @@ describe('ExpressionEvaluator', () => {
9494

9595
it('should handle boolean values directly', () => {
9696
const evaluator = new ExpressionEvaluator({});
97-
97+
9898
expect(evaluator.evaluateCondition(true)).toBe(true);
9999
expect(evaluator.evaluateCondition(false)).toBe(false);
100100
});
101101
});
102+
103+
// #2661 — a `{ dialect: 'cel' }` predicate must route to the canonical
104+
// @objectstack/formula engine (like fieldRules / list conditionals), NOT the
105+
// legacy JS evaluator, so component/action `visible`/`disabled` verdicts match
106+
// the server. Bare strings and `${…}` templates stay on the legacy path.
107+
describe('evaluateCondition — CEL dialect routing (#2661)', () => {
108+
const cel = (source: string) => ({ dialect: 'cel' as const, source });
109+
110+
it('evaluates a CEL envelope on the canonical engine (CEL `in`, not JS `in`)', () => {
111+
// CEL: `x in list` is list membership → true. Legacy JS `in` checks object
112+
// keys/array indices → false. The divergence proves the canonical route.
113+
const evaluator = new ExpressionEvaluator({ record: { roles: ['admin'] } });
114+
expect(evaluator.evaluateCondition(cel("'admin' in record.roles"))).toBe(true);
115+
expect(evaluator.evaluateCondition(cel("'editor' in record.roles"))).toBe(false);
116+
});
117+
118+
it('binds record.* and the host scope (features.*) for a CEL envelope', () => {
119+
const evaluator = new ExpressionEvaluator({
120+
record: { status: 'open' },
121+
features: { beta: true },
122+
});
123+
expect(evaluator.evaluateCondition(cel('record.status == "open" && features.beta'))).toBe(true);
124+
expect(evaluator.evaluateCondition(cel('record.status == "closed"'))).toBe(false);
125+
});
126+
127+
it('resolves an ISO date field against today() with an ordering comparison', () => {
128+
// today() is a canonical stdlib fn absent from the legacy evaluator; the
129+
// string date field hydrates on the CEL ordering path (framework #1530).
130+
const iso = new Date().toISOString().slice(0, 10);
131+
const evaluator = new ExpressionEvaluator({ record: { due: iso } });
132+
expect(evaluator.evaluateCondition(cel('record.due <= today()'))).toBe(true);
133+
expect(evaluator.evaluateCondition(cel('record.due >= today()'))).toBe(true);
134+
});
135+
136+
it('fails soft to visible/enabled on a faulting CEL predicate (legacy parity)', () => {
137+
const evaluator = new ExpressionEvaluator({ record: {} });
138+
// `record.nope.deep` faults; default (no throwOnError) → true.
139+
expect(evaluator.evaluateCondition(cel('record.nope.deep == 1'))).toBe(true);
140+
});
141+
142+
it('throws on a faulting CEL predicate when throwOnError is set (fail-closed)', () => {
143+
const evaluator = new ExpressionEvaluator({ record: { x: 2 } });
144+
// Missing-key deep access faults in CEL → throws under throwOnError.
145+
expect(() => evaluator.evaluateCondition(cel('record.a.b.c == 1'), { throwOnError: true })).toThrow();
146+
// A genuine `false` (field present, predicate simply false) must NOT throw.
147+
expect(evaluator.evaluateCondition(cel('record.x == 1'), { throwOnError: true })).toBe(false);
148+
});
149+
150+
it('an empty CEL source is "no predicate" → visible/enabled', () => {
151+
const evaluator = new ExpressionEvaluator({ record: { x: 1 } });
152+
expect(evaluator.evaluateCondition(cel(' '))).toBe(true);
153+
});
154+
155+
it('leaves bare strings and `${…}` templates on the legacy JS path (back-compat)', () => {
156+
const evaluator = new ExpressionEvaluator({ data: { age: 25 }, record: { roles: ['admin'] } });
157+
// `${…}` template — legacy JS.
158+
expect(evaluator.evaluateCondition('${data.age >= 18}')).toBe(true);
159+
// A `{ dialect: 'template' }` envelope is NOT rerouted — unwrapped to source
160+
// and run on the legacy path (here a bare non-`${}` string → its own value).
161+
expect(evaluator.evaluateCondition({ dialect: 'template', source: '${data.age < 18}' })).toBe(false);
162+
});
163+
});
102164
});
103165

104166
describe('evaluatePlainCondition', () => {

packages/react/src/hooks/__tests__/useExpression.test.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import { describe, it, expect, vi } from 'vitest';
66
import { renderHook } from '@testing-library/react';
77
import { createElement } from 'react';
8-
import { useExpression, useCondition, useRowPredicate, PredicateScopeProvider } from '../useExpression';
8+
import { useExpression, useCondition, useRowPredicate, toPredicateInput, PredicateScopeProvider } from '../useExpression';
99

1010
describe('useExpression', () => {
1111
it('returns string value directly for non-expression strings', () => {
@@ -177,3 +177,44 @@ describe('useRowPredicate (canonical CEL row predicate — issue #1584)', () =>
177177
warn.mockRestore();
178178
});
179179
});
180+
181+
// #2661 — a CEL-dialect action/component predicate must reach the canonical
182+
// engine through `toPredicateInput` → `useCondition`, not collapse to a legacy
183+
// `${…}` string.
184+
describe('toPredicateInput — CEL envelope preservation (#2661)', () => {
185+
it('preserves a { dialect: "cel" } envelope (does not wrap as ${…})', () => {
186+
expect(toPredicateInput({ dialect: 'cel', source: 'record.x == 1' }))
187+
.toEqual({ dialect: 'cel', source: 'record.x == 1' });
188+
});
189+
190+
it('still wraps bare strings and non-cel envelopes as legacy ${…}', () => {
191+
expect(toPredicateInput('data.age >= 18')).toBe('${data.age >= 18}');
192+
expect(toPredicateInput({ dialect: 'template', source: 'data.age >= 18' })).toBe('${data.age >= 18}');
193+
});
194+
195+
it('passes booleans / empties through', () => {
196+
expect(toPredicateInput(true)).toBe(true);
197+
expect(toPredicateInput('')).toBeUndefined();
198+
expect(toPredicateInput({ dialect: 'cel', source: '' })).toBeUndefined();
199+
});
200+
});
201+
202+
describe('useCondition — CEL envelope routes to the canonical engine (#2661)', () => {
203+
it('evaluates a cel envelope from toPredicateInput on the CEL engine (CEL `in`)', () => {
204+
const { result } = renderHook(() =>
205+
useCondition(toPredicateInput({ dialect: 'cel', source: "'admin' in record.roles" }), {
206+
record: { roles: ['admin'] },
207+
}),
208+
);
209+
expect(result.current).toBe(true);
210+
});
211+
212+
it('a cel envelope predicate that is false hides/disables (not defaulted true)', () => {
213+
const { result } = renderHook(() =>
214+
useCondition(toPredicateInput({ dialect: 'cel', source: 'record.status == "open"' }), {
215+
record: { status: 'closed' },
216+
}),
217+
);
218+
expect(result.current).toBe(false);
219+
});
220+
});

packages/react/src/hooks/useExpression.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,13 +67,19 @@ export function usePredicateScope(): Record<string, any> {
6767
*/
6868
export function toPredicateInput(
6969
value: unknown,
70-
): string | boolean | undefined {
70+
): string | boolean | { dialect: 'cel'; source: string } | undefined {
7171
if (value === null || value === undefined || value === '') return undefined;
7272
if (typeof value === 'boolean') return value;
7373
if (typeof value === 'string') return `\${${value}}`;
7474
if (typeof value === 'object' && typeof (value as any).source === 'string') {
7575
const src = (value as any).source as string;
76-
return src ? `\${${src}}` : undefined;
76+
if (!src) return undefined;
77+
// #2661 — preserve a CEL-dialect envelope so `useCondition` routes it to the
78+
// canonical `@objectstack/formula` engine (identical verdict to the server),
79+
// instead of collapsing it to a `${source}` string on the legacy JS path.
80+
// Every other dialect (template / unset) keeps the legacy `${…}` behavior.
81+
if ((value as any).dialect === 'cel') return { dialect: 'cel', source: src };
82+
return `\${${src}}`;
7783
}
7884
return undefined;
7985
}
@@ -117,7 +123,7 @@ export function useExpression(
117123
const _warnedConditions = new Set<string>();
118124

119125
export function useCondition(
120-
condition: string | boolean | undefined,
126+
condition: string | boolean | undefined | { dialect?: string; source?: string },
121127
context: Record<string, any> = {},
122128
options?: { throwOnError?: boolean; label?: string }
123129
): boolean {

0 commit comments

Comments
 (0)