カテゴリー:
Rails
タグ:
 Rails has_many paper_trail has_one

このエントリーをはてなブックマークに追加
更新日時:
2015年05月02日(土)
作成日時:
2015年05月02日(土)

前の記事 / 次の記事

paper_trailでhas_manyな関係を保存できるようになっていたのでメモ。
paper_trailはActiveRecordなオブジェクトの履歴を保存してくれる便利なGem。

でも基本的に1つのモデルに対してだけで、
同時にhas_manyな子オブジェクトの履歴も保存する、みたいなことはできなかった、
のだけどできるようになっていたので試行しつつメモ。

各コードは既存のコードから不要な部分を削除して脳内で再構築したもので、
実際には動かしてないので動かなかったらすいません。

親オブジェクトと子オブジェクトが同時に保存される必要があるっていうところと、
has_manyなオブジェクトが削除された状態に戻す場合は一手間必要ってところにが
最大の嵌まりポイントかと。

環境は Rails4.2.0 で。

Gemfileに書いてインストール

vi Gemfile
gem 'paper_trail', '~> 4.0.0.beta'
bundle install

これだけでは動かなくて別途インストールコマンドを打つ必要がある。
has_manyな関係も保存する場合は '--with-associations' オプションが必要。

bundle exec rails generate paper_trail:install --with-associations
bundle exec rake db:migrate

履歴を保存したいモデルに 'has_paper_trail' を追加する。
親モデル子モデル問わず、履歴を保存するモデルには全て追加する。
(但し、STIを使っている場合は親モデルにだけ追加する。)

class Book < ActiveRecord::Base
  has_paper_trail
  has_many :pages, dependent: :destroy
end

class Page < ActiveRecord::Base
  has_paper_trail
  belongs_to :book
end

基本的な使い方、saveすることで現状を変更。

book = Book.create(name: "King of Kings")
book.update(name: "キングオブキングス")
book.name #=> "キングオブキングス"

book.previous_version.name  #=> "King of Kings"
book.previous_version.save
book.name                   #=> "King of Kings"

versionsで履歴の一覧を取得できる。
履歴から元のオブジェクトを生成するにはreifyが必要。

                                            # versions[0] == nil
book = Book.create(name: "King of Kings 1") # versions[1]
book.update(name: "King of Kings 2")        # versions[2]
book.update(name: "King of Kings 3")        # versions[3]
book.update(name: "King of Kings 4")        # Book.last

book.versions.size #=> 4
book.versions.each do |version|
  version_book = version.reify
  version_book.try(:name)
end

book.versions[2].prev.reify.name #=> "King of Kings 1"
book.versions[2].next.reify.name #=> "King of Kings 3"

paper_trailはオブジェクト発生前の状態をnilとして保存し、
現状である最新の状態は履歴として保存されない。
最新のオブジェクトが必要な場合は現役のオブジェクトから取得する。

book = Book.create(name: "King of Kings")
book.update(name: "キングオブキングス")

book.versions.first.reify      #=> nil
book.versions.last.reify.name  #=> "King of Kings"
Book.last.name                 #=> "キングオブキングス"

has_manyな関係も併せて取得する場合はreifyする時、
オプションに has_many: true を指定する。

# initial version
nil

# create ver 1
book = Book.create(name: "King of Kings 1")
book.pages.create(name: "episode1")

version_book = book.versions.last.reify(has_many: true)
version_book             #=> nil
version_book.try(:pages) #=> nil

# update to ver 1.1
book.update(name: "King of Kings 1.1")
book.pages.create(name: "episode2")
book.pages.create(name: "episode3")

version_book = book.versions.last.reify(has_many: true)
version_book.name              #=> "King of Kings 1"
version_book.pages.map(&:name) #=> ["episode 1"]

# update to ver 1.2
book.update(name: "King of Kings 1.2")
book.pages.find_by(name: "episode 2").update(name: "episode 2.1")
book.pages.find_by(name: "episode 3").update(name: "episode 3.1")

version_book = book.versions.last.reify(has_many: true)
version_book.name              #=> "King of Kings 1.1"
version_book.pages.map(&:name) #=> ["episode 1", "episode2", "episode3"]

# update to ver 1.3
book.update(name: "King of Kings 1.3")
book.pages.find_by(name: "episode 2.1").destroy
book.pages.find_by(name: "episode 3.1").destroy

version_book = book.versions.last.reify(has_many: true)
version_book.name              #=> "King of Kings 1.2"
version_book.pages.map(&:name) #=> ["episode 1", "episode2.1", "episode3.1"]

# update to ver 1.4
book.update(name: "King of Kings 1.4")
book.pages.create(name: "episode4")
book.pages.create(name: "episode5")

version_book = book.versions.last.reify(has_many: true)
version_book.name              #=> "King of Kings 1.3"
version_book.pages.map(&:name) #=> ["episode 1"]

has_manyな履歴を現状に復活させる時

version_book = book.versions.last.reify(has_many: true)
version_book.save

has_manyな履歴を現状に復活させる時に、
現状に存在しないオブジェクトは自動的に復活するが、
現状に存在するオブジェクトは破棄されないため独自の対応が必要。

live_book = Book.last       
live_book.name              #=> "King of Kings 1.4"
live_book.pages.map(&:name) #=> ["episode 1", "episode4", "episode5"]

# ver 1.2 に戻そうとした時
version_book = book.versions[-2].reify(has_many: true)
version_book.name              #=> "King of Kings 1.2"
version_book.pages.map(&:name) #=> ["episode 1", "episode 2", "episode 3"]

version_book.save

live_book = Book.last
live_book.name              #=> "King of Kings 1.2"
live_book.pages.map(&:name) #=> ["episode 1", "episode 2", "episode 3", "episode4", "episode5"]

# e.g.
version_page_ids = version_book.pages.map(&:id)
Book.transaction do
  Book.last.pages.each do |page|
    page.destroy! unless page.id.in?(version_page_ids)
  end
end

また、親オブジェクトと子オブジェクトは同時に更新される必要があり、
親オブジェクトだけ、子オブジェクトだけが更新された場合は、履歴の一貫性は保たれない。
従って、has_many な履歴を保存させる場合は、何らかの方法で同時に保存される細工が必要。

# e.g.
class Book < ActiveRecordBase
  has_paper_trail
  has_many :pages, dependent: :destroy

  before_update :anything_changed?

  private
    def anything_changed?
      self.updated_at = DateTime.now if pages.any?(&:changed?)
    end
end 

ちなみに pages.map(&:name)とかやってるヶ所をpages.pluck(:name)とか
Railsの便利なメソッドを使おうとすると現役の値を取得してきてしまうため、
paper_trailの履歴をイテレーションさせたい時は、
paper_trailが用意してくれたオブジェクトを使って欲しい値を取得する必要がある。

has_one の時も基本的に同じ。
has_many through の時も同じだと思うけど、
destroyが絡む場合はActiveRecordにパッチを当てる必要がありそう。
(試してないから has_many through の場合は実際どうなるのかは不明。)

Rails界はどんどん便利になっていきますね。

あと、has_manyに foreign_key とか指定した時。
生成されるレコードの内容を見ると、これはたぶんforeign_keyについては
自動的にトラックしてうまいくとやってくれてるんだと思うけど、
primary_keyについてはそもそも眼中にない感じなので、
もしprimary_keyを指定する場合はprimary_keyとidを同じにするか、
idの自動採番をやめてprimary_keyをぶち込むかしないとうまく捕捉してくれない。
無理にいじってもバグの温床として多大な貢献をしそうな気がするので、
基本規約に沿って大人しくしておくのがいいんじゃないかと、思っている。