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
10 changes: 6 additions & 4 deletions packages/orm/src/client/crud-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1900,7 +1900,8 @@ type NestedUpdateInput<
Field extends RelationFields<Schema, Model>,
> =
FieldIsArray<Schema, Model, Field> extends true
? OrArray<
? // to-many
OrArray<
{
/**
* Unique filter to select the record to update.
Expand All @@ -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<Schema, RelationFieldType<Schema, Model, Field>>;
where?: WhereInput<Schema, RelationFieldType<Schema, Model, Field>>;

/**
* The data to update the record with.
Expand Down
45 changes: 28 additions & 17 deletions packages/orm/src/client/crud/operations/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -961,9 +961,20 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
}
}

// 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;
Expand Down Expand Up @@ -997,10 +1008,18 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
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);
Expand All @@ -1010,24 +1029,13 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
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,
field,
fieldDef,
thisEntity,
finalData[field],
throwIfNotFound,
);

if (Object.keys(parentUpdates).length > 0) {
Expand All @@ -1044,8 +1052,8 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
}

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
Expand Down Expand Up @@ -1347,7 +1355,6 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
fieldDef: FieldDef,
parentIds: any,
args: any,
throwIfNotFound: boolean,
) {
const fieldModel = fieldDef.type as GetModels<Schema>;
const fromRelationContext: FromRelationContext = {
Expand Down Expand Up @@ -1415,6 +1422,10 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
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;
Expand Down
4 changes: 2 additions & 2 deletions packages/orm/src/client/crud/validator/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1187,15 +1187,15 @@ export class InputValidator<Schema extends SchemaDef> {
fields['update'] = array
? this.orArray(
z.strictObject({
where: this.makeWhereSchema(fieldType, true).optional(),
where: this.makeWhereSchema(fieldType, true),
data: this.makeUpdateDataSchema(fieldType, withoutFields),
}),
true,
).optional()
: 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),
Expand Down
111 changes: 110 additions & 1 deletion tests/e2e/orm/client-api/upsert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ describe('Client upsert tests', () => {
let client: ClientContract<typeof schema>;

beforeEach(async () => {
client = (await createTestClient(schema)) as any;
client = await createTestClient(schema);
});

afterEach(async () => {
Expand Down Expand Up @@ -69,4 +69,113 @@ describe('Client upsert tests', () => {
email: '[email protected]',
});
});

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: '[email protected]',
name: 'John',
},
update: {},
select: { id: true, email: true, name: true },
});

expect(created).toMatchObject({
id: '1',
email: '[email protected]',
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: '[email protected]',
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: '[email protected]',
name: 'Jane',
},
update: {},
select: { id: true, email: true, name: true },
});

expect(result).toMatchObject({
id: '1',
email: '[email protected]',
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: '[email protected]',
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();
});
});
9 changes: 5 additions & 4 deletions tests/e2e/orm/policy/crud/read.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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 },
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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 },
Expand Down
39 changes: 26 additions & 13 deletions tests/e2e/orm/policy/migrated/nested-to-many.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -668,48 +668,61 @@ 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' },
include: { m3: true, m2: true },
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 },
data: {
m2: {
update: {
where: { id: '1' },
data: { value: 2 },
data: { value: 3 },
},
},
},
Expand Down
4 changes: 3 additions & 1 deletion tests/e2e/orm/policy/migrated/nested-to-one.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand All @@ -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 () => {
Expand Down