From e32d28f893aceb4425327867ea38c068c737e14e Mon Sep 17 00:00:00 2001 From: Alexander Mochalin Date: Wed, 14 Aug 2019 21:45:48 +0500 Subject: [PATCH] feat(associations): source and target key support for belongs-to-many (#11311) --- docs/associations.md | 64 ++ lib/associations/belongs-to-many.js | 147 ++-- lib/dialects/abstract/query-generator.js | 10 +- .../associations/belongs-to-many.test.js | 630 +++++++++++++++++- .../dialects/postgres/query-interface.test.js | 8 +- .../unit/associations/belongs-to-many.test.js | 47 +- types/lib/associations/belongs-to-many.d.ts | 14 + 7 files changed, 850 insertions(+), 70 deletions(-) diff --git a/docs/associations.md b/docs/associations.md index 0ffaa185c21e..f2375c389a0b 100644 --- a/docs/associations.md +++ b/docs/associations.md @@ -531,6 +531,70 @@ Person.belongsToMany(Person, { as: 'Children', through: 'PersonChildren' }) ``` +#### Source and target keys + +If you want to create a belongs to many relationship that does not use the default primary key some setup work is required. +You must set the `sourceKey` (optionally `targetKey`) appropriately for the two ends of the belongs to many. Further you must also ensure you have appropriate indexes created on your relationships. For example: + +```js +const User = this.sequelize.define('User', { + id: { + type: DataTypes.UUID, + allowNull: false, + primaryKey: true, + defaultValue: DataTypes.UUIDV4, + field: 'user_id' + }, + userSecondId: { + type: DataTypes.UUID, + allowNull: false, + defaultValue: DataTypes.UUIDV4, + field: 'user_second_id' + } +}, { + tableName: 'tbl_user', + indexes: [ + { + unique: true, + fields: ['user_second_id'] + } + ] +}); + +const Group = this.sequelize.define('Group', { + id: { + type: DataTypes.UUID, + allowNull: false, + primaryKey: true, + defaultValue: DataTypes.UUIDV4, + field: 'group_id' + }, + groupSecondId: { + type: DataTypes.UUID, + allowNull: false, + defaultValue: DataTypes.UUIDV4, + field: 'group_second_id' + } +}, { + tableName: 'tbl_group', + indexes: [ + { + unique: true, + fields: ['group_second_id'] + } + ] +}); + +User.belongsToMany(Group, { + through: 'usergroups', + sourceKey: 'userSecondId' +}); +Group.belongsToMany(User, { + through: 'usergroups', + sourceKey: 'groupSecondId' +}); +``` + If you want additional attributes in your join table, you can define a model for the join table in sequelize, before you define the association, and then tell sequelize that it should use that model for joining, instead of creating a new one: ```js diff --git a/lib/associations/belongs-to-many.js b/lib/associations/belongs-to-many.js index ce267b18a3b8..683629ca3b81 100644 --- a/lib/associations/belongs-to-many.js +++ b/lib/associations/belongs-to-many.js @@ -110,43 +110,6 @@ class BelongsToMany extends Association { this.targetAssociation = this; } - /* - * Default/generated foreign/other keys - */ - if (_.isObject(this.options.foreignKey)) { - this.foreignKeyAttribute = this.options.foreignKey; - this.foreignKey = this.foreignKeyAttribute.name || this.foreignKeyAttribute.fieldName; - } else { - if (!this.options.foreignKey) { - this.foreignKeyDefault = true; - } - - this.foreignKeyAttribute = {}; - this.foreignKey = this.options.foreignKey || Utils.camelize( - [ - this.source.options.name.singular, - this.source.primaryKeyAttribute - ].join('_') - ); - } - - if (_.isObject(this.options.otherKey)) { - this.otherKeyAttribute = this.options.otherKey; - this.otherKey = this.otherKeyAttribute.name || this.otherKeyAttribute.fieldName; - } else { - if (!this.options.otherKey) { - this.otherKeyDefault = true; - } - - this.otherKeyAttribute = {}; - this.otherKey = this.options.otherKey || Utils.camelize( - [ - this.isSelfAssociation ? Utils.singularize(this.as) : this.target.options.name.singular, - this.target.primaryKeyAttribute - ].join('_') - ); - } - /* * Find paired association (if exists) */ @@ -160,6 +123,23 @@ class BelongsToMany extends Association { } }); + /* + * Default/generated source/target keys + */ + this.sourceKey = this.options.sourceKey || this.source.primaryKeyAttribute; + this.sourceKeyField = this.source.rawAttributes[this.sourceKey].field || this.sourceKey; + + if (this.options.targetKey) { + this.targetKey = this.options.targetKey; + this.targetKeyField = this.target.rawAttributes[this.targetKey].field || this.targetKey; + } else { + this.targetKeyDefault = true; + this.targetKey = this.target.primaryKeyAttribute; + this.targetKeyField = this.target.rawAttributes[this.targetKey].field || this.targetKey; + } + + this._createForeignAndOtherKeys(); + if (typeof this.through.model === 'string') { if (!this.sequelize.isDefined(this.through.model)) { this.through.model = this.sequelize.define(this.through.model, {}, Object.assign(this.options, { @@ -178,6 +158,25 @@ class BelongsToMany extends Association { ])); if (this.paired) { + let needInjectPaired = false; + + if (this.targetKeyDefault) { + this.targetKey = this.paired.sourceKey; + this.targetKeyField = this.paired.sourceKeyField; + this._createForeignAndOtherKeys(); + } + if (this.paired.targetKeyDefault) { + // in this case paired.otherKey depends on paired.targetKey, + // so cleanup previously wrong generated otherKey + if (this.paired.targetKey !== this.sourceKey) { + delete this.through.model.rawAttributes[this.paired.otherKey]; + this.paired.targetKey = this.sourceKey; + this.paired.targetKeyField = this.sourceKeyField; + this.paired._createForeignAndOtherKeys(); + needInjectPaired = true; + } + } + if (this.otherKeyDefault) { this.otherKey = this.paired.foreignKey; } @@ -187,9 +186,13 @@ class BelongsToMany extends Association { if (this.paired.otherKey !== this.foreignKey) { delete this.through.model.rawAttributes[this.paired.otherKey]; this.paired.otherKey = this.foreignKey; - this.paired._injectAttributes(); + needInjectPaired = true; } } + + if (needInjectPaired) { + this.paired._injectAttributes(); + } } if (this.through) { @@ -218,6 +221,41 @@ class BelongsToMany extends Association { }; } + _createForeignAndOtherKeys() { + /* + * Default/generated foreign/other keys + */ + if (_.isObject(this.options.foreignKey)) { + this.foreignKeyAttribute = this.options.foreignKey; + this.foreignKey = this.foreignKeyAttribute.name || this.foreignKeyAttribute.fieldName; + } else { + this.foreignKeyAttribute = {}; + this.foreignKey = this.options.foreignKey || Utils.camelize( + [ + this.source.options.name.singular, + this.sourceKey + ].join('_') + ); + } + + if (_.isObject(this.options.otherKey)) { + this.otherKeyAttribute = this.options.otherKey; + this.otherKey = this.otherKeyAttribute.name || this.otherKeyAttribute.fieldName; + } else { + if (!this.options.otherKey) { + this.otherKeyDefault = true; + } + + this.otherKeyAttribute = {}; + this.otherKey = this.options.otherKey || Utils.camelize( + [ + this.isSelfAssociation ? Utils.singularize(this.as) : this.target.options.name.singular, + this.targetKey + ].join('_') + ); + } + } + // the id is in the target table // or in an extra table which connects two tables _injectAttributes() { @@ -240,12 +278,12 @@ class BelongsToMany extends Association { } }); - const sourceKey = this.source.rawAttributes[this.source.primaryKeyAttribute]; + const sourceKey = this.source.rawAttributes[this.sourceKey]; const sourceKeyType = sourceKey.type; - const sourceKeyField = sourceKey.field || this.source.primaryKeyAttribute; - const targetKey = this.target.rawAttributes[this.target.primaryKeyAttribute]; + const sourceKeyField = this.sourceKeyField; + const targetKey = this.target.rawAttributes[this.targetKey]; const targetKeyType = targetKey.type; - const targetKeyField = targetKey.field || this.target.primaryKeyAttribute; + const targetKeyField = this.targetKeyField; const sourceAttribute = _.defaults({}, this.foreignKeyAttribute, { type: sourceKeyType }); const targetAttribute = _.defaults({}, this.otherKeyAttribute, { type: targetKeyType }); @@ -393,7 +431,7 @@ class BelongsToMany extends Association { if (Object(through.model) === through.model) { throughWhere = {}; - throughWhere[this.foreignKey] = instance.get(this.source.primaryKeyAttribute); + throughWhere[this.foreignKey] = instance.get(this.sourceKey); if (through.scope) { Object.assign(throughWhere, through.scope); @@ -442,12 +480,11 @@ class BelongsToMany extends Association { * @returns {Promise} */ count(instance, options) { - const model = this.target; - const sequelize = model.sequelize; + const sequelize = this.target.sequelize; options = Utils.cloneDeep(options); options.attributes = [ - [sequelize.fn('COUNT', sequelize.col([this.target.name, model.primaryKeyField].join('.'))), 'count'] + [sequelize.fn('COUNT', sequelize.col([this.target.name, this.targetKeyField].join('.'))), 'count'] ]; options.joinTableAttributes = []; options.raw = true; @@ -474,7 +511,7 @@ class BelongsToMany extends Association { raw: true }, options, { scope: false, - attributes: [this.target.primaryKeyAttribute], + attributes: [this.targetKey], joinTableAttributes: [] }); @@ -483,7 +520,7 @@ class BelongsToMany extends Association { return instance.where(); } return { - [this.target.primaryKeyAttribute]: instance + [this.targetKey]: instance }; }); @@ -495,7 +532,7 @@ class BelongsToMany extends Association { }; return this.get(sourceInstance, options).then(associatedObjects => - _.differenceBy(instancePrimaryKeys, associatedObjects, this.target.primaryKeyAttribute).length === 0 + _.differenceBy(instancePrimaryKeys, associatedObjects, this.targetKey).length === 0 ); } @@ -514,8 +551,8 @@ class BelongsToMany extends Association { set(sourceInstance, newAssociatedObjects, options) { options = options || {}; - const sourceKey = this.source.primaryKeyAttribute; - const targetKey = this.target.primaryKeyAttribute; + const sourceKey = this.sourceKey; + const targetKey = this.targetKey; const identifier = this.identifier; const foreignIdentifier = this.foreignIdentifier; let where = {}; @@ -626,8 +663,8 @@ class BelongsToMany extends Association { options = _.clone(options) || {}; const association = this; - const sourceKey = association.source.primaryKeyAttribute; - const targetKey = association.target.primaryKeyAttribute; + const sourceKey = association.sourceKey; + const targetKey = association.targetKey; const identifier = association.identifier; const foreignIdentifier = association.foreignIdentifier; const defaultAttributes = options.through || {}; @@ -721,8 +758,8 @@ class BelongsToMany extends Association { oldAssociatedObjects = association.toInstanceArray(oldAssociatedObjects); const where = { - [association.identifier]: sourceInstance.get(association.source.primaryKeyAttribute), - [association.foreignIdentifier]: oldAssociatedObjects.map(newInstance => newInstance.get(association.target.primaryKeyAttribute)) + [association.identifier]: sourceInstance.get(association.sourceKey), + [association.foreignIdentifier]: oldAssociatedObjects.map(newInstance => newInstance.get(association.targetKey)) }; return association.through.model.destroy(_.defaults({ where }, options)); diff --git a/lib/dialects/abstract/query-generator.js b/lib/dialects/abstract/query-generator.js index bb2dc9657e4f..0bfbed518e13 100755 --- a/lib/dialects/abstract/query-generator.js +++ b/lib/dialects/abstract/query-generator.js @@ -1828,13 +1828,11 @@ class QueryGenerator { }); const association = include.association; const parentIsTop = !include.parent.association && include.parent.model.name === topLevelInfo.options.model.name; - const primaryKeysSource = association.source.primaryKeyAttributes; const tableSource = parentTableName; const identSource = association.identifierField; - const primaryKeysTarget = association.target.primaryKeyAttributes; const tableTarget = includeAs.internalAs; const identTarget = association.foreignIdentifierField; - const attrTarget = association.target.rawAttributes[primaryKeysTarget[0]].field || primaryKeysTarget[0]; + const attrTarget = association.targetKeyField; const joinType = include.required ? 'INNER JOIN' : 'LEFT OUTER JOIN'; let joinBody; @@ -1843,7 +1841,7 @@ class QueryGenerator { main: [], subQuery: [] }; - let attrSource = primaryKeysSource[0]; + let attrSource = association.sourceKey; let sourceJoinOn; let targetJoinOn; let throughWhere; @@ -1858,10 +1856,10 @@ class QueryGenerator { // Figure out if we need to use field or attribute if (!topLevelInfo.subQuery) { - attrSource = association.source.rawAttributes[primaryKeysSource[0]].field; + attrSource = association.sourceKeyField; } if (topLevelInfo.subQuery && !include.subQuery && !include.parent.subQuery && include.parent.model !== topLevelInfo.options.mainModel) { - attrSource = association.source.rawAttributes[primaryKeysSource[0]].field; + attrSource = association.sourceKeyField; } // Filter statement for left side of through diff --git a/test/integration/associations/belongs-to-many.test.js b/test/integration/associations/belongs-to-many.test.js index 1b94c1926e48..472ddfc5ab4e 100644 --- a/test/integration/associations/belongs-to-many.test.js +++ b/test/integration/associations/belongs-to-many.test.js @@ -272,9 +272,9 @@ describe(Support.getTestDialectTeaser('BelongsToMany'), () => { }); }); - it('supports primary key attributes with different field names', function() { + it('supports primary key attributes with different field and attribute names', function() { const User = this.sequelize.define('User', { - id: { + userSecondId: { type: DataTypes.UUID, allowNull: false, primaryKey: true, @@ -286,7 +286,7 @@ describe(Support.getTestDialectTeaser('BelongsToMany'), () => { }); const Group = this.sequelize.define('Group', { - id: { + groupSecondId: { type: DataTypes.UUID, allowNull: false, primaryKey: true, @@ -322,6 +322,628 @@ describe(Support.getTestDialectTeaser('BelongsToMany'), () => { include: [Group] }) ); + }).then(([user, users]) => { + expect(user.Groups.length).to.be.equal(1); + expect(user.Groups[0].User_has_Group.UserUserSecondId).to.be.ok; + expect(user.Groups[0].User_has_Group.UserUserSecondId).to.be.equal(user.userSecondId); + expect(user.Groups[0].User_has_Group.GroupGroupSecondId).to.be.ok; + expect(user.Groups[0].User_has_Group.GroupGroupSecondId).to.be.equal(user.Groups[0].groupSecondId); + expect(users.length).to.be.equal(1); + expect(users[0].toJSON()).to.be.eql(user.toJSON()); + }); + }); + }); + + it('supports non primary key attributes for joins (sourceKey only)', function() { + const User = this.sequelize.define('User', { + id: { + type: DataTypes.UUID, + allowNull: false, + primaryKey: true, + defaultValue: DataTypes.UUIDV4, + field: 'user_id' + }, + userSecondId: { + type: DataTypes.UUID, + allowNull: false, + defaultValue: DataTypes.UUIDV4, + field: 'user_second_id' + } + }, { + tableName: 'tbl_user', + indexes: [ + { + unique: true, + fields: ['user_second_id'] + } + ] + }); + + const Group = this.sequelize.define('Group', { + id: { + type: DataTypes.UUID, + allowNull: false, + primaryKey: true, + defaultValue: DataTypes.UUIDV4, + field: 'group_id' + }, + groupSecondId: { + type: DataTypes.UUID, + allowNull: false, + defaultValue: DataTypes.UUIDV4, + field: 'group_second_id' + } + }, { + tableName: 'tbl_group', + indexes: [ + { + unique: true, + fields: ['group_second_id'] + } + ] + }); + + User.belongsToMany(Group, { through: 'usergroups', sourceKey: 'userSecondId' }); + Group.belongsToMany(User, { through: 'usergroups', sourceKey: 'groupSecondId' }); + + return this.sequelize.sync({ force: true }).then(() => { + return Promise.join( + User.create(), + User.create(), + Group.create(), + Group.create() + ).then(([user1, user2, group1, group2]) => { + return Promise.join(user1.addGroup(group1), user2.addGroup(group2)); + }).then(() => { + return Promise.join( + User.findAll({ + where: {}, + include: [Group] + }), + Group.findAll({ + include: [User] + }) + ); + }).then(([users, groups]) => { + expect(users.length).to.be.equal(2); + expect(users[0].Groups.length).to.be.equal(1); + expect(users[1].Groups.length).to.be.equal(1); + expect(users[0].Groups[0].usergroups.UserUserSecondId).to.be.ok; + expect(users[0].Groups[0].usergroups.UserUserSecondId).to.be.equal(users[0].userSecondId); + expect(users[0].Groups[0].usergroups.GroupGroupSecondId).to.be.ok; + expect(users[0].Groups[0].usergroups.GroupGroupSecondId).to.be.equal(users[0].Groups[0].groupSecondId); + expect(users[1].Groups[0].usergroups.UserUserSecondId).to.be.ok; + expect(users[1].Groups[0].usergroups.UserUserSecondId).to.be.equal(users[1].userSecondId); + expect(users[1].Groups[0].usergroups.GroupGroupSecondId).to.be.ok; + expect(users[1].Groups[0].usergroups.GroupGroupSecondId).to.be.equal(users[1].Groups[0].groupSecondId); + + expect(groups.length).to.be.equal(2); + expect(groups[0].Users.length).to.be.equal(1); + expect(groups[1].Users.length).to.be.equal(1); + expect(groups[0].Users[0].usergroups.GroupGroupSecondId).to.be.ok; + expect(groups[0].Users[0].usergroups.GroupGroupSecondId).to.be.equal(groups[0].groupSecondId); + expect(groups[0].Users[0].usergroups.UserUserSecondId).to.be.ok; + expect(groups[0].Users[0].usergroups.UserUserSecondId).to.be.equal(groups[0].Users[0].userSecondId); + expect(groups[1].Users[0].usergroups.GroupGroupSecondId).to.be.ok; + expect(groups[1].Users[0].usergroups.GroupGroupSecondId).to.be.equal(groups[1].groupSecondId); + expect(groups[1].Users[0].usergroups.UserUserSecondId).to.be.ok; + expect(groups[1].Users[0].usergroups.UserUserSecondId).to.be.equal(groups[1].Users[0].userSecondId); + }); + }); + }); + + it('supports non primary key attributes for joins (targetKey only)', function() { + const User = this.sequelize.define('User', { + id: { + type: DataTypes.UUID, + allowNull: false, + primaryKey: true, + defaultValue: DataTypes.UUIDV4, + field: 'user_id' + }, + userSecondId: { + type: DataTypes.UUID, + allowNull: false, + defaultValue: DataTypes.UUIDV4, + field: 'user_second_id' + } + }, { + tableName: 'tbl_user', + indexes: [ + { + unique: true, + fields: ['user_second_id'] + } + ] + }); + + const Group = this.sequelize.define('Group', { + id: { + type: DataTypes.UUID, + allowNull: false, + primaryKey: true, + defaultValue: DataTypes.UUIDV4, + field: 'group_id' + } + }, { + tableName: 'tbl_group', + indexes: [ + { + unique: true, + fields: ['group_id'] + } + ] + }); + + User.belongsToMany(Group, { through: 'usergroups', sourceKey: 'userSecondId' }); + Group.belongsToMany(User, { through: 'usergroups', targetKey: 'userSecondId' }); + + return this.sequelize.sync({ force: true }).then(() => { + return Promise.join( + User.create(), + User.create(), + Group.create(), + Group.create() + ).then(([user1, user2, group1, group2]) => { + return Promise.join(user1.addGroup(group1), user2.addGroup(group2)); + }).then(() => { + return Promise.join( + User.findAll({ + where: {}, + include: [Group] + }), + Group.findAll({ + include: [User] + }) + ); + }).then(([users, groups]) => { + expect(users.length).to.be.equal(2); + expect(users[0].Groups.length).to.be.equal(1); + expect(users[1].Groups.length).to.be.equal(1); + expect(users[0].Groups[0].usergroups.UserUserSecondId).to.be.ok; + expect(users[0].Groups[0].usergroups.UserUserSecondId).to.be.equal(users[0].userSecondId); + expect(users[0].Groups[0].usergroups.GroupId).to.be.ok; + expect(users[0].Groups[0].usergroups.GroupId).to.be.equal(users[0].Groups[0].id); + expect(users[1].Groups[0].usergroups.UserUserSecondId).to.be.ok; + expect(users[1].Groups[0].usergroups.UserUserSecondId).to.be.equal(users[1].userSecondId); + expect(users[1].Groups[0].usergroups.GroupId).to.be.ok; + expect(users[1].Groups[0].usergroups.GroupId).to.be.equal(users[1].Groups[0].id); + + expect(groups.length).to.be.equal(2); + expect(groups[0].Users.length).to.be.equal(1); + expect(groups[1].Users.length).to.be.equal(1); + expect(groups[0].Users[0].usergroups.GroupId).to.be.ok; + expect(groups[0].Users[0].usergroups.GroupId).to.be.equal(groups[0].id); + expect(groups[0].Users[0].usergroups.UserUserSecondId).to.be.ok; + expect(groups[0].Users[0].usergroups.UserUserSecondId).to.be.equal(groups[0].Users[0].userSecondId); + expect(groups[1].Users[0].usergroups.GroupId).to.be.ok; + expect(groups[1].Users[0].usergroups.GroupId).to.be.equal(groups[1].id); + expect(groups[1].Users[0].usergroups.UserUserSecondId).to.be.ok; + expect(groups[1].Users[0].usergroups.UserUserSecondId).to.be.equal(groups[1].Users[0].userSecondId); + }); + }); + }); + + it('supports non primary key attributes for joins (sourceKey and targetKey)', function() { + const User = this.sequelize.define('User', { + id: { + type: DataTypes.UUID, + allowNull: false, + primaryKey: true, + defaultValue: DataTypes.UUIDV4, + field: 'user_id' + }, + userSecondId: { + type: DataTypes.UUID, + allowNull: false, + defaultValue: DataTypes.UUIDV4, + field: 'user_second_id' + } + }, { + tableName: 'tbl_user', + indexes: [ + { + unique: true, + fields: ['user_second_id'] + } + ] + }); + + const Group = this.sequelize.define('Group', { + id: { + type: DataTypes.UUID, + allowNull: false, + primaryKey: true, + defaultValue: DataTypes.UUIDV4, + field: 'group_id' + }, + groupSecondId: { + type: DataTypes.UUID, + allowNull: false, + defaultValue: DataTypes.UUIDV4, + field: 'group_second_id' + } + }, { + tableName: 'tbl_group', + indexes: [ + { + unique: true, + fields: ['group_second_id'] + } + ] + }); + + User.belongsToMany(Group, { through: 'usergroups', sourceKey: 'userSecondId', targetKey: 'groupSecondId' }); + Group.belongsToMany(User, { through: 'usergroups', sourceKey: 'groupSecondId', targetKey: 'userSecondId' }); + + return this.sequelize.sync({ force: true }).then(() => { + return Promise.join( + User.create(), + User.create(), + Group.create(), + Group.create() + ).then(([user1, user2, group1, group2]) => { + return Promise.join(user1.addGroup(group1), user2.addGroup(group2)); + }).then(() => { + return Promise.join( + User.findAll({ + where: {}, + include: [Group] + }), + Group.findAll({ + include: [User] + }) + ); + }).then(([users, groups]) => { + expect(users.length).to.be.equal(2); + expect(users[0].Groups.length).to.be.equal(1); + expect(users[1].Groups.length).to.be.equal(1); + expect(users[0].Groups[0].usergroups.UserUserSecondId).to.be.ok; + expect(users[0].Groups[0].usergroups.UserUserSecondId).to.be.equal(users[0].userSecondId); + expect(users[0].Groups[0].usergroups.GroupGroupSecondId).to.be.ok; + expect(users[0].Groups[0].usergroups.GroupGroupSecondId).to.be.equal(users[0].Groups[0].groupSecondId); + expect(users[1].Groups[0].usergroups.UserUserSecondId).to.be.ok; + expect(users[1].Groups[0].usergroups.UserUserSecondId).to.be.equal(users[1].userSecondId); + expect(users[1].Groups[0].usergroups.GroupGroupSecondId).to.be.ok; + expect(users[1].Groups[0].usergroups.GroupGroupSecondId).to.be.equal(users[1].Groups[0].groupSecondId); + + expect(groups.length).to.be.equal(2); + expect(groups[0].Users.length).to.be.equal(1); + expect(groups[1].Users.length).to.be.equal(1); + expect(groups[0].Users[0].usergroups.GroupGroupSecondId).to.be.ok; + expect(groups[0].Users[0].usergroups.GroupGroupSecondId).to.be.equal(groups[0].groupSecondId); + expect(groups[0].Users[0].usergroups.UserUserSecondId).to.be.ok; + expect(groups[0].Users[0].usergroups.UserUserSecondId).to.be.equal(groups[0].Users[0].userSecondId); + expect(groups[1].Users[0].usergroups.GroupGroupSecondId).to.be.ok; + expect(groups[1].Users[0].usergroups.GroupGroupSecondId).to.be.equal(groups[1].groupSecondId); + expect(groups[1].Users[0].usergroups.UserUserSecondId).to.be.ok; + expect(groups[1].Users[0].usergroups.UserUserSecondId).to.be.equal(groups[1].Users[0].userSecondId); + }); + }); + }); + + it('supports non primary key attributes for joins (custom through model)', function() { + const User = this.sequelize.define('User', { + id: { + type: DataTypes.UUID, + allowNull: false, + primaryKey: true, + defaultValue: DataTypes.UUIDV4, + field: 'user_id' + }, + userSecondId: { + type: DataTypes.UUID, + allowNull: false, + defaultValue: DataTypes.UUIDV4, + field: 'user_second_id' + } + }, { + tableName: 'tbl_user', + indexes: [ + { + unique: true, + fields: ['user_second_id'] + } + ] + }); + + const Group = this.sequelize.define('Group', { + id: { + type: DataTypes.UUID, + allowNull: false, + primaryKey: true, + defaultValue: DataTypes.UUIDV4, + field: 'group_id' + }, + groupSecondId: { + type: DataTypes.UUID, + allowNull: false, + defaultValue: DataTypes.UUIDV4, + field: 'group_second_id' + } + }, { + tableName: 'tbl_group', + indexes: [ + { + unique: true, + fields: ['group_second_id'] + } + ] + }); + + const User_has_Group = this.sequelize.define('User_has_Group', { + }, { + tableName: 'tbl_user_has_group', + indexes: [ + { + unique: true, + fields: ['UserUserSecondId', 'GroupGroupSecondId'] + } + ] + }); + + User.belongsToMany(Group, { through: User_has_Group, sourceKey: 'userSecondId' }); + Group.belongsToMany(User, { through: User_has_Group, sourceKey: 'groupSecondId' }); + + return this.sequelize.sync({ force: true }).then(() => { + return Promise.join( + User.create(), + User.create(), + Group.create(), + Group.create() + ).then(([user1, user2, group1, group2]) => { + return Promise.join(user1.addGroup(group1), user2.addGroup(group2)); + }).then(() => { + return Promise.join( + User.findAll({ + where: {}, + include: [Group] + }), + Group.findAll({ + include: [User] + }) + ); + }).then(([users, groups]) => { + expect(users.length).to.be.equal(2); + expect(users[0].Groups.length).to.be.equal(1); + expect(users[1].Groups.length).to.be.equal(1); + expect(users[0].Groups[0].User_has_Group.UserUserSecondId).to.be.ok; + expect(users[0].Groups[0].User_has_Group.UserUserSecondId).to.be.equal(users[0].userSecondId); + expect(users[0].Groups[0].User_has_Group.GroupGroupSecondId).to.be.ok; + expect(users[0].Groups[0].User_has_Group.GroupGroupSecondId).to.be.equal(users[0].Groups[0].groupSecondId); + expect(users[1].Groups[0].User_has_Group.UserUserSecondId).to.be.ok; + expect(users[1].Groups[0].User_has_Group.UserUserSecondId).to.be.equal(users[1].userSecondId); + expect(users[1].Groups[0].User_has_Group.GroupGroupSecondId).to.be.ok; + expect(users[1].Groups[0].User_has_Group.GroupGroupSecondId).to.be.equal(users[1].Groups[0].groupSecondId); + + expect(groups.length).to.be.equal(2); + expect(groups[0].Users.length).to.be.equal(1); + expect(groups[1].Users.length).to.be.equal(1); + expect(groups[0].Users[0].User_has_Group.GroupGroupSecondId).to.be.ok; + expect(groups[0].Users[0].User_has_Group.GroupGroupSecondId).to.be.equal(groups[0].groupSecondId); + expect(groups[0].Users[0].User_has_Group.UserUserSecondId).to.be.ok; + expect(groups[0].Users[0].User_has_Group.UserUserSecondId).to.be.equal(groups[0].Users[0].userSecondId); + expect(groups[1].Users[0].User_has_Group.GroupGroupSecondId).to.be.ok; + expect(groups[1].Users[0].User_has_Group.GroupGroupSecondId).to.be.equal(groups[1].groupSecondId); + expect(groups[1].Users[0].User_has_Group.UserUserSecondId).to.be.ok; + expect(groups[1].Users[0].User_has_Group.UserUserSecondId).to.be.equal(groups[1].Users[0].userSecondId); + }); + }); + }); + + it('supports non primary key attributes for joins (custom foreignKey)', function() { + const User = this.sequelize.define('User', { + id: { + type: DataTypes.UUID, + allowNull: false, + primaryKey: true, + defaultValue: DataTypes.UUIDV4, + field: 'user_id' + }, + userSecondId: { + type: DataTypes.UUID, + allowNull: false, + defaultValue: DataTypes.UUIDV4, + field: 'user_second_id' + } + }, { + tableName: 'tbl_user', + indexes: [ + { + unique: true, + fields: ['user_second_id'] + } + ] + }); + + const Group = this.sequelize.define('Group', { + id: { + type: DataTypes.UUID, + allowNull: false, + primaryKey: true, + defaultValue: DataTypes.UUIDV4, + field: 'group_id' + }, + groupSecondId: { + type: DataTypes.UUID, + allowNull: false, + defaultValue: DataTypes.UUIDV4, + field: 'group_second_id' + } + }, { + tableName: 'tbl_group', + indexes: [ + { + unique: true, + fields: ['group_second_id'] + } + ] + }); + + User.belongsToMany(Group, { through: 'usergroups', foreignKey: 'userId2', sourceKey: 'userSecondId' }); + Group.belongsToMany(User, { through: 'usergroups', foreignKey: 'groupId2', sourceKey: 'groupSecondId' }); + + return this.sequelize.sync({ force: true }).then(() => { + return Promise.join( + User.create(), + User.create(), + Group.create(), + Group.create() + ).then(([user1, user2, group1, group2]) => { + return Promise.join(user1.addGroup(group1), user2.addGroup(group2)); + }).then(() => { + return Promise.join( + User.findAll({ + where: {}, + include: [Group] + }), + Group.findAll({ + include: [User] + }) + ); + }).then(([users, groups]) => { + expect(users.length).to.be.equal(2); + expect(users[0].Groups.length).to.be.equal(1); + expect(users[1].Groups.length).to.be.equal(1); + expect(users[0].Groups[0].usergroups.userId2).to.be.ok; + expect(users[0].Groups[0].usergroups.userId2).to.be.equal(users[0].userSecondId); + expect(users[0].Groups[0].usergroups.groupId2).to.be.ok; + expect(users[0].Groups[0].usergroups.groupId2).to.be.equal(users[0].Groups[0].groupSecondId); + expect(users[1].Groups[0].usergroups.userId2).to.be.ok; + expect(users[1].Groups[0].usergroups.userId2).to.be.equal(users[1].userSecondId); + expect(users[1].Groups[0].usergroups.groupId2).to.be.ok; + expect(users[1].Groups[0].usergroups.groupId2).to.be.equal(users[1].Groups[0].groupSecondId); + + expect(groups.length).to.be.equal(2); + expect(groups[0].Users.length).to.be.equal(1); + expect(groups[1].Users.length).to.be.equal(1); + expect(groups[0].Users[0].usergroups.groupId2).to.be.ok; + expect(groups[0].Users[0].usergroups.groupId2).to.be.equal(groups[0].groupSecondId); + expect(groups[0].Users[0].usergroups.userId2).to.be.ok; + expect(groups[0].Users[0].usergroups.userId2).to.be.equal(groups[0].Users[0].userSecondId); + expect(groups[1].Users[0].usergroups.groupId2).to.be.ok; + expect(groups[1].Users[0].usergroups.groupId2).to.be.equal(groups[1].groupSecondId); + expect(groups[1].Users[0].usergroups.userId2).to.be.ok; + expect(groups[1].Users[0].usergroups.userId2).to.be.equal(groups[1].Users[0].userSecondId); + }); + }); + }); + + it('supports non primary key attributes for joins (custom foreignKey, custom through model)', function() { + const User = this.sequelize.define('User', { + id: { + type: DataTypes.UUID, + allowNull: false, + primaryKey: true, + defaultValue: DataTypes.UUIDV4, + field: 'user_id' + }, + userSecondId: { + type: DataTypes.UUID, + allowNull: false, + defaultValue: DataTypes.UUIDV4, + field: 'user_second_id' + } + }, { + tableName: 'tbl_user', + indexes: [ + { + unique: true, + fields: ['user_second_id'] + } + ] + }); + + const Group = this.sequelize.define('Group', { + id: { + type: DataTypes.UUID, + allowNull: false, + primaryKey: true, + defaultValue: DataTypes.UUIDV4, + field: 'group_id' + }, + groupSecondId: { + type: DataTypes.UUID, + allowNull: false, + defaultValue: DataTypes.UUIDV4, + field: 'group_second_id' + } + }, { + tableName: 'tbl_group', + indexes: [ + { + unique: true, + fields: ['group_second_id'] + } + ] + }); + + const User_has_Group = this.sequelize.define('User_has_Group', { + userId2: { + type: DataTypes.UUID, + allowNull: false, + field: 'user_id2' + }, + groupId2: { + type: DataTypes.UUID, + allowNull: false, + field: 'group_id2' + } + }, { + tableName: 'tbl_user_has_group', + indexes: [ + { + unique: true, + fields: ['user_id2', 'group_id2'] + } + ] + }); + + User.belongsToMany(Group, { through: User_has_Group, foreignKey: 'userId2', sourceKey: 'userSecondId' }); + Group.belongsToMany(User, { through: User_has_Group, foreignKey: 'groupId2', sourceKey: 'groupSecondId' }); + + return this.sequelize.sync({ force: true }).then(() => { + return Promise.join( + User.create(), + User.create(), + Group.create(), + Group.create() + ).then(([user1, user2, group1, group2]) => { + return Promise.join(user1.addGroup(group1), user2.addGroup(group2)); + }).then(() => { + return Promise.join( + User.findAll({ + where: {}, + include: [Group] + }), + Group.findAll({ + include: [User] + }) + ); + }).then(([users, groups]) => { + expect(users.length).to.be.equal(2); + expect(users[0].Groups.length).to.be.equal(1); + expect(users[1].Groups.length).to.be.equal(1); + expect(users[0].Groups[0].User_has_Group.userId2).to.be.ok; + expect(users[0].Groups[0].User_has_Group.userId2).to.be.equal(users[0].userSecondId); + expect(users[0].Groups[0].User_has_Group.groupId2).to.be.ok; + expect(users[0].Groups[0].User_has_Group.groupId2).to.be.equal(users[0].Groups[0].groupSecondId); + expect(users[1].Groups[0].User_has_Group.userId2).to.be.ok; + expect(users[1].Groups[0].User_has_Group.userId2).to.be.equal(users[1].userSecondId); + expect(users[1].Groups[0].User_has_Group.groupId2).to.be.ok; + expect(users[1].Groups[0].User_has_Group.groupId2).to.be.equal(users[1].Groups[0].groupSecondId); + + expect(groups.length).to.be.equal(2); + expect(groups[0].Users.length).to.be.equal(1); + expect(groups[1].Users.length).to.be.equal(1); + expect(groups[0].Users[0].User_has_Group.groupId2).to.be.ok; + expect(groups[0].Users[0].User_has_Group.groupId2).to.be.equal(groups[0].groupSecondId); + expect(groups[0].Users[0].User_has_Group.userId2).to.be.ok; + expect(groups[0].Users[0].User_has_Group.userId2).to.be.equal(groups[0].Users[0].userSecondId); + expect(groups[1].Users[0].User_has_Group.groupId2).to.be.ok; + expect(groups[1].Users[0].User_has_Group.groupId2).to.be.equal(groups[1].groupSecondId); + expect(groups[1].Users[0].User_has_Group.userId2).to.be.ok; + expect(groups[1].Users[0].User_has_Group.userId2).to.be.equal(groups[1].Users[0].userSecondId); }); }); }); @@ -1588,7 +2210,7 @@ describe(Support.getTestDialectTeaser('BelongsToMany'), () => { it('should infer otherKey from paired BTM relationship with a through model defined', function() { const User = this.sequelize.define('User', {}); - const Place = this.sequelize.define('User', {}); + const Place = this.sequelize.define('Place', {}); const UserPlace = this.sequelize.define('UserPlace', { id: { primaryKey: true, type: DataTypes.INTEGER, autoIncrement: true } }, { timestamps: false }); const Places = User.belongsToMany(Place, { through: UserPlace, foreignKey: 'user_id' }); diff --git a/test/integration/dialects/postgres/query-interface.test.js b/test/integration/dialects/postgres/query-interface.test.js index 193c97d28998..2c6fe19cc00e 100644 --- a/test/integration/dialects/postgres/query-interface.test.js +++ b/test/integration/dialects/postgres/query-interface.test.js @@ -164,13 +164,13 @@ if (dialect.match(/^postgres/)) { const second_body = 'return \'second\';'; // create function - return this.queryInterface.createFunction('my_func', [], 'varchar', 'plpgsql', first_body, null) + return this.queryInterface.createFunction('create_job', [{ type: 'varchar', name: 'test' }], 'varchar', 'plpgsql', first_body, null) // override - .then(() => this.queryInterface.createFunction('my_func', [], 'varchar', 'plpgsql', second_body, null, { force: true })) + .then(() => this.queryInterface.createFunction('create_job', [{ type: 'varchar', name: 'test' }], 'varchar', 'plpgsql', second_body, null, { force: true })) // validate - .then(() => this.sequelize.query('select my_func();', { type: this.sequelize.QueryTypes.SELECT })) + .then(() => this.sequelize.query("select create_job('abc');", { type: this.sequelize.QueryTypes.SELECT })) .then(res => { - expect(res[0].my_func).to.be.eql('second'); + expect(res[0].create_job).to.be.eql('second'); }); }); diff --git a/test/unit/associations/belongs-to-many.test.js b/test/unit/associations/belongs-to-many.test.js index 3f2a30ee3c5c..672187f800f1 100644 --- a/test/unit/associations/belongs-to-many.test.js +++ b/test/unit/associations/belongs-to-many.test.js @@ -224,7 +224,7 @@ describe(Support.getTestDialectTeaser('belongsToMany'), () => { it('should infer otherKey from paired BTM relationship with a through model defined', function() { const User = this.sequelize.define('User', {}); - const Place = this.sequelize.define('User', {}); + const Place = this.sequelize.define('Place', {}); const UserPlace = this.sequelize.define('UserPlace', { id: { primaryKey: true, @@ -249,6 +249,51 @@ describe(Support.getTestDialectTeaser('belongsToMany'), () => { }); }); + describe('source/target keys', () => { + it('should infer targetKey from paired BTM relationship with a through string defined', function() { + const User = this.sequelize.define('User', { user_id: DataTypes.UUID }); + const Place = this.sequelize.define('Place', { place_id: DataTypes.UUID }); + + const Places = User.belongsToMany(Place, { through: 'user_places', sourceKey: 'user_id' }); + const Users = Place.belongsToMany(User, { through: 'user_places', sourceKey: 'place_id' }); + + expect(Places.paired).to.equal(Users); + expect(Users.paired).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', function() { + const User = this.sequelize.define('User', { user_id: DataTypes.UUID }); + const Place = this.sequelize.define('Place', { place_id: DataTypes.UUID }); + const UserPlace = this.sequelize.define('UserPlace', { + id: { + primaryKey: true, + type: DataTypes.INTEGER, + autoIncrement: true + } + }, { timestamps: false }); + + const Places = User.belongsToMany(Place, { through: UserPlace, sourceKey: 'user_id' }); + const Users = Place.belongsToMany(User, { through: UserPlace, sourceKey: 'place_id' }); + + expect(Places.paired).to.equal(Users); + expect(Users.paired).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'); + + expect(Object.keys(UserPlace.rawAttributes).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', function() { const Product = this.sequelize.define('Product', { diff --git a/types/lib/associations/belongs-to-many.d.ts b/types/lib/associations/belongs-to-many.d.ts index 8e6f4a3ca2e9..e7af47d59da6 100644 --- a/types/lib/associations/belongs-to-many.d.ts +++ b/types/lib/associations/belongs-to-many.d.ts @@ -63,6 +63,18 @@ export interface BelongsToManyOptions extends ManyToManyOptions { */ otherKey?: string | ForeignKeyOptions; + /** + * The name of the field to use as the key for the association in the source table. Defaults to the primary + * key of the source table + */ + sourceKey?: string; + + /** + * The name of the field to use as the key for the association in the target table. Defaults to the primary + * key of the target table + */ + targetKey?: string; + /** * Should the join model have timestamps */ @@ -76,6 +88,8 @@ export interface BelongsToManyOptions extends ManyToManyOptions { export class BelongsToMany extends Association { public otherKey: string; + public sourceKey: string; + public targetKey: string; public accessors: MultiAssociationAccessors; constructor(source: ModelCtor, target: ModelCtor, options: BelongsToManyOptions); }