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
28 changes: 28 additions & 0 deletions .changeset/objectql-defaults-before-beforeinsert-2703.md
Original file line numberDiff line numberDiff line change
@@ -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.
30 changes: 30 additions & 0 deletions packages/objectql/src/engine.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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();
Expand Down
31 changes: 19 additions & 12 deletions packages/objectql/src/engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, unknown>, opCtx.context, nowSnap),
)
: this.applyFieldDefaults(object, opCtx.data as Record<string, unknown>, 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,
Expand All@@ -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<string, unknown>, 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<Record<string, unknown>>;
for (const r of rows) {
await this.applyAutonumbers(object, r as Record<string, unknown>, opCtx.context, driverOwnsAutonumber);
}
Expand All@@ -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<string, unknown>,
opCtx.context,
nowSnap,
);
// Defaults already resolved pre-hook (#2703); use the hook's data.
const row = hookContext.input.data as Record<string, unknown>;
await this.applyAutonumbers(object, row, opCtx.context, driverOwnsAutonumber);
await this.encryptSecretFields(object, row, opCtx.context, hookContext.input.options);
normalizeMultiValueFields(schemaForValidation, row);
Expand Down