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.blogshas the object inuseras@owner, the collection of hisblogsas@target, and the@reflectionobject represents a:has_manymacro.
This class delegates unknown methods to@targetviamethod_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