Ruby
Rails

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

TL;DR(最初にざっくり結論)

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

はじめに

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

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

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

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

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

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

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

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

before_destroyで削除を中断する場合

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

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

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

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_destroythrow :abortが呼ばれると、そこで処理が中断され、destroyメソッドがfalseを返します。

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

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オプションを設定します。

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

  # 省略
end

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

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例外を発生させます。
これがdestroydestroy!の違いです。

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

blog = Blog.first

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

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

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

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

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

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

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!を使うのもありかもしれません。

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)が発生するようになります。

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

  # 省略
end
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

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

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

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

# 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のコードはこんなふうになってます(参考)。
うーん、destroyの戻り値はチェックされていませんね。。。

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

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

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

まとめ

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

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