Rails 7: has_many :through関連付けにdisable_joins: trueオプションが追加(翻訳)
私たちはRailsアプリケーションで、マルチプルデータベースを多用しています。Rails 6からマルチプルデータベースへの接続が簡単に行えるようになりました。
ここで以下のユースケースを考えてみましょう。
- 1人のユーザーは複数の記事(post)を作成できる
- 記事を見ているユーザーは誰でも記事にコメントを付けられる(いわゆるcroud-sourcedなコメント)
この場合コメントが急速に増加する可能性があり、別の種類のデータ管理アプローチが必要になるかもしれないので、croud-sourcedなコメントを別のデータベースに保存することが考えられます。
この場合のdatabase.ymlは以下のようになるでしょう。
default: &default
adapter: postgresql
encoding: unicode
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
username: root
password: password
development:
primary:
<<: *default
database: rails_demo_development
crowd_sourced:
<<: *default
migrations_paths: db/migrate_crowd_sourced
database: rails_demo_crowd_sourced_development
モデルは以下のようになります。
# app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
connects_to database: { writing: :primary, reading: :primary }
end
# app/models/crowd_sourced_record.rb
class CrowdSourcedRecord < ActiveRecord::Base
self.abstract_class = true
connects_to database: { writing: :crowd_sourced, reading: :crowd_sourced }
end
# app/models/user.rb
class User < ApplicationRecord
has_many :posts
end
# app/models/user.rb
class Post < ApplicationRecord
belongs_to :user
has_many :comments
end
# app/models/comment.rb
class Comment < CrowdSourcedRecord
end
変更前
Rails 6で、あるユーザーが持つすべての記事に付けられたコメントをフェッチする場合は、以下のようにカスタムメソッドを追加することになります(データベースをまたぐ関連付けをJOINできないため)。
# app/models/user.rb
class User < ApplicationRecord
has_many :posts
def comments
Comment.where(post_id: posts.pluck(:id))
end
end
この場合、最初にユーザーが作成した記事idを取得し、続いてそれらの記事idに対応するすべてのコメントを取得しています。
> User.first.comments
User Load (0.3ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT $1 [["LIMIT", 1]]
(0.2ms) SELECT "posts"."id" FROM "posts" WHERE "posts"."user_id" = $1 [["user_id", 1]]
Comment Load (0.3ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = $1 /* loading for inspect */ LIMIT $2 [["post_id", 1], ["LIMIT", 11]]
=> #<ActiveRecord::Relation [#<Comment id: 1, post_id: 1, name: "Chantay Hayes", email: "ronna.dooley@friesen.us"...>]>
関連付けが異なるデータベース間にまたがる場合は、常に上記のようにカスタムメソッドを追加する必要があります。
変更後
Rails 7では、マルチプルデータベースのJOIN問題をエレガントに解決する方法として、has many through
の関連付けにあるオプションが追加されました(41937)。新しく追加されたdisable_joins: true
オプションは、以下のようにhas_many :through
関連付けと組み合わせられます。
class User < ApplicationRecord
has_many :posts
has_many :comments, through: :posts, disable_joins: true
end
これで上の関連付けを読み込めば、「記事idを読み込むクエリ」と「その記事idを持つコメントを読み込むクエリ」がそれぞれ実行されます。
> User.first.comments
User Load (0.3ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT $1 [["LIMIT", 1]]
Post Pluck (0.3ms) SELECT "posts"."id" FROM "posts" WHERE "posts"."user_id" = $1 [["user_id", 1]]
Comment Load (0.3ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = $1 /* loading for inspect */ LIMIT $2 [["post_id", 1], ["LIMIT", 11]]
=> #<ActiveRecord::Associations::CollectionProxy [#<Comment id: 1, post_id: 1, name: "Chantay Hayes", email: "ronna.dooley@friesen.us", .....]>
これにより、コメントをeager loadingできるようになります。
ただし、以下のように複数のユーザーをイテレートしてコメントを読み込もうとすると、ユーザーごとに記事idをSELECTする追加のクエリも発生します。
> users = User.limit(5).includes(:comments).load
User Load (0.2ms) SELECT "users".* FROM "users" LIMIT $1 [["LIMIT", 5]]
Post Load (0.2ms) SELECT "posts".* FROM "posts" WHERE "posts"."user_id" IN ($1, $2, $3, $4, $5) [["user_id", 1], ["user_id", 2], ["user_id", 3], ["user_id", 4], ["user_id", 5]]
Comment Load (0.2ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" IN ($1, $2, $3, $4, $5) [["post_id", 1], ["post_id", 2], ["post_id", 3], ["post_id", 4], ["post_id", 5]]
=> #<ActiveRecord::Relation [#<User id: 1, name: "Milton Dicki", email: "lyman@heller.us", created_at: "2021-04-20 17:13:20.492541000 +0000", updated_at: "2021-04-20 17:13:20.492541000...
> users.collect &:comments
Post Pluck (0.2ms) SELECT "posts"."id" FROM "posts" WHERE "posts"."user_id" = $1 [["user_id", 1]]
Post Pluck (0.1ms) SELECT "posts"."id" FROM "posts" WHERE "posts"."user_id" = $1 [["user_id", 2]]
Post Pluck (0.1ms) SELECT "posts"."id" FROM "posts" WHERE "posts"."user_id" = $1 [["user_id", 3]]
Post Pluck (0.1ms) SELECT "posts"."id" FROM "posts" WHERE "posts"."user_id" = $1 [["user_id", 4]]
Post Pluck (0.1ms) SELECT "posts"."id" FROM "posts" WHERE "posts"."user_id" = $1 [["user_id", 5]]
=> [#<ActiveRecord::Associations::CollectionProxy [#<Comment id: 1, post_id: 1, name: "Chantay Hayes", email: "ronna.dooley@friesen.us">.......]>]
なお、以下の回避方法を用いれば、関連する記事をメモリに読み込むコストと引き換えにこのN+1クエリの問題を解決できます。
> users = User.limit(5).includes(posts: :comments).load
User Load (0.2ms) SELECT "users".* FROM "users" LIMIT $1 [["LIMIT", 5]]
Post Load (0.3ms) SELECT "posts".* FROM "posts" WHERE "posts"."user_id" IN ($1, $2, $3, $4, $5) [["user_id", 1], ["user_id", 2], ["user_id", 3], ["user_id", 4], ["user_id", 5]]
Comment Load (0.3ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" IN ($1, $2, $3, $4, $5) [["post_id", 1], ["post_id", 2], ["post_id", 3], ["post_id", 4], ["post_id", 5]]
=> #<ActiveRecord::Relation [#<User id: 1, name: "Milton Dicki", email: "lyman@heller.us", created_at: "2021-04-20 17:13:20.492541000 +0000", updated_at: "2021-04-20 17:13:20.492541000...
> users.collect { |u| u.posts.collect(&:comments) }.flatten
=> [#<Comment id: 1, post_id: 1, name: "Chantay Hayes", email: "ronna.dooley@friesen.us", comment: "Bad song!".....>]
概要
原著者の許諾を得て翻訳・公開いたします。