2015-08-03 18:37:40 -04:00
|
|
|
module PaperTrail
|
|
|
|
# Given a version record and some options, builds a new model object.
|
|
|
|
# @api private
|
|
|
|
module Reifier
|
|
|
|
class << self
|
|
|
|
# See `VersionConcern#reify` for documentation.
|
|
|
|
# @api private
|
|
|
|
def reify(version, options)
|
|
|
|
options = options.dup
|
|
|
|
|
|
|
|
options.reverse_merge!(
|
2016-02-15 22:32:40 -05:00
|
|
|
version_at: version.created_at,
|
|
|
|
mark_for_destruction: false,
|
|
|
|
has_one: false,
|
|
|
|
has_many: false,
|
2016-03-09 14:41:21 -05:00
|
|
|
belongs_to: false,
|
2016-02-15 22:32:40 -05:00
|
|
|
unversioned_attributes: :nil
|
2015-08-03 18:37:40 -04:00
|
|
|
)
|
|
|
|
|
2016-04-01 01:42:54 -04:00
|
|
|
attrs = version.object_deserialized
|
2015-08-03 18:37:40 -04:00
|
|
|
|
|
|
|
# Normally a polymorphic belongs_to relationship allows us to get the
|
|
|
|
# object we belong to by calling, in this case, `item`. However this
|
|
|
|
# returns nil if `item` has been destroyed, and we need to be able to
|
|
|
|
# retrieve destroyed objects.
|
|
|
|
#
|
|
|
|
# In this situation we constantize the `item_type` to get hold of the
|
|
|
|
# class...except when the stored object's attributes include a `type`
|
|
|
|
# key. If this is the case, the object we belong to is using single
|
|
|
|
# table inheritance and the `item_type` will be the base class, not the
|
|
|
|
# actual subclass. If `type` is present but empty, the class is the base
|
|
|
|
# class.
|
|
|
|
if options[:dup] != true && version.item
|
|
|
|
model = version.item
|
|
|
|
# Look for attributes that exist in the model and not in this
|
|
|
|
# version. These attributes should be set to nil.
|
|
|
|
if options[:unversioned_attributes] == :nil
|
|
|
|
(model.attribute_names - attrs.keys).each { |k| attrs[k] = nil }
|
|
|
|
end
|
|
|
|
else
|
2016-03-13 19:20:17 -04:00
|
|
|
klass = version_reification_class(version, attrs)
|
2015-08-03 18:37:40 -04:00
|
|
|
# The `dup` option always returns a new object, otherwise we should
|
|
|
|
# attempt to look for the item outside of default scope(s).
|
2016-02-15 20:09:59 -05:00
|
|
|
if options[:dup] || (item_found = klass.unscoped.find_by_id(version.item_id)).nil?
|
2015-08-03 18:37:40 -04:00
|
|
|
model = klass.new
|
|
|
|
elsif options[:unversioned_attributes] == :nil
|
2016-02-15 20:09:59 -05:00
|
|
|
model = item_found
|
2015-08-03 18:37:40 -04:00
|
|
|
# Look for attributes that exist in the model and not in this
|
|
|
|
# version. These attributes should be set to nil.
|
|
|
|
(model.attribute_names - attrs.keys).each { |k| attrs[k] = nil }
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
Maps enums to database values before storing in `object_changes`
Keep consistency between versions with regard to `changes` and
`object_changes` and how enum columns store their values.
Before, `changes` would map the changed attributes enum columns to the database
values (integer values). This allows reifying that version to maintain the
integrity of the enum. It did not do so for `object_changes` and thus, `0`
for non-json columns, and the enum value for json columns would be stored instead.
For the non-json columns, it mapped any non-integer enum value to `0` because
during serialization that column is an `integer`. Now this is fixed,
so that `object_changes` stores the enum mapped value.
Here is an example:
```ruby
class PostWithStatus < ActiveRecord::Base
has_paper_trail
enum status: { draft: 0, published: 1, archived: 2 }
end
post = PostWithStatus.new(status: :draft)
post.published!
version = post.versions.last
# Before
version.changeset #> { 'status' => ['draft', 'draft'] } (stored as [0, 0])
# After
version.changeset #> { 'status' => ['draft', 'published'] } (stored as [0, 1])
```
2016-03-11 04:18:40 -05:00
|
|
|
reify_attributes(model, version, attrs)
|
2015-08-03 18:37:40 -04:00
|
|
|
model.send "#{model.class.version_association_name}=", version
|
2016-03-13 19:23:15 -04:00
|
|
|
reify_associations(model, options, version)
|
|
|
|
model
|
|
|
|
end
|
|
|
|
|
|
|
|
private
|
2015-08-03 18:37:40 -04:00
|
|
|
|
Maps enums to database values before storing in `object_changes`
Keep consistency between versions with regard to `changes` and
`object_changes` and how enum columns store their values.
Before, `changes` would map the changed attributes enum columns to the database
values (integer values). This allows reifying that version to maintain the
integrity of the enum. It did not do so for `object_changes` and thus, `0`
for non-json columns, and the enum value for json columns would be stored instead.
For the non-json columns, it mapped any non-integer enum value to `0` because
during serialization that column is an `integer`. Now this is fixed,
so that `object_changes` stores the enum mapped value.
Here is an example:
```ruby
class PostWithStatus < ActiveRecord::Base
has_paper_trail
enum status: { draft: 0, published: 1, archived: 2 }
end
post = PostWithStatus.new(status: :draft)
post.published!
version = post.versions.last
# Before
version.changeset #> { 'status' => ['draft', 'draft'] } (stored as [0, 0])
# After
version.changeset #> { 'status' => ['draft', 'published'] } (stored as [0, 1])
```
2016-03-11 04:18:40 -05:00
|
|
|
# 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 : {}
|
|
|
|
model.class.unserialize_attributes_for_paper_trail! attrs
|
|
|
|
|
|
|
|
attrs.each do |k, v|
|
|
|
|
# `unserialize_attributes_for_paper_trail!` will return the mapped enum value
|
|
|
|
# and in Rails < 5, the []= uses the integer type caster from the column
|
|
|
|
# definition (in general) and thus will turn a (usually) string to 0 instead
|
|
|
|
# of the correct value
|
2016-03-13 14:51:26 -04:00
|
|
|
is_enum_without_type_caster = ::ActiveRecord::VERSION::MAJOR < 5 && enums.key?(k)
|
Maps enums to database values before storing in `object_changes`
Keep consistency between versions with regard to `changes` and
`object_changes` and how enum columns store their values.
Before, `changes` would map the changed attributes enum columns to the database
values (integer values). This allows reifying that version to maintain the
integrity of the enum. It did not do so for `object_changes` and thus, `0`
for non-json columns, and the enum value for json columns would be stored instead.
For the non-json columns, it mapped any non-integer enum value to `0` because
during serialization that column is an `integer`. Now this is fixed,
so that `object_changes` stores the enum mapped value.
Here is an example:
```ruby
class PostWithStatus < ActiveRecord::Base
has_paper_trail
enum status: { draft: 0, published: 1, archived: 2 }
end
post = PostWithStatus.new(status: :draft)
post.published!
version = post.versions.last
# Before
version.changeset #> { 'status' => ['draft', 'draft'] } (stored as [0, 0])
# After
version.changeset #> { 'status' => ['draft', 'published'] } (stored as [0, 1])
```
2016-03-11 04:18:40 -05:00
|
|
|
|
|
|
|
if model.has_attribute?(k) && !is_enum_without_type_caster
|
|
|
|
model[k.to_sym] = v
|
|
|
|
elsif model.respond_to?("#{k}=")
|
|
|
|
model.send("#{k}=", v)
|
|
|
|
else
|
|
|
|
version.logger.warn(
|
|
|
|
"Attribute #{k} does not exist on #{version.item_type} (Version id: #{version.id})."
|
|
|
|
)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2015-10-08 19:04:24 -04:00
|
|
|
# Replaces each record in `array` with its reified version, if present
|
|
|
|
# in `versions`.
|
|
|
|
#
|
|
|
|
# @api private
|
|
|
|
# @param array - The collection to be modified.
|
|
|
|
# @param options
|
|
|
|
# @param versions - A `Hash` mapping IDs to `Version`s
|
|
|
|
# @return nil - Always returns `nil`
|
|
|
|
#
|
|
|
|
# Once modified by this method, `array` will be assigned to the
|
|
|
|
# AR association currently being reified.
|
|
|
|
#
|
|
|
|
def prepare_array_for_has_many(array, options, versions)
|
|
|
|
# Iterate each child to replace it with the previous value if there is
|
|
|
|
# a version after the timestamp.
|
|
|
|
array.map! do |record|
|
|
|
|
if (version = versions.delete(record.id)).nil?
|
|
|
|
record
|
2016-03-05 17:07:32 -05:00
|
|
|
elsif version.event == "create"
|
2016-03-05 17:00:08 -05:00
|
|
|
options[:mark_for_destruction] ? record.tap(&:mark_for_destruction) : nil
|
2015-10-08 19:04:24 -04:00
|
|
|
else
|
2016-03-09 14:41:21 -05:00
|
|
|
version.reify(options.merge(has_many: false, has_one: false, belongs_to: false))
|
2015-10-08 19:04:24 -04:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
# Reify the rest of the versions and add them to the collection, these
|
|
|
|
# versions are for those that have been removed from the live
|
|
|
|
# associations.
|
|
|
|
array.concat(
|
|
|
|
versions.values.map { |v|
|
2016-03-09 14:41:21 -05:00
|
|
|
v.reify(options.merge(has_many: false, has_one: false, belongs_to: false))
|
2015-10-08 19:04:24 -04:00
|
|
|
}
|
|
|
|
)
|
|
|
|
|
|
|
|
array.compact!
|
|
|
|
|
|
|
|
nil
|
|
|
|
end
|
|
|
|
|
2016-03-09 14:41:21 -05:00
|
|
|
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
|
|
|
|
|
2015-08-03 18:37:40 -04:00
|
|
|
# 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).
|
|
|
|
def reify_has_ones(transaction_id, model, options = {})
|
|
|
|
version_table_name = model.class.paper_trail_version_class.table_name
|
|
|
|
model.class.reflect_on_all_associations(:has_one).each do |assoc|
|
2016-03-05 17:27:33 -05:00
|
|
|
next unless assoc.klass.paper_trail_enabled_for_model?
|
|
|
|
version = model.class.paper_trail_version_class.joins(:version_associations).
|
|
|
|
where("version_associations.foreign_key_name = ?", assoc.foreign_key).
|
|
|
|
where("version_associations.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).
|
|
|
|
order("#{version_table_name}.id ASC").
|
|
|
|
first
|
|
|
|
next unless version
|
|
|
|
if version.event == "create"
|
|
|
|
if options[:mark_for_destruction]
|
|
|
|
model.send(assoc.name).mark_for_destruction if model.send(assoc.name, true)
|
|
|
|
else
|
|
|
|
model.appear_as_new_record do
|
|
|
|
model.send "#{assoc.name}=", nil
|
|
|
|
end
|
|
|
|
end
|
|
|
|
else
|
2016-03-09 14:41:21 -05:00
|
|
|
child = version.reify(options.merge(has_many: false, has_one: false, belongs_to: false))
|
2016-03-05 17:27:33 -05:00
|
|
|
model.appear_as_new_record do
|
|
|
|
without_persisting(child) do
|
|
|
|
model.send "#{assoc.name}=", child
|
2015-08-03 18:37:40 -04:00
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2016-03-09 14:41:21 -05:00
|
|
|
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
|
|
|
|
|
2015-08-03 18:37:40 -04:00
|
|
|
# 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(transaction_id, 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(transaction_id, assoc_has_many_directly, model, options)
|
|
|
|
reify_has_many_through(transaction_id, assoc_has_many_through, model, options)
|
|
|
|
end
|
|
|
|
|
|
|
|
# Restore the `model`'s has_many associations not associated through
|
|
|
|
# another association.
|
|
|
|
def reify_has_many_directly(transaction_id, associations, model, options = {})
|
|
|
|
version_table_name = model.class.paper_trail_version_class.table_name
|
|
|
|
associations.each do |assoc|
|
|
|
|
next unless assoc.klass.paper_trail_enabled_for_model?
|
|
|
|
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
|
2015-10-08 19:54:33 -04:00
|
|
|
versions = versions_by_id(model.class, version_id_subquery)
|
2016-01-11 22:03:20 -05:00
|
|
|
collection = Array.new model.send(assoc.name).reload # to avoid cache
|
2015-10-08 19:04:24 -04:00
|
|
|
prepare_array_for_has_many(collection, options, versions)
|
|
|
|
model.send(assoc.name).proxy_association.target = collection
|
2015-08-03 18:37:40 -04:00
|
|
|
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(transaction_id, associations, model, options = {})
|
|
|
|
associations.each do |assoc|
|
|
|
|
next unless assoc.klass.paper_trail_enabled_for_model?
|
|
|
|
|
|
|
|
# Load the collection of through-models. For example, if `model` is a
|
|
|
|
# Chapter, having many Paragraphs through Sections, then
|
|
|
|
# `through_collection` will contain Sections.
|
|
|
|
through_collection = model.send(assoc.options[:through])
|
|
|
|
|
|
|
|
# Examine the `source_reflection`, i.e. the "source" of `assoc` the
|
|
|
|
# `ThroughReflection`. The source can be a `BelongsToReflection`
|
|
|
|
# or a `HasManyReflection`.
|
|
|
|
#
|
|
|
|
# If the association is a has_many association again, then call
|
|
|
|
# reify_has_manys for each record in `through_collection`.
|
|
|
|
if !assoc.source_reflection.belongs_to? && through_collection.present?
|
|
|
|
through_collection.each do |through_model|
|
|
|
|
reify_has_manys(transaction_id, through_model, options)
|
|
|
|
end
|
2015-10-12 03:27:24 -04:00
|
|
|
|
|
|
|
# At this point, the "through" part of the association chain has
|
|
|
|
# been reified, but not the final, "target" part. To continue our
|
|
|
|
# example, `model.sections` (including `model.sections.paragraphs`)
|
|
|
|
# has been loaded. However, the final "target" part of the
|
|
|
|
# association, that is, `model.paragraphs`, has not been loaded. So,
|
|
|
|
# we do that now.
|
|
|
|
collection = through_collection.flat_map { |through_model|
|
|
|
|
through_model.public_send(assoc.name.to_sym).to_a
|
|
|
|
}
|
2015-10-12 03:25:24 -04:00
|
|
|
else
|
|
|
|
collection_keys = through_collection.map { |through_model|
|
|
|
|
through_model.send(assoc.association_foreign_key)
|
|
|
|
}
|
2015-08-03 18:37:40 -04:00
|
|
|
|
2015-10-12 03:25:24 -04:00
|
|
|
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 = versions_by_id(assoc.klass, version_id_subquery)
|
|
|
|
collection = Array.new assoc.klass.where(assoc.klass.primary_key => collection_keys)
|
|
|
|
prepare_array_for_has_many(collection, options, versions)
|
|
|
|
end
|
2015-10-12 03:27:24 -04:00
|
|
|
|
|
|
|
# To continue our example above, assign to `model.paragraphs` the
|
|
|
|
# `collection` (an array of `Paragraph`s).
|
|
|
|
model.send(assoc.name).proxy_association.target = collection
|
2015-08-03 18:37:40 -04:00
|
|
|
end
|
|
|
|
end
|
2015-10-08 19:54:33 -04:00
|
|
|
|
2016-03-13 19:20:17 -04:00
|
|
|
# Given a `version`, return the class to reify. This method supports
|
2016-04-06 18:29:45 -04:00
|
|
|
# Single Table Inheritance (STI) with custom inheritance columns.
|
|
|
|
#
|
|
|
|
# For example, imagine a `version` whose `item_type` is "Animal". The
|
|
|
|
# `animals` table is an STI table (it has cats and dogs) and it has a
|
|
|
|
# custom inheritance column, `species`. If `attrs["species"]` is "Dog",
|
|
|
|
# this method returns the constant `Dog`. If `attrs["species"]` is blank,
|
|
|
|
# this method returns the constant `Animal`. You can see this particular
|
|
|
|
# example in action in `spec/models/animal_spec.rb`.
|
|
|
|
#
|
2016-03-13 19:20:17 -04:00
|
|
|
def version_reification_class(version, attrs)
|
|
|
|
inheritance_column_name = version.item_type.constantize.inheritance_column
|
2016-04-01 01:42:54 -04:00
|
|
|
inher_col_value = attrs[inheritance_column_name]
|
|
|
|
class_name = inher_col_value.blank? ? version.item_type : inher_col_value
|
2016-03-13 19:20:17 -04:00
|
|
|
class_name.constantize
|
|
|
|
end
|
|
|
|
|
2015-10-08 19:54:33 -04:00
|
|
|
# Given a SQL fragment that identifies the IDs of version records,
|
|
|
|
# returns a `Hash` mapping those IDs to `Version`s.
|
|
|
|
#
|
|
|
|
# @api private
|
|
|
|
# @param klass - An ActiveRecord class.
|
|
|
|
# @param version_id_subquery - String. A SQL subquery that selects
|
|
|
|
# the IDs of version records.
|
|
|
|
# @return A `Hash` mapping IDs to `Version`s
|
|
|
|
#
|
|
|
|
def versions_by_id(klass, version_id_subquery)
|
|
|
|
klass.
|
|
|
|
paper_trail_version_class.
|
|
|
|
where("id IN (#{version_id_subquery})").
|
2016-03-05 17:13:22 -05:00
|
|
|
inject({}) { |a, e| a.merge!(e.item_id => e) }
|
2015-10-08 19:54:33 -04:00
|
|
|
end
|
2016-01-11 22:07:57 -05:00
|
|
|
|
|
|
|
# Temporarily suppress #save so we can reassociate with the reified
|
|
|
|
# master of a has_one relationship. Since ActiveRecord 5 the related
|
|
|
|
# object is saved when it is assigned to the association. ActiveRecord
|
|
|
|
# 5 also happens to be the first version that provides #suppress.
|
|
|
|
def without_persisting(record)
|
|
|
|
if record.class.respond_to? :suppress
|
|
|
|
record.class.suppress { yield }
|
|
|
|
else
|
|
|
|
yield
|
|
|
|
end
|
|
|
|
end
|
2015-08-03 18:37:40 -04:00
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|