From 10cabf24087043cfbb0baa137ca67714791cc927 Mon Sep 17 00:00:00 2001 From: Jared Beck Date: Wed, 18 May 2016 00:32:24 -0400 Subject: [PATCH] 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. --- .rubocop_todo.yml | 2 +- lib/paper_trail.rb | 1 - .../attribute_serializers/README.md | 10 ++ .../cast_attribute_serializer.rb | 58 +++++++ .../legacy_active_record_shim.rb | 48 ++++++ .../attribute_serializers/object_attribute.rb | 39 +++++ .../object_changes_attribute.rb | 42 +++++ lib/paper_trail/attributes_serialization.rb | 161 ------------------ lib/paper_trail/has_paper_trail.rb | 15 +- lib/paper_trail/reifier.rb | 7 +- lib/paper_trail/version_concern.rb | 7 +- 11 files changed, 218 insertions(+), 172 deletions(-) create mode 100644 lib/paper_trail/attribute_serializers/README.md create mode 100644 lib/paper_trail/attribute_serializers/cast_attribute_serializer.rb create mode 100644 lib/paper_trail/attribute_serializers/legacy_active_record_shim.rb create mode 100644 lib/paper_trail/attribute_serializers/object_attribute.rb create mode 100644 lib/paper_trail/attribute_serializers/object_changes_attribute.rb delete mode 100644 lib/paper_trail/attributes_serialization.rb diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 5e29bd95..384273fe 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -8,7 +8,7 @@ Metrics/CyclomaticComplexity: Max: 13 # Goal: 6 Metrics/ModuleLength: - Max: 311 + Max: 313 Metrics/PerceivedComplexity: Max: 16 # Goal: 7 diff --git a/lib/paper_trail.rb b/lib/paper_trail.rb index c5869b7e..3b789cb9 100644 --- a/lib/paper_trail.rb +++ b/lib/paper_trail.rb @@ -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" diff --git a/lib/paper_trail/attribute_serializers/README.md b/lib/paper_trail/attribute_serializers/README.md new file mode 100644 index 00000000..7af305b6 --- /dev/null +++ b/lib/paper_trail/attribute_serializers/README.md @@ -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`. diff --git a/lib/paper_trail/attribute_serializers/cast_attribute_serializer.rb b/lib/paper_trail/attribute_serializers/cast_attribute_serializer.rb new file mode 100644 index 00000000..2db28570 --- /dev/null +++ b/lib/paper_trail/attribute_serializers/cast_attribute_serializer.rb @@ -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 diff --git a/lib/paper_trail/attribute_serializers/legacy_active_record_shim.rb b/lib/paper_trail/attribute_serializers/legacy_active_record_shim.rb new file mode 100644 index 00000000..e9928caa --- /dev/null +++ b/lib/paper_trail/attribute_serializers/legacy_active_record_shim.rb @@ -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 diff --git a/lib/paper_trail/attribute_serializers/object_attribute.rb b/lib/paper_trail/attribute_serializers/object_attribute.rb new file mode 100644 index 00000000..2157bbda --- /dev/null +++ b/lib/paper_trail/attribute_serializers/object_attribute.rb @@ -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 diff --git a/lib/paper_trail/attribute_serializers/object_changes_attribute.rb b/lib/paper_trail/attribute_serializers/object_changes_attribute.rb new file mode 100644 index 00000000..682c7b06 --- /dev/null +++ b/lib/paper_trail/attribute_serializers/object_changes_attribute.rb @@ -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 diff --git a/lib/paper_trail/attributes_serialization.rb b/lib/paper_trail/attributes_serialization.rb deleted file mode 100644 index 86071ab8..00000000 --- a/lib/paper_trail/attributes_serialization.rb +++ /dev/null @@ -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 diff --git a/lib/paper_trail/has_paper_trail.rb b/lib/paper_trail/has_paper_trail.rb index 041914c4..d38e5814 100644 --- a/lib/paper_trail/has_paper_trail.rb +++ b/lib/paper_trail/has_paper_trail.rb @@ -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 diff --git a/lib/paper_trail/reifier.rb b/lib/paper_trail/reifier.rb index 43c065e4..9fc339fc 100644 --- a/lib/paper_trail/reifier.rb +++ b/lib/paper_trail/reifier.rb @@ -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 diff --git a/lib/paper_trail/version_concern.rb b/lib/paper_trail/version_concern.rb index 2cb69e4c..06bd39df 100644 --- a/lib/paper_trail/version_concern.rb +++ b/lib/paper_trail/version_concern.rb @@ -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