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

[RFC] Decorators #14298

Closed
ephys opened this issue Dec 23, 2021 · 5 comments
Closed

[RFC] Decorators #14298

ephys opened this issue Dec 23, 2021 · 5 comments
Assignees
Labels
RFC Request for comments regarding breaking or large changes type: typescript For issues and PRs. Things that involve typescript, such as typings and intellisense.

Comments

@ephys
Copy link
Member

ephys commented Dec 23, 2021

This is a long one, bear with me.

I'd like to bring built-in support for decorator based model declaration in Sequelize. It's one of the main arguments I've seen in favor of using TypeORM over the years and I think it would help reduce the boilerplate of creating a new model.

I am not going to lie, a lot of this is very inspired by sequelize-typescript, with some differences.

Prior art

Foreword: Legacy Decorators & Stage 2 Decorators

Both the TypeScript & Babel communities have been using Decorators for years. These decorators follow the old decorator spec. A new spec has been in the works for a few years now. It is unclear when stable decorators will actually land in ECMAScript.

As such, I propose to do the initial implementation using Legacy Decorators but expose them through sequelize/decorators-legacy. Once decorators become stage 4, we can do a parallel implementation that is directly exposed in the root sequelize import, and deprecate /decorators-legacy.

API Design

Model registration

One of the first parts of the design would be to provide a way to register a model that has been decorated. A basic building block that's decorator-implementation agnostic. Both for existing third-party packages and a future stage-4 decorators implementation.

I would expose two methods: registerModelAttributeOptions & registerModelOptions:

/**
 * Registers attribute options for future registering of the Model using Model.init
 * Subsequent calls for the same model & attributeName will be merged, with the newer call taking precedence.
 */
registerModelAttributeOptions(model: typeof Model, attributeName: string, options: Partial<ModelAttributeColumnOptions>): void;

/**
 * Registers model options for future registering of the Model using Model.init
 * Subsequent calls for the same model & attributeName will be merged, with the newer call taking precedence.
 * 'sequelize' option is not accepted here. Pass it through `Model.init` when registering the model.
 */
registerModelOptions(model: typeof Model, options: Partial<ModelOptions>): void;

We then also need a way to register the model to Sequelize. I see a few options:

  • Overload Model.init:
    class Model {
     // use this one when registering a decorated model.
     static init(options: { sequelize: Sequelize });
     // use this one when registering a non-decorated model.
     static init(attributes: ModelAttributes, options: InitOptions);
    }
  • Overload Sequelize#define
  • Add Sequelize#registerModels
  • Add a models parameter to the Sequelize constructor.

I'd opt for overloading.

Automatic model registration

Similarly to what TypeORM & SequelizeTypescript are doing, we could add an async method Sequelize#importModels(glob) that loads files matching the glob (using ESM dynamic import), and register any export that extends model and isn't tagged as abstract (see Model Inheritance).

Model Inheritance

this would resolve #1243

A big benefit of decorator-based definition is that it becomes possible to inherit definitions (both model options & attributes):

@ModelOptions({
  underscored: true,
  abstract: true, // do not actually register this model!
})
abstract class BaseModel extends Model {
  @Column(DataType.TEXT, { unique: true, defaultValue: shortId(), field: 'external_id' })
  publicId: string;

  @Column(DataType.INTEGER, { primaryKey: true, autoIncrement: true, field: 'private_id' })
  privateId: number;
}

// inherits 'publicId' & 'privateId' from BaseModel
class User extends MyBaseModel {
  @Column(DataType.TEXT)
  name: string;
}

// inherits 'publicId' & 'privateId' from BaseModel
class Project extends MyBaseModel {
  @Column(DataType.TEXT)
  name: string;
}

Model Options Decorator: @ModelOptions

Note: Name is already taken by typing. Alternative names: @Model (already taken), @Table, @Entity, etc...

The simplest design for a Model Option decorator would be one that simply accepts the model options:

// abstract is for the creation of base models, see "inheritance"
// the `abstract` tag is of course not itself inherited :)
function ModelOptions(options: TModelOptions & { abstract?: boolean });

@ModelOptions({
  tableName: 'users'
})
class User extends MyBaseModel {}

Model Attribute Decorator

We have two choices here: A simple Attribute decorator which accepts ModelAttributeColumnOptions, or a decorator per-option like with sequelize-typescript.

This is the design I came up with, critics and counter-proposals welcome:

type TimestampAttributeOptions = Omit<ModelAttributeColumnOptions, 'type' | 'allowNull' | 'unique' | 'primaryKey' | 'autoIncrement' | 'defaultValue' | 'autoIncrementIdentity' | 'references' | 'onUpdate' | 'onDelete'>;

// use one of these to define an attribute:
function CreatedAtAttribute(options?: TimestampAttributeOptions);
function UpdateAtAttribute(options?: TimestampAttributeOptions);
function DeletedAtAttribute(options?: TimestampAttributeOptions);
function Attribute(type: DataType, options?: Omit<ModelAttributeColumnOptions, 'type'>);

Model Association Decorator

This part depends on RFC #14302 and is described over there but is basically: a decorator per association type:

import { Model, HasManyAttribute, HasMany } from "sequelize";

class User extends Model {
  @HasMany(Project)
  readonly projects!: HasManyAttribute<Project, number>;
}

class User extends Model {
  @BelongsTo(User, {
	  foreignKey: 'authorId',
	  onDelete: 'CASCADE',
  })
  readonly author!: BelongsToAttribute<User, number>;

  @Attribute(DataType.number, { allowNull: false })
  authorId: number;
}

emitDecoratorMetadata

I would vote against guessing which DataType to use based on decorator metadata. Having defaults for string, number, etc.. will lead to users accidentally using the Column Type as they're not strict enough.

other elements to consider

  • decorators for hooks
  • decorators for scopes
  • decorators for validators
@ephys ephys added the RFC Request for comments regarding breaking or large changes label Dec 23, 2021
@ephys ephys self-assigned this Dec 23, 2021
@jesse23
Copy link
Contributor

jesse23 commented Dec 23, 2021

Yes we'd better try to be compatible with that ( if no strong reason for that is not good ), as far as I know all the nest.js might be using that approach already.

@ephys
Copy link
Member Author

ephys commented Mar 28, 2022

The new decorator proposal has reached stage 3, we can consider implementing experimental support if we don't need the metadata part of it.

@ephys ephys transferred this issue from sequelize/meetings Mar 30, 2022
@ephys ephys added the type: typescript For issues and PRs. Things that involve typescript, such as typings and intellisense. label Mar 31, 2022
@mschipperheyn
Copy link

One of the main issues I have with sequelize-typescript is recursive imports. When I wanted to upgrade NextJs on my project I ran into tons of problems because recursive imports suddenly become errors instead of "just" warnings. They had switched to es6 and some behaviors changed. I wrote a PR (sequelize/sequelize-typescript#1206) to solve this issue and it did, but it was never merged. That's fine. Perhaps it offers some inspiration. My point is: these decorators have a tendency to promote recursive imports and those can create problems.

@ephys
Copy link
Member Author

ephys commented Sep 17, 2022

That's a strange decision by next, recursive imports should work as long as the export is not used during the initial module execution

I don't see why we couldn't include this when merging with sequelize-typescript. Sounds like a good change :)

@ephys
Copy link
Member Author

ephys commented Jun 17, 2023

Everything described in here is implemented now

@ephys ephys closed this as completed Jun 17, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
RFC Request for comments regarding breaking or large changes type: typescript For issues and PRs. Things that involve typescript, such as typings and intellisense.
Projects
Status: Done
Development

No branches or pull requests

3 participants