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
40 changes: 40 additions & 0 deletions .changeset/cel-renderer-predicates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
"@object-ui/core": minor
"@object-ui/react": minor
---

feat(evaluator): route `{ dialect: 'cel' }` component/action predicates to the canonical CEL engine (#2661)

Component and action `visible` / `disabled` / `hidden` predicates were evaluated
by the home-grown JS `ExpressionEvaluator`, while field rules
(`visibleWhen`/`readonlyWhen`/`requiredWhen`, via `fieldRules.ts`) and row/list
conditionals (via `evalRowPredicate`) already delegate to the canonical
`@objectstack/formula` engine. That split meant a `{ dialect: 'cel' }` predicate
in a renderer/action surface was executed as **JavaScript** — CEL-only forms
(`x in list`, `has()`, typed `==`, the `today()`/`daysFromNow()` catalog) behaved
differently from, or faulted against, the server's enforcement.

This converges the remaining tier onto the same engine:

- **`@object-ui/core`** — `ExpressionEvaluator.evaluateCondition` now detects a
`{ dialect: 'cel', source }` envelope and evaluates it on `@objectstack/formula`
(via `evalFieldPredicate`), binding the `record` namespace plus the whole
context bag as top-level scope (`record.*`, `features.*`, `user.*`, `app.*`).
Fail-soft to visible/enabled to match the legacy default; `throwOnError`
callers still fail closed on a *faulting* predicate (a genuine `false` never
throws). This fixes every `SchemaRenderer` visibility/disabled read at once.
- **`@object-ui/react`** — `toPredicateInput` preserves a CEL envelope instead of
collapsing it to a `${source}` string, and `useCondition` accepts and forwards
the envelope (keyed on a stable `(dialect, source)` so it doesn't re-evaluate
each render). Action buttons (`action-icon`/`group`/`bar`/`button`) therefore
evaluate CEL `visible`/`enabled`/`disabled` on the canonical engine.

**Back-compat:** bare strings and `${…}` templates stay on the legacy JS path
(deprecation window); only an explicit `{ dialect: 'cel' }` envelope is rerouted.
`{ dialect: 'template' }` is unaffected.

Together with the `^15.1.1` alignment (#2662), a renderer CEL predicate now
reaches the identical verdict as the server — including the framework's
`dateField == today()` equality fix (objectstack-ai/framework#3205) once it
lands in a published 15.x. The broader home-grown-vs-canonical divergence
motivation is #2661.
47 changes: 47 additions & 0 deletions packages/core/src/evaluator/ExpressionEvaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import { ExpressionContext } from './ExpressionContext.js';
import { ExpressionCache } from './ExpressionCache.js';
import { FormulaFunctions } from './FormulaFunctions.js';
import { evalFieldPredicate } from './fieldRules.js';

/**
* Options for expression evaluation
Expand Down Expand Up @@ -208,6 +209,21 @@ export class ExpressionEvaluator {
return condition;
}

// #2661 — a CEL-dialect envelope routes to the canonical `@objectstack/formula`
// engine (the one `fieldRules` / list conditionals already use), NOT the legacy
// JS evaluator below. This makes a component / action `visible` / `disabled`
// predicate reach the SAME verdict as server enforcement — including CEL-only
// behavior like `record.due_date == today()` (framework#3205). Bare strings and
// `${…}` templates stay on the legacy path (back-compat deprecation window);
// only an explicit `{ dialect: 'cel' }` envelope is rerouted.
if (
condition && typeof condition === 'object'
&& (condition as { dialect?: string }).dialect === 'cel'
&& typeof (condition as { source?: string }).source === 'string'
) {
return this.evaluateCelCondition((condition as { source: string }).source, options);
}

// Unwrap Expression envelope (see `evaluate` for rationale).
if (condition && typeof condition === 'object' && typeof (condition as any).source === 'string') {
condition = (condition as any).source as string;
Expand Down Expand Up @@ -250,6 +266,37 @@ export class ExpressionEvaluator {
}
}

/**
* Evaluate a `{ dialect: 'cel' }` predicate on the canonical `@objectstack/formula`
* engine (via `evalFieldPredicate`), binding this evaluator's context: the
* `record` key as the `record` namespace and the whole context bag as top-level
* scope so `record.*`, `features.*`, `user.*`, `app.*` all resolve. Fail-soft to
* `true` (visible/enabled — the legacy default) unless the caller opted into
* `throwOnError`, in which case a *faulting* predicate (bad field / non-CEL
* syntax) throws; a genuine `false` never throws.
*/
private evaluateCelCondition(source: string, options: EvaluationOptions): boolean {
if (!source.trim()) return true; // no predicate → visible/enabled
const bag = this.context.toObject();
const rec = bag.record;
const record = (rec && typeof rec === 'object' && !Array.isArray(rec))
? (rec as Record<string, unknown>)
: (bag as Record<string, unknown>);
if (!options.throwOnError) {
// Fast path: one evaluation, fail-soft to visible/enabled (legacy parity).
return evalFieldPredicate(source, record, true, undefined, bag);
}
// Fail-closed callers need to tell a genuine `false` from a fault. The
// canonical helper fails soft to the fallback, so a value that tracks the
// fallback in BOTH runs means the predicate faulted — then we throw.
const asTrue = evalFieldPredicate(source, record, true, undefined, bag);
const asFalse = evalFieldPredicate(source, record, false, undefined, bag);
if (asTrue !== asFalse) {
throw new Error(`CEL predicate failed to evaluate: ${source}`);
}
return asTrue;
}

/**
* Update the context with new data
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,73 @@ describe('ExpressionEvaluator', () => {

it('should handle boolean values directly', () => {
const evaluator = new ExpressionEvaluator({});

expect(evaluator.evaluateCondition(true)).toBe(true);
expect(evaluator.evaluateCondition(false)).toBe(false);
});
});

// #2661 — a `{ dialect: 'cel' }` predicate must route to the canonical
// @objectstack/formula engine (like fieldRules / list conditionals), NOT the
// legacy JS evaluator, so component/action `visible`/`disabled` verdicts match
// the server. Bare strings and `${…}` templates stay on the legacy path.
describe('evaluateCondition — CEL dialect routing (#2661)', () => {
const cel = (source: string) => ({ dialect: 'cel' as const, source });

it('evaluates a CEL envelope on the canonical engine (CEL `in`, not JS `in`)', () => {
// CEL: `x in list` is list membership → true. Legacy JS `in` checks object
// keys/array indices → false. The divergence proves the canonical route.
const evaluator = new ExpressionEvaluator({ record: { roles: ['admin'] } });
expect(evaluator.evaluateCondition(cel("'admin' in record.roles"))).toBe(true);
expect(evaluator.evaluateCondition(cel("'editor' in record.roles"))).toBe(false);
});

it('binds record.* and the host scope (features.*) for a CEL envelope', () => {
const evaluator = new ExpressionEvaluator({
record: { status: 'open' },
features: { beta: true },
});
expect(evaluator.evaluateCondition(cel('record.status == "open" && features.beta'))).toBe(true);
expect(evaluator.evaluateCondition(cel('record.status == "closed"'))).toBe(false);
});

it('resolves an ISO date field against today() with an ordering comparison', () => {
// today() is a canonical stdlib fn absent from the legacy evaluator; the
// string date field hydrates on the CEL ordering path (framework #1530).
const iso = new Date().toISOString().slice(0, 10);
const evaluator = new ExpressionEvaluator({ record: { due: iso } });
expect(evaluator.evaluateCondition(cel('record.due <= today()'))).toBe(true);
expect(evaluator.evaluateCondition(cel('record.due >= today()'))).toBe(true);
});

it('fails soft to visible/enabled on a faulting CEL predicate (legacy parity)', () => {
const evaluator = new ExpressionEvaluator({ record: {} });
// `record.nope.deep` faults; default (no throwOnError) → true.
expect(evaluator.evaluateCondition(cel('record.nope.deep == 1'))).toBe(true);
});

it('throws on a faulting CEL predicate when throwOnError is set (fail-closed)', () => {
const evaluator = new ExpressionEvaluator({ record: { x: 2 } });
// Missing-key deep access faults in CEL → throws under throwOnError.
expect(() => evaluator.evaluateCondition(cel('record.a.b.c == 1'), { throwOnError: true })).toThrow();
// A genuine `false` (field present, predicate simply false) must NOT throw.
expect(evaluator.evaluateCondition(cel('record.x == 1'), { throwOnError: true })).toBe(false);
});

it('an empty CEL source is "no predicate" → visible/enabled', () => {
const evaluator = new ExpressionEvaluator({ record: { x: 1 } });
expect(evaluator.evaluateCondition(cel(' '))).toBe(true);
});

it('leaves bare strings and `${…}` templates on the legacy JS path (back-compat)', () => {
const evaluator = new ExpressionEvaluator({ data: { age: 25 }, record: { roles: ['admin'] } });
// `${…}` template — legacy JS.
expect(evaluator.evaluateCondition('${data.age >= 18}')).toBe(true);
// A `{ dialect: 'template' }` envelope is NOT rerouted — unwrapped to source
// and run on the legacy path (here a bare non-`${}` string → its own value).
expect(evaluator.evaluateCondition({ dialect: 'template', source: '${data.age < 18}' })).toBe(false);
});
});
});

describe('evaluatePlainCondition', () => {
Expand Down
43 changes: 42 additions & 1 deletion packages/react/src/hooks/__tests__/useExpression.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import { describe, it, expect, vi } from 'vitest';
import { renderHook } from '@testing-library/react';
import { createElement } from 'react';
import { useExpression, useCondition, useRowPredicate, PredicateScopeProvider } from '../useExpression';
import { useExpression, useCondition, useRowPredicate, toPredicateInput, PredicateScopeProvider } from '../useExpression';

describe('useExpression', () => {
it('returns string value directly for non-expression strings', () => {
Expand Down Expand Up @@ -177,3 +177,44 @@ describe('useRowPredicate (canonical CEL row predicate — issue #1584)', () =>
warn.mockRestore();
});
});

// #2661 — a CEL-dialect action/component predicate must reach the canonical
// engine through `toPredicateInput` → `useCondition`, not collapse to a legacy
// `${…}` string.
describe('toPredicateInput — CEL envelope preservation (#2661)', () => {
it('preserves a { dialect: "cel" } envelope (does not wrap as ${…})', () => {
expect(toPredicateInput({ dialect: 'cel', source: 'record.x == 1' }))
.toEqual({ dialect: 'cel', source: 'record.x == 1' });
});

it('still wraps bare strings and non-cel envelopes as legacy ${…}', () => {
expect(toPredicateInput('data.age >= 18')).toBe('${data.age >= 18}');
expect(toPredicateInput({ dialect: 'template', source: 'data.age >= 18' })).toBe('${data.age >= 18}');
});

it('passes booleans / empties through', () => {
expect(toPredicateInput(true)).toBe(true);
expect(toPredicateInput('')).toBeUndefined();
expect(toPredicateInput({ dialect: 'cel', source: '' })).toBeUndefined();
});
});

describe('useCondition — CEL envelope routes to the canonical engine (#2661)', () => {
it('evaluates a cel envelope from toPredicateInput on the CEL engine (CEL `in`)', () => {
const { result } = renderHook(() =>
useCondition(toPredicateInput({ dialect: 'cel', source: "'admin' in record.roles" }), {
record: { roles: ['admin'] },
}),
);
expect(result.current).toBe(true);
});

it('a cel envelope predicate that is false hides/disables (not defaulted true)', () => {
const { result } = renderHook(() =>
useCondition(toPredicateInput({ dialect: 'cel', source: 'record.status == "open"' }), {
record: { status: 'closed' },
}),
);
expect(result.current).toBe(false);
});
});
12 changes: 9 additions & 3 deletions packages/react/src/hooks/useExpression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,19 @@ export function usePredicateScope(): Record<string, any> {
*/
export function toPredicateInput(
value: unknown,
): string | boolean | undefined {
): string | boolean | { dialect: 'cel'; source: string } | undefined {
if (value === null || value === undefined || value === '') return undefined;
if (typeof value === 'boolean') return value;
if (typeof value === 'string') return `\${${value}}`;
if (typeof value === 'object' && typeof (value as any).source === 'string') {
const src = (value as any).source as string;
return src ? `\${${src}}` : undefined;
if (!src) return undefined;
// #2661 — preserve a CEL-dialect envelope so `useCondition` routes it to the
// canonical `@objectstack/formula` engine (identical verdict to the server),
// instead of collapsing it to a `${source}` string on the legacy JS path.
// Every other dialect (template / unset) keeps the legacy `${…}` behavior.
if ((value as any).dialect === 'cel') return { dialect: 'cel', source: src };
return `\${${src}}`;
}
return undefined;
}
Expand Down Expand Up @@ -117,7 +123,7 @@ export function useExpression(
const _warnedConditions = new Set<string>();

export function useCondition(
condition: string | boolean | undefined,
condition: string | boolean | undefined | { dialect?: string; source?: string },
context: Record<string, any> = {},
options?: { throwOnError?: boolean; label?: string }
): boolean {
Expand Down
Loading