diff --git a/README.md b/README.md index f6a8019c7..904581c3b 100644 --- a/README.md +++ b/README.md @@ -746,7 +746,8 @@ the association that are created in the same transaction. To restore Has-One associations as they were at the time, pass option `:has_one => true` to `reify`. To restore Has-Many and Has-Many-Through associations, use -option `:has_many => true`. For example: +option `:has_many => true`. To restore Belongs-To association, use +option `:belongs_to => true`. For example: ```ruby class Location < ActiveRecord::Base diff --git a/lib/paper_trail/reifier.rb b/lib/paper_trail/reifier.rb index 684962039..46bafff7e 100644 --- a/lib/paper_trail/reifier.rb +++ b/lib/paper_trail/reifier.rb @@ -13,6 +13,7 @@ def reify(version, options) mark_for_destruction: false, has_one: false, has_many: false, + belongs_to: false, unversioned_attributes: :nil ) @@ -60,15 +61,6 @@ def reify(version, options) private - def reify_associations(model, options, version) - if options[:has_one] - reify_has_ones version.transaction_id, model, options - end - if options[:has_many] - reify_has_manys version.transaction_id, model, options - end - end - # Set all the attributes in this version on the model. def reify_attributes(model, version, attrs) enums = model.class.respond_to?(:defined_enums) ? model.class.defined_enums : {} @@ -114,7 +106,7 @@ def prepare_array_for_has_many(array, options, versions) elsif version.event == "create" options[:mark_for_destruction] ? record.tap(&:mark_for_destruction) : nil else - version.reify(options.merge(has_many: false, has_one: false)) + version.reify(options.merge(has_many: false, has_one: false, belongs_to: false)) end end @@ -123,7 +115,7 @@ def prepare_array_for_has_many(array, options, versions) # associations. array.concat( versions.values.map { |v| - v.reify(options.merge(has_many: false, has_one: false)) + v.reify(options.merge(has_many: false, has_one: false, belongs_to: false)) } ) @@ -132,6 +124,14 @@ def prepare_array_for_has_many(array, options, versions) nil end + def reify_associations(model, options, version) + reify_has_ones version.transaction_id, model, options if options[:has_one] + + reify_belongs_tos version.transaction_id, model, options if options[:belongs_to] + + reify_has_manys version.transaction_id, model, options if options[:has_many] + end + # 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). @@ -156,7 +156,7 @@ def reify_has_ones(transaction_id, model, options = {}) end end else - child = version.reify(options.merge(has_many: false, has_one: false)) + child = version.reify(options.merge(has_many: false, has_one: false, belongs_to: false)) model.appear_as_new_record do without_persisting(child) do model.send "#{assoc.name}=", child @@ -166,6 +166,30 @@ def reify_has_ones(transaction_id, model, options = {}) end end + def reify_belongs_tos(transaction_id, model, options = {}) + associations = model.class.reflect_on_all_associations(:belongs_to) + + associations.each do |assoc| + next unless assoc.klass.paper_trail_enabled_for_model? + collection_key = model.send(assoc.association_foreign_key) + + version = assoc.klass.paper_trail_version_class. + where("item_type = ?", assoc.class_name). + where("item_id = ?", collection_key). + where("created_at >= ? OR transaction_id = ?", options[:version_at], transaction_id). + order("id").limit(1).first + + collection = if version.nil? + assoc.klass.where(assoc.klass.primary_key => collection_key).first + else + version.reify(options.merge(has_many: false, has_one: false, + belongs_to: false)) + end + + model.send("#{assoc.name}=".to_sym, collection) + end + end + # Restore the `model`'s has_many associations as they were at version_at # timestamp We lookup the first child versions after version_at timestamp or # in same transaction. diff --git a/test/unit/associations_test.rb b/test/unit/associations_test.rb index 1209fd41f..8839e90a5 100644 --- a/test/unit/associations_test.rb +++ b/test/unit/associations_test.rb @@ -769,4 +769,126 @@ class AssociationsTest < ActiveSupport::TestCase end end end + + context "belongs_to associations" do + context "Wotsit and Widget" do + setup { @widget = Widget.create(name: "widget_0") } + + context "where the association is created between model versions" do + setup do + @wotsit = Wotsit.create(name: "wotsit_0") + Timecop.travel 1.second.since + @wotsit.update_attributes widget_id: @widget.id, name: "wotsit_1" + end + + context "when reified" do + setup { @wotsit_0 = @wotsit.versions.last.reify(belongs_to: true) } + + should "see the associated as it was at the time" do + assert_equal nil, @wotsit_0.widget + end + + should "not persist changes to the live association" do + assert_equal @widget, @wotsit.reload.widget + end + end + + context "and then the associated is updated between model versions" do + setup do + @widget.update_attributes name: "widget_1" + @widget.update_attributes name: "widget_2" + Timecop.travel 1.second.since + @wotsit.update_attributes name: "wotsit_2" + @widget.update_attributes name: "widget_3" + end + + context "when reified" do + setup { @wotsit_1 = @wotsit.versions.last.reify(belongs_to: true) } + + should "see the associated as it was at the time" do + assert_equal "widget_2", @wotsit_1.widget.name + end + + should "not persist changes to the live association" do + assert_equal "widget_3", @wotsit.reload.widget.name + end + end + + context "when reified opting out of belongs_to reification" do + setup { @wotsit_1 = @wotsit.versions.last.reify(belongs_to: false) } + + should "see the associated as it is live" do + assert_equal "widget_3", @wotsit_1.widget.name + end + end + end + + context "and then the associated is destroyed" do + setup do + @wotsit.update_attributes name: "wotsit_2" + @widget.destroy + end + + context "when reified" do + setup { @wotsit_2 = @wotsit.versions.last.reify(belongs_to: true) } + + should "see the associated as it was at the time" do + assert_equal @widget, @wotsit_2.widget + end + + should "not persist changes to the live association" do + assert_nil @wotsit.reload.widget + end + end + + context "and then the model is updated" do + setup do + Timecop.travel 1.second.since + @wotsit.update_attributes name: "wotsit_3" + end + + context "when reified" do + setup { @wotsit_2 = @wotsit.versions.last.reify(belongs_to: true) } + + should "see the associated as it was the time" do + assert_nil @wotsit_2.widget + end + end + end + end + end + + context "where the association is changed between model versions" do + setup do + @wotsit = @widget.create_wotsit(name: "wotsit_0") + Timecop.travel 1.second.since + @new_widget = Widget.create(name: "new_widget") + @wotsit.update_attributes(widget_id: @new_widget.id, name: "wotsit_1") + end + + context "when reified" do + setup { @wotsit_0 = @wotsit.versions.last.reify(belongs_to: true) } + + should "see the association as it was at the time" do + assert_equal "widget_0", @wotsit_0.widget.name + end + + should "not persist changes to the live association" do + assert_equal @new_widget, @wotsit.reload.widget + end + end + + context "when reified with option mark_for_destruction" do + setup do + @wotsit_0 = @wotsit.versions.last. + reify(belongs_to: true, mark_for_destruction: true) + end + + should "should not mark the new associated for destruction" do + assert_equal false, @new_widget.marked_for_destruction? + end + end + end + end + end end