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_at: :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_at: :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_at: :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? Have a suggestion? Get in touch at blog@hocnest.com.