ActiveRecordにおけるdestroyとdestroy!の違い

## TL;DR（最初にざっくり結論）

- `destroy` : 削除できたら真の値（削除したインスタンス自身）、できなかったら偽の値（`false`）を返す。
- `destroy!` : 削除できたら真の値（削除したインスタンス自身）、できなかったら`ActiveRecord::RecordNotDestroyed`例外を発生させる。
- 削除に失敗する例
  - `before_destroy`コールバックで`throw :abort`された場合
  - `dependent: :restrict_with_error`が設定され、なおかつ関連する子レコードを持つ親レコードを削除しようとした場合

## はじめに

ActiveRecordにはデータを削除するメソッドとして、`destroy`と`destroy!`があります。
これは`save`と`save!`の関係によく似ています（`save`は検証エラーが発生したときに`false`を返し、`save!`は例外を発生させる）。

ですが、Scaffoldでコントローラを作ったりすると、`destroy`メソッドの戻り値は特にチェックしていません。

たとえば、Scaffoldで自動生成されたコントローラのコードは以下のようになっています。

```ruby
class BlogsController < ApplicationController
  # 省略

  def create
    @blog = Blog.new(blog_params)

    respond_to do |format|
      # saveメソッドの戻り値はチェックしている
      if @blog.save
        format.html { redirect_to @blog, notice: 'Blog was successfully created.' }
        format.json { render :show, status: :created, location: @blog }
      else
        format.html { render :new }
        format.json { render json: @blog.errors, status: :unprocessable_entity }
      end
    end
  end

  # 省略

  def destroy
    # destroyメソッドの戻り値はチェックしていない
    @blog.destroy
    respond_to do |format|
      format.html { redirect_to blogs_url, notice: 'Blog was successfully destroyed.' }
      format.json { head :no_content }
    end
  end

  # 省略
end
```

Scaffoldがこのようなコードになっているため、「削除に失敗するケース」自体を考慮していない人も意外と多いのではないかと思います。

そこで、この記事では削除に失敗するケースと、`destroy`と`destroy!`の使い分けについて説明していきます。

### 対象となるRailsのバージョン

この記事はRails 5.1.4を対象にしています。

## 基礎知識：削除処理の中断とdestroyメソッドの戻り値について

### before_destroyで削除を中断する場合

`save`や`update`を呼びだした場合は、モデルのバリデーションが呼ばれますが、`destroy`の場合は呼ばれません。

`destroy`ではバリデーションが使えないため、もし特定の条件下でデータの削除を防止したい場合は、`before_destroy`コールバックを定義し、そこで`throw :abort`を実行します。

以下はそのコード例です。

```ruby
class Blog < ApplicationRecord
  has_many :comments

  before_destroy :should_not_destroy_if_published

  private

  def should_not_destroy_if_published
    if published?
      # 公開済みのブログは削除できない
      throw :abort
    end
  end
end
```

`before_destroy`で`throw :abort`が呼ばれると、そこで処理が中断され、`destroy`メソッドが`false`を返します。

以下は削除が中断される場合と、中断されない場合の実行例です。

```ruby
blog = Blog.first

# 公開済みのブログは削除できない
blog.published = true
blog.destroy    #=> false
blog.destroyed? #=> false

# 非公開であれば削除できる
blog.published = false
blog.destroy    #=> #<Blog:0x00007f80af154a50>
blog.destroyed? #=> true
```

### 関連する子レコードが存在するときに削除を止める場合

他にも関連する子レコードがある場合に削除を防止することもできます。

たとえば、「ブログ記事に1件でもコメントが付いていたら削除できないようにする」という要件があった場合は、次のように`dependent: :restrict_with_error`オプションを設定します。

```ruby
class Blog < ApplicationRecord
  # 1件でもコメントが付いていたら削除禁止
  has_many :comments, dependent: :restrict_with_error
  
  # 省略
end
```

この場合もやはり、削除が中断された場合は`destroy`が`false`を返します。

```ruby
blog = Blog.first

# コメントがあると削除できない
blog.comments.count #=> 1
blog.destroy    #=> false
blog.destroyed? #=> false

# コメントを全件削除する
blog.comments.destroy_all
blog.comments.count #=> 0

# コメントがなければ削除できる
blog.destroy    #=> #<Blog:0x00007f80af154a50>
blog.destroyed? #=> true
```

このように、`destroy`メソッドは毎回確実にデータを削除するのではなく、条件によっては削除を中断する場合があります。

## destroy!メソッドはfalseを返す代わりに例外を発生させる

`destroy!`メソッドは削除が中断された場合に`ActiveRecord::RecordNotDestroyed`例外を発生させます。
これが`destroy`と`destroy!`の違いです。

たとえば、先ほど示した「公開済みのブログは削除できない」の実行例を`destroy`から`destroy!`に変更すると次のようになります。

```ruby
blog = Blog.first

blog.published = true
blog.destroy! #=> ActiveRecord::RecordNotDestroyed (Failed to destroy the record)
```

## 議論：データ削除時のコントローラの設計について

`save`や`update`と同様、`destroy`も条件によっては「削除されないケース」がある以上、戻り値を無視してしまうのは少し危険な気がします。

たとえば、Scaffoldが吐き出したコードをそのまま使うと、削除が中断された場合も、画面上には常に"Blog was successfully destroyed."が表示されてしまいます。

```ruby
class BlogsController < ApplicationController
  # 省略

  def destroy
    # 戻り値をチェックしていないので、削除が中断されていても気づけない
    @blog.destroy
    respond_to do |format|
      format.html { redirect_to blogs_url, notice: 'Blog was successfully destroyed.' }
      format.json { head :no_content }
    end
  end

  # 省略
end
```

予期せぬトラブルを防ぐためには、`create`や`update`と同じように戻り値をチェックすべきかもしれません。

```ruby
def destroy
  respond_to do |format|
    # 戻り値をチェックして、削除が中断された場合も考慮する
    if @blog.destroy
      format.html { redirect_to blogs_url, notice: 'Blog was successfully destroyed.' }
      format.json { head :no_content }
    else
      format.html {
        @blogs = Blog.all
        render :index
      }
      format.json { head :unprocessable_entity }
    end
  end
end
```

もしくは「中断されることはまずないはずだが、万一中断されたらすぐに気づきたい」という場合は、`destroy!`を使うのもありかもしれません。

```ruby
def destroy
  # 万一、削除が中断されたときは例外が発生するので、最悪すぐに気づける
  @blog.destroy!
  respond_to do |format|
    format.html { redirect_to blogs_url, notice: 'Blog was successfully destroyed.' }
    format.json { head :no_content }
  end
end
```

ただ、こういったコードはあまり見かけないので、これが「Railsらしい書き方」なのかどうかは確信がありません。
詳しい人がいたらコメント欄等で教えてください :bow:

## 参考1: 関連する子レコードが存在したら例外を発生させる場合

先ほど、「関連する子レコードが存在するときに削除を止める場合」で、`dependent: :restrict_with_error`オプションを紹介しましたが、これによく似たオプションで`dependent: :restrict_with_exception`もあります。

このオプションを使うと、（`destroy!`を呼び出したときだけでなく）`destroy`を呼びだしたときにも例外（`ActiveRecord::DeleteRestrictionError`）が発生するようになります。

```ruby
class Blog < ApplicationRecord
  # 1件でもコメントが付いていたら削除禁止（例外を発生させる）
  has_many :comments, dependent: :restrict_with_exception
  
  # 省略
end
```

```ruby
blog = Blog.first

blog.comments.count #=> 1
blog.destroy
#=> ActiveRecord::DeleteRestrictionError (Cannot delete record because of dependent comments)
```

うっかり`destroy`の戻り値をチェックし忘れるリスクを考慮すると、`:restrict_with_exception`を指定して、最悪、例外の発生で削除中断に気づけるようにしておいた方がいいかもしれません。

参考: [dependent: :restrict\_with\_error と :restrict\_with\_exception の違い \- Qiita](https://qiita.com/jnchito/items/3456ce734ef41d216ecd)

## 参考2: destroy_allを使う場合の注意点

複数のレコードをまとめて削除する`destroy_all`には、`destroy_all!`のような"!"付きのメソッドが用意されていません。
また、`destroy_all`の戻り値は「削除対象となったモデルの配列」であり、成功/失敗を表すような真偽値ではありません。

もし、対象のレコードが2件あり、一方が削除中断、もう一方が削除実行された場合は、片方のレコードだけが削除されます。

```ruby
# 2件のレコードがある
Blog.count #=> 2
blog_1, blog_2 = Blog.all

# blog_1は公開済みなので削除できない、blog_2は未公開なので削除できる
blog_1.published? #=> true
blog_2.published? #=> false

# destroy_allを実行する（戻り値はモデルの配列）
Blog.destroy_all #=> [#<Blog id: 3, (省略)>, #<Blog id: 4, (省略)>]

# blog_2だけが削除され、blog_1は残ったままになる
Blog.count #=> 1
blog_1.reload.destroyed? #=> false
blog_2.reload
#=> ActiveRecord::RecordNotFound (Couldn't find Blog with 'id'=4)
```

ちなみに、`destroy_all`のコードはこんなふうになってます（[参考](https://github.com/rails/rails/blob/v5.1.4/activerecord/lib/active_record/relation.rb#L443-L445)）。
うーん、`destroy`の戻り値はチェックされていませんね。。。

```ruby
def destroy_all
  records.each(&:destroy).tap { reset }
end
```

なので、もし確実に全件が削除されたことを保証したい（なおかつ、全件削除されなければロールバックしたい）場合は、次のように全件が`destroyed?`になっているかチェックする必要があります。

```ruby
Blog.transaction do
  records = Blog.destroy_all
  unless records.all?(&:destroyed?)
    # 全件削除できていなければロールバック
    raise ActiveRecord::Rollback
  end
end
```

## まとめ

というわけで、この記事ではActiveRecordにおけるdestroyとdestroy!の違いについてまとめてみました。

ネットを検索してもこのあたりの情報がまとまっているページをほとんど見かけないので、よかったら参考にしてみてください。
（「Railsらしいdestroyの書き方はこうだ！」というご意見もお待ちしています）