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(types): make Model.init aware of pre-configured foreign keys #14148

Merged
merged 8 commits into from Mar 22, 2022
Merged
Show file tree
Hide file tree
Changes from 6 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
128 changes: 72 additions & 56 deletions docs/manual/other-topics/typescript.md
Expand Up @@ -103,7 +103,7 @@ import {
HasManyCreateAssociationMixin, HasManyGetAssociationsMixin, HasManyHasAssociationMixin,
HasManySetAssociationsMixin, HasManyAddAssociationsMixin, HasManyHasAssociationsMixin,
HasManyRemoveAssociationMixin, HasManyRemoveAssociationsMixin, Model, ModelDefined, Optional,
Sequelize, InferAttributes, InferCreationAttributes, CreationOptional, NonAttribute
Sequelize, InferAttributes, InferCreationAttributes, CreationOptional, NonAttribute, ForeignKey,
} from '@sequelize/core';

const sequelize = new Sequelize('mysql://root:asd123@localhost:3306/mydb');
Expand Down Expand Up @@ -153,10 +153,14 @@ class User extends Model<InferAttributes<User, { omit: 'projects' }>, InferCreat
class Project extends Model<
InferAttributes<Project>,
InferCreationAttributes<Project>
> {
> {
// id can be undefined during creation when using `autoIncrement`
declare id: CreationOptional<number>;
declare ownerId: number;

// foreign keys are automatically added by associations methods (like Project.belongsTo)
// by branding them using the `ForeignKey` type, `Project.init` will know it does not need to
// display an error if ownerId is missing.
declare ownerId: ForeignKey<User['id']>;
declare name: string;

// `owner` is an eagerly-loaded association.
Expand All @@ -173,7 +177,7 @@ class Address extends Model<
InferAttributes<Address>,
InferCreationAttributes<Address>
> {
declare userId: number;
declare userId: ForeignKey<User['id']>;
declare address: string;

// createdAt can be undefined during creation
Expand All @@ -189,10 +193,6 @@ Project.init(
autoIncrement: true,
primaryKey: true
},
ownerId: {
type: DataTypes.INTEGER.UNSIGNED,
allowNull: false
},
name: {
type: new DataTypes.STRING(128),
allowNull: false
Expand Down Expand Up @@ -232,9 +232,6 @@ User.init(

Address.init(
{
userId: {
type: DataTypes.INTEGER.UNSIGNED
},
address: {
type: new DataTypes.STRING(128),
allowNull: false
Expand Down Expand Up @@ -321,6 +318,63 @@ async function doStuffWithUser() {
})();
```

### The case of `Model.init`

`Model.init` requires an attribute configuration for each attribute declared in typings.

Some attributes don't actually need to be passed to `Model.init`, this is how you can make this static method aware of them:

- Methods used to define associations (`Model.belongsTo`, `Model.hasMany`, etc…) already handle
the configuration of the necessary foreign keys attributes. It is not necessary to configure
these foreign keys using `Model.init`.
Use the `ForeignKey<>` branded type to make `Model.init` aware of the fact that it isn't necessary to configure the foreign key:

```typescript
import { Model, InferAttributes, InferCreationAttributes, DataTypes, ForeignKey } from 'sequelize';

class Project extends Model<InferAttributes<Project>, InferCreationAttributes<Project>> {
id: number;
userId: ForeignKey<number>;
}

// this configures the `userId` attribute.
Project.belongsTo(User);

// therefore, `userId` doesn't need to be specified here.
Project.init({
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
}, { sequelize });
```

- Timestamp attributes managed by Sequelize (by default, `createdAt`, `updatedAt`, and `deletedAt`) don't need to be configured using `Model.init`,
unfortunately `Model.init` has no way of knowing this. We recommend you use the minimum necessary configuration to silence this error:

```typescript
import { Model, InferAttributes, InferCreationAttributes, DataTypes } from 'sequelize';

class User extends Model<InferAttributes<User>, InferCreationAttributes<User>> {
id: number;
createdAt: Date;
updatedAt: Date;
}

User.init({
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
// technically, `createdAt` & `updatedAt` are added by Sequelize and don't need to be configured in Model.init
// but the typings of Model.init do not know this. Add the following to mute the typing error:
createdAt: DataTypes.DATE,
updatedAt: DataTypes.DATE,
}, { sequelize });
```

### Usage without strict types for attributes

The typings for Sequelize v5 allowed you to define models without specifying types for the attributes. This is still possible for backwards compatibility and for cases where you feel strict typing for attributes isn't worth it.
Expand Down Expand Up @@ -380,58 +434,19 @@ In Sequelize versions before v5, the default way of defining a model involved us
[//]: # (NOTE for maintainers: Keep the following code in sync with `typescriptDocs/Define.ts` to ensure it typechecks correctly.)

```ts
import { Sequelize, Model, DataTypes, Optional } from '@sequelize/core';
import { Sequelize, Model, DataTypes, CreationOptional, InferAttributes, InferCreationAttributes } from '@sequelize/core';

const sequelize = new Sequelize("mysql://root:asd123@localhost:3306/mydb");
const sequelize = new Sequelize('mysql://root:asd123@localhost:3306/mydb');

// We recommend you declare an interface for the attributes, for stricter typechecking
interface UserAttributes {
id: number;
name: string;
}

// Some fields are optional when calling UserModel.create() or UserModel.build()
interface UserCreationAttributes extends Optional<UserAttributes, "id"> {}

// We need to declare an interface for our model that is basically what our class would be
interface UserInstance
extends Model<UserAttributes, UserCreationAttributes>,
UserAttributes {}

const UserModel = sequelize.define<UserInstance>("User", {
id: {
primaryKey: true,
type: DataTypes.INTEGER.UNSIGNED,
},
name: {
type: DataTypes.STRING,
},
});

async function doStuff() {
const instance = await UserModel.findByPk(1, {
rejectOnEmpty: true,
});
console.log(instance.id);
}
```

If you're comfortable with somewhat less strict typing for the attributes on a model, you can save some code by defining the Instance to just extend `Model` without any attributes in the generic types.

[//]: # (NOTE for maintainers: Keep the following code in sync with `typescriptDocs/DefineNoAttributes.ts` to ensure it typechecks correctly.)

```ts
import { Sequelize, Model, DataTypes } from '@sequelize/core';

const sequelize = new Sequelize("mysql://root:asd123@localhost:3306/mydb");

// We need to declare an interface for our model that is basically what our class would be
interface UserInstance extends Model {
id: number;
interface UserModel extends Model<InferAttributes<UserModel>, InferCreationAttributes<UserModel>> {
// Some fields are optional when calling UserModel.create() or UserModel.build()
id: CreationOptional<number>;
name: string;
}

const UserModel = sequelize.define<UserInstance>("User", {
const UserModel = sequelize.define<UserModel>('User', {
id: {
primaryKey: true,
type: DataTypes.INTEGER.UNSIGNED,
Expand All @@ -445,6 +460,7 @@ async function doStuff() {
const instance = await UserModel.findByPk(1, {
rejectOnEmpty: true,
});

console.log(instance.id);
}
```
Expand Down
37 changes: 32 additions & 5 deletions src/model.d.ts
Expand Up @@ -7,7 +7,7 @@ import { ValidationOptions } from './instance-validator';
import { IndexesOptions, QueryOptions, TableName } from './dialects/abstract/query-interface';
import { Sequelize, SyncOptions } from './sequelize';
import { Col, Fn, Literal, Where, MakeNullishOptional, AnyFunction } from './utils';
import { LOCK, Transaction, Op } from './index';
import { LOCK, Transaction, Op, Optional } from './index';
import { SetRequired } from './utils/set-required';

export interface Logging {
Expand Down Expand Up @@ -1730,7 +1730,12 @@ export abstract class Model<TModelAttributes extends {} = any, TCreationAttribut
*/
public static init<MS extends ModelStatic<Model>, M extends InstanceType<MS>>(
this: MS,
attributes: ModelAttributes<M, Attributes<M>>, options: InitOptions<M>
attributes: ModelAttributes<
M,
// 'foreign keys' are optional in Model.init as they are added by association declaration methods
Optional<Attributes<M>, BrandedKeysOf<Attributes<M>, typeof ForeignKeyBrand>>
>,
options: InitOptions<M>
): MS;

/**
Expand Down Expand Up @@ -3042,6 +3047,10 @@ type IsBranded<T, Brand extends symbol> = keyof NonNullable<T> extends keyof Omi
? false
: true;

type BrandedKeysOf<T, Brand extends symbol> = {
[P in keyof T]-?: IsBranded<T[P], Brand> extends true ? P : never
}[keyof T];

/**
* Dummy Symbol used as branding by {@link NonAttribute}.
*
Expand All @@ -3054,14 +3063,32 @@ declare const NonAttributeBrand: unique symbol;
* You can use it to tag fields from your class that are NOT attributes.
* They will be ignored by {@link InferAttributes} and {@link InferCreationAttributes}
*/

export type NonAttribute<T> =
// we don't brand null & undefined as they can't have properties.
// This means `NonAttribute<null>` will not work, but who makes an attribute that only accepts null?
// Note that `NonAttribute<string | null>` does work!
T extends null | undefined ? T
: (T & { [NonAttributeBrand]?: true });

/**
* Dummy Symbol used as branding by {@link ForeignKey}.
*
* Do not export, Do not use.
*/
declare const ForeignKeyBrand: unique symbol;

/**
* This is a Branded Type.
* You can use it to tag fields from your class that are foreign keys.
* They will become optional in {@link Model.init} (as foreign keys are added by association methods, like {@link Model.hasMany}.
*/
export type ForeignKey<T> =
// we don't brand null & undefined as they can't have properties.
// This means `ForeignKey<null>` will not work, but who makes an attribute that only accepts null?
// Note that `ForeignKey<string | null>` does work!
T extends null | undefined ? T
: (T & { [ForeignKeyBrand]?: true });

/**
* Option bag for {@link InferAttributes}.
*
Expand Down Expand Up @@ -3136,8 +3163,8 @@ declare const CreationAttributeBrand: unique symbol;
*/
export type CreationOptional<T> =
// we don't brand null & undefined as they can't have properties.
// This means `CreationAttributeBrand<null>` will not work, but who makes an attribute that only accepts null?
// Note that `CreationAttributeBrand<string | null>` does work!
// This means `CreationOptional<null>` will not work, but who makes an attribute that only accepts null?
// Note that `CreationOptional<string | null>` does work!
T extends null | undefined ? T
: (T & { [CreationAttributeBrand]?: true });

Expand Down
13 changes: 13 additions & 0 deletions test/types/infer-attributes.ts
Expand Up @@ -3,10 +3,13 @@ import {
Attributes,
CreationAttributes,
CreationOptional,
DataTypes,
ForeignKey,
InferAttributes,
InferCreationAttributes,
Model,
NonAttribute,
Sequelize,
} from '@sequelize/core';

class Project extends Model<InferAttributes<Project>> {
Expand All @@ -32,6 +35,7 @@ class User extends Model<InferAttributes<User, { omit: 'omittedAttribute' | 'omi
declare omittedAttributeArray: number[];

declare joinedEntity?: NonAttribute<Project>;
declare projectId: CreationOptional<ForeignKey<number>>;

instanceMethod() {
}
Expand All @@ -40,6 +44,15 @@ class User extends Model<InferAttributes<User, { omit: 'omittedAttribute' | 'omi
}
}

User.init({
mandatoryArrayAttribute: DataTypes.ARRAY(DataTypes.STRING),
mandatoryAttribute: DataTypes.STRING,
// projectId is omitted but still works, because it is branded with 'ForeignKey'
WikiRik marked this conversation as resolved.
Show resolved Hide resolved
nullableOptionalAttribute: DataTypes.STRING,
optionalArrayAttribute: DataTypes.ARRAY(DataTypes.STRING),
optionalAttribute: DataTypes.INTEGER,
}, { sequelize: new Sequelize() });

type UserAttributes = Attributes<User>;
type UserCreationAttributes = CreationAttributes<User>;

Expand Down
21 changes: 8 additions & 13 deletions test/types/typescriptDocs/Define.ts
Expand Up @@ -4,37 +4,32 @@
*
* Don't include this comment in the md file.
*/
import { Sequelize, Model, DataTypes, Optional } from '@sequelize/core';
import { Sequelize, Model, DataTypes, CreationOptional, InferAttributes, InferCreationAttributes } from '@sequelize/core';

const sequelize = new Sequelize('mysql://root:asd123@localhost:3306/mydb');

// We recommend you declare an interface for the attributes, for stricter typechecking
interface UserAttributes {
id: number;

interface UserModel extends Model<InferAttributes<UserModel>, InferCreationAttributes<UserModel>> {
// Some fields are optional when calling UserModel.create() or UserModel.build()
id: CreationOptional<number>;
name: string;
}

// Some fields are optional when calling UserModel.create() or UserModel.build()
interface UserCreationAttributes extends Optional<UserAttributes, 'id'> {}

// We need to declare an interface for our model that is basically what our class would be
interface UserInstance
extends Model<UserAttributes, UserCreationAttributes>,
UserAttributes {}

const UserModel = sequelize.define<UserInstance>('User', {
const UserModel = sequelize.define<UserModel>('User', {
id: {
primaryKey: true,
type: DataTypes.INTEGER.UNSIGNED,
},
name: {
type: DataTypes.STRING,
}
},
});

async function doStuff() {
const instance = await UserModel.findByPk(1, {
rejectOnEmpty: true,
});

console.log(instance.id);
}
32 changes: 0 additions & 32 deletions test/types/typescriptDocs/DefineNoAttributes.ts

This file was deleted.