Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Delegating method to has_many association ignores preloading

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?

like image 526
Neil Avatar asked Dec 05 '17 20:12

Neil


Video Answer


1 Answers

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 in user as @owner, the collection of his blogs as @target, and the @reflection object represents a :has_many macro.
This class delegates unknown methods to @target via method_missing.

So the order of things that are happening is as follows:

  1. delegate defines all_have_title? instance method in has_many scope in User model on initialization;
  2. when called on user all_have_title? method is delegated to the has_many association;
  3. as there is no such method defined there it is delegated to Blog class all_have_title? method via method_missing;
  4. 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
like image 153
potashin Avatar answered Sep 22 '22 21:09

potashin