diff --git a/package-lock.json b/package-lock.json index 4770ff6..5cce906 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@athenna/database", - "version": "5.57.0", + "version": "5.58.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@athenna/database", - "version": "5.57.0", + "version": "5.58.0", "license": "MIT", "dependencies": { "@faker-js/faker": "^8.4.1" diff --git a/package.json b/package.json index a67cd31..de68a20 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@athenna/database", - "version": "5.57.0", + "version": "5.58.0", "description": "The Athenna database handler for SQL/NoSQL.", "license": "MIT", "author": "João Lenon ", diff --git a/src/constants/MetadataKeys.ts b/src/constants/MetadataKeys.ts index 9ea152a..bd2e42d 100644 --- a/src/constants/MetadataKeys.ts +++ b/src/constants/MetadataKeys.ts @@ -8,6 +8,7 @@ */ export const COLUMNS_KEY = 'database:columns:options' +export const HOOKS_KEY = 'database:hooks:options' export const HAS_ONE_KEY = 'database:hasOne:options' export const HAS_MANY_KEY = 'database:hasMany:options' export const HAS_ONE_THROUGH_KEY = 'database:hasOneThrough:options' diff --git a/src/helpers/Annotation.ts b/src/helpers/Annotation.ts index 4451485..569ee99 100644 --- a/src/helpers/Annotation.ts +++ b/src/helpers/Annotation.ts @@ -7,8 +7,11 @@ * file that was distributed with this source code. */ +import 'reflect-metadata' + import { COLUMNS_KEY, + HOOKS_KEY, HAS_ONE_KEY, HAS_MANY_KEY, HAS_ONE_THROUGH_KEY, @@ -19,6 +22,7 @@ import { import type { RelationOptions, ColumnOptions, + ModelHookOptions, HasOneOptions, HasManyOptions, HasOneThroughOptions, @@ -40,6 +44,38 @@ export class Annotation { Reflect.defineMetadata(COLUMNS_KEY, columns, target) } + /** + * Get the lifecycle hooks of the model, including the ones inherited + * from parent models. Hooks are returned in firing order: parent + * class hooks first, each class in declaration order. + * + * Metadata is intentionally read per-class (own metadata) and merged + * by walking the prototype chain: `Reflect.getMetadata()` alone would + * return only the closest metadata in the chain, either hiding parent + * hooks or (worse) leaking child hooks into the parent when the child + * pushes into the parent's inherited array. + */ + public static getHooksMeta(target: any): ModelHookOptions[] { + const hooks: ModelHookOptions[] = [] + let current = target + + while (current && current !== Function.prototype) { + hooks.unshift(...(Reflect.getOwnMetadata(HOOKS_KEY, current) || [])) + + current = Object.getPrototypeOf(current) + } + + return hooks + } + + public static defineHookMeta(target: any, options: ModelHookOptions) { + const hooks = Reflect.getOwnMetadata(HOOKS_KEY, target) || [] + + hooks.push(options) + + Reflect.defineMetadata(HOOKS_KEY, hooks, target) + } + public static getRelationsMeta(target: any): RelationOptions[] { return [ ...this.getHasOnesMeta(target), diff --git a/src/index.ts b/src/index.ts index 93ac2a4..ed37a88 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ export * from '#src/models/BaseModel' export * from '#src/models/builders/ModelQueryBuilder' export * from '#src/models/schemas/ModelSchema' export * from '#src/models/annotations/Column' +export * from '#src/models/annotations/Hooks' export * from '#src/models/annotations/HasOne' export * from '#src/models/annotations/HasMany' export * from '#src/models/annotations/HasOneThrough' diff --git a/src/models/BaseModel.ts b/src/models/BaseModel.ts index 8797530..0992fe0 100644 --- a/src/models/BaseModel.ts +++ b/src/models/BaseModel.ts @@ -742,6 +742,10 @@ export class BaseModel { /** * Save the changes done in the model in database. + * + * Lifecycle hooks are fired here with the model instance as + * payload, so the persistence queries run `withoutHooks()` to + * avoid firing them a second time at the query builder level. */ public async save(cleanPersist = true) { const Model = this.constructor as any @@ -773,30 +777,50 @@ export class BaseModel { this[deletedAt.property] = null } + const isNew = !this.isPersisted() + + await schema.fireHooks('beforeSave', this) + await schema.fireHooks(isNew ? 'beforeCreate' : 'beforeUpdate', this) + const data = this.dirty() - if (!this.isPersisted()) { - const created = await Model.create(data, cleanPersist) + if (isNew) { + const created = await Model.query() + .withoutHooks() + .create(data, cleanPersist) Object.keys(created).forEach(key => (this[key] = created[key])) - return this.setOriginal() + this.setOriginal() + + await schema.fireHooks('afterCreate', this) + await schema.fireHooks('afterSave', this) + + return this } /** * Means data is not dirty because there are any * value that is different from original symbol. + * No query runs, so no after hook fires either. */ if (!Object.keys(data).length) { return this } - const where = { [primaryKey]: this[primaryKey] } - const updated = await Model.update(where, data, cleanPersist) + const updated = await Model.query() + .withoutHooks() + .where(primaryKey, this[primaryKey]) + .update(data, cleanPersist) Object.keys(updated).forEach(key => (this[key] = updated[key])) - return this.setOriginal() + this.setOriginal() + + await schema.fireHooks('afterUpdate', this) + await schema.fireHooks('afterSave', this) + + return this } /** @@ -853,12 +877,23 @@ export class BaseModel { /** * Delete or soft delete your model from database. + * + * The `beforeDelete`/`afterDelete` hooks are fired here with the + * model instance as payload. Query deletes fire no delete hooks. */ public async delete(force = false) { const Model = this.constructor as any - const primaryKey = Model.schema().getMainPrimaryKeyProperty() + const schema = Model.schema() + const primaryKey = schema.getMainPrimaryKeyProperty() + + await schema.fireHooks('beforeDelete', this) + + await Model.query() + .withoutHooks() + .where(primaryKey, this[primaryKey]) + .delete(force) - await Model.query().where(primaryKey, this[primaryKey]).delete(force) + await schema.fireHooks('afterDelete', this) } /** diff --git a/src/models/annotations/Hooks.ts b/src/models/annotations/Hooks.ts new file mode 100644 index 0000000..34cc0f7 --- /dev/null +++ b/src/models/annotations/Hooks.ts @@ -0,0 +1,122 @@ +/** + * @athenna/database + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import 'reflect-metadata' + +import { debug } from '#src/debug' +import { Annotation } from '#src/helpers/Annotation' +import type { ModelHookType } from '#src/types' + +/** + * Register a static model method as a lifecycle hook. Hooks are + * inherited by child models and fired parent-first, in declaration + * order. The payload each hook receives depends on the operation, + * see {@link ModelHookOptions.method}. + */ +function createHookAnnotation(type: ModelHookType): MethodDecorator { + return (target: any, key: any, descriptor: PropertyDescriptor) => { + /** + * Static methods hand the constructor as target, instance + * methods hand the prototype. `Is.Function()` can't be used + * here because it returns false for classes. + */ + const Target = typeof target === 'function' ? target : target.constructor + + debug('registering %s hook for model %s: %s', type, Target.name, key) + + Annotation.defineHookMeta(Target, { type, method: descriptor.value }) + } +} + +/** + * Fire the method before creating models. Receives the data object + * (query/static creates) or the model instance (`model.save()`) and + * may mutate it to change what is persisted. + */ +export function BeforeCreate(): MethodDecorator { + return createHookAnnotation('beforeCreate') +} + +/** + * Fire the method after creating models. Receives each created + * model instance. + */ +export function AfterCreate(): MethodDecorator { + return createHookAnnotation('afterCreate') +} + +/** + * Fire the method before updating models. Receives the data object + * (query/static updates) or the model instance (`model.save()`) and + * may mutate it to change what is persisted. + */ +export function BeforeUpdate(): MethodDecorator { + return createHookAnnotation('beforeUpdate') +} + +/** + * Fire the method after updating models. Receives each updated + * model instance. + */ +export function AfterUpdate(): MethodDecorator { + return createHookAnnotation('afterUpdate') +} + +/** + * Fire the method before creating or updating models, always before + * the `beforeCreate`/`beforeUpdate` hooks. Receives the same payload + * they do. + */ +export function BeforeSave(): MethodDecorator { + return createHookAnnotation('beforeSave') +} + +/** + * Fire the method after creating or updating models, always after + * the `afterCreate`/`afterUpdate` hooks. Receives the same payload + * they do. + */ +export function AfterSave(): MethodDecorator { + return createHookAnnotation('afterSave') +} + +/** + * Fire the method before deleting a model via `model.delete()`. + * Receives the model instance. Query/static deletes (bulk) don't + * fire delete hooks since there is no instance to hand over. + */ +export function BeforeDelete(): MethodDecorator { + return createHookAnnotation('beforeDelete') +} + +/** + * Fire the method after deleting a model via `model.delete()`. + * Receives the model instance. + */ +export function AfterDelete(): MethodDecorator { + return createHookAnnotation('afterDelete') +} + +/** + * Fire the method before executing `find()`, `findMany()` and + * `paginate()` queries. Receives the model query builder, so the + * hook can add default constraints. + */ +export function BeforeFind(): MethodDecorator { + return createHookAnnotation('beforeFind') +} + +/** + * Fire the method after retrieving models from `find()`, `findMany()` + * and `paginate()` queries. Receives each model instance retrieved. + * Not fired for custom selects, which return raw data. + */ +export function AfterFind(): MethodDecorator { + return createHookAnnotation('afterFind') +} diff --git a/src/models/builders/ModelQueryBuilder.ts b/src/models/builders/ModelQueryBuilder.ts index a020885..163d678 100644 --- a/src/models/builders/ModelQueryBuilder.ts +++ b/src/models/builders/ModelQueryBuilder.ts @@ -47,6 +47,7 @@ export class ModelQueryBuilder< private primaryKeyName: string private primaryKeyProperty: ModelColumns private isToSetAttributes: boolean = true + private isToFireHooks: boolean = true private isToValidateUnique: boolean = true private isToValidateNullable: boolean = true private selectColumns: string[] = [] @@ -242,10 +243,26 @@ export class ModelQueryBuilder< return super.pluckMany(columnName) } + /** + * Disable firing the model lifecycle hooks for this query + * instance. Used internally by the instance operations + * (`model.save()`, `model.delete()`), which fire the hooks + * themselves with the model instance as payload. + */ + public withoutHooks() { + this.isToFireHooks = false + + return this + } + /** * Find a value in database. */ public async find() { + if (this.isToFireHooks) { + await this.schema.fireHooks('beforeFind', this) + } + this.setInternalQueries() const data = await super.find() @@ -256,7 +273,13 @@ export class ModelQueryBuilder< return data } - return this.generator.generateOne(data) + const model = await this.generator.generateOne(data) + + if (model && this.isToFireHooks) { + await this.schema.fireHooks('afterFind', model) + } + + return model } /** @@ -314,6 +337,10 @@ export class ModelQueryBuilder< * Find many values in database. */ public async findMany() { + if (this.isToFireHooks) { + await this.schema.fireHooks('beforeFind', this) + } + this.setInternalQueries() const data = await super.findMany() @@ -324,7 +351,15 @@ export class ModelQueryBuilder< return data } - return this.generator.generateMany(data) + const models = await this.generator.generateMany(data) + + if (this.isToFireHooks) { + for (const model of models) { + await this.schema.fireHooks('afterFind', model) + } + } + + return models } /** @@ -335,6 +370,10 @@ export class ModelQueryBuilder< limit = 10, resourceUrl = '/' ) { + if (this.isToFireHooks) { + await this.schema.fireHooks('beforeFind', this) + } + this.setInternalQueries() const data = await super.paginate(page, limit, resourceUrl) @@ -347,6 +386,12 @@ export class ModelQueryBuilder< data.data = await this.generator.generateMany(data.data) + if (this.isToFireHooks) { + for (const model of data.data) { + await this.schema.fireHooks('afterFind', model) + } + } + return data } @@ -373,6 +418,13 @@ export class ModelQueryBuilder< * Create many values in database. */ public async createMany(data: Partial[], cleanPersist = true) { + if (this.isToFireHooks) { + for (const d of data) { + await this.schema.fireHooks('beforeSave', d) + await this.schema.fireHooks('beforeCreate', d) + } + } + data = await Promise.all( data.map(async d => { const parsed = this.toPersistColumns(d, cleanPersist) @@ -385,8 +437,16 @@ export class ModelQueryBuilder< ) const created = await super.createMany(data) + const models = await this.generator.generateMany(created) - return this.generator.generateMany(created) + if (this.isToFireHooks) { + for (const model of models) { + await this.schema.fireHooks('afterCreate', model) + await this.schema.fireHooks('afterSave', model) + } + } + + return models } /** @@ -447,6 +507,11 @@ export class ModelQueryBuilder< * the race-prone model unique pre-check. */ public async createOrIgnore(data: Partial = {}, cleanPersist = true) { + if (this.isToFireHooks) { + await this.schema.fireHooks('beforeSave', data) + await this.schema.fireHooks('beforeCreate', data) + } + this.setInternalQueries() const parsed = this.toPersistColumns(data, cleanPersist) @@ -459,14 +524,30 @@ export class ModelQueryBuilder< return null } - return this.generator.generateOne(created) + const model = await this.generator.generateOne(created) + + if (this.isToFireHooks) { + await this.schema.fireHooks('afterCreate', model) + await this.schema.fireHooks('afterSave', model) + } + + return model } /** * Find the first value matching the current query or create it, never * throwing on a concurrent unique violation. Always returns a model. + * + * Since the driver can't tell apart a created row from a concurrently + * found one, the `afterCreate`/`afterSave` hooks always fire with the + * returned model, even when it already existed. */ public async createOrFirst(data: Partial = {}, cleanPersist = true) { + if (this.isToFireHooks) { + await this.schema.fireHooks('beforeSave', data) + await this.schema.fireHooks('beforeCreate', data) + } + this.setInternalQueries() const parsed = this.toPersistColumns(data, cleanPersist) @@ -474,14 +555,47 @@ export class ModelQueryBuilder< this.validateNullable(parsed) const value = await super.createOrFirst(parsed) + const model = await this.generator.generateOne(value) - return this.generator.generateOne(value) + if (this.isToFireHooks) { + await this.schema.fireHooks('afterCreate', model) + await this.schema.fireHooks('afterSave', model) + } + + return model } /** * Update a value in database. */ public async update(data: Partial, cleanPersist = true) { + if (this.isToFireHooks) { + await this.schema.fireHooks('beforeSave', data) + await this.schema.fireHooks('beforeUpdate', data) + } + + const updated = await this.rawUpdate(data, cleanPersist) + + if (this.isToFireHooks) { + const models = (Is.Array(updated) ? updated : [updated]).filter( + model => !!model + ) + + for (const model of models) { + await this.schema.fireHooks('afterUpdate', model) + await this.schema.fireHooks('afterSave', model) + } + } + + return updated + } + + /** + * Update a value in database without firing the lifecycle hooks. + * Used by the soft delete path, which must not masquerade as an + * update to the hooks. + */ + private async rawUpdate(data: Partial, cleanPersist = true) { this.setInternalQueries() const date = new Date() @@ -510,6 +624,10 @@ export class ModelQueryBuilder< /** * Delete or soft delete a value in database. + * + * Delete hooks are fired only by instance deletes (`model.delete()`), + * where the model instance is known. Query deletes fire no hooks — + * not even update hooks on the soft delete path. */ public async delete(force = false): Promise { this.setInternalQueries({ addSelect: false }) @@ -520,7 +638,7 @@ export class ModelQueryBuilder< return } - await this.update({ [this.DELETED_AT_PROP]: new Date() } as any) + await this.rawUpdate({ [this.DELETED_AT_PROP]: new Date() } as any) } /** @@ -1486,6 +1604,7 @@ export class ModelQueryBuilder< if (isUpdate) { const data = await this.Model.query() + .withoutHooks() .where(column.name as never, value) .findMany() @@ -1497,6 +1616,7 @@ export class ModelQueryBuilder< } const isDuplicated = await this.Model.query() + .withoutHooks() .where(column.name as never, value) .exists() diff --git a/src/models/schemas/ModelSchema.ts b/src/models/schemas/ModelSchema.ts index f1e6de1..348ce1e 100644 --- a/src/models/schemas/ModelSchema.ts +++ b/src/models/schemas/ModelSchema.ts @@ -11,7 +11,9 @@ import type { ColumnOptions, ModelColumns, ModelRelations, - RelationOptions + RelationOptions, + ModelHookType, + ModelHookOptions } from '#src/types' import { Database } from '#src/facades/Database' import { Annotation } from '#src/helpers/Annotation' @@ -32,6 +34,13 @@ export class ModelSchema extends Macroable { */ public relations: RelationOptions[] + /** + * Save the lifecycle hooks defined by the \@Before...() and + * \@After...() annotations. Not copied like columns/relations + * because hooks hold function references. + */ + public hooks: ModelHookOptions[] + /** * The model class that is going to be used * to craft the schema. @@ -43,6 +52,26 @@ export class ModelSchema extends Macroable { this.Model = model this.columns = Json.copy(Annotation.getColumnsMeta(model)) this.relations = Json.copy(Annotation.getRelationsMeta(model)) + this.hooks = Annotation.getHooksMeta(model) + } + + /** + * Get all the lifecycle hooks registered for a given type, + * in firing order. + */ + public getHooksByType(type: ModelHookType): ModelHookOptions[] { + return this.hooks.filter(hook => hook.type === type) + } + + /** + * Fire all the lifecycle hooks of a given type sequentially, + * awaiting async hooks. Hooks are called with the model class + * as `this` and receive the given payload. + */ + public async fireHooks(type: ModelHookType, payload: any): Promise { + for (const hook of this.getHooksByType(type)) { + await hook.method.call(this.Model, payload) + } } /** diff --git a/src/types/ModelHookOptions.ts b/src/types/ModelHookOptions.ts new file mode 100644 index 0000000..078c520 --- /dev/null +++ b/src/types/ModelHookOptions.ts @@ -0,0 +1,49 @@ +/** + * @athenna/database + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +/** + * All the model lifecycle hook types that can be registered + * with the `@Before...()` and `@After...()` annotations. + */ +export type ModelHookType = + | 'beforeCreate' + | 'afterCreate' + | 'beforeUpdate' + | 'afterUpdate' + | 'beforeSave' + | 'afterSave' + | 'beforeDelete' + | 'afterDelete' + | 'beforeFind' + | 'afterFind' + +/** + * The metadata registered for each model lifecycle hook. + */ +export interface ModelHookOptions { + /** + * The lifecycle moment when the hook method will be fired. + */ + type: ModelHookType + + /** + * The static model method that will be fired. The method is + * always called with the model class as `this` and receives + * a single payload argument that changes per operation: + * + * - Instance operations (`model.save()`, `model.delete()`) + * receive the model instance. + * - Query/static create and update operations receive the data + * object in before hooks and the resulting model instance in + * after hooks. + * - `beforeFind` receives the model query builder. + * - `afterFind` receives each model instance retrieved. + */ + method: (payload?: any) => any +} diff --git a/src/types/index.ts b/src/types/index.ts index 7c2c7df..184b6eb 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -15,6 +15,7 @@ export * from '#src/types/columns/ColumnType' export * from '#src/types/relations/Relation' export * from '#src/types/columns/ModelColumns' export * from '#src/types/columns/ColumnOptions' +export * from '#src/types/ModelHookOptions' export * from '#src/types/relations/HasOneOptions' export * from '#src/types/relations/HasManyOptions' diff --git a/tests/fixtures/models/UserHooked.ts b/tests/fixtures/models/UserHooked.ts new file mode 100644 index 0000000..76069dd --- /dev/null +++ b/tests/fixtures/models/UserHooked.ts @@ -0,0 +1,103 @@ +/** + * @athenna/database + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { BaseModel } from '#src/models/BaseModel' +import { Column } from '#src/models/annotations/Column' +import { + BeforeCreate, + AfterCreate, + BeforeUpdate, + AfterUpdate, + BeforeSave, + AfterSave, + BeforeDelete, + AfterDelete, + BeforeFind, + AfterFind +} from '#src/models/annotations/Hooks' + +export class UserHooked extends BaseModel { + public static connection() { + return 'fake' + } + + public static table() { + return 'users' + } + + /** + * Recorded hook firings so tests can assert order and payloads. + */ + public static events: { type: string; payload: any }[] = [] + + @Column() + public id: string + + @Column() + public name: string + + @Column({ isCreateDate: true, name: 'created_at' }) + public createdAt: Date + + @Column({ isUpdateDate: true, name: 'updated_at' }) + public updatedAt: Date + + @Column({ isDeleteDate: true, name: 'deleted_at' }) + public deletedAt: Date + + @BeforeCreate() + public static beforeCreateHook(payload: any) { + this.events.push({ type: 'beforeCreate', payload }) + } + + @AfterCreate() + public static afterCreateHook(payload: any) { + this.events.push({ type: 'afterCreate', payload }) + } + + @BeforeUpdate() + public static beforeUpdateHook(payload: any) { + this.events.push({ type: 'beforeUpdate', payload }) + } + + @AfterUpdate() + public static afterUpdateHook(payload: any) { + this.events.push({ type: 'afterUpdate', payload }) + } + + @BeforeSave() + public static beforeSaveHook(payload: any) { + this.events.push({ type: 'beforeSave', payload }) + } + + @AfterSave() + public static afterSaveHook(payload: any) { + this.events.push({ type: 'afterSave', payload }) + } + + @BeforeDelete() + public static beforeDeleteHook(payload: any) { + this.events.push({ type: 'beforeDelete', payload }) + } + + @AfterDelete() + public static afterDeleteHook(payload: any) { + this.events.push({ type: 'afterDelete', payload }) + } + + @BeforeFind() + public static beforeFindHook(payload: any) { + this.events.push({ type: 'beforeFind', payload }) + } + + @AfterFind() + public static afterFindHook(payload: any) { + this.events.push({ type: 'afterFind', payload }) + } +} diff --git a/tests/unit/models/ModelHooksTest.ts b/tests/unit/models/ModelHooksTest.ts new file mode 100644 index 0000000..02501a7 --- /dev/null +++ b/tests/unit/models/ModelHooksTest.ts @@ -0,0 +1,341 @@ +/** + * @athenna/database + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Path } from '@athenna/common' +import { Database } from '#src/facades/Database' +import { BaseModel } from '#src/models/BaseModel' +import { Annotation } from '#src/helpers/Annotation' +import { UserHooked } from '#tests/fixtures/models/UserHooked' +import { DatabaseProvider } from '#src/providers/DatabaseProvider' +import { BeforeCreate } from '#src/models/annotations/Hooks' +import { ModelQueryBuilder } from '#src/models/builders/ModelQueryBuilder' +import { Test, type Context, BeforeEach, AfterEach, Mock } from '@athenna/test' + +export default class ModelHooksTest { + @BeforeEach() + public async beforeEach() { + new DatabaseProvider().register() + await Config.loadAll(Path.fixtures('config')) + Database.connection('fake') + UserHooked.events = [] + } + + @AfterEach() + public afterEach() { + Config.clear() + ioc.reconstruct() + Mock.restoreAll() + BaseModel.setAttributes(true).uniqueValidation(true).nullableValidation(true) + } + + private eventTypes() { + return UserHooked.events.map(event => event.type) + } + + @Test() + public async shouldRegisterHooksMetadataForAllTenAnnotations({ assert }: Context) { + const hooks = Annotation.getHooksMeta(UserHooked) + + assert.lengthOf(hooks, 10) + assert.deepEqual( + hooks.map(h => h.type), + [ + 'beforeCreate', + 'afterCreate', + 'beforeUpdate', + 'afterUpdate', + 'beforeSave', + 'afterSave', + 'beforeDelete', + 'afterDelete', + 'beforeFind', + 'afterFind' + ] + ) + } + + @Test() + public async shouldInheritHooksMetadataFromParentModelsParentFirst({ assert }: Context) { + class SubUserHooked extends UserHooked { + @BeforeCreate() + public static subBeforeCreateHook() {} + } + + const hooks = Annotation.getHooksMeta(SubUserHooked) + + assert.lengthOf(hooks, 11) + assert.deepEqual(hooks[0].type, 'beforeCreate') + assert.deepEqual(hooks[10].method, SubUserHooked.subBeforeCreateHook) + + /** + * Child hooks must never leak into the parent metadata. + */ + assert.lengthOf(Annotation.getHooksMeta(UserHooked), 10) + } + + @Test() + public async shouldFireCreateHooksInOrderWhenCreatingByStaticMethod({ assert }: Context) { + Mock.when(Database.driver, 'createMany').resolve([{ id: '1', name: 'lenon' }]) + + const data = { name: 'lenon' } + const user = await UserHooked.create(data) + + assert.deepEqual(this.eventTypes(), ['beforeSave', 'beforeCreate', 'afterCreate', 'afterSave']) + assert.deepEqual(UserHooked.events[0].payload, data) + assert.instanceOf(UserHooked.events[2].payload, UserHooked) + assert.deepEqual(UserHooked.events[2].payload, user) + } + + @Test() + public async shouldFireCreateHooksPerItemWhenCreatingMany({ assert }: Context) { + Mock.when(Database.driver, 'createMany').resolve([{ id: '1' }, { id: '2' }]) + + await UserHooked.createMany([{ name: 'lenon' }, { name: 'txsoura' }]) + + assert.deepEqual(this.eventTypes(), [ + 'beforeSave', + 'beforeCreate', + 'beforeSave', + 'beforeCreate', + 'afterCreate', + 'afterSave', + 'afterCreate', + 'afterSave' + ]) + } + + @Test() + public async shouldAllowBeforeCreateHooksToMutateTheDataBeforePersisting({ assert }: Context) { + class MutatingUserHooked extends UserHooked { + @BeforeCreate() + public static mutateHook(data: any) { + data.name = 'mutated' + } + } + + Mock.when(Database.driver, 'createMany').resolve([{ id: '1', name: 'mutated' }]) + + await MutatingUserHooked.create({ name: 'original' }) + + const persisted = (Database.driver.createMany as any).getCall(0).args[0] + + assert.deepEqual(persisted[0].name, 'mutated') + } + + @Test() + public async shouldFireUpdateHooksInOrderWhenUpdatingByStaticMethod({ assert }: Context) { + Mock.when(Database.driver, 'update').resolve({ id: '1', name: 'lenon' }) + + const data = { name: 'lenon' } + const user = await UserHooked.update({ id: '1' }, data) + + assert.deepEqual(this.eventTypes(), ['beforeSave', 'beforeUpdate', 'afterUpdate', 'afterSave']) + assert.deepEqual(UserHooked.events[0].payload, data) + assert.instanceOf(UserHooked.events[2].payload, UserHooked) + assert.deepEqual(UserHooked.events[2].payload, user) + } + + @Test() + public async shouldFireAfterUpdateHooksPerModelWhenUpdateReturnsMany({ assert }: Context) { + Mock.when(Database.driver, 'update').resolve([{ id: '1' }, { id: '2' }]) + + await UserHooked.update({}, { name: 'lenon' }) + + assert.deepEqual(this.eventTypes(), [ + 'beforeSave', + 'beforeUpdate', + 'afterUpdate', + 'afterSave', + 'afterUpdate', + 'afterSave' + ]) + } + + @Test() + public async shouldFireFindHooksWhenFindingOneValue({ assert }: Context) { + Mock.when(Database.driver, 'find').resolve({ id: '1', name: 'lenon' }) + + const user = await UserHooked.find() + + assert.deepEqual(this.eventTypes(), ['beforeFind', 'afterFind']) + assert.instanceOf(UserHooked.events[0].payload, ModelQueryBuilder) + assert.deepEqual(UserHooked.events[1].payload, user) + } + + @Test() + public async shouldNotFireAfterFindHookWhenNothingIsFound({ assert }: Context) { + Mock.when(Database.driver, 'find').resolve(undefined) + + await UserHooked.find() + + assert.deepEqual(this.eventTypes(), ['beforeFind']) + } + + @Test() + public async shouldFireAfterFindHooksPerModelWhenFindingManyValues({ assert }: Context) { + Mock.when(Database.driver, 'findMany').resolve([{ id: '1' }, { id: '2' }]) + + await UserHooked.findMany() + + assert.deepEqual(this.eventTypes(), ['beforeFind', 'afterFind', 'afterFind']) + } + + @Test() + public async shouldFireFindHooksWhenPaginating({ assert }: Context) { + Mock.when(Database.driver, 'paginate').resolve({ + data: [{ id: '1' }], + meta: {}, + links: {} + }) + + await UserHooked.paginate() + + assert.deepEqual(this.eventTypes(), ['beforeFind', 'afterFind']) + } + + @Test() + public async shouldFireCreateHooksOnceWithTheInstanceWhenSavingANewModel({ assert }: Context) { + Mock.when(Database.driver, 'createMany').resolve([{ id: '1', name: 'lenon' }]) + + const user = new UserHooked() + + user.name = 'lenon' + + await user.save() + + assert.deepEqual(this.eventTypes(), ['beforeSave', 'beforeCreate', 'afterCreate', 'afterSave']) + UserHooked.events.forEach(event => assert.deepEqual(event.payload, user)) + } + + @Test() + public async shouldFireUpdateHooksOnceWithTheInstanceWhenSavingAPersistedModel({ assert }: Context) { + Mock.when(Database.driver, 'find').resolve({ id: '1', name: 'lenon' }) + + const user = await UserHooked.find() + + UserHooked.events = [] + Mock.when(Database.driver, 'update').resolve({ id: '1', name: 'txsoura' }) + + user.name = 'txsoura' + + await user.save() + + assert.deepEqual(this.eventTypes(), ['beforeSave', 'beforeUpdate', 'afterUpdate', 'afterSave']) + UserHooked.events.forEach(event => assert.deepEqual(event.payload, user)) + } + + @Test() + public async shouldNotFireAfterHooksWhenSavingAPersistedModelWithoutChanges({ assert }: Context) { + const date = new Date() + + Mock.when(Database.driver, 'find').resolve({ + id: '1', + name: 'lenon', + created_at: date, + updated_at: date, + deleted_at: null + }) + + const user = await UserHooked.find() + + UserHooked.events = [] + Mock.when(Database.driver, 'update').resolve({ id: '1', name: 'lenon' }) + + await user.save() + + assert.deepEqual(this.eventTypes(), ['beforeSave', 'beforeUpdate']) + assert.notCalled(Database.driver.update) + } + + @Test() + public async shouldFireDeleteHooksWithTheInstanceWhenDeletingAModel({ assert }: Context) { + Mock.when(Database.driver, 'find').resolve({ id: '1', name: 'lenon' }) + + const user = await UserHooked.find() + + UserHooked.events = [] + Mock.when(Database.driver, 'update').resolve({ id: '1' }) + + await user.delete() + + /** + * A soft delete runs an update query, but must fire only the + * delete hooks, never the save/update ones. + */ + assert.deepEqual(this.eventTypes(), ['beforeDelete', 'afterDelete']) + UserHooked.events.forEach(event => assert.deepEqual(event.payload, user)) + assert.calledOnce(Database.driver.update) + } + + @Test() + public async shouldFireDeleteHooksWhenForceDeletingAModel({ assert }: Context) { + Mock.when(Database.driver, 'find').resolve({ id: '1', name: 'lenon' }) + + const user = await UserHooked.find() + + UserHooked.events = [] + Mock.when(Database.driver, 'delete').resolve(undefined) + + await user.delete(true) + + assert.deepEqual(this.eventTypes(), ['beforeDelete', 'afterDelete']) + assert.calledOnce(Database.driver.delete) + } + + @Test() + public async shouldNotFireHooksOnQueryDeletes({ assert }: Context) { + Mock.when(Database.driver, 'update').resolve({ id: '1' }) + + await UserHooked.query().where('id', '1').delete() + + assert.deepEqual(this.eventTypes(), []) + } + + @Test() + public async shouldNotFireHooksWhenUsingWithoutHooks({ assert }: Context) { + Mock.when(Database.driver, 'createMany').resolve([{ id: '1' }]) + Mock.when(Database.driver, 'find').resolve({ id: '1' }) + + await UserHooked.query().withoutHooks().create({ name: 'lenon' }) + await UserHooked.query().withoutHooks().find() + + assert.deepEqual(this.eventTypes(), []) + } + + @Test() + public async shouldFireInheritedHooksWhenPersistingChildModels({ assert }: Context) { + class SubUserHooked extends UserHooked {} + + Mock.when(Database.driver, 'createMany').resolve([{ id: '1' }]) + + await SubUserHooked.create({ name: 'lenon' }) + + assert.deepEqual(this.eventTypes(), ['beforeSave', 'beforeCreate', 'afterCreate', 'afterSave']) + } + + @Test() + public async shouldSupportAsyncHookMethods({ assert }: Context) { + class AsyncUserHooked extends UserHooked { + @BeforeCreate() + public static async asyncHook(data: any) { + await new Promise(resolve => setTimeout(resolve, 1)) + + data.name = 'async' + } + } + + Mock.when(Database.driver, 'createMany').resolve([{ id: '1', name: 'async' }]) + + await AsyncUserHooked.create({ name: 'original' }) + + const persisted = (Database.driver.createMany as any).getCall(0).args[0] + + assert.deepEqual(persisted[0].name, 'async') + } +}