Railsで外部キー制約の参照制約の書き方

# はじめに
　Rails(というかActive Record)で「TableAを削除時、TableAを参照しているTableBも削除する」というように実装しようと思った時、DBの外部キー参照制約を使おうと思いました。ただ、「Rails 外部キー制約」などで検索しても思いの外、目的の情報が引っかからなかったので、ここでまとめようと思います。

# Migrationファイルを使用して、DBに設定する方法


下記のURLを参照。

- [Rails4で外部キー制約をつけるときに見直すメモ](http://niku.name/2015/05/22/Rails4%E3%81%A7%E5%A4%96%E9%83%A8%E3%82%AD%E3%83%BC%E5%88%B6%E7%B4%84%E3%82%92%E3%81%A4%E3%81%91%E3%82%8B%E3%81%A8%E3%81%8D%E3%81%AB%E8%A6%8B%E7%9B%B4%E3%81%99%E3%83%A1%E3%83%A2.html)

- [ActiveRecord::ConnectionAdapters::SchemaStatements - add_foreign_key](http://api.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/SchemaStatements.html#method-i-add_foreign_key)

migrationファイルを作成します。

```shell
$ bundle exec rails generate migration CreateOrderModel

Running via Spring preloader in process 30867
      invoke  active_record
      create    db/migrate/20171218102816_create_order_model.rb
```

作成された db/migrate/20171218102816_create_order_model.rbを編集して
参照先テーブル、参照元テーブルを作ります。

```ruby
$ vi db/migrate/20171218102816_create_order_model.rb

class CreateOrderModel < ActiveRecord::Migration[5.1]
  def change
    create_table :orders do |t|
      t.string :address
    end

    create_table :order_details do |t|
      t.references :order, index: true
      t.string :item_name
      t.integer :item_num
    end

    add_foreign_key :order_details, :orders, on_delete: :cascade
  end
end
```

`on_delete`だと削除時がトリガーとなります。`cascade`で参照元が削除された場合、参照しているテーブルを削除するようになります。


- `on_update`
- `on_delete`

といったトリガーに

- `:nullify`
- `:cascade`
- `:restrict`


という種別。意味はもちろんSQLの外部キー参照制約と同じです。

- [MySQLの外部キー制約RESTRICT,CASCADE,SET NULL,NO ACTIONの違いは？](https://qiita.com/suin/items/21fe6c5a78c1505b19cb)


migrateでテーブルを作成します。

```shell
$ bundle exec rake db:migrate
== 20171218102816 CreateOrderModel: migrating =================================
-- create_table(:orders)
   -> 0.0098s
-- create_table(:order_details)
   -> 0.0110s
-- add_foreign_key(:order_details, :orders, {:on_delete=>:cascade})
   -> 0.0191s
== 20171218102816 CreateOrderModel: migrated (0.0404s) ========================
```

さらにモデルも作成します。

```ruby

$ vi app/models/order.rb

class Order < ApplicationRecord
end

$ vi app/models/orderdetail.rb

class OrderDetail < ApplicationRecord
end
```

これで完了。 rails consoleでテストします。

```ruby
$ bundle exec rails c
Running via Spring preloader in process 28634
Loading development environment (Rails 5.1.4)

# Orderを作成
irb(main):001:0> order = Order.create(address: '東京都目黒区')
   (0.3ms)  SET NAMES utf8,  @@SESSION.sql_mode = CONCAT(CONCAT(@@sql_mode, ',STRICT_ALL_TABLES'), ',NO_AUTO_VALUE_ON_ZERO'),  @@SESSION.sql_auto_is_null = 0, @@SESSION.wait_timeout = 2147483
   (0.1ms)  BEGIN
  SQL (0.3ms)  INSERT INTO `orders` (`address`) VALUES ('東京都目黒区')
   (9.3ms)  COMMIT
=> #<Order id: 1, address: "東京都目黒区">

# OrderDetailを作成、Orderを参照
irb(main):002:0> detail = OrderDetail.create(order_id: order.id, item_name: 'タブレットPC', item_num: 1)
   (0.1ms)  BEGIN
  SQL (0.2ms)  INSERT INTO `order_details` (`order_id`, `item_name`, `item_num`) VALUES (1, 'タブレットPC', 1)
   (0.3ms)  COMMIT
=> #<OrderDetail id: 1, order_id: 1, item_name: "タブレットPC", item_num: 1>

# OrderDetailを作成されているのを確認
irb(main):003:0> OrderDetail.find(1)
  OrderDetail Load (0.4ms)  SELECT  `order_details`.* FROM `order_details` WHERE `order_details`. 'id' = 1 LIMIT 1
=> #<OrderDetail id: 1, order_id: 1, item_name: "タブレットPC", item_num: 1>

# Orderを削除
irb(main):004:0> order.delete
  SQL (1.2ms)  DELETE FROM `orders` WHERE `orders`. 'id' = 1
=> #<Order id: 1, address: "東京都目黒区">

# OrderDetailが削除されているのを確認
irb(main):005:0> OrderDetail.find(1)
  OrderDetail Load (0.4ms)  SELECT  `order_details`.* FROM `order_details` WHERE `order_details`. `id` = 1 LIMIT 1
ActiveRecord::RecordNotFound: Couldn t find OrderDetail with 'id'=1

```
参照先のテーブルが削除されたことにより、参照元テーブルも削除されてました。
テーブルの設定を見てもらえれば分かりますが、order_detailsテーブルに
`ON DELETE CASCADE`の設定が入っています。

# ActiveRecordのみの設定でカバーする方法

下記のURLを参照。

- [RailsGuid Active Record の関連付け (アソシエーション)](https://railsguides.jp/association_basics.html)

ActiveRecordの`dependency: delete`など使っても同様のことができるようです。
`dependency: delete`を試すために、一度RollbackしてDB設定を初期化します。

```shell
$ bundle exec rake db:rollback
== 20171218102816 CreateOrderModel: reverting =================================
-- remove_foreign_key(:order_details, :orders)
   -> 0.0241s
-- drop_table(:order_details)
   -> 0.0020s
-- drop_table(:orders)
   -> 0.0033s
== 20171218102816 CreateOrderModel: reverted (0.0334s) ========================

$ bundle exec rake db:migrate:status
database: test_project_development

 Status   Migration ID    Migration Name
--------------------------------------------------
  down    20171218102816  Create order model

```

Rollback実施後、add_foreign_keyの設定を無効化します[^1]。

```ruby
$ vi db/migrate/20171218102816_create_order_model.rb

class CreateOrderModel < ActiveRecord::Migration[5.1]
  def change
    create_table :orders do |t|
      t.string :address
    end

    create_table :order_details do |t|
      t.references :order, index: true
      t.string :item_name
      t.integer :item_num
    end

    # add_foreign_key :order_details, :orders, on_delete: :cascade
  end
end
```

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

```ruby

$ vi app/models/order.rb

class Order < ApplicationRecord
  has_many :order_details, :dependent => :destroy
end

$ vi app/models/orderdetail.rb

class OrderDetail < ApplicationRecord
  belongs_to :order
end
```

migrateで再度、テーブルを作成します。
その後、rails consoleでテストします。

```ruby
$ bundle exec rake db:migrate
$ bundle exec rails c
Running via Spring preloader in process 28634
Loading development environment (Rails 5.1.4)

# Orderを作成
irb(main):001:0> order = Order.create(address: '東京都目黒区')
   (0.3ms)  SET NAMES utf8,  @@SESSION.sql_mode = CONCAT(CONCAT(@@sql_mode, ',STRICT_ALL_TABLES'), ',NO_AUTO_VALUE_ON_ZERO'),  @@SESSION.sql_auto_is_null = 0, @@SESSION.wait_timeout = 2147483
   (0.1ms)  BEGIN
  SQL (0.3ms)  INSERT INTO `orders` (`address`) VALUES ('東京都目黒区')
   (9.3ms)  COMMIT
=> #<Order id: 1, address: "東京都目黒区">

# OrderDetailを作成、Orderを参照
irb(main):002:0> detail = OrderDetail.create(order_id: order.id, item_name: 'タブレットPC', item_num: 1)
   (0.1ms)  BEGIN
  SQL (0.2ms)  INSERT INTO `order_details` (`order_id`, `item_name`, `item_num`) VALUES (1, 'タブレットPC', 1)
   (0.3ms)  COMMIT
=> #<OrderDetail id: 1, order_id: 1, item_name: "タブレットPC", item_num: 1>

# OrderDetailを作成されているのを確認
irb(main):003:0> OrderDetail.find(1)
  OrderDetail Load (0.4ms)  SELECT  `order_details`.* FROM `order_details` WHERE `order_details`. 'id' = 1 LIMIT 1
=> #<OrderDetail id: 1, order_id: 1, item_name: "タブレットPC", item_num: 1>

# Orderを削除
irb(main):004:0> order.delete
  SQL (1.4ms)  DELETE FROM `orders` WHERE `orders`. 'id' = 1

# OrderDetailが削除されているのを確認
irb(main):005:0> OrderDetail.find(1)
  OrderDetail Load (0.4ms)  SELECT  `order_details`.* FROM `order_details` WHERE `order_details`. 'id' = 1 LIMIT 1
=> #<OrderDetail id: 1, order_id: 1, item_name: "タブレットPC", item_num: 1>
```

あれ？ 普通に残ってる。
もしかして、OrderDetailを作成する前の古いインスタンスを使って削除した影響？

再度、DBをRollback後、migrateして再チャレンジします。

```ruby
$ bundle exec rake db:rollback
$ bundle exec rake db:migrate

$ bundle exec rails c
Running via Spring preloader in process 28634
Loading development environment (Rails 5.1.4)

# Orderを作成
irb(main):001:0> order = Order.create(address: '東京都目黒区')
   (0.3ms)  SET NAMES utf8,  @@SESSION.sql_mode = CONCAT(CONCAT(@@sql_mode, ',STRICT_ALL_TABLES'), ',NO_AUTO_VALUE_ON_ZERO'),  @@SESSION.sql_auto_is_null = 0, @@SESSION.wait_timeout = 2147483
   (0.1ms)  BEGIN
  SQL (0.3ms)  INSERT INTO `orders` (`address`) VALUES ('東京都目黒区')
   (9.3ms)  COMMIT
=> #<Order id: 1, address: "東京都目黒区">

# OrderDetailを作成、Orderを参照
irb(main):002:0> detail = OrderDetail.create(order_id: order.id, item_name: 'タブレットPC', item_num: 1)
   (0.1ms)  BEGIN
  SQL (0.2ms)  INSERT INTO `order_details` (`order_id`, `item_name`, `item_num`) VALUES (1, 'タブレットPC', 1)
   (0.3ms)  COMMIT
=> #<OrderDetail id: 1, order_id: 1, item_name: "タブレットPC", item_num: 1>

# OrderDetailを作成されているのを確認
irb(main):003:0> OrderDetail.find(1)
  OrderDetail Load (0.4ms)  SELECT  `order_details`.* FROM `order_details` WHERE `order_details`. 'id' = 1 LIMIT 1
=> #<OrderDetail id: 1, order_id: 1, item_name: "タブレットPC", item_num: 1>

# Orderを確認、その後古いインスタンスが格納された変数orderを使用せずに削除。
irb(main):004:0> Order.find(1)
  Order Load (0.4ms)  SELECT  `orders`.* FROM `orders` WHERE `orders`. 'id' = 1 LIMIT 1
=> #<Order id: 1, address: "東京都目黒区">

irb(main):005:0> Order.find(1).destroy
  Order Load (0.4ms)  SELECT  `orders`.* FROM `orders` WHERE `orders`.'id' = 1 LIMIT 1
   (0.1ms)  BEGIN
  OrderDetail Load (0.4ms)  SELECT `order_details`.* FROM `order_details` WHERE `order_details`. 'order_id' = 1
  SQL (0.2ms)  DELETE FROM `order_details` WHERE `order_details`. 'id' = 1
  SQL (0.3ms)  DELETE FROM `orders` WHERE `orders`. 'id' = 1
   (16.9ms)  COMMIT
=> #<Order id: 1, address: "東京都目黒区">

# OrderDetailが削除されているのを確認
irb(main):006:0> OrderDetail.find(1)
  OrderDetail Load (0.4ms)  SELECT  `order_details`.* FROM `order_details` WHERE `order_details`. 'id' = 1 LIMIT 1
ActiveRecord::RecordNotFound: Couldn t find OrderDetail with 'id'=1
```

おお、無事削除できました。


# 最後に
　DBのテーブルにしっかりと設定したいので、今回はMigrationファイルに外部キー参照制約を書くようにしました。ActiveRecordがやってくれるのはいいですけど、やっぱりDB側の設定として入っていないとちょっと気持ち悪い感じがします。

[^1]: 設定を変更するために既存のmigrationファイルを編集していますが、あんまりよろしくない方法です。基本は新しくmigrationファイルを作成して、差分を追記していく形になるかと思います。この辺はmigrationファイルを使ってrollbackできるようにしておくか、もしくはrollbackしない前提にするかでも異なりますが。