Skip to content
Open
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
284 changes: 283 additions & 1 deletion packages/cli/test/ts-schema-gen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -747,5 +747,287 @@ model Post {
authType: 'User',
plugins: {}
});
})
});

it('supports specifying fields for @updatedAt', async () => {
const { schema } = await generateTsSchema(`
model User {
id String @id @default(uuid())
name String
email String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt(fields: [email])
posts Post[]

@@map('users')
}

model Post {
id String @id @default(cuid())
title String
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
authorId String
}
`);

expect(schema).toMatchObject({
provider: {
type: 'sqlite'
},
models: {
User: {
name: 'User',
fields: {
id: {
name: 'id',
type: 'String',
id: true,
attributes: [
{
name: '@id'
},
{
name: '@default',
args: [
{
name: 'value',
value: {
kind: 'call',
function: 'uuid'
}
}
]
}
],
default: {
kind: 'call',
function: 'uuid'
}
},
name: {
name: 'name',
type: 'String'
},
email: {
name: 'email',
type: 'String',
unique: true,
attributes: [
{
name: '@unique'
}
]
},
createdAt: {
name: 'createdAt',
type: 'DateTime',
attributes: [
{
name: '@default',
args: [
{
name: 'value',
value: {
kind: 'call',
function: 'now'
}
}
]
}
],
default: {
kind: 'call',
function: 'now'
}
},
updatedAt: {
name: 'updatedAt',
type: 'DateTime',
updatedAt: {
fields: [
'email'
]
},
attributes: [
{
name: '@updatedAt',
args: [
{
name: 'fields',
value: {
kind: 'array',
items: [
{
kind: 'field',
field: 'email'
}
]
}
}
]
}
]
},
posts: {
name: 'posts',
type: 'Post',
array: true,
relation: {
opposite: 'author'
}
}
},
attributes: [
{
name: '@@map',
args: [
{
name: 'name',
value: {
kind: 'literal',
value: 'users'
}
}
]
}
],
idFields: [
'id'
],
uniqueFields: {
id: {
type: 'String'
},
email: {
type: 'String'
}
}
},
Post: {
name: 'Post',
fields: {
id: {
name: 'id',
type: 'String',
id: true,
attributes: [
{
name: '@id'
},
{
name: '@default',
args: [
{
name: 'value',
value: {
kind: 'call',
function: 'cuid'
}
}
]
}
],
default: {
kind: 'call',
function: 'cuid'
}
},
title: {
name: 'title',
type: 'String'
},
published: {
name: 'published',
type: 'Boolean',
attributes: [
{
name: '@default',
args: [
{
name: 'value',
value: {
kind: 'literal',
value: false
}
}
]
}
],
default: false
},
author: {
name: 'author',
type: 'User',
attributes: [
{
name: '@relation',
args: [
{
name: 'fields',
value: {
kind: 'array',
items: [
{
kind: 'field',
field: 'authorId'
}
]
}
},
{
name: 'references',
value: {
kind: 'array',
items: [
{
kind: 'field',
field: 'id'
}
]
}
},
{
name: 'onDelete',
value: {
kind: 'literal',
value: 'Cascade'
}
}
]
}
],
relation: {
opposite: 'posts',
fields: [
'authorId'
],
references: [
'id'
],
onDelete: 'Cascade'
}
},
authorId: {
name: 'authorId',
type: 'String',
foreignKeyFor: [
'author'
]
}
},
idFields: [
'id'
],
uniqueFields: {
id: {
type: 'String'
}
}
}
},
authType: 'User',
plugins: {}
});
});
});
8 changes: 6 additions & 2 deletions packages/language/res/stdlib.zmodel
Original file line number Diff line number Diff line change
Expand Up @@ -405,9 +405,13 @@ attribute @omit()
*
* @param ignore: A list of field names that are not considered when the ORM client is determining whether any
* updates have been made to a record. An update that only contains ignored fields does not change the
* timestamp.
* timestamp. Mutually exclusive with the `fields` parameter.

* @param fields: A list of field names that are considered when the ORM client is determining whether any
* updates have been made to a record. The timestamp will only change when any of the specified fields
* are updated. Mutually exclusive with the `ignore` parameter.
*/
attribute @updatedAt(ignore: FieldReference[]?) @@@targetField([DateTimeField]) @@@prisma
attribute @updatedAt(ignore: FieldReference[]?, fields: FieldReference[]?) @@@targetField([DateTimeField]) @@@prisma

/**
* Add full text index (MySQL only).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,15 @@ export default class AttributeApplicationValidator implements AstValidator<Attri
}
}

@check('@updatedAt')
private _checkUpdatedAt(attr: AttributeApplication, accept: ValidationAcceptor) {
const ignoreArg = attr.args.find(arg => arg.$resolvedParam.name === 'ignore');
const fieldsArg = attr.args.find(arg => arg.$resolvedParam.name === 'fields');
if (ignoreArg && fieldsArg) {
accept('error', `\`ignore\` and \`fields\` are mutually exclusive`, { node: attr.$container });
}
}

@check('@@validate')
private _checkValidate(attr: AttributeApplication, accept: ValidationAcceptor) {
const condition = attr.args[0]?.value;
Expand Down
31 changes: 23 additions & 8 deletions packages/orm/src/client/crud/operations/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1149,14 +1149,29 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
const autoUpdatedFields: string[] = [];
for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) {
if (fieldDef.updatedAt && finalData[fieldName] === undefined) {
const ignoredFields = new Set(typeof fieldDef.updatedAt === 'boolean' ? [] : 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),
);
if (hasNonIgnoredFields) {
let hasUpdated = true;
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),
);
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),
);
hasUpdated = hasAnyTargetFields;
}
}
if (hasUpdated) {
if (finalData === data) {
Comment on lines 1151 to 1175
Copy link
Contributor

@coderabbitai coderabbitai bot Feb 12, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

@updatedAt(ignore/fields) can miss relation-driven FK updates.

hasUpdated only inspects scalar/foreign-key fields present in the input data. When a nested relation update changes owned FK fields (e.g., author: { connect: ... }), those FK changes are applied later via parentUpdates, so hasUpdated stays false and updatedAt is not bumped. This is a regression for ignore/fields configurations.

🛠️ Suggested fix (treat owned relation updates as FK updates)
-                        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) => {
+                            if (ignoredFields.has(field)) return false;
+                            if (
+                                isScalarField(this.schema, modelDef.name, field) ||
+                                isForeignKeyField(this.schema, modelDef.name, field)
+                            ) {
+                                return true;
+                            }
+                            const relation = modelDef.fields[field]?.relation;
+                            return relation?.fields?.some((fk) => !ignoredFields.has(fk)) ?? false;
+                        });
 
-                        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) => {
+                            if (targetFields.has(field)) return true;
+                            if (
+                                isScalarField(this.schema, modelDef.name, field) ||
+                                isForeignKeyField(this.schema, modelDef.name, field)
+                            ) {
+                                return false;
+                            }
+                            const relation = modelDef.fields[field]?.relation;
+                            return relation?.fields?.some((fk) => targetFields.has(fk)) ?? false;
+                        });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (fieldDef.updatedAt && finalData[fieldName] === undefined) {
const ignoredFields = new Set(typeof fieldDef.updatedAt === 'boolean' ? [] : 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),
);
if (hasNonIgnoredFields) {
let hasUpdated = true;
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),
);
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),
);
hasUpdated = hasAnyTargetFields;
}
}
if (hasUpdated) {
if (finalData === data) {
if (fieldDef.updatedAt && finalData[fieldName] === undefined) {
let hasUpdated = true;
if (typeof fieldDef.updatedAt === 'object') {
if (fieldDef.updatedAt.ignore) {
const ignoredFields = new Set(fieldDef.updatedAt.ignore);
const hasNonIgnoredFields = Object.keys(data).some((field) => {
if (ignoredFields.has(field)) return false;
if (
isScalarField(this.schema, modelDef.name, field) ||
isForeignKeyField(this.schema, modelDef.name, field)
) {
return true;
}
const relation = modelDef.fields[field]?.relation;
return relation?.fields?.some((fk) => !ignoredFields.has(fk)) ?? false;
});
hasUpdated = hasNonIgnoredFields;
} else if (fieldDef.updatedAt.fields) {
const targetFields = new Set(fieldDef.updatedAt.fields);
const hasAnyTargetFields = Object.keys(data).some((field) => {
if (targetFields.has(field)) return true;
if (
isScalarField(this.schema, modelDef.name, field) ||
isForeignKeyField(this.schema, modelDef.name, field)
) {
return false;
}
const relation = modelDef.fields[field]?.relation;
return relation?.fields?.some((fk) => targetFields.has(fk)) ?? false;
});
hasUpdated = hasAnyTargetFields;
}
}
if (hasUpdated) {
if (finalData === data) {
🤖 Prompt for AI Agents
In `@packages/orm/src/client/crud/operations/base.ts` around lines 1151 - 1175,
The updatedAt logic (around fieldDef.updatedAt / hasUpdated) currently only
checks keys on the incoming data using isScalarField/isForeignKeyField so nested
relation operations that mutate owned FKs later via parentUpdates (e.g., author:
{ connect/create/update } which apply FK changes through parentUpdates) are
ignored; update the hasUpdated determination to also inspect parentUpdates for
relation entries that will produce FK changes (look at parentUpdates collection
and treat relation ops that result in owned FK writes as equivalent to scalar FK
presence), keeping the existing ignore/fields semantics (respect
fieldDef.updatedAt.ignore and fieldDef.updatedAt.fields) and using the same
isScalarField/isForeignKeyField logic when mapping relation->affected FK names
so updatedAt is bumped correctly.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@ymc9 this doesn't seem like a regression. Was the original implementation of ignore wrong?

Copy link
Contributor

Choose a reason for hiding this comment

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

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

Copy link
Member

Choose a reason for hiding this comment

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

Hi @sanny-io , I think it's a valid issue. As @coderabbitai mentioned, if the model-under-update owns a relation and is doing a nested update to that relation, it'll result in an update to the fk field. So, if the @updatedAt explicitly includes the fk field, it won't trigger the timestamp update, because the fk field doesn't appear in the payload.

await db.post.update({
  where: { ... },
  data: { author: { connect: { ... } } }
});

I think we need a bit more analysis in the updatedAt.fields branch to identify relation update and translate them to fk fields.

Copy link
Contributor

Choose a reason for hiding this comment

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

@ymc9 Thanks for confirming! You're absolutely right—the current logic only checks for scalar/FK fields present directly in the data object, so nested relation operations like { author: { connect: ... } } that will update FK fields are completely missed.

Looking at the schema structure, when a model owns a relation, the fieldDef.relation.fields array contains the FK field names that will be updated by that relation operation. The fix should check if any relation field in the data will trigger FK updates to:

  • Non-ignored fields (for the ignore case)
  • Target fields (for the fields case)

Would you like me to plan the code changes to implement this fix? I can create a detailed plan that:

  1. Extends the hasUpdated logic to inspect relation fields
  2. Maps relation operations to their corresponding FK fields using relation.fields
  3. Applies the same ignore/target field filtering to those FK fields

Let me know if you'd like me to proceed with the planning, or if you prefer to handle this internally!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@coderabbitai plan the code changes to implement the fix.

Copy link
Contributor

Choose a reason for hiding this comment

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

Task: Fix updatedAt to account for relation-driven FK updates

The current hasUpdated logic only checks scalar and FK fields present directly in the data object. When nested relation operations (e.g., author: { connect: ... }) update owned FK fields, those changes happen later via parentUpdates and are missed. We need to:

  1. Detect relation fields in the update data
  2. Map those relation operations to their corresponding FK fields using relation.fields
  3. Apply the same ignore/fields filtering to those FK fields

This will ensure @updatedAt(ignore: [...]) and @updatedAt(fields: [...]) work correctly with nested relation updates.


💡 For code changes across multiple files, use @coderabbitai plan in an issue comment

finalData = clone(data);
}
Expand Down
1 change: 1 addition & 0 deletions packages/schema/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export type RelationInfo = {

export type UpdatedAtInfo = {
ignore?: readonly string[];
fields?: readonly string[];
};

export type FieldDef = {
Expand Down
11 changes: 6 additions & 5 deletions packages/sdk/src/ts-schema-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -522,10 +522,10 @@ export class TsSchemaGenerator {
);
}

private createUpdatedAtObject(ignoreArg: AttributeArg) {
private createUpdatedAtObject(arg: AttributeArg) {
return ts.factory.createObjectLiteralExpression([
ts.factory.createPropertyAssignment('ignore', ts.factory.createArrayLiteralExpression(
(ignoreArg.value as ArrayExpr).items.map((item) => ts.factory.createStringLiteral((item as ReferenceExpr).target.$refText))
ts.factory.createPropertyAssignment(arg.$resolvedParam.name, ts.factory.createArrayLiteralExpression(
(arg.value as ArrayExpr).items.map((item) => ts.factory.createStringLiteral((item as ReferenceExpr).target.$refText))
))
]);
}
Expand Down Expand Up @@ -575,9 +575,10 @@ export class TsSchemaGenerator {
const updatedAtAttrib = getAttribute(field, '@updatedAt') as DataFieldAttribute | undefined;
if (updatedAtAttrib) {
const ignoreArg = updatedAtAttrib.args.find(arg => arg.$resolvedParam?.name === 'ignore');
const fieldsArg = updatedAtAttrib.args.find(arg => arg.$resolvedParam?.name === 'fields');
objectFields.push(ts.factory.createPropertyAssignment('updatedAt',
ignoreArg
? this.createUpdatedAtObject(ignoreArg)
(ignoreArg || fieldsArg)
? this.createUpdatedAtObject(ignoreArg ?? fieldsArg!)
: ts.factory.createTrue()
));
}
Expand Down
Loading
Loading