DB上のテーブル間に外部キー制約があり、「親を消したら、親に関係する子も削除する」みたいな処理を行いたいとします。
この場合、テーブルの外部キー定義で ON DELETE
を指定することで対応できます。
そんな中、Railsでは「親を消したら、親に関係する子も削除する」をどのように実現するか調べてみたところ、
- モデルで
dependent
を指定する - マイグレーションファイルの外部キー定義で
on_delete: :cascade
を指定する
の2つの方法があるようでした。
そこで、それぞれの挙動を確認してみたため、メモを残します。
目次
環境
テーブルの準備
今回は
- 親 : 子 = 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".
とありました。
そのため、 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
になるようです。
- 4.3 has_many関連付けの詳細 | Active Record の関連付け - Railsガイド
- 4.2 has_one関連付けの詳細 | Active Record の関連付け - Railsガイド
まずは念のためデータを初期化しておきます。
% 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
のようなメソッドは見当たりませんでした。
そこで、
- 既存のデータを削除しない
外部キーの削除
>外部キーの設定
の順で変更する
を満たすマイグレーションファイルを作成します。
- add_foreign_key(from_table, to_table, **options) | ActiveRecord::ConnectionAdapters::SchemaStatements
- remove_foreign_key(from_table, to_table = nil, **options) | ActiveRecord::ConnectionAdapters::SchemaStatements
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_many
に dependent: 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