From 6a7f6278a955ad4ace6dc398487db25a7c58d70e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Ad=C3=A1mek?= Date: Wed, 24 Jun 2020 00:17:53 +0200 Subject: [PATCH] feat(core): execute hooks via `EventManager` (#623) Lifecycle hooks are now executed the same way as `EntitySubscriber`s, this means that they will also get the `EventArgs` in the parameters. `EventArgs` now also optionally contain the `ChangeSet` object, so it is possible to get the information about what fields actually changed or access the original entity data. Closes #622 --- docs/docs/lifecycle-hooks.md | 18 ++++++++++++ packages/core/src/entity/EntityFactory.ts | 2 +- packages/core/src/events/EventManager.ts | 22 +++++++++------ packages/core/src/events/EventSubscriber.ts | 2 ++ packages/core/src/unit-of-work/ChangeSet.ts | 1 + .../src/unit-of-work/ChangeSetComputer.ts | 4 +++ packages/core/src/unit-of-work/UnitOfWork.ts | 24 ++++------------ tests/EntityManager.mysql.test.ts | 28 +++++++++++-------- tests/entities-sql/Author2.ts | 18 ++++++++---- 9 files changed, 75 insertions(+), 44 deletions(-) diff --git a/docs/docs/lifecycle-hooks.md b/docs/docs/lifecycle-hooks.md index 5d8b6b21a651..e2dca83d0f31 100644 --- a/docs/docs/lifecycle-hooks.md +++ b/docs/docs/lifecycle-hooks.md @@ -9,6 +9,10 @@ There are two ways to hook to the lifecycle of an entity: - **EventSubscriber**s are classes that can be used to hook to multiple entities or when you do not want to have the method present on the entity prototype. +> Hooks are internally executed the same way as subscribers. + +> Hooks are executed before subscribers. + ## Hooks You can use lifecycle hooks to run some code when entity gets persisted. You can mark any of @@ -102,3 +106,17 @@ export class EverythingSubscriber implements EventSubscriber { } ``` + +## EventArgs + +As a parameter to the hook method we get `EventArgs` instance. It will always contain +reference to the current `EntityManager` and the particular entity. Events fired +from `UnitOfWork` during flush operation also contain the `ChangeSet` object. + +```typescript +interface EventArgs { + entity: T; + em: EntityManager; + changeSet?: ChangeSet; +} +``` diff --git a/packages/core/src/entity/EntityFactory.ts b/packages/core/src/entity/EntityFactory.ts index 2cb1f1da500e..cde12ae0ca8d 100644 --- a/packages/core/src/entity/EntityFactory.ts +++ b/packages/core/src/entity/EntityFactory.ts @@ -150,7 +150,7 @@ export class EntityFactory { hooks.forEach(hook => (entity[hook] as unknown as () => void)()); } - this.em.getEventManager().dispatchEvent(EventType.onInit, entity, this.em); + this.em.getEventManager().dispatchEvent(EventType.onInit, entity, { em: this.em }); } } diff --git a/packages/core/src/events/EventManager.ts b/packages/core/src/events/EventManager.ts index cc389ee91e6a..8f3535f39b9a 100644 --- a/packages/core/src/events/EventManager.ts +++ b/packages/core/src/events/EventManager.ts @@ -1,8 +1,8 @@ import { AnyEntity } from '../typings'; -import { EntityManager } from '../EntityManager'; -import { EventSubscriber } from './EventSubscriber'; +import { EventArgs, EventSubscriber } from './EventSubscriber'; import { Utils } from '../utils'; import { EventType } from './EventType'; +import { wrap } from '../entity/wrap'; export class EventManager { @@ -23,24 +23,28 @@ export class EventManager { }); } - dispatchEvent(event: EventType.onInit, entity: AnyEntity, em: EntityManager): unknown; - dispatchEvent(event: EventType, entity: AnyEntity, em: EntityManager): Promise; - dispatchEvent(event: EventType, entity: AnyEntity, em: EntityManager): Promise | unknown { - const listeners: EventSubscriber[] = []; + dispatchEvent>(event: EventType.onInit, entity: T, args: Partial>): unknown; + dispatchEvent>(event: EventType, entity: T, args: Partial>): Promise; + dispatchEvent>(event: EventType, entity: T, args: Partial>): Promise | unknown { + const listeners: [EventType, EventSubscriber][] = []; + + // execute lifecycle hooks first + const hooks = wrap(entity, true).__meta.hooks[event] || []; + listeners.push(...hooks.map(hook => [hook, entity] as [EventType, EventSubscriber])); for (const listener of this.listeners[event] || []) { const entities = this.entities.get(listener)!; if (entities.length === 0 || entities.includes(entity.constructor.name)) { - listeners.push(listener); + listeners.push([event, listener]); } } if (event === EventType.onInit) { - return listeners.forEach(listener => listener[event]!({ em, entity })); + return listeners.forEach(listener => listener[1][listener[0]]!({ ...args, entity } as EventArgs)); } - return Utils.runSerial(listeners, listener => listener[event]!({ em, entity })); + return Utils.runSerial(listeners, listener => listener[1][listener[0]]!({ ...args, entity } as EventArgs) as Promise); } private getSubscribedEntities(listener: EventSubscriber): string[] { diff --git a/packages/core/src/events/EventSubscriber.ts b/packages/core/src/events/EventSubscriber.ts index a2f634d577f9..c2715f3fa3cd 100644 --- a/packages/core/src/events/EventSubscriber.ts +++ b/packages/core/src/events/EventSubscriber.ts @@ -1,9 +1,11 @@ import { AnyEntity, EntityName } from '../typings'; import { EntityManager } from '../EntityManager'; +import { ChangeSet } from '../unit-of-work'; export interface EventArgs { entity: T; em: EntityManager; + changeSet?: ChangeSet; } export interface EventSubscriber { diff --git a/packages/core/src/unit-of-work/ChangeSet.ts b/packages/core/src/unit-of-work/ChangeSet.ts index f65b4f4ed1aa..5514cab167b2 100644 --- a/packages/core/src/unit-of-work/ChangeSet.ts +++ b/packages/core/src/unit-of-work/ChangeSet.ts @@ -7,6 +7,7 @@ export interface ChangeSet> { entity: T; payload: EntityData; persisted: boolean; + originalEntity?: EntityData; } export enum ChangeSetType { diff --git a/packages/core/src/unit-of-work/ChangeSetComputer.ts b/packages/core/src/unit-of-work/ChangeSetComputer.ts index f4e6a31c639e..36c22ca0ec6f 100644 --- a/packages/core/src/unit-of-work/ChangeSetComputer.ts +++ b/packages/core/src/unit-of-work/ChangeSetComputer.ts @@ -24,6 +24,10 @@ export class ChangeSetComputer { changeSet.collection = meta.collection; changeSet.payload = this.computePayload(entity); + if (changeSet.type === ChangeSetType.UPDATE) { + changeSet.originalEntity = this.originalEntityData[wrap(entity, true).__uuid]; + } + this.validator.validate(changeSet.entity, changeSet.payload, meta); for (const prop of Object.values(meta.properties)) { diff --git a/packages/core/src/unit-of-work/UnitOfWork.ts b/packages/core/src/unit-of-work/UnitOfWork.ts index c7fd2a3a3567..4b1cbacdc914 100644 --- a/packages/core/src/unit-of-work/UnitOfWork.ts +++ b/packages/core/src/unit-of-work/UnitOfWork.ts @@ -268,7 +268,9 @@ export class UnitOfWork { } const type = changeSet.type.charAt(0).toUpperCase() + changeSet.type.slice(1); - await this.runHooks(`before${type}` as EventType, changeSet.entity, changeSet.payload); + const copy = Utils.prepareEntity(changeSet.entity, this.metadata, this.platform) as T; + await this.runHooks(`before${type}` as EventType, changeSet); + Object.assign(changeSet.payload, Utils.diffEntities(copy, changeSet.entity, this.metadata, this.platform)); await this.changeSetPersister.persistToDatabase(changeSet, ctx); switch (changeSet.type) { @@ -277,25 +279,11 @@ export class UnitOfWork { case ChangeSetType.DELETE: this.unsetIdentity(changeSet.entity as T); break; } - await this.runHooks(`after${type}` as EventType, changeSet.entity as T); + await this.runHooks(`after${type}` as EventType, changeSet); } - private async runHooks>(type: EventType, entity: T, payload?: EntityData) { - const hooks = this.metadata.get(entity.constructor.name).hooks; - - /* istanbul ignore next */ - if ((hooks?.[type]?.length || 0) === 0) { - return await this.em.getEventManager().dispatchEvent(type, entity, this.em); - } - - const copy = Utils.prepareEntity(entity, this.metadata, this.platform); - await Utils.runSerial(hooks[type]!, hook => (entity[hook] as unknown as () => Promise)()); - - if (payload) { - Object.assign(payload, Utils.diffEntities(copy as T, entity, this.metadata, this.platform)); - } - - await this.em.getEventManager().dispatchEvent(type, entity, this.em); + private async runHooks>(type: EventType, changeSet: ChangeSet) { + await this.em.getEventManager().dispatchEvent(type, changeSet.entity, { em: this.em, changeSet }); } /** diff --git a/tests/EntityManager.mysql.test.ts b/tests/EntityManager.mysql.test.ts index afb8466b3144..3d3dd6f4db5c 100644 --- a/tests/EntityManager.mysql.test.ts +++ b/tests/EntityManager.mysql.test.ts @@ -1157,16 +1157,21 @@ describe('EntityManagerMySql', () => { expect(author.id).toBeUndefined(); expect(author.version).toBeUndefined(); expect(author.versionAsString).toBeUndefined(); + expect(author.hookParams).toHaveLength(0); await repo.persistAndFlush(author); expect(author.id).toBeDefined(); expect(author.version).toBe(1); expect(author.versionAsString).toBe('v1'); + expect(author.hookParams[0].em).toBe(orm.em); + expect(author.hookParams[0].changeSet).toMatchObject({ entity: author, type: 'create', payload: { name: 'Jon Snow' } }); author.name = 'John Snow'; await repo.persistAndFlush(author); expect(author.version).toBe(2); expect(author.versionAsString).toBe('v2'); + expect(author.hookParams[2].em).toBe(orm.em); + expect(author.hookParams[2].changeSet).toMatchObject({ entity: author, type: 'update', payload: { name: 'John Snow' }, originalEntity: { name: 'Jon Snow' } }); expect(Author2.beforeDestroyCalled).toBe(0); expect(Author2.afterDestroyCalled).toBe(0); @@ -1179,17 +1184,18 @@ describe('EntityManagerMySql', () => { await repo.removeAndFlush(author2); expect(Author2.beforeDestroyCalled).toBe(2); expect(Author2.afterDestroyCalled).toBe(2); - expect(Author2Subscriber.log).toEqual([ - ['beforeCreate', { em: orm.em, entity: author }], - ['afterCreate', { em: orm.em, entity: author }], - ['beforeUpdate', { em: orm.em, entity: author }], - ['afterUpdate', { em: orm.em, entity: author }], - ['beforeDelete', { em: orm.em, entity: author }], - ['afterDelete', { em: orm.em, entity: author }], - ['beforeCreate', { em: orm.em, entity: author2 }], - ['afterCreate', { em: orm.em, entity: author2 }], - ['beforeDelete', { em: orm.em, entity: author2 }], - ['afterDelete', { em: orm.em, entity: author2 }], + + expect(Author2Subscriber.log.map(l => [l[0], l[1].entity.constructor.name])).toEqual([ + ['beforeCreate', 'Author2'], + ['afterCreate', 'Author2'], + ['beforeUpdate', 'Author2'], + ['afterUpdate', 'Author2'], + ['beforeDelete', 'Author2'], + ['afterDelete', 'Author2'], + ['beforeCreate', 'Author2'], + ['afterCreate', 'Author2'], + ['beforeDelete', 'Author2'], + ['afterDelete', 'Author2'], ]); }); diff --git a/tests/entities-sql/Author2.ts b/tests/entities-sql/Author2.ts index a00f155d0b96..8f40c6997f44 100644 --- a/tests/entities-sql/Author2.ts +++ b/tests/entities-sql/Author2.ts @@ -1,6 +1,6 @@ import { AfterCreate, AfterDelete, AfterUpdate, BeforeCreate, BeforeDelete, BeforeUpdate, Collection, Entity, OneToMany, Property, ManyToOne, - QueryOrder, OnInit, ManyToMany, DateType, TimeType, Index, Unique, OneToOne, Cascade, LoadStrategy, + QueryOrder, OnInit, ManyToMany, DateType, TimeType, Index, Unique, OneToOne, Cascade, LoadStrategy, EventArgs, } from '@mikro-orm/core'; import { Book2 } from './Book2'; @@ -84,6 +84,8 @@ export class Author2 extends BaseEntity2 { @Property({ persist: false }) booksTotal!: number; + hookParams: any[] = []; + constructor(name: string, email: string) { super(); this.name = name; @@ -93,26 +95,32 @@ export class Author2 extends BaseEntity2 { @OnInit() onInit() { this.code = `${this.email} - ${this.name}`; + this.hookParams = []; } @BeforeCreate() - beforeCreate() { + beforeCreate(args: EventArgs) { this.version = 1; + this.hookParams.push(args); } @AfterCreate() - afterCreate() { + afterCreate(args: EventArgs) { this.versionAsString = 'v' + this.version; + this.hookParams.push(args); } @BeforeUpdate() - beforeUpdate() { + beforeUpdate(args: EventArgs) { this.version += 1; + console.log(this); + this.hookParams.push(args); } @AfterUpdate() - afterUpdate() { + afterUpdate(args: EventArgs) { this.versionAsString = 'v' + this.version; + this.hookParams.push(args); } @BeforeDelete()