将方法委托给 has_many 关联会忽略预加载

Delegating method to has_many association ignores preloading

是否可以将方法委托给 rails 中的 has_many 关联,并且仍将预加载的数据保存在该关联上,同时遵循得墨忒耳法则?目前在我看来,你被迫选择一个或另一个。即:通过不委托来保留预加载的数据,或者丢失预加载的数据并进行委托。

例子:我有以下两个模型:

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

注意:User#all_blogs_have_title?all_have_title? 的委托方法完全相同。

据我了解,以下内容违反了得墨忒耳定律。但是:它会保留您预加载的数据:

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

注意:当我调用 user.all_blogs_have_title? 时,它没有执行额外的查询。但是,请注意方法 all_blogs_have_title? 正在询问 Blog 属性,这违反了得墨忒耳法则。

应用得墨忒耳定律但丢失预加载数据的其他方式:

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 

希望这两种实现的缺点是显而易见的。理想情况下:我想用委托实现的第二种方式来做,但要维护预加载的数据。这可能吗?

这是一个遵循得墨忒耳法则并尊重预加载数据的解决方案(不会再次访问数据库)。这当然有点奇怪,但我找不到任何其他解决方案,我真的很想知道其他人对此有何看法:

型号

class User < ApplicationRecord     
  has_many :blogs

  def all_blogs_have_title?
    blogs.all_have_title_present?(self)
  end
end

class Blog < ApplicationRecord
  belongs_to :user

  def self.all_have_title_present?(user)
    user.blogs.any? && user.blogs.all? {|blog| blog.title.present?}
  end
end

用法

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 

所以我们注意到它没有再次访问数据库(尊重预加载的数据),而不是 user 进入它的邻居(Blog)的属性,它委托向它的邻居提问并允许邻居(再次:Blog)回答关于它自己的属性.

的问题

奇怪的事情显然在 Blog 模型内部,其中 class 方法询问 user.blogs,因此 Blog 知道 User 上的关联。但这也许没关系,因为毕竟 BlogUser 彼此共享关联。

说明

all_have_title? 委托在您的示例中无法正常工作的原因是您将方法委托给 blogs 关联,但仍将其定义为 Blog class 方法,它们是不同的实体,因此是接收者。

到这里大家一定会问为什么在OP提供的第二个例子中调用user.all_have_title?时没有出现NoMethodError异常。这背后的原因在 ActiveRecord::Associations::CollectionProxy 文档(这是 user.blogs 调用的结果对象 class)中进行了详细说明,由于我们的示例命名而改写为:

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.

所以事情发生的顺序如下:

  1. delegateUser 初始化模型的 has_many 范围内定义 all_have_title? 实例方法;
  2. user 上调用时 all_have_title? 方法被委托给 has_many 关联;
  3. 因为那里没有定义这样的方法,所以它通过 method_missing;
  4. 委托给 Blog class all_have_title? 方法
  5. all 方法在 Blog 上调用,current_scope 保持 user_id 条件(此时 scoped_attributes 保持 {"user_id"=>1} 值),所以没有关于预加载的信息,因为基本上发生的事情是:

    Blog.where(user_id: 1)
    

    分别为每个user,这是与之前执行的预加载的主要区别,后者使用in按多个值查询关联记录,但这里执行的是查询一个= 的单条记录(这就是为什么在这两个调用之间甚至没有缓存查询本身的原因)。

解决方案

要显式封装该方法并将其标记为基于关系(介于 UserBlog 之间),您应该在 has_many 关联范围内定义和描述它的逻辑:

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

因此您所做的调用应该只会导致以下 2 个查询:

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

这种方式 User 不会隐式操作 Blog 的属性,您也不会丢失预加载的数据。如果您不希望关联方法直接与 title 属性一起操作(在 all 方法中阻塞),您可以在 Blog 模型中定义一个实例方法并在那里定义所有逻辑:

class Blog
  def has_title?
    title.present?
  end
end

这个 scope_delegation gem 会完成你的工作。

如果你会这样定义你的作品

class User < ApplicationRecord
   has_many :blogs

   delegate :all_have_title?, to: :blogs, prefix: false, allow_nil: false
end

class Blog < ApplicationRecord
  belongs_to :user

  def self.all_have_title?
    where.not(title: nil)
  end
end

这应该有效:)