はじめに
Rails学習中のカレー屋めぐりにハマっている私を含め、多くの初学者はActiveRecordを使用する際、背後で実行されているSQL文をあまり意識していなく「これ、どんな動きをするんだっけ?」と手が止まることも少なくありません。
SQL文を理解していないと、複雑なデータ操作などができず、気が付かぬうちに非効率なコードを書いてしまう可能性があります。
本記事ではカレー屋めぐりにハマっている私がカレー屋を例えに、ActiveRecordのメソッドをまとめてみました。
対象者
- Rails初学者
基本的な Rubyの記法の理解 - SQL初学者
クエリの構造の理解
環境
Ruby バージョン: 3.2.2
Rails バージョン: 7.0.4
メソッド一覧とSQL
find: 番号でカレーを探す
CurryDish.find(1)
# SELECT `curry_dishes`.* FROM `curry_dishes` WHERE `curry_dishes`.`id` = 1 LIMIT 1
CurryDish.find([2,3,4])
# SELECT `curry_dishes`.* FROM `curry_dishes` WHERE `curry_dishes`.`id` IN (2, 3, 4)
findメソッドは、メニューから指定された番号(ID)を持つカレー(レコード)を検索します。複数のカレーを検索することもできます。
- メニュー番号1のカレーの詳細を確認する
- メニュー番号2、3、4のカレーをまとめて確認する
where: 条件に合うカレーを探す
CurryDish.where(spiciness: "中辛")
# SELECT `curry_dishes`.* FROM `curry_dishes` WHERE `curry_dishes`.`spiciness` = '中辛'
CurryDish.where(spiciness: "中辛", vegetarian: true)
# SELECT `curry_dishes`.* FROM `curry_dishes` WHERE `curry_dishes`.`spiciness` = '中辛' AND `curry_dishes`.`vegetarian` = TRUE
CurryDish.where("price < ?", 1000)
# SELECT `curry_dishes`.* FROM `curry_dishes` WHERE (price < 1000)
whereメソッドは、指定された条件に合うカレー(レコード)を検索します。複数の条件を指定すると、それらすべてを満たすレコードを返します。
- 中辛のカレーを探す
- 中辛で野菜のカレーを探す
- 1000円未満のカレーを探す
select: カレーの特定の情報だけを取り出す
CurryDish.select(:name, :price)
# SELECT `curry_dishes`.`name`, `curry_dishes`.`price` FROM `curry_dishes`
CurryDish.select("name, price, spiciness")
# SELECT name, price, spiciness FROM `curry_dishes`
CurryDish.select("name, price AS cost")
# SELECT name, price AS cost FROM `curry_dishes`
selectメソッドは、取得するカラム(値段などの情報)を指定します。指定したカラムのみが結果に含まれます。
- カレーの名前と価格だけを知りたい
- カレーの名前、価格、辛さを知りたい
- カレーの名前と、価格を「料金」として知りたい
order: カレーのメニューを並び替える
CurryDish.order(:price)
# SELECT `curry_dishes`.* FROM `curry_dishes` ORDER BY `curry_dishes`.`price` ASC
CurryDish.order(price: :desc)
# SELECT `curry_dishes`.* FROM `curry_dishes` ORDER BY `curry_dishes`.`price` DESC
CurryDish.order(spiciness: :asc, price: :desc)
# SELECT `curry_dishes`.* FROM `curry_dishes` ORDER BY `curry_dishes`.`spiciness` ASC, `curry_dishes`.`price` DESC
orderメソッドは、取得するメニュー(レコード)の順序を指定します。デフォルトは昇順(ASC)ですが、降順(DESC)も指定できます。
- カレーを価格の安い順に見たい
- カレーを価格の高い順に見たい
- カレーを辛さの低い順に、同じ辛さなら価格の高い順に見たい
create: カレーの注文を作成する
Order.create(curry: "グリーンカレー", spiciness: "中辛", extra_naan: true)
# INSERT INTO `orders` (`curry`, `spiciness`, `extra_naan`) VALUES ('グリーンカレー', '中辛', TRUE)
order = Order.create(curry: "ビーフカレー")
# INSERT INTO `orders` (`curry`) VALUES ('ビーフカレー')
order.persisted? # => true
order = Order.create!(curry: "")
# エラー: ActiveRecord::RecordInvalid (Validation failed: Curry can't be blank)
createメソッドは、新しい注文(レコード)をデータベースに作成し保存します。注文内容をハッシュで渡し、成功するとその注文オブジェクトを返します。
- 通常の注文(すべての情報が揃っているので、注文が正常に作成されます)
- 最小限の注文(必須項目だけで注文が作成されます。他の項目はデフォルト値か空になります)
- 不完全な注文(必須項目が欠けているので、create!を使用するとエラーになります)
update: カレーの注文を更新する
order = Order.find(1)
order.update(spiciness: "激辛")
# UPDATE `orders` SET `spiciness` = '激辛' WHERE `orders`.`id` = 1
order.update(curry: "バターチキンカレー", extra_naan: true)
# UPDATE `orders` SET `curry` = 'バターチキンカレー', `extra_naan` = TRUE WHERE `orders`.`id` = 1
result = order.update(curry: "")
# SQL文は実行されない
result # => false
updateメソッドは、既存の注文(レコード)の内容を更新します。
- 前の注文を見つけて、辛さを変更する
- 注文を複数変更することもできます
- 変更に失敗する
destroy: メニューからカレーを削除する
order = Order.find(1)
order.destroy
# DELETE FROM `orders` WHERE `orders`.`id` = 1
Order.destroy_all
# DELETE FROM `orders`
Order.where(status: "pending").destroy_all
# DELETE FROM `orders` WHERE `orders`.`status` = 'pending'
destroyメソッドは、注文(レコード)をデータベースから削除します。
- 特定の注文をキャンセルする
- すべての注文をキャンセルする
- 条件に合う注文だけキャンセルする
joins: カレーと使用している材料を一緒に検索
Curry.joins(:ingredients)
# SELECT `curries`.* FROM `curries`
# INNER JOIN `curry_ingredients` ON `curry_ingredients`.`curry_id` = `curries`.`id`
# INNER JOIN `ingredients` ON `ingredients`.`id` = `curry_ingredients`.`ingredient_id`
Curry.joins(:ingredients).where(ingredients: { name: "トマト" })
# SELECT `curries`.* FROM `curries`
# INNER JOIN `curry_ingredients` ON `curry_ingredients`.`curry_id` = `curries`.`id`
# INNER JOIN `ingredients` ON `ingredients`.`id` = `curry_ingredients`.`ingredient_id`
# WHERE `ingredients`.`name` = 'トマト'
Curry.joins(:ingredients).group('curries.id').having('COUNT(DISTINCT ingredients.id) > 5')
# SELECT `curries`.* FROM `curries`
# INNER JOIN `curry_ingredients` ON `curry_ingredients`.`curry_id` = `curries`.`id`
# INNER JOIN `ingredients` ON `ingredients`.`id` = `curry_ingredients`.`ingredient_id`
# GROUP BY `curries`.`id` HAVING COUNT(DISTINCT ingredients.id) > 5
joinsメソッドは、関連するテーブルを結合してデータを検索します。カレーと材料を柔軟に組み合わせて探すことができます。
- トマトを使用しているカレーを見つける
- トマトとニンニクを使用しているカレーを探す
- 5種類以上の材料を使用しているカレーを探す
※joinsを使用することで期待できること
- 関連データに基づく効率的な絞り込み
「トマトを使ったカレー」のように、材料情報を基に素早くカレーを絞り込めます。材料リストを見ながらカレーを一つずつ確認する代わりに、トマトを使っているカレーだけを即座に取り出せるイメージです。 - 複雑な検索条件の実現
「トマトとニンニクを両方使っていて、辛さレベルが中辛以上のカレー」といった複雑な条件での検索が可能になります。これは、複数の条件を満たすカレーを手作業で探すのではなく、コンピュータが瞬時に見つけ出すようなものです。 - 集計操作との組み合わせ
「5種類以上の野菜を使用しているカレー」のように、材料の数や種類に基づいた高度な検索ができます。これは、各カレーの材料リストを数えて回るのではなく、自動的に材料の多いカレーを抽出するようなイメージです。 - データベースレベルでの処理による効率化
大量のカレーメニューから条件に合うものを探す際、すべてのカレーの情報を取得してから絞り込むのではなく、データベース側で絞り込んでから結果を取得します。これは、全メニューを厨房に運んでから必要なものを選ぶのではなく、必要なメニューだけを直接取りに行くようなものです。
includes: カレーとよく注文されるトッピングを一緒に取得
(お店側の視点で解説します)
Curry.includes(:side_dishes)
# SELECT `curries`.* FROM `curries`
# SELECT `side_dishes`.* FROM `side_dishes` WHERE `side_dishes`.`curry_id` IN (1, 2, 3, ...)
Curry.includes(:side_dishes, :spice_level)
# SELECT `curries`.* FROM `curries`
# SELECT `side_dishes`.* FROM `side_dishes` WHERE `side_dishes`.`curry_id` IN (1, 2, 3, ...)
# SELECT `spice_levels`.* FROM `spice_levels` WHERE `spice_levels`.`id` IN (1, 2, 3, ...)
Curry.includes(:side_dishes).where(side_dishes: { name: "ナン" })
# SELECT `curries`.* FROM `curries`
# LEFT OUTER JOIN `side_dishes` ON `side_dishes`.`curry_id` = `curries`.`id`
# WHERE `side_dishes`.`name` = 'ナン'
includesメソッドは、関連するデータを事前に読み込みます。カレーとサイドメニューを効率的に準備できます。
- カレーとサイドメニューを一緒に準備
- カレー、サイドメニュー、辛さレベルを全て事前に確認
- 特定のサイドメニューがあるカレーを効率的に準備
(事前に用意することで忙しい時間帯でも素早くメニューを確認できます。)
※includesを使用することで期待できること
-
パフォーマンスの向上
ランチタイムの注文ラッシュ時、各カレーに対して個別にサイドメニューを確認する(データベースに問い合わせる)のではなく、一度にすべてのカレーとサイドメニューの情報を取得することで、注文処理が格段に速くなります。 -
N+1問題の解決
カレー屋の例:50種類のカレーメニューがあり、各カレーに対してサイドメニューを個別に確認すると51回のデータベースアクセスが必要になります。includesを使うと、2回のアクセスで済みます。 -
コードの簡潔さ
メニュー表を作成する際、カレーごとにサイドメニューを個別に取得する複雑なコードを書く代わりに、一度の操作ですべての情報を取得できます。 -
メモリ使用の最適化
カレー屋の例:繁忙期に備えて全メニューの詳細情報を事前に用意しておくことで、注文が入るたびに慌てて情報を集める必要がなくなります。
group & having: カレーの注文データを集計して分析する
(お店側の視点で解説します)
Order.group(:curry_id).count
# SELECT curry_id, COUNT(*) AS count_all FROM `orders` GROUP BY `orders`.`curry_id`
Order.group(:curry_id).having('COUNT(*) > 10').count
# SELECT curry_id, COUNT(*) AS count_all FROM `orders`
# GROUP BY `orders`.`curry_id` HAVING COUNT(*) > 10
Order.joins(:curry).group('curries.spice_level').count
# SELECT COUNT(*) AS count_all, curries.spice_level
# FROM `orders` INNER JOIN `curries` ON `curries`.`id` = `orders`.`curry_id`
# GROUP BY curries.spice_level
groupメソッドはデータをグループ化し、havingメソッドはグループ化されたデータに対して条件を適用します。これらを使うことで、カレーの注文データを様々な角度から分析できます。
- 各カレーの注文数を集計する(人気メニューのランキングを作成)
- 10回以上注文されたカレーを抽出する(定番メニューを特定)
- 辛さレベル別の注文数を集計する(どの辛さが人気か分析)
※groupとhavingを使用することで期待できること
- 条件付きの集計と抽出
「週に20回以上注文される人気メニュー」や「全注文の10%以上を占める主力カレー」など、特定の条件を満たすメニューだけを抽出できます。これは、大量の注文データから重要な情報だけをフィルタリングするようなイメージです。
単純な.count:「今日の注文は全部で何件でした」
group(:curry_id).count:「バターチキンカレーが20件、ビーフカレーが15件、カツカレーが10件...」 - 時系列分析の実現
「月別の辛さレベル(辛口、中辛、甘口)の注文傾向」といった、時間軸を含めた分析が可能になります。これは、季節ごとのお客様の好みの変化を追跡し、メニュー展開の最適化につなげるようなものです。 - 効率的なデータ集約
個々の注文データをいちいち数えるのではなく、データベース側で集計して結果だけを取得できます。これは、数万件の注文伝票を一瞬で意味のある統計情報に変換するようなものです。
limit: 表示するカレーメニューを制限する
(お店側の視点で解説します)
Curry.limit(5)
# SELECT `curries`.* FROM `curries` LIMIT 5
Curry.order(popularity: :desc).limit(3)
# SELECT `curries`.* FROM `curries` ORDER BY `curries`.`popularity` DESC LIMIT 3
Curry.where(spice_level: 'hot').limit(2)
# SELECT `curries`.* FROM `curries` WHERE `curries`.`spice_level` = 'hot' LIMIT 2
limitメソッドは、クエリの結果として取得するレコードの数を制限します。
- 人気のカレートップ5を表示する
- 今週のおすすめカレーを3種類選ぶ
- 辛口カレーの中から2種類をピックアップする
offset: 膨大なカレーメニューの中から範囲を指定して表示する
Curry.offset(5)
# SELECT `curries`.* FROM `curries` OFFSET 5
Curry.limit(5).offset(5)
# SELECT `curries`.* FROM `curries` LIMIT 5 OFFSET 5
Curry.order(popularity: :desc).limit(3).offset(3)
# SELECT `curries`.* FROM `curries` ORDER BY `curries`.`popularity` DESC LIMIT 3 OFFSET 3
offsetメソッドは、指定した数のレコードをスキップしてから結果を取得します。主にページネーションや、結果の一部を取得する際に使用されます。
- 100種類あるメニュー表の範囲を決めて表示する
- 週替わりで違うおすすめカレーを5個提示する
- 人気のカレーの4位以降を表示する
※offsetを使用することで期待できること
- 効率的なメニュー表示
100種類あるカレーメニューを1ページに10個ずつ表示する場合2ページ目limit(10).offset(10)を使用して表示できます。 - メニュー表示のイメージ
1ページ目: Curry.limit(10)
2ページ目: Curry.limit(10).offset(10)
3ページ目: Curry.limit(10).offset(20)
eager_load: 効率的な注文の仕方
Curry.eager_load(:toppings)
# SELECT `curries`.*, `toppings`.*
# FROM `curries`
# LEFT OUTER JOIN `toppings` ON `toppings`.`curry_id` = `curries`.`id`
Curry.eager_load(:toppings, :spice_level)
# SELECT `curries`.*, `toppings`.*, `spice_levels`.*
# FROM `curries`
# LEFT OUTER JOIN `toppings` ON `toppings`.`curry_id` = `curries`.`id`
# LEFT OUTER JOIN `spice_levels` ON `spice_levels`.`id` = `curries`.`spice_level_id`
Curry.eager_load(:toppings).where(toppings: { name: "チーズナン" })
# SELECT `curries`.*, `toppings`.*
# FROM `curries`
# LEFT OUTER JOIN `toppings` ON `toppings`.`curry_id` = `curries`.`id`
# WHERE `toppings`.`name` = 'チーズナン'
eager_loadメソッドは、関連するデータを一度のクエリで取得します。常にLEFT OUTER JOINを使用してデータを結合します。大量のデータを扱い、ほぼ確実に関連データが必要な場合は eager_load が適しています。
データ量が多くなく、関連データが必要になるかわからない場合は includes が適しています。
- 「お店に入る前に」カレーメニューとトッピングの組み合わせを事前に確認
- 「お店に入る前に」カレーメニューとトッピング、辛さレベルの組み合わせを事前に確認
- 「お店に入る前に」特定のトッピングがあるカレーを探す
(お店に入ったとき素早く注文することができます)
※eager_loadを使用することで期待できること
- パフォーマンスの向上
100種類のカレーとそのトッピングを確認する際、1回のクエリで全ての情報を取得できます。これは、メニュー表を見ながら厨房に一度だけ行って全ての情報を聞いてくるようなものです。 - N+1問題の解決
50種類のカレーメニューがあり、各カレーに対してトッピングを個別に確認すると51回のデータベースアクセスが必要になります。eager_loadを使うと、1回のアクセスで済みます。 - 関連データに基づく効率的なフィルタリング
「チーズナンが付いているカレー」を探す際、すでに全ての情報が手元にあるので、素早く検索できます。 - メモリ使用の増加に注意
全てのカレーとトッピングの情報を一度に取得するので、たくさんの注文票を一度に持ち運ぶようなもので、扱いに注意が必要です。
最後に
プログラミングスクールのActiveRecord演習というカリキュラムで全くSQL文とActiveRecordメソッドの紐づけができておらず、全く回答できませんでした。
今回は学習のために始めて記事を書いてみました。
(記事の内容で、間違っている点などあればこっそり教えて下さい。)