diff --git a/.changeset/objectql-defaults-before-beforeinsert-2703.md b/.changeset/objectql-defaults-before-beforeinsert-2703.md new file mode 100644 index 0000000000..c1b6ee2e5a --- /dev/null +++ b/.changeset/objectql-defaults-before-beforeinsert-2703.md @@ -0,0 +1,28 @@ +--- +'@objectstack/objectql': patch +--- + +fix(data): resolve field `defaultValue`s BEFORE the `beforeInsert` hook (#2703) + +Declarative field defaults (including the `current_user` token) were resolved +by `applyFieldDefaults` *after* the user `beforeInsert` hook ran. A hook that +DERIVED one field from another therefore read a stale `null` for any field that +was about to be defaulted — e.g. `sales_person: Field.user({ defaultValue: +'current_user' })` left `sales_person == null` inside the hook, so a derived +`current_status` computed to `unassigned` unless the client passed the field +explicitly. + +`applyFieldDefaults` now runs at record-initialization time, before +`beforeInsert`, matching the industry-standard order of execution (Salesforce +field defaults / ServiceNow dictionary defaults are populated before before- +triggers; engine-owned generation — autonumber sequences, encryption, timestamps +— stays after the hook). The hook still has final say: it runs after and may +override any defaulted field. Defaults still only fill fields left `undefined`, +so client-supplied values are untouched, and the caller's input object is no +longer mutated in place. + +Behavior note: a `beforeInsert` hook can no longer distinguish "client omitted +field X" from "field X received its default" for fields that declare a +`defaultValue` — the hook now always sees the resolved default. This matches how +Salesforce/ServiceNow behave (before logic sees a fully-initialized record) and +is the intended fix. diff --git a/packages/objectql/src/engine.test.ts b/packages/objectql/src/engine.test.ts index 09c02a786b..7535efbb01 100644 --- a/packages/objectql/src/engine.test.ts +++ b/packages/objectql/src/engine.test.ts @@ -319,6 +319,36 @@ describe('ObjectQL Engine', () => { expect(arg.owner).toBeUndefined(); }); + 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 { + name: 'ticket', + fields: { + title: { type: 'text' }, + owner: { type: 'user', reference: 'sys_user', defaultValue: 'current_user' }, + current_status: { type: 'text' }, + }, + } as any; + if (name === 'sys_user') return { name: 'sys_user', fields: { name: { type: 'text' } } } as any; + return undefined; + }); + + // A beforeInsert hook that DERIVES `current_status` from the defaulted + // `owner` field — the exact os-tianshun-mtc#29 scenario. + engine.registerHook('beforeInsert', async (ctx: any) => { + const data = ctx.input.data; + data.current_status = data.owner ? 'assigned' : 'unassigned'; + }, { object: 'ticket' }); + + await engine.insert('ticket', { title: 'T1' }, { context: { userId: 'u-42' } as any }); + + expect(mockDriver.create).toHaveBeenCalledWith( + 'ticket', + expect.objectContaining({ title: 'T1', owner: 'u-42', current_status: 'assigned' }), + expect.anything(), + ); + }); + it('should execute find operation', async () => { const result = await engine.find('task', {}); expect(mockDriver.find).toHaveBeenCalled(); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index aeea424525..9f88aaebec 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -2131,10 +2131,23 @@ export class ObjectQL implements IDataEngine { }; await this.executeWithMiddleware(opCtx, async () => { + // Resolve field `defaultValue`s (including the `current_user` token) + // BEFORE the beforeInsert hook runs, so a hook that DERIVES one field + // from another can read the defaulted value instead of a stale `null` + // (#2703). The hook still has final say — it runs after and may override + // any defaulted field. `applyFieldDefaults` returns a fresh copy and only + // fills fields left `undefined`, so client-supplied values are untouched. + const nowSnap = new Date(); + const defaultedData = Array.isArray(opCtx.data) + ? (opCtx.data as any[]).map((row) => + this.applyFieldDefaults(object, row as Record, opCtx.context, nowSnap), + ) + : this.applyFieldDefaults(object, opCtx.data as Record, opCtx.context, nowSnap); + const hookContext: HookContext = { object, event: 'beforeInsert', - input: { data: opCtx.data, options: opCtx.options }, + input: { data: defaultedData, options: opCtx.options }, session: this.buildSession(opCtx.context), api: this.buildHookApi(opCtx.context), transaction: opCtx.context?.transaction, @@ -2150,16 +2163,14 @@ export class ObjectQL implements IDataEngine { try { let result; - const nowSnap = new Date(); const schemaForValidation = this._registry.getObject(object); // When the driver generates autonumbers natively (persistent SQL // sequence), the engine defers to it — see #1603. const driverOwnsAutonumber = (driver as any)?.supports?.autonumber === true; if (Array.isArray(hookContext.input.data)) { - // Bulk Create — apply defaults per row - const rows = (hookContext.input.data as any[]).map((row) => - this.applyFieldDefaults(object, row as Record, opCtx.context, nowSnap), - ); + // Defaults are already resolved above (pre-hook, #2703); the hook may + // have overridden or added fields — take its data as-is. + const rows = hookContext.input.data as Array>; for (const r of rows) { await this.applyAutonumbers(object, r as Record, opCtx.context, driverOwnsAutonumber); } @@ -2178,12 +2189,8 @@ export class ObjectQL implements IDataEngine { result = await Promise.all(rows.map((item) => driver.create(object, item, hookContext.input.options as any))); } } else { - const row = this.applyFieldDefaults( - object, - hookContext.input.data as Record, - opCtx.context, - nowSnap, - ); + // Defaults already resolved pre-hook (#2703); use the hook's data. + const row = hookContext.input.data as Record; await this.applyAutonumbers(object, row, opCtx.context, driverOwnsAutonumber); await this.encryptSecretFields(object, row, opCtx.context, hookContext.input.options); normalizeMultiValueFields(schemaForValidation, row);