投稿日:2026/8/5
更新日:2026/8/5

has_many に付ける dependent: は、親レコードが削除されたとき子レコードをどうするかを決める Rails の関連オプション。
「カテゴリを消したら記事まで消えた」という事故はここの選択ミスで起きる。選び方と、:nullify を選んだときに必ず踏むハマりどころをまとめる。
:nullify(子は残し、外部キーだけ NULL にする):destroy(親と一緒に消す):nullify を使うなら、外部キーカラムは null: true で定義されていなければならない。この2つはセットで設計する:nullify が戻すのは外部キーカラムだけ。「カテゴリ内での表示順」のような親に紐づく他のカラムは取り残されるので、自前で戻すブログ(Blog)が記事カテゴリ(ArticleCategory)を持ち、カテゴリに記事(Article)が並ぶ、という3層で考える。
erDiagram
blogs ||--o{ article_categories : "1ブログに複数カテゴリ"
article_categories |o--o{ articles : "1カテゴリに複数記事(未分類あり)"
blogs {
int id
string title
}
article_categories {
int id
int blog_id
string name
int priority
}
articles {
int id
int article_category_id "NULL 可"
string title
int category_priority
}
class Blog < ApplicationRecord
has_many :article_categories, -> { order(priority: :asc) }, dependent: :destroy
end
class ArticleCategory < ApplicationRecord
belongs_to :blog
has_many :articles, dependent: :nullify
end
class Article < ApplicationRecord
belongs_to :article_category, optional: true
end
カラム定義側はこうなっている。:nullify が成立する前提はここ。
create_table :articles do |t|
t.references :article_category, null: true, foreign_key: true
t.string :title, null: false
t.integer :category_priority, null: false, default: 0 # カテゴリ内での表示順
end
articles.article_category_idがnull: trueだから:nullifyが使える。null: falseなら:nullifyは実行時に落ちる(後述)。
ArticleCategory#destroy を呼ぶと、:nullify は1本の UPDATE を撃ってから親を消す。
UPDATE articles SET article_category_id = NULL
WHERE article_category_id = '削除するカテゴリのid';
DELETE FROM article_categories WHERE id = '削除するカテゴリのid';
:destroy だったら、子を1件ずつ destroy する(= 子のコールバックが走り、さらにその孫にも dependent が連鎖する)。
DELETE FROM articles WHERE id = '子1';
DELETE FROM articles WHERE id = '子2';
DELETE FROM article_categories WHERE id = '...';
| 指定 | 親を消したときの子の扱い | 子のコールバック | 発行クエリ |
|---|---|---|---|
:destroy |
子も1件ずつ削除 | 走る | 子の件数ぶん DELETE |
:delete_all |
子をまとめて削除 | 走らない | DELETE 1本 |
:nullify |
子は残し、外部キーだけ NULL | 走らない | UPDATE 1本 |
:restrict_with_error |
子がいると親を削除させない(バリデーションエラー) | — | 存在チェックの SELECT |
:restrict_with_exception |
子がいると ActiveRecord::DeleteRestrictionError |
— | 存在チェックの SELECT |
:destroy_async |
子の削除をジョブに逃がす(Rails 6.1+) | 走る(非同期) | ジョブ登録 |
| 指定なし | 何もしない | — | 親の DELETE のみ |
指定なしは「安全」ではない。 DB 側に外部キー制約があれば
ActiveRecord::InvalidForeignKeyで落ち、制約が無ければ存在しない親を指す孤児レコードが静かに残る。どちらも事故なので、has_manyを書いたらdependent:は必ず意識して決める。
判断軸は1つだけ。子は親に従属しているのか、それとも子自身が業務の中核データなのか。
flowchart TD
Q1{"親が消えたとき<br/>子も消えて意味が通るか?"}
Q1 -->|はい・子は親の一部| Q2{"子にコールバックや<br/>孫の dependent があるか?"}
Q1 -->|いいえ・子は独立データ| Q3{"親を消した後も<br/>子を残してよいか?"}
Q2 -->|ある| D1["dependent: :destroy"]
Q2 -->|ない・大量件数| D2["dependent: :delete_all"]
Q3 -->|残してよい| D3["dependent: :nullify"]
Q3 -->|子がいる間は消させない| D4["dependent: :restrict_with_error"]
この題材に当てはめるとこうなる。
| 関連 | 選択 | 理由 |
|---|---|---|
Blog → ArticleCategory |
:destroy |
カテゴリはブログに完全従属する。ブログが消えたらカテゴリだけ残っても意味がない |
ArticleCategory → Article |
:nullify |
記事は中核データ。カテゴリは「並べ方」の付加情報にすぎない |
カテゴリはあくまで陳列棚であって、記事の所有者ではない。運営者が「イチオシ」というカテゴリを消したときに、そこに入れていた記事まで消えたら事故になる(記事に紐づくコメントや公開履歴まで巻き添えになる)。
一方 :restrict_with_error にすると「記事が入っているカテゴリは消せません」となり、棚を整理するだけの UI としては使いづらい。
同じアプリの中でも、関連の性質ごとに使い分ける。「このモデルは
:destroyで統一」のようなモデル単位の決め方はしない。
null: false と :nullify は同時に成立しない:nullify は外部キーに NULL を入れる。カラムが null: false なら当然落ちる。
ActiveRecord::NotNullViolation: null value in column "article_category_id" violates not-null constraint
しかも親を消すまで発覚しない。dependent: :nullify を書いたら、対になるマイグレーションが null: true になっているかを必ず一緒に確認する。
:nullify は外部キー以外のカラムを戻さないここが一番踏みやすい。:nullify が触るのは外部キー(とポリモーフィックの type)だけで、他のカラムはそのまま残る。
今回の題材では、記事に category_priority(カテゴリ内での表示順)という別カラムがある。:nullify 任せにすると、カテゴリを消された記事は
所属なし(
article_category_idは NULL)・でも表示順は3のまま
という中途半端な状態になる。未分類の一覧を category_priority で並べると、消えたカテゴリ時代の古い順序が割り込んでくる。
そのため Form 側で、削除対象カテゴリに属していた記事の ID を消す前に拾っておき、後で表示順も戻す。
# app/forms/article_category_arrangement_form.rb
removed_categories = existing_categories.values_at(*(existing_categories.keys - input_ids))
# dependent: :nullify は所属を外すだけで表示順を残すため、削除対象の所属記事は後で表示順も戻す
@orphaned_article_ids = removed_categories.flat_map { |category| category.articles.ids }
removed_categories.each(&:destroy!)
# 後段:拾っておいた ID の表示順を未設定(0)に戻す
Article.where(id: @orphaned_article_ids).update_all(category_priority: 0)
destroy!より前に ID を確定させること。 消した後ではcategory.articlesは空になっていて、対象を特定できない。
レビューでは「ID を拾う処理」と「表示順を戻す処理」が対になっているかを見る。
:nullify / :delete_all では子のコールバックが走らない:nullify は UPDATE 1本、:delete_all は DELETE 1本で済ませるので、子の before_destroy や after_commit は実行されない。子側でキャッシュ更新・検索インデックス同期・通知などをコールバックに載せている場合、それらは黙ってスキップされる。
副作用を確実に走らせたいなら :destroy(件数が多いなら :destroy_async)を選ぶか、親側で明示的に呼ぶ。
belongs_to 側の dependent: は意味が違うbelongs_to にも dependent: は書けるが、指定できるのは :destroy / :delete だけで、「子を消したら親も消える」という逆方向の挙動になる。has_many のつもりで書くと親が巻き込まれるので注意。
dependent: は「親が消えたときの子の後始末」の宣言。書き忘れは孤児レコードか外部キー違反になる:nullify、完全従属なら :destroy、消させたくないなら :restrict_with_error:nullify は null: true とセット、そして 外部キー以外のカラムは自分で戻す