diff --git a/packages/cli/test/ts-schema-gen.test.ts b/packages/cli/test/ts-schema-gen.test.ts index 29aa7b522..ec83a7cf2 100644 --- a/packages/cli/test/ts-schema-gen.test.ts +++ b/packages/cli/test/ts-schema-gen.test.ts @@ -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: {} + }); + }); }); diff --git a/packages/language/res/stdlib.zmodel b/packages/language/res/stdlib.zmodel index cb604c74a..cba3b0476 100644 --- a/packages/language/res/stdlib.zmodel +++ b/packages/language/res/stdlib.zmodel @@ -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). diff --git a/packages/language/src/validators/attribute-application-validator.ts b/packages/language/src/validators/attribute-application-validator.ts index 28983e822..20fec18d6 100644 --- a/packages/language/src/validators/attribute-application-validator.ts +++ b/packages/language/src/validators/attribute-application-validator.ts @@ -280,6 +280,15 @@ export default class AttributeApplicationValidator implements AstValidator 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; diff --git a/packages/orm/src/client/crud/operations/base.ts b/packages/orm/src/client/crud/operations/base.ts index fc75cac9d..8a2185624 100644 --- a/packages/orm/src/client/crud/operations/base.ts +++ b/packages/orm/src/client/crud/operations/base.ts @@ -1149,14 +1149,29 @@ export abstract class BaseOperationHandler { 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) { finalData = clone(data); } diff --git a/packages/schema/src/schema.ts b/packages/schema/src/schema.ts index d98b86f01..3c9771856 100644 --- a/packages/schema/src/schema.ts +++ b/packages/schema/src/schema.ts @@ -61,6 +61,7 @@ export type RelationInfo = { export type UpdatedAtInfo = { ignore?: readonly string[]; + fields?: readonly string[]; }; export type FieldDef = { diff --git a/packages/sdk/src/ts-schema-generator.ts b/packages/sdk/src/ts-schema-generator.ts index ac6fcf00f..917e39451 100644 --- a/packages/sdk/src/ts-schema-generator.ts +++ b/packages/sdk/src/ts-schema-generator.ts @@ -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)) )) ]); } @@ -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() )); } diff --git a/tests/e2e/orm/client-api/update.test.ts b/tests/e2e/orm/client-api/update.test.ts index e14432050..08377e518 100644 --- a/tests/e2e/orm/client-api/update.test.ts +++ b/tests/e2e/orm/client-api/update.test.ts @@ -154,68 +154,6 @@ 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/client-api/updated-at.test.ts b/tests/e2e/orm/client-api/updated-at.test.ts new file mode 100644 index 000000000..33e6d6aa6 --- /dev/null +++ b/tests/e2e/orm/client-api/updated-at.test.ts @@ -0,0 +1,255 @@ +import { describe, expect, it } from 'vitest'; +import { createTestClient } from '@zenstackhq/testtools'; + +describe('@updatedAt attribute', () => { + describe('fields arg', () => { + const schema = ` + model User { + id String @id @default(uuid()) + name String + email String @default('test@test.com') + age Int @default(18) + address String @default('Fake Street') + + nameUpdatedAt DateTime @updatedAt(fields: [name]) + emailUpdatedAt DateTime @updatedAt(fields: [email]) + majorFieldUpdatedAt DateTime @updatedAt(fields: [name, email]) + emptyFieldUpdatedAt DateTime @updatedAt(fields: []) + anyFieldUpdatedAt DateTime @updatedAt + } + `; + + it('updates if any targeted field changes', async () => { + const client = await createTestClient(schema); + const user = await client.user.create({ + data: { + name: 'test', + }, + }); + const nameUpdatedAt = user.nameUpdatedAt; + const majorFieldUpdatedAt = user.majorFieldUpdatedAt; + + await client.user.update({ + data: { + name: 'test2', + }, + + where: { + id: user.id, + }, + }); + + const updatedUser1 = await client.user.findUnique({ + where: { + id: user.id, + }, + }); + + expect(updatedUser1.nameUpdatedAt.getTime()).toBeGreaterThan(nameUpdatedAt.getTime()); + expect(updatedUser1.emailUpdatedAt.getTime()).toEqual(user.emailUpdatedAt.getTime()); + expect(updatedUser1.majorFieldUpdatedAt.getTime()).toBeGreaterThan(majorFieldUpdatedAt.getTime()); + + await client.user.update({ + data: { + name: 'test3', + email: 'test3@test.com', + }, + + where: { + id: user.id, + }, + }); + + const updatedUser2 = await client.user.findUnique({ + where: { + id: user.id, + }, + }); + + expect(updatedUser2.nameUpdatedAt.getTime()).toBeGreaterThan(updatedUser1.nameUpdatedAt.getTime()); + expect(updatedUser2.emailUpdatedAt.getTime()).toBeGreaterThan(updatedUser1.emailUpdatedAt.getTime()); + expect(updatedUser2.majorFieldUpdatedAt.getTime()).toBeGreaterThan(updatedUser1.majorFieldUpdatedAt.getTime()); + }); + + it('does not update if any non-targeted field changes', async () => { + const client = await createTestClient(schema); + const user = await client.user.create({ + data: { + name: 'test', + }, + }); + const nameUpdatedAt = user.nameUpdatedAt; + const emailUpdatedAt = user.emailUpdatedAt; + const majorFieldUpdatedAt = user.majorFieldUpdatedAt; + const emptyFieldUpdatedAt = user.emptyFieldUpdatedAt; + + await client.user.update({ + data: { + age: 19, + }, + + where: { + id: user.id, + }, + }); + + const updatedUser1 = await client.user.findUnique({ + where: { + id: user.id, + }, + }); + + expect(updatedUser1.nameUpdatedAt.getTime()).toEqual(nameUpdatedAt.getTime()); + expect(updatedUser1.emailUpdatedAt.getTime()).toEqual(emailUpdatedAt.getTime()); + expect(updatedUser1.majorFieldUpdatedAt.getTime()).toEqual(majorFieldUpdatedAt.getTime()); + expect(updatedUser1.emptyFieldUpdatedAt.getTime()).toEqual(emptyFieldUpdatedAt.getTime()); + + await client.user.update({ + data: { + age: 20, + address: 'Fake Road', + }, + + where: { + id: user.id, + }, + }); + + const updatedUser2 = await client.user.findUnique({ + where: { + id: user.id, + }, + }); + + expect(updatedUser2.nameUpdatedAt.getTime()).toEqual(updatedUser1.nameUpdatedAt.getTime()); + expect(updatedUser2.emailUpdatedAt.getTime()).toEqual(updatedUser1.emailUpdatedAt.getTime()); + expect(updatedUser2.majorFieldUpdatedAt.getTime()).toEqual(updatedUser1.majorFieldUpdatedAt.getTime()); + expect(updatedUser2.emptyFieldUpdatedAt.getTime()).toEqual(emptyFieldUpdatedAt.getTime()); + }); + }); + + describe('ignore arg', () => { + const schema = ` + model User { + id String @id @default(uuid()) + name String + email String @default('test@test.com') + age Int @default(18) + address String @default('Fake Street') + + exceptNameUpdatedAt DateTime @updatedAt(ignore: [name]) + exceptEmailUpdatedAt DateTime @updatedAt(ignore: [email]) + exceptMajorFieldUpdatedAt DateTime @updatedAt(ignore: [name, email]) + emptyFieldUpdatedAt DateTime @updatedAt(ignore: []) + anyFieldUpdatedAt DateTime @updatedAt + } + `; + + it('updates if any non-ignored fields are present', async () => { + const client = await createTestClient(schema); + const user = await client.user.create({ + data: { + name: 'test', + }, + }); + const exceptNameUpdatedAt = user.exceptNameUpdatedAt; + const exceptMajorFieldUpdatedAt = user.exceptMajorFieldUpdatedAt; + const emptyFieldUpdatedAt = user.emptyFieldUpdatedAt; + const anyFieldUpdatedAt = user.anyFieldUpdatedAt; + + await client.user.update({ + data: { + age: 19, + }, + + where: { + id: user.id, + }, + }); + + const updatedUser1 = await client.user.findUnique({ + where: { + id: user.id, + }, + }); + + expect(updatedUser1.exceptNameUpdatedAt.getTime()).toBeGreaterThan(exceptNameUpdatedAt.getTime()); + expect(updatedUser1.exceptMajorFieldUpdatedAt.getTime()).toBeGreaterThan(exceptMajorFieldUpdatedAt.getTime()); + expect(updatedUser1.emptyFieldUpdatedAt.getTime()).toBeGreaterThan(emptyFieldUpdatedAt.getTime()); + expect(updatedUser1.anyFieldUpdatedAt.getTime()).toBeGreaterThan(anyFieldUpdatedAt.getTime()); + + await client.user.update({ + data: { + age: 20, + name: 'test4', + }, + + where: { + id: user.id, + }, + }); + + const updatedUser2 = await client.user.findUnique({ + where: { + id: user.id, + }, + }); + + expect(updatedUser2.exceptNameUpdatedAt.getTime()).toBeGreaterThan(updatedUser1.exceptNameUpdatedAt.getTime()); + expect(updatedUser2.exceptMajorFieldUpdatedAt.getTime()).toBeGreaterThan(updatedUser1.exceptMajorFieldUpdatedAt.getTime()); + expect(updatedUser2.emptyFieldUpdatedAt.getTime()).toBeGreaterThan(updatedUser1.emptyFieldUpdatedAt.getTime()); + expect(updatedUser2.anyFieldUpdatedAt.getTime()).toBeGreaterThan(updatedUser1.anyFieldUpdatedAt.getTime()); + }); + + it('does not update if only ignored fields are present', async () => { + const client = await createTestClient(schema); + const user = await client.user.create({ + data: { + name: 'test', + }, + }); + const exceptNameUpdatedAt = user.exceptNameUpdatedAt; + const exceptEmailUpdatedAt = user.exceptEmailUpdatedAt; + const exceptMajorFieldUpdatedAt = user.exceptMajorFieldUpdatedAt; + + await client.user.update({ + data: { + name: 'test2', + }, + + where: { + id: user.id, + }, + }); + + const updatedUser1 = await client.user.findUnique({ + where: { + id: user.id, + }, + }); + + expect(updatedUser1.exceptNameUpdatedAt.getTime()).toEqual(exceptNameUpdatedAt.getTime()); + expect(updatedUser1.exceptEmailUpdatedAt.getTime()).toBeGreaterThan(exceptEmailUpdatedAt.getTime()); + expect(updatedUser1.exceptMajorFieldUpdatedAt.getTime()).toEqual(exceptMajorFieldUpdatedAt.getTime()); + + await client.user.update({ + data: { + name: 'test3', + email: 'test3@test.com', + }, + + where: { + id: user.id, + }, + }); + + const updatedUser2 = await client.user.findUnique({ + where: { + id: user.id, + }, + }); + + expect(updatedUser2.exceptMajorFieldUpdatedAt.getTime()).toEqual(updatedUser1.exceptMajorFieldUpdatedAt.getTime()); + }); + }); +}); diff --git a/tests/e2e/orm/schemas/basic/schema.ts b/tests/e2e/orm/schemas/basic/schema.ts index ea345b9ed..ce123db2d 100644 --- a/tests/e2e/orm/schemas/basic/schema.ts +++ b/tests/e2e/orm/schemas/basic/schema.ts @@ -30,8 +30,8 @@ export class SchemaType implements SchemaDef { updatedAt: { name: "updatedAt", type: "DateTime", - updatedAt: { ignore: ["id", "createdAt"] }, - attributes: [{ name: "@updatedAt", args: [{ name: "ignore", value: ExpressionUtils.array("String", [ExpressionUtils.field("id"), ExpressionUtils.field("createdAt")]) }] }] + updatedAt: true, + attributes: [{ name: "@updatedAt" }] }, email: { name: "email", @@ -97,8 +97,8 @@ export class SchemaType implements SchemaDef { updatedAt: { name: "updatedAt", type: "DateTime", - updatedAt: { ignore: ["id", "createdAt"] }, - attributes: [{ name: "@updatedAt", args: [{ name: "ignore", value: ExpressionUtils.array("String", [ExpressionUtils.field("id"), ExpressionUtils.field("createdAt")]) }] }] + updatedAt: true, + attributes: [{ name: "@updatedAt" }] }, title: { name: "title", @@ -164,8 +164,8 @@ export class SchemaType implements SchemaDef { updatedAt: { name: "updatedAt", type: "DateTime", - updatedAt: { ignore: ["id", "createdAt"] }, - attributes: [{ name: "@updatedAt", args: [{ name: "ignore", value: ExpressionUtils.array("String", [ExpressionUtils.field("id"), ExpressionUtils.field("createdAt")]) }] }] + updatedAt: true, + attributes: [{ name: "@updatedAt" }] }, content: { name: "content", @@ -211,8 +211,8 @@ export class SchemaType implements SchemaDef { updatedAt: { name: "updatedAt", type: "DateTime", - updatedAt: { ignore: ["id", "createdAt"] }, - attributes: [{ name: "@updatedAt", args: [{ name: "ignore", value: ExpressionUtils.array("String", [ExpressionUtils.field("id"), ExpressionUtils.field("createdAt")]) }] }] + updatedAt: true, + attributes: [{ name: "@updatedAt" }] }, bio: { name: "bio", @@ -287,8 +287,8 @@ export class SchemaType implements SchemaDef { updatedAt: { name: "updatedAt", type: "DateTime", - updatedAt: { ignore: ["id", "createdAt"] }, - attributes: [{ name: "@updatedAt", args: [{ name: "ignore", value: ExpressionUtils.array("String", [ExpressionUtils.field("id"), ExpressionUtils.field("createdAt")]) }] }] + updatedAt: true, + attributes: [{ name: "@updatedAt" }] } } } diff --git a/tests/e2e/orm/schemas/basic/schema.zmodel b/tests/e2e/orm/schemas/basic/schema.zmodel index 8fd48872c..9d3a7d91d 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(ignore: [id, createdAt]) + updatedAt DateTime @updatedAt } model User with CommonFields {