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
.