1
0
Fork 0
mirror of https://github.com/paper-trail-gem/paper_trail.git synced 2022-11-09 11:33:19 -05:00

reify has_many through; add option mark_for_destruction; disable recursive reification of has_many; add tests

This commit is contained in:
Ben Li 2014-11-03 14:05:00 +11:00
parent 86046a6c2f
commit 0e6b1119ae
9 changed files with 446 additions and 55 deletions

View file

@ -107,23 +107,27 @@ module PaperTrail
# Restore the item from this version.
#
# This will automatically restore all :has_one associations as they were "at the time",
# if they are also being versioned by PaperTrail. NOTE: this isn't always guaranteed
# to work so you can either change the lookback period (from the default 3 seconds) or
# opt out.
# Optionally this can also restore all :has_one and :has_many (including has_many :through) associations as
# they were "at the time", if they are also being versioned by PaperTrail.
#
# Options:
# :has_one set to `false` to opt out of has_one reification.
# set to a float to change the lookback time (check whether your db supports
# sub-second datetimes if you want them).
# :dup `false` default behavior
# `true` it always create a new object instance. It is useful for comparing two versions of the same object
# :has_one set to `true` to also reify has_one associations. Default is `false`.
# :has_many set to `true` to also reify has_many and has_many :through associations.
# Default is `false`.
# :version_at the time as at that to reify the has_one/has_many associations.
# Default is the time this version is created
# :mark_for_destruction set to `true` to mark the has_one/has_many associations that did not exist in the
# reified version for destruction, instead of remove them. Default is `false`.
# This option is handy for people who want to persist the reified version.
# :dup `false` default behavior
# `true` it always create a new object instance. It is useful for comparing two versions of the same object
def reify(options = {})
return nil if object.nil?
without_identity_map do
options.reverse_merge!(
:version_at => created_at,
:mark_for_destruction => false,
:has_one => false,
:has_many => false
)
@ -267,13 +271,13 @@ module PaperTrail
order("#{version_table_name}.id ASC").first
if version
if version.event == 'create'
if child = version.item
child.mark_for_destruction
if options[:mark_for_destruction]
model.send(assoc.name).mark_for_destruction if model.send(assoc.name, true)
else
model.send "#{assoc.name}=", nil
end
else
child = version.reify options
logger.info "Reify #{child}"
child = version.reify(options.merge(has_many: false, has_one: false))
model.appear_as_new_record do
model.send "#{assoc.name}=", child
end
@ -285,36 +289,83 @@ module PaperTrail
# 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.
def reify_has_manys(model, options = {})
assoc_has_many_through, assoc_has_many_directly =
model.class.reflect_on_all_associations(:has_many).partition { |assoc| assoc.options[:through] }
reify_has_many_directly(assoc_has_many_directly, model, options)
reify_has_many_through(assoc_has_many_through, model, options)
end
# Restore the `model`'s has_many associations not associated through another association
def reify_has_many_directly(associations, model, options = {})
version_table_name = model.class.paper_trail_version_class.table_name
model.class.reflect_on_all_associations(:has_many).each do |assoc|
associations.each do |assoc|
next if assoc.name == model.class.versions_association_name
version_id_subquery = PaperTrail::VersionAssociation.joins(model.class.version_association_name).
select("MIN(version_id)").
where("foreign_key_name = ?", assoc.foreign_key).
where("foreign_key_id = ?", model.id).
where("#{version_table_name}.item_type = ?", assoc.class_name).
where("created_at >= ? OR transaction_id = ?", options[:version_at], transaction_id).
group("item_id").to_sql
versions = model.class.paper_trail_version_class.where("id IN (#{version_id_subquery})")
select("MIN(version_id)").
where("foreign_key_name = ?", assoc.foreign_key).
where("foreign_key_id = ?", model.id).
where("#{version_table_name}.item_type = ?", assoc.class_name).
where("created_at >= ? OR transaction_id = ?", options[:version_at], transaction_id).
group("item_id").to_sql
versions = model.class.paper_trail_version_class.where("id IN (#{version_id_subquery})").inject({}) do |acc, v|
acc.merge!(v.item_id => v)
end
# Pass true to force the model to load
collection = Array.new model.send(assoc.name, true)
# Iterate all the child records to replace them with the previous values
versions.each do |version|
collection << version.reify(options) if version.event == 'destroy'
collection.map! do |c|
if version.event == 'create'
c.mark_for_destruction if version.item && version.item.id == c.id
c
else
child = version.reify(options)
c.id == child.id ? child : c
end
# Iterate each child to replace it with the previous value if there is a version after the timestamp
collection.map! do |c|
if (version = versions.delete(c.id)).nil?
c
elsif version.event == 'create'
options[:mark_for_destruction] ? c.tap { |r| r.mark_for_destruction } : nil
else
version.reify(options.merge(has_many: false, has_one: false))
end
end
model.send(assoc.name).proxy_association.target = collection
# Reify the rest of the versions and add them to the collection
collection += versions.map { |version| version.reify(options.merge(has_many: false, has_one: false)) }
model.send(assoc.name).proxy_association.target = collection.compact
end
end
# Restore the `model`'s has_many associations through another association
# This must be called after the direct has_manys have been reified (reify_has_many_directly)
def reify_has_many_through(associations, model, options = {})
associations.each do |assoc|
through_collection = model.send(assoc.options[:through])
collection_keys = through_collection.map { |through_model| through_model.send(assoc.foreign_key) }
version_id_subquery = assoc.klass.paper_trail_version_class.
select("MIN(id)").
where("item_type = ?", assoc.class_name).
where("item_id IN (?)", collection_keys).
where("created_at >= ? OR transaction_id = ?", options[:version_at], transaction_id).
group("item_id").to_sql
versions = assoc.klass.paper_trail_version_class.where("id IN (#{version_id_subquery})").inject({}) do |acc, v|
acc.merge!(v.item_id => v)
end
collection = Array.new assoc.klass.where(assoc.klass.primary_key => collection_keys)
# Iterate each child to replace it with the previous value if there is a version after the timestamp
collection.map! do |c|
if (version = versions.delete(c.id)).nil?
c
elsif version.event == 'create'
options[:mark_for_destruction] ? c.tap { |r| r.mark_for_destruction } : nil
else
version.reify(options.merge(has_many: false, has_one: false))
end
end
# Reify the rest of the versions and add them to the collection
collection += versions.map { |version| version.reify(options.merge(has_many: false, has_one: false)) }
model.send(assoc.name).proxy_association.target = collection.compact
end
end

View file

@ -33,6 +33,7 @@ Gem::Specification.new do |s|
s.add_development_dependency 'generator_spec'
s.add_development_dependency 'database_cleaner', '~> 1.2'
s.add_development_dependency 'pry-byebug'
s.add_development_dependency 'timecop'
# JRuby support for the test ENV
unless defined?(JRUBY_VERSION)

View file

@ -0,0 +1,4 @@
class Customer < ActiveRecord::Base
has_many :orders, :dependent => :destroy
has_paper_trail
end

View file

@ -0,0 +1,4 @@
class LineItem < ActiveRecord::Base
belongs_to :order, :dependent => :destroy
has_paper_trail
end

View file

@ -0,0 +1,5 @@
class Order < ActiveRecord::Base
belongs_to :customer
has_many :line_items
has_paper_trail
end

View file

@ -132,6 +132,20 @@ class SetUpTestTables < ActiveRecord::Migration
t.string :brand
t.timestamps
end
create_table :customers, :force => true do |t|
t.string :name
end
create_table :orders, :force => true do |t|
t.integer :customer_id
t.string :order_date
end
create_table :line_items, :force => true do |t|
t.integer :order_id
t.string :product
end
end
def self.down

View file

@ -34,6 +34,10 @@ ActiveRecord::Schema.define(version: 20110208155312) do
t.string "title"
end
create_table "customers", force: true do |t|
t.string "name"
end
create_table "documents", force: true do |t|
t.string "name"
end
@ -55,15 +59,25 @@ ActiveRecord::Schema.define(version: 20110208155312) do
t.integer "version"
end
create_table "line_items", force: true do |t|
t.integer "order_id"
t.string "product"
end
create_table "orders", force: true do |t|
t.integer "customer_id"
t.string "order_date"
end
create_table "people", force: true do |t|
t.string "name"
t.string "time_zone"
end
create_table "post_versions", force: true do |t|
t.string "item_type", null: false
t.integer "item_id", null: false
t.string "event", null: false
t.string "item_type", null: false
t.integer "item_id", null: false
t.string "event", null: false
t.string "whodunnit"
t.text "object"
t.integer "transaction_id"

View file

@ -14,6 +14,7 @@ require "rails/test_help"
require 'shoulda'
require 'ffaker'
require 'database_cleaner'
require 'timecop'
Rails.backtrace_cleaner.remove_silencers!

View file

@ -1,13 +1,5 @@
require 'test_helper'
# Updates `model`'s last version so it looks like the version was
# created 2 seconds ago.
def make_last_version_earlier(model)
PaperTrail::Version.record_timestamps = false
model.versions.last.update_attributes :created_at => 2.seconds.ago
PaperTrail::Version.record_timestamps = true
end
class HasPaperTrailModelTest < ActiveSupport::TestCase
context "A record with defined 'only' and 'ignore' attributes" do
@ -1315,6 +1307,7 @@ class HasPaperTrailModelTransactionalTest < ActiveSupport::TestCase
end
teardown do
Timecop.return
# This would have been done in test_helper.rb if using_mysql? is true
DatabaseCleaner.clean unless using_mysql?
end
@ -1329,7 +1322,7 @@ class HasPaperTrailModelTransactionalTest < ActiveSupport::TestCase
end
context 'when reified' do
setup { @widget_0 = @widget.versions.last.reify(:has_one => 1) }
setup { @widget_0 = @widget.versions.last.reify(:has_one => true) }
should 'see the associated as it was at the time' do
assert_nil @widget_0.wotsit
@ -1340,13 +1333,12 @@ class HasPaperTrailModelTransactionalTest < ActiveSupport::TestCase
context 'where the association is created between model versions' do
setup do
@wotsit = @widget.create_wotsit :name => 'wotsit_0'
make_last_version_earlier @wotsit
Timecop.travel 1.second.since
@widget.update_attributes :name => 'widget_1'
end
context 'when reified' do
setup { @widget_0 = @widget.versions.last.reify(:has_one => 1) }
setup { @widget_0 = @widget.versions.last.reify(:has_one => true) }
should 'see the associated as it was at the time' do
assert_equal 'wotsit_0', @widget_0.wotsit.name
@ -1356,16 +1348,14 @@ class HasPaperTrailModelTransactionalTest < ActiveSupport::TestCase
context 'and then the associated is updated between model versions' do
setup do
@wotsit.update_attributes :name => 'wotsit_1'
make_last_version_earlier @wotsit
@wotsit.update_attributes :name => 'wotsit_2'
make_last_version_earlier @wotsit
Timecop.travel 1.second.since
@widget.update_attributes :name => 'widget_2'
@wotsit.update_attributes :name => 'wotsit_3'
end
context 'when reified' do
setup { @widget_1 = @widget.versions.last.reify(:has_one => 1) }
setup { @widget_1 = @widget.versions.last.reify(:has_one => true) }
should 'see the associated as it was at the time' do
assert_equal 'wotsit_2', @widget_1.wotsit.name
@ -1384,13 +1374,12 @@ class HasPaperTrailModelTransactionalTest < ActiveSupport::TestCase
context 'and then the associated is destroyed between model versions' do
setup do
@wotsit.destroy
make_last_version_earlier @wotsit
Timecop.travel 1.second.since
@widget.update_attributes :name => 'widget_3'
end
context 'when reified' do
setup { @widget_2 = @widget.versions.last.reify(:has_one => 1) }
setup { @widget_2 = @widget.versions.last.reify(:has_one => true) }
should 'see the associated as it was at the time' do
assert_nil @widget_2.wotsit
@ -1399,4 +1388,312 @@ class HasPaperTrailModelTransactionalTest < ActiveSupport::TestCase
end
end
end
context 'A model with a has_many association' do
setup { @customer = Customer.create :name => 'customer_0' }
context 'updated before the associated was created' do
setup do
@customer.update_attributes! :name => 'customer_1'
@customer.orders.create! :order_date => Date.today
end
context 'when reified' do
setup { @customer_0 = @customer.versions.last.reify(:has_many => true) }
should 'see the associated as it was at the time' do
assert_equal [], @customer_0.orders
end
end
context 'when reified with option mark_for_destruction' do
setup { @customer_0 = @customer.versions.last.reify(:has_many => true, :mark_for_destruction => true) }
should 'mark the associated for destruction' do
assert_equal [true], @customer_0.orders.map(&:marked_for_destruction?)
end
end
end
context 'where the association is created between model versions' do
setup do
@order = @customer.orders.create! :order_date => 'order_date_0'
Timecop.travel 1.second.since
@customer.update_attributes :name => 'customer_1'
end
context 'when reified' do
setup { @customer_0 = @customer.versions.last.reify(:has_many => true) }
should 'see the associated as it was at the time' do
assert_equal ['order_date_0'], @customer_0.orders.map(&:order_date)
end
end
context 'and then a nested has_many association is created' do
setup do
@order.line_items.create! :product => 'product_0'
end
context 'when reified' do
setup { @customer_0 = @customer.versions.last.reify(:has_many => true) }
should 'see the live version of the nested association' do
assert_equal ['product_0'], @customer_0.orders.first.line_items.map(&:product)
end
end
end
context 'and then the associated is updated between model versions' do
setup do
@order.update_attributes :order_date => 'order_date_1'
@order.update_attributes :order_date => 'order_date_2'
Timecop.travel 1.second.since
@customer.update_attributes :name => 'customer_2'
@order.update_attributes :order_date => 'order_date_3'
end
context 'when reified' do
setup { @customer_1 = @customer.versions.last.reify(:has_many => true) }
should 'see the associated as it was at the time' do
assert_equal ['order_date_2'], @customer_1.orders.map(&:order_date)
end
end
context 'when reified opting out of has_many reification' do
setup { @customer_1 = @customer.versions.last.reify(:has_many => false) }
should 'see the associated as it is live' do
assert_equal ['order_date_3'], @customer_1.orders.map(&:order_date)
end
end
end
context 'and then the associated is destroyed between model versions' do
setup do
@order.destroy
Timecop.travel 1.second.since
@customer.update_attributes :name => 'customer_2'
end
context 'when reified' do
setup { @customer_1 = @customer.versions.last.reify(:has_many => true) }
should 'see the associated as it was at the time' do
assert_equal [], @customer_1.orders
end
end
end
context 'and then another association is added' do
setup do
@customer.orders.create! :order_date => 'order_date_1'
end
context 'when reified' do
setup { @customer_0 = @customer.versions.last.reify(:has_many => true) }
should 'see the associated as it was at the time' do
assert_equal ['order_date_0'], @customer_0.orders.map(&:order_date)
end
end
context 'when reified with option mark_for_destruction' do
setup { @customer_0 = @customer.versions.last.reify(:has_many => true, :mark_for_destruction => true) }
should 'mark the newly associated for destruction' do
assert @customer_0.orders.detect { |o| o.order_date == 'order_date_1'}.marked_for_destruction?
end
end
end
end
end
context 'A model with a has_many through association' do
setup { @book = Book.create :title => 'book_0' }
context 'updated before the associated was created' do
setup do
@book.update_attributes! :title => 'book_1'
@book.authors.create! :name => 'author_0'
end
context 'when reified' do
setup { @book_0 = @book.versions.last.reify(:has_many => true) }
should 'see the associated as it was at the time' do
assert_equal [], @book_0.authors
end
end
context 'when reified with option mark_for_destruction' do
setup { @book_0 = @book.versions.last.reify(:has_many => true, :mark_for_destruction => true) }
should 'mark the associated for destruction' do
assert_equal [true], @book_0.authors.map(&:marked_for_destruction?)
end
should 'mark the associated-through for destruction' do
assert_equal [true], @book_0.authorships.map(&:marked_for_destruction?)
end
end
end
context 'updated before it is associated with an existing one' do
setup do
person_existing = Person.create(:name => 'person_existing')
Timecop.travel 1.second.since
@book.update_attributes! :title => 'book_1'
@book.authors << person_existing
end
context 'when reified' do
setup { @book_0 = @book.versions.last.reify(:has_many => true) }
should 'see the associated as it was at the time' do
assert_equal [], @book_0.authors
end
end
context 'when reified with option mark_for_destruction' do
setup { @book_0 = @book.versions.last.reify(:has_many => true, :mark_for_destruction => true) }
should 'not mark the associated for destruction' do
assert_equal [false], @book_0.authors.map(&:marked_for_destruction?)
end
should 'mark the associated-through for destruction' do
assert_equal [true], @book_0.authorships.map(&:marked_for_destruction?)
end
end
end
context 'where the association is created between model versions' do
setup do
@author = @book.authors.create! :name => 'author_0'
@person_existing = Person.create(:name => 'person_existing')
Timecop.travel 1.second.since
@book.update_attributes! :title => 'book_1'
end
context 'when reified' do
setup { @book_0 = @book.versions.last.reify(:has_many => true) }
should 'see the associated as it was at the time' do
assert_equal ['author_0'], @book_0.authors.map(&:name)
end
end
context 'and then the associated is updated between model versions' do
setup do
@author.update_attributes :name => 'author_1'
@author.update_attributes :name => 'author_2'
Timecop.travel 1.second.since
@book.update_attributes :title => 'book_2'
@author.update_attributes :name => 'author_3'
end
context 'when reified' do
setup { @book_1 = @book.versions.last.reify(:has_many => true) }
should 'see the associated as it was at the time' do
assert_equal ['author_2'], @book_1.authors.map(&:name)
end
end
context 'when reified opting out of has_many reification' do
setup { @book_1 = @book.versions.last.reify(:has_many => false) }
should 'see the associated as it is live' do
assert_equal ['author_3'], @book_1.authors.map(&:name)
end
end
end
context 'and then the associated is destroyed between model versions' do
setup do
@author.destroy
Timecop.travel 1.second.since
@book.update_attributes :title => 'book_2'
end
context 'when reified' do
setup { @book_1 = @book.versions.last.reify(:has_many => true) }
should 'see the associated as it was at the time' do
assert_equal [], @book_1.authors
end
end
end
context 'and then the associated is dissociated between model versions' do
setup do
@book.authors = []
Timecop.travel 1.second.since
@book.update_attributes :title => 'book_2'
end
context 'when reified' do
setup { @book_1 = @book.versions.last.reify(:has_many => true) }
should 'see the associated as it was at the time' do
assert_equal [], @book_1.authors
end
end
end
context 'and then another associated is created' do
setup do
@book.authors.create! :name => 'author_1'
end
context 'when reified' do
setup { @book_0 = @book.versions.last.reify(:has_many => true) }
should 'only see the first associated' do
assert_equal ['author_0'], @book_0.authors.map(&:name)
end
end
context 'when reified with option mark_for_destruction' do
setup { @book_0 = @book.versions.last.reify(:has_many => true, :mark_for_destruction => true) }
should 'mark the newly associated for destruction' do
assert @book_0.authors.detect { |a| a.name == 'author_1' }.marked_for_destruction?
end
should 'mark the newly associated-through for destruction' do
assert @book_0.authorships.detect { |as| as.person.name == 'author_1' }.marked_for_destruction?
end
end
end
context 'and then an existing one is associated' do
setup do
@book.authors << @person_existing
end
context 'when reified' do
setup { @book_0 = @book.versions.last.reify(:has_many => true) }
should 'only see the first associated' do
assert_equal ['author_0'], @book_0.authors.map(&:name)
end
end
context 'when reified with option mark_for_destruction' do
setup { @book_0 = @book.versions.last.reify(:has_many => true, :mark_for_destruction => true) }
should 'not mark the newly associated for destruction' do
assert !@book_0.authors.detect { |a| a.name == 'person_existing' }.marked_for_destruction?
end
should 'mark the newly associated-through for destruction' do
assert @book_0.authorships.detect { |as| as.person.name == 'person_existing' }.marked_for_destruction?
end
end
end
end
end
end