Ruby on RailsでのJOIN活用をわかりやすく解説

はじめに

Ruby on Railsでアプリケーションを作るとき、データ同士を結び付けて活用する場面は多いですよね。 記事とコメント、ユーザーと投稿、注文と商品など、いろいろなテーブルを結び付けて表示したい場面があるのではないでしょうか。 そんなときに役立つのが、JOIN というデータベースのテーブル結合機能です。 Railsにおいては、Active Recordのクエリメソッドを使って、JOINをわかりやすい形で書くことができます。 しかし、実務でJOINを使うときにどのメソッドを使えばいいのか、どう書けば意図したデータが取得できるのかが分からないという方もいるかもしれません。 今回は初心者の皆さんにも理解しやすいよう、JOINを使ったActive Recordの活用シーンや書き方を具体例とともに紹介していきます。

この記事を読むとわかること

  • JOINを使う理由と、実務で役立つ具体的なシーン
  • RailsのActive RecordでJOINを記述する方法
  • 等価結合以外の結合や、テーブル別名を活用する方法
  • JOIN関連のよくあるトラブルと対策
  • 実務での注意点やパフォーマンス面のヒント

JOINを使うメリットとは

RailsのJOINは、複数のテーブルをまとめて検索できる という特長があります。 単純にテーブルを横並びにするだけでなく、リレーションを活かして効率的に必要なデータを取得することができます。

データ取得の効率化

例えば、ユーザー投稿 の2つのテーブルがあり、それぞれに紐づくコメント数やタグ情報なども取得したいとします。 もしテーブルを個別にSELECTしていたら、ユーザー情報を取得してから投稿を取得して、さらにコメントやタグを取得して…というふうに何度もクエリを発行する必要があるかもしれません。 JOINを使えば1回のクエリで関連情報をまとめて取り出すことができるので、データ取得の効率化につながります。

実務での活用シーン

JOINを使うシーンとして、たとえば「注文一覧で、注文者の名前や配送先の情報を一覧表示する」といった場面があります。 ECサイトを想像すると、orders テーブルに対して usersaddresses など複数のテーブルが紐づくことが多いですよね。 そこでJOINを使って、ひとまとめに表示できるようにすると処理がスムーズになることが多いです。

どのようなJOINがあるか

JOINには主に以下の種類があります。

  • INNER JOIN:結合キーが両方のテーブルで一致するデータのみ取得
  • LEFT OUTER JOIN:左側のテーブルを基準に、右側のテーブルとの一致がなくても左側の行をすべて取得
  • RIGHT OUTER JOIN:右側のテーブルを基準にする結合(あまりRailsでは多用されない)
  • FULL OUTER JOIN:両テーブルの全行を取得する(DBの種類によってはサポートしていない場合もあります)

RailsのActive Recordでは、joinsleft_joins を使うことで、INNER JOINやLEFT OUTER JOINを簡単に書くことができます。

Active RecordでのJOINの基本

RailsでJOINをする場合、よく使われるのが joins メソッドです。 Active Recordのリレーションメソッドとして提供されているため、テーブル名や関連付け名を指定するだけでシンプルにJOINを実行できます。

基本的な構文

たとえば、PostモデルとUserモデルが「1対多」の関係で、PostがUserに属している場合を考えます。 データベース上では「postsテーブル」に「user_id」カラムがある状態です。

# Post.rb
class Post < ApplicationRecord
  belongs_to :user
end

# User.rb
class User < ApplicationRecord
  has_many :posts
end

このとき、PostをUserの情報と合わせて取得したい場合に、以下のように書きます。

# Postモデルに対してUserテーブルをJOINする例
posts = Post.joins(:user)

これにより、Railsが内部的に SELECT "posts".* FROM "posts" INNER JOIN "users" ON "users"."id" = "posts"."user_id" のようなクエリを発行します。

LEFT OUTER JOINを使う場合

もしLEFT OUTER JOINのように、結合されるデータがなくても左のテーブルの行をすべて取得したい場合は、以下のように書きます。

posts = Post.left_joins(:user)

結果として、ユーザーが存在しない投稿であっても取得することができます。 これもActive Recordがうまくやってくれるので、クエリを書き換える手間が少なくて便利ですね。

JOINで参照するテーブルを増やす

実際のアプリケーションでは、PostとUserだけでなく、さらにCommentやTagなど別の関連テーブルも一緒に取得したい場合がありますよね。 JOINをネストすることで、複数の関連テーブルをまとめて取得することができます。

belongs_toが複数ある例

例えば、Comment モデルが Post に属しており、さらに PostUser に属しているケースを見てみましょう。

# Comment.rb
class Comment < ApplicationRecord
  belongs_to :post
end

# Post.rb
class Post < ApplicationRecord
  belongs_to :user
  has_many :comments
end

# User.rb
class User < ApplicationRecord
  has_many :posts
end

CommentモデルからUser情報までたどりたいときは、以下のように書きます。

comments = Comment.joins(post: :user)

これでCommentからPost、さらにUserへとつながるテーブルがJOINされます。 テーブルを多段階に結合できるので、複雑なリレーションも1回のクエリで一気に取得することができます。

has_manyで多段階JOINする

一方で、User モデルからCommentまでをJOINで取得したい場合には、次のように書きます。

users = User.joins(posts: :comments)

これにより、User -> Post -> Comment の順にJOINが実行されます。 INNER JOINの場合は、コメントがない投稿や、投稿がないユーザーは結果に含まれなくなるので注意が必要です。

JOINを使った条件検索

JOINの良いところは、結合先テーブルのカラムを条件にして検索することができる点です。 例えば、ユーザー名を部分一致検索して該当する投稿データを引っ張ってくる、というようなこともシンプルに実現できます。

具体例:ユーザーの名前を条件に検索

Userテーブルのnameカラムから、"Alice" を含む投稿だけを取得してみましょう。

posts = Post.joins(:user)
            .where("users.name LIKE ?", "%Alice%")

Railsは結合されるテーブルのカラムをSQLのプレースホルダとして扱うため、実行されるクエリは下記のようになります。

SELECT "posts".* FROM "posts"
INNER JOIN "users" ON "users"."id" = "posts"."user_id"
WHERE (users.name LIKE '%Alice%')

テーブル名の省略に注意

where句を書いているときに、同名のカラムが異なるテーブルに存在することがあります。 その場合、クエリがあいまいになると、データベース側でエラーが発生するか、意図しない結果になる可能性があります。 JOINで複数テーブルを結合するときは、"テーブル名.カラム名" という形で明示する習慣を付けると混乱を防ぎやすいです。

テーブル別名を使う

場合によっては、JOINされるテーブルに別名(エイリアス)を付けたほうが読みやすくなるケースがあります。 Active Recordでも joinsreferences で、SQLのAS句に相当する別名を付けることができます。

Railsでの書き方

実際のSQLでは JOIN users AS u のように書くことがありますが、Railsの場合はもう少し抽象化されています。 複雑なクエリを書くときや、同じテーブルを複数回JOINする必要があるときなど、Arel を直接使ってエイリアスを設定する方法が考えられます。 ただ、初心者の方にはArelでの記述は少しハードルが高いかもしれませんので、最初は可能な限り普通のリレーション名を利用し、複雑になってきたらArelやSQLフラグメントを検討すると良いでしょう。

Arelを使ったJOIN例

少し応用ですが、参考としてArelを使った書き方を簡単に見てみます。 ここではUserモデルに対して別名"u"を付けてJOINするイメージです。

# Postモデルのテーブルを取得
post_table = Post.arel_table
# Userモデルのテーブルを取得
user_table = User.arel_table

# Userテーブルに"u"という別名を付けてJOINするArel構文
join_condition = post_table[:user_id].eq(user_table[:id])
join_alias = Arel::Nodes::TableAlias.new(user_table, Arel.sql("u"))

relation = Post.joins(
  post_table.join(join_alias).on(join_condition).join_sources
)

posts_with_user_alias = relation.select("posts.*", "u.name AS user_name")

こうしてテーブル別名を活用すると、SQLが複雑になる場合でも可読性をある程度維持できます。 ただし、一気に難度が上がるため、はじめはActive Recordのシンプルな書き方に慣れてからで大丈夫です。

JOINとincludesの違い

Railsで関連テーブルを読み込むとき、joins 以外にも includeseager_load といったメソッドがありますよね。 これらはN+1問題 を回避する目的でよく用いられ、裏側ではJOINやLEFT JOINが使われることがあります。

includesはJOINではなくプリロード

たとえば、PostとUserを同時に取得したいときに Post.includes(:user) と書きます。 するとRailsは、投稿データを取得したあと、ユーザー情報を別クエリでもう一度取得するというように、2回のクエリを発行することがあります。 これがいわゆるプリロードの仕組みで、JOINとは少し違ったアプローチを取ります。

eager_loadやpreloadとの使い分け

eager_load は内部でLEFT OUTER JOINを使い、preload は2回クエリを発行してデータを取得する、という違いがあります。 基本的に、関連テーブルの条件検索をしたい場合は joins、関連テーブルをまとめて取得してN+1問題を防ぎたいだけなら includespreload を使うというイメージです。 必要なシーンに応じて使い分けると良いですね。

JOINを使う際の注意点

JOINはとても便利ですが、いくつか気を付けるべきポイントがあります。

結合結果の重複

INNER JOINを使うと、結合先テーブルに紐づくデータの数だけ、メインの行が重複してしまうことがあります。 例えば、1つの投稿にコメントが5件ある場合は、その投稿が5行に重複して並ぶことになるわけです。 この重複データをそのまま扱うと、ビューで重複表示が起こったり、集計に不備が生じたりするので注意が必要ですね。

LEFT JOINではNULLが含まれる可能性

LEFT JOINやLEFT OUTER JOINを使うと、結合先のテーブルが存在しない行については、NULLが入るカラムが発生します。 アプリケーション側でNULLチェックを忘れているとエラーが出たり、不用意にエラーをスローしてしまうこともあります。 結果セットのカラム値がNULLになる場合があることを念頭に置いておきましょう。

JOINした結果、結合先のカラムがNULLになるケースを想定してコードを記述しておくことが大切です。

パフォーマンスに注意

JOINを安易に使いすぎると、テーブル間のデータ量が大きいときにクエリが重くなる可能性があります。 とくに複数の大きなテーブルを結合する場合は、ビューの表示速度が低下するかもしれません。 業務システムでは、インデックスを貼るなどのパフォーマンス対策が求められる場面があります。

JOINにおける実務例

ここでは実務でありがちな例として、ECサイトの受注管理 を考えてみましょう。 orders テーブルに対して usersorder_itemsproducts などが関連しているイメージです。

ORDERとUSERをJOINして購入者名を表示

例えば、注文履歴一覧ページで購入者の名前やメールアドレスをまとめて表示したいときは、こんな感じになります。

# orders.rb
class Order < ApplicationRecord
  belongs_to :user
  has_many :order_items
end

# users.rb
class User < ApplicationRecord
  has_many :orders
end

orders = Order.joins(:user).select(
  "orders.id, orders.total_price, users.name AS purchaser_name, users.email AS purchaser_email"
)

これで、注文情報と一緒にユーザーの名前やメールアドレスを1回のクエリで取得できます。

ORDER_ITEMSとPRODUCTSをJOINして商品情報を引き出す

さらに、注文明細(order_items)を商品テーブル(products)とJOINして、商品名や単価を取得したい場合もありますよね。 下記は、注文詳細ページやダッシュボードなどで使いそうなイメージです。

# order_items.rb
class OrderItem < ApplicationRecord
  belongs_to :order
  belongs_to :product
end

# products.rb
class Product < ApplicationRecord
  has_many :order_items
end

order_items_with_products = OrderItem.joins(:product).select(
  "order_items.id, order_items.quantity, products.name AS product_name, products.price AS product_price"
)

こうすることで、order_itemsproducts がJOINされ、結果セットに商品名や価格が含まれます。 実務では、これを更に orders ともJOINして一度に取得する場面があるでしょう。

JOINで複数テーブルを結合する場合のSQLフラグメント

RailsのActive Recordは基本的に抽象化されているので、複雑な条件の結合でもわりとスムーズに書けます。 ただ、どうしても複雑すぎる結合や、特定のDB機能を使った条件を付けるときはSQLフラグメントを使うことがあります。

例:3つ以上のテーブルを結合して複数条件を設定する

たとえば以下のように、joins の中で文字列を使い、直接SQLを書く方法があります。

order_summary = Order.joins("
  INNER JOIN users ON users.id = orders.user_id
  LEFT JOIN order_items ON order_items.order_id = orders.id
  INNER JOIN products ON products.id = order_items.product_id
").select("
  orders.id AS order_id,
  users.name AS user_name,
  products.name AS product_name
").where("products.active = ?", true)

あまりに複雑になる場合は可読性が落ちるため、モデルの関連を活かしたJOINの書き方を優先し、どうしても必要な時のみフラグメントを活用するほうがおすすめです。

JOINを使ったデータ更新や削除

JOINを使ってデータを取得した後に、レコードを更新・削除したい場面もあるかもしれません。 RailsのActive Recordでは、JOINリレーションがかかったオブジェクトをそのまま更新しようとすると思わぬ動きをすることがあります。

Active Recordオブジェクトとしての取り扱い

Post.joins(:user) などで取得した結果は、基本的には Post モデルのオブジェクト一覧になります。 そのまま update メソッドなどで更新すると、結合先のテーブルであるUserモデルの情報は更新されません。 JOIN後に取得したActive Recordオブジェクトは、JOINしていない独立のモデル(ここではPost)のインスタンスと考えてください。

結合先テーブルのレコード変更

一方、結合先テーブルのUserレコードなどを更新したい場合は、usersテーブル へ直接アプローチする必要があります。 User.where(...) のように条件を書いて、該当のUserレコードを更新します。 JOINを使ってデータ取得しているからといって、結合先もまとめて更新できるわけではない点に注意が必要です。

パフォーマンスとインデックスの考え方

JOINを多用すると、DBの負荷が気になってくることはありませんか。 複数テーブルを結合しているので、それぞれの結合キーにインデックスが張られていないと、検索速度が遅くなりやすいです。

主キーや外部キーにインデックスを付ける

Railsのmigrationファイルで、user_idproduct_id といった外部キーを設定する際にインデックスを付与することを考えてみてください。 たとえば、t.references :user, foreign_key: true, index: true のような書き方でマイグレーションを作成すると、JOIN時の検索パフォーマンスが改善する場合があります。

大量データを扱うシーン

実務では、数百万件のデータを抱える大規模テーブルをJOINするケースもありますよね。 そういった場合には、適切なWHERE句LIMIT句 を組み合わせて、必要なデータだけを取得する工夫をすることでパフォーマンスをコントロールできます。 ビュー側ではページネーションを使って、一度に大量のレコードを表示しないようにするなどの設計も考えられます。

複数テーブルをJOINする場合は、外部キーにインデックスを付け、必要に応じてWHERE句やLIMIT句を使って絞り込むとパフォーマンスを維持しやすいです。

JOINのよくあるトラブルシューティング

最後に、JOINを使っているとよく起こるトラブルと、その対策を簡単にまとめてみます。

同名カラムの衝突

複数のテーブルに同じ名前のカラムがあるとき、Railsがどちらのカラムを取得するか分からずエラーや想定外の動きが起こることがあります。 この場合、select メソッドで別名をつけるか、テーブル名を明示して衝突を避けることが大事です。

JOINするたびにオブジェクト数が増える

リレーションをJOINでどんどんつなげていくと、想定以上に結果のレコードが増えてしまうことがあります。 実際にはモデルオブジェクトが重複して返ってきて、View側で繰り返し表示されてしまうケースがあるので、distinct メソッドなどを使って重複排除を行う方法があります。

posts = Post.joins(:comments).distinct

こうすると、投稿が重複して返ってくるのを防げます。

LEFT JOINでNULLを扱う際の例外

Railsのバージョンによっては、JOINした結果NULLが混ざっていることで予期せぬ例外が発生することがあります。 基本的には、User.joins(:profile) のような関係を扱う場合に、関連が必須ではないなら left_joins を使いつつ、NULLチェックを丁寧に行うと安全です。

まとめ

RailsでのJOINは、テーブル同士を結合して複数のデータを一気に扱える便利な仕組みです。 ユーザー情報や投稿情報をまとめて取得する、ECサイトで注文履歴と商品情報をまとめて引き出すなど、実務でも大いに活躍します。

ただし、JOINは使い方を誤ると重複データやパフォーマンスの問題を引き起こす可能性があります。 そのため、joinsincludes の使い分けを理解し、必要に応じてLEFT JOINやSQLフラグメントなどを活用することが大切ですね。

また、実務では複数テーブルを扱うときにインデックスを貼ることや、重複データを排除する方法など、いろいろなポイントを意識するとスムーズに開発が進みやすいです。

JOINはテーブルのスキーマ設計やリレーションの理解が深まるほど、より活用の幅が広がります。 初心者の皆さんも、まずは1対多の単純な結合から始めて、慣れてきたら複数テーブルや複雑な結合に挑戦してみてはいかがでしょうか。

Rubyをマスターしよう

この記事で学んだRubyの知識をさらに伸ばしませんか?
Udemyには、現場ですぐ使えるスキルを身につけられる実践的な講座が揃っています。