はじめに
実運用するRailsアプリケーションに、テーブルが1つだけということはあまりないでしょう。
ほとんどの場合、いくつかのテーブルを持って、それらが関連したデータベース構成になっているでしょう。
そういった構造のデータベースから必要なデータを取得する場合に使われるメソッドの1つが、joinsメソッドです。
joinsメソッドを利用することで、関連するテーブルからデータを取得する(ように見える)ので、その後の処理がすっきりと分かりやすくなります。
今回は、そんなjoinsメソッドについて、詳しく解説いたします。
新たなキャリアへの第一歩!正社員エンジニアを目指しませんか?
転職成功率98%を誇る【DMM WEBCAMP】は
✔経済産業省認定の充実したカリキュラム
✔条件を満たすことで受講料最大70%オフ
✔受講生の97%が未経験者!
\参加満足度99%!/
まずは気軽に無料のキャリア相談へ!
※最短1分で申し込み可能
joinsの動き
最初に、joinsメソッドがどのような動きでデータベースからデータを取得してくるのかを説明しましょう。
テスト環境の構築
説明を簡単にするために、具体的なRailsアプリケーションの環境とデータを準備します。
コマンドプロンプトにて、以下のコマンドを実行してください。(scaffoldの詳細は「【Rails入門説明書】scaffoldについて解説」を参照してください)
rails new joins_test cd joins_test rails generate scaffold Seller name:string rails generate scaffold Item seller_id:integer title:string price:integer rake db:migrate
以上の操作で、販売者のテーブルと、商品のテーブルが生成されました。次に、テーブル間の関連付けを行うため、モデルクラスにhas_many(参照される側)とbelongs_to(参照する側)の追加を行います。
(has_manyについては、「【Rails入門説明書】has_manyについて解説」を参照してください)
(app/models/seller.rb)
class Seller < ApplicationRecord has_many :items end
(app/models/item.rb)
class Item < ApplicationRecord belongs_to :seller end
※(商品は1人の販売者しか参照しませんので、sellerは単数形になります。注意してください)
また、今回はテーブル間の関連を確認するため、ビューファイルを修正して、レコードのIDが表示されるようにします。
(app/views/items/index.html.erb)
: <table> <thead> <tr> <th>ID</th> <!-- (1) --> <th>Seller</th> <th>Title</th> <th>Price</th> <th colspan="3"></th> </tr> </thead> <tbody> <% @items.each do |item| %> <tr> <td><%= item.id %></td> <!-- (2) --> <td><%= item.seller.name %></td> <!-- (3) --> <td><%= item.title %></td> :
(app/views/sellers/index.html.erb)
: <table> <thead> <tr> <th>ID</th> <!-- (1) --> <th>Name</th> <th colspan="3"></th> </tr> </thead> <tbody> <% @sellers.each do |seller| %> <tr> <td><%= seller.id %></td> <!-- (2) --> <td><%= seller.name %></td> :
それぞれのファイルについて、(1)と(2)を追記してください。
また、itemsは(3)の行を修正以下のように修正します。
item.seller_id → item.seller.name
この変更で、itemの一覧表示画面で、登録されたseller_idに対応する名前を表示されるようになるのです。
これでRailsアプリケーションの環境ができましたので、次はデータです。データはrails consoleでまとめて導入しましょう。
コマンドプロンプトで、以下を順に実行してください。
rails c Seller.create(name:"Tanaka") Seller.create(name:"Ohashi") Seller.create(name:"Kimura") Seller.create(name:"Takahashi") Seller.create(name:"Ogawa") Seller.create(name:"Kobayashi") Item.create(seller_id:3,title:"book", price:400) Item.create(seller_id:2,title:"cap", price:8000) Item.create(seller_id:4,title:"shoes", price:1500) Item.create(seller_id:2,title:"ring", price:12000) Item.create(seller_id:1,title:"ring", price:50000) Item.create(seller_id:5,title:"computer", price:150000) Item.create(seller_id:2,title:"shirts", price:1000) Item.create(seller_id:5,title:"mouse", price:100) exit
これで、データも揃いました。
「rails s」でサーバーを起動して「localhost:3000/sellers」と「localhost:3000/items」にアクセスしてみましょう。
なお、この後の確認が簡単になりますので、それぞれ別のウインドウで表示しておくことをおすすめします。
では、joinsメソッドの動きを説明しましょう。
まず、改めて「localhost:3000/items」を読み込んでください。そして、今度はブラウザの画面ではなく、サーバーを実行しているコマンドプロンプトに目を移します。
そこには、以下のようなログが表示されていることでしょう。
Processing by ItemsController#index as HTML Rendering items/index.html.erb within layouts/application Item Load (0.0ms) SELECT "items".* FROM "items" └app/views/items/index.html.erb:17 Seller Load (0.0ms) SELECT "sellers".* FROM "sellers" WHERE "sellers"."id" = ? LIMIT ? [["id", 3], ["LIMIT", 1]] └app/views/items/index.html.erb:20 Seller Load (0.9ms) SELECT "sellers".* FROM "sellers" WHERE "sellers"."id" = ? LIMIT ? [["id", 2], ["LIMIT", 1]] └app/views/items/index.html.erb:20 Seller Load (0.0ms) SELECT "sellers".* FROM "sellers" WHERE "sellers"."id" = ? LIMIT ? [["id", 4], ["LIMIT", 1]] └app/views/items/index.html.erb:20 CACHE Seller Load (0.0ms) SELECT "sellers".* FROM "sellers" WHERE "sellers"."id" = ? LIMIT ? [["id", 2], ["LIMIT", 1]] └app/views/items/index.html.erb:20 Seller Load (1.0ms) SELECT "sellers".* FROM "sellers" WHERE "sellers"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]] └app/views/items/index.html.erb:20 Seller Load (0.0ms) SELECT "sellers".* FROM "sellers" WHERE "sellers"."id" = ? LIMIT ? [["id", 5], ["LIMIT", 1]] └app/views/items/index.html.erb:20 CACHE Seller Load (0.0ms) SELECT "sellers".* FROM "sellers" WHERE "sellers"."id" = ? LIMIT ? [["id", 2], ["LIMIT", 1]] └app/views/items/index.html.erb:20 CACHE Seller Load (1.0ms) SELECT "sellers".* FROM "sellers" WHERE "sellers"."id" = ? LIMIT ? [["id", 5], ["LIMIT", 1]] └app/views/items/index.html.erb:20
ここで注目すべきなのは、3行目の「SELECT “items”.* FROM “items”」というSQLです。
これは、itemコントロールのindexメソッドにある以下のプログラムコードが、SQLに変換された結果です。
(app/controllers/items_controller.rb)
: def index @items = Item.all end :
つまり、allメソッドを利用することで、itemテーブルのすべての要素を抽出する(SELECT)SQLを生成するわけです。
では次に、allメソッドをjoinsメソッドへ変更してみましょう。
(app/controllers/items_controller.rb)
: def index # @items = Item.all # コメントアウト @items = Item.joins(:seller) # 追記 end :
そして、再度ブラウザで「localhost:3000/items」を読み込み、コマンドプロンプトのログを見てみましょう。(ちなみに、ブラウザ上の表示に変化はありません)
Processing by ItemsController#index as HTML Rendering items/index.html.erb within layouts/application Item Load (1.0ms) SELECT "items".* FROM "items" INNER JOIN "sellers" ON "sellers"."id" = "items"."seller_id" └app/views/items/index.html.erb:17 Seller Load (1.0ms) SELECT "sellers".* FROM "sellers" WHERE "sellers"."id" = ? LIMIT ? [["id", 3], ["LIMIT", 1]] └app/views/items/index.html.erb:20 Seller Load (0.0ms) SELECT "sellers".* FROM "sellers" WHERE "sellers"."id" = ? LIMIT ? [["id", 2], ["LIMIT", 1]] └app/views/items/index.html.erb:20 Seller Load (1.0ms) SELECT "sellers".* FROM "sellers" WHERE "sellers"."id" = ? LIMIT ? [["id", 4], ["LIMIT", 1]] └app/views/items/index.html.erb:20 CACHE Seller Load (1.0ms) SELECT "sellers".* FROM "sellers" WHERE "sellers"."id" = ? LIMIT ? [["id", 2], ["LIMIT", 1]] └app/views/items/index.html.erb:20 Seller Load (1.0ms) SELECT "sellers".* FROM "sellers" WHERE "sellers"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]] └app/views/items/index.html.erb:20 Seller Load (0.8ms) SELECT "sellers".* FROM "sellers" WHERE "sellers"."id" = ? LIMIT ? [["id", 5], ["LIMIT", 1]] └app/views/items/index.html.erb:20 CACHE Seller Load (0.0ms) SELECT "sellers".* FROM "sellers" WHERE "sellers"."id" = ? LIMIT ? [["id", 2], ["LIMIT", 1]] └app/views/items/index.html.erb:20 CACHE Seller Load (0.0ms) SELECT "sellers".* FROM "sellers" WHERE "sellers"."id" = ? LIMIT ? [["id", 5], ["LIMIT", 1]] └app/views/items/index.html.erb:20
こちらも、3行目のSQLに注目してください。
allメソッドで生成されたSQLより少し複雑になって、以下の処理が追加されています。
「INNER JOIN “sellers” ON “sellers”.”id” = “items”.”seller_id”」
このSQLは、「sellerテーブルのidとitemテーブルのseller_idが等しい、sellerテーブルレコードを内部結合する」という意味です。
つまり、allメソッドとjoinsメソッドでは、表示される結果は同じですが、内部的に以下のような違いがあります。
・allメソッド :itemテーブルのすべてのレコードを抽出する
・joinsメソッド:itemテーブルに登録されているseller_idと等しいidを持つsellerテーブルのレコードを内部結合した結果を抽出する
「内部結合」というのは、簡単に説明すると「関連する双方のテーブルにデータがあるレコードだけを結合する」ということです。つまり、idが一致するsellerテーブルのデータがitemテーブルにくっついたテーブルを作り出しているということです。
内部結合
例えば、今回の場合、itemテーブルとsellerテーブルの内部結合は、次のようになります
itemテーブルのseller_idとidが一致するsellerテーブルのレコードが結合されています。そして、このときitemテーブルで使用されていない「id:6」「Kobayashi」のレコードは結合されていません。
これが、「関連する双方のテーブルにデータがあるレコードだけを結合する」ということです。
なお、どちらか一方にデータがあるレコードを結合する方法を「外部結合」と呼びます。
joinsとallの具体的な違い
今のままでは、allメソッドとjoinsメソッドに明確な違いはないように見えます。
しかし、実際には大きな違いがありますので、分かりやすくプログラムコードを修正してみましょう。
絞り込む対象は、「販売者名が”Ohashi”」のレコードです。
まずは、allメソッドの場合の絞り込みを試してみます。
(app/controllers/items_controller.rb)
: def index @items = Item.all.where(["sellers.name = ?", "Ohashi"]) # 変更 # @items = Item.joins(:seller) # コメントアウト end :
残念ながら、エラーが出てしまいます。
これは、itemテーブルにsellers.nameなどというカラムがないからです。
最初に説明したとおり、allメソッドは指定のテーブルのレコードをすべて取得するだけです。この段階ではsellerテーブルのデータはありませんので、当然のようにカラムが見つからないわけです。
※ブラウザ上で販売者の名前が表示されていますが、これはビューで販売者名を表示するプログラムコードがあり、そのときに改めてSQLが生成されて、データベースから読み込んでいるのです
そのため、もしallメソッドを使って販売者名で絞り込むのであれば、seller_idを指定して、以下のようにする必要があります。
(app/controllers/items_controller.rb)
: def index @items = Item.all.where(["seller_id = ?", 2]) # "Ohashi"のid # @items = Item.joins(:seller) # コメントアウト end :
前述のとおり、joinsメソッドはテーブルを結合しています。そのため、allメソッドのようにわざわざseller_idを指定する必要がありませんので、さきほどallメソッドでエラーが出たwhereのプログラムコードを使うことができます。
(app/controllers/items_controller.rb)
: def index # @items = Item.all.where(["seller_id = ?", 2]) # "Ohashi"のid コメントアウト @items = Item.joins(:seller).where(["sellers.name = ?", "Ohashi"]) # "Ohashi"で絞り込み end :
このように、joinsは関連するテーブルのデータを使って絞り込みを行うときに、その真価を発揮するのです。
joinsの使い方
joinsメソッドの使い方を、構文から整理しておきましょう。
構文
joinsメソッドの構文は、とてもシンプルです。
モデル.joins(テーブル名のシンボル,,,)
joinsメソッドはモデルクラスのメソッドですので、モデルが保持しているテーブルを主にして考えます。つまり、モデルが保持しているテーブルのカラムにあるidと、引数として渡されたテーブルのレコードのidを比較して、一致したものを内部結合するのです。
また、テーブルは複数渡すことも可能です。
例えば、itemテーブルとsellerテーブルの他に、buyerテーブルがあったとして、itemテーブルにsellerとbuyerのidを持っていれば、以下のようにして合計3つのテーブルを内部結合することができます。
Item.joins(:comment, :buyer)
同様に、ネストした結合も可能です。テーブルのネストというのは、AテーブルをBテーブルが参照し、BテーブルをCテーブルが参照するといった、連続して参照しているような関連のことです。
その場合、次のような記載になります。
C.joins(:b => :a)
「新たなキャリアへの第一歩!」
転職成功率98%を誇る【DMM WEBCAMP】は
✔働きながら正社員エンジニアを目指せる
✔通学型とオンライン型どちらでも受講可能
✔受講生の97%が未経験者!
\参加満足度99%!/
まずは気軽に無料のキャリア相談へ!
※最短1分で申し込み可能
joinsのメリット・デメリット
前述までの説明で、関連のあるテーブルを扱う場合は、joinsを使った方が良いことが分かりました。
しかし、残念ながら大きなデメリットもありますので、安易に使うのは避けた方が良いときもあります。
ここでは、そんなjoinsメソッドのデメリットを紹介し、そこから改めてメリットを説明しましょう。
デメリット:N+1問題
joinsメソッドのもっとも大きなデメリットが、「N+1問題」です。
先ほどから何度か確認している、joinsメソッド実行時のログを、改めて確認してみましょう。
Processing by ItemsController#index as HTML Rendering items/index.html.erb within layouts/application Item Load (1.0ms) SELECT "items".* FROM "items" INNER JOIN "sellers" ON "sellers"."id" = "items"."seller_id" └app/views/items/index.html.erb:17 Seller Load (1.0ms) SELECT "sellers".* FROM "sellers" WHERE "sellers"."id" = ? LIMIT ? [["id", 3], ["LIMIT", 1]] └app/views/items/index.html.erb:20 Seller Load (0.0ms) SELECT "sellers".* FROM "sellers" WHERE "sellers"."id" = ? LIMIT ? [["id", 2], ["LIMIT", 1]] └app/views/items/index.html.erb:20 Seller Load (1.0ms) SELECT "sellers".* FROM "sellers" WHERE "sellers"."id" = ? LIMIT ? [["id", 4], ["LIMIT", 1]] └app/views/items/index.html.erb:20 CACHE Seller Load (1.0ms) SELECT "sellers".* FROM "sellers" WHERE "sellers"."id" = ? LIMIT ? [["id", 2], ["LIMIT", 1]] └app/views/items/index.html.erb:20 Seller Load (1.0ms) SELECT "sellers".* FROM "sellers" WHERE "sellers"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]] └app/views/items/index.html.erb:20 Seller Load (0.8ms) SELECT "sellers".* FROM "sellers" WHERE "sellers"."id" = ? LIMIT ? [["id", 5], ["LIMIT", 1]] └app/views/items/index.html.erb:20 CACHE Seller Load (0.0ms) SELECT "sellers".* FROM "sellers" WHERE "sellers"."id" = ? LIMIT ? [["id", 2], ["LIMIT", 1]] └app/views/items/index.html.erb:20 CACHE Seller Load (0.0ms) SELECT "sellers".* FROM "sellers" WHERE "sellers"."id" = ? LIMIT ? [["id", 5], ["LIMIT", 1]] └app/views/items/index.html.erb:20
これは、リストを全件表示したときのログです。
ここで、9つのSQLが生成されているところがポイントです。
これらのSQLを詳しく見てみると、最初のSQLでitemテーブルを取得し、取得したレコードを表示する際に、販売者の名前を取得するためのSQLが生成されてsellerテーブルにアクセスしています。
つまり、リストを表示するのに、テーブルのレコード数+1回のデータベースアクセスが発生しているのです。そのため、テーブルが数百、数千というレコードで構成されている場合、データベースへのアクセスが膨大になり、パフォーマンスに影響が出ます。
これが、「N+1問題」です。
そのため、一覧表示などではjoinsメソッドを使うべきではありません。代わりにincludesメソッドやpreloadメソッドなどを使うようにしましょう。
なお、joinsメソッドによってN+1問題が起きる原因は、joinsメソッドが関連性を保持せず、ただ抽出するだけだからです。ただ抽出した結果を持っているだけですので、表示するためのデータを取得するために、データベースへアクセスする必要があるのです。
メリット:whereと組み合わせて
joinsメソッドのメリットは、「テーブルを結合して絞り込める」点に尽きます。そのため、一般的にjoinsメソッドはwhereメソッドと組み合わせて使うことが多くなります。
そして、前述のデメリットで説明したように、関連性を保持しないため、余計なメモリを消費しません。
この「メモリ消費せずに抽出できる」のは、関連するテーブルの数が多くなればとても効果的に働きます。
多数のテーブルと関連するテーブルを絞り込むだけなのに、すべての関連する値を保持するのは無駄なメモリ消費としか言えません。
メモリが枯渇すると、パフォーマンスに大きな影響を与えます。そのため、「関連しているテーブルの値で抽出した行数を出したい」といった「関連するテーブルの値を使わない処理」の場合は、joinsメソッドとwhereメソッドを組み合わせた方が明らかに効率が良いのです。
まとめ
Rails のjoinsメソッドについて解説しました。
joinsメソッドは、関連するテーブルを内部結合するため、関連のある複数のテーブルを扱うときに、頻繁に現れます。
しかし、説明したように、joinsメソッドにはN+1問題のリスクがありますので、その使い方には注意が必要です。
N+1問題が発生するのは、関連したテーブルの値へアクセスした場合です。その点を考慮すれば、メモリ消費の少ないjoinsメソッドの使いどころは、「関連するテーブルで絞り込んだレコードの数を表示する」、「メインテーブルのデータだけを表示する」など、いくつも見えてくることでしょう。
こういったメソッドの使い方というのは、知識よりも経験がものを言います。そのため、プログラミングスクールなど対面で説明できる機会を活用して学ぶのは、とても良い方法です。ぜひ、活用していってください。
・allメソッドは、テーブルのデータをすべて取り出す
・joinsメソッドは、関連するテーブルを内部結合したデータを取り出す
・内部結合は、関連しあう2つのテーブルの両方にデータがあるレコードだけを結合する
・外部結合は、関連しあう2つのテーブルのどちらかにデータがあるレコードを結合する
・joinsメソッドで関連するテーブルのデータを扱うと、N+1問題が発生する
・joinsメソッドは関連するテーブルを保持しないため、メモリ消費が少ない
・joinsメソッドはwhereで絞り込んで、関連するテーブルのデータを使わない処理に向いている
【インタビュー】1ヶ月でRubyをゼロから学び、Webエンジニアとして転職!
ブラジルから帰国し技術をつけようとRubyエンジニアを目指してWebCampでRubyを学び、見事Webエンジニアとして転職を果たした田中さんにお話を伺いました。
「Rubyの学習がしたい。基礎をしっかりと理解したい」
「転職のサポートがほしい」
と考えている方はぜひお読み下さい。