Skip to content
This repository was archived by the owner on Mar 1, 2026. It is now read-only.

Commit eff3502

Browse files
Copilotymc9
andauthored
Fix upsert validation to merge $create and $update schemas (#604)
* Initial plan * fix: merge $create and $update schemas for upsert validation - Handle upsert operation specially to match TypeScript type behavior - When both $create and $update schemas exist, merge them for upsert - Add test case to verify the fix works correctly Co-authored-by: ymc9 <104139426+ymc9@users.noreply.github.com> * fix: improve comment accuracy about Zod merge behavior Co-authored-by: ymc9 <104139426+ymc9@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: ymc9 <104139426+ymc9@users.noreply.github.com>
1 parent eda54d3 commit eff3502

File tree

2 files changed

+116
-0
lines changed

2 files changed

+116
-0
lines changed

packages/orm/src/client/crud/validator/index.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,34 @@ export class InputValidator<Schema extends SchemaDef> {
392392
if (operation in plugin.queryArgs && plugin.queryArgs[operation]) {
393393
// most specific operation takes highest precedence
394394
result = plugin.queryArgs[operation];
395+
} else if (operation === 'upsert') {
396+
// upsert is special: it's in both CoreCreateOperations and CoreUpdateOperations
397+
// so we need to merge both $create and $update schemas to match the type system
398+
const createSchema =
399+
'$create' in plugin.queryArgs && plugin.queryArgs['$create']
400+
? plugin.queryArgs['$create']
401+
: undefined;
402+
const updateSchema =
403+
'$update' in plugin.queryArgs && plugin.queryArgs['$update']
404+
? plugin.queryArgs['$update']
405+
: undefined;
406+
407+
if (createSchema && updateSchema) {
408+
invariant(
409+
createSchema instanceof z.ZodObject,
410+
'Plugin extended query args schema must be a Zod object',
411+
);
412+
invariant(
413+
updateSchema instanceof z.ZodObject,
414+
'Plugin extended query args schema must be a Zod object',
415+
);
416+
// merge both schemas (combines their properties)
417+
result = createSchema.merge(updateSchema);
418+
} else if (createSchema) {
419+
result = createSchema;
420+
} else if (updateSchema) {
421+
result = updateSchema;
422+
}
395423
} else if (
396424
// then comes grouped operations: $create, $read, $update, $delete
397425
CoreCreateOperations.includes(operation as CoreCreateOperations) &&

tests/e2e/orm/plugin-infra/ext-query-args.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,4 +230,92 @@ describe('Plugin extended query args', () => {
230230
await expect(db.user.findMany({ cache: { ttl: 2000 } })).rejects.toThrow('Unrecognized key');
231231
await expect(extDb.user.findMany({ cache: { ttl: 2000 } })).toResolveWithLength(0);
232232
});
233+
234+
it('should merge $create and $update schemas for upsert operation', async () => {
235+
// Define different schemas for $create and $update
236+
const createOnlySchema = z.object({
237+
tracking: z
238+
.strictObject({
239+
source: z.string().optional(),
240+
})
241+
.optional(),
242+
});
243+
244+
const updateOnlySchema = z.object({
245+
audit: z
246+
.strictObject({
247+
reason: z.string().optional(),
248+
})
249+
.optional(),
250+
});
251+
252+
const extDb = db.$use(
253+
definePlugin({
254+
id: 'test',
255+
queryArgs: {
256+
$create: createOnlySchema,
257+
$update: updateOnlySchema,
258+
},
259+
}),
260+
);
261+
262+
// upsert should accept both tracking (from $create) and audit (from $update)
263+
await expect(
264+
extDb.user.upsert({
265+
where: { id: 999 },
266+
create: { name: 'Alice' },
267+
update: { name: 'Alice Updated' },
268+
tracking: { source: 'test' },
269+
audit: { reason: 'testing merge' },
270+
}),
271+
).resolves.toMatchObject({ name: 'Alice' });
272+
273+
// upsert should reject tracking-only in update operations
274+
await expect(
275+
extDb.user.update({
276+
where: { id: 1 },
277+
data: { name: 'Test' },
278+
// @ts-expect-error - tracking is only for $create
279+
tracking: { source: 'test' },
280+
}),
281+
).rejects.toThrow('Unrecognized key');
282+
283+
// upsert should reject audit-only in create operations
284+
await expect(
285+
extDb.user.create({
286+
data: { name: 'Bob' },
287+
// @ts-expect-error - audit is only for $update
288+
audit: { reason: 'test' },
289+
}),
290+
).rejects.toThrow('Unrecognized key');
291+
292+
// verify that upsert without both is fine
293+
await expect(
294+
extDb.user.upsert({
295+
where: { id: 888 },
296+
create: { name: 'Charlie' },
297+
update: { name: 'Charlie Updated' },
298+
}),
299+
).resolves.toMatchObject({ name: 'Charlie' });
300+
301+
// verify that upsert with only tracking is fine
302+
await expect(
303+
extDb.user.upsert({
304+
where: { id: 777 },
305+
create: { name: 'David' },
306+
update: { name: 'David Updated' },
307+
tracking: { source: 'test' },
308+
}),
309+
).resolves.toMatchObject({ name: 'David' });
310+
311+
// verify that upsert with only audit is fine
312+
await expect(
313+
extDb.user.upsert({
314+
where: { id: 666 },
315+
create: { name: 'Eve' },
316+
update: { name: 'Eve Updated' },
317+
audit: { reason: 'testing' },
318+
}),
319+
).resolves.toMatchObject({ name: 'Eve' });
320+
});
233321
});

0 commit comments

Comments
 (0)