Skip to content

Commit fb37eb9

Browse files
os-zhuangclaude
andcommitted
fix(objectql): validation field == null fires on insert when field omitted (#1871)
A `script`/`cross_field` validation predicate (`record.priority == "urgent" && 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 (≠ null) and silently couldn't match. It worked on update (prior record supplies the field) and when explicitly null. Fix: in evaluateValidationRules, on insert, default declared-but-absent schema fields to null in the rule-evaluation `merged` scope, so an omitted optional reads as null — matching explicit-null and the update path. Adds a regression test (omitted / explicit-null / present / not-applicable). Full objectql suite 613 pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 39a51e1 commit fb37eb9

3 files changed

Lines changed: 73 additions & 0 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
fix(objectql): `record.<field> == null` validation fires on insert when the field is omitted (#1871)
6+
7+
A `script` / `cross_field` validation predicate like `record.due_date == null`
8+
did not fire on **insert** when the optional field was omitted entirely from the
9+
payload — the CEL `record` scope lacked the key, so `record.x == null` saw a
10+
missing key (not null) and silently couldn't match. It worked on update (the
11+
prior record supplies the field) and when the field was explicitly `null`.
12+
13+
Fix: on insert, default declared-but-absent schema fields to `null` in the rule
14+
evaluation scope, so an omitted optional reads as `null` — matching an explicit
15+
`null` and the update path.
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #1871 — a `record.<field> == null` predicate must fire on INSERT when the
5+
* optional field is omitted entirely from the payload (key absent), the same
6+
* way it fires when the field is explicitly `null`. Before the fix the CEL
7+
* `record` scope on insert lacked the key, so `record.x == null` could not match.
8+
*/
9+
import { describe, it, expect } from 'vitest';
10+
import { evaluateValidationRules } from './rule-validator';
11+
12+
const schema = {
13+
fields: {
14+
priority: { name: 'priority', label: 'Priority', type: 'text' },
15+
due_date: { name: 'due_date', label: 'Due', type: 'date' },
16+
},
17+
validations: [
18+
{
19+
name: 'urgent_needs_due',
20+
type: 'script',
21+
condition: 'record.priority == "urgent" && record.due_date == null',
22+
message: 'Urgent tasks require a due date',
23+
events: ['insert', 'update'],
24+
},
25+
],
26+
} as any;
27+
28+
describe('validation: `field == null` on insert with omitted field (#1871)', () => {
29+
it('fires when due_date is OMITTED from the insert payload', () => {
30+
expect(() => evaluateValidationRules(schema, { priority: 'urgent' }, 'insert', {}))
31+
.toThrow(/rule_violation|_record|Validation failed/);
32+
});
33+
34+
it('fires when due_date is explicitly null (already worked)', () => {
35+
expect(() => evaluateValidationRules(schema, { priority: 'urgent', due_date: null }, 'insert', {}))
36+
.toThrow(/rule_violation|_record|Validation failed/);
37+
});
38+
39+
it('does NOT fire when due_date is present', () => {
40+
expect(() => evaluateValidationRules(schema, { priority: 'urgent', due_date: '2026-01-01' }, 'insert', {}))
41+
.not.toThrow();
42+
});
43+
44+
it('does NOT fire when priority is not urgent', () => {
45+
expect(() => evaluateValidationRules(schema, { priority: 'low' }, 'insert', {}))
46+
.not.toThrow();
47+
});
48+
});

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,16 @@ export function evaluateValidationRules(
263263
// Merged view used by predicate rules: prior state overlaid with the PATCH,
264264
// so a rule referencing an unchanged field still sees its persisted value.
265265
const merged: Record<string, unknown> = { ...(previous ?? {}), ...data };
266+
// #1871 — on INSERT, a field omitted entirely from the payload is absent from
267+
// the record, so a `record.x == null` predicate sees a missing CEL key (which
268+
// does not equal null) and silently can't match. Default declared-but-absent
269+
// fields to null so an omitted optional reads as null — matching an explicit
270+
// `null` and the UPDATE path (where the prior record already supplies them).
271+
if (mode === 'insert' && fields) {
272+
for (const name of Object.keys(fields)) {
273+
if (!(name in merged)) merged[name] = null;
274+
}
275+
}
266276
const ctx: RuleContext = { data, merged, previous, mode, logger: opts.logger };
267277

268278
const errors: FieldValidationError[] = [];

0 commit comments

Comments
 (0)