Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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 <lenon@athenna.io>",
Expand Down
1 change: 1 addition & 0 deletions src/constants/MetadataKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
36 changes: 36 additions & 0 deletions src/helpers/Annotation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -19,6 +22,7 @@ import {
import type {
RelationOptions,
ColumnOptions,
ModelHookOptions,
HasOneOptions,
HasManyOptions,
HasOneThroughOptions,
Expand All @@ -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),
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
51 changes: 43 additions & 8 deletions src/models/BaseModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

/**
Expand Down Expand Up @@ -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)
}

/**
Expand Down
122 changes: 122 additions & 0 deletions src/models/annotations/Hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* @athenna/database
*
* (c) João Lenon <lenon@athenna.io>
*
* 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')
}
Loading
Loading