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
15 changes: 15 additions & 0 deletions .changeset/validation-null-omitted-insert.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
---
"@objectstack/objectql": patch
---

fix(objectql): `record.<field> == 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.
48 changes: 48 additions & 0 deletions packages/objectql/src/validation/rule-null-omitted.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #1871 — a `record.<field> == 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();
});
});
10 changes: 10 additions & 0 deletions packages/objectql/src/validation/rule-validator.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, unknown> = { ...(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[] = [];
Expand Down
Loading