Help us understand the problem. What is going on with this article?

PaperTrailはどうやってActiveRecordのバージョン管理をしているか

More than 5 years have passed since last update.

この記事はRails Advent Calendar 2014の21日目の記事です。

Qiitaでは投稿の履歴管理にpaper_trailというgemを使っています。本稿ではPaperTrailがどんな感じでイベント情報をDBに保存しているかを紹介しつつ、PaperTrailが作り出すversionオブジェクトの渡り歩き方を簡単に解説したいと思います。

PaperTrailを使ってみよう

前提

PaperTrailで管理している Item modelがあり、そのインスタンスを2回編集してから削除したとします。

image

コードで表現するならこんな感じです。

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)を作ります。

image

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 になります。

image

作成時の状態は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 変更前の状態を復元する

図にするとこのようになります。

image

reify はこのversionの元々状態を復元した Item を作り出します。実体はDBに保存されていない状態ですが、ここで save することで過去の状態にロールバックさせることが可能です。

>> init_state = second_version.reify
#=> #<Item body: 'foo'>

PaperTrailで管理されるmodelのメソッド

Item にも便利メソッドが追加されます。

メソッド名 返り値
previous_version 前の状態
next_version 次の状態
version この状態を生成したversion

image

versionでの差分を知るにはnextと比較する

さて、versionには変更前の状態が格納されている、ということは、どういう風に変化したかを知るには、次のversionと比較すればいいことになります。

まずは手元の次のversionを取ってきましょう。

>> third_version = second_version.next
#=> #<PaperTrail::Version event: 'update', ...>

そして、そのときの状態を復元します。

>> second_state = third_version.reify
#=> #<Item body: 'bar'>

この second_stateinit_state.next_version で作ることもできます。

>> init_state.next_version
#=> #<Item body: 'bar'>

あとは init_statesecond_state を比較することで、 second_version による差分を得ることができます。

updateなversionでnextがnilの場合

手元のversionのeventが "update" なのに nextnil になる場合があります。これはそれ以降 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のデータは今回簡単に説明したようにそれなりに複雑です。完全に正しいデータを作らないと、極稀に発生する不正データ(再現方法不明)のための例外処理がもりもり生えていきます。

:innocent:

中間テーブルも一緒に保存する

結局いきついた結論は、中間テーブルごと履歴に突っ込んでしまえ、です。

これをやるにはPaperTrailのコードを睨みつつ Item でメソッドを上書きして挙動を変えてやらねばなりません。上書きするのは write_attributeobject_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をしっかり読んでから導入することをおすすめします。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away