この記事はRails Advent Calendar 2014の21日目の記事です。
Qiitaでは投稿の履歴管理にpaper_trailというgemを使っています。本稿ではPaperTrailがどんな感じでイベント情報をDBに保存しているかを紹介しつつ、PaperTrailが作り出すversionオブジェクトの渡り歩き方を簡単に解説したいと思います。
PaperTrailを使ってみよう
前提
PaperTrailで管理している Item
modelがあり、そのインスタンスを2回編集してから削除したとします。
コードで表現するならこんな感じです。
class Item < ActiveRecord::Base
has_paper_trail
end
item = Item.create(body: 'foo')
item.update_attributes(body: 'bar')
item.update_attributes(body: 'baz')
item.destroy
このときPaperTrailは自動的にversionsテーブルに4つのレコード(create, update, update, destroy)を作ります。
PaperTrail::Version
を使って確認することができます。
>> PaperTrail::Version.all
#=> [#<PaperTrail::Version event: 'create', ...>,
#<PaperTrail::Version event: 'update', ...>,
#<PaperTrail::Version event: 'update', ...>,
#<PaperTrail::Version event: 'destroy', ...>]
objectには変更前の状態が入っている
versionsテーブルはobjectカラムを持っています。PaperTrailはversionを作る度に、そのカラムにmodelの属性を保存します(デフォルトでYAMLを使う)。確かめてみましょう。
>> init_version = PaperTrail::Version.first
#=> #<PaperTrail::Version event: 'create', ...>
>> YAML.load(init_version.object)
#=> nil
あれ?最初の Item
は { body: 'foo' }
で保存しましたが、objectが空になっています。
実はobjectは 変更される前の状態 が格納されています。なのでcreate versionのobjectは必ず nil
になります。
作成時の状態は2番目のversionが持っていることになります。確認してみましょう。
>> second_version = PaperTrail::Version.second
#=> #<PaperTrail::Version event: 'update', ...>
>> YAML.load(second_version.object)
#=> { 'id' => 1, 'body' => 'foo' }
PaperTrailの機能
PaperTrail::Versionのメソッド
PaparTrail::Version
は便利なメソッドをいろいろ持っています。特によく使うものは以下の4つです。
メソッド名 | 返り値 |
---|---|
previous |
前のversionオブジェクトを返す |
next |
次のversionオブジェクトを返す |
index |
自分が何番目かを返す |
reify |
変更前の状態を復元する |
図にするとこのようになります。
reify
はこのversionの元々状態を復元した Item
を作り出します。実体はDBに保存されていない状態ですが、ここで save
することで過去の状態にロールバックさせることが可能です。
>> init_state = second_version.reify
#=> #<Item body: 'foo'>
PaperTrailで管理されるmodelのメソッド
Item
にも便利メソッドが追加されます。
メソッド名 | 返り値 |
---|---|
previous_version |
前の状態 |
next_version |
次の状態 |
version |
この状態を生成したversion |
versionでの差分を知るにはnextと比較する
さて、versionには変更前の状態が格納されている、ということは、どういう風に変化したかを知るには、次のversionと比較すればいいことになります。
まずは手元の次のversionを取ってきましょう。
>> third_version = second_version.next
#=> #<PaperTrail::Version event: 'update', ...>
そして、そのときの状態を復元します。
>> second_state = third_version.reify
#=> #<Item body: 'bar'>
この second_state
は init_state.next_version
で作ることもできます。
>> init_state.next_version
#=> #<Item body: 'bar'>
あとは init_state
と second_state
を比較することで、 second_version
による差分を得ることができます。
updateなversionでnextがnilの場合
手元のversionのeventが "update"
なのに next
が nil
になる場合があります。これはそれ以降 Item
が変更されていない、ということを意味します。なので、DBから今の Item
のレコードを取ってきて比較することで差分を得ることができます。
今回の例では最後にdestroyしてしまっていますが、仮にそういうversionがあったとするとこんな感じになります。
>> version.event
#=> "update"
>> version.next
#=> nil
>> next_state = Item.find(version.item_id)
#=> #<Item body: 'hoge'>
暗黒面
さてここまで読むと「PaperTrailって便利だなー」って感じですが、ここからはPaperTrailの闇の面についてです。簡単な話が、has-manyやhas-many-throughを管理しようとすると一気に辛い感じになってきます。
has-many-throughを管理しようとすると地獄を見る
理由はいくつかあって
-
削除不整合が起きる
{ tag_id: 1 }
がversionsの中にある一方で、id=1なtagは既に削除されているとすると、履歴から状態を復元することができなくなります。- tagもPaperTrailで管理し、削除されている場合はdestroy versionを探して取ってくる
- そもそもtagを削除できないようにする
などをする必要があります。
-
どのversionが一緒に発生したものかを別途保存する必要がある
投稿とタグを同時に編集すると、投稿のversionと中間テーブルのversionが同時に作成されます。PaperTrailにはこの2つが一緒に作られた、というのを管理する機能が無いので、それを別途自作する必要があります。
-
中間テーブルの状態を復元するのが大変
一般的に中間テーブルは基本的に変更されません。関係を変更しようにも、前後で関係の数が同じ保証がないので、既存のものを削除して、新しく作りなおすためです。
そのため作成されるversionはcreateとdestroyだけになります。これは差分情報です。つまりある時刻を与えられた時に、その投稿にどういうタグがついていたのか、を知るには一番最初の状態から順番順番に追加・削除されたタグを適応していくことでしか計算ができません。 -
補助データにバグがあると死ぬ
以上の問題に対処するために、自前のデータ構造を導入するとします。PaperTrailのデータは今回簡単に説明したようにそれなりに複雑です。完全に正しいデータを作らないと、極稀に発生する不正データ(再現方法不明)のための例外処理がもりもり生えていきます。
中間テーブルも一緒に保存する
結局いきついた結論は、中間テーブルごと履歴に突っ込んでしまえ、です。
これをやるにはPaperTrailのコードを睨みつつ Item
でメソッドを上書きして挙動を変えてやらねばなりません。上書きするのは write_attribute
と object_attrs_for_paper_trail
の2つ。あと中間テーブルの情報をまるで自身の属性のように扱えるようにします。
以下は非常に雑なサンプルです(こんなことやる必要がある人がそんなに沢山いるとは思えないので)。
class Item < ActiveRecord::Base
has_paper_trail
def write_attribute_with_tag_data(attr, value)
if attr.to_sym == :tag_data
self.tag_data = value
save_changed_attribute(attr, value)
else
write_attribute_without_tag_data(attr, value)
end
end
alias_method_chain :write_attribute, :tag_data
def object_attrs_for_paper_trail_with_tag_data(object)
object_attrs_for_paper_trail_without_tag_data(object)
.merge('tag_data' => tag_data_was)
end
alias_method_chain :object_attrs_for_paper_trail, :tag_data
def tag_data_was
changed_attributes[:tag_data] || tag_data
end
def tag_data
# 中間テーブルの情報からハッシュの配列みたいなのデータを作って返す
end
def tag_data=(value)
if value != tag_data
save_changed_attributes(:tag_data, tag_data)
end
# valueを保持し、after_saveとかでDBに反映する
value
end
end
おわりに
paper_trailを使うととりあえずActiveRecordのバージョン管理をすることができます。しかし、何も考えずに導入すると、データが増えた時に足元をすくわれることにもなりかねないので、事前にREADMEをしっかり読んでから導入することをおすすめします。