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])
```
This commit is contained in:
Chris Barton 2016-03-11 01:18:40 -08:00
parent 16a07c2f50
commit e1f94d4597
4 changed files with 98 additions and 41 deletions

View File

@ -25,13 +25,53 @@ module PaperTrail
end end
end end
SERIALIZE, DESERIALIZE = class AbstractSerializer
if ::ActiveRecord::VERSION::MAJOR >= 5 def initialize(klass)
[:serialize, :deserialize] @klass = klass
else
[:type_cast_for_database, :type_cast_from_database]
end end
private
def apply_serialization(method, attr, val)
@klass.type_for_attribute(attr).send(method, val)
end
end
if ::ActiveRecord::VERSION::MAJOR >= 5
class CastedAttributeSerializer < AbstractSerializer
def serialize(attr, val)
apply_serialization(:serialize, attr, val)
end
def deserialize(attr, val)
apply_serialization(:deserialize, attr, val)
end
end
else
class CastedAttributeSerializer < AbstractSerializer
def serialize(attr, val)
val = defined_enums[attr][val] if defined_enums[attr]
apply_serialization(:type_cast_for_database, attr, val)
end
def deserialize(attr, val)
val = apply_serialization(:type_cast_from_database, attr, val)
if defined_enums[attr]
defined_enums[attr].key(val)
else
val
end
end
private
def defined_enums
@defined_enums ||= (@klass.respond_to?(:defined_enums) ? @klass.defined_enums : {})
end
end
end
if ::ActiveRecord::VERSION::STRING < "4.2" if ::ActiveRecord::VERSION::STRING < "4.2"
# Backport Rails 4.2 and later's `type_for_attribute` to build # Backport Rails 4.2 and later's `type_for_attribute` to build
# on a common interface. # on a common interface.
@ -49,40 +89,43 @@ module PaperTrail
# Used for `Version#object` attribute. # Used for `Version#object` attribute.
def serialize_attributes_for_paper_trail!(attributes) def serialize_attributes_for_paper_trail!(attributes)
alter_attributes_for_paper_trail!(SERIALIZE, attributes) alter_attributes_for_paper_trail!(:serialize, attributes)
end end
def unserialize_attributes_for_paper_trail!(attributes) def unserialize_attributes_for_paper_trail!(attributes)
alter_attributes_for_paper_trail!(DESERIALIZE, attributes) alter_attributes_for_paper_trail!(:deserialize, attributes)
end end
def alter_attributes_for_paper_trail!(serializer, attributes) def alter_attributes_for_paper_trail!(serialization_method, attributes)
# Don't serialize before values before inserting into columns of type # Don't serialize before values before inserting into columns of type
# `JSON` on `PostgreSQL` databases. # `JSON` on `PostgreSQL` databases.
return attributes if paper_trail_version_class.object_col_is_json? return attributes if paper_trail_version_class.object_col_is_json?
serializer = CastedAttributeSerializer.new(self)
attributes.each do |key, value| attributes.each do |key, value|
attributes[key] = type_for_attribute(key).send(serializer, value) attributes[key] = serializer.send(serialization_method, key, value)
end end
end end
# Used for Version#object_changes attribute. # Used for Version#object_changes attribute.
def serialize_attribute_changes_for_paper_trail!(changes) def serialize_attribute_changes_for_paper_trail!(changes)
alter_attribute_changes_for_paper_trail!(SERIALIZE, changes) alter_attribute_changes_for_paper_trail!(:serialize, changes)
end end
def unserialize_attribute_changes_for_paper_trail!(changes) def unserialize_attribute_changes_for_paper_trail!(changes)
alter_attribute_changes_for_paper_trail!(DESERIALIZE, changes) alter_attribute_changes_for_paper_trail!(:deserialize, changes)
end end
def alter_attribute_changes_for_paper_trail!(serializer, changes) def alter_attribute_changes_for_paper_trail!(serialization_method, changes)
# Don't serialize before values before inserting into columns of type # Don't serialize before values before inserting into columns of type
# `JSON` on `PostgreSQL` databases. # `JSON` on `PostgreSQL` databases.
return changes if paper_trail_version_class.object_changes_col_is_json? return changes if paper_trail_version_class.object_changes_col_is_json?
serializer = CastedAttributeSerializer.new(self)
changes.clone.each do |key, change| changes.clone.each do |key, change|
type = type_for_attribute(key) changes[key] = Array(change).map do |value|
changes[key] = Array(change).map { |value| type.send(serializer, value) } serializer.send(serialization_method, key, value)
end
end end
end end
end end

View File

@ -364,11 +364,10 @@ module PaperTrail
# `PaperTrail.serializer`. # `PaperTrail.serializer`.
# @api private # @api private
def pt_recordable_object def pt_recordable_object
object_attrs = object_attrs_for_paper_trail(attributes_before_change)
if self.class.paper_trail_version_class.object_col_is_json? if self.class.paper_trail_version_class.object_col_is_json?
object_attrs object_attrs_for_paper_trail
else else
PaperTrail.serializer.dump(object_attrs) PaperTrail.serializer.dump(object_attrs_for_paper_trail)
end end
end end
@ -494,20 +493,14 @@ module PaperTrail
end end
def attributes_before_change def attributes_before_change
attributes.tap do |prev| changed = changed_attributes.select { |k, _v| self.class.column_names.include?(k) }
enums = respond_to?(:defined_enums) ? defined_enums : {} attributes.merge(changed)
attrs = changed_attributes.select { |k, _v| self.class.column_names.include?(k) }
attrs.each do |attr, before|
before = enums[attr][before] if enums[attr]
prev[attr] = before
end
end
end end
# Returns hash of attributes (with appropriate attributes serialized), # Returns hash of attributes (with appropriate attributes serialized),
# ommitting attributes to be skipped. # ommitting attributes to be skipped.
def object_attrs_for_paper_trail(attributes_hash) def object_attrs_for_paper_trail
attrs = attributes_hash.except(*paper_trail_options[:skip]) attrs = attributes_before_change.except(*paper_trail_options[:skip])
self.class.serialize_attributes_for_paper_trail!(attrs) self.class.serialize_attributes_for_paper_trail!(attrs)
attrs attrs
end end

View File

@ -57,20 +57,7 @@ module PaperTrail
end end
end end
model.class.unserialize_attributes_for_paper_trail! attrs reify_attributes(model, version, attrs)
# Set all the attributes in this version on the model.
attrs.each do |k, v|
if model.has_attribute?(k)
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
model.send "#{model.class.version_association_name}=", version model.send "#{model.class.version_association_name}=", version
@ -87,6 +74,30 @@ module PaperTrail
private private
# 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
is_enum_without_type_caster = ::ActiveRecord::VERSION::MAJOR < 5 && enums[k]
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
# Replaces each record in `array` with its reified version, if present # Replaces each record in `array` with its reified version, if present
# in `versions`. # in `versions`.
# #

View File

@ -12,6 +12,16 @@ describe PostWithStatus, type: :model do
post.archived! post.archived!
expect(post.previous_version.published?).to be true expect(post.previous_version.published?).to be true
end end
context "storing enum object_changes" do
subject { post.versions.last }
it "should stash the enum value properly in versions object_changes" do
post.published!
post.archived!
expect(subject.changeset["status"]).to eql %w(published archived)
end
end
end end
end end
end end