diff --git a/.changeset/validation-null-omitted-insert.md b/.changeset/validation-null-omitted-insert.md new file mode 100644 index 0000000000..695fdcb526 --- /dev/null +++ b/.changeset/validation-null-omitted-insert.md @@ -0,0 +1,15 @@ +--- +"@objectstack/objectql": patch +--- + +fix(objectql): `record. == null` validation fires on insert when the field is omitted (#1871) + +A `script` / `cross_field` validation predicate like `record.due_date == null` +did not fire on **insert** when the optional field was omitted entirely from the +payload — the CEL `record` scope lacked the key, so `record.x == null` saw a +missing key (not null) and silently couldn't match. It worked on update (the +prior record supplies the field) and when the field was explicitly `null`. + +Fix: on insert, default declared-but-absent schema fields to `null` in the rule +evaluation scope, so an omitted optional reads as `null` — matching an explicit +`null` and the update path. diff --git a/packages/objectql/src/validation/rule-null-omitted.test.ts b/packages/objectql/src/validation/rule-null-omitted.test.ts new file mode 100644 index 0000000000..6f19ed7edc --- /dev/null +++ b/packages/objectql/src/validation/rule-null-omitted.test.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #1871 — a `record. == null` predicate must fire on INSERT when the + * optional field is omitted entirely from the payload (key absent), the same + * way it fires when the field is explicitly `null`. Before the fix the CEL + * `record` scope on insert lacked the key, so `record.x == null` could not match. + */ +import { describe, it, expect } from 'vitest'; +import { evaluateValidationRules } from './rule-validator'; + +const schema = { + fields: { + priority: { name: 'priority', label: 'Priority', type: 'text' }, + due_date: { name: 'due_date', label: 'Due', type: 'date' }, + }, + validations: [ + { + name: 'urgent_needs_due', + type: 'script', + condition: 'record.priority == "urgent" && record.due_date == null', + message: 'Urgent tasks require a due date', + events: ['insert', 'update'], + }, + ], +} as any; + +describe('validation: `field == null` on insert with omitted field (#1871)', () => { + it('fires when due_date is OMITTED from the insert payload', () => { + expect(() => evaluateValidationRules(schema, { priority: 'urgent' }, 'insert', {})) + .toThrow(/rule_violation|_record|Validation failed/); + }); + + it('fires when due_date is explicitly null (already worked)', () => { + expect(() => evaluateValidationRules(schema, { priority: 'urgent', due_date: null }, 'insert', {})) + .toThrow(/rule_violation|_record|Validation failed/); + }); + + it('does NOT fire when due_date is present', () => { + expect(() => evaluateValidationRules(schema, { priority: 'urgent', due_date: '2026-01-01' }, 'insert', {})) + .not.toThrow(); + }); + + it('does NOT fire when priority is not urgent', () => { + expect(() => evaluateValidationRules(schema, { priority: 'low' }, 'insert', {})) + .not.toThrow(); + }); +}); diff --git a/packages/objectql/src/validation/rule-validator.ts b/packages/objectql/src/validation/rule-validator.ts index c2874eea51..5f8a0c9751 100644 --- a/packages/objectql/src/validation/rule-validator.ts +++ b/packages/objectql/src/validation/rule-validator.ts @@ -263,6 +263,16 @@ export function evaluateValidationRules( // Merged view used by predicate rules: prior state overlaid with the PATCH, // so a rule referencing an unchanged field still sees its persisted value. const merged: Record = { ...(previous ?? {}), ...data }; + // #1871 — on INSERT, a field omitted entirely from the payload is absent from + // the record, so a `record.x == null` predicate sees a missing CEL key (which + // does not equal null) and silently can't match. Default declared-but-absent + // fields to null so an omitted optional reads as null — matching an explicit + // `null` and the UPDATE path (where the prior record already supplies them). + if (mode === 'insert' && fields) { + for (const name of Object.keys(fields)) { + if (!(name in merged)) merged[name] = null; + } + } const ctx: RuleContext = { data, merged, previous, mode, logger: opts.logger }; const errors: FieldValidationError[] = [];