diff --git a/BREAKINGCHANGES.md b/BREAKINGCHANGES.md index 96c3d629d..8612ff374 100644 --- a/BREAKINGCHANGES.md +++ b/BREAKINGCHANGES.md @@ -4,3 +4,4 @@ 1. `@omit` and `@password` attributes have been removed 1. SWR plugin is removed 1. `makeModelSchema()` no longer includes relation fields by default — use `include` or `select` options to opt in, mirroring ORM behaviour +1. ORM entity mutation catch-all hooks now need to be registered under `onEntityMutation.$all` diff --git a/packages/orm/src/client/executor/zenstack-query-executor.ts b/packages/orm/src/client/executor/zenstack-query-executor.ts index ed4f6f6b1..4628be8fa 100644 --- a/packages/orm/src/client/executor/zenstack-query-executor.ts +++ b/packages/orm/src/client/executor/zenstack-query-executor.ts @@ -123,7 +123,12 @@ export class ZenStackQueryExecutor extends DefaultQueryExecutor { } private get hasEntityMutationPluginsWithAfterMutationHooks() { - return (this.client.$options.plugins ?? []).some((plugin) => plugin.onEntityMutation?.afterEntityMutation); + return (this.client.$options.plugins ?? []).some((plugin) => { + if (!plugin.onEntityMutation) return false; + if (plugin.onEntityMutation['$all']?.afterEntityMutation) return true; + const models = Object.keys(this.client.$schema.models); + return models.some((model) => (plugin.onEntityMutation as any)?.[model]?.afterEntityMutation); + }); } private get hasOnKyselyHooks() { @@ -351,18 +356,34 @@ export class ZenStackQueryExecutor extends DefaultQueryExecutor { if (this.options.plugins) { for (const plugin of this.options.plugins) { const onEntityMutation = plugin.onEntityMutation; - if (!onEntityMutation?.beforeEntityMutation) { + if (!onEntityMutation) { continue; } - await onEntityMutation.beforeEntityMutation({ - model: mutationInfo.model, - action: mutationInfo.action, - queryNode, - loadBeforeMutationEntities, - client, - queryId, - }); + // all-model hook + if (onEntityMutation['$all']?.beforeEntityMutation) { + await onEntityMutation['$all'].beforeEntityMutation({ + model: mutationInfo.model, + action: mutationInfo.action, + queryNode, + loadBeforeMutationEntities, + client, + queryId, + }); + } + + // per-model hook + const modelHooks = (onEntityMutation as Record)[mutationInfo.model]; + if (modelHooks?.beforeEntityMutation) { + await modelHooks.beforeEntityMutation({ + model: mutationInfo.model, + action: mutationInfo.action, + queryNode, + loadBeforeMutationEntities, + client, + queryId, + }); + } } } } @@ -373,22 +394,29 @@ export class ZenStackQueryExecutor extends DefaultQueryExecutor { const hooks: AfterEntityMutationCallback[] = []; - // tsc perf for (const plugin of this.options.plugins ?? []) { const onEntityMutation = plugin.onEntityMutation; - if (!onEntityMutation?.afterEntityMutation) { - continue; - } - if (filterFor === 'inTx' && !onEntityMutation.runAfterMutationWithinTransaction) { + if (!onEntityMutation) { continue; } - if (filterFor === 'outTx' && onEntityMutation.runAfterMutationWithinTransaction) { - continue; + // all-model hook + if (onEntityMutation['$all']?.afterEntityMutation) { + const runInTx = onEntityMutation['$all'].runAfterMutationWithinTransaction ?? false; + if (filterFor === 'all' || (filterFor === 'inTx' && runInTx) || (filterFor === 'outTx' && !runInTx)) { + hooks.push(onEntityMutation['$all'].afterEntityMutation.bind(plugin)); + } } - hooks.push(onEntityMutation.afterEntityMutation.bind(plugin)); + // per-model hook + const modelHooks = (onEntityMutation as Record)[mutationInfo.model]; + if (modelHooks?.afterEntityMutation) { + const runInTx = modelHooks.runAfterMutationWithinTransaction ?? false; + if (filterFor === 'all' || (filterFor === 'inTx' && runInTx) || (filterFor === 'outTx' && !runInTx)) { + hooks.push(modelHooks.afterEntityMutation.bind(plugin)); + } + } } if (hooks.length === 0) { diff --git a/packages/orm/src/client/plugin.ts b/packages/orm/src/client/plugin.ts index 2a2431637..c9f0d791a 100644 --- a/packages/orm/src/client/plugin.ts +++ b/packages/orm/src/client/plugin.ts @@ -5,6 +5,7 @@ import type { GetModelFields, GetModels, NonRelationFields, SchemaDef } from '@z import type { MaybePromise } from '../utils/type-utils'; import type { MapModelFieldType } from './crud-types'; import type { AllCrudOperations, CoreCrudOperations } from './crud/operations/base'; +import type { DefaultModelResult } from './crud-types'; type AllowedExtQueryArgKeys = CoreCrudOperations | '$create' | '$read' | '$update' | '$delete' | '$all'; @@ -287,6 +288,12 @@ type OnQueryHookContext = { // #region OnEntityMutation hooks export type EntityMutationHooksDef = { + [M in '$all' | GetModels]?: M extends '$all' + ? AllEntityMutationHooksDef + : ModelEntityMutationHooksDef>; +}; + +export type AllEntityMutationHooksDef = { /** * Called before entities are mutated. */ @@ -374,13 +381,38 @@ export type PluginAfterEntityMutationArgs = MutationHo /** * The ZenStack client you can use to perform additional operations. - * See {@link EntityMutationHooksDef.runAfterMutationWithinTransaction} for detailed transaction behavior. + * See {@link AllEntityMutationHooksDef.runAfterMutationWithinTransaction} for detailed transaction behavior. * * Mutations initiated from this client will NOT trigger entity mutation hooks to avoid infinite loops. */ client: ClientContract; }; +export type PluginModelBeforeEntityMutationArgs> = Omit< + MutationHooksArgs, + 'model' +> & { + model: Model; + loadBeforeMutationEntities(): Promise[] | undefined>; + client: ClientContract; +}; + +export type PluginModelAfterEntityMutationArgs> = Omit< + MutationHooksArgs, + 'model' +> & { + model: Model; + loadAfterMutationEntities(): Promise[] | undefined>; + beforeMutationEntities?: DefaultModelResult[]; + client: ClientContract; +}; + +export type ModelEntityMutationHooksDef> = { + beforeEntityMutation?: (args: PluginModelBeforeEntityMutationArgs) => MaybePromise; + afterEntityMutation?: (args: PluginModelAfterEntityMutationArgs) => MaybePromise; + runAfterMutationWithinTransaction?: boolean; +}; + // #endregion // #region OnKyselyQuery hooks diff --git a/tests/e2e/orm/plugin-infra/entity-mutation-hooks.test.ts b/tests/e2e/orm/plugin-infra/entity-mutation-hooks.test.ts index 8ddb35f7e..0bc5514e3 100644 --- a/tests/e2e/orm/plugin-infra/entity-mutation-hooks.test.ts +++ b/tests/e2e/orm/plugin-infra/entity-mutation-hooks.test.ts @@ -22,20 +22,22 @@ describe('Entity mutation hooks tests', () => { const client = _client.$use({ id: 'test', onEntityMutation: { - beforeEntityMutation(args) { - beforeCalled[args.action] = true; - if (args.action === 'create') { - expect(InsertQueryNode.is(args.queryNode)).toBe(true); - } - if (args.action === 'update') { - expect(UpdateQueryNode.is(args.queryNode)).toBe(true); - } - if (args.action === 'delete') { - expect(DeleteQueryNode.is(args.queryNode)).toBe(true); - } - }, - afterEntityMutation(args) { - afterCalled[args.action] = true; + $all: { + beforeEntityMutation(args) { + beforeCalled[args.action] = true; + if (args.action === 'create') { + expect(InsertQueryNode.is(args.queryNode)).toBe(true); + } + if (args.action === 'update') { + expect(UpdateQueryNode.is(args.queryNode)).toBe(true); + } + if (args.action === 'delete') { + expect(DeleteQueryNode.is(args.queryNode)).toBe(true); + } + }, + afterEntityMutation(args) { + afterCalled[args.action] = true; + }, }, }, }); @@ -72,24 +74,26 @@ describe('Entity mutation hooks tests', () => { const client = _client.$use({ id: 'test', onEntityMutation: { - async beforeEntityMutation(args) { - if (args.action === 'update' || args.action === 'delete') { - await expect(args.loadBeforeMutationEntities()).resolves.toEqual([ - expect.objectContaining({ - email: args.action === 'update' ? 'u1@test.com' : 'u3@test.com', - }), - ]); - queryIds[args.action].before = args.queryId.queryId; - } - }, - async afterEntityMutation(args) { - if (args.action === 'update' || args.action === 'delete') { - queryIds[args.action].after = args.queryId.queryId; - } - - if (args.action === 'update') { - beforeMutationEntitiesInAfterHooks = args.beforeMutationEntities; - } + $all: { + async beforeEntityMutation(args) { + if (args.action === 'update' || args.action === 'delete') { + await expect(args.loadBeforeMutationEntities()).resolves.toEqual([ + expect.objectContaining({ + email: args.action === 'update' ? 'u1@test.com' : 'u3@test.com', + }), + ]); + queryIds[args.action].before = args.queryId.queryId; + } + }, + async afterEntityMutation(args) { + if (args.action === 'update' || args.action === 'delete') { + queryIds[args.action].after = args.queryId.queryId; + } + + if (args.action === 'update') { + beforeMutationEntitiesInAfterHooks = args.beforeMutationEntities; + } + }, }, }, }); @@ -122,22 +126,24 @@ describe('Entity mutation hooks tests', () => { const client = _client.$use({ id: 'test', onEntityMutation: { - async afterEntityMutation(args) { - if (args.action === 'create' || args.action === 'update') { - if (args.action === 'create') { - userCreateIntercepted = true; - } - if (args.action === 'update') { - userUpdateIntercepted = true; + $all: { + async afterEntityMutation(args) { + if (args.action === 'create' || args.action === 'update') { + if (args.action === 'create') { + userCreateIntercepted = true; + } + if (args.action === 'update') { + userUpdateIntercepted = true; + } + await expect(args.loadAfterMutationEntities()).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + email: args.action === 'create' ? 'u1@test.com' : 'u2@test.com', + }), + ]), + ); } - await expect(args.loadAfterMutationEntities()).resolves.toEqual( - expect.arrayContaining([ - expect.objectContaining({ - email: args.action === 'create' ? 'u1@test.com' : 'u2@test.com', - }), - ]), - ); - } + }, }, }, }); @@ -162,38 +168,40 @@ describe('Entity mutation hooks tests', () => { const client = _client.$use({ id: 'test', onEntityMutation: { - async afterEntityMutation(args) { - if (args.action === 'create') { - userCreateIntercepted = true; - await expect(args.loadAfterMutationEntities()).resolves.toEqual( - expect.arrayContaining([ - expect.objectContaining({ email: 'u1@test.com' }), - expect.objectContaining({ email: 'u2@test.com' }), - ]), - ); - } else if (args.action === 'update') { - userUpdateIntercepted = true; - await expect(args.loadAfterMutationEntities()).resolves.toEqual( - expect.arrayContaining([ - expect.objectContaining({ - email: 'u1@test.com', - name: 'A user', - }), - expect.objectContaining({ - email: 'u2@test.com', - name: 'A user', - }), - ]), - ); - } else if (args.action === 'delete') { - userDeleteIntercepted = true; - await expect(args.loadAfterMutationEntities()).resolves.toEqual( - expect.arrayContaining([ - expect.objectContaining({ email: 'u1@test.com' }), - expect.objectContaining({ email: 'u2@test.com' }), - ]), - ); - } + $all: { + async afterEntityMutation(args) { + if (args.action === 'create') { + userCreateIntercepted = true; + await expect(args.loadAfterMutationEntities()).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ email: 'u1@test.com' }), + expect.objectContaining({ email: 'u2@test.com' }), + ]), + ); + } else if (args.action === 'update') { + userUpdateIntercepted = true; + await expect(args.loadAfterMutationEntities()).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + email: 'u1@test.com', + name: 'A user', + }), + expect.objectContaining({ + email: 'u2@test.com', + name: 'A user', + }), + ]), + ); + } else if (args.action === 'delete') { + userDeleteIntercepted = true; + await expect(args.loadAfterMutationEntities()).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ email: 'u1@test.com' }), + expect.objectContaining({ email: 'u2@test.com' }), + ]), + ); + } + }, }, }, }); @@ -216,18 +224,20 @@ describe('Entity mutation hooks tests', () => { const client = _client.$use({ id: 'test', onEntityMutation: { - async afterEntityMutation(args) { - if (args.action === 'create') { - if (args.model === 'Post') { - const afterEntities = await args.loadAfterMutationEntities(); - if ((afterEntities![0] as any).title === 'Post1') { - post1Intercepted = true; - } - if ((afterEntities![0] as any).title === 'Post2') { - post2Intercepted = true; + $all: { + async afterEntityMutation(args) { + if (args.action === 'create') { + if (args.model === 'Post') { + const afterEntities = await args.loadAfterMutationEntities(); + if ((afterEntities![0] as any).title === 'Post1') { + post1Intercepted = true; + } + if ((afterEntities![0] as any).title === 'Post2') { + post2Intercepted = true; + } } } - } + }, }, }, }); @@ -256,12 +266,14 @@ describe('Entity mutation hooks tests', () => { const client = _client.$use({ id: 'test', onEntityMutation: { - async afterEntityMutation(args) { - triggered.push({ - action: args.action, - model: args.model, - afterMutationEntities: await args.loadAfterMutationEntities(), - }); + $all: { + async afterEntityMutation(args) { + triggered.push({ + action: args.action, + model: args.model, + afterMutationEntities: await args.loadAfterMutationEntities(), + }); + }, }, }, }); @@ -303,17 +315,19 @@ describe('Entity mutation hooks tests', () => { const client = _client.$use({ id: 'test', onEntityMutation: { - async beforeEntityMutation(ctx) { - await ctx.client.profile.create({ - data: { bio: 'Bio1' }, - }); - }, - async afterEntityMutation(ctx) { - intercepted = true; - await ctx.client.user.update({ - where: { email: 'u1@test.com' }, - data: { email: 'u2@test.com' }, - }); + $all: { + async beforeEntityMutation(ctx) { + await ctx.client.profile.create({ + data: { bio: 'Bio1' }, + }); + }, + async afterEntityMutation(ctx) { + intercepted = true; + await ctx.client.user.update({ + where: { email: 'u1@test.com' }, + data: { email: 'u2@test.com' }, + }); + }, }, }, }); @@ -333,18 +347,20 @@ describe('Entity mutation hooks tests', () => { const client = _client.$use({ id: 'test', onEntityMutation: { - runAfterMutationWithinTransaction: true, - async beforeEntityMutation(ctx) { - await ctx.client.profile.create({ - data: { bio: 'Bio1' }, - }); - }, - async afterEntityMutation(ctx) { - intercepted = true; - await ctx.client.user.update({ - where: { email: 'u1@test.com' }, - data: { email: 'u2@test.com' }, - }); + $all: { + runAfterMutationWithinTransaction: true, + async beforeEntityMutation(ctx) { + await ctx.client.profile.create({ + data: { bio: 'Bio1' }, + }); + }, + async afterEntityMutation(ctx) { + intercepted = true; + await ctx.client.user.update({ + where: { email: 'u1@test.com' }, + data: { email: 'u2@test.com' }, + }); + }, }, }, }); @@ -362,8 +378,10 @@ describe('Entity mutation hooks tests', () => { const client = _client.$use({ id: 'test', onEntityMutation: { - async beforeEntityMutation() { - throw new Error('trigger failure'); + $all: { + async beforeEntityMutation() { + throw new Error('trigger failure'); + }, }, }, }); @@ -384,9 +402,11 @@ describe('Entity mutation hooks tests', () => { const client = _client.$use({ id: 'test', onEntityMutation: { - async afterEntityMutation() { - intercepted = true; - throw new Error('trigger rollback'); + $all: { + async afterEntityMutation() { + intercepted = true; + throw new Error('trigger rollback'); + }, }, }, }); @@ -406,11 +426,13 @@ describe('Entity mutation hooks tests', () => { const client = _client.$use({ id: 'test', onEntityMutation: { - runAfterMutationWithinTransaction: true, - async afterEntityMutation(ctx) { - intercepted = true; - await ctx.client.user.create({ data: { email: 'u2@test.com' } }); - throw new Error('trigger rollback'); + $all: { + runAfterMutationWithinTransaction: true, + async afterEntityMutation(ctx) { + intercepted = true; + await ctx.client.user.create({ data: { email: 'u2@test.com' } }); + throw new Error('trigger rollback'); + }, }, }, }); @@ -432,9 +454,11 @@ describe('Entity mutation hooks tests', () => { const client = _client.$use({ id: 'test', onEntityMutation: { - async afterEntityMutation(ctx) { - intercepted = true; - await ctx.client.user.create({ data: { email: 'u2@test.com' } }); + $all: { + async afterEntityMutation(ctx) { + intercepted = true; + await ctx.client.user.create({ data: { email: 'u2@test.com' } }); + }, }, }, }); @@ -461,10 +485,12 @@ describe('Entity mutation hooks tests', () => { const client = _client.$use({ id: 'test', onEntityMutation: { - runAfterMutationWithinTransaction: true, - async afterEntityMutation(ctx) { - intercepted = true; - await ctx.client.user.create({ data: { email: 'u2@test.com' } }); + $all: { + runAfterMutationWithinTransaction: true, + async afterEntityMutation(ctx) { + intercepted = true; + await ctx.client.user.create({ data: { email: 'u2@test.com' } }); + }, }, }, }); @@ -492,13 +518,15 @@ describe('Entity mutation hooks tests', () => { const client = _client.$use({ id: 'test', onEntityMutation: { - async beforeEntityMutation(args) { - if (args.action === 'update') { - intercepted = true; - await expect(args.loadBeforeMutationEntities()).resolves.toEqual([ - expect.objectContaining({ email: 'u1@test.com' }), - ]); - } + $all: { + async beforeEntityMutation(args) { + if (args.action === 'update') { + intercepted = true; + await expect(args.loadBeforeMutationEntities()).resolves.toEqual([ + expect.objectContaining({ email: 'u1@test.com' }), + ]); + } + }, }, }, }); @@ -519,11 +547,13 @@ describe('Entity mutation hooks tests', () => { const client = _client.$use({ id: 'test', onEntityMutation: { - async beforeEntityMutation(ctx) { - intercepted = true; - await ctx.client.profile.create({ - data: { bio: 'Bio1' }, - }); + $all: { + async beforeEntityMutation(ctx) { + intercepted = true; + await ctx.client.profile.create({ + data: { bio: 'Bio1' }, + }); + }, }, }, }); @@ -549,27 +579,29 @@ describe('Entity mutation hooks tests', () => { const client = _client.$use({ id: 'test', onEntityMutation: { - async beforeEntityMutation(ctx) { - const r = await ctx.client.user.findUnique({ where: { email: 'u1@test.com' } }); - if (r) { - // second create - txVisible = true; - } else { - // first create - await ctx.client.profile.create({ - data: { bio: 'Bio1' }, + $all: { + async beforeEntityMutation(ctx) { + const r = await ctx.client.user.findUnique({ where: { email: 'u1@test.com' } }); + if (r) { + // second create + txVisible = true; + } else { + // first create + await ctx.client.profile.create({ + data: { bio: 'Bio1' }, + }); + } + }, + async afterEntityMutation(ctx) { + if (intercepted) { + return; + } + intercepted = true; + await ctx.client.user.update({ + where: { email: 'u1@test.com' }, + data: { email: 'u3@test.com' }, }); - } - }, - async afterEntityMutation(ctx) { - if (intercepted) { - return; - } - intercepted = true; - await ctx.client.user.update({ - where: { email: 'u1@test.com' }, - data: { email: 'u3@test.com' }, - }); + }, }, }, }); @@ -602,16 +634,18 @@ describe('Entity mutation hooks tests', () => { const client = _client.$use({ id: 'test', onEntityMutation: { - runAfterMutationWithinTransaction: true, - async afterEntityMutation(ctx) { - if (intercepted) { - return; - } - intercepted = true; - await ctx.client.user.update({ - where: { email: 'u1@test.com' }, - data: { email: 'u3@test.com' }, - }); + $all: { + runAfterMutationWithinTransaction: true, + async afterEntityMutation(ctx) { + if (intercepted) { + return; + } + intercepted = true; + await ctx.client.user.update({ + where: { email: 'u1@test.com' }, + data: { email: 'u3@test.com' }, + }); + }, }, }, }); @@ -642,10 +676,12 @@ describe('Entity mutation hooks tests', () => { const client = _client.$use({ id: 'test', onEntityMutation: { - async afterEntityMutation(ctx) { - intercepted = true; - await ctx.client.user.create({ data: { email: 'u2@test.com' } }); - throw new Error('trigger error'); + $all: { + async afterEntityMutation(ctx) { + intercepted = true; + await ctx.client.user.create({ data: { email: 'u2@test.com' } }); + throw new Error('trigger error'); + }, }, }, }); @@ -668,11 +704,13 @@ describe('Entity mutation hooks tests', () => { const client = _client.$use({ id: 'test', onEntityMutation: { - runAfterMutationWithinTransaction: true, - async afterEntityMutation(ctx) { - intercepted = true; - await ctx.client.user.create({ data: { email: 'u2@test.com' } }); - throw new Error('trigger error'); + $all: { + runAfterMutationWithinTransaction: true, + async afterEntityMutation(ctx) { + intercepted = true; + await ctx.client.user.create({ data: { email: 'u2@test.com' } }); + throw new Error('trigger error'); + }, }, }, }); @@ -691,6 +729,146 @@ describe('Entity mutation hooks tests', () => { await expect(client.user.findMany()).toResolveWithLength(0); }); }); + + it('can use per-model hooks with typed entities', async () => { + let userBeforeEntities: any; + let postBeforeEntities: any; + let userAfterEntities: any; + let postAfterEntities: any; + + const client = _client.$use({ + id: 'test', + onEntityMutation: { + User: { + async beforeEntityMutation(args) { + expect(args.model).toBe('User'); + userBeforeEntities = await args.loadBeforeMutationEntities(); + }, + async afterEntityMutation(args) { + expect(args.model).toBe('User'); + userAfterEntities = await args.loadAfterMutationEntities(); + }, + }, + Post: { + async beforeEntityMutation(args) { + expect(args.model).toBe('Post'); + postBeforeEntities = await args.loadBeforeMutationEntities(); + }, + async afterEntityMutation(args) { + expect(args.model).toBe('Post'); + postAfterEntities = await args.loadAfterMutationEntities(); + }, + }, + }, + }); + + const user = await client.user.create({ + data: { email: 'u1@test.com' }, + }); + + // User after hook should have fired, Post hooks should not + expect(userAfterEntities).toEqual([expect.objectContaining({ email: 'u1@test.com' })]); + expect(postAfterEntities).toBeUndefined(); + + await client.user.update({ + where: { id: user.id }, + data: { email: 'u2@test.com' }, + }); + + // User before hook should have captured old entity + expect(userBeforeEntities).toEqual([expect.objectContaining({ email: 'u1@test.com' })]); + // User after hook should have captured new entity + expect(userAfterEntities).toEqual([expect.objectContaining({ email: 'u2@test.com' })]); + // Post hooks still untouched + expect(postBeforeEntities).toBeUndefined(); + }); + + it('can combine per-model and catch-all hooks', async () => { + const catchAllModels: string[] = []; + const perModelModels: string[] = []; + + const client = _client.$use({ + id: 'test', + onEntityMutation: { + $all: { + afterEntityMutation(args) { + catchAllModels.push(args.model); + }, + }, + User: { + afterEntityMutation(args) { + perModelModels.push(args.model); + }, + }, + }, + }); + + await client.user.create({ + data: { email: 'u1@test.com', posts: { create: { title: 'Post1' } } }, + }); + + // catch-all fires for both User and Post + expect(catchAllModels).toContain('User'); + expect(catchAllModels).toContain('Post'); + + // per-model fires only for User + expect(perModelModels).toEqual(['User']); + }); + + it('per-model hooks provide beforeMutationEntities in after hook', async () => { + let beforeEntitiesInAfterHook: any; + + const client = _client.$use({ + id: 'test', + onEntityMutation: { + User: { + async beforeEntityMutation(args) { + await args.loadBeforeMutationEntities(); + }, + async afterEntityMutation(args) { + beforeEntitiesInAfterHook = args.beforeMutationEntities; + }, + }, + }, + }); + + const user = await client.user.create({ + data: { email: 'u1@test.com' }, + }); + await client.user.update({ + where: { id: user.id }, + data: { email: 'u2@test.com' }, + }); + + expect(beforeEntitiesInAfterHook).toEqual([expect.objectContaining({ email: 'u1@test.com' })]); + }); + + it('per-model hooks infer correct entity types', async () => { + const client = _client.$use({ + id: 'test', + onEntityMutation: { + User: { + async afterEntityMutation(args) { + const entities = await args.loadAfterMutationEntities(); + if (entities) { + const entity = entities[0]; + expect(entity).toBeDefined(); + if (!entity) { + return; + } + // This access compiles without casts because entity is a User + const _email: string = entity.email; + expect(_email).toBeTruthy(); + } + }, + }, + }, + }); + + await client.user.create({ + data: { email: 'u1@test.com' }, + }); + }); }); describe('Entity mutation hooks - delegate model interaction', () => { @@ -713,7 +891,9 @@ model Child extends Base { const withPlugin = client.$use({ id: 'test', onEntityMutation: { - afterEntityMutation() {}, + $all: { + afterEntityMutation() {}, + }, }, }); diff --git a/tests/regression/test/issue-586.test.ts b/tests/regression/test/issue-586.test.ts index 0c1244728..3a69d3590 100644 --- a/tests/regression/test/issue-586.test.ts +++ b/tests/regression/test/issue-586.test.ts @@ -46,9 +46,11 @@ describe('Regression for issue #586', () => { name: 'foo', description: 'foo', onEntityMutation: { - afterEntityMutation: async () => Promise.resolve(), - beforeEntityMutation: async () => Promise.resolve(), - runAfterMutationWithinTransaction: true, + $all: { + afterEntityMutation: async () => Promise.resolve(), + beforeEntityMutation: async () => Promise.resolve(), + runAfterMutationWithinTransaction: true, + }, }, }, ],