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

Feature: source/target keys in belongsToMany associations #11311

Merged
merged 4 commits into from Aug 14, 2019
Merged
Show file tree
Hide file tree
Changes from all 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
64 changes: 64 additions & 0 deletions docs/associations.md
Expand Up @@ -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
Expand Down
147 changes: 92 additions & 55 deletions lib/associations/belongs-to-many.js
Expand Up @@ -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)
*/
Expand All @@ -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, {
Expand All @@ -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;
}
Expand All @@ -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) {
Expand Down Expand Up @@ -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() {
Expand All @@ -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 });

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -442,12 +480,11 @@ class BelongsToMany extends Association {
* @returns {Promise<number>}
*/
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;
Expand All @@ -474,7 +511,7 @@ class BelongsToMany extends Association {
raw: true
}, options, {
scope: false,
attributes: [this.target.primaryKeyAttribute],
attributes: [this.targetKey],
joinTableAttributes: []
});

Expand All @@ -483,7 +520,7 @@ class BelongsToMany extends Association {
return instance.where();
}
return {
[this.target.primaryKeyAttribute]: instance
[this.targetKey]: instance
};
});

Expand All @@ -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
);
}

Expand All @@ -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 = {};
Expand Down Expand Up @@ -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 || {};
Expand Down Expand Up @@ -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));
Expand Down
10 changes: 4 additions & 6 deletions lib/dialects/abstract/query-generator.js
Expand Up @@ -1666,13 +1666,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;
Expand All @@ -1681,7 +1679,7 @@ class QueryGenerator {
main: [],
subQuery: []
};
let attrSource = primaryKeysSource[0];
let attrSource = association.sourceKey;
let sourceJoinOn;
let targetJoinOn;
let throughWhere;
Expand All @@ -1696,10 +1694,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
Expand Down