123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380 |
- import type {
- BelongsToGetAssociationMixin,
- BelongsToManyAssociation,
- BelongsToManySetAssociationsMixin,
- CreationOptional,
- InferAttributes,
- InferCreationAttributes,
- ModelStatic,
- } from '@sequelize/core';
- import {
- AssociationError,
- BelongsToAssociation,
- DataTypes,
- HasManyAssociation,
- HasOneAssociation,
- Model,
- } from '@sequelize/core';
- import { expect } from 'chai';
- import each from 'lodash/each';
- import type { SinonStub } from 'sinon';
- import sinon from 'sinon';
- import {
- beforeEach2,
- createSequelizeInstance,
- getTestDialectTeaser,
- resetSequelizeInstance,
- sequelize,
- } from '../../support';
- describe(getTestDialectTeaser('belongsToMany'), () => {
- beforeEach(() => {
- resetSequelizeInstance();
- });
- it('throws when invalid model is passed', () => {
- const User = sequelize.define('User');
- expect(() => {
- // @ts-expect-error -- testing that invalid input results in error
- User.belongsToMany();
- }).to.throw(
- `User.belongsToMany was called with undefined as the target model, but it is not a subclass of Sequelize's Model class`,
- );
- });
- it('creates the join table when through is a string', async () => {
- const User = sequelize.define('User');
- const Group = sequelize.define('Group');
- User.belongsToMany(Group, { as: 'MyGroups', through: 'GroupUser' });
- Group.belongsToMany(User, { as: 'MyUsers', through: 'GroupUser' });
- expect(sequelize.models.getOrThrow('GroupUser')).to.exist;
- });
- it('should not inherit scopes from parent to join table', () => {
- const A = sequelize.define('a');
- const B = sequelize.define(
- 'b',
- {},
- {
- defaultScope: {
- where: {
- foo: 'bar',
- },
- },
- scopes: {
- baz: {
- where: {
- fooz: 'zab',
- },
- },
- },
- },
- );
- B.belongsToMany(A, { through: 'AB' });
- const AB = sequelize.models.getOrThrow('AB');
- expect(AB.options.defaultScope).to.deep.equal({});
- expect(AB.options.scopes).to.deep.equal({});
- });
- it('should not inherit validations from parent to join table', () => {
- const A = sequelize.define('a');
- const B = sequelize.define(
- 'b',
- {},
- {
- validate: {
- validateModel() {
- return true;
- },
- },
- },
- );
- B.belongsToMany(A, { through: 'AB' });
- const AB = sequelize.models.getOrThrow('AB');
- expect(AB.options.validate).to.deep.equal({});
- });
- it('should not override custom methods with association mixin', () => {
- const methods = {
- getTasks: 'get',
- countTasks: 'count',
- hasTask: 'has',
- hasTasks: 'has',
- setTasks: 'set',
- addTask: 'add',
- addTasks: 'add',
- removeTask: 'remove',
- removeTasks: 'remove',
- createTask: 'create',
- };
- const User = sequelize.define('User');
- const Task = sequelize.define('Task');
- function originalMethod() {}
- each(methods, (alias, method) => {
- // TODO: remove this eslint-disable once we drop support for TypeScript <= 5.3
- // eslint-disable-next-line @typescript-eslint/prefer-ts-expect-error
- // @ts-ignore -- This only became invalid starting with TS 5.4
- User.prototype[method] = originalMethod;
- });
- User.belongsToMany(Task, { through: 'UserTasks', as: 'task' });
- const user = User.build();
- each(methods, (alias, method) => {
- // @ts-expect-error -- dynamic type, not worth typing
- expect(user[method]).to.eq(originalMethod);
- });
- });
- it('allows customizing the inverse association name (long form)', () => {
- const User = sequelize.define('User');
- const Task = sequelize.define('Task');
- User.belongsToMany(Task, { through: 'UserTask', as: 'tasks', inverse: { as: 'users' } });
- expect(Task.associations.users).to.be.ok;
- expect(User.associations.tasks).to.be.ok;
- });
- it('allows customizing the inverse association name (shorthand)', () => {
- const User = sequelize.define('User');
- const Task = sequelize.define('Task');
- User.belongsToMany(Task, { through: 'UserTask', as: 'tasks', inverse: 'users' });
- expect(Task.associations.users).to.be.ok;
- expect(User.associations.tasks).to.be.ok;
- });
- it('allows defining two associations with the same through, but with a different scope on the through table', () => {
- const User = sequelize.define('User');
- const Post = sequelize.define('Post', { editing: DataTypes.BOOLEAN });
- User.belongsToMany(Post, { through: 'UserPost' });
- Post.belongsToMany(User, { through: 'UserPost' });
- User.belongsToMany(Post, {
- as: 'editingPosts',
- inverse: {
- as: 'editingUsers',
- },
- through: {
- model: 'UserPost',
- scope: {
- editing: true,
- },
- },
- });
- });
- it('allows defining two associations with the same inverse association', () => {
- const User = sequelize.define('User');
- const Post = sequelize.define('Post');
- const Association1 = Post.belongsToMany(User, {
- through: { model: 'UserPost' },
- as: 'categories',
- scope: { type: 'category' },
- });
- const Association2 = Post.belongsToMany(User, {
- through: { model: 'UserPost' },
- as: 'tags',
- scope: { type: 'tag' },
- });
- // This means Association1.pairedWith.pairedWith is not always Association1
- // This may be an issue
- expect(Association1.pairedWith).to.eq(Association2.pairedWith);
- });
- it('lets you customize the name of the intermediate associations', () => {
- const User = sequelize.define('User');
- const Group = sequelize.define('Group');
- const GroupUser = sequelize.define('GroupUser');
- User.belongsToMany(Group, {
- through: GroupUser,
- as: 'groups',
- throughAssociations: {
- toSource: 'toSource',
- toTarget: 'toTarget',
- fromSource: 'fromSources',
- fromTarget: 'fromTargets',
- },
- inverse: {
- as: 'members',
- },
- });
- expect(Object.keys(User.associations).sort()).to.deep.eq([
- 'fromSource',
- 'fromSources',
- 'groups',
- ]);
- expect(Object.keys(Group.associations).sort()).to.deep.eq([
- 'fromTarget',
- 'fromTargets',
- 'members',
- ]);
- expect(Object.keys(GroupUser.associations).sort()).to.deep.eq(['toSource', 'toTarget']);
- });
- it('errors when trying to define similar associations with incompatible inverse associations', () => {
- const User = sequelize.define('User');
- const Post = sequelize.define('Post');
- Post.belongsToMany(User, {
- through: { model: 'UserPost' },
- as: 'categories',
- scope: { type: 'category' },
- });
- expect(() => {
- Post.belongsToMany(User, {
- through: { model: 'UserPost' },
- as: 'tags',
- scope: { type: 'tag' },
- otherKey: {
- onUpdate: 'NO ACTION',
- },
- });
- }).to.throw('Defining BelongsToMany association "tags" from Post to User failed');
- });
- it('errors when trying to define the same association', () => {
- const User = sequelize.define('User');
- const Post = sequelize.define('Post');
- Post.belongsToMany(User, {
- through: { model: 'UserPost' },
- });
- expect(() => {
- Post.belongsToMany(User, { through: { model: 'UserPost' } });
- }).to.throw(
- 'You have defined two associations with the same name "users" on the model "Post". Use another alias using the "as" parameter',
- );
- });
- it('generates a default association name', () => {
- const User = sequelize.define('User', {});
- const Task = sequelize.define('Task', {});
- User.belongsToMany(Task, { through: 'UserTask' });
- expect(Object.keys(Task.associations)).to.deep.eq(['users', 'usersTasks', 'userTask']);
- expect(Object.keys(User.associations)).to.deep.eq(['tasks', 'tasksUsers', 'taskUser']);
- });
- describe('proper syntax', () => {
- it('throws an AssociationError if the through option is undefined, true, or null', () => {
- const User = sequelize.define('User', {});
- const Task = sequelize.define('Task', {});
- // @ts-expect-error -- we're testing that these do throw
- const errorFunction1 = () => User.belongsToMany(Task, { through: true });
- // @ts-expect-error -- see above
- const errorFunction2 = () => User.belongsToMany(Task, { through: undefined });
- // @ts-expect-error -- see above
- const errorFunction3 = () => User.belongsToMany(Task, { through: null });
- for (const errorFunction of [errorFunction1, errorFunction2, errorFunction3]) {
- expect(errorFunction).to.throwWithCause(
- AssociationError,
- `${User.name}.belongsToMany(${Task.name}) requires a through model, set the "through", or "through.model" options to either a string or a model`,
- );
- }
- });
- it('throws an AssociationError for a self-association defined without an alias', () => {
- const User = sequelize.define('User', {});
- const errorFunction = User.belongsToMany.bind(User, User, { through: 'jointable' });
- expect(errorFunction).to.throwWithCause(
- AssociationError,
- 'Both options "as" and "inverse.as" must be defined for belongsToMany self-associations, and their value must be different.',
- );
- });
- });
- describe('timestamps', () => {
- it('follows the global timestamps true option', () => {
- const User = sequelize.define('User', {});
- const Task = sequelize.define('Task', {});
- User.belongsToMany(Task, { through: 'user_task1' });
- expect(sequelize.models.getOrThrow('user_task1').getAttributes()).to.contain.all.keys([
- 'createdAt',
- 'updatedAt',
- ]);
- });
- it('allows me to override the global timestamps option', () => {
- const User = sequelize.define('User', {});
- const Task = sequelize.define('Task', {});
- User.belongsToMany(Task, { through: { model: 'user_task2', timestamps: false } });
- expect(sequelize.models.getOrThrow('user_task2').getAttributes()).not.to.contain.any.keys([
- 'createdAt',
- 'updatedAt',
- ]);
- });
- it('follows the global timestamps false option', () => {
- const sequelize2 = createSequelizeInstance({
- define: {
- timestamps: false,
- },
- });
- const User = sequelize2.define('User', {});
- const Task = sequelize2.define('Task', {});
- User.belongsToMany(Task, { through: 'user_task3' });
- expect(sequelize2.models.getOrThrow('user_task3').getAttributes()).not.to.contain.any.keys([
- 'createdAt',
- 'updatedAt',
- ]);
- });
- });
- describe('optimizations using bulk create, destroy and update', () => {
- function getEntities() {
- class User extends Model<InferAttributes<User>, InferCreationAttributes<User>> {
- declare id: CreationOptional<number>;
- declare username: string | null;
- declare setTasks: BelongsToManySetAssociationsMixin<Task, number>;
- }
- User.init(
- {
- id: {
- type: DataTypes.INTEGER,
- primaryKey: true,
- autoIncrement: true,
- },
- username: DataTypes.STRING,
- },
- { sequelize },
- );
- class Task extends Model<InferAttributes<Task>> {
- declare id: CreationOptional<number>;
- declare title: string | null;
- }
- Task.init(
- {
- id: {
- type: DataTypes.INTEGER,
- primaryKey: true,
- autoIncrement: true,
- },
- title: DataTypes.STRING,
- },
- { sequelize },
- );
- const UserTasks = sequelize.define('UserTasks', {});
- User.belongsToMany(Task, { through: UserTasks });
- Task.belongsToMany(User, { through: UserTasks });
- const user = User.build({
- id: 42,
- });
- const task1 = Task.build({
- id: 15,
- });
- const task2 = Task.build({
- id: 16,
- });
- sinon.stub(UserTasks, 'findAll').resolves([]);
- sinon.stub(UserTasks, 'bulkCreate').resolves([]);
- sinon.stub(UserTasks, 'destroy').resolves(0);
- return { user, task1, task2, UserTasks };
- }
- afterEach(() => {
- sinon.restore();
- });
- it('uses one insert into statement', async () => {
- const { user, task1, task2, UserTasks } = getEntities();
- await user.setTasks([task1, task2]);
- expect(UserTasks.findAll).to.have.been.calledOnce;
- expect(UserTasks.bulkCreate).to.have.been.calledOnce;
- });
- it('uses one delete from statement', async () => {
- const { user, task1, task2, UserTasks } = getEntities();
- (UserTasks.findAll as SinonStub)
- .onFirstCall()
- .resolves([])
- .onSecondCall()
- .resolves([
- { userId: 42, taskId: 15 },
- { userId: 42, taskId: 16 },
- ]);
- await user.setTasks([task1, task2]);
- await user.setTasks([]);
- expect(UserTasks.findAll).to.have.been.calledTwice;
- expect(UserTasks.destroy).to.have.been.calledOnce;
- });
- });
- describe('foreign keys', () => {
- it('should infer otherKey from paired BTM relationship with a through string defined', () => {
- const User = sequelize.define('User', {});
- const Place = sequelize.define('Place', {});
- const Places = User.belongsToMany(Place, {
- through: 'user_places',
- foreignKey: 'user_id',
- otherKey: 'place_id',
- });
- const Users = Place.getAssociation('users') as BelongsToManyAssociation;
- expect(Places.pairedWith).to.equal(Users);
- expect(Users.pairedWith).to.equal(Places);
- expect(Places.foreignKey).to.equal('user_id');
- expect(Users.foreignKey).to.equal('place_id');
- expect(Places.otherKey).to.equal('place_id');
- expect(Users.otherKey).to.equal('user_id');
- });
- it('should infer otherKey from paired BTM relationship with a through model defined', () => {
- const User = sequelize.define('User', {});
- const Place = sequelize.define('Place', {});
- const UserPlace = sequelize.define(
- 'UserPlace',
- {
- id: {
- primaryKey: true,
- type: DataTypes.INTEGER,
- autoIncrement: true,
- },
- },
- { timestamps: false },
- );
- const Places = User.belongsToMany(Place, {
- through: UserPlace,
- foreignKey: 'user_id',
- otherKey: 'place_id',
- });
- const Users = Place.getAssociation('users') as BelongsToManyAssociation;
- expect(Places.pairedWith).to.equal(Users);
- expect(Users.pairedWith).to.equal(Places);
- expect(Places.foreignKey).to.equal('user_id');
- expect(Users.foreignKey).to.equal('place_id');
- expect(Places.otherKey).to.equal('place_id');
- expect(Users.otherKey).to.equal('user_id');
- expect(Object.keys(UserPlace.getAttributes()).length).to.equal(3); // Defined primary key and two foreign keys
- });
- it('should infer foreign keys (camelCase)', () => {
- const Person = sequelize.define('Person');
- const PersonChildren = sequelize.define('PersonChildren');
- const Children = Person.belongsToMany(Person, {
- as: 'Children',
- through: PersonChildren,
- inverse: { as: 'Parents' },
- });
- expect(Children.foreignKey).to.equal('parentId');
- expect(Children.otherKey).to.equal('childId');
- expect(PersonChildren.getAttributes()[Children.foreignKey]).to.be.ok;
- expect(PersonChildren.getAttributes()[Children.otherKey]).to.be.ok;
- });
- it('should infer foreign keys (snake_case)', () => {
- const Person = sequelize.define('Person', {}, { underscored: true });
- const PersonChildren = sequelize.define('PersonChildren', {}, { underscored: true });
- const Children = Person.belongsToMany(Person, {
- as: 'Children',
- through: PersonChildren,
- inverse: { as: 'Parents' },
- });
- expect(Children.foreignKey).to.equal('parentId');
- expect(Children.otherKey).to.equal('childId');
- expect(PersonChildren.getAttributes()[Children.foreignKey]).to.be.ok;
- expect(PersonChildren.getAttributes()[Children.otherKey]).to.be.ok;
- expect(PersonChildren.getAttributes()[Children.foreignKey].columnName).to.equal('parent_id');
- expect(PersonChildren.getAttributes()[Children.otherKey].columnName).to.equal('child_id');
- });
- it('should create non-null foreign keys by default', () => {
- const A = sequelize.define('A');
- const B = sequelize.define('B');
- const association = A.belongsToMany(B, { through: 'AB' });
- const attributes = association.throughModel.getAttributes();
- expect(attributes.aId.allowNull).to.be.false;
- expect(attributes.bId.allowNull).to.be.false;
- });
- it('allows creating nullable FKs', () => {
- const A = sequelize.define('A');
- const B = sequelize.define('B');
- const association = A.belongsToMany(B, {
- through: 'AB',
- foreignKey: { allowNull: true },
- otherKey: { allowNull: true },
- });
- const attributes = association.throughModel.getAttributes();
- expect(attributes.aId.allowNull).to.be.true;
- expect(attributes.bId.allowNull).to.be.true;
- });
- it('should add FKs with onDelete=cascade by default', () => {
- const A = sequelize.define('A');
- const B = sequelize.define('B');
- const association = A.belongsToMany(B, { through: 'AB', foreignKey: {} });
- const attributes = association.throughModel.getAttributes();
- expect(attributes.aId.onDelete).to.eq('CASCADE');
- expect(attributes.bId.onDelete).to.eq('CASCADE');
- });
- });
- describe('source/target keys', () => {
- it('should infer targetKey from paired BTM relationship with a through string defined', () => {
- const User = sequelize.define('User', { user_id: DataTypes.UUID });
- const Place = sequelize.define('Place', { place_id: DataTypes.UUID });
- const Places = User.belongsToMany(Place, {
- through: 'user_places',
- sourceKey: 'user_id',
- targetKey: 'place_id',
- });
- const Users = Place.getAssociation('users') as BelongsToManyAssociation;
- expect(Places.pairedWith).to.equal(Users);
- expect(Users.pairedWith).to.equal(Places);
- expect(Places.sourceKey).to.equal('user_id');
- expect(Users.sourceKey).to.equal('place_id');
- expect(Places.targetKey).to.equal('place_id');
- expect(Users.targetKey).to.equal('user_id');
- });
- it('should infer targetKey from paired BTM relationship with a through model defined', () => {
- const User = sequelize.define('User', { user_id: DataTypes.UUID });
- const Place = sequelize.define('Place', { place_id: DataTypes.UUID });
- const UserPlace = sequelize.define(
- 'UserPlace',
- {
- id: {
- primaryKey: true,
- type: DataTypes.INTEGER,
- autoIncrement: true,
- },
- },
- { timestamps: false },
- );
- const Places = User.belongsToMany(Place, {
- through: UserPlace,
- sourceKey: 'user_id',
- targetKey: 'place_id',
- });
- const Users = Place.getAssociation('users') as BelongsToManyAssociation;
- expect(Places.pairedWith).to.equal(Users);
- expect(Users.pairedWith).to.equal(Places);
- expect(Places.sourceKey).to.equal('user_id');
- expect(Users.sourceKey).to.equal('place_id', 'Users.sourceKey is invalid');
- expect(Places.targetKey).to.equal('place_id');
- expect(Users.targetKey).to.equal('user_id', 'Users.targetKey is invalid');
- expect(Object.keys(UserPlace.getAttributes()).length).to.equal(3); // Defined primary key and two foreign keys
- });
- });
- describe('pseudo associations', () => {
- it('should setup belongsTo relations to source and target from join model with defined foreign/other keys', () => {
- const Product = sequelize.define('Product', {
- title: DataTypes.STRING,
- });
- const Tag = sequelize.define('Tag', {
- name: DataTypes.STRING,
- });
- const ProductTag = sequelize.define(
- 'ProductTag',
- {
- id: {
- primaryKey: true,
- type: DataTypes.INTEGER,
- autoIncrement: true,
- },
- priority: DataTypes.INTEGER,
- },
- {
- timestamps: false,
- },
- );
- const ProductTags = Product.belongsToMany(Tag, {
- through: ProductTag,
- foreignKey: 'productId',
- otherKey: 'tagId',
- });
- const TagProducts = Tag.belongsToMany(Product, {
- through: ProductTag,
- foreignKey: 'tagId',
- otherKey: 'productId',
- });
- expect(ProductTags.fromThroughToSource).to.be.an.instanceOf(BelongsToAssociation);
- expect(ProductTags.fromThroughToTarget).to.be.an.instanceOf(BelongsToAssociation);
- expect(TagProducts.fromThroughToSource).to.be.an.instanceOf(BelongsToAssociation);
- expect(TagProducts.fromThroughToTarget).to.be.an.instanceOf(BelongsToAssociation);
- expect(ProductTags.fromThroughToSource.foreignKey).to.equal(ProductTags.foreignKey);
- expect(ProductTags.fromThroughToTarget.foreignKey).to.equal(ProductTags.otherKey);
- expect(TagProducts.fromThroughToSource.foreignKey).to.equal(TagProducts.foreignKey);
- expect(TagProducts.fromThroughToTarget.foreignKey).to.equal(TagProducts.otherKey);
- expect(Object.keys(ProductTag.getAttributes()).length).to.equal(4);
- expect(Object.keys(ProductTag.getAttributes()).sort()).to.deep.equal(
- ['id', 'priority', 'productId', 'tagId'].sort(),
- );
- });
- it('should setup hasMany relations to source and target from join model with defined foreign/other keys', () => {
- const Product = sequelize.define('Product', {
- title: DataTypes.STRING,
- });
- const Tag = sequelize.define('Tag', {
- name: DataTypes.STRING,
- });
- const ProductTag = sequelize.define(
- 'ProductTag',
- {
- id: {
- primaryKey: true,
- type: DataTypes.INTEGER,
- autoIncrement: true,
- },
- priority: DataTypes.INTEGER,
- },
- {
- timestamps: false,
- },
- );
- const ProductTags = Product.belongsToMany(Tag, {
- through: ProductTag,
- foreignKey: 'productId',
- otherKey: 'tagId',
- });
- const TagProducts = Tag.belongsToMany(Product, {
- through: ProductTag,
- foreignKey: 'tagId',
- otherKey: 'productId',
- });
- expect(ProductTags.fromSourceToThrough).to.be.an.instanceOf(HasManyAssociation);
- expect(ProductTags.fromTargetToThrough).to.be.an.instanceOf(HasManyAssociation);
- expect(TagProducts.fromSourceToThrough).to.be.an.instanceOf(HasManyAssociation);
- expect(TagProducts.fromTargetToThrough).to.be.an.instanceOf(HasManyAssociation);
- expect(ProductTags.fromSourceToThrough.foreignKey).to.equal(ProductTags.foreignKey);
- expect(ProductTags.fromTargetToThrough.foreignKey).to.equal(ProductTags.otherKey);
- expect(TagProducts.fromSourceToThrough.foreignKey).to.equal(TagProducts.foreignKey);
- expect(TagProducts.fromTargetToThrough.foreignKey).to.equal(TagProducts.otherKey);
- expect(Object.keys(ProductTag.getAttributes()).length).to.equal(4);
- expect(Object.keys(ProductTag.getAttributes()).sort()).to.deep.equal(
- ['id', 'priority', 'tagId', 'productId'].sort(),
- );
- });
- it('should setup hasOne relations to source and target from join model with defined foreign/other keys', () => {
- const Product = sequelize.define('Product', {
- title: DataTypes.STRING,
- });
- const Tag = sequelize.define('Tag', {
- name: DataTypes.STRING,
- });
- const ProductTag = sequelize.define(
- 'ProductTag',
- {
- id: {
- primaryKey: true,
- type: DataTypes.INTEGER,
- autoIncrement: true,
- },
- priority: DataTypes.INTEGER,
- },
- {
- timestamps: false,
- },
- );
- const ProductTags = Product.belongsToMany(Tag, {
- through: ProductTag,
- foreignKey: 'productId',
- otherKey: 'tagId',
- });
- const TagProducts = Tag.belongsToMany(Product, {
- through: ProductTag,
- foreignKey: 'tagId',
- otherKey: 'productId',
- });
- expect(ProductTags.fromSourceToThroughOne).to.be.an.instanceOf(HasOneAssociation);
- expect(ProductTags.fromTargetToThroughOne).to.be.an.instanceOf(HasOneAssociation);
- expect(TagProducts.fromSourceToThroughOne).to.be.an.instanceOf(HasOneAssociation);
- expect(TagProducts.fromTargetToThroughOne).to.be.an.instanceOf(HasOneAssociation);
- expect(ProductTags.fromSourceToThroughOne.foreignKey).to.equal(ProductTags.foreignKey);
- expect(ProductTags.fromTargetToThroughOne.foreignKey).to.equal(ProductTags.otherKey);
- expect(TagProducts.fromSourceToThroughOne.foreignKey).to.equal(TagProducts.foreignKey);
- expect(TagProducts.fromTargetToThroughOne.foreignKey).to.equal(TagProducts.otherKey);
- expect(Object.keys(ProductTag.getAttributes()).length).to.equal(4);
- expect(Object.keys(ProductTag.getAttributes()).sort()).to.deep.equal(
- ['id', 'priority', 'productId', 'tagId'].sort(),
- );
- });
- it('should setup hasOne relations to source and target from join model with defined source keys', () => {
- const Product = sequelize.define('Product', {
- title: DataTypes.STRING,
- productSecondaryId: DataTypes.STRING,
- });
- const Tag = sequelize.define('Tag', {
- name: DataTypes.STRING,
- tagSecondaryId: DataTypes.STRING,
- });
- const ProductTag = sequelize.define(
- 'ProductTag',
- {
- id: {
- primaryKey: true,
- type: DataTypes.INTEGER,
- autoIncrement: true,
- },
- priority: DataTypes.INTEGER,
- },
- {
- timestamps: false,
- },
- );
- const ProductTags = Product.belongsToMany(Tag, {
- through: ProductTag,
- sourceKey: 'productSecondaryId',
- targetKey: 'tagSecondaryId',
- });
- const TagProducts = Tag.getAssociation('products') as BelongsToManyAssociation;
- expect(ProductTags.foreignKey).to.equal(
- 'productProductSecondaryId',
- 'generated foreign key for source name (product) + source key (productSecondaryId) should result in productProductSecondaryId',
- );
- expect(TagProducts.foreignKey).to.equal('tagTagSecondaryId');
- expect(ProductTags.fromSourceToThroughOne).to.be.an.instanceOf(HasOneAssociation);
- expect(ProductTags.fromTargetToThroughOne).to.be.an.instanceOf(HasOneAssociation);
- expect(TagProducts.fromSourceToThroughOne).to.be.an.instanceOf(HasOneAssociation);
- expect(TagProducts.fromTargetToThroughOne).to.be.an.instanceOf(HasOneAssociation);
- expect(TagProducts.fromSourceToThroughOne.sourceKey).to.equal(TagProducts.sourceKey);
- expect(TagProducts.fromTargetToThroughOne.sourceKey).to.equal(TagProducts.targetKey);
- expect(ProductTags.fromSourceToThroughOne.sourceKey).to.equal(ProductTags.sourceKey);
- expect(ProductTags.fromTargetToThroughOne.sourceKey).to.equal(ProductTags.targetKey);
- expect(Object.keys(ProductTag.getAttributes()).length).to.equal(4);
- expect(Object.keys(ProductTag.getAttributes()).sort()).to.deep.equal(
- ['id', 'priority', 'productProductSecondaryId', 'tagTagSecondaryId'].sort(),
- );
- });
- it('should setup belongsTo relations to source and target from join model with only foreign keys defined', () => {
- const Product = sequelize.define('Product', {
- title: DataTypes.STRING,
- });
- const Tag = sequelize.define('Tag', {
- name: DataTypes.STRING,
- });
- const ProductTag = sequelize.define(
- 'ProductTag',
- {
- id: {
- primaryKey: true,
- type: DataTypes.INTEGER,
- autoIncrement: true,
- },
- priority: DataTypes.INTEGER,
- },
- {
- timestamps: false,
- },
- );
- const ProductTags = Product.belongsToMany(Tag, {
- through: ProductTag,
- foreignKey: 'product_ID',
- otherKey: 'tag_ID',
- });
- const TagProducts = Tag.getAssociation('products') as BelongsToManyAssociation;
- expect(ProductTags.fromThroughToSource).to.be.ok;
- expect(ProductTags.fromThroughToTarget).to.be.ok;
- expect(TagProducts.fromThroughToSource).to.be.ok;
- expect(TagProducts.fromThroughToTarget).to.be.ok;
- expect(ProductTags.fromThroughToSource.foreignKey).to.equal(ProductTags.foreignKey);
- expect(ProductTags.fromThroughToTarget.foreignKey).to.equal(ProductTags.otherKey);
- expect(TagProducts.fromThroughToSource.foreignKey).to.equal(TagProducts.foreignKey);
- expect(TagProducts.fromThroughToTarget.foreignKey).to.equal(TagProducts.otherKey);
- expect(Object.keys(ProductTag.getAttributes()).length).to.equal(4);
- expect(Object.keys(ProductTag.getAttributes()).sort()).to.deep.equal(
- ['id', 'priority', 'product_ID', 'tag_ID'].sort(),
- );
- });
- it('should setup hasOne relations to source and target from join model with only foreign keys defined', () => {
- const Product = sequelize.define('Product', {
- title: DataTypes.STRING,
- });
- const Tag = sequelize.define('Tag', {
- name: DataTypes.STRING,
- });
- const ProductTag = sequelize.define(
- 'ProductTag',
- {
- id: {
- primaryKey: true,
- type: DataTypes.INTEGER,
- autoIncrement: true,
- },
- priority: DataTypes.INTEGER,
- },
- {
- timestamps: false,
- },
- );
- const ProductTags = Product.belongsToMany(Tag, {
- through: ProductTag,
- foreignKey: 'product_ID',
- otherKey: 'tag_ID',
- });
- const TagProducts = Tag.getAssociation('products') as BelongsToManyAssociation;
- expect(ProductTags.fromSourceToThroughOne).to.be.an.instanceOf(HasOneAssociation);
- expect(ProductTags.fromTargetToThroughOne).to.be.an.instanceOf(HasOneAssociation);
- expect(TagProducts.fromSourceToThroughOne).to.be.an.instanceOf(HasOneAssociation);
- expect(TagProducts.fromTargetToThroughOne).to.be.an.instanceOf(HasOneAssociation);
- expect(ProductTags.fromSourceToThroughOne.foreignKey).to.equal(ProductTags.foreignKey);
- expect(ProductTags.fromTargetToThroughOne.foreignKey).to.equal(ProductTags.otherKey);
- expect(TagProducts.fromSourceToThroughOne.foreignKey).to.equal(TagProducts.foreignKey);
- expect(TagProducts.fromTargetToThroughOne.foreignKey).to.equal(TagProducts.otherKey);
- expect(Object.keys(ProductTag.getAttributes()).length).to.equal(4);
- expect(Object.keys(ProductTag.getAttributes()).sort()).to.deep.equal(
- ['id', 'priority', 'product_ID', 'tag_ID'].sort(),
- );
- });
- it('should setup belongsTo relations to source and target from join model with no foreign keys defined', () => {
- const Product = sequelize.define('Product', {
- title: DataTypes.STRING,
- });
- const Tag = sequelize.define('Tag', {
- name: DataTypes.STRING,
- });
- const ProductTag = sequelize.define(
- 'ProductTag',
- {
- id: {
- primaryKey: true,
- type: DataTypes.INTEGER,
- autoIncrement: true,
- },
- priority: DataTypes.INTEGER,
- },
- {
- timestamps: false,
- },
- );
- const ProductTags = Product.belongsToMany(Tag, { through: ProductTag });
- const TagProducts = Tag.belongsToMany(Product, { through: ProductTag });
- expect(ProductTags.fromThroughToSource).to.be.ok;
- expect(ProductTags.fromThroughToTarget).to.be.ok;
- expect(TagProducts.fromThroughToSource).to.be.ok;
- expect(TagProducts.fromThroughToTarget).to.be.ok;
- expect(ProductTags.fromThroughToSource.foreignKey).to.equal(ProductTags.foreignKey);
- expect(ProductTags.fromThroughToTarget.foreignKey).to.equal(ProductTags.otherKey);
- expect(TagProducts.fromThroughToSource.foreignKey).to.equal(TagProducts.foreignKey);
- expect(TagProducts.fromThroughToTarget.foreignKey).to.equal(TagProducts.otherKey);
- expect(Object.keys(ProductTag.getAttributes()).length).to.equal(4);
- expect(Object.keys(ProductTag.getAttributes()).sort()).to.deep.equal(
- ['id', 'priority', 'productId', 'tagId'].sort(),
- );
- });
- });
- describe('associations on the join table', () => {
- const vars = beforeEach2(() => {
- class User extends Model {}
- class Project extends Model {}
- class UserProject extends Model {
- declare getUser: BelongsToGetAssociationMixin<User>;
- declare getProject: BelongsToGetAssociationMixin<Project>;
- }
- sequelize.addModels([User, Project, UserProject]);
- User.belongsToMany(Project, { through: UserProject });
- Project.belongsToMany(User, { through: UserProject });
- return { User, Project, UserProject };
- });
- it('should work for belongsTo associations defined before belongsToMany', () => {
- expect(vars.UserProject.prototype.getUser).to.be.ok;
- });
- it('should work for belongsTo associations defined after belongsToMany', () => {
- expect(vars.UserProject.prototype.getProject).to.be.ok;
- });
- });
- describe('self-associations', () => {
- it('does not pair multiple self associations with different through arguments', () => {
- const User = sequelize.define('user', {});
- const UserFollower = sequelize.define('userFollowers', {});
- const Invite = sequelize.define('invite', {});
- const UserFollowers = User.belongsToMany(User, {
- as: 'Followers',
- inverse: {
- as: 'Followings',
- },
- through: UserFollower,
- });
- const UserInvites = User.belongsToMany(User, {
- as: 'Invites',
- inverse: {
- as: 'Inviters',
- },
- foreignKey: 'InviteeId',
- through: Invite,
- });
- expect(UserFollowers.pairedWith).not.to.eq(UserInvites);
- expect(UserInvites.pairedWith).not.to.be.eq(UserFollowers);
- expect(UserFollowers.otherKey).not.to.equal(UserInvites.foreignKey);
- });
- it('correctly generates a foreign/other key when none are defined', () => {
- const User = sequelize.define('user', {});
- const UserFollower = sequelize.define(
- 'userFollowers',
- {
- id: {
- type: DataTypes.INTEGER,
- primaryKey: true,
- autoIncrement: true,
- },
- },
- {
- timestamps: false,
- },
- );
- const UserFollowers = User.belongsToMany(User, {
- as: 'Followers',
- inverse: {
- as: 'Followings',
- },
- through: UserFollower,
- });
- expect(UserFollowers.foreignKey).to.eq('followingId');
- expect(UserFollowers.otherKey).to.eq('followerId');
- expect(Object.keys(UserFollower.getAttributes()).length).to.equal(3);
- });
- it('works with singular and plural name for self-associations', () => {
- // Models taken from https://github.com/sequelize/sequelize/issues/3796
- const Service = sequelize.define('service', {});
- Service.belongsToMany(Service, {
- through: 'Supplements',
- as: 'supplements',
- inverse: {
- as: { singular: 'supplemented', plural: 'supplemented' },
- },
- });
- expect(Service.prototype).to.have.ownProperty('getSupplements').to.be.a('function');
- expect(Service.prototype).to.have.ownProperty('addSupplement').to.be.a('function');
- expect(Service.prototype).to.have.ownProperty('addSupplements').to.be.a('function');
- expect(Service.prototype).to.have.ownProperty('getSupplemented').to.be.a('function');
- expect(Service.prototype).not.to.have.ownProperty('getSupplementeds').to.be.a('function');
- expect(Service.prototype).to.have.ownProperty('addSupplemented').to.be.a('function');
- expect(Service.prototype).not.to.have.ownProperty('addSupplementeds').to.be.a('function');
- });
- });
- describe('constraints', () => {
- it('work properly when through is a string', () => {
- const User = sequelize.define('User', {});
- const Group = sequelize.define('Group', {});
- User.belongsToMany(Group, {
- as: 'MyGroups',
- through: 'group_user',
- foreignKey: {
- onUpdate: 'RESTRICT',
- onDelete: 'SET NULL',
- },
- otherKey: {
- onUpdate: 'SET NULL',
- onDelete: 'RESTRICT',
- },
- inverse: {
- as: 'MyUsers',
- },
- });
- const MyUsers = Group.associations.MyUsers as BelongsToManyAssociation;
- const MyGroups = User.associations.MyGroups as BelongsToManyAssociation;
- const throughModel = MyUsers.through.model;
- expect(Object.keys(throughModel.getAttributes()).sort()).to.deep.equal(
- ['userId', 'groupId', 'createdAt', 'updatedAt'].sort(),
- );
- expect(throughModel === MyGroups.through.model);
- expect(throughModel.getAttributes().userId.onUpdate).to.equal('RESTRICT');
- expect(throughModel.getAttributes().userId.onDelete).to.equal('SET NULL');
- expect(throughModel.getAttributes().groupId.onUpdate).to.equal('SET NULL');
- expect(throughModel.getAttributes().groupId.onDelete).to.equal('RESTRICT');
- });
- it('work properly when through is a model', () => {
- const User = sequelize.define('User', {});
- const Group = sequelize.define('Group', {});
- const UserGroup = sequelize.define('GroupUser', {}, { tableName: 'user_groups' });
- User.belongsToMany(Group, {
- as: 'MyGroups',
- through: UserGroup,
- foreignKey: {
- onUpdate: 'RESTRICT',
- onDelete: 'SET NULL',
- },
- otherKey: {
- onUpdate: 'SET NULL',
- onDelete: 'RESTRICT',
- },
- inverse: {
- as: 'MyUsers',
- },
- });
- const MyUsers = Group.associations.MyUsers as BelongsToManyAssociation;
- const MyGroups = User.associations.MyGroups as BelongsToManyAssociation;
- expect(MyUsers.through.model === MyGroups.through.model);
- const Through = MyUsers.through.model;
- expect(Object.keys(Through.getAttributes()).sort()).to.deep.equal(
- ['userId', 'groupId', 'createdAt', 'updatedAt'].sort(),
- );
- expect(Through.getAttributes().userId.onUpdate).to.equal(
- 'RESTRICT',
- 'UserId.onUpdate should have been RESTRICT',
- );
- expect(Through.getAttributes().userId.onDelete).to.equal(
- 'SET NULL',
- 'UserId.onDelete should have been SET NULL',
- );
- expect(Through.getAttributes().groupId.onUpdate).to.equal(
- 'SET NULL',
- 'GroupId.OnUpdate should have been SET NULL',
- );
- expect(Through.getAttributes().groupId.onDelete).to.equal(
- 'RESTRICT',
- 'GroupId.onDelete should have been RESTRICT',
- );
- });
- it('makes the foreign keys primary keys', () => {
- const User = sequelize.define('User', {});
- const Group = sequelize.define('Group', {});
- const association = User.belongsToMany(Group, {
- as: 'MyGroups',
- through: 'GroupUser',
- inverse: {
- as: 'MyUsers',
- },
- });
- const Through = association.throughModel;
- expect(Object.keys(Through.getAttributes()).sort()).to.deep.equal(
- ['createdAt', 'updatedAt', 'groupId', 'userId'].sort(),
- );
- expect(Through.getAttributes().userId.primaryKey).to.be.true;
- expect(Through.getAttributes().groupId.primaryKey).to.be.true;
- // @ts-expect-error -- this property does not exist after normalization
- expect(Through.getAttributes().userId.unique).to.be.undefined;
- // @ts-expect-error -- this property does not exist after normalization
- expect(Through.getAttributes().groupId.unique).to.be.undefined;
- });
- it('generates unique identifier with very long length', () => {
- const User = sequelize.define('User', {}, { tableName: 'table_user_with_very_long_name' });
- const Group = sequelize.define('Group', {}, { tableName: 'table_group_with_very_long_name' });
- const UserGroup = sequelize.define(
- 'GroupUser',
- {
- id: {
- type: DataTypes.INTEGER,
- primaryKey: true,
- },
- id_user_very_long_field: {
- type: DataTypes.INTEGER(1),
- },
- id_group_very_long_field: {
- type: DataTypes.INTEGER(1),
- },
- },
- { tableName: 'table_user_group_with_very_long_name' },
- );
- User.belongsToMany(Group, {
- as: 'MyGroups',
- through: UserGroup,
- foreignKey: 'id_user_very_long_field',
- otherKey: 'id_group_very_long_field',
- inverse: {
- as: 'MyUsers',
- },
- });
- const MyUsers = Group.associations.MyUsers as BelongsToManyAssociation;
- const MyGroups = User.associations.MyGroups as BelongsToManyAssociation;
- const Through = MyUsers.through.model;
- expect(Through === MyGroups.through.model);
- expect(Object.keys(Through.getAttributes()).sort()).to.deep.equal(
- [
- 'id',
- 'createdAt',
- 'updatedAt',
- 'id_user_very_long_field',
- 'id_group_very_long_field',
- ].sort(),
- );
- expect(Through.getIndexes()).to.deep.equal([
- {
- name: 'table_user_group_with_very_long_name_id_group_very_long_field_id_user_very_long_field_unique',
- unique: true,
- fields: ['id_user_very_long_field', 'id_group_very_long_field'],
- column: 'id_user_very_long_field',
- },
- ]);
- // @ts-expect-error -- this property does not exist after normalization
- expect(Through.getAttributes().id_user_very_long_field.unique).to.be.undefined;
- // @ts-expect-error -- this property does not exist after normalization
- expect(Through.getAttributes().id_group_very_long_field.unique).to.be.undefined;
- });
- it('generates unique identifier with custom name', () => {
- const User = sequelize.define('User', {}, { tableName: 'table_user_with_very_long_name' });
- const Group = sequelize.define('Group', {}, { tableName: 'table_group_with_very_long_name' });
- const UserGroup = sequelize.define(
- 'GroupUser',
- {
- id: {
- type: DataTypes.INTEGER,
- primaryKey: true,
- },
- id_user_very_long_field: {
- type: DataTypes.INTEGER(1),
- },
- id_group_very_long_field: {
- type: DataTypes.INTEGER(1),
- },
- },
- { tableName: 'table_user_group_with_very_long_name' },
- );
- User.belongsToMany(Group, {
- as: 'MyGroups',
- through: {
- model: UserGroup,
- unique: 'custom_user_group_unique',
- },
- foreignKey: 'id_user_very_long_field',
- otherKey: 'id_group_very_long_field',
- inverse: {
- as: 'MyUsers',
- },
- });
- const MyUsers = Group.associations.MyUsers as BelongsToManyAssociation;
- const MyGroups = User.associations.MyGroups as BelongsToManyAssociation;
- expect(MyUsers.through.model === UserGroup);
- expect(MyGroups.through.model === UserGroup);
- expect(UserGroup.getIndexes()).to.deep.equal([
- {
- name: 'custom_user_group_unique',
- unique: true,
- fields: ['id_user_very_long_field', 'id_group_very_long_field'],
- column: 'id_user_very_long_field',
- },
- ]);
- // @ts-expect-error -- this property does not exist after normalization
- expect(UserGroup.getAttributes().id_user_very_long_field.unique).to.be.undefined;
- // @ts-expect-error -- this property does not exist after normalization
- expect(UserGroup.getAttributes().id_group_very_long_field.unique).to.be.undefined;
- });
- });
- describe('association hooks', () => {
- let Project: ModelStatic<any>;
- let Task: ModelStatic<any>;
- beforeEach(() => {
- Project = sequelize.define('Project', { title: DataTypes.STRING });
- Task = sequelize.define('Task', { title: DataTypes.STRING });
- });
- describe('beforeBelongsToManyAssociate', () => {
- it('should trigger', () => {
- const beforeAssociate = sinon.spy();
- Project.beforeAssociate(beforeAssociate);
- Project.belongsToMany(Task, { through: 'projects_and_tasks', hooks: true });
- const beforeAssociateArgs = beforeAssociate.getCall(0).args;
- expect(beforeAssociate).to.have.been.called;
- expect(beforeAssociateArgs.length).to.equal(2);
- const firstArg = beforeAssociateArgs[0];
- expect(Object.keys(firstArg).join(',')).to.equal('source,target,type,sequelize');
- expect(firstArg.source).to.equal(Project);
- expect(firstArg.target).to.equal(Task);
- expect(firstArg.type.name).to.equal('BelongsToMany');
- expect(firstArg.sequelize.constructor.name).to.equal('Sequelize');
- });
- it('should not trigger association hooks', () => {
- const beforeAssociate = sinon.spy();
- Project.beforeAssociate(beforeAssociate);
- Project.belongsToMany(Task, { through: 'projects_and_tasks', hooks: false });
- expect(beforeAssociate).to.not.have.been.called;
- });
- });
- describe('afterBelongsToManyAssociate', () => {
- it('should trigger', () => {
- const afterAssociate = sinon.spy();
- Project.afterAssociate(afterAssociate);
- Project.belongsToMany(Task, { through: 'projects_and_tasks', hooks: true });
- const afterAssociateArgs = afterAssociate.getCalls()[afterAssociate.callCount - 1].args;
- expect(afterAssociate).to.have.been.called;
- expect(afterAssociateArgs.length).to.equal(2);
- const firstArg = afterAssociateArgs[0];
- expect(Object.keys(firstArg).join(',')).to.equal(
- 'source,target,type,association,sequelize',
- );
- expect(firstArg.source).to.equal(Project);
- expect(firstArg.target).to.equal(Task);
- expect(firstArg.type.name).to.equal('BelongsToMany');
- expect(firstArg.association.constructor.name).to.equal('BelongsToMany');
- expect(firstArg.sequelize.constructor.name).to.equal('Sequelize');
- });
- it('should not trigger association hooks', () => {
- const afterAssociate = sinon.spy();
- Project.afterAssociate(afterAssociate);
- Project.belongsToMany(Task, { through: 'projects_and_tasks', hooks: false });
- expect(afterAssociate).to.not.have.been.called;
- });
- });
- });
- });
|