Is it possible to delegate a method to a has_many
association in rails, AND still save the preloaded data on that association, all while following the law of demeter? Currently it seems to me that you are forced to choose one or the other. That is: keep your preloaded data by NOT delegating, or lose your preloaded data and delegate.
Example: I have the following two models:
class User < ApplicationRecord
has_many :blogs
delegate :all_have_title?, to: :blogs, prefix: false, allow_nil: false
def all_blogs_have_title?
blogs.all? {|blog| blog.title.present?}
end
end
class Blog < ApplicationRecord
belongs_to :user
def self.all_have_title?
all.all? {|blog| blog.title.present?}
end
end
Notice: that User#all_blogs_have_title?
does the exact same thing as the delegation method of all_have_title?
.
The following, as I understand it, violates law of demeter. However: it maintains your preloaded data:
user = User.includes(:blogs).first
User Load (0.1ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ? [["LIMIT", 1]]
Blog Load (0.1ms) SELECT "blogs".* FROM "blogs" WHERE "blogs"."user_id" = 1
=> #<User id: 1, name: "all yes", created_at: "2017-12-05 20:28:00", updated_at: "2017-12-05 20:28:00">
user.all_blogs_have_title?
=> true
Notice: when I called user.all_blogs_have_title?
it DID NOT do an additional query. However, notice that the method all_blogs_have_title?
is asking about Blog
attributes, which is violating law of demeter.
Other way which applies law of demeter but you lose the preloaded data:
user = User.includes(:blogs).first
User Load (0.1ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ? [["LIMIT", 1]]
Blog Load (0.1ms) SELECT "blogs".* FROM "blogs" WHERE "blogs"."user_id" = 1
=> #<User id: 1, name: "all yes", created_at: "2017-12-05 20:28:00", updated_at: "2017-12-05 20:28:00">
user.all_have_title?
Blog Load (0.2ms) SELECT "blogs".* FROM "blogs" WHERE "blogs"."user_id" = ? [["user_id", 1]]
=> true
Hopefully the downside of both implementations is apparent. Ideally: I would like to do it the second way with the delegate implementation, but to maintain that preloaded data. Is this possible?
Explanation
The reason why all_have_title?
delegation doesn't work properly in your example is that your are delegating the method to blogs
association, but yet defining it as a Blog
class method, which are different entities and thus receivers.
At this point everybody following would be asking a question why there is no NoMethodError
exception raised when calling user.all_have_title?
in the second example provided by OP. The reason behind this is elaborated in the ActiveRecord::Associations::CollectionProxy
documentation (which is the resulting object class of the user.blogs
call), which rephrasing due to our example namings states:
that the association proxy in
user.blogs
has the object inuser
as@owner
, the collection of hisblogs
as@target
, and the@reflection
object represents a:has_many
macro.
This class delegates unknown methods to@target
viamethod_missing
.
So the order of things that are happening is as follows:
delegate
defines all_have_title?
instance method in has_many
scope in User
model on initialization;user
all_have_title?
method is delegated to the has_many
association;Blog
class all_have_title?
method via method_missing
;all
method is called on Blog
with current_scope
which holds user_id
condition (scoped_attributes
at this point is holding {"user_id"=>1}
value), so there is no information about preloading, because basically what is happening is:
Blog.where(user_id: 1)
for each user
separately, which is the key difference in comparison with the preloading that was performed before, which queries associated records by multiple values using in
, but the one performed here queries a single record with =
(this is the reason why the query itself is not even cached between these two calls).
Solution
To both encapsulate the method explicitly and mark it as a relation-based (between User
and Blog
) you should define and describe it's logic in the has_many
association scope:
class User
delegate :all_have_title?, to: :blogs, prefix: false, allow_nil: false
has_many :blogs do
def all_have_title?
all? { |blog| blog.title.present? }
end
end
end
Thus the calling you do should result in the following 2 queries only:
user = User.includes(:blogs).first
=> #<User:0x00007f9ace1067e0
User Load (0.8ms) SELECT `users`.* FROM `users` ORDER BY `users`.`id` ASC LIMIT 1
Blog Load (1.4ms) SELECT `blogs`.* FROM `blogs` WHERE `blogs`.`user_id` IN (1)
user.all_have_title?
=> true
this way User
doesn't implicitly operate with Blog
's attributes and you don't lose you preloaded data. If you don't want association methods operating with title
attribute directly (block in the all
method ), you can define an instance method in Blog
model and define all the logic there:
class Blog
def has_title?
title.present?
end
end
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With