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で自動生成されたコントローラのコードは以下のようになっています。
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
を実行します。
以下はそのコード例です。
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
を返します。
以下は削除が中断される場合と、中断されない場合の実行例です。
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
この場合もやはり、削除が中断された場合はdestroy
がfalse
を返します。
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!
に変更すると次のようになります。
blog = Blog.first
blog.published = true
blog.destroy! #=> ActiveRecord::RecordNotDestroyed (Failed to destroy the record)
議論:データ削除時のコントローラの設計について
save
やupdate
と同様、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
予期せぬトラブルを防ぐためには、create
やupdate
と同じように戻り値をチェックすべきかもしれません。
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らしい書き方」なのかどうかは確信がありません。
詳しい人がいたらコメント欄等で教えてください
参考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の書き方はこうだ!」というご意見もお待ちしています)