Automatically restore has_one associations.

This commit is contained in:
Andy Stewart 2010-10-20 13:48:52 +01:00
parent 734b450037
commit 9728e3158e
5 changed files with 121 additions and 10 deletions

View File

@ -11,6 +11,7 @@ PaperTrail lets you track changes to your models' data. It's good for auditing
* Allows you to get at every version, including the original, even once destroyed.
* Allows you to get at every version even if the schema has since changed.
* Allows you to get at the version as of a particular time.
* Automatically restores the `has_one` associations as they were at the time.
* Automatically records who was responsible via your controller. PaperTrail calls `current_user` by default, if it exists, but you can have it call any method you like.
* Allows you to set who is responsible at model-level (useful for migrations).
* Allows you to store arbitrary model-level metadata with each version (useful for filtering versions).
@ -212,6 +213,27 @@ To find out who made a `version`'s object look that way, use `version.originator
>> last_version.terminator # 'Bob'
## Has-One Associations
PaperTrail automatically restores `:has_one` associations as they were at the time.
class Treasure < ActiveRecord::Base
has_one :location
end
>> treasure.amount # 100
>> treasure.location.latitude # 12.345
>> treasure.update_attributes :amount => 153
>> treasure.location.update_attributes :latitude => 54.321
>> t = treasure.versions.last.reify
>> t.amount # 100
>> t.location.latitude # 12.345
Unfortunately PaperTrail doesn't do this for `:has_many` associations (I can't get it to work) or `:belongs_to` (I ran out of time looking at `:has_many`).
## Has-Many-Through Associations
PaperTrail can track most changes to the join table. Specifically it can track all additions but it can only track removals which fire the `after_destroy` callback on the join table. Here are some examples:

View File

@ -85,14 +85,10 @@ module PaperTrail
# Returns the object (not a Version) as it was at the given timestamp.
def version_at(timestamp)
# Short-circuit if the current state is applicable.
return self if self.updated_at <= timestamp
# Look for the first version created after, rather than before, the
# timestamp because a version stores how the object looked before the
# change.
version = versions.first :conditions => ['created_at > ?', timestamp],
:order => 'created_at ASC'
version.try :reify
# Because a version stores how its object looked *before* the change,
# we need to look for the first version created *after* the timestamp.
version = versions.first :conditions => ['created_at > ?', timestamp], :order => 'created_at ASC, id ASC'
version ? version.reify : self
end
# Returns the object (not a Version) as it was most recently.
@ -109,6 +105,17 @@ module PaperTrail
subsequent_version.reify if subsequent_version
end
protected
# Returns the object (not a Version) as it was until the version record
# with the given id.
def version_until(id)
# Because a version stores how its object looked *before* the change,
# we need to look for the first version created *on or after* the id.
version = versions.first :conditions => ['id >= ?', id], :order => 'id ASC'
version ? version.reify : self
end
private
def merge_metadata(data)

View File

@ -36,6 +36,10 @@ class Version < ActiveRecord::Base
end
model.version = self
# Restore the model's has_one associations as they were when this version was
# superseded by the next (because that's what the user was looking at when they
# made the change).
reify_has_ones model
model
end
end
@ -66,4 +70,21 @@ class Version < ActiveRecord::Base
:order => 'id ASC').index(self)
end
private
def reify_has_ones(model)
model.class.reflect_on_all_associations(:has_one).each do |assoc|
child = model.send assoc.name
if child.respond_to? :version_until
if (version_until = child.version_until(id))
version_until.attributes.each do |k,v|
model.send(assoc.name).send "#{k}=", v rescue nil
end
else
model.send "#{assoc.name}=", nil
end
end
end
end
end

View File

@ -10,6 +10,7 @@ class FooWidget < Widget
end
class Wotsit < ActiveRecord::Base
has_paper_trail
belongs_to :widget
end
@ -45,7 +46,8 @@ end
class HasPaperTrailModelTest < Test::Unit::TestCase
load_schema
=begin
=end
context 'A record' do
setup { @article = Article.create }
@ -126,7 +128,8 @@ class HasPaperTrailModelTest < Test::Unit::TestCase
end
should 'copy the has_one association when reifying' do
assert_equal @wotsit, @reified_widget.wotsit
assert_nil @reified_widget.wotsit # wotsit wasn't there at the last version
assert_equal @wotsit, @widget.wotsit # wotsit came into being on the live object
end
end
@ -644,4 +647,61 @@ class HasPaperTrailModelTest < Test::Unit::TestCase
end
end
context 'A model with a has_one association' do
setup { @widget = Widget.create :name => 'widget_0' }
context 'before the associated was created' do
setup do
@widget.update_attributes :name => 'widget_1'
@wotsit = @widget.create_wotsit :name => 'wotsit_0'
end
context 'when reified' do
setup { @widget_0 = @widget.versions.last.reify }
should 'see the associated as it was at the time' do
assert_nil @widget_0.wotsit
end
end
end
context 'where the associated is created between model versions' do
setup do
@wotsit = @widget.create_wotsit :name => 'wotsit_0'
@widget.update_attributes :name => 'widget_1'
end
context 'when reified' do
setup { @widget_0 = @widget.versions.last.reify }
should 'see the associated as it was at the time' do
assert_equal 'wotsit_0', @widget_0.wotsit.name
end
end
context 'and then the associated is updated between model versions' do
setup do
@wotsit.update_attributes :name => 'wotsit_1'
@wotsit.update_attributes :name => 'wotsit_2'
@widget.update_attributes :name => 'widget_2'
end
context 'when reified' do
setup { @widget_1 = @widget.versions.last.reify }
should 'see the associated as it was at the time' do
assert_equal 'wotsit_2', @widget_1.wotsit.name
end
end
end
context 'and then the associated is destroyed between model versions' do
setup do
@wotsit.destroy
@widget.update_attributes :name => 'widget_3'
end
context 'when reified' do
setup { @widget_2 = @widget.versions.last.reify }
should 'see the associated as it was at the time' do
assert_nil @widget_2.wotsit
end
end
end
end
end
end

View File

@ -37,6 +37,7 @@ ActiveRecord::Schema.define(:version => 0) do
create_table :wotsits, :force => true do |t|
t.integer :widget_id
t.string :name
t.datetime :created_at, :updated_at
end
create_table :fluxors, :force => true do |t|