Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails 7: has_many :through関連付けにdisable_joins: trueオプションが追加(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

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!".....>]

関連記事


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。