はじめに
Railsで「お気に入り」や「いいね」を作るとき、こんなコードを書きます。
has_many :favorites
has_many :favorite_books, through: :favorites, source: :book
私は最初、この2行目をなんとなく写していました。
ER図もまだ詳しく学んでいなくて、読み方もよく分からないし、スラスラとは書けません。
でも、ER図を川の流れとして眺めてみたら、
このコードが図のどこを表しているのかが、少しずつ見えてきました。
この記事では、小さなER図を何度も出しながら
「ここから、こう流れて、こう読む → だからコードはこうなる」 を一緒にやっていきます。
この記事で分かること
- ER図の線の読み方(この記事に必要な分だけ)
- 多対多で中間テーブルが必要になる理由
-
throughとsourceが図のどこを表しているか
対象読者
-
has_many/belongs_toは書いたことがある人 - ER図を見ると「うっ」となる人(私です)
この記事の流れ
| STEP | 川で言うと | Railsで言うと |
|---|---|---|
| 1 | 1本の川を読む | 1対多(has_many / belongs_to) |
| 2 | 2つの源流を直接つないでみる | 多対多はそのままではテーブルにできない |
| 3 | 湖で合流させる | 中間テーブル |
| 4 | 湖を経由する船旅 | through |
| 5 | どの源流へさかのぼるか | source |
今回の例:読書記録アプリ
| モデル | 役割 |
|---|---|
| User | ユーザー |
| Book | ユーザーが登録した本 |
| Favorite | 「誰が、どの本をお気に入りしたか」の記録 |
全体図はこうなります。今は分からなくて大丈夫です。最後にもう一度見ます。
STEP 1:1本の川を読む(1対多)
まずは「ユーザーが本を登録する」の線1本だけを切り出します。
記号は2つだけ
| 記号 | 川で言うと | 意味 |
|---|---|---|
||(縦棒2本) |
源流。ここから流れ出す | ちょうど1 |
o{(丸+三つ又) |
川が何本にも分かれた先 | 0以上の多 |
この線は、USERS という源流から、BOOKS へ向かって川が分かれて流れている図です。
下って読む・さかのぼって読む
下る(源流 → 下流)
① USERS(源流)からスタート
② 川を下ると、BOOKS 側で分かれている(o{)
③ 読む:「ユーザー1人から、本へ向かう川は何本もある」= 1人が本を0冊以上登録する
さかのぼる(下流 → 源流)
① BOOKS のどれか1冊からスタート
② 川をさかのぼると、たどり着く源流は1つだけ(||)
③ 読む:「どの本も、さかのぼるとちょうど1人のユーザーにたどり着く」
だからコードはこう
has_many :books # 源流から見ると、下流は「たくさん」
belongs_to :user # 下流から見ると、源流は「1つ」
下って読んだ結果が has_many、さかのぼって読んだ結果が belongs_to。
1本の線が、モデルの2行になります。
💡小ネタ:この書き方は「鳥の足記法」
o{ の三つ又が鳥の足に見えることから、この書き方(IE記法)は「鳥の足記法」とも呼ばれます。英語では crow's foot(カラスの足)です。
私には川の分かれ目に見えたので、この記事では川として読んでいます。三つ又を見たら「多」です。
💡小ネタ:FK はいつも「下流」にある
books には user_id(FK)がありますが、users に book_id はありません。
下流の1冊1冊が「自分の源流はユーザー何番か」を覚えている、という形です。
迷ったら FK を持っている箱 = 下流 = belongs_to を書く側 と判断できます。
STEP 2:2つの源流を、直接つないでみる
次は「ユーザーが本をお気に入りする」です。
1人が何冊もお気に入りでき、1冊が何人にもお気に入りされます。
素直に描くと、こうなりそうです。
両端を読んでみる
① USERS から見ると、BOOKS 側で分かれている(o{)→ 多
② BOOKS から見ると、USERS 側でも分かれている(}o)→ 多
両端とも分かれている。どちらも源流ではありません。
でも、これはテーブルにできない
STEP 1 では、FK を「下流」に置きました。では今回は、どちらが下流でしょう?
-
booksにfavorited_user_idを置く → 1冊につき1人分しか入らない -
usersにfavorite_book_idを置く → 1人につき1冊分しか入らない
1つのカラムには値が1つしか入らないので、どちらも下流になれません。
2つの源流を、直接1本の川でつなぐことはできないのです。
STEP 3:湖で合流させる(中間テーブル)
そこで、2つの川を湖に流れ込ませて合流させます。この湖が中間テーブルです。
湖の岸には合流地点がたくさんあります。1つの合流地点には、USERS からの川と BOOKS からの川がちょうど1本ずつ流れ込んでいます。
テーブルにすると、合流地点1つが1行です。
| id | user_id | book_id |
|---|---|---|
| 1 | 1 | 3 |
| 2 | 1 | 7 |
| 3 | 2 | 3 |
- 合流地点1:ユーザー1の川 と 本3の川
- 合流地点2:ユーザー1の川 と 本7の川
- 合流地点3:ユーザー2の川 と 本3の川
湖に流れ込む2本の川を読む
湖に流れ込む川は2本の線になりました。どちらも STEP 1 と同じ読み方です。
USERS の川
- 下る:ユーザー1人から、湖の合流地点へ向かう川は何本も(
o{) - さかのぼる:合流地点1つから、たどり着くユーザーは1人(
||)
BOOKS の川
- 下る:本1冊から、湖の合流地点へ向かう川は何本も(
o{) - さかのぼる:合流地点1つから、たどり着く本は1冊(
||)
直接はつなげなかった2つの源流が、湖で合流する2本の川になりました。
たとえの限界
本物の湖では水が混ざってしまいますが、中間テーブルでは1行ごとに「どのユーザー」「どの本」がはっきり記録されています。
だからコードはこう
川2本 × 下る・さかのぼる = 4行です。
has_many :favorites # USERS の源流から、湖へ下る
has_many :favorites # BOOKS の源流から、湖へ下る
class Favorite < ApplicationRecord
belongs_to :user # 湖から、USERS の源流へさかのぼる
belongs_to :book # 湖から、BOOKS の源流へさかのぼる
validates :user_id, uniqueness: { scope: :book_id }
end
湖(Favorite)は FK を2つ持っているので、さかのぼる先も2つ、belongs_to も2つです。
validates は「同じユーザーと同じ本の合流地点は1つだけ」というルールです。
💡小ネタ:has_and_belongs_to_many という書き方もある
Rails には、中間テーブルのモデルを作らずに多対多を書く has_and_belongs_to_many もあります。
ただ、モデルがないと今回のような validates を書けないので、has_many :through を使うことのほうが多いです。
STEP 4:through = 湖を経由する船旅
ここまでで、お気に入りの本はこう取れます。
user.favorites.map(&:book)
💡小ネタ:map(&:book) の &: って何?
user.favorites.map { |favorite| favorite.book } の省略形です。「各要素に book メソッドを呼んで、結果を集める」という意味です。
「湖の合流地点を1つずつ見に行って、そこから本を取りに行く」流れです。
これを1回で済ませる船のルートを作るのが through です。
船旅のルートを辿る
① USERS の源流から船を出す
② 川を下って FAVORITES の湖へ → 合流地点はたくさん(多)
③ 各合流地点から、本の川をさかのぼる → たどり着く本は1冊ずつ(1)
④ 読む:「ユーザー1人から、合流地点(多)を経由して、それぞれ本1冊ずつ」→ 本は多
多 × 1 = 多 なので、船旅の結果は「本の一覧」です。だから has_many で書きます。
だからコードはこう
has_many :favorites
has_many :favorite_books, through: :favorites, source: :book
| 船旅 | 図のどこ | コード |
|---|---|---|
| 湖を経由する | ① USERS → FAVORITES | through: :favorites |
| 本の源流へさかのぼる | ② FAVORITES → BOOKS | source: :book |
| 船旅のルート名 | ①+② | has_many :favorite_books |
through は英語で「〜を通って」。湖を通って行くルートに名前を付けているだけで、新しい川を掘っているわけではありません。
なので、①か②の川(has_many :favorites か belongs_to :book)を埋め立てると、船旅もできなくなります。
たとえの限界
船旅は合流地点を1つずつ回りますが、through は全部まとめて SQL 1本で運びます。
SQL で比べる
user.favorites.map(&:book) # through なし
SELECT "favorites".* FROM "favorites" WHERE "favorites"."user_id" = 1;
SELECT "books".* FROM "books" WHERE "books"."id" = 3 LIMIT 1;
SELECT "books".* FROM "books" WHERE "books"."id" = 7 LIMIT 1;
-- 合流地点の数だけ続く(N+1)
user.favorite_books # through あり
SELECT "books".* FROM "books"
INNER JOIN "favorites" ON "books"."id" = "favorites"."book_id"
WHERE "favorites"."user_id" = 1;
-- JOIN した1本だけ
※ 見やすくするため、SQL は実際のログから少し簡略化しています(値の埋め込みなど)。
返ってくるのは普通の Book の一覧なので、user.favorite_books.include?(book) のように、そのままメソッドをつなげられます。
STEP 5:source = 「源流」
最後に source: :book です。
source は「源・出どころ」
source は英語で「源・出どころ」、川について使うと「水源」という意味です。川の比喩で言えば、まさに源流です。
つまり source: :book は、
湖に流れ込んでいる川のうち、book の源流から来た川をさかのぼってね
という指定です。
なぜ、わざわざ指定するのか
全体図に戻って、USERS から BOOKS へ行く道を探してみます。
-
ルートA:USERS から BOOKS へ直接下る = 登録した本(
has_many :books) - ルートB:USERS から湖を経由して BOOKS へ = お気に入りの本
ルートAがすでに books という名前を使っているので、ルートBには favorite_books という別名を付けました。
ところが Rails は、名前から推測して、湖(Favorite)で
「favorite_book(または favorite_books)という源流から来た川はある?」と探します。
湖に流れ込んでいるのは user と book の川だけなので、見つかりません。
そこで source: :book と書いて、「book の源流だよ」と教えるわけです。
| 部品 | 意味 | どこを見ている? |
|---|---|---|
has_many :favorite_books |
船旅のルート名(自由に付けられる) | ― |
through: :favorites |
どの湖を通るか | user.rb の has_many :favorites
|
source: :book |
湖から、どの源流へさかのぼるか | favorite.rb の belongs_to :book
|
わざと source を消してみる
has_many :favorite_books, through: :favorites # source を消した
user.favorite_books を呼ぶと、こうなります。
ActiveRecord::HasManyThroughSourceAssociationNotFoundError:
Could not find the source association(s) "favorite_book" or :favorite_books in model Favorite.
Try 'has_many :favorite_books, :through => :favorites, :source => <name>'.
Is it one of user or book?
「Favorite の湖で favorite_book の源流を探したけど見つからなかった。user か book のどっち?」と、
船が湖で迷子になっている様子がそのまま書いてあります(表示の形式は Rails のバージョンなどで少し異なります)。
エラー名にも SourceAssociation(源流の関連)と入っていますね。
💡小ネタ:「フォロー機能」も同じ形
「ユーザーがユーザーをフォローする」も多対多で、中間テーブルと through を使います。
ただし、湖に流れ込む2本の川の源流がどちらも USERS なので、どっちの源流へさかのぼるかの指定がもっと大事になります。
今回の形が分かっていると、フォロー機能を作るときにも迷いにくくなるはずです。
もう一度、全体図を読む
最初に「今は分からなくて大丈夫」と言った図です。
| 図 | 川で読むと | コード |
|---|---|---|
| USERS ― BOOKS | USERS の源流から、本へ川が分かれる |
has_many :books / belongs_to :user
|
| USERS ― FAVORITES | USERS の源流から、湖へ川が分かれる |
has_many :favorites / belongs_to :user
|
| BOOKS ― FAVORITES | BOOKS の源流から、湖へ川が分かれる |
has_many :favorites / belongs_to :book
|
| USERS → FAVORITES → BOOKS | 湖を経由して、本の源流へさかのぼる船旅 | has_many :favorite_books, through: :favorites, source: :book |
まとめ
- ER図の線は川。
||が源流(1)、o{が分かれた先(多)。下って読むとhas_many、さかのぼって読むとbelongs_to - 2つの源流は直接つなげない → 湖(中間テーブル)で合流させる
-
throughは湖を通る船旅、sourceは湖からどの源流へさかのぼるかの指定
おわりに
ER図は今もスラスラ書けるわけではありません。
でも、源流と分かれ道を探すだけで、コードとのつながりが見えるようになりました。
source が「源流」だと気づいたとき、なんとなく写していた1行が、ちゃんと意味のある1行になりました。
同じところでモヤモヤしている方の助けになればうれしいです。
参考
- Active Record の関連付け - Railsガイド(英語版)
- ActiveRecord::Associations::ClassMethods(has_many の :through / :source オプション)- Rails API
- HasManyThroughSourceAssociationNotFoundError のエラーメッセージ(Rails ソースコード)
- Entity Relationship Diagrams - Mermaid
- ER図とは?IE記法・IDEF1X記法 - NTTコミュニケーションズ
- Symbol#to_proc - Ruby リファレンスマニュアル
- source の意味 - Weblio英和辞書