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
53 changes: 45 additions & 8 deletions packages/orm/src/client/crud/operations/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1159,14 +1159,28 @@ 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) => {
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) => {
const effectiveFields = this.getUpdatedAtEffectiveFields(modelDef.name, field);
return effectiveFields.some((f) => targetFields.has(f));
});
Comment on lines +1166 to +1179
Copy link
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;
}
}
if (hasUpdated) {
if (finalData === data) {
finalData = clone(data);
}
Expand Down Expand Up @@ -1517,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
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
Loading
Loading