DIVX テックブログ

catch-img

Railsにおける参照レコードの削除について ~dependentオプションとON DELETE CASCADEの違い~

はじめに

こんにちは、エンジニアの大本です。

親子関係を持つデータで親レコードの削除時に関連する子レコードを削除する方法は様々あると思います。

その中でも本記事ではActiveRecordの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
# Userの作成
[1] pry(main)> user = User.create(name: 'test1')
=> #<User:0x000000012c8c11a8 id: 1, created_at: "2024-08-17 17:17:12.419534000 +0000", updated_at: "2024-08-17 17:17:12.419534000 +0000", name: "test1">

# Todoの作成
[2] pry(main)> todo1 = Todo.create(title: 'todo1', user:)
=> #<Todo:0x000000012caf03c0 id: 1, created_at: "2024-08-17 17:17:15.085604000 +0000", updated_at: "2024-08-17 17:17:15.085604000 +0000", title: "todo1", user_id: 1>
[3] pry(main)> todo2 = Todo.create(title: 'todo2', user:)
=> #<Todo:0x000000012c4e7be0 id: 2, created_at: "2024-08-17 17:17:18.412263000 +0000", updated_at: "2024-08-17 17:17:18.412263000 +0000", title: "todo2", user_id: 1>

# 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
# Userの取得                                                                                              
[1] pry(main)> user = User.first
=> #<User:0x000000012bf61860 id: 1, created_at: "2024-08-17 17:17:12.419534000 +0000", updated_at: "2024-08-17 17:17:12.419534000 +0000", name: "test1">

# Todoのカウント
[2] pry(main)> Todo.count
=> 2

# Userの削除
[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
=> #<User:0x000000012bf61860 id: 1, created_at: "2024-08-17 17:17:12.419534000 +0000", updated_at: "2024-08-17 17:17:12.419534000 +0000", name: "test1">

# Todoのカウント
[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
# Userの作成
[1] pry(main)> user = User.create(name: 'test1')
=> #<User:0x000000012a0c8890 id: 1, created_at: "2024-08-17 17:31:54.255552000 +0000", updated_at: "2024-08-17 17:31:54.255552000 +0000", name: "test1">

# Todoの作成
[2] pry(main)> todo1 = Todo.create(title: 'todo1', user:)
=> #<Todo:0x000000012a390050 id: 1, created_at: "2024-08-17 17:31:58.327171000 +0000", updated_at: "2024-08-17 17:31:58.327171000 +0000", title: "todo1", user_id: 1>
[3] pry(main)> todo2 = Todo.create(title: 'todo2', user:)
=> #<Todo:0x0000000129c2a798 id: 2, created_at: "2024-08-17 17:32:03.020374000 +0000", updated_at: "2024-08-17 17:32:03.020374000 +0000", title: "todo2", user_id: 1>

# 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
=> #<User:0x000000012a0c8890 id: 1, created_at: "2024-08-17 17:31:54.255552000 +0000", updated_at: "2024-08-17 17:31:54.255552000 +0000", name: "test1">

# Todoのカウント
[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
# Userの作成
[1] pry(main)> user = User.create(name: 'test1')
=> #<User:0x000000012b9ba1c8 id: 1, created_at: "2024-08-17 18:34:12.453995000 +0000", updated_at: "2024-08-17 18:34:12.453995000 +0000", name: "test1">

# Todoの作成
[2] pry(main)> todo1 = Todo.create(title: 'todo1', user:, completed: true)
=> #<Todo:0x000000012bede780
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:)
=> #<Todo:0x0000000128fa6cd8
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>

# Userの削除
[4] pry(main)> user.destroy
TRANSACTION (0.3ms) BEGIN
User Destroy (1.6ms) DELETE FROM users WHERE users.id = 1
TRANSACTION (1.3ms) COMMIT
=> #<User:0x000000012b9ba1c8 id: 1, created_at: "2024-08-17 18:34:12.453995000 +0000", updated_at: "2024-08-17 18:34:12.453995000 +0000", name: "test1">

# Todoのカウント
[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                                                                                                                        
# Userの作成
[1] pry(main)> user = User.create(name: 'test1')
=> #<User:0x000000012a7d3540 id: 1, created_at: "2024-08-17 18:42:52.513226000 +0000", updated_at: "2024-08-17 18:42:52.513226000 +0000", name: "test1">

# Todoの作成
[2] pry(main)> todo1 = Todo.create(title: 'todo1', user:, completed: true)
=> #<Todo:0x000000012c75e478
 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:)
=> #<Todo:0x000000012c9dfc88
 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>

# Userの削除
[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の場合は削除できません => #<Todo 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>
  TRANSACTION (0.3ms)  ROLLBACK
=> false

# Todoのカウント
[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での削除の挙動の違いを検証してみました。

どちらを選ぶかはプロジェクトの要件、チームの開発スタイル、具体的な使用ケースによって変わると思いますが、関連レコードの削除時に条件を指定するかどうかは一つの判断基準になりそうです。

最後までご覧いただきありがとうございました。

お気軽にご相談ください


ご不明な点はお気軽に
お問い合わせください

サービス資料や
お役立ち資料はこちら

DIVXブログ

テックブログ タグ一覧

採用ブログ タグ一覧

人気記事ランキング

GoTopイメージ