Rails ActiveRecord Associations Inverse Scope

Scopes and Associations

Scopes help create a useful public interface and reduce code duplication. Although when applied to an association, you might find yourself scratching your head.

Let’s consider a real world example.

If you want to start a Conversation by sending Message then you might have something like this:

class Conversation < ApplicationRecord
  has_many :messages
end

class Message < ApplicationRecord
  belongs_to :conversation
end

Saving message built from a new conversation should create and link them both.

message = Conversation.new.messages.build(body: 'Hello')
message.conversation

 => Conversation id: nil>

message.save

message
=> Message id: 1, body: 'Hello', conversation_id: 1>
message.conversation
=> Conversation id: 1>

Adding a scope

It makes sense to sort the messages chronologically. A potential solution might look something like this:

class Conversation < ApplicationRecord
  has_many :messages, -> { order(created_id: :asc) }
end

But here lies the problem

The build method on an association depends on the inverse being set. Adding this scope will prevent you from building a Message on a Conversation.

message = Conversation.new.messages.build(body: 'Hello')
message.conversation
 => nil

message.save
   (0.2ms)  BEGIN
   (0.4ms)  ROLLBACK
 => false

message.errors.full_messages
=> ["Conversation must exist"]

Why is our message no longer associated to the conversation? The inverse is no longer set.

Adding a scope prevented rails from setting the inverse. Rails source code states this in the ActiveRecord::Reflection module.

# ActiveRecord::Reflection module (Rails 5.2.1)

  # Checks to see if the reflection doesn't have any options that prevent
  # us from being able to guess the inverse automatically. First, the
  # <tt>inverse_of</tt> option cannot be set to false. Second, we must
  # have <tt>has_many</tt>, <tt>has_one</tt>, <tt>belongs_to</tt> associations.
  # Third, we must not have options such as <tt>:foreign_key</tt>
  # which prevent us from correctly guessing the inverse association.
  #
  # Anything with a scope can additionally ruin our attempt at finding an
  # inverse, so we exclude reflections with scopes.
  def can_find_inverse_of_automatically?(reflection)
    reflection.options[:inverse_of] != false &&
      VALID_AUTOMATIC_INVERSE_MACROS.include?(reflection.macro) &&
      !INVALID_AUTOMATIC_INVERSE_OPTIONS.any? { |opt| reflection.options[opt] } &&
      !reflection.scope
  end

How to Avoid This?

I would make two changes.

First, be explicit and set the inverse on the association.

class Conversation < ApplicationRecord
  has_many :messages, -> { order(created_id: :asc) }, inverse_of: :conversation
end

And second, decouple scope and the association. Define a separate scope instead of tying it into :messages. Then you could chain your :ordered scope like this: messages.ordered.

class Conversation < ApplicationRecord
  has_many :messages, inverse_of: :conversation
end

class Message < ApplicationRecord
  scope :ordered, -> { order(created_id: :asc) }
end

Don’t forget to add a test making sure inverse_of is set. Also, with a code linter like RuboCop, you can enforce this by adding a Rails/InverseOf Cop.

Takeaway

Losing the benefits of an inverse association are avoided by setting :inverse_of and separating scope.

 

Did you like this article? Check out these too.


 

Found this useful? Know how it can be improved? Get in touch and share your thoughts at blog@hocnest.com