From 0451547feb188f32ba27a6985b8123fb7c2de3ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Sun, 12 Jul 2026 22:55:10 -0700 Subject: [PATCH] fix(objectql): apply defaultValue on explicit null at insert (#2706) --- .changeset/objectql-default-explicit-null.md | 13 ++++++ packages/objectql/src/engine.test.ts | 44 ++++++++++++++++++++ packages/objectql/src/engine.ts | 14 ++++++- 3 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 .changeset/objectql-default-explicit-null.md diff --git a/.changeset/objectql-default-explicit-null.md b/.changeset/objectql-default-explicit-null.md new file mode 100644 index 0000000000..3ceda63d57 --- /dev/null +++ b/.changeset/objectql-default-explicit-null.md @@ -0,0 +1,13 @@ +--- +"@objectstack/objectql": patch +--- + +fix(objectql): apply field `defaultValue` when a field is explicitly `null` on insert, not only when omitted (#2706) + +`applyFieldDefaults` previously skipped any field whose value was not +`undefined`, so a form that serialized an unpicked control as `null` (rather +than omitting it) fell through and stored `null` — the `current_user` token and +static defaults never filled in. Both an omitted field and an explicit `null` +now count as "no value supplied" and receive the default. This runs on the +insert path only, so a deliberate "set to null" on update is untouched; an +explicit empty string `''` is still respected as a real value. diff --git a/packages/objectql/src/engine.test.ts b/packages/objectql/src/engine.test.ts index 7535efbb01..01b85f9664 100644 --- a/packages/objectql/src/engine.test.ts +++ b/packages/objectql/src/engine.test.ts @@ -319,6 +319,50 @@ describe('ObjectQL Engine', () => { expect(arg.owner).toBeUndefined(); }); + it('backfills a default when the field is EXPLICITLY null, not just omitted (#2706)', async () => { + vi.mocked(SchemaRegistry.getObject).mockImplementation((name) => { + if (name === 'ticket') return { + name: 'ticket', + fields: { + title: { type: 'text' }, + owner: { type: 'user', reference: 'sys_user', defaultValue: 'current_user' }, + status: { type: 'text', defaultValue: 'planned' }, + }, + } as any; + if (name === 'sys_user') return { name: 'sys_user', fields: { name: { type: 'text' } } } as any; + return undefined; + }); + + // A form serializes an unpicked control as explicit `null` (not + // omission) — both the dynamic `current_user` token and a static + // default must still fill in. + await engine.insert('ticket', { title: 'T1', owner: null, status: null }, { context: { userId: 'u-42' } as any }); + + expect(mockDriver.create).toHaveBeenCalledWith( + 'ticket', + expect.objectContaining({ title: 'T1', owner: 'u-42', status: 'planned' }), + expect.anything(), + ); + }); + + it('respects an explicit empty-string value and does not overwrite it with the default (#2706)', async () => { + vi.mocked(SchemaRegistry.getObject).mockImplementation((name) => { + if (name === 'ticket') return { + name: 'ticket', + fields: { + title: { type: 'text' }, + status: { type: 'text', defaultValue: 'planned' }, + }, + } as any; + return undefined; + }); + + await engine.insert('ticket', { title: 'T1', status: '' }); + + const arg = (mockDriver.create as any).mock.calls.at(-1)[1]; + expect(arg.status).toBe(''); + }); + it('resolves field defaults BEFORE beforeInsert so a hook can derive from them (#2703)', async () => { vi.mocked(SchemaRegistry.getObject).mockImplementation((name) => { if (name === 'ticket') return { diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index d68239a851..43e998646f 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -779,6 +779,14 @@ export class ObjectQL implements IDataEngine { * defaults are applied verbatim. Records that already supplied a value for a * field are left untouched. * + * "Supplied a value" means the field is present with a non-null value. Both an + * OMITTED field (`undefined`) and an EXPLICIT `null` are treated as "not + * supplied" and get the default. This runs on the INSERT path only, where a + * `null` from a form (an unpicked control serializes to `null`, not omission) + * unambiguously means "no value"; the UPDATE path never calls this, so a + * deliberate "set to null" on update is preserved (#2706). Empty string `''` + * is a real, user-entered value and is left as-is. + * * Implements ROADMAP §M9.9b — `defaultValue` accepts Expression so authors * can replace "write a hook to default to today/current-user" with a * declarative `defaultValue: cel\`today()\``. @@ -799,7 +807,11 @@ export class ObjectQL implements IDataEngine { const out = { ...record }; const now = nowSnapshot ?? new Date(); for (const f of fieldEntries) { - if (out[f.name] !== undefined) continue; + // Apply the default when the field is either OMITTED (`undefined`) or an + // EXPLICIT `null` — both mean "no value supplied" on insert (#2706). A + // real value (including `''`) is respected. Insert-only path, so an + // intentional "set to null" on update is never touched here. + if (out[f.name] != null) continue; if (f.defaultValue == null) continue; const dv = f.defaultValue; if (typeof dv === 'object' && dv !== null && (dv as any).dialect && typeof (dv as any).source === 'string') {