diff --git a/.changeset/b2-conditional-field-rules.md b/.changeset/b2-conditional-field-rules.md new file mode 100644 index 000000000..e944c5934 --- /dev/null +++ b/.changeset/b2-conditional-field-rules.md @@ -0,0 +1,8 @@ +--- +"@objectstack/spec": minor +"@objectstack/objectql": minor +--- + +Field-level conditional rules (CEL): `visibleWhen` / `readonlyWhen` / `requiredWhen`, enforced server-side. + +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. diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 13afbb187..71f7596de 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -29,7 +29,7 @@ import type { Expression } from '@objectstack/spec'; import { isAggregatedViewContainer, expandViewContainer } from '@objectstack/spec'; import { bindHooksToEngine } from './hook-binder.js'; import { validateRecord } from './validation/record-validator.js'; -import { evaluateValidationRules, needsPriorRecord } from './validation/rule-validator.js'; +import { evaluateValidationRules, needsPriorRecord, stripReadonlyWhenFields } from './validation/rule-validator.js'; import { applyInMemoryAggregation } from './in-memory-aggregation.js'; interface FormulaPlanEntry { name: string; expression: Expression; } @@ -2106,6 +2106,10 @@ export class ObjectQL implements IDataEngine { const priorAst: QueryAST = { object, where: { id: hookContext.input.id }, limit: 1 }; priorRecord = await driver.findOne(object, priorAst, hookContext.input.options as any); } + // B2: drop writes to fields locked by a TRUE `readonlyWhen` — the + // field is read-only for this record's state, so the incoming + // change is ignored (the persisted value is kept). + hookContext.input.data = stripReadonlyWhenFields(updateSchema as any, hookContext.input.data as Record, priorRecord, this.logger) as any; evaluateValidationRules(updateSchema as any, hookContext.input.data as Record, 'update', { previous: priorRecord, logger: this.logger }); result = await driver.update(object, hookContext.input.id as string, hookContext.input.data as Record, hookContext.input.options as any); } else if (options?.multi && driver.updateMany) { diff --git a/packages/objectql/src/validation/rule-validator.test.ts b/packages/objectql/src/validation/rule-validator.test.ts index 51777475f..7fa0f3484 100644 --- a/packages/objectql/src/validation/rule-validator.test.ts +++ b/packages/objectql/src/validation/rule-validator.test.ts @@ -5,9 +5,63 @@ import { evaluateValidationRules, needsPriorRecord, legalNextStates, + stripReadonlyWhenFields, } from './rule-validator.js'; import { ValidationError } from './record-validator.js'; +// B2 — field-level conditional rules (CEL over `record`). +const invoiceFields = { + fields: { + status: { type: 'select' }, + // amount is required once the invoice is sent, and locked once it's paid. + amount: { + type: 'currency', + requiredWhen: "record.status == 'sent'", + readonlyWhen: "record.status == 'paid'", + }, + }, +}; + +describe('field requiredWhen enforcement (B2)', () => { + it('rejects a missing required-when field (insert, predicate TRUE)', () => { + expect(() => evaluateValidationRules(invoiceFields, { status: 'sent' }, 'insert')).toThrow(ValidationError); + }); + it('passes when the required-when field has a value', () => { + expect(() => evaluateValidationRules(invoiceFields, { status: 'sent', amount: 10 }, 'insert')).not.toThrow(); + }); + it('passes when the predicate is FALSE', () => { + expect(() => evaluateValidationRules(invoiceFields, { status: 'draft' }, 'insert')).not.toThrow(); + }); + it('evaluates over the merged record on update (prior status=sent, amount absent → required)', () => { + expect(() => evaluateValidationRules(invoiceFields, { note: 'x' } as any, 'update', { previous: { status: 'sent' } })).toThrow(ValidationError); + }); + it('honors the conditionalRequired alias', () => { + const f = { fields: { amount: { type: 'currency', conditionalRequired: "record.status == 'sent'" } } }; + expect(() => evaluateValidationRules(f, { status: 'sent' }, 'insert')).toThrow(ValidationError); + }); +}); + +describe('stripReadonlyWhenFields (B2)', () => { + it('drops a field locked by a TRUE readonlyWhen (keeps the persisted value)', () => { + const out = stripReadonlyWhenFields(invoiceFields, { amount: 999 }, { status: 'paid', amount: 100 }); + expect(out).toEqual({}); + }); + it('keeps the field when readonlyWhen is FALSE', () => { + const out = stripReadonlyWhenFields(invoiceFields, { amount: 999 }, { status: 'draft', amount: 100 }); + expect(out).toEqual({ amount: 999 }); + }); + it('returns the same object when no readonlyWhen fields are touched', () => { + const d = { x: 1 }; + expect(stripReadonlyWhenFields({ fields: { x: { type: 'number' } } }, d, null)).toBe(d); + }); +}); + +describe('needsPriorRecord — field conditional rules (B2)', () => { + it('is true when a field declares requiredWhen / readonlyWhen', () => { + expect(needsPriorRecord(invoiceFields as any)).toBe(true); + }); +}); + // Mirrors the showcase Account lifecycle: a re-entrant FSM where a churned // account can be reactivated but cannot jump straight back to prospect. const accountSchema = { diff --git a/packages/objectql/src/validation/rule-validator.ts b/packages/objectql/src/validation/rule-validator.ts index 68c10d289..c2874eea5 100644 --- a/packages/objectql/src/validation/rule-validator.ts +++ b/packages/objectql/src/validation/rule-validator.ts @@ -149,11 +149,47 @@ export interface EvaluateRulesOptions { * extra fetch on the update path is worth it). */ export function needsPriorRecord( - objectSchema: { validations?: unknown[] } | undefined | null, + objectSchema: { validations?: unknown[]; fields?: Record } | undefined | null, ): boolean { const rules = objectSchema?.validations; - if (!Array.isArray(rules)) return false; - return rules.some((r) => ruleNeedsPrior(r)); + const ruleNeeds = Array.isArray(rules) && rules.some((r) => ruleNeedsPrior(r)); + return !!(ruleNeeds || fieldsNeedPrior(objectSchema?.fields)); +} + +/** + * Strip fields whose `readonlyWhen` CEL predicate is TRUE for the (merged) + * record from an UPDATE payload — the field is locked, so an incoming change is + * ignored (the persisted value is kept) rather than rejected. Returns the same + * object when nothing is locked, else a shallow copy with the locked keys + * removed. A broken predicate is fail-open (the change is allowed through). + */ +export function stripReadonlyWhenFields( + objectSchema: { fields?: Record } | undefined | null, + data: Record | undefined | null, + previous: Record | undefined | null, + logger?: EvaluateRulesOptions['logger'], +): Record | undefined | null { + const fields = objectSchema?.fields; + if (!fields || !data) return data; + const merged = { ...(previous ?? {}), ...data }; + let result = data; + for (const [name, def] of Object.entries(fields)) { + if (!def?.readonlyWhen || !(name in data)) continue; + const res = ExpressionEngine.evaluate(toExpression(def.readonlyWhen), { + record: merged, + previous: previous ?? undefined, + }); + if (!res.ok) { + logger?.warn?.(`readonlyWhen for '${name}' failed to evaluate — change allowed through`); + continue; + } + if (res.value === true) { + if (result === data) result = { ...data }; + delete (result as Record)[name]; + logger?.warn?.(`Field '${name}' is read-only (readonlyWhen) — ignoring incoming change`); + } + } + return result; } /** @@ -177,6 +213,27 @@ function ruleNeedsPrior(r: unknown): boolean { return false; } +/** Field-level conditional rules (B2): a field is required / read-only when its + * CEL predicate is TRUE over the record. */ +interface ConditionalFieldDef { + requiredWhen?: string | Expression; + conditionalRequired?: string | Expression; // back-compat alias of requiredWhen + readonlyWhen?: string | Expression; +} + +function isMissing(v: unknown): boolean { + return v === undefined || v === null || (typeof v === 'string' && v.trim() === ''); +} + +/** True when any field declares a conditional rule that needs the merged/prior + * record to evaluate (so the engine fetches `previous` on update). */ +function fieldsNeedPrior(fields: Record | undefined): boolean { + if (!fields) return false; + return Object.values(fields).some( + (f) => f && (f.requiredWhen || f.conditionalRequired || f.readonlyWhen), + ); +} + /** Normalize an author-time ExpressionInput into the canonical envelope. */ function toExpression(cond: string | Expression): Expression { return typeof cond === 'string' ? { dialect: 'cel', source: cond } : cond; @@ -190,13 +247,17 @@ function toExpression(cond: string | Expression): Expression { * rules are violated. Returns void otherwise. */ export function evaluateValidationRules( - objectSchema: { validations?: unknown[] } | undefined | null, + objectSchema: { validations?: unknown[]; fields?: Record } | undefined | null, data: Record | undefined | null, mode: Mode, opts: EvaluateRulesOptions = {}, ): void { + if (!data) return; const rules = objectSchema?.validations; - if (!Array.isArray(rules) || rules.length === 0 || !data) return; + const hasRules = Array.isArray(rules) && rules.length > 0; + const fields = objectSchema?.fields; + const hasFieldRules = fieldsNeedPrior(fields); + if (!hasRules && !hasFieldRules) return; const previous = opts.previous ?? undefined; // Merged view used by predicate rules: prior state overlaid with the PATCH, @@ -206,7 +267,27 @@ export function evaluateValidationRules( const errors: FieldValidationError[] = []; - const ordered = rules + // Field-level conditional rules (B2): a field whose `requiredWhen` + // (or its `conditionalRequired` alias) predicate is TRUE over the merged + // record must have a value — enforced server-side so the rule can't be + // bypassed. (`readonlyWhen` is handled by stripReadonlyWhenFields on the + // write path, not here.) A broken predicate is fail-open (logged, skipped). + if (hasFieldRules && fields) { + for (const [name, def] of Object.entries(fields)) { + const pred = def?.requiredWhen ?? def?.conditionalRequired; + if (!pred) continue; + const res = ExpressionEngine.evaluate(toExpression(pred), { record: merged, previous }); + if (!res.ok) { + opts.logger?.warn?.(`requiredWhen for '${name}' failed to evaluate — skipped`); + continue; + } + if (res.value === true && isMissing(merged[name])) { + errors.push({ field: name, code: 'required', message: `${name} is required` }); + } + } + } + + const ordered = (hasRules ? rules! : []) .filter((r): r is BaseRule => r != null && typeof r === 'object') .filter((r) => r.active !== false) .filter((r) => { diff --git a/packages/spec/src/data/field.test.ts b/packages/spec/src/data/field.test.ts index 046c4c871..7290db620 100644 --- a/packages/spec/src/data/field.test.ts +++ b/packages/spec/src/data/field.test.ts @@ -1434,6 +1434,37 @@ describe('FieldSchema - conditionalRequired property', () => { }); }); +describe('FieldSchema - conditional field rules (visibleWhen / readonlyWhen / requiredWhen)', () => { + it('accepts CEL predicates and normalizes them to the { dialect, source } envelope', () => { + const result = FieldSchema.parse({ + type: 'currency', + visibleWhen: "record.type == 'invoice'", + readonlyWhen: "record.status == 'paid'", + requiredWhen: "record.status == 'sent'", + }); + expect(result.visibleWhen).toEqual({ dialect: 'cel', source: "record.type == 'invoice'" }); + expect(result.readonlyWhen).toEqual({ dialect: 'cel', source: "record.status == 'paid'" }); + expect(result.requiredWhen).toEqual({ dialect: 'cel', source: "record.status == 'sent'" }); + }); + + it('all three are optional', () => { + const result = FieldSchema.parse({ type: 'text' }); + expect(result.visibleWhen).toBeUndefined(); + expect(result.readonlyWhen).toBeUndefined(); + expect(result.requiredWhen).toBeUndefined(); + }); + + it('requiredWhen and its alias conditionalRequired can coexist', () => { + const result = FieldSchema.parse({ + type: 'text', + requiredWhen: "record.status == 'sent'", + conditionalRequired: "record.status == 'closed_won'", + }); + expect(result.requiredWhen).toEqual({ dialect: 'cel', source: "record.status == 'sent'" }); + expect(result.conditionalRequired).toEqual({ dialect: 'cel', source: "record.status == 'closed_won'" }); + }); +}); + // ============================================================================ // columnName — Storage Layer Mapping // ============================================================================ diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 0f17b7326..cdbc276b7 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -526,8 +526,20 @@ export const FieldSchema = lazySchema(() => z.object({ /** Layout & Grouping */ group: z.string().optional().describe('Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system")'), - /** Conditional Requirements */ - conditionalRequired: ExpressionInputSchema.optional().describe('Predicate (CEL) — field is required when TRUE. e.g. P`record.status == \'closed_won\'`'), + /** + * Conditional field rules (CEL predicates over `record`). Evaluated on BOTH + * sides: the client form toggles the field's visibility / read-only / required + * state live as the record changes (UX), and the server enforces + * `requiredWhen` and ignores writes to a field whose `readonlyWhen` is TRUE + * (so the rule can't be bypassed). e.g. `P\`record.status == 'paid'\``. + */ + visibleWhen: ExpressionInputSchema.optional().describe("Predicate (CEL) — field is shown only when TRUE (else hidden). e.g. P`record.type == 'invoice'`"), + readonlyWhen: ExpressionInputSchema.optional().describe("Predicate (CEL) — field is read-only when TRUE. e.g. P`record.status == 'paid'`"), + requiredWhen: ExpressionInputSchema.optional().describe("Predicate (CEL) — field is required when TRUE. Canonical name for `conditionalRequired`."), + + /** Conditional Requirements + * @deprecated Alias of `requiredWhen` — kept for back-compat. */ + conditionalRequired: ExpressionInputSchema.optional().describe('Predicate (CEL) — field is required when TRUE. Alias of `requiredWhen`.'), /** Security & Visibility */ hidden: z.boolean().default(false).describe('Hidden from default UI'),