From bd037c80871a7bfff178887d4f0a6ae5c30a1e99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zo=C3=A9?= Date: Sat, 26 Nov 2022 13:20:19 +0100 Subject: [PATCH] feat: add model hook decorators (#15333) --- package.json | 5 + src/decorators/legacy/README.md | 3 + src/decorators/legacy/hook-decorators.ts | 75 ++++++++++ src/decorators/legacy/index.mjs | 34 +++++ src/decorators/legacy/index.ts | 2 + src/decorators/legacy/model-hooks-bulk.ts | 50 +++++++ src/decorators/legacy/model-hooks-single.ts | 146 ++++++++++++++++++++ test/tsconfig.json | 6 +- test/unit/decorators/hooks.test.ts | 142 +++++++++++++++++++ 9 files changed, 461 insertions(+), 2 deletions(-) create mode 100644 src/decorators/legacy/README.md create mode 100644 src/decorators/legacy/hook-decorators.ts create mode 100644 src/decorators/legacy/index.mjs create mode 100644 src/decorators/legacy/index.ts create mode 100644 src/decorators/legacy/model-hooks-bulk.ts create mode 100644 src/decorators/legacy/model-hooks-single.ts create mode 100644 test/unit/decorators/hooks.test.ts diff --git a/package.json b/package.json index 2e3b3500b7e5..2a1602e0fa41 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,11 @@ "require": "./lib/index.js", "types": "./types/index.d.ts" }, + "./decorators-legacy": { + "types": "./types/decorators/legacy/index.d.ts", + "import": "./lib/decorators/legacy/index.mjs", + "require": "./lib/decorators/legacy/index.js" + }, "./_non-semver-use-at-your-own-risk_/*": { "types": "./types/*", "default": "./lib/*" diff --git a/src/decorators/legacy/README.md b/src/decorators/legacy/README.md new file mode 100644 index 000000000000..a7ab90e4c2d8 --- /dev/null +++ b/src/decorators/legacy/README.md @@ -0,0 +1,3 @@ +# decorators-legacy + +This directory regroups the decorators that are built using the legacy decorator proposal. diff --git a/src/decorators/legacy/hook-decorators.ts b/src/decorators/legacy/hook-decorators.ts new file mode 100644 index 000000000000..498671dd37ef --- /dev/null +++ b/src/decorators/legacy/hook-decorators.ts @@ -0,0 +1,75 @@ +import upperFirst from 'lodash/upperFirst'; +import type { ModelHooks } from '../../model-typescript.js'; +import { Model } from '../../model.js'; +import { isModelStatic } from '../../utils/model-utils.js'; + +export interface HookOptions { + name?: string; +} + +export type HookDecoratorArgs = [targetOrOptions: Object | HookOptions, propertyName?: string | symbol]; + +/** + * Implementation for hook decorator functions. These are polymorphic. When + * called with a single argument (IHookOptions) they return a decorator + * factory function. When called with multiple arguments, they add the hook + * to the model’s metadata. + * + * @param hookType + * @param args + */ +export function implementHookDecorator( + hookType: keyof ModelHooks, + args: HookDecoratorArgs, +): MethodDecorator | undefined { + if (args.length === 1) { + const options: HookOptions = args[0]; + + return (target: Object, propertyName: string | symbol) => { + addHook(target, propertyName, hookType, options); + }; + } + + const target = args[0]; + const propertyName = args[1]!; + + addHook(target, propertyName, hookType); + + // eslint-disable-next-line consistent-return + return undefined; +} + +function addHook( + targetModel: Object, + methodName: string | symbol, + hookType: keyof ModelHooks, + options?: HookOptions, +): void { + if (typeof targetModel !== 'function') { + throw new TypeError( + `Decorator @${upperFirst(hookType)} has been used on method "${targetModel.constructor.name}.${String(methodName)}" which is not static. Only static methods can be used for hooks.`, + ); + } + + if (!isModelStatic(targetModel)) { + throw new TypeError( + `Decorator @${upperFirst(hookType)} has been used on "${targetModel.name}.${String(methodName)}", but class "${targetModel.name}" does not extend Model. Hook decorators can only be used on models.`, + ); + } + + // @ts-expect-error + const targetMethod: unknown = targetModel[methodName]; + if (typeof targetMethod !== 'function') { + throw new TypeError( + `Decorator @${upperFirst(hookType)} has been used on "${targetModel.name}.${String(methodName)}", which is not a method.`, + ); + } + + if (methodName in Model) { + throw new Error( + `Decorator @${upperFirst(hookType)} has been used on "${targetModel.name}.${String(methodName)}", but method ${JSON.stringify(methodName)} already exists on the base Model class and replacing it can lead to issues.`, + ); + } + + targetModel.hooks.addListener(hookType, targetMethod.bind(targetModel), options?.name); +} diff --git a/src/decorators/legacy/index.mjs b/src/decorators/legacy/index.mjs new file mode 100644 index 000000000000..e5ba2c147c92 --- /dev/null +++ b/src/decorators/legacy/index.mjs @@ -0,0 +1,34 @@ +import Pkg from './index.js'; + +export const AfterAssociate = Pkg.AfterAssociate; +export const AfterBulkCreate = Pkg.AfterBulkCreate; +export const AfterBulkDestroy = Pkg.AfterBulkDestroy; +export const AfterBulkRestore = Pkg.AfterBulkRestore; +export const AfterBulkUpdate = Pkg.AfterBulkUpdate; +export const AfterCreate = Pkg.AfterCreate; +export const AfterDestroy = Pkg.AfterDestroy; +export const AfterFind = Pkg.AfterFind; +export const AfterRestore = Pkg.AfterRestore; +export const AfterSave = Pkg.AfterSave; +export const AfterSync = Pkg.AfterSync; +export const AfterUpdate = Pkg.AfterUpdate; +export const AfterUpsert = Pkg.AfterUpsert; +export const AfterValidate = Pkg.AfterValidate; +export const BeforeAssociate = Pkg.BeforeAssociate; +export const BeforeBulkCreate = Pkg.BeforeBulkCreate; +export const BeforeBulkDestroy = Pkg.BeforeBulkDestroy; +export const BeforeBulkRestore = Pkg.BeforeBulkRestore; +export const BeforeBulkUpdate = Pkg.BeforeBulkUpdate; +export const BeforeCount = Pkg.BeforeCount; +export const BeforeCreate = Pkg.BeforeCreate; +export const BeforeDestroy = Pkg.BeforeDestroy; +export const BeforeFind = Pkg.BeforeFind; +export const BeforeFindAfterExpandIncludeAll = Pkg.BeforeFindAfterExpandIncludeAll; +export const BeforeFindAfterOptions = Pkg.BeforeFindAfterOptions; +export const BeforeRestore = Pkg.BeforeRestore; +export const BeforeSave = Pkg.BeforeSave; +export const BeforeSync = Pkg.BeforeSync; +export const BeforeUpdate = Pkg.BeforeUpdate; +export const BeforeUpsert = Pkg.BeforeUpsert; +export const BeforeValidate = Pkg.BeforeValidate; +export const ValidationFailed = Pkg.ValidationFailed; diff --git a/src/decorators/legacy/index.ts b/src/decorators/legacy/index.ts new file mode 100644 index 000000000000..5c1402ddd5e4 --- /dev/null +++ b/src/decorators/legacy/index.ts @@ -0,0 +1,2 @@ +export * from './model-hooks-bulk.js'; +export * from './model-hooks-single.js'; diff --git a/src/decorators/legacy/model-hooks-bulk.ts b/src/decorators/legacy/model-hooks-bulk.ts new file mode 100644 index 000000000000..0302707c3ff3 --- /dev/null +++ b/src/decorators/legacy/model-hooks-bulk.ts @@ -0,0 +1,50 @@ +import type { HookDecoratorArgs, HookOptions } from './hook-decorators.js'; +import { implementHookDecorator } from './hook-decorators.js'; + +export function BeforeBulkCreate(target: Object, propertyName: string): void; +export function BeforeBulkCreate(options: HookOptions): MethodDecorator; +export function BeforeBulkCreate(...args: HookDecoratorArgs): undefined | MethodDecorator { + return implementHookDecorator('beforeBulkCreate', args); +} + +export function AfterBulkCreate(target: Object, propertyName: string): void; +export function AfterBulkCreate(options: HookOptions): MethodDecorator; +export function AfterBulkCreate(...args: HookDecoratorArgs): undefined | MethodDecorator { + return implementHookDecorator('afterBulkCreate', args); +} + +export function BeforeBulkDestroy(target: Object, propertyName: string): void; +export function BeforeBulkDestroy(options: HookOptions): MethodDecorator; +export function BeforeBulkDestroy(...args: HookDecoratorArgs): undefined | MethodDecorator { + return implementHookDecorator('beforeBulkDestroy', args); +} + +export function AfterBulkDestroy(target: Object, propertyName: string): void; +export function AfterBulkDestroy(options: HookOptions): MethodDecorator; +export function AfterBulkDestroy(...args: HookDecoratorArgs): undefined | MethodDecorator { + return implementHookDecorator('afterBulkDestroy', args); +} + +export function BeforeBulkRestore(target: Object, propertyName: string): void; +export function BeforeBulkRestore(options: HookOptions): MethodDecorator; +export function BeforeBulkRestore(...args: HookDecoratorArgs): undefined | MethodDecorator { + return implementHookDecorator('beforeBulkRestore' as any, args); +} + +export function AfterBulkRestore(target: Object, propertyName: string): void; +export function AfterBulkRestore(options: HookOptions): MethodDecorator; +export function AfterBulkRestore(...args: HookDecoratorArgs): undefined | MethodDecorator { + return implementHookDecorator('afterBulkRestore' as any, args); +} + +export function BeforeBulkUpdate(target: Object, propertyName: string): void; +export function BeforeBulkUpdate(options: HookOptions): MethodDecorator; +export function BeforeBulkUpdate(...args: HookDecoratorArgs): undefined | MethodDecorator { + return implementHookDecorator('beforeBulkUpdate', args); +} + +export function AfterBulkUpdate(target: Object, propertyName: string): void; +export function AfterBulkUpdate(options: HookOptions): MethodDecorator; +export function AfterBulkUpdate(...args: HookDecoratorArgs): undefined | MethodDecorator { + return implementHookDecorator('afterBulkUpdate', args); +} diff --git a/src/decorators/legacy/model-hooks-single.ts b/src/decorators/legacy/model-hooks-single.ts new file mode 100644 index 000000000000..4833968072f7 --- /dev/null +++ b/src/decorators/legacy/model-hooks-single.ts @@ -0,0 +1,146 @@ +import type { HookOptions, HookDecoratorArgs } from './hook-decorators.js'; +import { implementHookDecorator } from './hook-decorators.js'; + +export function BeforeAssociate(target: Object, propertyName: string): void; +export function BeforeAssociate(options: HookOptions): MethodDecorator; +export function BeforeAssociate(...args: HookDecoratorArgs): undefined | MethodDecorator { + return implementHookDecorator('beforeAssociate', args); +} + +export function AfterAssociate(target: Object, propertyName: string): void; +export function AfterAssociate(options: HookOptions): MethodDecorator; +export function AfterAssociate(...args: HookDecoratorArgs): undefined | MethodDecorator { + return implementHookDecorator('afterAssociate', args); +} + +export function BeforeCount(target: Object, propertyName: string): void; +export function BeforeCount(options: HookOptions): MethodDecorator; +export function BeforeCount(...args: HookDecoratorArgs): undefined | MethodDecorator { + return implementHookDecorator('beforeCount', args); +} + +export function BeforeCreate(target: Object, propertyName: string): void; +export function BeforeCreate(options: HookOptions): MethodDecorator; +export function BeforeCreate(...args: HookDecoratorArgs): undefined | MethodDecorator { + return implementHookDecorator('beforeCreate', args); +} + +export function AfterCreate(target: Object, propertyName: string): void; +export function AfterCreate(options: HookOptions): MethodDecorator; +export function AfterCreate(...args: HookDecoratorArgs): undefined | MethodDecorator { + return implementHookDecorator('afterCreate', args); +} + +export function BeforeDestroy(target: Object, propertyName: string): void; +export function BeforeDestroy(options: HookOptions): MethodDecorator; +export function BeforeDestroy(...args: HookDecoratorArgs): undefined | MethodDecorator { + return implementHookDecorator('beforeDestroy', args); +} + +export function AfterDestroy(target: Object, propertyName: string): void; +export function AfterDestroy(options: HookOptions): MethodDecorator; +export function AfterDestroy(...args: HookDecoratorArgs): undefined | MethodDecorator { + return implementHookDecorator('afterDestroy', args); +} + +export function BeforeFind(target: Object, propertyName: string): void; +export function BeforeFind(options: HookOptions): MethodDecorator; +export function BeforeFind(...args: HookDecoratorArgs): undefined | MethodDecorator { + return implementHookDecorator('beforeFind', args); +} + +export function BeforeFindAfterExpandIncludeAll(target: Object, propertyName: string): void; +export function BeforeFindAfterExpandIncludeAll(options: HookOptions): MethodDecorator; +export function BeforeFindAfterExpandIncludeAll(...args: HookDecoratorArgs): undefined | MethodDecorator { + return implementHookDecorator('beforeFindAfterExpandIncludeAll', args); +} + +export function BeforeFindAfterOptions(target: Object, propertyName: string): void; +export function BeforeFindAfterOptions(options: HookOptions): MethodDecorator; +export function BeforeFindAfterOptions(...args: HookDecoratorArgs): undefined | MethodDecorator { + return implementHookDecorator('beforeFindAfterOptions', args); +} + +export function AfterFind(target: Object, propertyName: string): void; +export function AfterFind(options: HookOptions): MethodDecorator; +export function AfterFind(...args: HookDecoratorArgs): undefined | MethodDecorator { + return implementHookDecorator('afterFind', args); +} + +export function BeforeRestore(target: Object, propertyName: string): void; +export function BeforeRestore(options: HookOptions): MethodDecorator; +export function BeforeRestore(...args: HookDecoratorArgs): undefined | MethodDecorator { + return implementHookDecorator('beforeRestore' as any, args); +} + +export function AfterRestore(target: Object, propertyName: string): void; +export function AfterRestore(options: HookOptions): MethodDecorator; +export function AfterRestore(...args: HookDecoratorArgs): undefined | MethodDecorator { + return implementHookDecorator('afterRestore' as any, args); +} + +export function BeforeSave(target: Object, propertyName: string): void; +export function BeforeSave(options: HookOptions): MethodDecorator; +export function BeforeSave(...args: HookDecoratorArgs): undefined | MethodDecorator { + return implementHookDecorator('beforeSave' as any, args); +} + +export function AfterSave(target: Object, propertyName: string): void; +export function AfterSave(options: HookOptions): MethodDecorator; +export function AfterSave(...args: HookDecoratorArgs): undefined | MethodDecorator { + return implementHookDecorator('afterSave' as any, args); +} + +export function BeforeSync(target: Object, propertyName: string): void; +export function BeforeSync(options: HookOptions): MethodDecorator; +export function BeforeSync(...args: HookDecoratorArgs): undefined | MethodDecorator { + return implementHookDecorator('beforeSync', args); +} + +export function AfterSync(target: Object, propertyName: string): void; +export function AfterSync(options: HookOptions): MethodDecorator; +export function AfterSync(...args: HookDecoratorArgs): undefined | MethodDecorator { + return implementHookDecorator('afterSync', args); +} + +export function BeforeUpdate(target: Object, propertyName: string): void; +export function BeforeUpdate(options: HookOptions): MethodDecorator; +export function BeforeUpdate(...args: HookDecoratorArgs): undefined | MethodDecorator { + return implementHookDecorator('beforeUpdate', args); +} + +export function AfterUpdate(target: Object, propertyName: string): void; +export function AfterUpdate(options: HookOptions): MethodDecorator; +export function AfterUpdate(...args: HookDecoratorArgs): undefined | MethodDecorator { + return implementHookDecorator('afterUpdate', args); +} + +export function BeforeUpsert(target: Object, propertyName: string): void; +export function BeforeUpsert(options: HookOptions): MethodDecorator; +export function BeforeUpsert(...args: HookDecoratorArgs): undefined | MethodDecorator { + return implementHookDecorator('beforeUpsert' as any, args); +} + +export function AfterUpsert(target: Object, propertyName: string): void; +export function AfterUpsert(options: HookOptions): MethodDecorator; +export function AfterUpsert(...args: HookDecoratorArgs): undefined | MethodDecorator { + return implementHookDecorator('afterUpsert' as any, args); +} + +export function BeforeValidate(target: Object, propertyName: string): void; +export function BeforeValidate(options: HookOptions): MethodDecorator; +export function BeforeValidate(...args: HookDecoratorArgs): undefined | MethodDecorator { + return implementHookDecorator('beforeValidate', args); +} + +export function ValidationFailed(target: Object, propertyName: string): void; +export function ValidationFailed(options: HookOptions): MethodDecorator; +export function ValidationFailed(...args: HookDecoratorArgs): undefined | MethodDecorator { + return implementHookDecorator('validationFailed' as any, args); +} + +export function AfterValidate(target: Object, propertyName: string): void; +export function AfterValidate(options: HookOptions): MethodDecorator; +export function AfterValidate(...args: HookDecoratorArgs): undefined | MethodDecorator { + return implementHookDecorator('afterValidate', args); +} diff --git a/test/tsconfig.json b/test/tsconfig.json index 51e8fd9c9e1c..f163612e21eb 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -6,12 +6,14 @@ "paths": { "@sequelize/core": ["../types/index.d.ts"], "@sequelize/core/_non-semver-use-at-your-own-risk_/*": ["../types/*"], - "@sequelize/core/package.json": ["../package.json"] + "@sequelize/core/package.json": ["../package.json"], + "@sequelize/core/decorators-legacy": ["../types/decorators/legacy/index.d.ts"] }, "types": ["node", "mocha", "sinon", "chai", "sinon-chai", "chai-as-promised", "chai-datetime"], "noEmit": true, "emitDeclarationOnly": false, - "exactOptionalPropertyTypes": false + "exactOptionalPropertyTypes": false, + "experimentalDecorators": true }, "include": ["./types/**/*", "./**/**/*.ts"] } diff --git a/test/unit/decorators/hooks.test.ts b/test/unit/decorators/hooks.test.ts new file mode 100644 index 000000000000..79768c34ba24 --- /dev/null +++ b/test/unit/decorators/hooks.test.ts @@ -0,0 +1,142 @@ +import { Model } from '@sequelize/core'; +import type { ModelHooks } from '@sequelize/core/_non-semver-use-at-your-own-risk_/model-typescript.js'; +import { + AfterAssociate, + AfterBulkCreate, + AfterBulkDestroy, + AfterBulkRestore, + AfterBulkUpdate, + AfterCreate, + AfterDestroy, + AfterFind, + AfterRestore, + AfterSave, + AfterSync, + AfterUpdate, + AfterUpsert, + AfterValidate, + BeforeAssociate, + BeforeBulkCreate, + BeforeBulkDestroy, + BeforeBulkRestore, + BeforeBulkUpdate, + BeforeCount, + BeforeCreate, + BeforeDestroy, + BeforeFind, + BeforeFindAfterExpandIncludeAll, + BeforeFindAfterOptions, + BeforeRestore, + BeforeSave, + BeforeSync, + BeforeUpdate, + BeforeUpsert, + BeforeValidate, + ValidationFailed, +} from '@sequelize/core/decorators-legacy'; +import { expect } from 'chai'; + +// map of hook name to hook decorator +const hookMap: Partial> = { + afterAssociate: AfterAssociate, + afterBulkCreate: AfterBulkCreate, + afterBulkDestroy: AfterBulkDestroy, + afterBulkRestore: AfterBulkRestore, + afterBulkUpdate: AfterBulkUpdate, + afterCreate: AfterCreate, + afterDestroy: AfterDestroy, + afterFind: AfterFind, + afterRestore: AfterRestore, + afterSave: AfterSave, + afterSync: AfterSync, + afterUpdate: AfterUpdate, + afterUpsert: AfterUpsert, + afterValidate: AfterValidate, + beforeAssociate: BeforeAssociate, + beforeBulkCreate: BeforeBulkCreate, + beforeBulkDestroy: BeforeBulkDestroy, + beforeBulkRestore: BeforeBulkRestore, + beforeBulkUpdate: BeforeBulkUpdate, + beforeCount: BeforeCount, + beforeCreate: BeforeCreate, + beforeDestroy: BeforeDestroy, + beforeFind: BeforeFind, + beforeFindAfterExpandIncludeAll: BeforeFindAfterExpandIncludeAll, + beforeFindAfterOptions: BeforeFindAfterOptions, + beforeRestore: BeforeRestore, + beforeSave: BeforeSave, + beforeSync: BeforeSync, + beforeUpdate: BeforeUpdate, + beforeUpsert: BeforeUpsert, + beforeValidate: BeforeValidate, + validationFailed: ValidationFailed, +}; + +for (const [hookName, decorator] of Object.entries(hookMap)) { + describe(`@${hookName} legacy decorator`, () => { + it('adds a hook on the current model', () => { + class MyModel extends Model { + @decorator + static myHook() {} + } + + expect(MyModel.hasHooks(hookName as keyof ModelHooks)).to.eq(true, `hook ${hookName} incorrectly registered its hook`); + }); + + it('supports a "name" option', () => { + class MyModel extends Model { + @decorator({ name: 'my-hook' }) + static myHook() {} + } + + expect(MyModel.hasHooks(hookName as keyof ModelHooks)).to.eq(true, `hook ${hookName} incorrectly registered its hook`); + MyModel.removeHook(hookName as keyof ModelHooks, 'my-hook'); + expect(MyModel.hasHooks(hookName as keyof ModelHooks)).to.eq(false, `hook ${hookName} should be possible to remove by name`); + }); + + it('throws on non-static hooks', () => { + expect(() => { + class MyModel extends Model { + @decorator + nonStaticMethod() {} + } + + return MyModel; + }).to.throw(Error, /Only static methods can be used for hooks/); + }); + + it('throws on non-method properties', () => { + expect(() => { + class MyModel extends Model { + @decorator + static nonMethod = 'abc'; + } + + return MyModel; + }).to.throw(Error, /is not a method/); + }); + + it('throws if the class is not a model', () => { + expect(() => { + class MyModel { + @decorator + static nonStaticMethod() {} + } + + return MyModel; + }).to.throw(Error, /Hook decorators can only be used on models/); + }); + + it('throws on reserved methods', () => { + expect(() => { + // @ts-expect-error -- replacing an existing method + class MyModel extends Model { + @decorator + static sync() {} + } + + return MyModel; + }).to.throw(Error, /already exists on the base Model/); + }); + }); +}