The project is in a healthy, maintained state
An ActiveRecord extension for polymorphic exclusive arc relationships
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
 Dependencies

Runtime

>= 6.1, < 8
>= 6.1, < 8
 Project Readme

đź’« activerecord-exclusive-arc đź’«

A RubyGem that allows an ActiveRecord model to exclusively belong to one of any number of different types of ActiveRecord models.

Doesn’t Rails already provide a way to do this?

Yes but here’s a post about why this exists.

So how does this work?

It reduces the boilerplate of managing a Polymorphic Assication modeled as a pattern called an Exclusive Arc, where each potential polymorphic reference has its own foreign key. This maps nicely to a set of optional belongs_to relationships, some polymorphic convenience methods, and a database check constraint with a matching ActiveRecord validation.

How to use

Firstly, add the gem to your Gemfile and bundle install:

gem "activerecord-exclusive-arc"

The feature set of this gem is offered via a Rails generator command:

bin/rails g exclusive_arc <Model> <arc> <belongs_to1> <belongs_to2> ...

This assumes you already have a <Model>. The <arc> is the name of the polymorphic association you want to establish that may either be a <belongs_to1>, <belongs_to2>, etc. Say we ran:

bin/rails g exclusive_arc Comment commentable post comment

This will inject code into your Comment Model:

class Comment < ApplicationRecord
  include ExclusiveArc::Model
  has_exclusive_arc :commentable, [:post, :comment]
end

At a high-level, this essentially transpiles to the following:

class Comment < ApplicationRecord
  belongs_to :post, optional: true
  belongs_to :comment, optional: true
  validate :post_or_comment_present?

  def commentable
    @commentable ||= (post || comment)
  end

  def commentable=(post_or_comment)
    @commentable = post_or_comment
  end
end

It's a bit more involved than that, but it demonstrates the essense of the API as an ActiveRecord user.

If you need to customize a specific belongs_to relationship, you can do so by declaring it before has_exclusive_arc:

class Comment < ApplicationRecord
  include ExclusiveArc::Model
  belongs_to :post, -> { where(comments_enabled: true) }, optional: true
  has_exclusive_arc :commentable, [:post, :comment]
end

Continuing with our example, the generator command would also produce a migration that looks like this:

class CommentCommentableExclusiveArcPostComment < ActiveRecord::Migration[7.0]
  def change
    add_reference :comments, :post, foreign_key: true, index: {where: "post_id IS NOT NULL"}
    add_reference :comments, :comment, foreign_key: true, index: {where: "comment_id IS NOT NULL"}
    add_check_constraint(
      :comments,
      "(CASE WHEN post_id IS NULL THEN 0 ELSE 1 END + CASE WHEN comment_id IS NULL THEN 0 ELSE 1 END) = 1",
      name: "commentable"
    )
  end
end

The check constraint ensures ActiveRecord validations can’t be bypassed to break the fabeled rule - "There Can Only Be One️". Traditional foreign key constraints can be used and the partial indexes provide improved lookup performance for each individual polymorphic assoication.

Exclusive Arc Options

Some options are available to the generator command. You can see them with:

$ bin/rails g exclusive_arc --help
Usage:
  rails generate exclusive_arc NAME [arc belongs_to1 belongs_to2 ...] [options]

Options:
  [--optional], [--no-optional]                                          # Exclusive arc is optional
  [--skip-foreign-key-constraints], [--no-skip-foreign-key-constraints]  # Skip foreign key constraints
  [--skip-foreign-key-indexes], [--no-skip-foreign-key-indexes]          # Skip foreign key partial indexes
  [--skip-check-constraint], [--no-skip-check-constraint]                # Skip check constraint

Adds an Exclusive Arc to an ActiveRecord model and generates the migration for it

Notably, if you want to make an Exclusive Arc optional, you can use the --optional flag. This will adjust the definition in your ActiveRecord model and loosen both the validation and database check constraint so that there can be 0 or 1 foreign keys set for the polymorphic reference.

Updating an existing exclusive arc

If you need to add an additional polymorphic option to an existing exclusive arc, you can simply run the generator command again with the additional target. Existing references will be skipped and the check constraint will be removed and re-added in a reversible manner.

bin/rails g exclusive_arc Comment commentable post comment page
class CommentCommentableExclusiveArcPostCommentPage < ActiveRecord::Migration[7.0]
  def change
    add_reference :comments, :page, foreign_key: true, index: {where: "page_id IS NOT NULL"}
    remove_check_constraint(
      :comments,
      "(CASE WHEN post_id IS NULL THEN 0 ELSE 1 END + CASE WHEN comment_id IS NULL THEN 0 ELSE 1 END) = 1",
      name: "commentable"
    )
    add_check_constraint(
      :comments,
      "(CASE WHEN post_id IS NULL THEN 0 ELSE 1 END + CASE WHEN comment_id IS NULL THEN 0 ELSE 1 END + CASE WHEN page_id IS NULL THEN 0 ELSE 1 END) = 1",
      name: "commentable"
    )
  end
end

The registration in the model will be updated as well.

class Comment < ApplicationRecord
  include ExclusiveArc::Model
  has_exclusive_arc :commentable, [:post, :comment, :page]
end

Compatibility

Currently activerecord-exclusive-arc is tested against a matrix of:

  • Ruby 3.2 and 3.3
  • Rails 6.1, 7.0, 7.1, 7.2, 8.0
  • postgresql, sqlite3, and mysql2 database adapters

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/waymondo/activerecord-exclusive-arc.

License

The gem is available as open source under the terms of the MIT License.