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

この記事は[Rails Advent Calendar 2014](http://qiita.com/advent-calendar/2014/rails)の21日目の記事です。

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

# PaperTrailを使ってみよう

## 前提

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

![image](https://qiita-image-store.s3.amazonaws.com/0/153/703ff443-020f-096b-82ac-5724da5df8c2.png)

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

```rb
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](https://qiita-image-store.s3.amazonaws.com/0/153/f9949940-8224-67dc-9332-e44cf3b4e180.png)

`PaperTrail::Version` を使って確認することができます。

```rb
>> 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を使う）。確かめてみましょう。

```rb
>> 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](https://qiita-image-store.s3.amazonaws.com/0/153/14b9aa5b-29f5-3471-d4f4-8a078a40607f.png)

作成時の状態は2番目のversionが持っていることになります。確認してみましょう。

```rb
>> 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](https://qiita-image-store.s3.amazonaws.com/0/153/b63ada0b-9726-bf91-1aed-b883e28e85be.png)

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

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

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

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

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

![image](https://qiita-image-store.s3.amazonaws.com/0/153/5798d6f0-62b4-0f4d-4e4d-f071bd49447a.png)

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

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

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

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

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

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

この `second_state` は `init_state.next_version` で作ることもできます。

```rb
>> 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があったとするとこんな感じになります。

```rb
>> 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_attribute` と `object_attrs_for_paper_trail` の2つ。あと中間テーブルの情報をまるで自身の属性のように扱えるようにします。

以下は非常に雑なサンプルです（こんなことやる必要がある人がそんなに沢山いるとは思えないので）。

```rb
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をしっかり読んでから導入することをおすすめします。