トレタ 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))