Ruby
Rails
ActiveRecord
トレタDay 21

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

More than 1 year has passed since last update.

トレタ Advent Calender 2016 の21日目の記事です。

変更履歴のように、そのときの状態を保存しておいて、過去データを遡ってそのときの状態を表示したいということがあったりあます。関連を含まない場合はPaperTrailを使ったりしますが、関連のデータも含めたい場合はなかなかいい方法がなくて困っていました。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のデータをいれます。

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もランダムでつけておきます

User.order(:id).each do |user|

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

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

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

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

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をつくるメソッドがあるとします

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

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

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から復活させて同じメソッドを呼んでみます

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メソッドのように、指定できるようになっています。

class AdventCalendar < ActiveRecord::Base

has_many :entries, dependent: :destroy
extend Toar::Ar
toar includes: [{ entries: :like_users }]
end

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レベルの手を考えたほうがいいかもです。


サンプルコード

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

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))