Skip to content

Commit

Permalink
feat: migrate model definitions to TypeScript (#15431)
Browse files Browse the repository at this point in the history
BREAKING CHANGE: Type `ModelAttributeColumnOptions` has been renamed `AttributeOptions`
BREAKING CHANGE: `setterMethods` and `getterMethods` model options, which were deprecated in a previous major version, have been removed. See https://sequelize.org/docs/v6/core-concepts/getters-setters-virtuals/#deprecated-in-sequelize-v7-gettermethods-and-settermethods for alternatives.
BREAKING CHANGE: In the "references" attribute option, the "model" option has been split in two options: "table" and "model". "table" references a database table, and "model" references a Sequelize model.
BREAKING CHANGE: Sequelize will now throw if you try to define an attribute that conflicts (i.e. its options are incompatible) with one of the automatic timestamp or version attributes that are added on Models by Sequelize.
BREAKING CHANGE: `Model#validators` and many private state and methods have been removed from models and moved to `Model.modelDefinition`
  • Loading branch information
ephys committed Dec 18, 2022
1 parent a85ca7e commit f57e5a0
Show file tree
Hide file tree
Showing 141 changed files with 3,364 additions and 2,832 deletions.
4 changes: 4 additions & 0 deletions .eslintrc.js
Expand Up @@ -38,9 +38,13 @@ module.exports = {
'prefer-object-has-own': 'off',
'unicorn/prefer-at': 'off',
'unicorn/prefer-string-replace-all': 'off',
'consistent-return': 'off',

// This rule is incompatible with DataTypes
'babel/new-cap': 'off',

// Too opinionated
'unicorn/prefer-set-has': 'off',
},
overrides: [{
files: ['**/*.{js,mjs,cjs}'],
Expand Down
7 changes: 4 additions & 3 deletions src/associations/base.ts
@@ -1,5 +1,5 @@
import type { Optional } from '../index.js';
import type { Model, ModelStatic, Hookable, AttributeNames, ModelAttributeColumnOptions } from '../model';
import type { Model, ModelStatic, Hookable, AttributeNames, AttributeOptions } from '../model';
import { cloneDeep } from '../utils/object.js';
import type { AllowArray } from '../utils/types.js';
import type { NormalizeBaseAssociationOptions } from './helpers';
Expand Down Expand Up @@ -221,6 +221,7 @@ export abstract class MultiAssociation<
}

const tmpInstance = Object.create(null);
// @ts-expect-error -- TODO: what if the target has no primary key?
tmpInstance[this.target.primaryKeyAttribute] = element;

return this.target.build(tmpInstance, { isNewRecord: false });
Expand Down Expand Up @@ -248,11 +249,11 @@ export type MultiAssociationAccessors = {
};

/** Foreign Key Options */
export interface ForeignKeyOptions<ForeignKey extends string> extends Optional<ModelAttributeColumnOptions, 'type'> {
export interface ForeignKeyOptions<ForeignKey extends string> extends Optional<AttributeOptions, 'type'> {
/**
* The name of the foreign key attribute.
*
* Not to be confused with {@link ModelAttributeColumnOptions#field} which controls the name of the foreign key Column.
* Not to be confused with {@link AttributeOptions#columnName} which controls the name of the foreign key Column.
*/
name?: ForeignKey;

Expand Down
29 changes: 16 additions & 13 deletions src/associations/belongs-to-many.ts
Expand Up @@ -339,9 +339,13 @@ export class BelongsToMany<
#makeFkPairUnique() {
let hasPrimaryKey = false;

const throughModelDefinition = this.throughModel.modelDefinition;

// remove any PKs previously defined by sequelize
// but ignore any keys that are part of this association (#5865)
each(this.through.model.rawAttributes, (attribute, attributeName) => {
const { rawAttributes: throughRawAttributes } = throughModelDefinition;

each(throughRawAttributes, (attribute, attributeName) => {
if (!attribute.primaryKey) {
return;
}
Expand All @@ -351,7 +355,7 @@ export class BelongsToMany<
}

if (attribute._autoGenerated) {
delete this.through.model.rawAttributes[attributeName];
delete throughRawAttributes[attributeName];

return;
}
Expand All @@ -365,22 +369,22 @@ export class BelongsToMany<
Add your own primary key to the through model, on different attributes than the foreign keys, to be able to use this option.`);
}

this.throughModel.rawAttributes[this.foreignKey].primaryKey = true;
this.throughModel.rawAttributes[this.otherKey].primaryKey = true;
throughRawAttributes[this.foreignKey].primaryKey = true;
throughRawAttributes[this.otherKey].primaryKey = true;
} else if (this.through.unique !== false) {
let uniqueKey;
if (typeof this.through.unique === 'string' && this.through.unique !== '') {
uniqueKey = this.through.unique;
} else {
const keys = [this.foreignKey, this.otherKey].sort();
uniqueKey = [this.through.model.tableName, ...keys, 'unique'].join('_');
uniqueKey = [this.through.model.table.tableName, ...keys, 'unique'].join('_');
}

this.throughModel.rawAttributes[this.foreignKey].unique = [{ name: uniqueKey }];
this.throughModel.rawAttributes[this.otherKey].unique = [{ name: uniqueKey }];
throughRawAttributes[this.foreignKey].unique = [{ name: uniqueKey }];
throughRawAttributes[this.otherKey].unique = [{ name: uniqueKey }];
}

this.throughModel.refreshAttributes();
throughModelDefinition.refreshAttributes();
}

static associate<
Expand Down Expand Up @@ -843,17 +847,16 @@ function normalizeThroughOptions<M extends Model>(
} else if (sequelize.isDefined(through.model)) {
model = sequelize.model<M>(through.model);
} else {
const sourceTable = source.table;

model = sequelize.define(through.model, {} as ModelAttributes<M>, removeUndefined({
tableName: through.model,
indexes: [], // we don't want indexes here (as referenced in #2416)
paranoid: through.paranoid || false, // Default to non-paranoid join (referenced in #11991)
validate: {}, // Don't propagate model-level validations
timestamps: through.timestamps,

// @ts-expect-error -- TODO: make 'schema' a public property on Model once the method has been removed (sequelize 8)
schema: source._schema,
// @ts-expect-error -- TODO: either remove or make a public readonly property
schemaDelimiter: source._schemaDelimiter,
schema: sourceTable.schema,
schemaDelimiter: sourceTable.delimiter,
}));
}

Expand Down
61 changes: 51 additions & 10 deletions src/associations/belongs-to.ts
@@ -1,4 +1,5 @@
import assert from 'node:assert';
import isEqual from 'lodash/isEqual';
import isObject from 'lodash/isObject.js';
import upperFirst from 'lodash/upperFirst';
import { cloneDataType } from '../dialects/abstract/data-types-utils.js';
Expand All @@ -12,7 +13,9 @@ import type {
SaveOptions,
AttributeNames,
Attributes,
AttributeReferencesOptions,
} from '../model';
import { normalizeReference } from '../model-definition.js';
import { Op } from '../operators';
import { getColumnName } from '../utils/format.js';
import { isSameInitialModel } from '../utils/model-utils.js';
Expand All @@ -24,7 +27,6 @@ import { HasMany } from './has-many.js';
import { HasOne } from './has-one.js';
import type { NormalizeBaseAssociationOptions } from './helpers';
import {
addForeignKeyConstraints,
defineAssociation,
mixinMethods, normalizeBaseAssociationOptions,
} from './helpers';
Expand Down Expand Up @@ -65,6 +67,7 @@ export class BelongsTo<
/**
* The column name of the foreign key
*/
// TODO: rename to foreignKeyColumnName
identifierField: string;

/**
Expand All @@ -77,6 +80,7 @@ export class BelongsTo<
/**
* The column name of the target key
*/
// TODO: rename to targetKeyColumnName
readonly targetKeyField: string;

readonly targetKeyIsPrimary: boolean;
Expand All @@ -100,7 +104,9 @@ export class BelongsTo<
// TODO: throw is source model has a composite primary key.
const targetKey = options?.targetKey || (target.primaryKeyAttribute as TargetKey);

if (!target.getAttributes()[targetKey]) {
const targetAttributes = target.modelDefinition.attributes;

if (!targetAttributes.has(targetKey)) {
throw new Error(`Unknown attribute "${options.targetKey}" passed as targetKey, define this attribute on model "${target.name}" first`);
}

Expand All @@ -115,10 +121,9 @@ export class BelongsTo<
// For Db2 server, a reference column of a FOREIGN KEY must be unique
// else, server throws SQL0573N error. Hence, setting it here explicitly
// for non primary columns.
if (target.sequelize.options.dialect === 'db2' && this.target.getAttributes()[this.targetKey].primaryKey !== true) {
if (target.sequelize.options.dialect === 'db2' && targetAttributes.get(this.targetKey)!.primaryKey !== true) {
// TODO: throw instead
// @ts-expect-error -- pending removal, not worth typing
this.target.getAttributes()[this.targetKey].unique = true;
this.target.modelDefinition.rawAttributes[this.targetKey].unique = true;
}

let foreignKey: string | undefined;
Expand All @@ -139,16 +144,52 @@ export class BelongsTo<

this.foreignKey = foreignKey as SourceKey;

this.targetKeyField = getColumnName(targetAttributes.get(this.targetKey)!);
this.targetKeyIsPrimary = this.targetKey === this.target.primaryKeyAttribute;

const targetAttribute = targetAttributes.get(this.targetKey)!;

const existingForeignKey = source.modelDefinition.rawAttributes[this.foreignKey];
const newForeignKeyAttribute = removeUndefined({
type: cloneDataType(this.target.rawAttributes[this.targetKey].type),
type: cloneDataType(targetAttribute.type),
...foreignKeyAttributeOptions,
allowNull: this.source.rawAttributes[this.foreignKey]?.allowNull ?? foreignKeyAttributeOptions?.allowNull,
allowNull: existingForeignKey?.allowNull ?? foreignKeyAttributeOptions?.allowNull,
});

this.targetKeyField = getColumnName(this.target.getAttributes()[this.targetKey]);
this.targetKeyIsPrimary = this.targetKey === this.target.primaryKeyAttribute;
// FK constraints are opt-in: users must either set `foreignKeyConstraints`
// on the association, or request an `onDelete` or `onUpdate` behavior
if (options.foreignKeyConstraints !== false) {
const existingReference = existingForeignKey?.references
? (normalizeReference(existingForeignKey.references) ?? existingForeignKey.references) as AttributeReferencesOptions
: undefined;

const queryGenerator = this.source.sequelize.getQueryInterface().queryGenerator;

const existingReferencedTable = existingReference?.table
? queryGenerator.extractTableDetails(existingReference.table)
: undefined;

addForeignKeyConstraints(newForeignKeyAttribute, this.target, this.options, this.targetKeyField);
const newReferencedTable = queryGenerator.extractTableDetails(this.target);

const newReference: AttributeReferencesOptions = {};
if (existingReferencedTable) {
if (!isEqual(existingReferencedTable, newReferencedTable)) {
throw new Error(`Foreign key ${this.foreignKey} on ${this.source.name} already references ${queryGenerator.quoteTable(existingReferencedTable)}, but this association needs to make it reference ${queryGenerator.quoteTable(newReferencedTable)} instead.`);
}
} else {
newReference.table = newReferencedTable;
}

if (existingReference?.key && existingReference.key !== this.targetKeyField) {
throw new Error(`Foreign key ${this.foreignKey} on ${this.source.name} already references column ${existingReference.key}, but this association needs to make it reference ${this.targetKeyField} instead.`);
}

newReference.key = this.targetKeyField;

newForeignKeyAttribute.references = newReference;
newForeignKeyAttribute.onDelete ??= newForeignKeyAttribute.allowNull !== false ? 'SET NULL' : 'CASCADE';
newForeignKeyAttribute.onUpdate ??= newForeignKeyAttribute.onUpdate ?? 'CASCADE';
}

this.source.mergeAttributesDefault({
[this.foreignKey]: newForeignKeyAttribute,
Expand Down
9 changes: 9 additions & 0 deletions src/associations/has-many.ts
Expand Up @@ -313,6 +313,7 @@ export class HasMany<
}

return {
// @ts-expect-error -- TODO: what if the target has no primary key?
[this.target.primaryKeyAttribute]: instance,
};
}),
Expand All @@ -321,6 +322,7 @@ export class HasMany<
const findOptions: HasManyGetAssociationsMixinOptions<T> = {
...options,
scope: false,
// @ts-expect-error -- TODO: what if the target has no primary key?
attributes: [this.target.primaryKeyAttribute],
raw: true,
// @ts-expect-error -- TODO: current WhereOptions typings do not allow having 'WhereOptions' inside another 'WhereOptions'
Expand Down Expand Up @@ -379,7 +381,9 @@ export class HasMany<
} as UpdateValues<T>;

const updateWhere = {
// @ts-expect-error -- TODO: what if the target has no primary key?
[this.target.primaryKeyAttribute]: unassociatedObjects.map(unassociatedObject => {
// @ts-expect-error -- TODO: what if the target has no primary key?
return unassociatedObject.get(this.target.primaryKeyAttribute);
}),
};
Expand Down Expand Up @@ -421,7 +425,9 @@ export class HasMany<
} as UpdateValues<T>;

const where = {
// @ts-expect-error -- TODO: what if the target has no primary key?
[this.target.primaryKeyAttribute]: targetInstances.map(unassociatedObject => {
// @ts-expect-error -- TODO: what if the target has no primary key?
return unassociatedObject.get(this.target.primaryKeyAttribute);
}),
};
Expand Down Expand Up @@ -460,12 +466,15 @@ export class HasMany<

const where = {
[this.foreignKey]: sourceInstance.get(this.sourceKey),
// @ts-expect-error -- TODO: what if the target has no primary key?
[this.target.primaryKeyAttribute]: targetInstances.map(targetInstance => {
if (targetInstance instanceof this.target) {
// @ts-expect-error -- TODO: what if the target has no primary key?
return (targetInstance as T).get(this.target.primaryKeyAttribute);
}

// raw entity
// @ts-expect-error -- TODO: what if the target has no primary key?
if (isPlainObject(targetInstance) && this.target.primaryKeyAttribute in targetInstance) {
// @ts-expect-error -- implicit any, can't be fixed
return targetInstance[this.target.primaryKeyAttribute];
Expand Down
2 changes: 2 additions & 0 deletions src/associations/has-one.ts
Expand Up @@ -259,6 +259,7 @@ because, as this is a hasOne association, the foreign key we need to update is l

const alreadyAssociated = !oldInstance || !associatedInstanceOrPk ? false
: associatedInstanceOrPk instanceof Model ? associatedInstanceOrPk.equals(oldInstance)
// @ts-expect-error -- TODO: what if the target has no primary key?
: oldInstance.get(this.target.primaryKeyAttribute) === associatedInstanceOrPk;

if (alreadyAssociated) {
Expand Down Expand Up @@ -286,6 +287,7 @@ because, as this is a hasOne association, the foreign key we need to update is l
associatedInstance = associatedInstanceOrPk as T;
} else {
const tmpInstance = Object.create(null);
// @ts-expect-error -- TODO: what if the target has no primary key?
tmpInstance[this.target.primaryKeyAttribute] = associatedInstanceOrPk;
associatedInstance = this.target.build(tmpInstance, {
isNewRecord: false,
Expand Down
28 changes: 1 addition & 27 deletions src/associations/helpers.ts
Expand Up @@ -6,7 +6,7 @@ import lowerFirst from 'lodash/lowerFirst';
import omit from 'lodash/omit';
import type { Class } from 'type-fest';
import { AssociationError } from '../errors/index.js';
import type { Model, ModelAttributeColumnOptions, ModelStatic } from '../model';
import type { Model, ModelStatic } from '../model';
import type { Sequelize } from '../sequelize';
import * as deprecations from '../utils/deprecations.js';
import { isModelStatic, isSameInitialModel } from '../utils/model-utils.js';
Expand All @@ -25,32 +25,6 @@ export function checkNamingCollision(source: ModelStatic<any>, associationName:
}
}

export function addForeignKeyConstraints(
newAttribute: ModelAttributeColumnOptions,
source: ModelStatic,
options: AssociationOptions<string>,
key: string,
): void {
// FK constraints are opt-in: users must either set `foreignKeyConstraints`
// on the association, or request an `onDelete` or `onUpdate` behavior

if (options.foreignKeyConstraints !== false) {
// Find primary keys: composite keys not supported with this approach
const primaryKeys = Object.keys(source.primaryKeys)
.map(primaryKeyAttribute => source.getAttributes()[primaryKeyAttribute].field || primaryKeyAttribute);

if (primaryKeys.length === 1 || !primaryKeys.includes(key)) {
newAttribute.references = {
model: source.getTableName(),
key: key || primaryKeys[0],
};

newAttribute.onDelete = newAttribute.onDelete ?? (newAttribute.allowNull !== false ? 'SET NULL' : 'CASCADE');
newAttribute.onUpdate = newAttribute.onUpdate ?? 'CASCADE';
}
}
}

/**
* Mixin (inject) association methods to model prototype
*
Expand Down
8 changes: 4 additions & 4 deletions src/decorators/legacy/attribute-utils.ts
@@ -1,4 +1,4 @@
import type { ModelAttributeColumnOptions, ModelStatic } from '../../model.js';
import type { AttributeOptions, ModelStatic } from '../../model.js';
import { Model } from '../../model.js';
import { registerModelAttributeOptions } from '../shared/model.js';
import type {
Expand All @@ -25,7 +25,7 @@ export function createRequiredAttributeOptionsDecorator<T>(
target: Object,
propertyName: string | symbol,
propertyDescriptor: PropertyDescriptor | undefined,
) => Partial<ModelAttributeColumnOptions>,
) => Partial<AttributeOptions>,
): RequiredParameterizedPropertyDecorator<T> {
return createOptionalAttributeOptionsDecorator(decoratorName, DECORATOR_NO_DEFAULT, callback);
}
Expand All @@ -45,7 +45,7 @@ export function createOptionalAttributeOptionsDecorator<T>(
target: Object,
propertyName: string | symbol,
propertyDescriptor: PropertyDescriptor | undefined,
) => Partial<ModelAttributeColumnOptions>,
) => Partial<AttributeOptions>,
): OptionalParameterizedPropertyDecorator<T> {
return createOptionallyParameterizedPropertyDecorator(
decoratorName,
Expand All @@ -63,7 +63,7 @@ function annotate(
target: Object,
propertyName: string | symbol,
propertyDescriptor: PropertyDescriptor | undefined,
options: Partial<ModelAttributeColumnOptions>,
options: Partial<AttributeOptions>,
): void {
if (typeof propertyName === 'symbol') {
throw new TypeError('Symbol Model Attributes are not currently supported. We welcome a PR that implements this feature.');
Expand Down

0 comments on commit f57e5a0

Please sign in to comment.