diff --git a/README.md b/README.md index 2b71ebdc..db260fbf 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/lib/paper_trail/has_paper_trail.rb b/lib/paper_trail/has_paper_trail.rb index cc01b6bd..3e135717 100644 --- a/lib/paper_trail/has_paper_trail.rb +++ b/lib/paper_trail/has_paper_trail.rb @@ -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) diff --git a/lib/paper_trail/version.rb b/lib/paper_trail/version.rb index a3d8661a..288c9d84 100644 --- a/lib/paper_trail/version.rb +++ b/lib/paper_trail/version.rb @@ -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 diff --git a/test/paper_trail_model_test.rb b/test/paper_trail_model_test.rb index 633d9851..7c10e6b5 100644 --- a/test/paper_trail_model_test.rb +++ b/test/paper_trail_model_test.rb @@ -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 diff --git a/test/schema.rb b/test/schema.rb index f7c183bf..0d165cc4 100644 --- a/test/schema.rb +++ b/test/schema.rb @@ -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|