Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
fix: handle relations
  • Loading branch information
sanny-io committed Mar 9, 2026
commit d3b50321a7d20caa65ef0b6d31cfd6a2c3341179
46 changes: 34 additions & 12 deletions packages/orm/src/client/crud/operations/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1163,21 +1163,20 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
if (typeof fieldDef.updatedAt === 'object') {
if (fieldDef.updatedAt.ignore) {
const ignoredFields = new Set(fieldDef.updatedAt.ignore);
const hasNonIgnoredFields = Object.keys(data).some(
(field) =>
(isScalarField(this.schema, modelDef.name, field) ||
isForeignKeyField(this.schema, modelDef.name, field)) &&
!ignoredFields.has(field),
);
const hasNonIgnoredFields = Object.keys(data).some((field) => {
const effectiveFields = this.getUpdatedAtEffectiveFields(modelDef.name, field);
return (
effectiveFields.length > 0 &&
!effectiveFields.some((f) => ignoredFields.has(f))
);
});
hasUpdated = hasNonIgnoredFields;
} else if (fieldDef.updatedAt.fields) {
const targetFields = new Set(fieldDef.updatedAt.fields);
const hasAnyTargetFields = Object.keys(data).some(
(field) =>
(isScalarField(this.schema, modelDef.name, field) ||
isForeignKeyField(this.schema, modelDef.name, field)) &&
targetFields.has(field),
);
const hasAnyTargetFields = Object.keys(data).some((field) => {
const effectiveFields = this.getUpdatedAtEffectiveFields(modelDef.name, field);
return effectiveFields.some((f) => targetFields.has(f));
});
Comment on lines +1166 to +1179
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't treat every owned relation payload as an FK change.

Line 1540 expands any owned relation key to [relation, ...relation.fields], so Line 1176 will also match @updatedAt(fields: [userId]) for a payload like data: { title: 'x', user: { update: { name: 'y' } } }. That only mutates the related row, but the title write gives the parent row a real update so the timestamp bump gets persisted anyway. Please derive the effective fields from operations that actually produce parentUpdates/owned-FK writes, or move this decision after relation processing. A regression test for nested relation update and the matched-upsert path would lock this down.

Also applies to: 1540-1554

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/orm/src/client/crud/operations/base.ts` around lines 1166 - 1179,
The current updatedAt detection treats any owned relation payload as if it
updates the parent FK because getUpdatedAtEffectiveFields is called for every
key in data; change the logic in the hasNonIgnoredFields/hasAnyTargetFields
branches so you only expand relation keys into effective fields when the
relation operation will actually produce parentUpdates/owned-FK writes (i.e.,
detect operation types like connect/disconnect/set/upsert/create that mutate the
FK or move this entire updatedAt decision until after relation processing
completes), update the code paths around getUpdatedAtEffectiveFields,
fieldDef.updatedAt.fields and the hasUpdated assignment accordingly, and add a
regression test for nested relation update and the upsert path to ensure nested
update payloads (e.g., data: { user: { update: { ... } } }) do not falsely
trigger FK-based updatedAt logic.

hasUpdated = hasAnyTargetFields;
}
}
Expand Down Expand Up @@ -1532,6 +1531,29 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
return NUMERIC_FIELD_TYPES.includes(fieldDef.type) && !fieldDef.array;
}

private getUpdatedAtEffectiveFields(model: string, field: string): string[] {
const fieldDef = this.getField(model, field);
if (!fieldDef) {
return [];
}

if (fieldDef.relation) {
if (fieldDef.relation.fields) {
// owned relation
return [field, ...fieldDef.relation.fields];
}
// non-owned relation
return [];
}

if (fieldDef.foreignKeyFor) {
return [field, ...fieldDef.foreignKeyFor];
}

// scalar
return [field];
}

private makeContextComment(_context: { model: string; operation: CRUD }) {
return sql``;
// return sql.raw(`${CONTEXT_COMMENT_PREFIX}${JSON.stringify(context)}`);
Expand Down
188 changes: 188 additions & 0 deletions tests/e2e/orm/client-api/updated-at.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,4 +252,192 @@ describe('@updatedAt attribute', () => {
expect(updatedUser2.exceptMajorFieldUpdatedAt.getTime()).toEqual(updatedUser1.exceptMajorFieldUpdatedAt.getTime());
});
});

describe('fields arg with relations', () => {
const schema = `
model User {
id String @id @default(uuid())
name String
posts Post[]
}

model Post {
id String @id @default(uuid())
title String
content String @default('default content')
userId String?
user User? @relation(fields: [userId], references: [id])

userUpdatedAt DateTime @updatedAt(fields: [user])
userIdUpdatedAt DateTime @updatedAt(fields: [userId])
titleUpdatedAt DateTime @updatedAt(fields: [title])
anyUpdatedAt DateTime @updatedAt
}
`;

it('updates when relation connect matches fields: [relationName]', async () => {
const client = await createTestClient(schema);
const user = await client.user.create({ data: { name: 'Alice' } });
const post = await client.post.create({ data: { title: 'Post 1' } });

const userUpdatedAt = post.userUpdatedAt;
const userIdUpdatedAt = post.userIdUpdatedAt;
const titleUpdatedAt = post.titleUpdatedAt;
const anyUpdatedAt = post.anyUpdatedAt;

await client.post.update({
where: { id: post.id },
data: { user: { connect: { id: user.id } } },
});

const updatedPost = await client.post.findUnique({ where: { id: post.id } });

expect(updatedPost.userUpdatedAt.getTime()).toBeGreaterThan(userUpdatedAt.getTime());
expect(updatedPost.userIdUpdatedAt.getTime()).toBeGreaterThan(userIdUpdatedAt.getTime());
expect(updatedPost.titleUpdatedAt.getTime()).toEqual(titleUpdatedAt.getTime());
expect(updatedPost.anyUpdatedAt.getTime()).toBeGreaterThan(anyUpdatedAt.getTime());
});

it('updates when relation disconnect matches fields: [relationName]', async () => {
const client = await createTestClient(schema);
const user = await client.user.create({ data: { name: 'Alice' } });
const post = await client.post.create({
data: { title: 'Post 1', user: { connect: { id: user.id } } },
});

const userUpdatedAt = post.userUpdatedAt;
const titleUpdatedAt = post.titleUpdatedAt;

await client.post.update({
where: { id: post.id },
data: { user: { disconnect: true } },
});

const updatedPost = await client.post.findUnique({ where: { id: post.id } });

expect(updatedPost.userUpdatedAt.getTime()).toBeGreaterThan(userUpdatedAt.getTime());
expect(updatedPost.titleUpdatedAt.getTime()).toEqual(titleUpdatedAt.getTime());
});

it('updates fields: [relationName] when FK is set directly', async () => {
const client = await createTestClient(schema);
const user = await client.user.create({ data: { name: 'Alice' } });
const post = await client.post.create({ data: { title: 'Post 1' } });

const userUpdatedAt = post.userUpdatedAt;
const titleUpdatedAt = post.titleUpdatedAt;

await client.post.update({
where: { id: post.id },
data: { userId: user.id },
});

const updatedPost = await client.post.findUnique({ where: { id: post.id } });

expect(updatedPost.userUpdatedAt.getTime()).toBeGreaterThan(userUpdatedAt.getTime());
expect(updatedPost.titleUpdatedAt.getTime()).toEqual(titleUpdatedAt.getTime());
});

it('does not update when unrelated relation changes', async () => {
const client = await createTestClient(schema);
const post = await client.post.create({ data: { title: 'Post 1' } });

const userUpdatedAt = post.userUpdatedAt;
const userIdUpdatedAt = post.userIdUpdatedAt;
const titleUpdatedAt = post.titleUpdatedAt;

await client.post.update({
where: { id: post.id },
data: { title: 'Updated title' },
});

const updatedPost = await client.post.findUnique({ where: { id: post.id } });

expect(updatedPost.userUpdatedAt.getTime()).toEqual(userUpdatedAt.getTime());
expect(updatedPost.userIdUpdatedAt.getTime()).toEqual(userIdUpdatedAt.getTime());
expect(updatedPost.titleUpdatedAt.getTime()).toBeGreaterThan(titleUpdatedAt.getTime());
});
});

describe('ignore arg with relations', () => {
const schema = `
model User {
id String @id @default(uuid())
name String
posts Post[]
}

model Post {
id String @id @default(uuid())
title String
content String @default('default content')
userId String?
user User? @relation(fields: [userId], references: [id])

ignoreUserUpdatedAt DateTime @updatedAt(ignore: [user])
ignoreUserIdUpdatedAt DateTime @updatedAt(ignore: [userId])
anyUpdatedAt DateTime @updatedAt
}
`;

it('does not update when only ignored relation changes via connect', async () => {
const client = await createTestClient(schema);
const user = await client.user.create({ data: { name: 'Alice' } });
const post = await client.post.create({ data: { title: 'Post 1' } });

const ignoreUserUpdatedAt = post.ignoreUserUpdatedAt;
const ignoreUserIdUpdatedAt = post.ignoreUserIdUpdatedAt;

await client.post.update({
where: { id: post.id },
data: { user: { connect: { id: user.id } } },
});

const updatedPost = await client.post.findUnique({ where: { id: post.id } });

expect(updatedPost.ignoreUserUpdatedAt.getTime()).toEqual(ignoreUserUpdatedAt.getTime());
expect(updatedPost.ignoreUserIdUpdatedAt.getTime()).toEqual(ignoreUserIdUpdatedAt.getTime());
});

it('updates when non-ignored fields change alongside ignored relation', async () => {
const client = await createTestClient(schema);
const user = await client.user.create({ data: { name: 'Alice' } });
const post = await client.post.create({ data: { title: 'Post 1' } });

const ignoreUserUpdatedAt = post.ignoreUserUpdatedAt;
const anyUpdatedAt = post.anyUpdatedAt;

await client.post.update({
where: { id: post.id },
data: {
title: 'Updated title',
user: { connect: { id: user.id } },
},
});

const updatedPost = await client.post.findUnique({ where: { id: post.id } });

expect(updatedPost.ignoreUserUpdatedAt.getTime()).toBeGreaterThan(ignoreUserUpdatedAt.getTime());
expect(updatedPost.anyUpdatedAt.getTime()).toBeGreaterThan(anyUpdatedAt.getTime());
});

it('does not update when only ignored FK is set directly', async () => {
const client = await createTestClient(schema);
const user = await client.user.create({ data: { name: 'Alice' } });
const post = await client.post.create({ data: { title: 'Post 1' } });

const ignoreUserUpdatedAt = post.ignoreUserUpdatedAt;
const ignoreUserIdUpdatedAt = post.ignoreUserIdUpdatedAt;

await client.post.update({
where: { id: post.id },
data: { userId: user.id },
});

const updatedPost = await client.post.findUnique({ where: { id: post.id } });

expect(updatedPost.ignoreUserUpdatedAt.getTime()).toEqual(ignoreUserUpdatedAt.getTime());
expect(updatedPost.ignoreUserIdUpdatedAt.getTime()).toEqual(ignoreUserIdUpdatedAt.getTime());
});
});
});
Loading