はじめに
こんにちは、エンジニアの大本です。
親子関係を持つデータで親レコードの削除時に関連する子レコードを削除する方法は様々あると思います。
その中でも本記事ではActiveRecordのdependentオプションとデータベースの外部キー制約によるON DELETE CASCADEでの削除の挙動の違いを検証します。
- dependentオプション
- ON DELETE CASCADE
どちらも親レコードを削除する際に関連する子レコードを削除できるようにする設定です。
dependentオプションの挙動
TODOアプリを想定して検証を行なっていきます。
Railsのバージョンは7.2.0を使用しています。
migrationは以下のようにします。
def change
create_table :users do |t|
t.timestamps
t.string :name
end
end
end
class CreateTodos < ActiveRecord::Migration[7.2]
def change
create_table :todos do |t|
t.timestamps
t.string :title
t.references :user, null: false, foreign_key: true
end
end
end
modelは以下のようにします。
class User < ApplicationRecord
has_many :todos
end
class Todo < ApplicationRecord
belongs_to :user
end
まずはdependentオプションを設定せずに挙動を確認します。
userを1件作成し関連するtodoを2件作成した後にuserの削除を実行します。
> rails c
[1] pry(main)> user = User.create(name: 'test1')
=>
[2] pry(main)> todo1 = Todo.create(title: 'todo1', user:)
=>
[3] pry(main)> todo2 = Todo.create(title: 'todo2', user:)
=>
[4] pry(main)> user.destroy
TRANSACTION (0.2ms) BEGIN
User Destroy (5.7ms) DELETE FROM users WHERE users.id = 1
TRANSACTION (0.3ms) ROLLBACK
ActiveRecord::InvalidForeignKey: Mysql2::Error: Cannot delete or update a parent row: a foreign key constraint fails (dependent_demo_app_development.todos, CONSTRAINT fk_rails_d94154aa95 FOREIGN KEY (user_id) REFERENCES users (id))
from /Users/divx/.rbenv/versions/3.3.4/lib/ruby/gems/3.3.0/gems/mysql2-0.5.6/lib/mysql2/client.rb:151:in _query' Caused by Mysql2::Error: Cannot delete or update a parent row: a foreign key constraint fails (dependent_demo_app_development.todos, CONSTRAINTfk_rails_d94154aa95FOREIGN KEY (user_id) REFERENCESusers(id)) from /Users/divx/.rbenv/versions/3.3.4/lib/ruby/gems/3.3.0/gems/mysql2-0.5.6/lib/mysql2/client.rb:151:in_query'
削除対象のuserに関連するtodoが存在するため、ActiveRecord::InvalidForeignKeyエラーが発生し、削除が失敗しました。
このエラーは、親レコードを削除する前に関連する子レコードが存在しているためデータベースの外部キー制約に違反していることを示しています。
userモデルを修正してdependentオプションを設定します。
class User < ApplicationRecord
has_many :todos, dependent: :destroy
end
同じように削除を実行します。
> rails c
[1] pry(main)> user = User.first
=>
[2] pry(main)> Todo.count
=> 2
[3] pry(main)> user.destroy
TRANSACTION (0.3ms) BEGIN
Todo Load (2.1ms) SELECT `todos`.* FROM `todos` WHERE `todos`.`user_id` = 1
Todo Destroy (0.5ms) DELETE FROM `todos` WHERE `todos`.`id` = 1
Todo Destroy (0.2ms) DELETE FROM `todos` WHERE `todos`.`id` = 2
User Destroy (0.9ms) DELETE FROM `users` WHERE `users`.`id` = 1
TRANSACTION (0.3ms) COMMIT
=>
[4] pry(main)> Todo.count
=> 0
dependent: :destroyオプションを設定したことで、userの削除と関連するtodoの削除が行われました。
ログを見るとuserを削除する前にuserに関連するtodoを取得し削除していることが確認できます。
ON DELETE CASCADEの挙動
次に、ON DELETE CASCADEを設定した場合の挙動に移ります。
この設定によって、親レコードが削除されるとどのような影響があるのかを検証します。
migrationを以下のように修正します。
class CreateTodos < ActiveRecord::Migration[7.2]
def change
create_table :todos do |t|
t.timestamps
t.string :title
t.references :user, null: false
end
add_foreign_key :todos, :users, on_delete: :cascade
end
end
userモデルのdependentオプションは削除します。
class User < ApplicationRecord
has_many :todos
end
先ほどと同じようにuserを1件作成し関連するtodoを2件作成した後にuserの削除を実行します。
> rails c
[1] pry(main)> user = User.create(name: 'test1')
=>
[2] pry(main)> todo1 = Todo.create(title: 'todo1', user:)
=>
[3] pry(main)> todo2 = Todo.create(title: 'todo2', user:)
=>
[4] pry(main)> user.destroy
TRANSACTION (0.3ms) BEGIN
User Destroy (1.3ms) DELETE FROM users WHERE users.id = 1
TRANSACTION (0.4ms) COMMIT
=>
[5] pry(main)> Todo.count
=> 0
削除に成功しました。
ログを確認するとdependentオプションを設定した時とは違い、user削除の前に事前にtodoを削しておらずuser削除と同時に削除されていることが確認できます。
条件付き削除の検証
ここでtodosテーブルにboolean型のcompletedカラムを追加します。
そしてcompletedがtrueでなければtodoは削除できないという条件で再度検証を行います。
migrationを以下のように修正します。
class CreateTodos < ActiveRecord::Migration[7.2]
def change
create_table :todos do |t|
t.timestamps
t.string :title
t.references :user, null: false
t.boolean :completed, default: false
end
add_foreign_key :todos, :users, on_delete: :cascade
end
end
todoモデルのcallback(before_destroy)でcompletedがtrueであるか検証する処理を追加します。
class Todo < ApplicationRecord
belongs_to :user
before_destroy :validate_deletable_todo
private
def validate_deletable_todo
if !completed?
logger.error("completedがfalseの場合は削除できません => #{self.inspect}")
throw :abort
end
end
end
completedがtrueとfalseのtodoをそれぞれ用意して削除を実行してみます。
> rails c
[1] pry(main)> user = User.create(name: 'test1')
=>
[2] pry(main)> todo1 = Todo.create(title: 'todo1', user:, completed: true)
=>
id: 1,
created_at: "2024-08-17 18:34:21.935231000 +0000",
updated_at: "2024-08-17 18:34:21.935231000 +0000",
title: "todo1",
user_id: 1,
completed: true>
[3] pry(main)> todo2 = Todo.create(title: 'todo2', user:)
=>
id: 2,
created_at: "2024-08-17 18:34:24.943868000 +0000",
updated_at: "2024-08-17 18:34:24.943868000 +0000",
title: "todo2",
user_id: 1,
completed: false>
[4] pry(main)> user.destroy
TRANSACTION (0.3ms) BEGIN
User Destroy (1.6ms) DELETE FROM users WHERE users.id = 1
TRANSACTION (1.3ms) COMMIT
=>
[5] pry(main)> Todo.count
=> 0
todo2はcompleted: falseであるにも関わらず削除されてしまいました。
ON DELETE CASCADEでの関連レコードの削除はcallback(before_destroy)が呼ばれていないことが確認できました。
userモデルを修正して再度dependentオプションを設定します。
class User < ApplicationRecord
has_many :todos, dependent: :destroy
end
同じように削除を実行してみます。
>rails c
[1] pry(main)> user = User.create(name: 'test1')
=>
[2] pry(main)> todo1 = Todo.create(title: 'todo1', user:, completed: true)
=>
id: 1,
created_at: "2024-08-17 18:42:54.120385000 +0000",
updated_at: "2024-08-17 18:42:54.120385000 +0000",
title: "todo1",
user_id: 1,
completed: true>
[3] pry(main)> todo2 = Todo.create(title: 'todo2', user:)
=>
id: 2,
created_at: "2024-08-17 18:42:55.742995000 +0000",
updated_at: "2024-08-17 18:42:55.742995000 +0000",
title: "todo2",
user_id: 1,
completed: false>
[4] pry(main)> user.destroy
TRANSACTION (0.2ms) BEGIN
Todo Load (0.8ms) SELECT `todos`.* FROM `todos` WHERE `todos`.`user_id` = 1
Todo Destroy (0.2ms) DELETE FROM `todos` WHERE `todos`.`id` = 1
completedがfalseの場合は削除できません =>
TRANSACTION (0.3ms) ROLLBACK
=> false
[5] pry(main)> Todo.count
Todo Count (0.5ms) SELECT COUNT(*) FROM `todos`
=> 2
callback(before_destroy)が処理され削除に失敗しました。
completedがtrueでなければtodoは削除できないという条件に沿った処理にできました。
補足
この段階ではON DELETE CASCADEとdependentオプションを併用した状態になっています。
アプリケーションから削除した場合は上記のように条件に沿った削除が実行できます。
しかし、アプリケーションを通さず直接DBからレコードを削除した場合はcompletedがfalseのレコードであっても削除されるためビジネスロジックが適用されません。
関連レコードの削除に条件を指定したい場合はON DELETE CASCADEは解除しておくと良さそうです。
違いのまとめ
dependentオプション
- 定義
- Railsにおいてモデル間の関連を定義することで、親レコードが削除されると関連する子レコード削除されます。
- 実行タイミング
- アプリケーションレベルで、親レコードが削除される際に実行されます。
今回はdestroyでの検証を行いましたが、delete_all, nullifyなどの複数のオプションがあります。
- ビジネスロジックの適用
- ActiveRecordのコールバックにてビジネスロジックを実行することができます。
- パフォーマンス
- 削除条件を指定できるなどの柔軟性がありますが、複雑なロジックを指定したり大量のレコードを削除する場合はパフォーマンスに影響する可能性があります。
ON DELETE CASCADE
- 定義
- データベースの外部キー制約の一部であり、親レコードが削除されると関連する子レコードも自動的に削除されます。
- 実行タイミング
- データベースレベルで、親レコードが削除された瞬間に実行されます。
- ビジネスロジックの適用
- データベースレベルでの自動削除のため、特定のビジネスロジックを実行することはできません。
- パフォーマンス
- 削除条件を指定するなどの柔軟性はありませんが、効率良く削除ができます。
最後に
今回はRailsアプリケーションにおけるActiveRecordのdependentオプションとデータベースの外部キー制約によるON DELETE CASCADEでの削除の挙動の違いを検証してみました。
どちらを選ぶかはプロジェクトの要件、チームの開発スタイル、具体的な使用ケースによって変わると思いますが、関連レコードの削除時に条件を指定するかどうかは一つの判断基準になりそうです。
最後までご覧いただきありがとうございました。