MySQLの外部キー制約(ON DELETE)を指定しているならRailsのdependentは未指定でOK！

今までMySQLの外部キー制約(ON DELETE)とRailsのアソシエーションに設定する`dependent`は、同じ挙動のものを設定しておけばいいだろうと思って特に意識せずに設定していましたが、本当にそうなのか気になったので動作を検証してみました。

# 前提条件

## MySQLの外部キー制約

今回の検証では、外部キー制約のDELETE時の挙動を検証します。
検証するのは下記の3つです。

* RESTRICT
* CASCADE
* SET NULL

外部キー制約についての詳細はMySQLのドキュメントをご覧ください。

https://dev.mysql.com/doc/refman/5.6/ja/create-table-foreign-keys.html

## dependent

dependentはhas_one/has_many/belongs_toに設定できますが、今回の検証ではhas_manyを使います。

検証するのはMySQLの外部キー制約に対応する下記の3つです。
restrict_with_errorはrestrict_with_exceptionと、destroyやdestroy_asyncはdelete_allと挙動が被るので省略します。

* restrict_with_exception(restrict_with_error)
* delete_all(destroy / destroy_async)
* nullify

dependentについての詳細はRailsガイドをご覧ください。

https://railsguides.jp/association_basics.html#dependent

## 検証で使うバージョン

この記事では下記のバージョンで検証しています。

* Ruby: 3.1.2
* Rails: 7.0.2.3
* MySQL: 8.0.27

## 検証で使うテーブル

検証では下記のテーブルを使います。

```sql
-- 親テーブル
CREATE TABLE `parents` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `created_at` datetime(6) NOT NULL,
  `updated_at` datetime(6) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci

-- 外部キー制約: RESTRICT
CREATE TABLE `children` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `parent_id` bigint NOT NULL,
  `created_at` datetime(6) NOT NULL,
  `updated_at` datetime(6) NOT NULL,
  PRIMARY KEY (`id`),
  KEY `parent_id` (`parent_id`),
  CONSTRAINT `parent_id_on_children` FOREIGN KEY (`parent_id`) REFERENCES `parents` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci

-- 外部キー制約: CASCADE
CREATE TABLE `cascade_children` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `parent_id` bigint NOT NULL,
  `created_at` datetime(6) NOT NULL,
  `updated_at` datetime(6) NOT NULL,
  PRIMARY KEY (`id`),
  KEY `parent_id` (`parent_id`),
  CONSTRAINT `parent_id_on_cascade_children` FOREIGN KEY (`parent_id`) REFERENCES `parents` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci

-- 外部キー制約: SET NULL
CREATE TABLE `set_null_children` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `parent_id` bigint DEFAULT NULL,
  `created_at` datetime(6) NOT NULL,
  `updated_at` datetime(6) NOT NULL,
  PRIMARY KEY (`id`),
  KEY `parent_id` (`parent_id`),
  CONSTRAINT `parent_id_on_set_null_children` FOREIGN KEY (`parent_id`) REFERENCES `parents` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
```

# 検証

## RESTRICTの検証

まずはRESTRICTを検証します。

### restrict_with_exception

dependentにrestrict_with_exceptionを指定します。

```ruby:app/models/parent.rb
class Parent < ApplicationRecord
  has_many :children, dependent: :restrict_with_exception
end
```

childrenが存在するparentを削除します。

```irb
irb(main):016:0> parent.destroy
  TRANSACTION (0.5ms)  BEGIN
  Child Exists? (1.3ms)  SELECT 1 AS one FROM `children` WHERE `children`.`parent_id` = 3 LIMIT 1
  TRANSACTION (0.5ms)  ROLLBACK
/usr/local/bundle/gems/activerecord-7.0.2.4/lib/active_record/associations/has_many_association.rb:16:in `handle_dependency': Cannot delete record because of dependent children (ActiveRecord::DeleteRestrictionError)
```

childrenの存在確認するためにchildrenテーブルにSELECT文が発行しています。
childrenが存在していることが確認できたら、RailsがActiveRecord::DeleteRestrictionErrorを発生させます。
DELETE文を発行する前に上記判定をしているため、DELETE文は発行されていません。

### 未指定

dependent未指定にします。

```ruby:app/models/parent.rb
class Parent < ApplicationRecord
  has_many :children
end
```

childrenが存在するparentを削除します。

```irb
irb(main):032:0> parent.destroy
  TRANSACTION (0.8ms)  BEGIN
  Parent Destroy (4.1ms)  DELETE FROM `parents` WHERE `parents`.`id` = 4
  TRANSACTION (3.3ms)  ROLLBACK
/usr/local/bundle/gems/mysql2-0.5.3/lib/mysql2/client.rb:131:in `_query': Mysql2::Error: Cannot delete or update a parent row: a foreign key constraint fails (`app_development`.`children`, CONSTRAINT `parent_id_on_children` FOREIGN KEY (`parent_id`) REFERENCES `parents` (`id`)) (ActiveRecord::InvalidForeignKey)
```

restrict_with_exceptionを指定した時と同様にエラーになります。
ただ、細かい挙動が異なります。
今回はchildrenの存在チェックするためのSELECT文が発行されず、いきなりDELETE文を発行しています。
DELETE文は発行されますがRESTRICTに違反したためMySQLでエラーが発生します。
そして、それを検知したRailsがActiveRecord::InvalidForeignKeyを発生させます。

## CASCADEの検証

次にCASCADEを検証します。

### delete_all

dependentにdelete_allを指定します。

```ruby:app/models/parent.rb
class Parent < ApplicationRecord
  has_many :cascade_children, dependent: :delete_all
end
```

childrenが存在するparentを削除します。

```irb
irb(main):004:0> parent.destroy
  TRANSACTION (0.6ms)  BEGIN
  CascadeChild Delete All (0.9ms)  DELETE FROM `cascade_children` WHERE `cascade_children`.`parent_id` = 1
  Parent Destroy (1.0ms)  DELETE FROM `parents` WHERE `parents`.`id` = 1
  TRANSACTION (3.3ms)  COMMIT
```

delete_allが指定されている場合、parentを削除する前にcascade_childrenのDELETE文が発行され、その後parentのDELETE文が発行されます。

### 未指定

dependent未指定にします。

```ruby:app/models/parent.rb
class Parent < ApplicationRecord
  has_many :cascade_children
end
```

childrenが存在するparentを削除します。

```irb
irb(main):011:0> parent.destroy
  TRANSACTION (0.8ms)  BEGIN
  Parent Destroy (1.4ms)  DELETE FROM `parents` WHERE `parents`.`id` = 2
  TRANSACTION (3.7ms)  COMMIT
irb(main):012:0> parent.cascade_children.count
  CascadeChild Count (1.0ms)  SELECT COUNT(*) FROM `cascade_children` WHERE `cascade_children`.`parent_id` = 2
=> 0
```

未指定の場合はparentに対してのみDELETE文が発行されます。
明示的にcascade_childrenにDELETE文は発行されていませんが、CASCADEを指定しているので削除されています。

## SET NULLの検証

最後にSET NULLを検証します。

### nullify

dependentにnullifyを指定します。

```ruby:app/models/parent.rb
class Parent < ApplicationRecord
  has_many :set_null_children, dependent: :nullify
end
```

childrenが存在するparentを削除します。

```irb
irb(main):017:0> parent.destroy
  TRANSACTION (0.6ms)  BEGIN
  SetNullChild Update All (1.3ms)  UPDATE `set_null_children` SET `set_null_children`.`parent_id` = NULL WHERE `set_null_children`.`parent_id` = 3
  Parent Destroy (1.0ms)  DELETE FROM `parents` WHERE `parents`.`id` = 3
  TRANSACTION (4.8ms)  COMMIT
```

nullifyが指定されている場合、parentを削除する前にset_null_childrenにUPDATE文が発行され、parent_idがNULLに更新され、その後parentのDELETE文が発行されます。

### 未指定

dependent未指定にします。

```ruby:app/models/parent.rb
class Parent < ApplicationRecord
  has_many :set_null_children
end
```

childrenが存在するparentを削除します。

```irb
irb(main):030:0> parent.destroy
  TRANSACTION (0.9ms)  BEGIN
  Parent Destroy (1.8ms)  DELETE FROM `parents` WHERE `parents`.`id` = 5
  TRANSACTION (5.3ms)  COMMIT
=> #<Parent:0x00007fdc287c6558 id: 5, status: 0, created_at: Mon, 02 May 2022 15:13:48.635508000 UTC +00:00, updated_at: Mon, 02 May 2022 15:13:48.635508000 UTC +00:00>
irb(main):031:0> SetNullChild.all
  SetNullChild Load (1.0ms)  SELECT `set_null_children`.* FROM `set_null_children`
=> 
[#<SetNullChild:0x00007fdc291c0680 id: 5, parent_id: nil, created_at: Mon, 02 May 2022 15:13:52.100174000 UTC +00:00, updated_at: Mon, 02 May 2022 15:13:52.100174000 UTC +00:00>,
 #<SetNullChild:0x00007fdc291c0158 id: 6, parent_id: nil, created_at: Mon, 02 May 2022 15:13:52.648940000 UTC +00:00, updated_at: Mon, 02 May 2022 15:13:52.648940000 UTC +00:00>]
```

未指定の場合はparentに対するDELETE文のみが発行されます。
明示的にset_null_childrenにUPDATE文は発行されていませんが、SET NULLを指定しているのでparent_idはNULLに更新されています。

# まとめ

ここまでの検証で、dependentは外部キー制約の挙動を模倣しているだけということがわかります。
ということは、外部キー制約をつけて、かつdependentを設定した場合は同じ処理を２箇所に実装していることと同義です。

同じ処理を２箇所に実装するのは無駄ですよね？1箇所にまとめるべきです。
どちらか1箇所にまとめる場合、私はデータベース側にまとめるのが良いと考えています。
理由はいくつかあります。

1つはdependentは動く時と動かない時があることです。
ActiveRecordで削除する場合、コールバックが動作する方法(destoryなど)と動作しない方法(delete_allなど)が存在します。
dependentはコールバックが動作する方法で削除した場合しか動作しません。
削除の場合には複数件を削除したいことがよくあり、その場合にはコールバックが動作しないdelete_allを使いたくなるのでdependentが動作しません。
また、親→子(delete_all)→孫(delete_all)と設定して親を削除した場合、子の削除がdelete_allで行われるので孫の削除が行われません。

次に(当然ですが)dependentはRailsを通して処理した場合にしか実行されません。
データを持っているのはデータベースです。そのため可能な限りデータの制約はデータを持っているデータベースに設定すべきだと思います。
外部キー制約をデータベースに設定しておけば、どこからデータを操作した場合も必ず制約に従ってデータが扱われ、制約違反のデータが存在しないことを保証してくれます。
dependentに設定した場合、それを定義しているRailsから操作した時のみ設定した通りの挙動をしますが、他から操作した場合の挙動は別途実装する必要があります。また、データベースに制約違反のデータが存在しないことは保証されません。

これらの理由から「データベースの外部キー制約を設定している場合はdependentは設定しない方が良い」という考えに至りました。