diff --git a/packages/cli/test/ts-schema-gen.test.ts b/packages/cli/test/ts-schema-gen.test.ts index 1056478f..29aa7b52 100644 --- a/packages/cli/test/ts-schema-gen.test.ts +++ b/packages/cli/test/ts-schema-gen.test.ts @@ -466,4 +466,286 @@ model User { expect(schemaLite!.models.User.fields.id.attributes).toBeUndefined(); expect(schemaLite!.models.User.fields.email.attributes).toBeUndefined(); }); + + it('supports ignorable 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(ignore: [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: { + ignore: [ + 'email' + ] + }, + attributes: [ + { + name: '@updatedAt', + args: [ + { + name: 'ignore', + 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: {} + }); + }) }); diff --git a/packages/language/res/stdlib.zmodel b/packages/language/res/stdlib.zmodel index 81d52dc9..f3f56882 100644 --- a/packages/language/res/stdlib.zmodel +++ b/packages/language/res/stdlib.zmodel @@ -402,8 +402,12 @@ attribute @omit() /** * Automatically stores the time when a record was last updated. + * + * @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. */ -attribute @updatedAt() @@@targetField([DateTimeField]) @@@prisma +attribute @updatedAt(_ ignore: FieldReference[]?) @@@targetField([DateTimeField]) @@@prisma /** * Add full text index (MySQL only). diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index 071e6674..4633953f 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -31,6 +31,7 @@ import type { SchemaDef, TypeDefFieldIsArray, TypeDefFieldIsOptional, + UpdatedAtInfo, } from '../schema'; import type { AtLeast, @@ -985,7 +986,7 @@ type OptionalFieldsForCreate extends true ? Key - : GetModelField['updatedAt'] extends true + : GetModelField['updatedAt'] extends (true | UpdatedAtInfo) ? Key : never]: GetModelField; }; diff --git a/packages/orm/src/client/crud/operations/base.ts b/packages/orm/src/client/crud/operations/base.ts index 957b94ab..ded2010b 100644 --- a/packages/orm/src/client/crud/operations/base.ts +++ b/packages/orm/src/client/crud/operations/base.ts @@ -994,11 +994,20 @@ export abstract class BaseOperationHandler { const autoUpdatedFields: string[] = []; for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) { if (fieldDef.updatedAt) { + 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 (finalData === data) { finalData = clone(data); } - finalData[fieldName] = this.dialect.transformPrimitive(new Date(), 'DateTime', false); - autoUpdatedFields.push(fieldName); + if (hasNonIgnoredFields) { + finalData[fieldName] = this.dialect.transformPrimitive(new Date(), 'DateTime', false); + autoUpdatedFields.push(fieldName); + } } } diff --git a/packages/schema/src/schema.ts b/packages/schema/src/schema.ts index 40f7d8bd..ba057add 100644 --- a/packages/schema/src/schema.ts +++ b/packages/schema/src/schema.ts @@ -59,6 +59,10 @@ export type RelationInfo = { onUpdate?: CascadeAction; }; +export type UpdatedAtInfo = { + ignore?: readonly string[]; +}; + export type FieldDef = { name: string; type: string; @@ -66,7 +70,7 @@ export type FieldDef = { array?: boolean; optional?: boolean; unique?: boolean; - updatedAt?: boolean; + updatedAt?: boolean | UpdatedAtInfo; attributes?: readonly AttributeApplication[]; default?: MappedBuiltinType | Expression | readonly unknown[]; omit?: boolean; @@ -282,7 +286,7 @@ export type FieldHasDefault< Field extends GetModelFields, > = GetModelField['default'] extends object | number | string | boolean ? true - : GetModelField['updatedAt'] extends true + : GetModelField['updatedAt'] extends (true | UpdatedAtInfo) ? true : GetModelField['relation'] extends { hasDefault: true } ? true diff --git a/packages/sdk/src/ts-schema-generator.ts b/packages/sdk/src/ts-schema-generator.ts index 83e97a27..96e73ffd 100644 --- a/packages/sdk/src/ts-schema-generator.ts +++ b/packages/sdk/src/ts-schema-generator.ts @@ -521,6 +521,14 @@ export class TsSchemaGenerator { ); } + private createUpdatedAtObject(ignoreArg: 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)) + )) + ]); + } + private mapFieldTypeToTSType(type: DataFieldType) { let result = match(type.type) .with('String', () => 'string') @@ -563,8 +571,14 @@ export class TsSchemaGenerator { objectFields.push(ts.factory.createPropertyAssignment('array', ts.factory.createTrue())); } - if (hasAttribute(field, '@updatedAt')) { - objectFields.push(ts.factory.createPropertyAssignment('updatedAt', ts.factory.createTrue())); + const updatedAtAttrib = getAttribute(field, '@updatedAt') as DataFieldAttribute | undefined; + if (updatedAtAttrib) { + const ignoreArg = updatedAtAttrib.args.find(arg => arg.$resolvedParam?.name === 'ignore'); + objectFields.push(ts.factory.createPropertyAssignment('updatedAt', + ignoreArg + ? this.createUpdatedAtObject(ignoreArg) + : ts.factory.createTrue() + )); } if (hasAttribute(field, '@omit')) { diff --git a/tests/e2e/orm/client-api/update.test.ts b/tests/e2e/orm/client-api/update.test.ts index c79396d7..cb13ee55 100644 --- a/tests/e2e/orm/client-api/update.test.ts +++ b/tests/e2e/orm/client-api/update.test.ts @@ -130,6 +130,68 @@ describe('Client update tests', () => { expect(updatedUser?.updatedAt).toEqual(originalUpdatedAt); }); + it('does not update updatedAt if only ignored fields are present', async () => { + const user = await createUser(client, 'u1@test.com'); + const originalUpdatedAt = user.updatedAt; + + await client.user.update({ + where: { + id: user.id, + }, + + data: { + createdAt: new Date(), + }, + }) + + let updatedUser = await client.user.findUnique({ where: { id: user.id } }); + expect(updatedUser?.updatedAt.getTime()).toEqual(originalUpdatedAt.getTime()); + + await client.user.update({ + where: { + id: user.id, + }, + + data: { + id: 'User2', + }, + }) + + updatedUser = await client.user.findUnique({ where: { id: 'User2' } }); + expect(updatedUser?.updatedAt.getTime()).toEqual(originalUpdatedAt.getTime()); + + // multiple ignored fields + await client.user.update({ + where: { + id: 'User2', + }, + + data: { + id: 'User3', + createdAt: new Date(), + }, + }) + + updatedUser = await client.user.findUnique({ where: { id: 'User3' } }); + expect(updatedUser?.updatedAt.getTime()).toEqual(originalUpdatedAt.getTime()); + }); + + it('updates updatedAt if any non-ignored fields are present', async () => { + const user = await createUser(client, 'u1@test.com'); + const originalUpdatedAt = user.updatedAt; + + await client.user.update({ + where: { id: user.id }, + data: { + id: 'User2', + name: 'User2', + }, + }); + + const updatedUser = await client.user.findUnique({ where: { id: 'User2' } }); + expect(updatedUser?.updatedAt.getTime()).toBeGreaterThan(originalUpdatedAt.getTime()); + }); + it('works with numeric incremental update', async () => { await createUser(client, 'u1@test.com', { profile: { create: { id: '1', bio: 'bio' } }, diff --git a/tests/e2e/orm/schemas/basic/schema.ts b/tests/e2e/orm/schemas/basic/schema.ts index 5f067685..98cfebda 100644 --- a/tests/e2e/orm/schemas/basic/schema.ts +++ b/tests/e2e/orm/schemas/basic/schema.ts @@ -30,7 +30,12 @@ export class SchemaType implements SchemaDef { updatedAt: { name: "updatedAt", type: "DateTime", - updatedAt: true, + updatedAt: { + ignore: [ + 'id', + 'createdAt', + ], + }, attributes: [{ name: "@updatedAt" }] }, email: { diff --git a/tests/e2e/orm/schemas/basic/schema.zmodel b/tests/e2e/orm/schemas/basic/schema.zmodel index a831b827..9a8914fd 100644 --- a/tests/e2e/orm/schemas/basic/schema.zmodel +++ b/tests/e2e/orm/schemas/basic/schema.zmodel @@ -15,7 +15,7 @@ enum Role { type CommonFields { id String @id @default(cuid()) createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + updatedAt DateTime @updatedAt(ignore: [id, createdAt]) } model User with CommonFields {