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
13 changes: 13 additions & 0 deletions .changeset/objectql-default-explicit-null.md
Original file line number Diff line number Diff line change
@@ -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.
44 changes: 44 additions & 0 deletions packages/objectql/src/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
14 changes: 13 additions & 1 deletion packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()\``.
Expand All @@ -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') {
Expand Down