Skip to content

Commit

Permalink
feat: add support for model inheritance (#16095)
Browse files Browse the repository at this point in the history
Co-authored-by: Rik Smale <13023439+WikiRik@users.noreply.github.com>
  • Loading branch information
ephys and WikiRik committed Jun 17, 2023
1 parent 4e4bb1f commit 6c553a9
Show file tree
Hide file tree
Showing 22 changed files with 859 additions and 147 deletions.
2 changes: 1 addition & 1 deletion packages/core/src/associations/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ export abstract class Association<

this.isAliased = Boolean(options?.as);

this.options = cloneDeep(options);
this.options = cloneDeep(options) ?? {};

source.associations[this.as] = this;
}
Expand Down
8 changes: 4 additions & 4 deletions packages/core/src/associations/belongs-to.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,13 +281,13 @@ export class BelongsTo<
* @param instances source instances
* @param options find options
*/
async get(instances: S, options: BelongsToGetAssociationMixinOptions<T>): Promise<T | null>;
async get(instances: S[], options: BelongsToGetAssociationMixinOptions<T>): Promise<Map<any, T | null>>;
async get(instances: S, options?: BelongsToGetAssociationMixinOptions<T>): Promise<T | null>;
async get(instances: S[], options?: BelongsToGetAssociationMixinOptions<T>): Promise<Map<any, T | null>>;
async get(
instances: S | S[],
options: BelongsToGetAssociationMixinOptions<T>,
options?: BelongsToGetAssociationMixinOptions<T>,
): Promise<Map<any, T | null> | T | null> {
options = cloneDeep(options);
options = cloneDeep(options) ?? {};

let Target = this.target;
if (options.scope != null) {
Expand Down
40 changes: 36 additions & 4 deletions packages/core/src/decorators/legacy/associations.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { inspect } from 'node:util';
import type { MaybeForwardedModelStatic } from '../../associations/helpers.js';
import { AssociationSecret, getForwardedModel } from '../../associations/helpers.js';
import type {
Expand All @@ -17,6 +18,7 @@ import type { AttributeNames, Model, ModelStatic } from '../../model.js';
import type { Sequelize } from '../../sequelize.js';
import { isString } from '../../utils/check.js';
import { isModelStatic } from '../../utils/model-utils.js';
import { EMPTY_ARRAY } from '../../utils/object.js';
import { throwMustBeInstanceProperty, throwMustBeModel } from './decorator-utils.js';

export type AssociationType = 'BelongsTo' | 'HasOne' | 'HasMany' | 'BelongsToMany';
Expand Down Expand Up @@ -107,15 +109,15 @@ export function BelongsToMany(
};
}

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

if (!associations) {
if (!associations.length) {
return;
}

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

const target = getForwardedModel(targetGetter, sequelize);
Expand All @@ -139,3 +141,33 @@ export function initDecoratedAssociations(model: ModelStatic, sequelize: Sequeli
}
}

function getDeclaredAssociations(model: ModelStatic): readonly RegisteredAssociation[] {
const associations: readonly RegisteredAssociation[] = registeredAssociations.get(model) ?? EMPTY_ARRAY;

const parentModel = Object.getPrototypeOf(model);
if (isModelStatic(parentModel)) {
const parentAssociations = getDeclaredAssociations(parentModel);

for (const parentAssociation of parentAssociations) {
if (parentAssociation.type !== 'BelongsTo') {
throw new Error(
`Models that use @HasOne, @HasMany, or @BelongsToMany associations cannot be inherited from, as they would add conflicting foreign keys on the target model.
Only @BelongsTo associations can be inherited, as it will add the foreign key on the source model.
Remove the ${parentAssociation.type} association ${inspect(parentAssociation.associationName)} from model ${inspect(parentModel.name)} to fix this error.`,
);
}

if ('inverse' in parentAssociation.options) {
throw new Error(
`Models that use @BelongsTo associations with the "inverse" option cannot be inherited from, as they would add conflicting associations on the target model.
Only @BelongsTo associations without the "inverse" option can be inherited, as they do not declare an association on the target model.
Remove the "inverse" option from association ${inspect(parentAssociation.associationName)} on model ${inspect(parentModel.name)} to fix this error.`,
);
}
}

return [...parentAssociations, ...associations];
}

return associations;
}
28 changes: 27 additions & 1 deletion packages/core/src/decorators/legacy/table.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Model, ModelOptions, ModelStatic } from '../../model.js';
import type { RegisteredModelOptions } from '../shared/model.js';
import { registerModelOptions } from '../shared/model.js';

/**
Expand Down Expand Up @@ -27,9 +28,34 @@ export function Table(arg: any): undefined | ClassDecorator {

const options: ModelOptions = { ...arg };

// @ts-expect-error -- making sure the option is not provided.
if (options.abstract) {
throw new Error('`abstract` is not a valid option for @Table. Did you mean to use @Table.Abstract?');
}

return (target: any) => annotate(target, options);
}

function AbstractTable<M extends Model = Model>(options: Omit<ModelOptions<M>, 'tableName' | 'name'>): ClassDecorator;
function AbstractTable(target: ModelStatic): void;
function AbstractTable(arg: any): undefined | ClassDecorator {
if (typeof arg === 'function') {
annotate(arg, { abstract: true });

return undefined;
}

const options: ModelOptions = { ...arg, abstract: true };

if (options.tableName || options.name) {
throw new Error('Options "tableName" and "name" cannot be set on abstract models.');
}

return (target: any) => annotate(target, options);
}

function annotate(target: ModelStatic, options: ModelOptions = {}): void {
Table.Abstract = AbstractTable;

function annotate(target: ModelStatic, options: RegisteredModelOptions = {}): void {
registerModelOptions(target, options);
}
125 changes: 117 additions & 8 deletions packages/core/src/decorators/shared/model.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,27 @@
import { cloneDataType } from '../../dialects/abstract/data-types-utils.js';
import { BaseError } from '../../errors/base-error.js';
import { mergeModelOptions } from '../../model-definition.js';
import { initModel } from '../../model-typescript.js';
import type { AttributeOptions, ModelAttributes, ModelOptions, ModelStatic } from '../../model.js';
import type { Sequelize } from '../../sequelize.js';
import { getAllOwnEntries } from '../../utils/object.js';
import { isModelStatic } from '../../utils/model-utils.js';
import { EMPTY_OBJECT, cloneDeep, getAllOwnEntries } from '../../utils/object.js';

export interface RegisteredModelOptions extends ModelOptions {
/**
* Abstract models cannot be used directly, or registered.
* They exist only to be extended by other models.
*/
abstract?: boolean;
}

export interface RegisteredAttributeOptions {
[key: string]: Partial<AttributeOptions>;
}

interface RegisteredOptions {
model: ModelOptions;
attributes: { [key: string]: Partial<AttributeOptions> };
model: RegisteredModelOptions;
attributes: RegisteredAttributeOptions;
}

const registeredOptions = new WeakMap<ModelStatic, RegisteredOptions>();
Expand All @@ -22,7 +36,7 @@ const registeredOptions = new WeakMap<ModelStatic, RegisteredOptions>();
*/
export function registerModelOptions(
model: ModelStatic,
options: ModelOptions,
options: RegisteredModelOptions,
): void {
if (!registeredOptions.has(model)) {
registeredOptions.set(model, { model: options, attributes: {} });
Expand Down Expand Up @@ -73,6 +87,16 @@ export function registerModelAttributeOptions(

const existingOptions = existingAttributesOptions[attributeName]!;

mergeAttributeOptions(attributeName, model, existingOptions, options, false);
}

export function mergeAttributeOptions(
attributeName: string,
model: ModelStatic,
existingOptions: Partial<AttributeOptions>,
options: Partial<AttributeOptions>,
overrideOnConflict: boolean,
): Partial<AttributeOptions> {
for (const [optionName, optionValue] of Object.entries(options)) {
if (!(optionName in existingOptions)) {
// @ts-expect-error -- runtime type checking is enforced by model
Expand All @@ -84,7 +108,7 @@ export function registerModelAttributeOptions(
if (optionName === 'validate') {
// @ts-expect-error -- dynamic type, not worth typing
for (const [subOptionName, subOptionValue] of getAllOwnEntries(optionValue)) {
if (subOptionName in existingOptions[optionName]!) {
if ((subOptionName in existingOptions[optionName]!) && !overrideOnConflict) {
throw new Error(`Multiple decorators are attempting to register option ${optionName}[${JSON.stringify(subOptionName)}] of attribute ${attributeName} on model ${model.name}.`);
}

Expand Down Expand Up @@ -114,21 +138,106 @@ export function registerModelAttributeOptions(
}

// @ts-expect-error -- dynamic type, not worth typing
if (optionValue === existingOptions[optionName]) {
if (optionValue === existingOptions[optionName] || overrideOnConflict) {
continue;
}

throw new Error(`Multiple decorators are attempting to set different values for the option ${optionName} of attribute ${attributeName} on model ${model.name}.`);
}

return existingOptions;
}

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

if (isAbstract) {
return false;
}

const modelOptions = getRegisteredModelOptions(model);
const attributeOptions = getRegisteredAttributeOptions(model);

initModel(model, attributeOptions as ModelAttributes, {
...modelOptions,
sequelize,
});

return true;
}

const NON_INHERITABLE_MODEL_OPTIONS = [
'modelName',
'name',
'tableName',
] as const;

function getRegisteredModelOptions(model: ModelStatic): ModelOptions {
const modelOptions = (registeredOptions.get(model)?.model ?? (EMPTY_OBJECT as ModelOptions));

const parentModel = Object.getPrototypeOf(model);
if (isModelStatic(parentModel)) {
const parentModelOptions: ModelOptions = { ...getRegisteredModelOptions(parentModel) };

for (const nonInheritableOption of NON_INHERITABLE_MODEL_OPTIONS) {
delete parentModelOptions[nonInheritableOption];
}

// options that must be cloned
parentModelOptions.indexes = cloneDeep(parentModelOptions.indexes);
parentModelOptions.defaultScope = cloneDeep(parentModelOptions.defaultScope);
parentModelOptions.scopes = cloneDeep(parentModelOptions.scopes);
parentModelOptions.validate = cloneDeep(parentModelOptions.validate);
parentModelOptions.hooks = cloneDeep(parentModelOptions.hooks);

return mergeModelOptions(parentModelOptions, modelOptions, true);
}

return modelOptions;
}

function getRegisteredAttributeOptions(model: ModelStatic): RegisteredAttributeOptions {
const descendantAttributes = {
...(registeredOptions.get(model)?.attributes ?? (EMPTY_OBJECT as RegisteredAttributeOptions)),
};

const parentModel = Object.getPrototypeOf(model);
if (isModelStatic(parentModel)) {
const parentAttributes: RegisteredAttributeOptions = getRegisteredAttributeOptions(parentModel);

for (const attributeName of Object.keys(parentAttributes)) {
const descendantAttribute = descendantAttributes[attributeName];
const parentAttribute = { ...parentAttributes[attributeName] };

if (parentAttribute.type) {
if (typeof parentAttribute.type === 'function') {
parentAttribute.type = new parentAttribute.type();
} else {
parentAttribute.type = cloneDataType(parentAttribute.type);
}
}

// options that must be cloned
parentAttribute.unique = cloneDeep(parentAttribute.unique);
parentAttribute.index = cloneDeep(parentAttribute.index);
parentAttribute.references = cloneDeep(parentAttribute.references);
parentAttribute.validate = cloneDeep(parentAttribute.validate);

if (!descendantAttribute) {
descendantAttributes[attributeName] = parentAttribute;
} else {
descendantAttributes[attributeName] = mergeAttributeOptions(
attributeName,
model,
parentAttribute,
descendantAttribute,
true,
);
}
}
}

return descendantAttributes;
}

export function isDecoratedModel(model: ModelStatic): boolean {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/dialects/abstract/connection-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export class AbstractConnectionManager<TConnection extends Connection = Connecti
#closed: boolean = false;

constructor(dialect: AbstractDialect, sequelize: Sequelize) {
const config: Sequelize['config'] = cloneDeep(sequelize.config);
const config: Sequelize['config'] = cloneDeep(sequelize.config) ?? {};

this.sequelize = sequelize;
this.config = config;
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/dialects/abstract/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@ export abstract class AbstractDialect {
};

protected static extendSupport(supportsOverwrite: DeepPartial<DialectSupports>): DialectSupports {
return merge(cloneDeep(this.supports), supportsOverwrite);
return merge(cloneDeep(this.supports) ?? {}, supportsOverwrite);
}

readonly sequelize: Sequelize;
Expand Down
16 changes: 8 additions & 8 deletions packages/core/src/dialects/abstract/query-interface.js
Original file line number Diff line number Diff line change
Expand Up @@ -581,7 +581,7 @@ export class AbstractQueryInterface extends AbstractQueryInterfaceTypeScript {
rawTablename = tableName;
}

options = cloneDeep(options);
options = cloneDeep(options) ?? {};
options.fields = attributes;
const sql = this.queryGenerator.addIndexQuery(tableName, options, rawTablename);

Expand Down Expand Up @@ -806,7 +806,7 @@ export class AbstractQueryInterface extends AbstractQueryInterfaceTypeScript {
assertNoReservedBind(options.bind);
}

options = cloneDeep(options);
options = cloneDeep(options) ?? {};
const modelDefinition = instance?.constructor.modelDefinition;

options.hasTrigger = modelDefinition?.options.hasTrigger;
Expand Down Expand Up @@ -988,9 +988,9 @@ export class AbstractQueryInterface extends AbstractQueryInterfaceTypeScript {
assertNoReservedBind(options.bind);
}

options = cloneDeep(options);
options = cloneDeep(options) ?? {};
if (typeof where === 'object') {
where = cloneDeep(where);
where = cloneDeep(where) ?? {};
}

const { bind, query } = this.queryGenerator.updateQuery(tableName, values, where, options, columnDefinitions);
Expand Down Expand Up @@ -1065,7 +1065,7 @@ export class AbstractQueryInterface extends AbstractQueryInterfaceTypeScript {
* @returns {Promise}
*/
async bulkDelete(tableName, where, options, model) {
options = cloneDeep(options);
options = cloneDeep(options) ?? {};
options = _.defaults(options, { limit: null });

if (options.truncate === true) {
Expand All @@ -1076,7 +1076,7 @@ export class AbstractQueryInterface extends AbstractQueryInterfaceTypeScript {
}

if (typeof identifier === 'object') {
where = cloneDeep(where);
where = cloneDeep(where) ?? {};
}

const sql = this.queryGenerator.deleteQuery(tableName, where, options, model);
Expand Down Expand Up @@ -1111,7 +1111,7 @@ export class AbstractQueryInterface extends AbstractQueryInterfaceTypeScript {
}

async #arithmeticQuery(operator, model, tableName, where, incrementAmountsByAttribute, extraAttributesToBeUpdated, options) {
options = cloneDeep(options);
options = cloneDeep(options) ?? {};
options.model = model;

const sql = this.queryGenerator.arithmeticQuery(operator, tableName, where, incrementAmountsByAttribute, extraAttributesToBeUpdated, options);
Expand All @@ -1125,7 +1125,7 @@ export class AbstractQueryInterface extends AbstractQueryInterfaceTypeScript {
}

async rawSelect(tableName, options, attributeSelector, Model) {
options = cloneDeep(options);
options = cloneDeep(options) ?? {};
options = _.defaults(options, {
raw: true,
plain: true,
Expand Down

0 comments on commit 6c553a9

Please sign in to comment.