Polymorphic Associations

*Note: the usage of polymorphic associations in Sequelize, as outlined in this guide, should be done with caution. Don’t just copy-paste code from here, otherwise you might easily make mistakes and introduce bugs in your code. Make sure you understand what is going on.*

Concept

A polymorphic association consists on two (or more) associations happening with the same foreign key.

For example, consider the models Image, Video and Comment. The first two represent something that a user might post. We want to allow comments to be placed in both of them. This way, we immediately think of establishing the following associations:

  • A One-to-Many association between Image and Comment:

    1. Image.hasMany(Comment);
    2. Comment.belongsTo(Image);
  • A One-to-Many association between Video and Comment:

    1. Video.hasMany(Comment);
    2. Comment.belongsTo(Video);

However, the above would cause Sequelize to create two foreign keys on the Comment table: ImageId and VideoId. This is not ideal because this structure makes it look like a comment can be attached at the same time to one image and one video, which isn’t true. Instead, what we really want here is precisely a polymorphic association, in which a Comment points to a single Commentable, an abstract polymorphic entity that represents one of Image or Video.

Before proceeding to how to configure such an association, let’s see how using it looks like:

  1. const image = await Image.create({ url: "https://placekitten.com/408/287" });
  2. const comment = await image.createComment({ content: "Awesome!" });
  3. console.log(comment.commentableId === image.id); // true
  4. // We can also retrieve which type of commentable a comment is associated to.
  5. // The following prints the model name of the associated commentable instance.
  6. console.log(comment.commentableType); // "Image"
  7. // We can use a polymorphic method to retrieve the associated commentable, without
  8. // having to worry whether it's an Image or a Video.
  9. const associatedCommentable = await comment.getCommentable();
  10. // In this example, `associatedCommentable` is the same thing as `image`:
  11. const isDeepEqual = require('deep-equal');
  12. console.log(isDeepEqual(image, commentable)); // true

Configuring a One-to-Many polymorphic association

To setup the polymorphic association for the example above (which is an example of One-to-Many polymorphic association), we have the following steps:

  • Define a string field called commentableType in the Comment model;
  • Define the hasMany and belongsTo association between Image/Video and Comment:
    • Disabling constraints (i.e. using { constraints: false }), since the same foreign key is referencing multiple tables;
    • Specifying the appropriate association scopes;
  • To properly support lazy loading, define a new instance method on the Comment model called getCommentable which calls, under the hood, the correct mixin to fetch the appropriate commentable;
  • To properly support eager loading, define an afterFind hook on the Comment model that automatically populates the commentable field in every instance;
  • To prevent bugs/mistakes in eager loading, you can also delete the concrete fields image and video from Comment instances in the same afterFind hook, leaving only the abstract commentable field available.

Here is an example:

  1. // Helper function
  2. const uppercaseFirst = str => `${str[0].toUpperCase()}${str.substr(1)}`;
  3. class Image extends Model {}
  4. Image.init({
  5. title: DataTypes.STRING,
  6. url: DataTypes.STRING
  7. }, { sequelize, modelName: 'image' });
  8. class Video extends Model {}
  9. Video.init({
  10. title: DataTypes.STRING,
  11. text: DataTypes.STRING
  12. }, { sequelize, modelName: 'video' });
  13. class Comment extends Model {
  14. getCommentable(options) {
  15. if (!this.commentableType) return Promise.resolve(null);
  16. const mixinMethodName = `get${uppercaseFirst(this.commentableType)}`;
  17. return this[mixinMethodName](options);
  18. }
  19. }
  20. Comment.init({
  21. title: DataTypes.STRING,
  22. commentableId: DataTypes.INTEGER,
  23. commentableType: DataTypes.STRING
  24. }, { sequelize, modelName: 'comment' });
  25. Image.hasMany(Comment, {
  26. foreignKey: 'commentableId',
  27. constraints: false,
  28. scope: {
  29. commentableType: 'image'
  30. }
  31. });
  32. Comment.belongsTo(Image, { foreignKey: 'commentableId', constraints: false });
  33. Video.hasMany(Comment, {
  34. foreignKey: 'commentableId',
  35. constraints: false,
  36. scope: {
  37. commentableType: 'video'
  38. }
  39. });
  40. Comment.belongsTo(Video, { foreignKey: 'commentableId', constraints: false });
  41. Comment.addHook("afterFind", findResult => {
  42. if (!Array.isArray(findResult)) findResult = [findResult];
  43. for (const instance of findResult) {
  44. if (instance.commentableType === "image" && instance.image !== undefined) {
  45. instance.commentable = instance.image;
  46. } else if (instance.commentableType === "video" && instance.video !== undefined) {
  47. instance.commentable = instance.video;
  48. }
  49. // To prevent mistakes:
  50. delete instance.image;
  51. delete instance.dataValues.image;
  52. delete instance.video;
  53. delete instance.dataValues.video;
  54. }
  55. });

Since the commentableId column references several tables (two in this case), we cannot add a REFERENCES constraint to it. This is why the constraints: false option was used.

Note that, in the code above:

  • The Image -> Comment association defined an association scope: { commentableType: 'image' }
  • The Video -> Comment association defined an association scope: { commentableType: 'video' }

These scopes are automatically applied when using the association functions (as explained in the Association Scopes guide). Some examples are below, with their generated SQL statements:

  • image.getComments():

    1. SELECT "id", "title", "commentableType", "commentableId", "createdAt", "updatedAt"
    2. FROM "comments" AS "comment"
    3. WHERE "comment"."commentableType" = 'image' AND "comment"."commentableId" = 1;

    Here we can see that `comment`.`commentableType` = 'image' was automatically added to the WHERE clause of the generated SQL. This is exactly the behavior we want.

  • image.createComment({ title: 'Awesome!' }):

    1. INSERT INTO "comments" (
    2. "id", "title", "commentableType", "commentableId", "createdAt", "updatedAt"
    3. ) VALUES (
    4. DEFAULT, 'Awesome!', 'image', 1,
    5. '2018-04-17 05:36:40.454 +00:00', '2018-04-17 05:36:40.454 +00:00'
    6. ) RETURNING *;
  • image.addComment(comment):

    1. UPDATE "comments"
    2. SET "commentableId"=1, "commentableType"='image', "updatedAt"='2018-04-17 05:38:43.948 +00:00'
    3. WHERE "id" IN (1)

Polymorphic lazy loading

The getCommentable instance method on Comment provides an abstraction for lazy loading the associated commentable - working whether the comment belongs to an Image or a Video.

It works by simply converting the commentableType string into a call to the correct mixin (either getImage or getVideo).

Note that the getCommentable implementation above:

  • Returns null when no association is present (which is good);
  • Allows you to pass an options object to getCommentable(options), just like any other standard Sequelize method. This is useful to specify where-conditions or includes, for example.

Polymorphic eager loading

Now, we want to perform a polymorphic eager loading of the associated commentables for one (or more) comments. We want to achieve something similar to the following idea:

  1. const comment = await Comment.findOne({
  2. include: [ /* What to put here? */ ]
  3. });
  4. console.log(comment.commentable); // This is our goal

The solution is to tell Sequelize to include both Images and Videos, so that our afterFind hook defined above will do the work, automatically adding the commentable field to the instance object, providing the abstraction we want.

For example:

  1. const comments = await Comment.findAll({
  2. include: [Image, Video]
  3. });
  4. for (const comment of comments) {
  5. const message = `Found comment #${comment.id} with ${comment.commentableType} commentable:`;
  6. console.log(message, comment.commentable.toJSON());
  7. }

Output example:

  1. Found comment #1 with image commentable: { id: 1,
  2. title: 'Meow',
  3. url: 'https://placekitten.com/408/287',
  4. createdAt: 2019-12-26T15:04:53.047Z,
  5. updatedAt: 2019-12-26T15:04:53.047Z }

Caution - possibly invalid eager/lazy loading!

Consider a comment Foo whose commentableId is 2 and commentableType is image. Consider also that Image A and Video X both happen to have an id equal to 2. Conceptually, it is clear that Video X is not associated to Foo, because even though its id is 2, the commentableType of Foo is image, not video. However, this distinction is made by Sequelize only at the level of the abstractions performed by getCommentable and the hook we created above.

This means that if you call Comment.findAll({ include: Video }) in the situation above, Video X will be eager loaded into Foo. Thankfully, our afterFind hook will delete it automatically, to help prevent bugs, but regardless it is important that you understand what is going on.

The best way to prevent this kind of mistake is to avoid using the concrete accessors and mixins directly at all costs (such as .image, .getVideo(), .setImage(), etc), always preferring the abstractions we created, such as .getCommentable() and .commentable. If you really need to access eager-loaded .image and .video for some reason, make sure you wrap that in a type check such as comment.commentableType === 'image'.

Configuring a Many-to-Many polymorphic association

In the above example, we had the models Image and Video being abstractly called commentables, with one commentable having many comments. However, one given comment would belong to a single commentable - this is why the whole situation is a One-to-Many polymorphic association.

Now, to consider a Many-to-Many polymorphic association, instead of considering comments, we will consider tags. For convenience, instead of calling Image and Video as commentables, we will now call them taggables. One taggable may have several tags, and at the same time one tag can be placed in several taggables.

The setup for this goes as follows:

  • Define the juncion model explicitly, specifying the two foreign keys as tagId and taggableId (this way it is a junction model for a Many-to-Many relationship between Tag and the abstract concept of taggable);
  • Define a string field called taggableType in the junction model;
  • Define the belongsToMany associations between the two models and Tag:
    • Disabling constraints (i.e. using { constraints: false }), since the same foreign key is referencing multiple tables;
    • Specifying the appropriate association scopes;
  • Define a new instance method on the Tag model called getTaggables which calls, under the hood, the correct mixin to fetch the appropriate taggables.

Implementation:

  1. class Tag extends Model {
  2. getTaggables(options) {
  3. const images = await this.getImages(options);
  4. const videos = await this.getVideos(options);
  5. // Concat images and videos in a single array of taggables
  6. return images.concat(videos);
  7. }
  8. }
  9. Tag.init({
  10. name: DataTypes.STRING
  11. }, { sequelize, modelName: 'tag' });
  12. // Here we define the junction model explicitly
  13. class Tag_Taggable extends Model {}
  14. Tag_Taggable.init({
  15. tagId: {
  16. type: DataTypes.INTEGER,
  17. unique: 'tt_unique_constraint'
  18. },
  19. taggableId: {
  20. type: DataTypes.INTEGER,
  21. unique: 'tt_unique_constraint',
  22. references: null
  23. },
  24. taggableType: {
  25. type: DataTypes.STRING,
  26. unique: 'tt_unique_constraint'
  27. }
  28. }, { sequelize, modelName: 'tag_taggable' });
  29. Image.belongsToMany(Tag, {
  30. through: {
  31. model: Tag_Taggable,
  32. unique: false,
  33. scope: {
  34. taggableType: 'image'
  35. }
  36. },
  37. foreignKey: 'taggableId',
  38. constraints: false
  39. });
  40. Tag.belongsToMany(Image, {
  41. through: {
  42. model: Tag_Taggable,
  43. unique: false
  44. },
  45. foreignKey: 'tagId',
  46. constraints: false
  47. });
  48. Video.belongsToMany(Tag, {
  49. through: {
  50. model: Tag_Taggable,
  51. unique: false,
  52. scope: {
  53. taggableType: 'video'
  54. }
  55. },
  56. foreignKey: 'taggableId',
  57. constraints: false
  58. });
  59. Tag.belongsToMany(Video, {
  60. through: {
  61. model: Tag_Taggable,
  62. unique: false
  63. },
  64. foreignKey: 'tagId',
  65. constraints: false
  66. });

The constraints: false option disables references constraints, as the taggableId column references several tables, we cannot add a REFERENCES constraint to it.

Note that:

  • The Image -> Tag association defined an association scope: { taggableType: 'image' }
  • The Video -> Tag association defined an association scope: { taggableType: 'video' }

These scopes are automatically applied when using the association functions. Some examples are below, with their generated SQL statements:

  • image.getTags():

    1. SELECT
    2. `tag`.`id`,
    3. `tag`.`name`,
    4. `tag`.`createdAt`,
    5. `tag`.`updatedAt`,
    6. `tag_taggable`.`tagId` AS `tag_taggable.tagId`,
    7. `tag_taggable`.`taggableId` AS `tag_taggable.taggableId`,
    8. `tag_taggable`.`taggableType` AS `tag_taggable.taggableType`,
    9. `tag_taggable`.`createdAt` AS `tag_taggable.createdAt`,
    10. `tag_taggable`.`updatedAt` AS `tag_taggable.updatedAt`
    11. FROM `tags` AS `tag`
    12. INNER JOIN `tag_taggables` AS `tag_taggable` ON
    13. `tag`.`id` = `tag_taggable`.`tagId` AND
    14. `tag_taggable`.`taggableId` = 1 AND
    15. `tag_taggable`.`taggableType` = 'image';

    Here we can see that `tag_taggable`.`taggableType` = 'image' was automatically added to the WHERE clause of the generated SQL. This is exactly the behavior we want.

  • tag.getTaggables():

    1. SELECT
    2. `image`.`id`,
    3. `image`.`url`,
    4. `image`.`createdAt`,
    5. `image`.`updatedAt`,
    6. `tag_taggable`.`tagId` AS `tag_taggable.tagId`,
    7. `tag_taggable`.`taggableId` AS `tag_taggable.taggableId`,
    8. `tag_taggable`.`taggableType` AS `tag_taggable.taggableType`,
    9. `tag_taggable`.`createdAt` AS `tag_taggable.createdAt`,
    10. `tag_taggable`.`updatedAt` AS `tag_taggable.updatedAt`
    11. FROM `images` AS `image`
    12. INNER JOIN `tag_taggables` AS `tag_taggable` ON
    13. `image`.`id` = `tag_taggable`.`taggableId` AND
    14. `tag_taggable`.`tagId` = 1;
    15. SELECT
    16. `video`.`id`,
    17. `video`.`url`,
    18. `video`.`createdAt`,
    19. `video`.`updatedAt`,
    20. `tag_taggable`.`tagId` AS `tag_taggable.tagId`,
    21. `tag_taggable`.`taggableId` AS `tag_taggable.taggableId`,
    22. `tag_taggable`.`taggableType` AS `tag_taggable.taggableType`,
    23. `tag_taggable`.`createdAt` AS `tag_taggable.createdAt`,
    24. `tag_taggable`.`updatedAt` AS `tag_taggable.updatedAt`
    25. FROM `videos` AS `video`
    26. INNER JOIN `tag_taggables` AS `tag_taggable` ON
    27. `video`.`id` = `tag_taggable`.`taggableId` AND
    28. `tag_taggable`.`tagId` = 1;

Note that the above implementation of getTaggables() allows you to pass an options object to getCommentable(options), just like any other standard Sequelize method. This is useful to specify where-conditions or includes, for example.

Applying scopes on the target model

In the example above, the scope options (such as scope: { taggableType: 'image' }) were applied to the through model, not the target model, since it was used under the through option.

We can also apply an association scope on the target model. We can even do both at the same time.

To illustrate this, consider an extension of the above example between tags and taggables, where each tag has a status. This way, to get all pending tags of an image, we could establish another belognsToMany relationship between Image and Tag, this time applying a scope on the through model and another scope on the target model:

  1. Image.belongsToMany(Tag, {
  2. through: {
  3. model: Tag_Taggable,
  4. unique: false,
  5. scope: {
  6. taggableType: 'image'
  7. }
  8. },
  9. scope: {
  10. status: 'pending'
  11. },
  12. as: 'pendingTags',
  13. foreignKey: 'taggableId',
  14. constraints: false
  15. });

This way, when calling image.getPendingTags(), the following SQL query will be generated:

  1. SELECT
  2. `tag`.`id`,
  3. `tag`.`name`,
  4. `tag`.`status`,
  5. `tag`.`createdAt`,
  6. `tag`.`updatedAt`,
  7. `tag_taggable`.`tagId` AS `tag_taggable.tagId`,
  8. `tag_taggable`.`taggableId` AS `tag_taggable.taggableId`,
  9. `tag_taggable`.`taggableType` AS `tag_taggable.taggableType`,
  10. `tag_taggable`.`createdAt` AS `tag_taggable.createdAt`,
  11. `tag_taggable`.`updatedAt` AS `tag_taggable.updatedAt`
  12. FROM `tags` AS `tag`
  13. INNER JOIN `tag_taggables` AS `tag_taggable` ON
  14. `tag`.`id` = `tag_taggable`.`tagId` AND
  15. `tag_taggable`.`taggableId` = 1 AND
  16. `tag_taggable`.`taggableType` = 'image'
  17. WHERE (
  18. `tag`.`status` = 'pending'
  19. );

We can see that both scopes were applied automatically:

  • `tag_taggable`.`taggableType` = 'image' was added automatically to the INNER JOIN;
  • `tag`.`status` = 'pending' was added automatically to an outer where clause.