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 association decorators #15483

Merged
merged 4 commits into from
Dec 20, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion src/associations/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ export type NormalizedAssociationOptions<ForeignKey extends string>
/**
* Options provided when associating models
*/
export interface AssociationOptions<ForeignKey extends string> extends Hookable {
export interface AssociationOptions<ForeignKey extends string = string> extends Hookable {
/**
* The alias of this model, in singular form. See also the `name` option passed to `sequelize.define`. If
* you create multiple associations between the same tables, you should provide an alias to be able to
Expand Down
14 changes: 8 additions & 6 deletions src/associations/belongs-to-many.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ import { MultiAssociation } from './base';
import type { BelongsTo } from './belongs-to';
import { HasMany } from './has-many';
import { HasOne } from './has-one';
import type { AssociationStatic } from './helpers';
import type { AssociationStatic, MaybeForwardedModelStatic } from './helpers';
import {
AssociationSecret,
defineAssociation,
Expand Down Expand Up @@ -838,13 +838,15 @@ function normalizeThroughOptions<M extends Model>(

let model: ModelStatic<M>;

if (!through || (typeof through.model !== 'string' && !isModelStatic<M>(through.model))) {
if (!through || (typeof through.model !== 'string' && typeof through.model !== 'function')) {
throw new AssociationError(`${source.name}.belongsToMany(${target.name}) requires a through model, set the "through", or "through.model" options to either a string or a model`);
}

if (isModelStatic<M>(through.model)) {
if (isModelStatic<M>(through.model)) { // model class provided directly
model = through.model;
} else if (sequelize.isDefined(through.model)) {
} else if (typeof through.model === 'function') { // model class provided as a forward reference
model = through.model(sequelize);
} else if (sequelize.isDefined(through.model)) { // model name provided: get if exists, create if not
model = sequelize.model<M>(through.model);
} else {
const sourceTable = source.table;
Expand Down Expand Up @@ -903,7 +905,7 @@ export interface ThroughOptions<ThroughModel extends Model> {
* The model used to join both sides of the N:M association.
* Can be a string if you want the model to be generated by sequelize.
*/
model: ModelStatic<ThroughModel> | string;
model: MaybeForwardedModelStatic<ThroughModel> | string;

/**
* See {@link ModelOptions.timestamps}
Expand Down Expand Up @@ -987,7 +989,7 @@ export interface BelongsToManyOptions<
* The name of the table that is used to join source and target in n:m associations. Can also be a
* sequelize model if you want to define the junction table yourself and add extra attributes to it.
*/
through: ModelStatic<ThroughModel> | string | ThroughOptions<ThroughModel>;
through: MaybeForwardedModelStatic<ThroughModel> | string | ThroughOptions<ThroughModel>;

/**
* The name of the foreign key in the join table (representing the target model) or an object representing
Expand Down
6 changes: 6 additions & 0 deletions src/associations/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,3 +300,9 @@ export function normalizeForeignKeyOptions<T extends string>(foreignKey: Associa
fieldName: undefined,
});
}

export type MaybeForwardedModelStatic<M extends Model = Model> = ModelStatic<M> | ((sequelize: Sequelize) => ModelStatic<M>);

export function getForwardedModel(model: MaybeForwardedModelStatic, sequelize: Sequelize): ModelStatic {
return typeof model === 'function' && !isModelStatic(model) ? model(sequelize) : model;
}
139 changes: 139 additions & 0 deletions src/decorators/legacy/associations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import type { MaybeForwardedModelStatic } from '../../associations/helpers.js';
import { AssociationSecret, getForwardedModel } from '../../associations/helpers.js';
import type {
AssociationOptions,
BelongsToManyOptions,
BelongsToOptions,
HasManyOptions,
HasOneOptions,
} from '../../associations/index.js';
import { BelongsTo as BelongsToAssociation, HasMany as HasManyAssociation, HasOne as HasOneAssociation, BelongsToMany as BelongsToManyAssociation } from '../../associations/index.js';
import type { ModelStatic, Model, AttributeNames } from '../../model.js';
import type { Sequelize } from '../../sequelize.js';
import { isString } from '../../utils/check.js';
import { isModelStatic } from '../../utils/model-utils.js';
import { throwMustBeInstanceProperty, throwMustBeModel } from './decorator-utils.js';

export type AssociationType = 'BelongsTo' | 'HasOne' | 'HasMany' | 'BelongsToMany';

interface RegisteredAssociation {
type: AssociationType;
associationName: string;
source: ModelStatic;
target: MaybeForwardedModelStatic;
options: AssociationOptions;
}

const registeredAssociations = new WeakMap<ModelStatic, RegisteredAssociation[]>();

function decorateAssociation(
type: AssociationType,
source: Object,
target: MaybeForwardedModelStatic,
associationName: string | symbol,
options: AssociationOptions,
): void {
if (typeof source === 'function') {
throwMustBeInstanceProperty(type, source, associationName);
}

const sourceClass = source.constructor;
if (!isModelStatic(sourceClass)) {
throwMustBeModel(type, source, associationName);
}

if (typeof associationName === 'symbol') {
throw new TypeError('Symbol associations are not currently supported. We welcome a PR that implements this feature.');
}

const associations = registeredAssociations.get(sourceClass) ?? [];
registeredAssociations.set(sourceClass, associations);

associations.push({ source: sourceClass, target, options, associationName, type });
}

export function HasOne<Target extends Model>(
target: MaybeForwardedModelStatic<Target>,
optionsOrForeignKey: HasOneOptions<string, AttributeNames<Target>> | AttributeNames<Target>,
) {
return (source: Model, associationName: string | symbol) => {
const options = isString(optionsOrForeignKey) ? { foreignKey: optionsOrForeignKey } : optionsOrForeignKey;

decorateAssociation('HasOne', source, target, associationName, options);
};
}

export function HasMany<Target extends Model>(
target: MaybeForwardedModelStatic<Target>,
optionsOrForeignKey: HasManyOptions<string, AttributeNames<Target>> | AttributeNames<Target>,
) {
return (source: Model, associationName: string | symbol) => {
const options = isString(optionsOrForeignKey) ? { foreignKey: optionsOrForeignKey } : optionsOrForeignKey;

decorateAssociation('HasMany', source, target, associationName, options);
};
}

export function BelongsTo<SourceKey extends string, Target extends Model>(
target: MaybeForwardedModelStatic<Target>,
optionsOrForeignKey: BelongsToOptions<SourceKey, AttributeNames<Target>> | SourceKey,
) {
return (
// This type is a hack to make sure the source model declares a property named [SourceKey].
// The error message is going to be horrendous, but at least it's enforced.
source: Model<{ [key in SourceKey]: unknown }>,
associationName: string,
) => {
const options = isString(optionsOrForeignKey) ? { foreignKey: optionsOrForeignKey } : optionsOrForeignKey;

decorateAssociation('BelongsTo', source, target, associationName, options);
};
}

export function BelongsToMany(
target: MaybeForwardedModelStatic,
options: BelongsToManyOptions,
): PropertyDecorator {
return (
source: Object,
associationName: string | symbol,
) => {
decorateAssociation('BelongsToMany', source, target, associationName, options);
};
}

// export const HasMany = createAssociationDecorator<HasManyOptions<string, string>>('HasMany');
// export const BelongsToMany = createAssociationDecorator<BelongsToManyOptions<string, string>>('BelongsToMany');
ephys marked this conversation as resolved.
Show resolved Hide resolved

export function initDecoratedAssociations(model: ModelStatic, sequelize: Sequelize): void {
const associations = registeredAssociations.get(model);

if (!associations) {
return;
}

for (const association of associations) {
const { type, source, target: targetGetter, associationName } = association;
const options: AssociationOptions = { ...association.options, as: associationName };

const target = getForwardedModel(targetGetter, sequelize);

switch (type) {
case 'BelongsTo':
BelongsToAssociation.associate(AssociationSecret, source, target, options as BelongsToOptions<string, string>);
break;
case 'HasOne':
HasOneAssociation.associate(AssociationSecret, source, target, options as HasOneOptions<string, string>);
break;
case 'HasMany':
HasManyAssociation.associate(AssociationSecret, source, target, options as HasManyOptions<string, string>);
break;
case 'BelongsToMany':
BelongsToManyAssociation.associate(AssociationSecret, source, target, options as BelongsToManyOptions);
break;
default:
throw new Error(`Unknown association type: ${type}`);
}
}
}

7 changes: 7 additions & 0 deletions src/decorators/legacy/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ export const Unique = Pkg.Unique;
export const Index = Pkg.Index;
export const createIndexDecorator = Pkg.createIndexDecorator;

// Association Decorators

export const BelongsTo = Pkg.BelongsTo;
export const BelongsToMany = Pkg.BelongsToMany;
export const HasMany = Pkg.HasMany;
export const HasOne = Pkg.HasOne;

// Validation Decorators

export const ValidateAttribute = Pkg.ValidateAttribute;
Expand Down
1 change: 1 addition & 0 deletions src/decorators/legacy/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './model-hooks.js';
export * from './attribute.js';
export * from './table.js';
export * from './validation.js';
export { HasOne, HasMany, BelongsTo, BelongsToMany } from './associations.js';
2 changes: 1 addition & 1 deletion src/decorators/shared/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ export function registerModelAttributeOptions(
}

export function initDecoratedModel(model: ModelStatic, sequelize: Sequelize): void {
const { model: modelOptions, attributes: attributeOptions } = registeredOptions.get(model) ?? {};
const { model: modelOptions, attributes: attributeOptions = {} } = registeredOptions.get(model) ?? {};

initModel(model, attributeOptions as ModelAttributes, {
...modelOptions,
Expand Down
2 changes: 1 addition & 1 deletion src/instance-validator.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ import { AbstractDataType } from './dialects/abstract/data-types';
import { validateDataType } from './dialects/abstract/data-types-utils';
import { getAllOwnKeys } from './utils/object';
import { SequelizeMethod } from './utils/sequelize-method';
import { BelongsTo } from './associations/belongs-to';

const _ = require('lodash');
const sequelizeError = require('./errors');
const { BelongsTo } = require('./associations/belongs-to');
const validator = require('./utils/validator-extras').validator;
const { promisify } = require('node:util');

Expand Down
25 changes: 16 additions & 9 deletions src/model.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@
import omit from 'lodash/omit';
import { AbstractDataType } from './dialects/abstract/data-types';
import { intersects } from './utils/array';
import { noNewModel } from './utils/deprecations';
import {
noDoubleNestedGroup,
noModelDropSchema,
noNewModel,
schemaRenamedToWithSchema,
scopeRenamedToWithScope,
} from './utils/deprecations';
import { toDefaultValue } from './utils/dialect';
import {
getComplexKeys,
Expand All @@ -18,21 +24,20 @@ import { isWhereEmpty } from './utils/query-builder-utils';
import { ModelTypeScript } from './model-typescript';
import { isModelStatic, isSameInitialModel } from './utils/model-utils';
import { SequelizeMethod } from './utils/sequelize-method';
import { Association, BelongsTo, BelongsToMany, HasMany, HasOne } from './associations';
import { AssociationSecret } from './associations/helpers';
import { Op } from './operators';
import { _validateIncludedElements, combineIncludes, setTransactionFromCls, throwInvalidInclude } from './model-internals';
import { QueryTypes } from './query-types';

const assert = require('node:assert');
const NodeUtil = require('node:util');
const _ = require('lodash');
const Dottie = require('dottie');
const { logger } = require('./utils/logger');
const { BelongsTo, BelongsToMany, Association, HasMany, HasOne } = require('./associations');
const { AssociationSecret } = require('./associations/helpers');
const { InstanceValidator } = require('./instance-validator');
const { QueryTypes } = require('./query-types');
const sequelizeErrors = require('./errors');
const DataTypes = require('./data-types');
const { Op } = require('./operators');
const { _validateIncludedElements, combineIncludes, throwInvalidInclude, setTransactionFromCls } = require('./model-internals');
const { noDoubleNestedGroup, scopeRenamedToWithScope, schemaRenamedToWithSchema, noModelDropSchema } = require('./utils/deprecations');

// This list will quickly become dated, but failing to maintain this list just means
// we won't throw a warning when we should. At least most common cases will forever be covered
Expand Down Expand Up @@ -79,8 +84,10 @@ export class Model extends ModelTypeScript {
* @param {object} [options] instance construction options
* @param {boolean} [options.raw=false] If set to true, values will ignore field and virtual setters.
* @param {boolean} [options.isNewRecord=true] Is this a new record
* @param {Array} [options.include] an array of include options - Used to build prefetched/included model instances. See `set`
* @param {symbol} secret Secret used to ensure Model.build is used instead of new Model(). Don't forget to pass it up if you define a custom constructor.
* @param {Array} [options.include] an array of include options - Used to build prefetched/included model instances. See
* `set`
* @param {symbol} secret Secret used to ensure Model.build is used instead of new Model(). Don't forget to pass it up if
* you define a custom constructor.
*/
constructor(values = {}, options = {}, secret) {
super();
Expand Down
9 changes: 8 additions & 1 deletion src/sequelize-typescript.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { AsyncLocalStorage } from 'node:async_hooks';
import { initDecoratedAssociations } from './decorators/legacy/associations.js';
import { initDecoratedModel } from './decorators/shared/model.js';
import type { Connection } from './dialects/abstract/connection-manager.js';
import type { AbstractQuery } from './dialects/abstract/query.js';
Expand Down Expand Up @@ -192,7 +193,13 @@ export abstract class SequelizeTypeScript {
);
}

// TODO: https://github.com/sequelize/sequelize/issues/15334 -- register associations declared by decorators
for (const model of models) {
initDecoratedAssociations(
model,
// @ts-expect-error -- remove once this class has been merged back with the Sequelize class
this,
);
}
}

/**
Expand Down
4 changes: 4 additions & 0 deletions test/support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -592,3 +592,7 @@ export function beforeAll2<T extends Record<string, any>>(cb: () => Promise<T> |

return out;
}

export function typeTest(_name: string, _callback: () => void): void {
// This function doesn't do anything. a type test is only checked by TSC and never runs.
}