Hatena::ブログ(Diary)

富士山は世界遺産 このページをアンテナに追加 RSSフィード Twitter

2008-09-12

Railsを業務システムに適用するなら、acts_as_paranoidの導入を検討しましょう。論理削除を実現。

タイトル変えました。まえのタイトルは「railsで論理削除をやるなら、acts_as_paranoid plugin」

基幹システムなんかは内部統制などの関係でデータを完全に消し去る訳にはいきません。となると削除フラグを利用した論理削除を実装する事になります。業務アプリを作ると99%*1で実装されるでしょう。

とても面倒。そんな面倒なロジックも、acts_as_paranoidがあれば簡単実装です。

まずはインストールgitを使うので、git-coreを入れておきましょう。

$ cd RAILS_ROOT
$ ruby script/plugin install git://github.com/technoweenie/acts_as_paranoid.git

言う事無し。

acts_as_paranoidを利用するには二つの事をするだけです。

  • 論理削除を実装したいテーブルに deleted_at:datetime というカラムを作る。
  • 対応するモデルに acts_as_paranoid 利用を宣言する。

簡単ですね。

利用例を紹介しますが、最初からacts_as_paranoidを使うことが決まっているのは幸せです。よって、今回は後からacts_as_paranoidを実装するケースを紹介します。

成績表アプリを作ります。と言っても名前と点数を格納するだけです。

$ ruby script/generate model Grade name:string score:integer
$ rake db:migrate

はい、出来上がり。コントローラやビューは面倒なので作りません。

これだけでは空っぽのデータとか作りたい放題なので、ちと制限をかけましょう。

class Grade < ActiveRecord::Base
  validates_numericality_of :score, :only_integer => true, :greater_than_or_equal_to => 0, :less_than_or_equal_to => 100
end

scoreは整数で0以上100以下であること、という制限です。

さて、Gradeモデルに論理削除を実装します。まずはカラム作成から。

$ ruby script/generate migration AddDeleteFlagToGrade deleted_at:datetime
$ rake db:migrate

次はacts_as_paranoid利用の宣言です。と言ってもclass定義の直後あたりにacts_as_paranoidって書くだけですが。

class Grade < ActiveRecord::Base
  acts_as_paranoid
  validates_numericality_of :score, :only_integer => true, :greater_than_or_equal_to => 0, :less_than_or_equal_to => 100
end

おしまい。30秒で出来ますね。

動作を確認してみます。

$ ruby script/console
>> Grade.new(:name => 'fujisan1', :score => 10).save
=> true
>> Grade.find(:all)
=> [#<Grade id: 1, name: "fujisan1", score: 10, created_at: "2008-09-12 23:13:04", updated_at: "2008-09-12 23:13:04", deleted_at: nil>]
>> Grade.destroy_all
=> [#<Grade id: 1, name: "fujisan1", score: 10, created_at: "2008-09-12 23:13:04", updated_at: "2008-09-12 23:13:04", deleted_at: nil>]
>> Grade.find(:all)
=> []
>> Grade.find_with_deleted(:all)
=> [#<Grade id: 1, name: "fujisan1", score: 10, created_at: "2008-09-12 23:13:04", updated_at: "2008-09-12 23:13:04", deleted_at: "2008-09-12 23:13:20">]

destroy_allしたタイミングでdeleted_atに値が入ります。この値の有無で論理削除されたか否かを判断しています。詳しくはRAILS_ENV=developmentで吐かれるSQL見てください。

また、acts_as_paranoid利用を宣言したモデルには find_with_deleted が実装されます。これで、削除済みデータを対象にfindする事も可能です。*2

acts_as_paranoidの良いところは

  • findだけでなくfind_by_HOGEやcount/sumなど、ファインダーメソッド全てで自動的にdeleted_atの値確認をしてくれる
  • destroy、destroy_allメソッドをSQLのDELETE文からUPDATE SET deleted_at = に置き換えてくれる

ところにあります。要するに、既存のコードの書き換えを極めて少なく済ませる事ができるのです。

$ ruby script/console
>> Grade.new(:name => 'fujisan1', :score => 10).save
=> true
>> Grade.new(:name => 'fujisan2', :score => 20).save
=> true
>> Grade.new(:name => 'fujisan3', :score => 100).save
=> true
>> Grade.count(:all)
=> 3
>> Grade.sum(:score)
=> 130
>> Grade.find_by_name('fujisan2').destroy
=> #<Grade id: 2, name: "fujisan2", score: 20, created_at: "2008-09-12 23:19:22", updated_at: "2008-09-12 23:19:22", deleted_at: nil>
>> Grade.count(:all)
=> 2
>> Grade.sum(:score)
=> 110
>> Grade.find_with_deleted(:all).size
=> 3

意図的にwith_deletedを呼ばない限り、論理削除されたデータは表に出てきません。

さて、本気で削除したくなるときもあるでしょう。その時は破壊的メソッドを呼びましょう。

$ ruby script/console
>> Grade.new(:name => 'fujisan1', :score => 10).save
=> true
>> Grade.new(:name => 'fujisan2', :score => 20).save
=> true
>> Grade.new(:name => 'fujisan3', :score => 100).save
=> true
>> Grade.find_by_name('fujisan1').destroy!
=> #<Grade id: 1, name: "fujisan1", score: 10, created_at: "2008-09-12 23:27:19", updated_at: "2008-09-12 23:27:19", deleted_at: nil>
>> Grade.find_with_deleted(:all)
=> [#<Grade id: 2, name: "fujisan2", score: 20, created_at: "2008-09-12 23:27:25", updated_at: "2008-09-12 23:27:25", deleted_at: nil>, #<Grade id: 3, name: "fujisan3", score: 100, created_at: "2008-09-12 23:27:31", updated_at: "2008-09-12 23:27:31", deleted_at: nil>]

destroy_all! destroy! がacts_as_paranoidによって実装されます。

ここまではacts_as_paranoidの基本的な機能を紹介しました。これに加えて、業務システムで使うコツを紹介。

一意制約に注意

acts_as_paranoidを利用しているモデルで一意制約を使用する場合、ちょっとしたコツが必要です。

まずはGradeモデルにnameで一意になるよう、モデル上で制約を加えてみます。

class Grade < ActiveRecord::Base
  acts_as_paranoid
  validates_numericality_of :score, :only_integer => true, :greater_than_or_equal_to => 0, :less_than_or_equal_to => 100
  validates_uniqueness_of :name, :case_sensitive => false
end

これで実装完了。試してみましょう。

$ ruby script/console
>> Grade.new(:name => 'fujisan1', :score => 10).save
=> true
>> Grade.find(1).destroy
=> #<Grade id: 1, name: "fujisan1", score: 10, created_at: "2008-09-12 23:33:42", updated_at: "2008-09-12 23:33:42", deleted_at: nil>
>> Grade.find(:all)
=> []
>> Grade.new(:name => 'fujisan1', :score => 10).save
=> false

あれ、保存できませんでした。削除されているわけですから保存できても良さそうです。

残念な事に、acts_as_paranoidはsave(createやupdate)までは面倒を見てくれません。save時には全件を対象に一意の調査してしまうのです。

これを回避するには:scopeを指定してしまうのが良いでしょう。

class Grade < ActiveRecord::Base
  acts_as_paranoid
  validates_numericality_of :score, :only_integer => true, :greater_than_or_equal_to => 0, :less_than_or_equal_to => 100
  validates_uniqueness_of :name, :case_sensitive => false, :scope => :deleted_at
end

実行してみます。

$ ruby script/console
>> Grade.new(:name => 'fujisan1', :score => 10).save
=> true
>> Grade.find(1).destroy
=> #<Grade id: 1, name: "fujisan1", score: 10, created_at: "2008-09-12 23:36:51", updated_at: "2008-09-12 23:36:51", deleted_at: nil>
>> Grade.find(:all)
=> []
>> Grade.new(:name => 'fujisan1', :score => 10).save
=> true
>> Grade.find(:all)
=> [#<Grade id: 2, name: "fujisan1", score: 10, created_at: "2008-09-12 23:37:00", updated_at: "2008-09-12 23:37:00", deleted_at: nil>]
>> Grade.find_with_deleted(:all)
=> [#<Grade id: 1, name: "fujisan1", score: 10, created_at: "2008-09-12 23:36:51", updated_at: "2008-09-12 23:36:51", deleted_at: "2008-09-12 23:36:54">, #<Grade id: 2, name: "fujisan1", score: 10, created_at: "2008-09-12 23:37:00", updated_at: "2008-09-12 23:37:00", deleted_at: nil>]

無事、意図通り動きました。

DBのunique indexを使う場合も制約条件にdeleted_atを加えれば同じ動作になるでしょう。

論理削除の復活

簡単です。deleted_at = nilです。

$ ruby script/console
>> Grade.new(:name => 'fujisan1', :score => 10).save
=> true
>> Grade.find(1).destroy
=> #<Grade id: 1, name: "fujisan1", score: 10, created_at: "2008-09-12 23:56:22", updated_at: "2008-09-12 23:56:22", deleted_at: nil>
>> Grade.find(:all)
=> []
>> a = Grade.find_with_deleted(1)
=> #<Grade id: 1, name: "fujisan1", score: 10, created_at: "2008-09-12 23:56:22", updated_at: "2008-09-12 23:56:22", deleted_at: "2008-09-12 23:56:28">
>> a.deleted_at = nil
=> nil
>> a.save
=> true
>> Grade.find(1)
=> #<Grade id: 1, name: "fujisan1", score: 10, created_at: "2008-09-12 23:56:22", updated_at: "2008-09-12 23:56:42", deleted_at: nil>

同様なものにacts_as_soft_deletableがあります。こっちは、削除後のデータをアーカイブ用のテーブルを用意し、移動するものです。

次回はacts_as_soft_deletableをご紹介。

*1:fujisan3776調べ

*2:findのオプションで:with_deleted => trueを指定しても同じです。Model#find(:all, :with_deleted => true)

スパム対策のためのダミーです。もし見えても何も入力しないでください
ゲスト


画像認証