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
8 changes: 8 additions & 0 deletions .changeset/b2-conditional-field-rules.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 5 additions & 1 deletion packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down Expand Up @@ -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<string, unknown>, priorRecord, this.logger) as any;
evaluateValidationRules(updateSchema as any, hookContext.input.data as Record<string, unknown>, 'update', { previous: priorRecord, logger: this.logger });
result = await driver.update(object, hookContext.input.id as string, hookContext.input.data as Record<string, unknown>, hookContext.input.options as any);
} else if (options?.multi && driver.updateMany) {
Expand Down
54 changes: 54 additions & 0 deletions packages/objectql/src/validation/rule-validator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
93 changes: 87 additions & 6 deletions packages/objectql/src/validation/rule-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ConditionalFieldDef> } | 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<string, ConditionalFieldDef> } | undefined | null,
data: Record<string, unknown> | undefined | null,
previous: Record<string, unknown> | undefined | null,
logger?: EvaluateRulesOptions['logger'],
): Record<string, unknown> | 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<boolean>(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<string, unknown>)[name];
logger?.warn?.(`Field '${name}' is read-only (readonlyWhen) — ignoring incoming change`);
}
}
return result;
}

/**
Expand All @@ -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<string, ConditionalFieldDef> | 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;
Expand All @@ -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<string, ConditionalFieldDef> } | undefined | null,
data: Record<string, unknown> | 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,
Expand All @@ -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<boolean>(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) => {
Expand Down
31 changes: 31 additions & 0 deletions packages/spec/src/data/field.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ============================================================================
Expand Down
16 changes: 14 additions & 2 deletions packages/spec/src/data/field.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down