Метод делегирования для has_many ассоциации игнорирует предварительную загрузку

Можно ли делегировать метод ассоциации 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?.

Следующее, как я понимаю, нарушает закон demeter. Однако: он поддерживает ваши предварительно загруженные данные:

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, что нарушает закон demeter.

Другой способ, который применяет закон demeter, но вы теряете предварительно загруженные данные:

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 

Мы надеемся, что недостатки обеих реализаций очевидны. В идеале: я хотел бы сделать это вторым способом с реализацией делегата, но для поддержания этих предварительно загруженных данных. Возможно ли это?

Ответ 1

Объяснение

Причина, по которой делегирование all_have_title? в вашем примере не работает должным образом, заключается в том, что вы делегируете метод ассоциации blogs, но все же определяете его как метод класса Blog, который представляет собой разные сущности и, следовательно, приемники,

В этот момент все последующие будут задавать вопрос, почему не возникает исключение NoMethodError, вызванное при вызове user.all_have_title? во втором примере, предоставленном OP. Причина этого заключается в документации ActiveRecord::Associations::CollectionProxy (которая является результирующим классом объекта вызова user.blogs), который перефразирует к нашему примеру namings заявляет:

что прокси-сервер ассоциации в user.blogs имеет объект в user как @owner, набор его blogs как @target, а объект @reflection представляет макрос :has_many.
> Этот класс делегирует неизвестные методы @target через method_missing.

Итак, порядок вещей, которые происходят, выглядит следующим образом:

  • delegate определяет метод экземпляра all_have_title? в области has_many в user при инициализации;
  • при вызове метода user all_have_title? делегируется ассоциации has_many;
  • так как нет такого метода, который определен там, он делегируется методу Blog class all_have_title? через method_missing;
  • all метод вызывается на Blog с current_scope, который содержит условие user_id (scoped_attributes на данный момент имеет значение {"user_id"=>1}), поэтому нет информации о предварительной загрузке, потому что в основном происходит следующее:

    Blog.where(user_id: 1)
    

    для каждого user отдельно, что является ключевым отличием по сравнению с предварительно загруженной ранее загрузкой, которая запрашивает связанные записи по нескольким значениям с помощью in, но тот, который здесь выполняется, запрашивает одну запись с = (именно по этой причине сам запрос не кэшируется между этими двумя вызовами).

Решение

Чтобы как инкапсулировать метод явным образом, так и пометить его как отношение (между user и Blog), вы должны определить и описать его логику в области 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

Таким образом, вызов, который вы вызываете, должен приводить только к следующим двум запросам:

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

Ответ 2

Вот решение, которое следует за законом demeter И чтит предварительно загруженные данные (не попадает в базу данных снова). Это, конечно, немного странно, но я не мог найти другого решения, и я действительно хочу знать, что другие думают об этом:

Модели

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, где метод класса запрашивает user.blogs, поэтому Blog знает об ассоциации на user. Но, возможно, это нормально, потому что, в конце концов, Blog и user разделяют связь друг с другом.

Ответ 3

Этот 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

Это должно работать:)