Метод делегирования для 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
Это должно работать:)