diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index 29a895ce2..65e07c1ca 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -1900,7 +1900,8 @@ type NestedUpdateInput< Field extends RelationFields, > = FieldIsArray extends true - ? OrArray< + ? // to-many + OrArray< { /** * Unique filter to select the record to update. @@ -1918,12 +1919,13 @@ type NestedUpdateInput< }, true > - : XOR< + : // to-one + XOR< { /** - * Unique filter to select the record to update. + * Filter to select the record to update. */ - where: WhereUniqueInput>; + where?: WhereInput>; /** * The data to update the record with. diff --git a/packages/orm/src/client/crud/operations/base.ts b/packages/orm/src/client/crud/operations/base.ts index f17542b7e..0ac2fe8c5 100644 --- a/packages/orm/src/client/crud/operations/base.ts +++ b/packages/orm/src/client/crud/operations/base.ts @@ -961,9 +961,20 @@ export abstract class BaseOperationHandler { } } + // read pre-update entity with ids so that the caller can use it to identify + // the entity being updated, the read data is used as return value if no update + // is made to the entity + const thisEntity = await this.getEntityIds(kysely, model, combinedWhere); + if (!thisEntity) { + if (throwIfNotFound) { + throw createNotFoundError(model); + } else { + return null; + } + } + if (Object.keys(finalData).length === 0) { - // nothing to update, return the original filter so that caller can identify the entity - return combinedWhere; + return thisEntity; } let needIdRead = false; @@ -997,10 +1008,18 @@ export abstract class BaseOperationHandler { finalData = baseUpdateResult.remainingFields; // base update may change entity ids, update the filter combinedWhere = baseUpdateResult.baseEntity; + + // update this entity with fields in updated base + if (baseUpdateResult.baseEntity) { + for (const [key, value] of Object.entries(baseUpdateResult.baseEntity)) { + if (key in thisEntity) { + thisEntity[key] = value; + } + } + } } const updateFields: any = {}; - let thisEntity: any = undefined; for (const field in finalData) { const fieldDef = this.requireField(model, field); @@ -1010,16 +1029,6 @@ export abstract class BaseOperationHandler { if (!allowRelationUpdate) { throw createNotSupportedError(`Relation update not allowed for field "${field}"`); } - if (!thisEntity) { - thisEntity = await this.getEntityIds(kysely, model, combinedWhere); - if (!thisEntity) { - if (throwIfNotFound) { - throw createNotFoundError(model); - } else { - return null; - } - } - } const parentUpdates = await this.processRelationUpdates( kysely, model, @@ -1027,7 +1036,6 @@ export abstract class BaseOperationHandler { fieldDef, thisEntity, finalData[field], - throwIfNotFound, ); if (Object.keys(parentUpdates).length > 0) { @@ -1044,8 +1052,8 @@ export abstract class BaseOperationHandler { } if (!hasFieldUpdate) { - // nothing to update, return the filter so that the caller can identify the entity - return combinedWhere; + // nothing to update, return the existing entity + return thisEntity; } else { fieldsToReturn = fieldsToReturn ?? requireIdFields(this.schema, model); const query = kysely @@ -1347,7 +1355,6 @@ export abstract class BaseOperationHandler { fieldDef: FieldDef, parentIds: any, args: any, - throwIfNotFound: boolean, ) { const fieldModel = fieldDef.type as GetModels; const fromRelationContext: FromRelationContext = { @@ -1415,6 +1422,10 @@ export abstract class BaseOperationHandler { where = undefined; data = item; } + // update should throw if: + // - to-many: there's a where clause and no entity is found + // - to-one: always throw if no entity is found + const throwIfNotFound = !fieldDef.array || !!where; await this.update(kysely, fieldModel, where, data, fromRelationContext, true, throwIfNotFound); } break; diff --git a/packages/orm/src/client/crud/validator/index.ts b/packages/orm/src/client/crud/validator/index.ts index 278efde0f..dc1a6f836 100644 --- a/packages/orm/src/client/crud/validator/index.ts +++ b/packages/orm/src/client/crud/validator/index.ts @@ -1187,7 +1187,7 @@ export class InputValidator { fields['update'] = array ? this.orArray( z.strictObject({ - where: this.makeWhereSchema(fieldType, true).optional(), + where: this.makeWhereSchema(fieldType, true), data: this.makeUpdateDataSchema(fieldType, withoutFields), }), true, @@ -1195,7 +1195,7 @@ export class InputValidator { : z .union([ z.strictObject({ - where: this.makeWhereSchema(fieldType, true).optional(), + where: this.makeWhereSchema(fieldType, false).optional(), data: this.makeUpdateDataSchema(fieldType, withoutFields), }), this.makeUpdateDataSchema(fieldType, withoutFields), diff --git a/tests/e2e/orm/client-api/upsert.test.ts b/tests/e2e/orm/client-api/upsert.test.ts index eba4df788..6a20de4f8 100644 --- a/tests/e2e/orm/client-api/upsert.test.ts +++ b/tests/e2e/orm/client-api/upsert.test.ts @@ -7,7 +7,7 @@ describe('Client upsert tests', () => { let client: ClientContract; beforeEach(async () => { - client = (await createTestClient(schema)) as any; + client = await createTestClient(schema); }); afterEach(async () => { @@ -69,4 +69,113 @@ describe('Client upsert tests', () => { email: 'u1@test.com', }); }); + + it('works with upsert with empty update payload', async () => { + // Test 1: Upsert with empty update should create new record when it doesn't exist + const created = await client.user.upsert({ + where: { id: '1' }, + create: { + id: '1', + email: 'u1@test.com', + name: 'John', + }, + update: {}, + select: { id: true, email: true, name: true }, + }); + + expect(created).toMatchObject({ + id: '1', + email: 'u1@test.com', + name: 'John', + }); + + // Verify the record was created + const fetchedAfterCreate = await client.user.findUnique({ + where: { id: '1' }, + select: { id: true, email: true, name: true }, + }); + + expect(fetchedAfterCreate).toMatchObject({ + id: '1', + email: 'u1@test.com', + name: 'John', + }); + + // Test 2: Upsert with empty update should return existing record unchanged + const result = await client.user.upsert({ + where: { id: '1' }, + create: { + id: '1', + email: 'u1@test.com', + name: 'Jane', + }, + update: {}, + select: { id: true, email: true, name: true }, + }); + + expect(result).toMatchObject({ + id: '1', + email: 'u1@test.com', + name: 'John', // Should remain unchanged + }); + + // Verify the record was not modified + const fetched = await client.user.findUnique({ + where: { id: '1' }, + select: { id: true, email: true, name: true }, + }); + + expect(fetched).toMatchObject({ + id: '1', + email: 'u1@test.com', + name: 'John', + }); + }); + + it('works with upsert with empty create payload', async () => { + const db = await createTestClient( + ` +model User { + id String @id @default(cuid()) + name String? +} + `, + { dbName: 'orm_upsert_empty_create' }, + ); + + // Test 1: First upsert should create the entity with empty data + const created = await db.user.upsert({ + where: { id: '1' }, + create: {}, + update: { name: 'Updated' }, + }); + + expect(created).toBeTruthy(); + + // Verify the record was created + await expect(db.user.findFirst()).resolves.toMatchObject(created); + + // Test 2: Second upsert should update the existing entity + const updated = await db.user.upsert({ + where: { id: created.id }, + create: {}, + update: { name: 'Updated' }, + }); + + expect(updated).toMatchObject({ + id: created.id, + name: 'Updated', + }); + + // Verify the record was updated + await expect( + db.user.findUnique({ + where: { id: created.id }, + }), + ).resolves.toMatchObject({ + name: 'Updated', + }); + + await db.$disconnect(); + }); }); diff --git a/tests/e2e/orm/policy/crud/read.test.ts b/tests/e2e/orm/policy/crud/read.test.ts index 652e9f1e1..fd767b519 100644 --- a/tests/e2e/orm/policy/crud/read.test.ts +++ b/tests/e2e/orm/policy/crud/read.test.ts @@ -36,7 +36,8 @@ model Foo { await expect(db.foo.create({ data: { id: 1, x: 0 } })).toBeRejectedByPolicy(); await expect(db.$unuseAll().foo.count()).resolves.toBe(1); - await expect(db.foo.update({ where: { id: 1 }, data: { x: 1 } })).resolves.toMatchObject({ x: 1 }); + await db.$unuseAll().foo.update({ where: { id: 1 }, data: { x: 1 } }); + await expect(db.foo.update({ where: { id: 1 }, data: { x: 2 } })).resolves.toMatchObject({ x: 2 }); }); it('works with to-one relation optional owner-side read', async () => { @@ -61,7 +62,7 @@ model Bar { await db.foo.create({ data: { id: 1, bar: { create: { id: 1, y: 0 } } } }); await expect(db.foo.findFirst({ include: { bar: true } })).resolves.toMatchObject({ id: 1, bar: null }); - await db.bar.update({ where: { id: 1 }, data: { y: 1 } }); + await db.$unuseAll().bar.update({ where: { id: 1 }, data: { y: 1 } }); await expect(db.foo.findFirst({ include: { bar: true } })).resolves.toMatchObject({ id: 1, bar: { id: 1 }, @@ -92,7 +93,7 @@ model Bar { await db.foo.create({ data: { id: 1, bar: { create: { id: 1, y: 0 } } } }); await expect(db.foo.findFirst({ include: { bar: true } })).resolves.toMatchObject({ id: 1, bar: null }); - await db.bar.update({ where: { id: 1 }, data: { y: 1 } }); + await db.$unuseAll().bar.update({ where: { id: 1 }, data: { y: 1 } }); await expect(db.foo.findFirst({ include: { bar: true } })).resolves.toMatchObject({ id: 1, bar: { id: 1 }, @@ -121,7 +122,7 @@ model Bar { await db.foo.create({ data: { id: 1, bar: { create: { id: 1, y: 0 } } } }); await expect(db.foo.findFirst({ include: { bar: true } })).resolves.toMatchObject({ id: 1, bar: null }); - await db.bar.update({ where: { id: 1 }, data: { y: 1 } }); + await db.$unuseAll().bar.update({ where: { id: 1 }, data: { y: 1 } }); await expect(db.foo.findFirst({ include: { bar: true } })).resolves.toMatchObject({ id: 1, bar: { id: 1 }, diff --git a/tests/e2e/orm/policy/migrated/nested-to-many.test.ts b/tests/e2e/orm/policy/migrated/nested-to-many.test.ts index e6402eb3c..290b19236 100644 --- a/tests/e2e/orm/policy/migrated/nested-to-many.test.ts +++ b/tests/e2e/orm/policy/migrated/nested-to-many.test.ts @@ -668,23 +668,30 @@ describe('Policy tests to-many', () => { ], }, m3: { - create: { value: 0 }, + create: { id: 'm3', value: 0 }, }, }, }); - const r = await db.m1.update({ - where: { id: '1' }, - include: { m3: true }, - data: { - m3: { - update: { - value: 1, + await expect( + db.m1.update({ + where: { id: '1' }, + include: { m3: true }, + data: { + m3: { + update: { + value: 1, + }, }, }, - }, + }), + ).toBeRejectedNotFound(); + + // make m3 readable + await db.$unuseAll().m3.update({ + where: { id: 'm3' }, + data: { value: 2 }, }); - expect(r.m3).toBeNull(); const r1 = await db.m1.update({ where: { id: '1' }, @@ -692,16 +699,22 @@ describe('Policy tests to-many', () => { data: { m3: { update: { - value: 2, + value: 3, }, }, }, }); // m3 is ok now - expect(r1.m3.value).toBe(2); + expect(r1.m3.value).toBe(3); // m2 got filtered expect(r1.m2).toHaveLength(0); + // make m2#1 readable + await db.$unuseAll().m2.update({ + where: { id: '1' }, + data: { value: 2 }, + }); + const r2 = await db.m1.update({ where: { id: '1' }, select: { m2: true }, @@ -709,7 +722,7 @@ describe('Policy tests to-many', () => { m2: { update: { where: { id: '1' }, - data: { value: 2 }, + data: { value: 3 }, }, }, }, diff --git a/tests/e2e/orm/policy/migrated/nested-to-one.test.ts b/tests/e2e/orm/policy/migrated/nested-to-one.test.ts index 1db225698..f839336cb 100644 --- a/tests/e2e/orm/policy/migrated/nested-to-one.test.ts +++ b/tests/e2e/orm/policy/migrated/nested-to-one.test.ts @@ -184,7 +184,7 @@ describe('With Policy:nested to-one', () => { }), ).toResolveTruthy(); - // nested update denied + // nested update to m2 is filtered await expect( db.m1.update({ where: { id: '1' }, @@ -195,6 +195,8 @@ describe('With Policy:nested to-one', () => { }, }), ).toBeRejectedNotFound(); + + await expect(db.m2.findFirst()).resolves.toMatchObject({ value: 1 }); }); it('nested update id tests', async () => {