第18章|今さら学ぶ「リレーション設計(応用)」
📚 シリーズ目次はこちら → 「今さら学ぶ」シリーズ — はじめに
🗺️ KnowledgeNoteの設計を確認 → 設計マップ
この章でわかること
- 中間テーブルと多対多 — 「生徒と授業」の関係を整理する
- 自己参照の多対多 — フォロー/フォロワーの仕組み
- 自己参照の親子(階層構造)— コメントへの返信を表現する
- ポリモーフィック関連 — 「いいねは記事にもコメントにもつく」を1モデルで
- STI(単一テーブル継承)— 似た者同士を1つのテーブルにまとめる
- どのパターンを選ぶか — 判断基準のフローチャート
🏠 たとえ話で掴む「リレーション設計」
第15章では基本の1対多(著者→記事)を扱いました。ここからは 応用パターン です。
人間関係にたとえると、1対多は「親と子」のシンプルな関係です。しかし現実はもっと複雑です。
| 人間関係 | DBパターン | KnowledgeNote |
|---|---|---|
| 親と子 | 基本の1対多 | ユーザー → 記事 |
| 生徒と授業(多対多) | 中間テーブル | 記事 ↔ タグ |
| SNSの相互フォロー | 自己参照の多対多 | ユーザー ↔ フォロー |
| コメントへの返信 | 自己参照の親子(階層構造) | コメント → 返信 |
| 記事にもコメントにも「いいね」 | ポリモーフィック | いいね |
| 「いいね通知」「コメント通知」「フォロー通知」 | STI候補 | 通知 |
📖 応用リレーションの技術的な全体像
RDBにおけるテーブル間の関連は、突き詰めると 1対1、1対多、多対多 の3種類です。第15章で扱った has_many / belongs_to は1対多の基本形でした。
応用パターンは、この基本形を 組み合わせたり、制約を加えたり することで生まれます。
| パターン | 本質 | なぜ必要か |
|---|---|---|
| 中間テーブル(多対多) | 1対多を2つ組み合わせる | 1つの外部キーでは多対多を表現できないため |
| 自己参照 | 同じテーブルへの外部キー | 「ユーザーがユーザーをフォロー」のように同種同士の関連が必要なため |
| ポリモーフィック | 外部キーの参照先を動的に切り替える | 「いいね」のように複数のテーブルに紐づくモデルをDRYに設計するため |
| STI | 1テーブルに複数モデルを同居させる | 構造がほぼ同じモデル群のテーブル増殖を防ぐため |
Railsではこれらをアソシエーションの宣言(has_many, belongs_to, class_name, polymorphic 等のオプション)で表現します。
🔗 中間テーブルと多対多
多対多の問題
記事とタグの関係は「多対多」です。1つの記事に複数のタグ、1つのタグに複数の記事が紐づきます。
❌ 直接紐づけようとすると…
articles テーブルに tag_id を持つ? → 1記事に1タグしかつけられない
tags テーブルに article_id を持つ? → 1タグに1記事しか紐づかない
中間テーブルで解決
中間テーブル(article_tags) を間に挟むことで、多対多を表現します。
articles article_tags tags
+----+-------+ +----+-----------+--------+ +----+-------+
| id | title | | id | article_id| tag_id | | id | name |
+----+-------+ +----+-----------+--------+ +----+-------+
| 1 | Ruby | | 1 | 1 | 1 | | 1 | Ruby |
| 2 | Rails | | 2 | 1 | 2 | | 2 | 初心者 |
| 3 | SQL | | 3 | 2 | 3 | | 3 | Rails |
+----+-------+ | 4 | 3 | 4 | | 4 | DB |
+----+-----------+--------+ +----+-------+
記事1(Ruby)→ タグ1(Ruby), タグ2(初心者)
記事2(Rails)→ タグ3(Rails)
記事3(SQL)→ タグ4(DB)
# app/models/article.rb
class Article < ApplicationRecord
has_many :article_tags, dependent: :destroy
has_many :tags, through: :article_tags
end
# app/models/tag.rb
class Tag < ApplicationRecord
has_many :article_tags, dependent: :destroy
has_many :articles, through: :article_tags
validates :name, presence: true, uniqueness: true
end
# app/models/article_tag.rb(中間テーブル)
class ArticleTag < ApplicationRecord
belongs_to :article
belongs_to :tag
validates :article_id, uniqueness: { scope: :tag_id }
# ↑ 同じ記事に同じタグを2回つけない
end
# 使い方
article = Article.find(1)
article.tags # => [#<Tag name: "Ruby">, #<Tag name: "初心者">]
article.tags << Tag.find_by(name: "Rails") # タグを追加
tag = Tag.find_by(name: "Ruby")
tag.articles # => Rubyタグのついた全記事
🔄 自己参照の多対多 — フォロー/フォロワー
フォロー機能は「ユーザーがユーザーをフォローする」という 同じテーブル同士の多対多 です。これを 自己参照の多対多 と呼びます。
users follows(中間テーブル)
+----+------+ +----+-------------+-------------+
| id | name | | id | follower_id | followed_id |
+----+------+ +----+-------------+-------------+
| 1 | 田中 | | 1 | 1 | 2 | ← 田中が鈴木をフォロー
| 2 | 鈴木 | | 2 | 1 | 3 | ← 田中が山田をフォロー
| 3 | 山田 | | 3 | 2 | 1 | ← 鈴木が田中をフォロー
+----+------+ +----+-------------+-------------+
通常の多対多と違い、 外部キーが両方とも users テーブルを指す のがポイントです。Railsの規約だけでは自動解決できないので、class_name と foreign_key を明示します。
# app/models/user.rb
class User < ApplicationRecord
# 自分がフォローしている関係
has_many :active_follows, class_name: "Follow",
foreign_key: "follower_id",
dependent: :destroy
has_many :following, through: :active_follows, source: :followed
# 自分がフォローされている関係
has_many :passive_follows, class_name: "Follow",
foreign_key: "followed_id",
dependent: :destroy
has_many :followers, through: :passive_follows, source: :follower
def follow(other_user)
following << other_user unless self == other_user
end
def unfollow(other_user)
active_follows.find_by(followed_id: other_user.id)&.destroy
end
def following?(other_user)
following.include?(other_user)
end
end
# app/models/follow.rb
class Follow < ApplicationRecord
belongs_to :follower, class_name: "User"
belongs_to :followed, class_name: "User"
validates :follower_id, uniqueness: { scope: :followed_id }
validate :cannot_follow_self
private
def cannot_follow_self
errors.add(:follower_id, "自分自身はフォローできません") if follower_id == followed_id
end
end
# 使い方
tanaka = User.find(1)
suzuki = User.find(2)
tanaka.follow(suzuki) # 田中が鈴木をフォロー
tanaka.following # => [鈴木]
suzuki.followers # => [田中]
tanaka.following?(suzuki) # => true
🌳 自己参照の親子(階層構造)— コメントへの返信
フォロー機能は「同じテーブル同士の 多対多」でしたが、コメントの返信機能は「同じテーブル同士の 1対多」です。これを 自己参照の親子(階層構造) と呼びます。
comments テーブル
+----+---------+------------+-----------+
| id | body | article_id | parent_id |
+----+---------+------------+-----------+
| 1 | すごい! | 1 | NULL | ← トップレベルのコメント
| 2 | 同感! | 1 | 1 | ← コメント1への返信
| 3 | 補足です | 1 | 1 | ← コメント1への返信
| 4 | ありがとう| 1 | 2 | ← コメント2への返信(孫)
+----+---------+------------+-----------+
parent_id が NULL ならトップレベルのコメント、値があればそのコメントへの返信です。
# app/models/comment.rb
class Comment < ApplicationRecord
belongs_to :user
belongs_to :article
belongs_to :parent, class_name: "Comment", optional: true
has_many :replies, class_name: "Comment",
foreign_key: "parent_id",
dependent: :destroy
has_many :likes, as: :likeable, dependent: :destroy
validates :body, presence: true
scope :top_level, -> { where(parent_id: nil) }
end
# 使い方
comment = Comment.find(1)
comment.replies # => [コメント2, コメント3]
comment.parent # => nil(トップレベルなので親なし)
reply = Comment.find(2)
reply.parent # => コメント1
reply.replies # => [コメント4]
# 記事のトップレベルコメントだけ取得
article.comments.top_level # => parent_id が NULL のコメントだけ
ポイントは optional: true です。トップレベルのコメントは親を持たない(parent_id が NULL)ため、belongs_to のデフォルトの必須チェックを外す必要があります。
⚠️ 階層が深くなりすぎると表示やパフォーマンスが複雑になります(→ 第24章で詳しく扱います)。KnowledgeNoteでは返信は1階層(コメントへの返信まで)に制限し、孫コメント以降は許可しない設計も検討に値します。
🎭 ポリモーフィック関連 — 1つのモデルで複数の親に対応
問題:記事にもコメントにも「いいね」をつけたい
# ❌ 方法A:テーブルを2つ作る
class ArticleLike < ApplicationRecord
belongs_to :article
end
class CommentLike < ApplicationRecord
belongs_to :comment
end
# → ほぼ同じ構造のテーブルが2つ。DRY違反。
# ✅ 方法B:ポリモーフィック関連で1テーブルにまとめる
class Like < ApplicationRecord
belongs_to :likeable, polymorphic: true
end
ポリモーフィックの仕組み
ポリモーフィック関連では、 「何に対するいいねか」を type カラムと id カラムで表現 します。
likes テーブル
+----+---------+---------------+-------------+
| id | user_id | likeable_type | likeable_id |
+----+---------+---------------+-------------+
| 1 | 1 | Article | 3 | ← 記事3へのいいね
| 2 | 1 | Comment | 7 | ← コメント7へのいいね
| 3 | 2 | Article | 3 | ← 記事3へのいいね
+----+---------+---------------+-------------+
たとえ話でいうと、 ハートのシール です。シールの裏に「何に貼ったか(記事 or コメント)」と「その番号」が書いてあるイメージです。
# app/models/like.rb
class Like < ApplicationRecord
belongs_to :user
belongs_to :likeable, polymorphic: true
validates :user_id, uniqueness: { scope: [:likeable_type, :likeable_id] }
# ↑ 同じユーザーが同じ対象に2回いいねできない
end
# app/models/article.rb
class Article < ApplicationRecord
has_many :likes, as: :likeable, dependent: :destroy
def liked_by?(user)
likes.exists?(user: user)
end
end
# app/models/comment.rb
class Comment < ApplicationRecord
has_many :likes, as: :likeable, dependent: :destroy
end
article = Article.find(1)
article.likes.count # => この記事のいいね数
article.liked_by?(current_user) # => true / false
# ポリモーフィックの逆引き
like = Like.first
like.likeable # => #<Article id: 3, ...> または #<Comment id: 7, ...>
ポリモーフィックの注意点
| メリット | デメリット |
|---|---|
| テーブルが1つで済む(DRY) |
DBレベルの外部キー制約が貼れない (likeable_id の参照先が動的なため) |
| 新しい対象の追加が容易(例:記事のいいね → ブックマークのいいねも追加可能) | JOINが複雑になる(likeable_type の条件が必要) |
| コードがシンプル | 参照先のテーブルが削除されても、likesの行は残ってしまう(孤児データ) |
外部キー制約が貼れない点は、アプリケーション層(バリデーションやコールバック)で整合性を担保する必要があります。
🧬 STI(単一テーブル継承)
STI(Single Table Inheritance) は、似た構造のモデルを 1つのテーブルにまとめて type カラムで区別する パターンです。
KnowledgeNoteの通知機能は STI の候補です。
notifications テーブル
+----+---------+-----------------+---------------+-------------+-------+
| id | user_id | notifiable_type | notifiable_id | action_type | read |
+----+---------+-----------------+---------------+-------------+-------+
| 1 | 2 | Like | 1 | liked | false |
| 2 | 2 | Comment | 3 | commented | false |
| 3 | 1 | Follow | 2 | followed | true |
+----+---------+-----------------+---------------+-------------+-------+
KnowledgeNoteでは STI ではなく action_type カラム + ポリモーフィックで通知を管理していますが、STI を使う場合はテーブルとコードがこうなります。
notifications テーブル(STI版)
+----+---------+---------------------+-------+
| id | user_id | type | read |
+----+---------+---------------------+-------+
| 1 | 2 | LikeNotification | false | ← type カラムがクラス名を保持
| 2 | 2 | CommentNotification | false |
| 3 | 1 | FollowNotification | true |
+----+---------+---------------------+-------+
# STIを使う場合のイメージ(参考)
class Notification < ApplicationRecord
# type カラムが自動的に使われる
end
class LikeNotification < Notification
# type = "LikeNotification" として保存される
end
class CommentNotification < Notification
end
class FollowNotification < Notification
end
STI の注意点
| メリット | デメリット |
|---|---|
| テーブルが1つで済む | カラムが増えるとNULLだらけになる |
| ActiveRecordの継承が使える | 子クラスごとに必要なカラムが違うと無駄が多い |
| クエリがシンプル | テーブルが肥大化しやすい |
STI が向いている場面: 子クラスのカラム構成がほぼ同じで、振る舞いだけが異なる場合。
🗺️ どのパターンを選ぶか — 判断フローチャート
① 2つのモデルの関係は?
→ 1対多 : has_many / belongs_to(基本)
→ 多対多 : has_many :through + 中間テーブル
→ 1対1 : has_one / belongs_to
② 同じテーブル同士の関連?
→ 多対多(フォロー等)→ 自己参照の多対多(class_name / foreign_key を指定)
→ 親子(コメント返信等)→ 自己参照の1対多(parent_id + optional: true)
③ 1つのモデルが複数の親に紐づく?(いいね→記事, いいね→コメント)
→ はい → ポリモーフィック関連
④ 似た構造のモデルが複数ある?
→ カラムがほぼ同じ → STI(単一テーブル継承)
→ カラムが大きく違う → 別テーブルに分ける
🛠️ KnowledgeNoteでの具体例 — 全パターンの一覧
KnowledgeNoteで使われている応用リレーションをまとめると、以下の通りです。
# app/models/user.rb(抜粋)
class User < ApplicationRecord
has_many :articles, dependent: :destroy
has_many :comments, dependent: :destroy
# 自己参照の多対多(フォロー)
has_many :active_follows, class_name: "Follow", foreign_key: "follower_id", dependent: :destroy
has_many :following, through: :active_follows, source: :followed
has_many :passive_follows, class_name: "Follow", foreign_key: "followed_id", dependent: :destroy
has_many :followers, through: :passive_follows, source: :follower
end
# app/models/article.rb(抜粋)
class Article < ApplicationRecord
belongs_to :user
has_many :comments, dependent: :destroy
has_many :likes, as: :likeable, dependent: :destroy # ポリモーフィック
has_many :article_tags, dependent: :destroy
has_many :tags, through: :article_tags # 多対多
end
# app/models/comment.rb(抜粋)
class Comment < ApplicationRecord
belongs_to :user
belongs_to :article
belongs_to :parent, class_name: "Comment", optional: true # 階層構造
has_many :replies, class_name: "Comment", foreign_key: "parent_id", dependent: :destroy
has_many :likes, as: :likeable, dependent: :destroy # ポリモーフィック
end
| パターン | モデル | 使う場面 |
|---|---|---|
| 多対多(中間テーブル) | Article ↔ ArticleTag ↔ Tag | 記事にタグをつける |
| 自己参照の多対多 | User ↔ Follow ↔ User | フォロー/フォロワー |
| 自己参照の親子 | Comment → Comment(parent_id) | コメントへの返信 |
| ポリモーフィック | Like → Article or Comment | 記事にもコメントにもいいね |
💼 面接で聞かれたら?
Q:ポリモーフィック関連について説明してください。
「ポリモーフィック関連は、1つのモデルが複数の異なるモデルに紐づけられる仕組みです。たとえばLikeモデルが記事にもコメントにも紐づく場合、
likeable_type(対象のモデル名)とlikeable_id(対象のID)の2カラムで関連先を動的に決定します。テーブルを対象ごとに分ける必要がなく、DRYに設計できます。」深掘りされたら:
- 「自己参照とは?」→ 同じテーブルの行同士が関連を持つパターン。フォロー機能のように、usersテーブルの行がusersテーブルの別の行をfollowする。class_nameとforeign_keyの明示が必要。
- 「STI はどんな場面で使う?」→ カラム構成がほぼ同じで、振る舞いだけが異なるモデル群に使う。カラムが大きく異なると NULLだらけになるため、別テーブルに分ける方がよい。
🔗 もっと深く知りたい人へ(1次情報リンク)
- Rails ガイド:Active Record の関連付け(ポリモーフィック) — ポリモーフィックの公式解説
- Rails ガイド:Active Record の関連付け(自己結合) — 自己参照の公式解説
- Rails ガイド:Active Record と継承(STI) — STIの公式解説
まとめ
- ✅ 多対多は中間テーブルで表現。
has_many :throughで自然にアクセスできる - ✅ 自己参照の多対多は
class_nameとforeign_keyを明示して定義する - ✅ 自己参照の親子(階層構造)は
parent_id+optional: trueで表現する - ✅ ポリモーフィックは
type+idの2カラムで複数の親モデルに対応する - ✅ STI は似た構造のモデルを1テーブルにまとめるが、カラム差が大きいと向かない
- ✅ パターン選択は「関係の種類」と「カラム構成の共通度」で判断する
📚 シリーズ目次:「今さら学ぶ」シリーズ — はじめに