0
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?

第18章|今さら学ぶ「リレーション設計(応用)」

0
Last updated at Posted at 2026-02-24

第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_nameforeign_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_idNULL ならトップレベルのコメント、値があればそのコメントへの返信です。

# 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_idNULL)ため、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次情報リンク)


まとめ

  • ✅ 多対多は中間テーブルで表現。has_many :through で自然にアクセスできる
  • ✅ 自己参照の多対多は class_nameforeign_key を明示して定義する
  • ✅ 自己参照の親子(階層構造)は parent_id + optional: true で表現する
  • ✅ ポリモーフィックは type + id の2カラムで複数の親モデルに対応する
  • ✅ STI は似た構造のモデルを1テーブルにまとめるが、カラム差が大きいと向かない
  • ✅ パターン選択は「関係の種類」と「カラム構成の共通度」で判断する

📚 シリーズ目次:「今さら学ぶ」シリーズ — はじめに

0
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
0
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?