Skip to content

Commit 0451547

Browse files
committed
fix(objectql): apply defaultValue on explicit null at insert (#2706)
1 parent 703099a commit 0451547

3 files changed

Lines changed: 70 additions & 1 deletion

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
fix(objectql): apply field `defaultValue` when a field is explicitly `null` on insert, not only when omitted (#2706)
6+
7+
`applyFieldDefaults` previously skipped any field whose value was not
8+
`undefined`, so a form that serialized an unpicked control as `null` (rather
9+
than omitting it) fell through and stored `null` — the `current_user` token and
10+
static defaults never filled in. Both an omitted field and an explicit `null`
11+
now count as "no value supplied" and receive the default. This runs on the
12+
insert path only, so a deliberate "set to null" on update is untouched; an
13+
explicit empty string `''` is still respected as a real value.

‎packages/objectql/src/engine.test.ts‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,50 @@ describe('ObjectQL Engine', () => {
319319
expect(arg.owner).toBeUndefined();
320320
});
321321

322+
it('backfills a default when the field is EXPLICITLY null, not just omitted (#2706)', async () => {
323+
vi.mocked(SchemaRegistry.getObject).mockImplementation((name) => {
324+
if (name === 'ticket') return {
325+
name: 'ticket',
326+
fields: {
327+
title: { type: 'text' },
328+
owner: { type: 'user', reference: 'sys_user', defaultValue: 'current_user' },
329+
status: { type: 'text', defaultValue: 'planned' },
330+
},
331+
} as any;
332+
if (name === 'sys_user') return { name: 'sys_user', fields: { name: { type: 'text' } } } as any;
333+
return undefined;
334+
});
335+
336+
// A form serializes an unpicked control as explicit `null` (not
337+
// omission) — both the dynamic `current_user` token and a static
338+
// default must still fill in.
339+
await engine.insert('ticket', { title: 'T1', owner: null, status: null }, { context: { userId: 'u-42' } as any });
340+
341+
expect(mockDriver.create).toHaveBeenCalledWith(
342+
'ticket',
343+
expect.objectContaining({ title: 'T1', owner: 'u-42', status: 'planned' }),
344+
expect.anything(),
345+
);
346+
});
347+
348+
it('respects an explicit empty-string value and does not overwrite it with the default (#2706)', async () => {
349+
vi.mocked(SchemaRegistry.getObject).mockImplementation((name) => {
350+
if (name === 'ticket') return {
351+
name: 'ticket',
352+
fields: {
353+
title: { type: 'text' },
354+
status: { type: 'text', defaultValue: 'planned' },
355+
},
356+
} as any;
357+
return undefined;
358+
});
359+
360+
await engine.insert('ticket', { title: 'T1', status: '' });
361+
362+
const arg = (mockDriver.create as any).mock.calls.at(-1)[1];
363+
expect(arg.status).toBe('');
364+
});
365+
322366
it('resolves field defaults BEFORE beforeInsert so a hook can derive from them (#2703)', async () => {
323367
vi.mocked(SchemaRegistry.getObject).mockImplementation((name) => {
324368
if (name === 'ticket') return {

‎packages/objectql/src/engine.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -779,6 +779,14 @@ export class ObjectQL implements IDataEngine {
779779
* defaults are applied verbatim. Records that already supplied a value for a
780780
* field are left untouched.
781781
*
782+
* "Supplied a value" means the field is present with a non-null value. Both an
783+
* OMITTED field (`undefined`) and an EXPLICIT `null` are treated as "not
784+
* supplied" and get the default. This runs on the INSERT path only, where a
785+
* `null` from a form (an unpicked control serializes to `null`, not omission)
786+
* unambiguously means "no value"; the UPDATE path never calls this, so a
787+
* deliberate "set to null" on update is preserved (#2706). Empty string `''`
788+
* is a real, user-entered value and is left as-is.
789+
*
782790
* Implements ROADMAP §M9.9b — `defaultValue` accepts Expression so authors
783791
* can replace "write a hook to default to today/current-user" with a
784792
* declarative `defaultValue: cel\`today()\``.
@@ -799,7 +807,11 @@ export class ObjectQL implements IDataEngine {
799807
const out = { ...record };
800808
const now = nowSnapshot ?? new Date();
801809
for (const f of fieldEntries) {
802-
if (out[f.name] !== undefined) continue;
810+
// Apply the default when the field is either OMITTED (`undefined`) or an
811+
// EXPLICIT `null` — both mean "no value supplied" on insert (#2706). A
812+
// real value (including `''`) is respected. Insert-only path, so an
813+
// intentional "set to null" on update is never touched here.
814+
if (out[f.name] != null) continue;
803815
if (f.defaultValue == null) continue;
804816
const dv = f.defaultValue;
805817
if (typeof dv === 'object' && dv !== null && (dv as any).dialect && typeof (dv as any).source === 'string') {

0 commit comments

Comments
 (0)