Skip to content

Commit 99111ec

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat: field conditional rules (CEL) — spec + server enforcement (B2) (#1649)
* feat(spec): field conditional rules — visibleWhen / readonlyWhen / requiredWhen (CEL) Add three CEL-predicate field props (over `record`) for field-level conditional behavior, designed for dual-side evaluation: the client form toggles visibility/read-only/required live, the server enforces them. `requiredWhen` is the canonical name; `conditionalRequired` stays as a back-compat alias. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(objectql): enforce field requiredWhen + ignore readonlyWhen writes (B2) Server-side enforcement for the conditional field rules: - evaluateValidationRules now also enforces field `requiredWhen`/ `conditionalRequired` (CEL over the merged record) → a 'required' error when the predicate is TRUE and the value is missing. Can't be bypassed by a direct API write. - stripReadonlyWhenFields drops update-payload fields whose `readonlyWhen` is TRUE for the record's state (keeps the persisted value); wired into engine.update. - needsPriorRecord accounts for conditional fields so the prior record is fetched on update (predicates over unchanged fields evaluate correctly). 46 rule-validator tests pass (requiredWhen, alias, readonly-strip, prior-fetch). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e000978 commit 99111ec

6 files changed

Lines changed: 199 additions & 9 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/objectql": minor
4+
---
5+
6+
Field-level conditional rules (CEL): `visibleWhen` / `readonlyWhen` / `requiredWhen`, enforced server-side.
7+
8+
Add three CEL-predicate field props (over `record`) evaluated on both sides. **Spec**: `visibleWhen` / `readonlyWhen` / `requiredWhen` (`requiredWhen` canonical; `conditionalRequired` kept as a back-compat alias). **Server (objectql)**: the validator now enforces `requiredWhen`/`conditionalRequired` over the merged record (so the rule can't be bypassed by a direct API write), and the update path ignores writes to a field whose `readonlyWhen` is TRUE (keeps the persisted value). `needsPriorRecord` accounts for conditional fields so the prior record is fetched on update.

packages/objectql/src/engine.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import type { Expression } from '@objectstack/spec';
2929
import { isAggregatedViewContainer, expandViewContainer } from '@objectstack/spec';
3030
import { bindHooksToEngine } from './hook-binder.js';
3131
import { validateRecord } from './validation/record-validator.js';
32-
import { evaluateValidationRules, needsPriorRecord } from './validation/rule-validator.js';
32+
import { evaluateValidationRules, needsPriorRecord, stripReadonlyWhenFields } from './validation/rule-validator.js';
3333
import { applyInMemoryAggregation } from './in-memory-aggregation.js';
3434

3535
interface FormulaPlanEntry { name: string; expression: Expression; }
@@ -2106,6 +2106,10 @@ export class ObjectQL implements IDataEngine {
21062106
const priorAst: QueryAST = { object, where: { id: hookContext.input.id }, limit: 1 };
21072107
priorRecord = await driver.findOne(object, priorAst, hookContext.input.options as any);
21082108
}
2109+
// B2: drop writes to fields locked by a TRUE `readonlyWhen` — the
2110+
// field is read-only for this record's state, so the incoming
2111+
// change is ignored (the persisted value is kept).
2112+
hookContext.input.data = stripReadonlyWhenFields(updateSchema as any, hookContext.input.data as Record<string, unknown>, priorRecord, this.logger) as any;
21092113
evaluateValidationRules(updateSchema as any, hookContext.input.data as Record<string, unknown>, 'update', { previous: priorRecord, logger: this.logger });
21102114
result = await driver.update(object, hookContext.input.id as string, hookContext.input.data as Record<string, unknown>, hookContext.input.options as any);
21112115
} else if (options?.multi && driver.updateMany) {

packages/objectql/src/validation/rule-validator.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,63 @@ import {
55
evaluateValidationRules,
66
needsPriorRecord,
77
legalNextStates,
8+
stripReadonlyWhenFields,
89
} from './rule-validator.js';
910
import { ValidationError } from './record-validator.js';
1011

12+
// B2 — field-level conditional rules (CEL over `record`).
13+
const invoiceFields = {
14+
fields: {
15+
status: { type: 'select' },
16+
// amount is required once the invoice is sent, and locked once it's paid.
17+
amount: {
18+
type: 'currency',
19+
requiredWhen: "record.status == 'sent'",
20+
readonlyWhen: "record.status == 'paid'",
21+
},
22+
},
23+
};
24+
25+
describe('field requiredWhen enforcement (B2)', () => {
26+
it('rejects a missing required-when field (insert, predicate TRUE)', () => {
27+
expect(() => evaluateValidationRules(invoiceFields, { status: 'sent' }, 'insert')).toThrow(ValidationError);
28+
});
29+
it('passes when the required-when field has a value', () => {
30+
expect(() => evaluateValidationRules(invoiceFields, { status: 'sent', amount: 10 }, 'insert')).not.toThrow();
31+
});
32+
it('passes when the predicate is FALSE', () => {
33+
expect(() => evaluateValidationRules(invoiceFields, { status: 'draft' }, 'insert')).not.toThrow();
34+
});
35+
it('evaluates over the merged record on update (prior status=sent, amount absent → required)', () => {
36+
expect(() => evaluateValidationRules(invoiceFields, { note: 'x' } as any, 'update', { previous: { status: 'sent' } })).toThrow(ValidationError);
37+
});
38+
it('honors the conditionalRequired alias', () => {
39+
const f = { fields: { amount: { type: 'currency', conditionalRequired: "record.status == 'sent'" } } };
40+
expect(() => evaluateValidationRules(f, { status: 'sent' }, 'insert')).toThrow(ValidationError);
41+
});
42+
});
43+
44+
describe('stripReadonlyWhenFields (B2)', () => {
45+
it('drops a field locked by a TRUE readonlyWhen (keeps the persisted value)', () => {
46+
const out = stripReadonlyWhenFields(invoiceFields, { amount: 999 }, { status: 'paid', amount: 100 });
47+
expect(out).toEqual({});
48+
});
49+
it('keeps the field when readonlyWhen is FALSE', () => {
50+
const out = stripReadonlyWhenFields(invoiceFields, { amount: 999 }, { status: 'draft', amount: 100 });
51+
expect(out).toEqual({ amount: 999 });
52+
});
53+
it('returns the same object when no readonlyWhen fields are touched', () => {
54+
const d = { x: 1 };
55+
expect(stripReadonlyWhenFields({ fields: { x: { type: 'number' } } }, d, null)).toBe(d);
56+
});
57+
});
58+
59+
describe('needsPriorRecord — field conditional rules (B2)', () => {
60+
it('is true when a field declares requiredWhen / readonlyWhen', () => {
61+
expect(needsPriorRecord(invoiceFields as any)).toBe(true);
62+
});
63+
});
64+
1165
// Mirrors the showcase Account lifecycle: a re-entrant FSM where a churned
1266
// account can be reactivated but cannot jump straight back to prospect.
1367
const accountSchema = {

packages/objectql/src/validation/rule-validator.ts

Lines changed: 87 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -149,11 +149,47 @@ export interface EvaluateRulesOptions {
149149
* extra fetch on the update path is worth it).
150150
*/
151151
export function needsPriorRecord(
152-
objectSchema: { validations?: unknown[] } | undefined | null,
152+
objectSchema: { validations?: unknown[]; fields?: Record<string, ConditionalFieldDef> } | undefined | null,
153153
): boolean {
154154
const rules = objectSchema?.validations;
155-
if (!Array.isArray(rules)) return false;
156-
return rules.some((r) => ruleNeedsPrior(r));
155+
const ruleNeeds = Array.isArray(rules) && rules.some((r) => ruleNeedsPrior(r));
156+
return !!(ruleNeeds || fieldsNeedPrior(objectSchema?.fields));
157+
}
158+
159+
/**
160+
* Strip fields whose `readonlyWhen` CEL predicate is TRUE for the (merged)
161+
* record from an UPDATE payload — the field is locked, so an incoming change is
162+
* ignored (the persisted value is kept) rather than rejected. Returns the same
163+
* object when nothing is locked, else a shallow copy with the locked keys
164+
* removed. A broken predicate is fail-open (the change is allowed through).
165+
*/
166+
export function stripReadonlyWhenFields(
167+
objectSchema: { fields?: Record<string, ConditionalFieldDef> } | undefined | null,
168+
data: Record<string, unknown> | undefined | null,
169+
previous: Record<string, unknown> | undefined | null,
170+
logger?: EvaluateRulesOptions['logger'],
171+
): Record<string, unknown> | undefined | null {
172+
const fields = objectSchema?.fields;
173+
if (!fields || !data) return data;
174+
const merged = { ...(previous ?? {}), ...data };
175+
let result = data;
176+
for (const [name, def] of Object.entries(fields)) {
177+
if (!def?.readonlyWhen || !(name in data)) continue;
178+
const res = ExpressionEngine.evaluate<boolean>(toExpression(def.readonlyWhen), {
179+
record: merged,
180+
previous: previous ?? undefined,
181+
});
182+
if (!res.ok) {
183+
logger?.warn?.(`readonlyWhen for '${name}' failed to evaluate — change allowed through`);
184+
continue;
185+
}
186+
if (res.value === true) {
187+
if (result === data) result = { ...data };
188+
delete (result as Record<string, unknown>)[name];
189+
logger?.warn?.(`Field '${name}' is read-only (readonlyWhen) — ignoring incoming change`);
190+
}
191+
}
192+
return result;
157193
}
158194

159195
/**
@@ -177,6 +213,27 @@ function ruleNeedsPrior(r: unknown): boolean {
177213
return false;
178214
}
179215

216+
/** Field-level conditional rules (B2): a field is required / read-only when its
217+
* CEL predicate is TRUE over the record. */
218+
interface ConditionalFieldDef {
219+
requiredWhen?: string | Expression;
220+
conditionalRequired?: string | Expression; // back-compat alias of requiredWhen
221+
readonlyWhen?: string | Expression;
222+
}
223+
224+
function isMissing(v: unknown): boolean {
225+
return v === undefined || v === null || (typeof v === 'string' && v.trim() === '');
226+
}
227+
228+
/** True when any field declares a conditional rule that needs the merged/prior
229+
* record to evaluate (so the engine fetches `previous` on update). */
230+
function fieldsNeedPrior(fields: Record<string, ConditionalFieldDef> | undefined): boolean {
231+
if (!fields) return false;
232+
return Object.values(fields).some(
233+
(f) => f && (f.requiredWhen || f.conditionalRequired || f.readonlyWhen),
234+
);
235+
}
236+
180237
/** Normalize an author-time ExpressionInput into the canonical envelope. */
181238
function toExpression(cond: string | Expression): Expression {
182239
return typeof cond === 'string' ? { dialect: 'cel', source: cond } : cond;
@@ -190,13 +247,17 @@ function toExpression(cond: string | Expression): Expression {
190247
* rules are violated. Returns void otherwise.
191248
*/
192249
export function evaluateValidationRules(
193-
objectSchema: { validations?: unknown[] } | undefined | null,
250+
objectSchema: { validations?: unknown[]; fields?: Record<string, ConditionalFieldDef> } | undefined | null,
194251
data: Record<string, unknown> | undefined | null,
195252
mode: Mode,
196253
opts: EvaluateRulesOptions = {},
197254
): void {
255+
if (!data) return;
198256
const rules = objectSchema?.validations;
199-
if (!Array.isArray(rules) || rules.length === 0 || !data) return;
257+
const hasRules = Array.isArray(rules) && rules.length > 0;
258+
const fields = objectSchema?.fields;
259+
const hasFieldRules = fieldsNeedPrior(fields);
260+
if (!hasRules && !hasFieldRules) return;
200261

201262
const previous = opts.previous ?? undefined;
202263
// Merged view used by predicate rules: prior state overlaid with the PATCH,
@@ -206,7 +267,27 @@ export function evaluateValidationRules(
206267

207268
const errors: FieldValidationError[] = [];
208269

209-
const ordered = rules
270+
// Field-level conditional rules (B2): a field whose `requiredWhen`
271+
// (or its `conditionalRequired` alias) predicate is TRUE over the merged
272+
// record must have a value — enforced server-side so the rule can't be
273+
// bypassed. (`readonlyWhen` is handled by stripReadonlyWhenFields on the
274+
// write path, not here.) A broken predicate is fail-open (logged, skipped).
275+
if (hasFieldRules && fields) {
276+
for (const [name, def] of Object.entries(fields)) {
277+
const pred = def?.requiredWhen ?? def?.conditionalRequired;
278+
if (!pred) continue;
279+
const res = ExpressionEngine.evaluate<boolean>(toExpression(pred), { record: merged, previous });
280+
if (!res.ok) {
281+
opts.logger?.warn?.(`requiredWhen for '${name}' failed to evaluate — skipped`);
282+
continue;
283+
}
284+
if (res.value === true && isMissing(merged[name])) {
285+
errors.push({ field: name, code: 'required', message: `${name} is required` });
286+
}
287+
}
288+
}
289+
290+
const ordered = (hasRules ? rules! : [])
210291
.filter((r): r is BaseRule => r != null && typeof r === 'object')
211292
.filter((r) => r.active !== false)
212293
.filter((r) => {

packages/spec/src/data/field.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1434,6 +1434,37 @@ describe('FieldSchema - conditionalRequired property', () => {
14341434
});
14351435
});
14361436

1437+
describe('FieldSchema - conditional field rules (visibleWhen / readonlyWhen / requiredWhen)', () => {
1438+
it('accepts CEL predicates and normalizes them to the { dialect, source } envelope', () => {
1439+
const result = FieldSchema.parse({
1440+
type: 'currency',
1441+
visibleWhen: "record.type == 'invoice'",
1442+
readonlyWhen: "record.status == 'paid'",
1443+
requiredWhen: "record.status == 'sent'",
1444+
});
1445+
expect(result.visibleWhen).toEqual({ dialect: 'cel', source: "record.type == 'invoice'" });
1446+
expect(result.readonlyWhen).toEqual({ dialect: 'cel', source: "record.status == 'paid'" });
1447+
expect(result.requiredWhen).toEqual({ dialect: 'cel', source: "record.status == 'sent'" });
1448+
});
1449+
1450+
it('all three are optional', () => {
1451+
const result = FieldSchema.parse({ type: 'text' });
1452+
expect(result.visibleWhen).toBeUndefined();
1453+
expect(result.readonlyWhen).toBeUndefined();
1454+
expect(result.requiredWhen).toBeUndefined();
1455+
});
1456+
1457+
it('requiredWhen and its alias conditionalRequired can coexist', () => {
1458+
const result = FieldSchema.parse({
1459+
type: 'text',
1460+
requiredWhen: "record.status == 'sent'",
1461+
conditionalRequired: "record.status == 'closed_won'",
1462+
});
1463+
expect(result.requiredWhen).toEqual({ dialect: 'cel', source: "record.status == 'sent'" });
1464+
expect(result.conditionalRequired).toEqual({ dialect: 'cel', source: "record.status == 'closed_won'" });
1465+
});
1466+
});
1467+
14371468
// ============================================================================
14381469
// columnName — Storage Layer Mapping
14391470
// ============================================================================

packages/spec/src/data/field.zod.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -526,8 +526,20 @@ export const FieldSchema = lazySchema(() => z.object({
526526
/** Layout & Grouping */
527527
group: z.string().optional().describe('Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system")'),
528528

529-
/** Conditional Requirements */
530-
conditionalRequired: ExpressionInputSchema.optional().describe('Predicate (CEL) — field is required when TRUE. e.g. P`record.status == \'closed_won\'`'),
529+
/**
530+
* Conditional field rules (CEL predicates over `record`). Evaluated on BOTH
531+
* sides: the client form toggles the field's visibility / read-only / required
532+
* state live as the record changes (UX), and the server enforces
533+
* `requiredWhen` and ignores writes to a field whose `readonlyWhen` is TRUE
534+
* (so the rule can't be bypassed). e.g. `P\`record.status == 'paid'\``.
535+
*/
536+
visibleWhen: ExpressionInputSchema.optional().describe("Predicate (CEL) — field is shown only when TRUE (else hidden). e.g. P`record.type == 'invoice'`"),
537+
readonlyWhen: ExpressionInputSchema.optional().describe("Predicate (CEL) — field is read-only when TRUE. e.g. P`record.status == 'paid'`"),
538+
requiredWhen: ExpressionInputSchema.optional().describe("Predicate (CEL) — field is required when TRUE. Canonical name for `conditionalRequired`."),
539+
540+
/** Conditional Requirements
541+
* @deprecated Alias of `requiredWhen` — kept for back-compat. */
542+
conditionalRequired: ExpressionInputSchema.optional().describe('Predicate (CEL) — field is required when TRUE. Alias of `requiredWhen`.'),
531543

532544
/** Security & Visibility */
533545
hidden: z.boolean().default(false).describe('Hidden from default UI'),

0 commit comments

Comments
 (0)