Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat!: add model parameter on model hooks run on sequelize if they don't have an instance, add afterCount #17020

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/decorators/legacy/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export const AfterBulkCreate = Pkg.AfterBulkCreate;
export const AfterBulkDestroy = Pkg.AfterBulkDestroy;
export const AfterBulkRestore = Pkg.AfterBulkRestore;
export const AfterBulkUpdate = Pkg.AfterBulkUpdate;
export const AfterCount = Pkg.AfterCount;
export const AfterCreate = Pkg.AfterCreate;
export const AfterDestroy = Pkg.AfterDestroy;
export const AfterFind = Pkg.AfterFind;
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/decorators/legacy/model-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export const AfterBulkUpdate = createHookDecorator('afterBulkUpdate');
export const BeforeAssociate = createHookDecorator('beforeAssociate');
export const AfterAssociate = createHookDecorator('afterAssociate');

export const AfterCount = createHookDecorator('afterCount');
export const BeforeCount = createHookDecorator('beforeCount');

export const BeforeCreate = createHookDecorator('beforeCreate');
Expand Down
8 changes: 4 additions & 4 deletions packages/core/src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,14 @@ type OnRunHook<HookConfig extends {}> = <HookName extends keyof HookConfig>(
* @private
*/
export class HookHandler<HookConfig extends {}> {
#validHookNames: Array<keyof HookConfig>;
#validHookNames: ReadonlyArray<keyof HookConfig>;
#eventTarget: object;
#listeners = new Multimap<PropertyKey, { listenerName: Nullish<string>, callback: HookConfig[keyof HookConfig] }>();
#onRunHook: OnRunHook<HookConfig> | undefined;

constructor(
eventTarget: object,
validHookNames: Array<keyof HookConfig>,
validHookNames: ReadonlyArray<keyof HookConfig>,
onRunHook?: OnRunHook<HookConfig>,
) {
this.#eventTarget = eventTarget;
Expand Down Expand Up @@ -187,11 +187,11 @@ export class HookHandler<HookConfig extends {}> {
}

export class HookHandlerBuilder<HookConfig extends {}> {
#validHookNames: Array<keyof HookConfig>;
#validHookNames: ReadonlyArray<keyof HookConfig>;
#hookHandlers = new WeakMap<object, HookHandler<HookConfig>>();
#onRunHook: OnRunHook<HookConfig> | undefined;

constructor(validHookNames: Array<keyof HookConfig>, onRunHook?: OnRunHook<HookConfig>) {
constructor(validHookNames: ReadonlyArray<keyof HookConfig>, onRunHook?: OnRunHook<HookConfig>) {
this.#validHookNames = validHookNames;
this.#onRunHook = onRunHook;
}
Expand Down
35 changes: 26 additions & 9 deletions packages/core/src/model-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
CreateOptions,
DestroyOptions,
FindOptions,
GroupedCountResultItem,
InstanceDestroyOptions,
InstanceRestoreOptions,
InstanceUpdateOptions,
Expand All @@ -17,22 +18,23 @@ import type {
UpdateOptions,
UpsertOptions,
} from './model.js';
import { isOverwrittenModelHook } from './sequelize-typescript.js';
import type { SyncOptions } from './sequelize.js';

export interface ModelHooks<M extends Model = Model, TAttributes = any> {
beforeValidate(instance: M, options: ValidationOptions): AsyncHookReturn;
afterValidate(instance: M, options: ValidationOptions): AsyncHookReturn;
validationFailed(instance: M, options: ValidationOptions, error: unknown): AsyncHookReturn;
beforeCreate(attributes: M, options: CreateOptions<TAttributes>): AsyncHookReturn;
afterCreate(attributes: M, options: CreateOptions<TAttributes>): AsyncHookReturn;
beforeCreate(instance: M, options: CreateOptions<TAttributes>): AsyncHookReturn;
afterCreate(instance: M, options: CreateOptions<TAttributes>): AsyncHookReturn;
beforeDestroy(instance: M, options: InstanceDestroyOptions): AsyncHookReturn;
afterDestroy(instance: M, options: InstanceDestroyOptions): AsyncHookReturn;
beforeRestore(instance: M, options: InstanceRestoreOptions): AsyncHookReturn;
afterRestore(instance: M, options: InstanceRestoreOptions): AsyncHookReturn;
beforeUpdate(instance: M, options: InstanceUpdateOptions<TAttributes>): AsyncHookReturn;
afterUpdate(instance: M, options: InstanceUpdateOptions<TAttributes>): AsyncHookReturn;
beforeUpsert(attributes: M, options: UpsertOptions<TAttributes>): AsyncHookReturn;
afterUpsert(attributes: [ M, boolean | null ], options: UpsertOptions<TAttributes>): AsyncHookReturn;
beforeUpsert(attributes: TAttributes, options: UpsertOptions<TAttributes>): AsyncHookReturn;
afterUpsert(results: [M, boolean | null], options: UpsertOptions<TAttributes>): AsyncHookReturn;
beforeSave(
instance: M,
options: InstanceUpdateOptions<TAttributes> | CreateOptions<TAttributes>
Expand All @@ -54,6 +56,7 @@ export interface ModelHooks<M extends Model = Model, TAttributes = any> {
* A hook that is run at the start of {@link Model.count}
*/
beforeCount(options: CountOptions<TAttributes>): AsyncHookReturn;
afterCount(results: number | GroupedCountResultItem[], options: CountOptions<TAttributes>): AsyncHookReturn;

/**
* A hook that is run before a find (select) query
Expand All @@ -76,7 +79,7 @@ export interface ModelHooks<M extends Model = Model, TAttributes = any> {
/**
* A hook that is run after a find (select) query
*/
afterFind(instancesOrInstance: readonly M[] | M | null, options: FindOptions<TAttributes>): AsyncHookReturn;
afterFind(results: readonly M[] | M | null, options: FindOptions<TAttributes>): AsyncHookReturn;

/**
* A hook that is run at the start of {@link Model.sync}
Expand All @@ -101,7 +104,7 @@ export interface ModelHooks<M extends Model = Model, TAttributes = any> {
afterDefinitionRefresh(): void;
}

export const validModelHooks: Array<keyof ModelHooks> = [
export const VALID_MODEL_HOOKS = Object.freeze([
'beforeValidate', 'afterValidate', 'validationFailed',
'beforeCreate', 'afterCreate',
'beforeDestroy', 'afterDestroy',
Expand All @@ -113,14 +116,14 @@ export const validModelHooks: Array<keyof ModelHooks> = [
'beforeBulkDestroy', 'afterBulkDestroy',
'beforeBulkRestore', 'afterBulkRestore',
'beforeBulkUpdate', 'afterBulkUpdate',
'beforeCount',
'beforeCount', 'afterCount',
'beforeFind', 'beforeFindAfterExpandIncludeAll', 'beforeFindAfterOptions', 'afterFind',
'beforeSync', 'afterSync',
'beforeAssociate', 'afterAssociate',
'beforeDefinitionRefresh', 'afterDefinitionRefresh',
];
] as const satisfies ReadonlyArray<keyof ModelHooks>);

export const staticModelHooks = new HookHandlerBuilder<ModelHooks>(validModelHooks, async (
export const staticModelHooks = new HookHandlerBuilder<ModelHooks>(VALID_MODEL_HOOKS, async (
eventTarget,
isAsync,
hookName: keyof ModelHooks,
Expand All @@ -133,9 +136,23 @@ export const staticModelHooks = new HookHandlerBuilder<ModelHooks>(validModelHoo
throw new Error('Model must be initialized before running hooks on it.');
}

if (isOverwrittenModelHook(hookName)) {
if (isAsync) {
// @ts-expect-error -- too difficult to type, not worth the work
await model.sequelize.hooks.runAsync(hookName, model, ...args);
} else {
// @ts-expect-error -- too difficult to type, not worth the work
model.sequelize.hooks.runSync(hookName, model, ...args);
}

return;
}

if (isAsync) {
// @ts-expect-error -- too difficult to type, not worth the work
await model.sequelize.hooks.runAsync(hookName, ...args);
} else {
// @ts-expect-error -- too difficult to type, not worth the work
model.sequelize.hooks.runSync(hookName, ...args);
}
});
4 changes: 4 additions & 0 deletions packages/core/src/model.js
Original file line number Diff line number Diff line change
Expand Up @@ -1578,6 +1578,10 @@ ${associationOwner._getAssociationDebugList()}`);
}));
}

if (options.hooks) {
await this.hooks.runAsync('afterCount', result, options);
}

return result;
}

Expand Down
46 changes: 43 additions & 3 deletions packages/core/src/sequelize-typescript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
import type { AsyncHookReturn, HookHandler } from './hooks.js';
import { HookHandlerBuilder } from './hooks.js';
import type { ModelHooks } from './model-hooks.js';
import { validModelHooks } from './model-hooks.js';
import { VALID_MODEL_HOOKS } from './model-hooks.js';
import { setTransactionFromCls } from './model-internals.js';
import type { ModelManager } from './model-manager.js';
import type { ConnectionOptions, NormalizedOptions, Options, QueryRawOptions, Sequelize } from './sequelize.js';
Expand All @@ -41,7 +41,26 @@ import type {
TruncateOptions,
} from '.';

export interface SequelizeHooks extends ModelHooks {
/**
* Hooks that are inherited from {@link ModelHooks}, but with an extra "Model" parameter
* before the model cannot be inferred from the other arguments.
*/
const SEQUELIZE_OVERWRITTEN_MODEL_HOOKS = [
'beforeUpsert', 'afterUpsert',
'beforeBulkDestroy', 'afterBulkDestroy',
'beforeBulkRestore', 'afterBulkRestore',
'beforeBulkUpdate', 'afterBulkUpdate',
'beforeCount', 'afterCount',
'beforeFind', 'beforeFindAfterExpandIncludeAll', 'beforeFindAfterOptions', 'afterFind',
'beforeSync', 'afterSync',
'beforeDefinitionRefresh', 'afterDefinitionRefresh',
] as const;

export function isOverwrittenModelHook(hookName: string): hookName is typeof SEQUELIZE_OVERWRITTEN_MODEL_HOOKS[number] {
return SEQUELIZE_OVERWRITTEN_MODEL_HOOKS.includes(hookName as any);
}

export interface SequelizeHooks extends Omit<ModelHooks, typeof SEQUELIZE_OVERWRITTEN_MODEL_HOOKS[number]> {
/**
* A hook that is run at the start of {@link Sequelize#define} and {@link Model.init}
*/
Expand Down Expand Up @@ -93,6 +112,27 @@ export interface SequelizeHooks extends ModelHooks {
* A hook that is run after a connection to the pool
*/
afterPoolAcquire(connection: Connection, options?: GetConnectionOptions): AsyncHookReturn;

// inherited from model hooks, but with an extra "Model" parameter

beforeUpsert(model: ModelStatic, ...args: Parameters<ModelHooks['beforeUpsert']>): ReturnType<ModelHooks['beforeUpsert']>;
afterUpsert(model: ModelStatic, ...args: Parameters<ModelHooks['afterUpsert']>): ReturnType<ModelHooks['afterUpsert']>;
beforeBulkDestroy(model: ModelStatic, ...args: Parameters<ModelHooks['beforeBulkDestroy']>): ReturnType<ModelHooks['beforeBulkDestroy']>;
afterBulkDestroy(model: ModelStatic, ...args: Parameters<ModelHooks['afterBulkDestroy']>): ReturnType<ModelHooks['afterBulkDestroy']>;
beforeBulkRestore(model: ModelStatic, ...args: Parameters<ModelHooks['beforeBulkRestore']>): ReturnType<ModelHooks['beforeBulkRestore']>;
afterBulkRestore(model: ModelStatic, ...args: Parameters<ModelHooks['afterBulkRestore']>): ReturnType<ModelHooks['afterBulkRestore']>;
beforeBulkUpdate(model: ModelStatic, ...args: Parameters<ModelHooks['beforeBulkUpdate']>): ReturnType<ModelHooks['beforeBulkUpdate']>;
afterBulkUpdate(model: ModelStatic, ...args: Parameters<ModelHooks['afterBulkUpdate']>): ReturnType<ModelHooks['afterBulkUpdate']>;
beforeCount(model: ModelStatic, ...args: Parameters<ModelHooks['beforeCount']>): ReturnType<ModelHooks['beforeCount']>;
afterCount(model: ModelStatic, ...args: Parameters<ModelHooks['afterCount']>): ReturnType<ModelHooks['afterCount']>;
beforeFind(model: ModelStatic, ...args: Parameters<ModelHooks['beforeFind']>): ReturnType<ModelHooks['beforeFind']>;
beforeFindAfterExpandIncludeAll(model: ModelStatic, ...args: Parameters<ModelHooks['beforeFindAfterExpandIncludeAll']>): ReturnType<ModelHooks['beforeFindAfterExpandIncludeAll']>;
beforeFindAfterOptions(model: ModelStatic, ...args: Parameters<ModelHooks['beforeFindAfterOptions']>): ReturnType<ModelHooks['beforeFindAfterOptions']>;
afterFind(model: ModelStatic, ...args: Parameters<ModelHooks['afterFind']>): ReturnType<ModelHooks['afterFind']>;
beforeSync(model: ModelStatic, ...args: Parameters<ModelHooks['beforeSync']>): ReturnType<ModelHooks['beforeSync']>;
afterSync(model: ModelStatic, ...args: Parameters<ModelHooks['afterSync']>): ReturnType<ModelHooks['afterSync']>;
beforeDefinitionRefresh(model: ModelStatic, ...args: Parameters<ModelHooks['beforeDefinitionRefresh']>): ReturnType<ModelHooks['beforeDefinitionRefresh']>;
afterDefinitionRefresh(model: ModelStatic, ...args: Parameters<ModelHooks['afterDefinitionRefresh']>): ReturnType<ModelHooks['afterDefinitionRefresh']>;
}

export interface StaticSequelizeHooks {
Expand Down Expand Up @@ -141,7 +181,7 @@ const instanceSequelizeHooks = new HookHandlerBuilder<SequelizeHooks>([
'beforeDisconnect', 'afterDisconnect',
'beforeDefine', 'afterDefine',
'beforePoolAcquire', 'afterPoolAcquire',
...validModelHooks,
...VALID_MODEL_HOOKS,
]);

type TransactionCallback<T> = (t: Transaction) => PromiseLike<T> | T;
Expand Down
102 changes: 67 additions & 35 deletions packages/core/test/types/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { expectTypeOf } from 'expect-type';
import type { ConnectionOptions, FindOptions, QueryOptions, SaveOptions, UpsertOptions } from '@sequelize/core';
import type {
Attributes,
ConnectionOptions,
FindOptions,
ModelStatic,
QueryOptions,
SaveOptions,
UpsertOptions,
} from '@sequelize/core';
import { Model, Sequelize } from '@sequelize/core';
import type {
AfterAssociateEventData,
Expand All @@ -13,37 +21,26 @@ import type {
import type { AbstractQuery } from '@sequelize/core/_non-semver-use-at-your-own-risk_/dialects/abstract/query.js';
import type { ValidationOptions } from '@sequelize/core/_non-semver-use-at-your-own-risk_/instance-validator';
import type { ModelHooks } from '@sequelize/core/_non-semver-use-at-your-own-risk_/model-hooks.js';
import type { SequelizeHooks } from '../../types/sequelize-typescript.js';
import type { SemiDeepWritable } from './type-helpers/deep-writable';

{
class TestModel extends Model {}

const hooks: Partial<ModelHooks<TestModel>> = {
validationFailed(m, options, error) {
expectTypeOf(m).toEqualTypeOf<TestModel>();
const commonHooks = {
validationFailed(instance, options, error) {
expectTypeOf(instance).toEqualTypeOf<TestModel>();
expectTypeOf(options).toEqualTypeOf<ValidationOptions>();
expectTypeOf(error).toEqualTypeOf<unknown>();
},
beforeSave(m, options) {
expectTypeOf(m).toEqualTypeOf<TestModel>();
beforeSave(instance, options) {
expectTypeOf(instance).toEqualTypeOf<TestModel>();
expectTypeOf(options).toMatchTypeOf<SaveOptions>(); // TODO consider `.toEqualTypeOf` instead ?
},
afterSave(m, options) {
expectTypeOf(m).toEqualTypeOf<TestModel>();
afterSave(instance, options) {
expectTypeOf(instance).toEqualTypeOf<TestModel>();
expectTypeOf(options).toMatchTypeOf<SaveOptions>(); // TODO consider `.toEqualTypeOf` instead ?
},
afterFind(m, options) {
expectTypeOf(m).toEqualTypeOf<readonly TestModel[] | TestModel | null>();
expectTypeOf(options).toEqualTypeOf<FindOptions>();
},
beforeUpsert(m, options) {
expectTypeOf(m).toEqualTypeOf<TestModel>();
expectTypeOf(options).toEqualTypeOf<UpsertOptions>();
},
afterUpsert(m, options) {
expectTypeOf(m).toEqualTypeOf<[ TestModel, boolean | null ]>();
expectTypeOf(options).toEqualTypeOf<UpsertOptions>();
},
beforeAssociate(data, options) {
expectTypeOf(data).toEqualTypeOf<BeforeAssociateEventData>();
expectTypeOf(options).toEqualTypeOf<AssociationOptions<any>>();
Expand All @@ -52,25 +49,60 @@ import type { SemiDeepWritable } from './type-helpers/deep-writable';
expectTypeOf(data).toEqualTypeOf<AfterAssociateEventData>();
expectTypeOf(options).toEqualTypeOf<AssociationOptions<any>>();
},
};
} as const satisfies Partial<ModelHooks<TestModel, Attributes<TestModel>>>;

const modelHooks = {
...commonHooks,
beforeUpsert(data, options) {
expectTypeOf(data).toEqualTypeOf<Attributes<TestModel>>();
expectTypeOf(options).toEqualTypeOf<UpsertOptions>();
},
afterUpsert(output, options) {
expectTypeOf(output).toEqualTypeOf<[ TestModel, boolean | null ]>();
expectTypeOf(options).toEqualTypeOf<UpsertOptions>();
},
afterFind(results, options) {
expectTypeOf(results).toEqualTypeOf<readonly TestModel[] | TestModel | null>();
expectTypeOf(options).toEqualTypeOf<FindOptions>();
},
} as const satisfies Partial<ModelHooks<TestModel, Attributes<TestModel>>>;

const sequelizeHooks = {
...commonHooks,
beforeUpsert(model, data, options) {
expectTypeOf(model).toEqualTypeOf<ModelStatic>();
expectTypeOf(data).toEqualTypeOf<Attributes<TestModel>>();
expectTypeOf(options).toEqualTypeOf<UpsertOptions>();
},
afterUpsert(model, output, options) {
expectTypeOf(model).toEqualTypeOf<ModelStatic>();
expectTypeOf(output).toEqualTypeOf<[ TestModel, boolean | null ]>();
expectTypeOf(options).toEqualTypeOf<UpsertOptions>();
},
afterFind(model, output, options) {
expectTypeOf(model).toEqualTypeOf<ModelStatic>();
expectTypeOf(output).toEqualTypeOf<readonly TestModel[] | TestModel | null>();
expectTypeOf(options).toEqualTypeOf<FindOptions>();
},
} as const satisfies Partial<SequelizeHooks>;

const sequelize = new Sequelize('uri', { hooks });
TestModel.init({}, { sequelize, hooks });
const sequelize = new Sequelize('uri', { hooks: sequelizeHooks });
TestModel.init({}, { sequelize, hooks: modelHooks });

TestModel.addHook('beforeSave', hooks.beforeSave!);
TestModel.addHook('afterSave', hooks.afterSave!);
TestModel.addHook('afterFind', hooks.afterFind!);
TestModel.addHook('beforeUpsert', hooks.beforeUpsert!);
TestModel.addHook('afterUpsert', hooks.afterUpsert!);
TestModel.addHook('beforeSave', modelHooks.beforeSave);
TestModel.addHook('afterSave', modelHooks.afterSave);
TestModel.addHook('afterFind', modelHooks.afterFind);
TestModel.addHook('beforeUpsert', modelHooks.beforeUpsert);
TestModel.addHook('afterUpsert', modelHooks.afterUpsert);

TestModel.beforeSave(hooks.beforeSave!);
TestModel.afterSave(hooks.afterSave!);
TestModel.afterFind(hooks.afterFind!);
TestModel.beforeSave(modelHooks.beforeSave);
TestModel.afterSave(modelHooks.afterSave);
TestModel.afterFind(modelHooks.afterFind);

sequelize.beforeSave(hooks.beforeSave!);
sequelize.afterSave(hooks.afterSave!);
sequelize.afterFind(hooks.afterFind!);
sequelize.afterFind('namedAfterFind', hooks.afterFind!);
sequelize.beforeSave(sequelizeHooks.beforeSave);
sequelize.afterSave(sequelizeHooks.afterSave);
sequelize.afterFind(sequelizeHooks.afterFind);
sequelize.afterFind('namedAfterFind', sequelizeHooks.afterFind);
}

// #12959
Expand Down
2 changes: 2 additions & 0 deletions packages/core/test/unit/decorators/hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
AfterBulkDestroy,
AfterBulkRestore,
AfterBulkUpdate,
AfterCount,
AfterCreate,
AfterDefinitionRefresh,
AfterDestroy,
Expand Down Expand Up @@ -61,6 +62,7 @@ const hookMap: Partial<Record<keyof ModelHooks, Function>> = {
beforeBulkRestore: BeforeBulkRestore,
beforeBulkUpdate: BeforeBulkUpdate,
beforeCount: BeforeCount,
afterCount: AfterCount,
beforeCreate: BeforeCreate,
beforeDestroy: BeforeDestroy,
beforeFind: BeforeFind,
Expand Down