Сопоставление атрибута ассоциации вложенной модели с включением

Предположим, у меня есть следующие модели:

class Post < ActiveRecord::Base
  has_many :authors

class Author < ActiveRecord::Base
  belongs_to :post

И предположим, что модель Author имеет атрибут name.

Я хочу найти все сообщения с данным автором «Алиса» по имени этого автора. Скажем, есть еще один автор "боб", который написал пост в соавторстве с Алисой.

Если я ищу первый результат, используя includes и where:

post = Post.includes(:authors).where("authors.name" => "alice").first

Вы увидите, что у поста сейчас только один автор, хотя на самом деле их больше:

post.authors #=> [#<Author id: 1, name: "alice", ...>]
post.reload
post.authors #=> [#<Author id: 1, name: "alice", ...>, #<Author id: 2, name: "bob", ...>]

Проблема, похоже, в сочетании includes и where, которое корректно ограничивает область действия до нужного поста, но при этом скрывает все ассоциации, кроме той, которая совпала.

Я хочу получить ActiveRecord::Relation для цепочки, поэтому приведенное выше решение для перезагрузки не совсем удовлетворительно. Замена includes на joins решает эту проблему, но не загружает ассоциации:

Post.joins(:authors).where("authors.name" => "alice").first.authors
#=> [#<Author id: 1, name: "alice", ...>, #<Author id: 2, name: "bob", ...>]
Post.joins(:authors).where("authors.name" => "alice").first.authors.loaded?
#=> false

Какие-либо предложения? Заранее спасибо, давно ломал голову над этой проблемой.


person Chris Salzberg    schedule 09.02.2012    source источник
comment
примечание: я понимаю, что связь пост/автор более реалистично должна быть HABTM, но это ничего не меняет для целей этой проблемы.   -  person Chris Salzberg    schedule 09.02.2012


Ответы (4)


Я вижу, что вы делаете, как и ожидалось, по крайней мере, так работает SQL ... Вы ограничиваете объединение авторов тем, где author.id = 1, так зачем загружать других? ActiveRecord просто берет строки, возвращенные базой данных, и не может узнать, есть ли другие, без выполнения другого запроса на основе posts.id.

Вот одно из возможных решений с подзапросом, это будет работать как связанное отношение и выполняется в одном запросе:

relation = Post.find_by_id(id: Author.where(id:1).select(:post_id))

Если вы добавите включения, вы увидите, что запросы выполняются одним из двух способов:

relation = relation.includes(:authors)

relation.first
# 1. Post Load SELECT DISTINCT `posts`.`id`...
# 2. SQL SELECT `posts`.`id` AS t0_r0, `posts`.`title` AS t0_r1, ...

relation.all.first
# 1. SQL SELECT `posts`.`id` AS t0_r0, `posts`.`title` AS t0_r1, ...

Таким образом, в зависимости от сценария ActiveRecord решает, следует ли искать идентификатор с помощью более простого запроса, прежде чем загружать всех связанных авторов. Иногда имеет смысл выполнить запрос в 2 шага.

person Andrew Vit    schedule 22.07.2012
comment
Большое спасибо, это имеет большой смысл. Я все еще нахожу поведение рельсов в этом случае неинтуитивным, и не таким, как я думаю, ожидает средний пользователь, но, возможно, это просто неизбежно. - person Chris Salzberg; 22.07.2012

Возвращаясь к этому вопросу спустя долгое время, я понял, что есть лучший способ сделать это. Ключевым моментом является выполнение не одного, а двух объединений, одного с includes и одного с Arel с использованием псевдонима таблицы:

posts   = Post.arel_table
authors = Author.arel_table.alias("matching_authors")
join    = posts.join(authors, Arel::Nodes::InnerJoin).
                on(authors[:post_id].eq(posts[:id])).join_sources

post = Post.includes(:authors).joins(join).
            where(matching_authors: { name: "Alice" }).first

SQL для этого запроса довольно длинный, так как он имеет includes, но ключевой момент заключается в том, что он имеет не одно, а два объединения, одно (из includes) использует LEFT OUTER JOIN для псевдонима posts_authors, другое ( от Arel join), используя INNER JOIN в псевдониме matching_authors. WHERE применяется только к последнему псевдониму, поэтому результаты ассоциации в возвращаемых результатах не ограничиваются этим условием.

person Chris Salzberg    schedule 27.05.2018

Я столкнулся с той же проблемой (которую я описываю так: предложение where фильтрует связанную модель, а не основную модель, когда includes используется для предотвращения запросов N+1) .

Попробовав различные решения, я обнаружил, что с помощью preload в сочетании с joins решает эту проблему для меня. Документация по Rails здесь не очень полезна. Но, по-видимому, preload будет явно использовать два отдельных запроса, один для фильтрации/выбора основной модели, а второй запрос для загрузки ассоциированные модели. Этот сообщение в блоге также содержит некоторые идеи, которые помогли мне найти решение.

Применение этого к вашим моделям будет выглядеть примерно так:

post = Post.preload(:authors).joins(:authors).where("authors.name" => "alice").first

Я подозреваю, что под прикрытием это делает то же самое, что и ваш принятый ответ, но на более хорошем уровне абстракции.

Я бы хотел, чтобы в документации по Rails было более подробно описано, как это сделать. Это настолько тонко, что я написал кучу тестов именно для этой ситуации в своей кодовой базе.

person George Armhold    schedule 16.12.2018
comment
Спасибо за ваш комментарий! Это действительно еще один способ сделать это. Однако, просто чтобы уточнить, любое решение, которое включает более одного запроса, отличается от принятого решения. Принятое решение использует два соединения для одной и той же таблицы (одно с псевдонимом), но только один запрос. Это несколько сложно и, возможно, не обязательно будет более производительным, чем решение с двумя запросами, но оно делает то, что я изначально хотел сделать. Вы правы в том, что было бы неплохо, если бы Rails мог более подробно описать, как решать проблемы такого рода, поскольку они должны быть довольно распространенными. - person Chris Salzberg; 18.12.2018

На самом деле, это потому, что этот код:

post = Post.includes(:authors).where("authors.name" => "alice").first

возвращает первую совпавшую запись из-за «.first». Я думаю, если бы вы сделали это:

post = Post.includes(:authors).where("authors.name" => "alice")

вы бы вернули все сообщения с «Алисой» и другими ее соавторами, если я правильно понимаю, о чем вы спрашиваете.

person Community    schedule 16.07.2012
comment
Нет, это не проблема. Запрос возвращает правильные сообщения (в данном случае те, где Алиса является одним из авторов). Но когда я получаю доступ к результатам этого запроса, ассоциация авторов в возвращенном сообщении (в данном случае первая, но это не имеет значения) имеет только автора, которого я искал, даже если могут быть другие. - person Chris Salzberg; 19.07.2012