Break AttributesSerialization into small modules

It already was organized *in memory* but now it's organized in files.

Also, there are six fewer methods being mixed into the model class
(they became ObjectAttribute and ObjectChangesAttribute).

I like how the legacy (rails < 4.2) support is now all in one file,
so it'll be really easy to drop when the time comes.

Finally, a minor thing, I renamed CastedAttributeSerializer to
CastAttributeSerializer, because I don't think "casted" is the
correct conjugation.
This commit is contained in:
Jared Beck 2016-05-18 00:32:24 -04:00
parent 358fb25529
commit 10cabf2408
11 changed files with 218 additions and 172 deletions

View File

@ -8,7 +8,7 @@ Metrics/CyclomaticComplexity:
Max: 13 # Goal: 6
Metrics/ModuleLength:
Max: 311
Max: 313
Metrics/PerceivedComplexity:
Max: 16 # Goal: 7

View File

@ -1,5 +1,4 @@
require "request_store"
require "paper_trail/attributes_serialization"
require "paper_trail/cleaner"
require "paper_trail/config"
require "paper_trail/has_paper_trail"

View File

@ -0,0 +1,10 @@
Attribute Serializers
=====================
"Serialization" here refers to the preparation of data for insertion into a
database, particularly the `object` and `object_changes` columns in the
`versions` table.
Likewise, "deserialization" refers to any processing of data after they
have been read from the database, for example preparing the result of
`VersionConcern#changeset`.

View File

@ -0,0 +1,58 @@
module PaperTrail
# :nodoc:
module AttributeSerializers
# The `CastAttributeSerializer` (de)serializes model attribute values. For
# example, the string "1.99" serializes into the integer `1` when assigned
# to an attribute of type `ActiveRecord::Type::Integer`.
#
# This implementation depends on the `type_for_attribute` method, which was
# introduced in rails 4.2. In older versions of rails, we shim this method
# with `LegacyActiveRecordShim`.
if ::ActiveRecord::VERSION::MAJOR >= 5
# This implementation uses AR 5's `serialize` and `deserialize`.
class CastAttributeSerializer
def initialize(klass)
@klass = klass
end
def serialize(attr, val)
@klass.type_for_attribute(attr).serialize(val)
end
def deserialize(attr, val)
@klass.type_for_attribute(attr).deserialize(val)
end
end
else
# This implementation uses AR 4.2's `type_cast_for_database`. For
# versions of AR < 4.2 we provide an implementation of
# `type_cast_for_database` in our shim attribute type classes,
# `NoOpAttribute` and `SerializedAttribute`.
class CastAttributeSerializer
def initialize(klass)
@klass = klass
end
def serialize(attr, val)
val = defined_enums[attr][val] if defined_enums[attr]
@klass.type_for_attribute(attr).type_cast_for_database(val)
end
def deserialize(attr, val)
val = @klass.type_for_attribute(attr).type_cast_from_database(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
end
end

View File

@ -0,0 +1,48 @@
module PaperTrail
module AttributeSerializers
# Included into model if AR version is < 4.2. Backport Rails 4.2 and later's
# `type_for_attribute` so we can build on a common interface.
module LegacyActiveRecordShim
# An attribute which needs no processing. It is part of our backport (shim)
# of rails 4.2's attribute API. See `type_for_attribute` below.
class NoOpAttribute
def type_cast_for_database(value)
value
end
def type_cast_from_database(data)
data
end
end
NO_OP_ATTRIBUTE = NoOpAttribute.new
# An attribute which requires manual (de)serialization to/from what we get
# from the database. It is part of our backport (shim) of rails 4.2's
# attribute API. See `type_for_attribute` below.
class SerializedAttribute
def initialize(coder)
@coder = coder.respond_to?(:dump) ? coder : PaperTrail.serializer
end
def type_cast_for_database(value)
@coder.dump(value)
end
def type_cast_from_database(data)
@coder.load(data)
end
end
def type_for_attribute(attr_name)
serialized_attribute_types[attr_name.to_s] || NO_OP_ATTRIBUTE
end
def serialized_attribute_types
@attribute_types ||= Hash[serialized_attributes.map do |attr_name, coder|
[attr_name, SerializedAttribute.new(coder)]
end]
end
private :serialized_attribute_types
end
end
end

View File

@ -0,0 +1,39 @@
require "paper_trail/attribute_serializers/cast_attribute_serializer"
module PaperTrail
module AttributeSerializers
# Serialize or deserialize the `version.object` column.
class ObjectAttribute
def initialize(model_class)
@model_class = model_class
end
def serialize(attributes)
alter(attributes, :serialize)
end
def deserialize(attributes)
alter(attributes, :deserialize)
end
private
# Modifies `attributes` in place.
# TODO: Return a new hash instead.
def alter(attributes, serialization_method)
# Don't serialize before values before inserting into columns of type
# `JSON` on `PostgreSQL` databases.
return attributes if object_col_is_json?
serializer = CastAttributeSerializer.new(@model_class)
attributes.each do |key, value|
attributes[key] = serializer.send(serialization_method, key, value)
end
end
def object_col_is_json?
@model_class.paper_trail_version_class.object_col_is_json?
end
end
end
end

View File

@ -0,0 +1,42 @@
require "paper_trail/attribute_serializers/cast_attribute_serializer"
module PaperTrail
module AttributeSerializers
# Serialize or deserialize the `version.object_changes` column.
class ObjectChangesAttribute
def initialize(item_class)
@item_class = item_class
end
def serialize(changes)
alter(changes, :serialize)
end
def deserialize(changes)
alter(changes, :deserialize)
end
private
# Modifies `changes` in place.
# TODO: Return a new hash instead.
def alter(changes, serialization_method)
# Don't serialize before values before inserting into columns of type
# `JSON` on `PostgreSQL` databases.
return changes if object_changes_col_is_json?
serializer = CastAttributeSerializer.new(@item_class)
changes.clone.each do |key, change|
# `change` is an Array with two elements, representing before and after.
changes[key] = Array(change).map do |value|
serializer.send(serialization_method, key, value)
end
end
end
def object_changes_col_is_json?
@item_class.paper_trail_version_class.object_changes_col_is_json?
end
end
end
end

View File

@ -1,161 +0,0 @@
module PaperTrail
# "Serialization" here refers to the preparation of data for insertion into a
# database, particularly the `object` and `object_changes` columns in the
# `versions` table.
#
# Likewise, "deserialization" refers to any processing of data after they
# have been read from the database, for example preparing the result of
# `VersionConcern#changeset`.
module AttributesSerialization
# An attribute which needs no processing. It is part of our backport (shim)
# of rails 4.2's attribute API. See `type_for_attribute` below.
class NoOpAttribute
def type_cast_for_database(value)
value
end
def type_cast_from_database(data)
data
end
end
NO_OP_ATTRIBUTE = NoOpAttribute.new
# An attribute which requires manual (de)serialization to/from what we get
# from the database. It is part of our backport (shim) of rails 4.2's
# attribute API. See `type_for_attribute` below.
class SerializedAttribute
def initialize(coder)
@coder = coder.respond_to?(:dump) ? coder : PaperTrail.serializer
end
def type_cast_for_database(value)
@coder.dump(value)
end
def type_cast_from_database(data)
@coder.load(data)
end
end
# The `AbstractSerializer` (de)serializes model attribute values. For
# example, the string "1.99" serializes into the integer `1` when assigned
# to an attribute of type `ActiveRecord::Type::Integer`.
#
# This implementation depends on the `type_for_attribute` method, which was
# introduced in rails 4.2. In older versions of rails, we shim this method
# below.
#
# At runtime, the `AbstractSerializer` has only one child class, the
# `CastedAttributeSerializer`, whose implementation varies depending on the
# version of ActiveRecord.
class AbstractSerializer
def initialize(klass)
@klass = klass
end
private
def apply_serialization(method, attr, val)
@klass.type_for_attribute(attr).send(method, val)
end
end
if ::ActiveRecord::VERSION::MAJOR >= 5
# This implementation uses AR 5's `serialize` and `deserialize`.
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
# This implementation uses AR 4.2's `type_cast_for_database`. For
# versions of AR < 4.2 we provide an implementation of
# `type_cast_for_database` in our shim attribute type classes,
# `NoOpAttribute` and `SerializedAttribute`.
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
# Backport Rails 4.2 and later's `type_for_attribute` so we can build
# on a common interface.
if ::ActiveRecord::VERSION::STRING < "4.2"
def type_for_attribute(attr_name)
serialized_attribute_types[attr_name.to_s] || NO_OP_ATTRIBUTE
end
def serialized_attribute_types
@attribute_types ||= Hash[serialized_attributes.map do |attr_name, coder|
[attr_name, SerializedAttribute.new(coder)]
end]
end
private :serialized_attribute_types
end
# Used for `Version#object` attribute.
def serialize_attributes_for_paper_trail!(attributes)
alter_attributes_for_paper_trail!(:serialize, attributes)
end
def unserialize_attributes_for_paper_trail!(attributes)
alter_attributes_for_paper_trail!(:deserialize, attributes)
end
def alter_attributes_for_paper_trail!(serialization_method, attributes)
# Don't serialize before values before inserting into columns of type
# `JSON` on `PostgreSQL` databases.
return attributes if paper_trail_version_class.object_col_is_json?
serializer = CastedAttributeSerializer.new(self)
attributes.each do |key, value|
attributes[key] = serializer.send(serialization_method, key, value)
end
end
# Used for Version#object_changes attribute.
def serialize_attribute_changes_for_paper_trail!(changes)
alter_attribute_changes_for_paper_trail!(:serialize, changes)
end
def unserialize_attribute_changes_for_paper_trail!(changes)
alter_attribute_changes_for_paper_trail!(:deserialize, changes)
end
def alter_attribute_changes_for_paper_trail!(serialization_method, changes)
# Don't serialize before values before inserting into columns of type
# `JSON` on `PostgreSQL` databases.
return changes if paper_trail_version_class.object_changes_col_is_json?
serializer = CastedAttributeSerializer.new(self)
changes.clone.each do |key, change|
# `change` is an Array with two elements, representing before and after.
changes[key] = Array(change).map do |value|
serializer.send(serialization_method, key, value)
end
end
end
end
end

View File

@ -1,5 +1,7 @@
require "active_support/core_ext/object" # provides the `try` method
require "paper_trail/attributes_serialization"
require "paper_trail/attribute_serializers/legacy_active_record_shim"
require "paper_trail/attribute_serializers/object_attribute"
require "paper_trail/attribute_serializers/object_changes_attribute"
module PaperTrail
# Extensions to `ActiveRecord::Base`. See `frameworks/active_record.rb`.
@ -110,7 +112,10 @@ module PaperTrail
# Lazily include the instance methods so we don't clutter up
# any more ActiveRecord models than we have to.
send :include, InstanceMethods
send :extend, AttributesSerialization
if ::ActiveRecord::VERSION::STRING < "4.2"
send :extend, AttributeSerializers::LegacyActiveRecordShim
end
class_attribute :version_association_name
self.version_association_name = options[:version] || :version
@ -444,7 +449,9 @@ module PaperTrail
def changes_for_paper_trail
notable_changes = changes.delete_if { |k, _v| !notably_changed.include?(k) }
self.class.serialize_attribute_changes_for_paper_trail!(notable_changes)
AttributeSerializers::ObjectChangesAttribute.
new(self.class).
serialize(notable_changes)
notable_changes.to_hash
end
@ -577,7 +584,7 @@ module PaperTrail
# ommitting attributes to be skipped.
def object_attrs_for_paper_trail
attrs = attributes_before_change.except(*paper_trail_options[:skip])
self.class.serialize_attributes_for_paper_trail!(attrs)
AttributeSerializers::ObjectAttribute.new(self.class).serialize(attrs)
attrs
end

View File

@ -1,3 +1,5 @@
require "paper_trail/attribute_serializers/object_attribute"
module PaperTrail
# Given a version record and some options, builds a new model object.
# @api private
@ -164,10 +166,9 @@ module PaperTrail
# 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
AttributeSerializers::ObjectAttribute.new(model.class).deserialize(attrs)
attrs.each do |k, v|
# `unserialize_attributes_for_paper_trail!` will return the mapped enum value
# `ObjectAttribute#deserialize` 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

View File

@ -1,4 +1,5 @@
require "active_support/concern"
require "paper_trail/attribute_serializers/object_changes_attribute"
module PaperTrail
# Originally, PaperTrail did not provide this module, and all of this
@ -284,7 +285,7 @@ module PaperTrail
# First, deserialize the `object_changes` column.
changes = HashWithIndifferentAccess.new(object_changes_deserialized)
# The next step is, perhaps unfortunately, called "un-serialization",
# The next step is, perhaps unfortunately, called "de-serialization",
# and appears to be responsible for custom attribute serializers. For an
# example of a custom attribute serializer, see
# `Person::TimeZoneSerializer` in the test suite.
@ -296,7 +297,9 @@ module PaperTrail
#
# Note: `item` returns nil if `event` is "destroy".
unless item.nil?
item.class.unserialize_attribute_changes_for_paper_trail!(changes)
AttributeSerializers::ObjectChangesAttribute.
new(item.class).
deserialize(changes)
end
# Finally, return a Hash mapping each attribute name to