Railsでrecordをdestroyする時、validation的に条件を設定する

Railsを使っていて、ある状態に当てはまらないときレコードを削除するdestroyを成功させたくないみたいなこと、あると思います。createするときのvalidationに対してdestroy時のvalidationというか。そんなときどうするか、という話です。

概要

  • あるレコードが条件に合わないとき、レコードを削除できてしまうと困る
  • 条件に合わない場合はdestroy時にエラーにして、削除が成功しないようにする
  • before destroythrow 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::Callbacksbefore_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が返ります。成否を判定して処理を変える場合でないのであれば基本的には!付きのほうがエラーがわかりやすくていいですね。

あとは当然deletedelete_allの場合は効きません。が、意図的にそれらを使わないときの削除は基本destroyを使うのが一般的なので問題ないはずです。

感想

今回は削除時にある条件によっては削除が失敗する実装をしました。コールバックメソッドは賛否両論ありますが、今回のように1モデル内で簡潔するような単純な場合はアリかな、と思っています。

それでなかったら削除用のメソッドやサービスを別で作ることになり、それはそれで使い忘れるという不安な点もありますし。

ともあれ、簡単ですが使いどころは意外に多いので備忘録的に書いておきます。

来た道、行く道 LifeJourney[39.4/2021-02](月次振り返り)翻訳をすることで学習につなげようとしたけど失敗した話。或いはDeepLが凄かった話。