Railsで、モデルとマイグレーションファイルのそれぞれにON DELETE CASCADEな設定をして挙動を確認してみた

DB上のテーブル間に外部キー制約があり、「親を消したら、親に関係する子も削除する」みたいな処理を行いたいとします。

この場合、テーブルの外部キー定義で ON DELETE を指定することで対応できます。

 
そんな中、Railsでは「親を消したら、親に関係する子も削除する」をどのように実現するか調べてみたところ、

  • モデルで dependent を指定する
  • マイグレーションファイルの外部キー定義で on_delete: :cascade を指定する

の2つの方法があるようでした。

そこで、それぞれの挙動を確認してみたため、メモを残します。

 
目次

 

環境

  • Rails 7.0.2.3
  • SQLite
    • Railsのドキュメントを読む限り、今回の検証範囲ではDBによる違いはなさそうなので、今回は準備が容易なSQLiteで検証しました

 

テーブルの準備

今回は

  • 親 : 子 = 1 : n
  • 子 : 孫 = 1 : n

な関係を持つテーブルを用意します。

 
ジェネレータでそれぞれのモデルを生成します。

# 親
% bin/rails g model Parent name:string

# 子
% bin/rails g model Child name:string parent:references

# 孫
% bin/rails g model Grandchild name:string child:references

 
続いて、1側のモデルからn側のモデルを参照できるよう、1側のモデルに has_many を追加します。
2.3 has_many関連付け | Active Record の関連付け - Railsガイド

また、親を削除したときにモデルのコールバックが動くかどうかも確認するため、ParentとChildにコールバックを追加します。

Parent

class Parent < ApplicationRecord
  before_destroy -> { puts '[Parent] before destroy' }  # 追加
  after_destroy -> { puts '[Parent] after destroy' }  # 追加

  has_many :children  # 追加
end

Child

class Child < ApplicationRecord
  before_destroy -> { puts '[Child] before destroy' }  # 追加
  after_destroy -> { puts '[Child] after destroy' }  # 追加

  belongs_to :parent
  has_many :grandchildren  # 追加
end

 
続いて、初期データとしてfixtureを用意します。

今回は「parents.name == 親1 なデータを削除したときに、子・孫がどうなるか」が確認できるデータを用意します。

parents.yml

parent_1:
  name: 親1

parent_2:
  name: 親2

children.yml

child_1_1:
  name: 親1の子1
  parent: parent_1

child_1_2:
  name: 親1の子2
  parent: parent_1

child_2:
  name: 親2の子
  parent: parent_2

grandchildren.yml

grand_child_1_1_1:
  name: 親1の子1の孫1
  child: child_1_1

grand_child_1_1_2:
  name: 親1の子1の孫2
  child: child_1_1

grand_child_1_2:
  name: 親1の子2の孫
  child: child_1_2

 
以上で準備ができました。

 

各テーブルの外部キーにON DELETE CASCADEがない版の動作確認

まずは各テーブルの外部キーに ON DELETE CASCADE がない版で動作を確認してみます。

 

モデルにdependentなし

ここまでの作業で モデルにdependentなし、外部キーに on_delete なし という状態になっているため、まずはこのパターンで動作を確認してみます。

Railsコンソールで Parent.find_by(name: '親1').destroy を実行してみると、エラーになりました。

>> Parent.find_by(name: '親1').destroy

(3.8ms)  SELECT sqlite_version(*)
Parent Load (0.2ms)  SELECT "parents".* FROM "parents" WHERE "parents"."name" = ? LIMIT ?  [["name", "親1"], ["LIMIT", 1]]
[Parent] before destroy
TRANSACTION (0.1ms)  begin transaction
Parent Destroy (0.9ms)  DELETE FROM "parents" WHERE "parents"."id" = ?  [["id", 393698370]]
TRANSACTION (0.3ms)  rollback transaction
path/to/rails_association_sample/gems/sqlite3-1.4.2/lib/sqlite3/statement.rb:108:in `step': SQLite3::ConstraintException: FOREIGN KEY constraint failed (ActiveRecord::InvalidForeignKey)
path/to/rails_association_sample/gems/sqlite3-1.4.2/lib/sqlite3/statement.rb:108:in `step': FOREIGN KEY constraint failed (SQLite3::ConstraintException)

 
SQLiteのドキュメントを見ると

The ON DELETE and ON UPDATE action associated with each foreign key in an SQLite database is one of "NO ACTION", "RESTRICT", "SET NULL", "SET DEFAULT" or "CASCADE". If an action is not explicitly specified, it defaults to "NO ACTION".

https://www.sqlite.org/foreignkeys.html#fk_actions

とありました。

そのため、 NO ACTION

NO ACTION: Configuring "NO ACTION" means just that: when a parent key is modified or deleted from the database, no special action is taken.

になり、Childが参照するParentのidがなくなってしまった結果、Parent - Child 間で整合性が取れなくなったことからエラーになったようです。

 

モデルにdependent: destroy あり

続いて、外部キーの設定は変更しないまま、モデルの has_many のオプション dependent: destroy を指定してみます。

まずは念のためデータを初期化しておきます。

% bin/rails db:fixtures:load

   
次にモデルを変更します。

Parent

class Parent < ApplicationRecord
  before_destroy -> { puts '[Parent] before destroy' }
  after_destroy -> { puts '[Parent] after destroy' }
  
  has_many :children, dependent: :destroy  # 変更
end

Child

class Child < ApplicationRecord
  before_destroy -> { puts '[Child] before destroy' }
  after_destroy -> { puts '[Child] after destroy' }
  
  belongs_to :parent
  has_many :grandchildren, dependent: :destroy  # 変更
end

 
Railsコンソールで実行するとエラーになりませんでした。

ログを見ると、孫 > 子 > 親の順で1つずつSQLを発行して削除していることから、外部キーの制約には引っかからずに消せているようです。

>> Parent.find_by(name: '親1').destroy

   (2.9ms)  SELECT sqlite_version(*)
  Parent Load (0.8ms)  SELECT "parents".* FROM "parents" WHERE "parents"."name" = ? LIMIT ?  [["name", "親1"], ["LIMIT", 1]]
[Parent] before destroy
  TRANSACTION (0.1ms)  begin transaction
  Child Load (0.6ms)  SELECT "children".* FROM "children" WHERE "children"."parent_id" = ?  [["parent_id", 393698370]]
[Child] before destroy
  Grandchild Load (0.5ms)  SELECT "grandchildren".* FROM "grandchildren" WHERE "grandchildren"."child_id" = ?  [["child_id", 242255126]]
  Grandchild Destroy (0.4ms)  DELETE FROM "grandchildren" WHERE "grandchildren"."id" = ?  [["id", 394275835]]
  Child Destroy (0.1ms)  DELETE FROM "children" WHERE "children"."id" = ?  [["id", 242255126]]
[Child] after destroy
[Child] before destroy
  Grandchild Load (0.0ms)  SELECT "grandchildren".* FROM "grandchildren" WHERE "grandchildren"."child_id" = ?  [["child_id", 393860266]]
  Grandchild Destroy (0.0ms)  DELETE FROM "grandchildren" WHERE "grandchildren"."id" = ?  [["id", 111204660]]
  Grandchild Destroy (0.1ms)  DELETE FROM "grandchildren" WHERE "grandchildren"."id" = ?  [["id", 531204236]]
  Child Destroy (0.0ms)  DELETE FROM "children" WHERE "children"."id" = ?  [["id", 393860266]]
[Child] after destroy
  Parent Destroy (0.1ms)  DELETE FROM "parents" WHERE "parents"."id" = ?  [["id", 393698370]]
[Parent] after destroy
  TRANSACTION (0.7ms)  commit transaction

 

モデルにdependent: delete_all あり

続いて dependent: delete_all を試します。

なお、モデルの設定が has_many だったため dependent に指定するキーは delete_all でしたが、 has_one などは delete になるようです。

 
まずは念のためデータを初期化しておきます。

% bin/rails db:fixtures:load

   
次に、モデルを変更します。

Parent

class Parent < ApplicationRecord
  before_destroy -> { puts '[Parent] before destroy' }
  after_destroy -> { puts '[Parent] after destroy' }

  has_many :children, dependent: :delete_all  # 変更
end

Child

class Child < ApplicationRecord
  before_destroy -> { puts '[Child] before destroy' }
  after_destroy -> { puts '[Child] after destroy' }

  belongs_to :parent
  has_many :grandchildren, dependent: :delete_all  # 変更
end

 
準備ができたため実行するとエラーになります。

Railsガイドによると

:delete_all: 関連付けられたオブジェクトはすべてデータベースから直接削除されます(コールバックは実行されません)。

のため、DBの外部キー制約の設定 (NO ACTION) に従いエラーとなったようです。

>> Parent.find_by(name: '親1').destroy

 Parent Load (0.3ms)  SELECT "parents".* FROM "parents" WHERE "parents"."name" = ? LIMIT ?  [["name", "親1"], ["LIMIT", 1]]
[Parent] before destroy
  TRANSACTION (0.1ms)  begin transaction
  Child Delete All (0.9ms)  DELETE FROM "children" WHERE "children"."parent_id" = ?  [["parent_id", 393698370]]
  TRANSACTION (0.8ms)  rollback transaction
path/to/rails_association_sample/gems/sqlite3-1.4.2/lib/sqlite3/statement.rb:108:in `step': SQLite3::ConstraintException: FOREIGN KEY constraint failed (ActiveRecord::InvalidForeignKey)
path/to/rails_association_sample/gems/sqlite3-1.4.2/lib/sqlite3/statement.rb:108:in `step': FOREIGN KEY constraint failed (SQLite3::ConstraintException)

 

各テーブルの外部キーにON DELETE CASCADEがある版の動作確認

続いて、各テーブルの外部キーに ON DELETE CASCADE を設定した上で、各動作を確認してみます。

 

マイグレーションファイルによる環境構築

まずは念のためデータを初期化しておきます。

% bin/rails db:fixtures:load

 
次に、外部キー制約の変更を行うためにマイグレーションファイルを生成します。

テーブルに外部キー制約があるため、 1 : n のn側のテーブルに対するマイグレーションファイルを生成します。

# Child用
% bin/rails g migration ChangeFkToChild

# Grandchild用
bin/rails g migration ChangeFkToGrandchild

 
ファイルができたのでマイグレーションファイルに追記していきます。

マイグレーションファイルで列の変更をする場合は change_column が使えそうです。
https://api.rubyonrails.org/v7.0.3/classes/ActiveRecord/ConnectionAdapters/SchemaStatements.html#method-i-change_column

ただ、今回は外部キーの設定なため、使うのは不適切そうでした。また、change_foreign_key のようなメソッドは見当たりませんでした。

そこで、

  • 既存のデータを削除しない
  • 外部キーの削除 > 外部キーの設定 の順で変更する

を満たすマイグレーションファイルを作成します。

 
Child用

class ChangeFkToChild < ActiveRecord::Migration[7.0]
  def change
    # 既存のFKを削除
    remove_foreign_key :children, :parents

    # FKを追加
    add_foreign_key :children, :parents, on_delete: :cascade
  end
end

 
Grandchild用

class ChangeFkToGrandhild < ActiveRecord::Migration[7.0]
  def change
    # 既存のFKを削除
    remove_foreign_key :grandchildren, :children

    # FKを追加
    add_foreign_key :grandchildren, :children, on_delete: :cascade
  end
end

 
マイグレーションファイルの準備ができたので実行します。

% bin/rails db:migrate

== 20220514011956 ChangeFkToChild: migrating ==================================
-- remove_foreign_key(:children, :parents)
   -> 0.0156s
-- add_foreign_key(:children, :parents, {:on_delete=>:cascade})
   -> 0.0130s
== 20220514011956 ChangeFkToChild: migrated (0.0287s) =========================
== 20220514013014 ChangeFkToGrandhild: migrating ==============================
-- remove_foreign_key(:grandchildren, :children)
   -> 0.0112s
-- add_foreign_key(:grandchildren, :children, {:on_delete=>:cascade})
   -> 0.0119s
== 20220514013014 ChangeFkToGrandhild: migrated (0.0233s) =====================

 
マイグレーション後のDDLを見ると、 on delete cascade が追加されていました。

-- auto-generated definition
create table children
(
    id         integer     not null
        primary key,
    name       varchar default NULL,
    parent_id  integer     not null
        constraint fk_rails_554cba9b33
            references parents
            on delete cascade,
    created_at datetime(6) not null,
    updated_at datetime(6) not null
);

create index index_children_on_parent_id
    on children (parent_id);

 
また、データもそのまま残っていました。

>> Child.all
   (3.6ms)  SELECT sqlite_version(*)
  Child Load (0.3ms)  SELECT "children".* FROM "children"
=> [#<Child:0x000000010ab28ff8 id: 242255126, name: "親1の子2", parent_id: 393698370, created_at: Wed, 11 May 2022 14:39:13.895500000 UTC +00:00, updated_at: Wed, 11 May 2022 14:39:13.895500000 UTC +00:00>, #<Child:0x000000010ab58758 id: 393860266, name: "親1の子1", parent_id: 393698370, created_at: Wed, 11 May 2022 14:39:13.895500000 UTC +00:00, updated_at: Wed, 11 May 2022 14:39:13.895500000 UTC +00:00>, #<Child:0x000000010ab58690 id: 476916307, name: "親2の子", parent_id: 243142138, created_at: Wed, 11 May 2022 14:39:13.895500000 UTC +00:00, updated_at: Wed, 11 May 2022 14:39:13.895500000 UTC +00:00>]

 
これで外部キー制約に ON DELETE CASCADE を付けたときの準備ができました。

 

モデルにdependent なし

モデルから dependent 設定を削除します。

Parent

class Parent < ApplicationRecord
  before_destroy -> { puts '[Parent] before destroy' }
  after_destroy -> { puts '[Parent] after destroy' }

  has_many :children  # dependent 設定なし
end

Child

class Child < ApplicationRecord
  before_destroy -> { puts '[Child] before destroy' }
  after_destroy -> { puts '[Child] after destroy' }

  belongs_to :parent
  has_many :grandchildren  # dependent 設定なし
end

 
Railsコンソールで実行すると、 parents テーブル削除の DELETE 文が1つだけログに出ていました。

>> Parent.find_by(name: '親1').destroy

   (3.7ms)  SELECT sqlite_version(*)
  Parent Load (0.2ms)  SELECT "parents".* FROM "parents" WHERE "parents"."name" = ? LIMIT ?  [["name", "親1"], ["LIMIT", 1]]
[Parent] before destroy
  TRANSACTION (0.2ms)  begin transaction
  Parent Destroy (0.7ms)  DELETE FROM "parents" WHERE "parents"."id" = ?  [["id", 393698370]]
[Parent] after destroy
  TRANSACTION (0.8ms)  commit transaction
=> #<Parent:0x000000010b570fb0 id: 393698370, name: "親1", created_at: Wed, 11 May 2022 14:39:13.902472000 UTC +00:00, updated_at: Wed, 11 May 2022 14:39:13.902472000 UTC +00:00>

 
次にデータを確認すると、Parent・Child・Grandchild の各テーブルから対象データが削除されていました。

>> Parent.all
  Parent Load (0.2ms)  SELECT "parents".* FROM "parents"
=> [#<Parent:0x000000010b5b9aa8 id: 243142138, name: "親2", created_at: Wed, 11 May 2022 14:39:13.902472000 UTC +00:00, updated_at: Wed, 11 May 2022 14:39:13.902472000 UTC +00:00>]
>> Child.all
  Child Load (0.3ms)  SELECT "children".* FROM "children"
=> [#<Child:0x000000010b5f2650 id: 476916307, name: "親2の子", parent_id: 243142138, created_at: Wed, 11 May 2022 14:39:13.895500000 UTC +00:00, updated_at: Wed, 11 May 2022 14:39:13.895500000 UTC +00:00>]
>> Grandchild.all
  Grandchild Load (0.2ms)  SELECT "grandchildren".* FROM "grandchildren"
=> []

 

モデルにdependent: destroy あり

まずはデータを初期化します。

% bin/rails db:fixtures:load

 
続いて、モデルの has_manydependent: destroy を追加します。

Parent

class Parent < ApplicationRecord
  before_destroy -> { puts '[Parent] before destroy' }
  after_destroy -> { puts '[Parent] after destroy' }
  
  has_many :children, dependent: :destroy  # 追加
end

 
Child

class Child < ApplicationRecord
  before_destroy -> { puts '[Child] before destroy' }
  after_destroy -> { puts '[Child] after destroy' }

  belongs_to :parent
  has_many :grandchildren, dependent: :destroy  # 追加
end

 
Railsコンソールで実行すると削除されました。外部キー制約に ON DELETE CASCADE は設定されていますが、都度 DELETE文を発行し、孫から消していっています。

>> Parent.find_by(name: '親1').destroy

   (3.7ms)  SELECT sqlite_version(*)
  Parent Load (0.2ms)  SELECT "parents".* FROM "parents" WHERE "parents"."name" = ? LIMIT ?  [["name", "親1"], ["LIMIT", 1]]
[Parent] before destroy
  TRANSACTION (0.1ms)  begin transaction
  Child Load (0.1ms)  SELECT "children".* FROM "children" WHERE "children"."parent_id" = ?  [["parent_id", 393698370]]
[Child] before destroy
  Grandchild Load (0.1ms)  SELECT "grandchildren".* FROM "grandchildren" WHERE "grandchildren"."child_id" = ?  [["child_id", 242255126]]
  Grandchild Destroy (0.4ms)  DELETE FROM "grandchildren" WHERE "grandchildren"."id" = ?  [["id", 394275835]]
  Child Destroy (0.1ms)  DELETE FROM "children" WHERE "children"."id" = ?  [["id", 242255126]]
[Child] after destroy
[Child] before destroy
  Grandchild Load (0.0ms)  SELECT "grandchildren".* FROM "grandchildren" WHERE "grandchildren"."child_id" = ?  [["child_id", 393860266]]
  Grandchild Destroy (0.0ms)  DELETE FROM "grandchildren" WHERE "grandchildren"."id" = ?  [["id", 111204660]]
  Grandchild Destroy (0.1ms)  DELETE FROM "grandchildren" WHERE "grandchildren"."id" = ?  [["id", 531204236]]
  Child Destroy (0.1ms)  DELETE FROM "children" WHERE "children"."id" = ?  [["id", 393860266]]
[Child] after destroy
  Parent Destroy (0.1ms)  DELETE FROM "parents" WHERE "parents"."id" = ?  [["id", 393698370]]
[Parent] after destroy
  TRANSACTION (0.9ms)  commit transaction
=> #<Parent:0x0000000113b188e8 id: 393698370, name: "親1", created_at: Sat, 14 May 2022 01:36:18.699254000 UTC +00:00, updated_at: Sat, 14 May 2022 01:36:18.699254000 UTC +00:00>

 

モデルにdependent: delete_all あり

まずはデータを初期化します。

% bin/rails db:fixtures:load

 
続いて、モデルの has_many について dependent: delete_all へと追加します。

Parent

class Parent < ApplicationRecord
  before_destroy -> { puts '[Parent] before destroy' }
  after_destroy -> { puts '[Parent] after destroy' }

  has_many :children, dependent: :delete_all  # 変更
end

Child

class Child < ApplicationRecord
  before_destroy -> { puts '[Child] before destroy' }
  after_destroy -> { puts '[Child] after destroy' }

  belongs_to :parent
  has_many :grandchildren, dependent: :delete_all  # 変更
end

 
実行するとデータが削除されました。ログを見ると

  • Parentは、DELETE文の発行とコールバックの実行ログがある
  • Childは、DELETE文の発行のみログがあり、コールバックは実行されていない
  • Grandchildは、なにもログに残っていない

という状態でした。

>> Parent.find_by(name: '親1').destroy

   (3.3ms)  SELECT sqlite_version(*)
  Parent Load (0.5ms)  SELECT "parents".* FROM "parents" WHERE "parents"."name" = ? LIMIT ?  [["name", "親1"], ["LIMIT", 1]]
[Parent] before destroy
  TRANSACTION (0.1ms)  begin transaction
  Child Delete All (0.5ms)  DELETE FROM "children" WHERE "children"."parent_id" = ?  [["parent_id", 393698370]]
  Parent Destroy (0.1ms)  DELETE FROM "parents" WHERE "parents"."id" = ?  [["id", 393698370]]
[Parent] after destroy
  TRANSACTION (0.8ms)  commit transaction
=> #<Parent:0x000000010c3e0988 id: 393698370, name: "親1", created_at: Sat, 14 May 2022 01:42:00.738531000 UTC +00:00, updated_at: Sat, 14 May 2022 01:42:00.738531000 UTC +00:00>

 
そこでデータを確認すると、テーブルの ON DELETE CASCADE に従い、孫も削除されているようでした。

>> Parent.all
  Parent Load (0.8ms)  SELECT "parents".* FROM "parents"
=> [#<Parent:0x000000010c473328 id: 243142138, name: "親2", created_at: Sat, 14 May 2022 01:42:00.738531000 UTC +00:00, updated_at: Sat, 14 May 2022 01:42:00.738531000 UTC +00:00>]
>> Child.all
  Child Load (0.7ms)  SELECT "children".* FROM "children"
=> [#<Child:0x000000010c47a060 id: 476916307, name: "親2の子", parent_id: 243142138, created_at: Sat, 14 May 2022 01:42:00.728159000 UTC +00:00, updated_at: Sat, 14 May 2022 01:42:00.728159000 UTC +00:00>]
>> Grandchild.all
  Grandchild Load (0.6ms)  SELECT "grandchildren".* FROM "grandchildren"
=> []

 

まとめ

外部キーに ON DELETE CASCADE の設定があるかどうかに関わらず、 has_many dependent: destroy は 孫 > 子 > 親 の順番で1つずつDELETEしていました。

もしDELETE文の発行回数を減らすにはマイグレーションファイルの外部キー設定で on_delete: :cascade を指定した上で、

  • has_many には何も指定しない場合、親用のDELETE文を1回発行して削除する
  • has_many dependent: delete_all の場合、親・子に対してDELETEを合計2回発行して削除する

とすれば良さそうでした。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/rails_association-sample

 
今回のプルリクはこちらです。各段階でコミットしています。
https://github.com/thinkAmi-sandbox/rails_association-sample/pull/2