3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

source は「源流」だった ── ER図を川の流れで読んで、中間テーブルと through を理解する

3
Posted at

はじめに

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人のユーザーにたどり着く」

だからコードはこう

app/models/user.rb
has_many :books      # 源流から見ると、下流は「たくさん」
app/models/book.rb
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行です。

app/models/user.rb
has_many :favorites   # USERS の源流から、湖へ下る
app/models/book.rb
has_many :favorites   # BOOKS の源流から、湖へ下る
app/models/favorite.rb
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 で書きます。

だからコードはこう

app/models/user.rb
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行になりました。
同じところでモヤモヤしている方の助けになればうれしいです。

参考

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?