ActiveRecordオブジェクトを関連ごとシリアライズしてデシリアライズする

[トレタ Advent Calender 2016](http://qiita.com/advent-calendar/2016/toreta) の21日目の記事です。

変更履歴のように、そのときの状態を保存しておいて、過去データを遡ってそのときの状態を表示したいということがあったりあます。関連を含まない場合は[PaperTrail](https://github.com/airblade/paper_trail)を使ったりしますが、関連のデータも含めたい場合はなかなかいい方法がなくて困っていました。PaperTrailに関連を保存するExperimentalな機能としてありますが、複雑なつくりになっていたり、読み出すのにけっこうな量のクエリが必要だったりして、少し試した感じだとなかなかきびしそうでした。

単に関連も含めてデータをシリアラライズしたい場合は、実はas_json/to_jsonのincludeオプションを使えば簡単にできてしまいます。あとは、そこからもとデータを復元できればいいので、その部分をつくりました。

swdyh/toar: Deserialize JSON to ActiveRecord Model with associations
https://github.com/swdyh/toar


## 使い方
アドベントカレンダーを例にしてみます。アドベントカレンダーには記事があって、ユーザがそれにいいねします。(モデルのclass定義やmigrationを含めたコードは最後に貼っておきます)

```
AdventCalendar -* Entry -* Like *- User
                    |*              |
                    -----------------
```

ざっとEntryのデータをいれます。

```ruby
ac = AdventCalendar.create!(title: 'Toreta')
entries = <<-EOS
12/1,horimislime,リリース自動化だけじゃないfastlane活用方法 
12/2,seri_k, Googleの検索エンジンを長年支えたPageRankについて調べてみた
12/3,masuidrive,【定番】 新しいWebサービスを開発・運営するときに使いたいサービス 【2016年末版】
EOS
entries.split("\n").each do |line|
  d, u, t = line.split(',')
  user = User.find_or_create_by(name: u)
  ac.entries.create!(date: Date.parse('2016/' + d), title: t.strip, user: user)
end
```

Likeもランダムでつけておきます

```ruby
User.order(:id).each do |user|
  ac.entries.sample(5).each do |entry|
    user.likes.create(entry: entry)
  end
end
```

to_jsonで現状のデータを保存しておきます

```ruby
snapshot = AdventCalendar.first.to_json(include: [{ entries: { include: [:like_users] } }])
```

EntryやLikeをいくつか削除して、追加してみます

```ruby
Entry.all.sample(1).map(&:destroy)
Like.all.sample(5).map(&:destroy)

entries = <<-EOS
12/4,seri_k, 自分が2016年に作ったrails拡張系gemとその解説
12/5,horimislime, esaの記事をステータスバーから見れるMacアプリを作った話と、個人開発で心がけたい4つの事
12/6,m_nakamura145, Herokuでデプロイと同時にrake db:migrateを実行する
EOS
entries.split("\n").each do |line|
  d, u, t = line.split(',')
  user = User.find_or_create_by(name: u)
  ac.entries.create!(date: Date.parse('2016/' + d), title: t.strip, user: user)
end

User.order(:id).each do |user|
  ac.entries.sample(5).each do |entry|
    user.likes.create!(entry: entry) rescue nil
  end
end
```

API用にJSONをつくるメソッドがあるとします

```ruby
def render_advent_carender_json(advent_calendar)
  entries = advent_calendar.entries.sort_by(&:date)
    .map do |i|
    {
      date: i.date.to_s,
      title: i.title,
      author: i.user.name,
      like_users: i.like_users.map(&:name)
    }
  end
  JSON.pretty_generate(advent_calendar.slice(:title).merge(entries: entries))
end
```

現状のデータで呼び出します

```ruby
puts render_advent_carender_json(ac)
```

```
{
  "title": "Toreta",
  "entries": [
    {
      "date": "2016-12-01",
      "title": "リリース自動化だけじゃないfastlane活用方法",
      "author": "horimislime",
      "like_users": [
        "horimislime",
        "masuidrive",
        "m_nakamura145"
      ]
    },
    {
      "date": "2016-12-02",
      "title": "Googleの検索エンジンを長年支えたPageRankについて調べてみた",
      "author": "seri_k",
      "like_users": [
        "horimislime",
        "seri_k",
        "masuidrive",
        "m_nakamura145"
      ]
    },
    {
      "date": "2016-12-03",
      "title": "【定番】 新しいWebサービスを開発・運営するときに使いたいサービス 【2016年末版】",
      "author": "masuidrive",
      "like_users": [
        "horimislime",
        "seri_k",
        "masuidrive",
        "m_nakamura145"
      ]
    },
    {
      "date": "2016-12-04",
      "title": "自分が2016年に作ったrails拡張系gemとその解説",
      "author": "seri_k",
      "like_users": [
        "horimislime",
        "seri_k"
      ]
    },
    {
      "date": "2016-12-05",
      "title": "esaの記事をステータスバーから見れるMacアプリを作った話と、個人開発で心がけたい4つの事",
      "author": "horimislime",
      "like_users": [
        "horimislime",
        "seri_k",
        "masuidrive",
        "m_nakamura145"
      ]
    },
    {
      "date": "2016-12-06",
      "title": "Herokuでデプロイと同時にrake db:migrateを実行する",
      "author": "m_nakamura145",
      "like_users": [
        "seri_k",
        "masuidrive",
        "m_nakamura145"
      ]
    }
  ]
}
```

snapshotから復活させて同じメソッドを呼んでみます

```ruby
ac_snapshot = Toar.to_ar(AdventCalendar, snapshot)
puts render_advent_carender_json(ac_snapshot)
```

```
{
  "title": "Toreta",
  "entries": [
    {
      "date": "2016-12-01",
      "title": "リリース自動化だけじゃないfastlane活用方法",
      "author": "horimislime",
      "like_users": [
        "horimislime",
        "seri_k",
        "masuidrive"
      ]
    },
    {
      "date": "2016-12-02",
      "title": "Googleの検索エンジンを長年支えたPageRankについて調べてみた",
      "author": "seri_k",
      "like_users": [
        "horimislime",
        "seri_k",
        "masuidrive"
      ]
    },
    {
      "date": "2016-12-03",
      "title": "【定番】 新しいWebサービスを開発・運営するときに使いたいサービス 【2016年末版】",
      "author": "masuidrive",
      "like_users": [
        "horimislime",
        "seri_k",
        "masuidrive"
      ]
    }
  ]
}
```

スナップショット時点のデータでてますね。

## 使い方2 Toar::Ar
Toar.to_ar以外にActiveRecord::Modelで宣言的にもかけるようにしてみました。
またas_json/to_jsonのincludeはオプションはややこしめなので、ARのincludesメソッドのように、指定できるようになっています。

```ruby
class AdventCalendar < ActiveRecord::Base
  has_many :entries, dependent: :destroy
  extend Toar::Ar
  toar includes: [{ entries: :like_users }]
end
```

```ruby
snapshot2 = ac.toar_to_json
p AdventCalendar.to_ar(snapshot2)
puts render_advent_carender_json(AdventCalendar.to_ar(snapshot2))
```

## FAQ
Q: わざわざARのオブジェクトにしなくても
A: そのままデータ使うケースはいいとして、そのデータを元にARオブジェクトのメソッドを使ってデータ処理があるとARのほうが便利です。またARオブジェクトでもJSONでも同じようにレンダリングできるメソッドを書こうとするとつらい感じなりがちなのでおすすめしません。

Q: PaperTrailみたいにDBに保存したりする機能は？
A: ここまであれば、それをつくるのは難しくないのでやってみてください。関連も含める場合データが大きくなったりするので、DBに入れるのが嬉しいかどうかは考えたほうがいいと思います。あと履歴というより、でスナップショットと考えたほうがいいです。

Q: 履歴?スナップショット?
A: 関連のデータ変更をまで含めて、正確な変更履歴をとるのは大変です。上の例でいうと、Entryの変更時にJSONで保存するのは現実的ですが、Userの名前変更で、それが関わる部分のJSONを全部保存するとかはきびしい気がします。Audit的な意味での履歴データがほしいのであれば、DBMSレベルの手を考えたほうがいいかもです。

## サンプルコード
ここまでの例でつかったコードの全体を貼っておきます

```ruby
require 'pp'
require 'toar'
require 'active_record'

ActiveRecord::Base.establish_connection(
  adapter:   'sqlite3',
  database:  ':memory:'
)

class InitialSchema < ActiveRecord::Migration[4.2]
  def change
    create_table :advent_calendars do |t|
      t.string :title
    end
    create_table :entries do |t|
      t.references :advent_calendar
      t.references :user
      t.string :title
      t.date :date
      t.text :body
    end
    create_table :users do |t|
      t.string :name
    end
    create_table :likes do |t|
      t.references :user
      t.references :entry
      t.index [:user_id, :entry_id], unique: true
    end
  end
end

InitialSchema.verbose = false
InitialSchema.migrate(:up)

class AdventCalendar < ActiveRecord::Base
  has_many :entries, dependent: :destroy
end

class Entry < ActiveRecord::Base
  belongs_to :adventcalendar
  belongs_to :user
  has_many :likes, dependent: :destroy
  has_many :like_users, through: :likes, source: :user
end

class User < ActiveRecord::Base
  has_many :entries
  has_many :likes, dependent: :destroy
  has_many :like_entries, through: :likes, source: :entry
end

class Like < ActiveRecord::Base
  belongs_to :user
  belongs_to :entry
end

ac = AdventCalendar.create!(title: 'Toreta')
entries = <<-EOS
12/1,horimislime,リリース自動化だけじゃないfastlane活用方法 
12/2,seri_k, Googleの検索エンジンを長年支えたPageRankについて調べてみた
12/3,masuidrive,【定番】 新しいWebサービスを開発・運営するときに使いたいサービス 【2016年末版】
EOS
entries.split("\n").each do |line|
  d, u, t = line.split(',')
  user = User.find_or_create_by(name: u)
  ac.entries.create!(date: Date.parse('2016/' + d), title: t.strip, user: user)
end

User.order(:id).each do |user|
  ac.entries.sample(5).each do |entry|
    user.likes.create(entry: entry)
  end
end

def render_advent_carender_json(advent_calendar)
  entries = advent_calendar.entries.sort_by(&:date)
    .map do |i|
    {
      date: i.date.to_s,
      title: i.title,
      author: i.user.name,
      like_users: i.like_users.map(&:name)
    }
  end
  JSON.pretty_generate(advent_calendar.slice(:title).merge(entries: entries))
end

snapshot = AdventCalendar.first.to_json(include: [{ entries: { include: [:like_users] } }])

entries = <<-EOS
12/4,seri_k, 自分が2016年に作ったrails拡張系gemとその解説
12/5,horimislime, esaの記事をステータスバーから見れるMacアプリを作った話と、個人開発で心がけたい4つの事
12/6,m_nakamura145, Herokuでデプロイと同時にrake db:migrateを実行する
EOS
entries.split("\n").each do |line|
  d, u, t = line.split(',')
  user = User.find_or_create_by(name: u)
  ac.entries.create!(date: Date.parse('2016/' + d), title: t.strip, user: user)
end
Entry.all.sample(1).map(&:destroy)
Like.all.sample(10).map(&:destroy)
User.order(:id).each do |user|
  ac.entries.sample(5).each do |entry|
    user.likes.create!(entry: entry) rescue nil
  end
end

puts '- AR object -'
puts render_advent_carender_json(ac)

ac_snapshot = Toar.to_ar(AdventCalendar, snapshot)
puts '- snapshot1 -'
puts render_advent_carender_json(ac_snapshot)

class AdventCalendar < ActiveRecord::Base
  has_many :entries, dependent: :destroy
  extend Toar::Ar
  toar includes: { entries: :like_users }
end

snapshot2 = ac.toar_to_json
puts '- snapshot2 -'
puts render_advent_carender_json(AdventCalendar.to_ar(snapshot2))

```