Railsでrecordをdestroyする時、validation的に条件を設定する
Railsを使っていて、ある状態に当てはまらないときレコードを削除するdestroyを成功させたくないみたいなこと、あると思います。createするときのvalidationに対してdestroy時のvalidationというか。そんなときどうするか、という話です。
概要
- あるレコードが条件に合わないとき、レコードを削除できてしまうと困る
- 条件に合わない場合はdestroy時にエラーにして、削除が成功しないようにする
before destroy
とthrow abort
を使おう
動機
例えば1 projectに関連するの複数todoがあるとします。 Rails的に言えばproject has_many todos
ですね。そしてtodoはstateとしてbacklog
をデフォルトとして、wip
,done
と遷移していく3パターンの状態のどれかを持つとします。
この時、todoはwip
の状態では削除できないものとします。そしてprojectの削除時も関連するtodoの中にwip
のものが1つでもあれば削除させたくないとします。
このようなとき、Railsではdestroyできてしまえないようにするにはどんな方法があるでしょうか、という話です。
期待すること
あるレコードを削除するとき、条件に合わない場合レコードの削除を成功しないようにしたい。
さらにdependent: :destroy
など関連レコードを一緒に削除する場合、関連レコード側が削除できない条件にひっかかった場合、元もと削除しようとしたレコードも削除できてはいけない。
その制約をなんとかして担保したい。
実際やったこと
まず最小限のサンプルとしてRailsでProjectとTodoのモデルがどうなってるか確認したいと思います。
親となるProject
# /app/models/project.rb
# == Schema Information
#
# Table name: projects
#
# id :bigint not null, primary key
# title :string not null
# created_at :datetime not null
# updated_at :datetime not null
#
class Project < ApplicationRecord
has_many :todos, dependent: :destroy
end
そしてその子であるTodo
# /app/models/todo.rb
# == Schema Information
#
# Table name: todos
#
# id :bigint not null, primary key
# project_id :bigint not null
# title :string not null
# state :integer default(0) not null
# created_at :datetime not null
# updated_at :datetime not null
#
class Todo < ApplicationRecord
belongs_to :project
enum state: { backlog: 0, wip: 1, done: 2 }
end
という感じです。stateは今回はenumで定義してDB的にはIntegerにしました。
今この状態では特になにもしてないので、
todo.destroy!
とするとエラーもなくレコードが削除されますね。
projectがいくつかtodoをもっている場合、project.destroy!
すると、dependent: :destroy
が設定されているため、子のtodoも同時に削除されるはずです。コンソールでやるとなると
$ bin/rails c -s
#
> project = Project.create(title: 'new project')
...
> todo1 = Todo.create(title: 'todo1', project: project)
...
> todo2 = Todo.create(title: 'todo2', project: project)
...
# ここまでで project は 2つのtodoを持つ状態
> todo2.destroy!
# todo2単体が削除される
> project.destroy!
# projectと子のtodo1が削除される
という感じでしょうか。
では、前述のとおり、現在wipな状態にあるtodoを削除できなくさせたいとします。
これにはActiveRecord::Callbacks
の before_destroy
を使ってdestroy時に状態を確認するメソッドを作って指定します。今回はwipではないことを保証するとしてensure_not_in_progress
というメソッドをつくることにします。
# /app/models/todo.rb
class Todo < ApplicationRecord
belongs_to :project
enum state: { backlog: 0, wip: 1, done: 2 }
before_destroy :ensure_not_in_progress
private
def ensure_not_in_progress
if wip?
errors.add(:base, 'todo is still work in progress')
throw :abort
end
end
end
という感じですね。ちなみにRailsのenumを使うとenumの定義に?
がついた、 確認用のメソッドが自動で生えます。つまりwip?
というメソッドはenum定義時に自動で使えるようになっていて、stateがwip
の場合はtrue
を返してくれます。
そしてwip?
がtrue
なら、まずバリデーションメソッドと同じようにerrors.add
でエラーを追加します。そして大事なのがthrow :abort
です。これによって削除が成功しないように止めることができます。
ではまたコンソールで試してみましょう。
1projectに1つは普通のtodo、もう1つはwipなtodoを作ります。
$ bin/rails c -s
#
> project = Project.create(title: 'new project')
...
> todo = Todo.create(title: 'todo', project: project)
...
> wip_todo = Todo.create(title: 'wip_todo', project: project, state: :wip)
...
# ここまでで project は stateがbacklogとwipのtodo1つづつを持つ状態
# wip_todoの削除を試みる
> wip_todo.destroy!
ActiveRecord::RecordNotDestroyed: Failed to destroy the record
# 削除できないことを確かめられました!
はい、これで期待どおり削除できないようになりました。そのまま続けて親のprojectを削除しようとするとどうなるかも見ていきましょう
> project.destroy!
D, [2021-02-27T20:51:39.855214 #1555] DEBUG -- : (0.2ms) SAVEPOINT active_record_1
D, [2021-02-27T20:51:39.860684 #1555] DEBUG -- : Todo Load (4.7ms) SELECT `todos`.* FROM `todos` WHERE `todos`.`project_id` = 1
D, [2021-02-27T20:51:39.863723 #1555] DEBUG -- : Todo Destroy (0.3ms) DELETE FROM `todos` WHERE `todos`.`id` = 1
D, [2021-02-27T20:51:39.891990 #1555] DEBUG -- : (26.1ms) ROLLBACK TO SAVEPOINT active_record_1
ActiveRecord::RecordNotDestroyed: Failed to destroy the record
# 削除できないことを確認できた!
はい、こちらも削除できないですね。ちゃんとROLLBACKしてくれています。削除できるもう片方の子も削除されていませんね。
これであとはstateがwipじゃなくなったら削除できればいいですね、それも試してみましょう。
> wip_todo.update!(state: :done)
# wip_todo の state を done に変更
> project.reload
# projectのインスタンスをreloadして子のtodosを取得しなおし
> project.destroy!
D, [2021-02-27T20:54:53.359573 #1555] DEBUG -- : (0.2ms) SAVEPOINT active_record_1
D, [2021-02-27T20:54:53.361002 #1555] DEBUG -- : Todo Load (0.4ms) SELECT `todos`.* FROM `todos` WHERE `todos`.`project_id` = 1
D, [2021-02-27T20:54:53.364240 #1555] DEBUG -- : Todo Destroy (0.3ms) DELETE FROM `todos` WHERE `todos`.`id` = 1
D, [2021-02-27T20:54:53.365608 #1555] DEBUG -- : Todo Destroy (0.2ms) DELETE FROM `todos` WHERE `todos`.`id` = 2
D, [2021-02-27T20:54:53.366736 #1555] DEBUG -- : Project Destroy (0.2ms) DELETE FROM `projects` WHERE `projects`.`id` = 1
D, [2021-02-27T20:54:53.367482 #1555] DEBUG -- : (0.1ms) RELEASE SAVEPOINT active_record_1
はい、wipではないので期待どおりprojectとそれがもつ2つのtodoごと削除が成功しました。
今回あまり触れなかったこと
上記ではあまり触れてないですが、destroy!
としているところdestroy
にすると単純に失敗した場合falseが返ります。成否を判定して処理を変える場合でないのであれば基本的には!
付きのほうがエラーがわかりやすくていいですね。
あとは当然delete
やdelete_all
の場合は効きません。が、意図的にそれらを使わないときの削除は基本destroy
を使うのが一般的なので問題ないはずです。
感想
今回は削除時にある条件によっては削除が失敗する実装をしました。コールバックメソッドは賛否両論ありますが、今回のように1モデル内で簡潔するような単純な場合はアリかな、と思っています。
それでなかったら削除用のメソッドやサービスを別で作ることになり、それはそれで使い忘れるという不安な点もありますし。
ともあれ、簡単ですが使いどころは意外に多いので備忘録的に書いておきます。