From e360ac12315ed6b9eadca5bcc0d95dc766ba8523 Mon Sep 17 00:00:00 2001 From: Evgenii Pecherkin Date: Tue, 17 Oct 2017 16:05:05 +0400 Subject: [PATCH 01/15] Introduce serializers to ActiveJob --- activejob/README.md | 62 +++++++- activejob/lib/active_job.rb | 1 + activejob/lib/active_job/arguments.rb | 132 +----------------- activejob/lib/active_job/base.rb | 2 + activejob/lib/active_job/serializers.rb | 109 +++++++++++++++ .../serializers/array_serializer.rb | 26 ++++ .../active_job/serializers/base_serializer.rb | 13 ++ .../serializers/class_serializer.rb | 24 ++++ .../serializers/duration_serializer.rb | 42 ++++++ .../serializers/global_id_serializer.rb | 32 +++++ .../active_job/serializers/hash_serializer.rb | 62 ++++++++ ...hash_with_indifferent_access_serializer.rb | 37 +++++ .../serializers/object_serializer.rb | 27 ++++ .../serializers/standard_type_serializer.rb | 26 ++++ .../serializers/struct_serializer.rb | 38 +++++ .../serializers/symbol_serializer.rb | 28 ++++ .../lib/rails/generators/job/job_generator.rb | 2 +- .../test/cases/argument_serialization_test.rb | 10 +- guides/source/active_job_basics.md | 65 ++++++++- 19 files changed, 602 insertions(+), 136 deletions(-) create mode 100644 activejob/lib/active_job/serializers.rb create mode 100644 activejob/lib/active_job/serializers/array_serializer.rb create mode 100644 activejob/lib/active_job/serializers/base_serializer.rb create mode 100644 activejob/lib/active_job/serializers/class_serializer.rb create mode 100644 activejob/lib/active_job/serializers/duration_serializer.rb create mode 100644 activejob/lib/active_job/serializers/global_id_serializer.rb create mode 100644 activejob/lib/active_job/serializers/hash_serializer.rb create mode 100644 activejob/lib/active_job/serializers/hash_with_indifferent_access_serializer.rb create mode 100644 activejob/lib/active_job/serializers/object_serializer.rb create mode 100644 activejob/lib/active_job/serializers/standard_type_serializer.rb create mode 100644 activejob/lib/active_job/serializers/struct_serializer.rb create mode 100644 activejob/lib/active_job/serializers/symbol_serializer.rb diff --git a/activejob/README.md b/activejob/README.md index f1ebb76e08a93..152f924525228 100644 --- a/activejob/README.md +++ b/activejob/README.md @@ -52,8 +52,21 @@ MyJob.set(wait: 1.week).perform_later(record) # Enqueue a job to be performed 1 That's it! +## Supported types for arguments -## GlobalID support +ActiveJob supports the following types of arguments by default: + + - Standard types (`NilClass`, `String`, `Integer`, `Fixnum`, `Bignum`, `Float`, `BigDecimal`, `TrueClass`, `FalseClass`) + - `Symbol` (`:foo`, `:bar`, ...) + - `ActiveSupport::Duration` (`1.day`, `2.weeks`, ...) + - Classes constants (`ActiveRecord::Base`, `MySpecialService`, ...) + - Struct instances (`Struct.new('Rectangle', :width, :height).new(12, 20)`, ...) + - `Hash`. Keys should be of `String` or `Symbol` type + - `ActiveSupport::HashWithIndifferentAccess` + - `Array` + + +### GlobalID support Active Job supports [GlobalID serialization](https://github.com/rails/globalid/) for parameters. This makes it possible to pass live Active Record objects to your job instead of class/id pairs, which @@ -81,6 +94,53 @@ end This works with any class that mixes in GlobalID::Identification, which by default has been mixed into Active Record classes. +### Serializers + +You can extend list of supported types for arguments. You just need to define your own serializer. + +```ruby +class MySpecialSerializer + class << self + # Check if this object should be serialized using this serializer + def serialize?(argument) + object.is_a? MySpecialValueObject + end + + # Convert an object to a simpler representative using supported object types. + # The recommended representative is a Hash with a specific key. Keys can be of basic types only + def serialize(object) + { + key => ActiveJob::Serializers.serialize(object.value) + 'another_attribute' => ActiveJob::Serializers.serialize(object.another_attribute) + } + end + + # Check if this serialized value be deserialized using this serializer + def deserialize?(argument) + object.is_a?(Hash) && object.keys == [key, 'another_attribute'] + end + + # Convert serialized value into a proper object + def deserialize(object) + value = ActiveJob::Serializers.deserialize(object[key]) + another_attribute = ActiveJob::Serializers.deserialize(object['another_attribute']) + MySpecialValueObject.new value, another_attribute + end + + # Define this method if you are using a hash as a representative. + # This key will be added to a list of restricted keys for hashes. Use basic types only + def key + "_aj_custom_dummy_value_object" + end + end +end +``` + +And now you just need to add this serializer to a list: + +```ruby +ActiveJob::Base.add_serializers(MySpecialSerializer) +``` ## Supported queueing systems diff --git a/activejob/lib/active_job.rb b/activejob/lib/active_job.rb index 626abaa767143..01fab4d918a65 100644 --- a/activejob/lib/active_job.rb +++ b/activejob/lib/active_job.rb @@ -33,6 +33,7 @@ module ActiveJob autoload :Base autoload :QueueAdapters + autoload :Serializers autoload :ConfiguredJob autoload :TestCase autoload :TestHelper diff --git a/activejob/lib/active_job/arguments.rb b/activejob/lib/active_job/arguments.rb index de11e7fcb1936..9d4713186416c 100644 --- a/activejob/lib/active_job/arguments.rb +++ b/activejob/lib/active_job/arguments.rb @@ -3,24 +3,6 @@ require "active_support/core_ext/hash" module ActiveJob - # Raised when an exception is raised during job arguments deserialization. - # - # Wraps the original exception raised as +cause+. - class DeserializationError < StandardError - def initialize #:nodoc: - super("Error while trying to deserialize arguments: #{$!.message}") - set_backtrace $!.backtrace - end - end - - # Raised when an unsupported argument type is set as a job argument. We - # currently support NilClass, Integer, Fixnum, Float, String, TrueClass, FalseClass, - # Bignum, BigDecimal, and objects that can be represented as GlobalIDs (ex: Active Record). - # Raised if you set the key for a Hash something else than a string or - # a symbol. Also raised when trying to serialize an object which can't be - # identified with a Global ID - such as an unpersisted Active Record model. - class SerializationError < ArgumentError; end - module Arguments extend self # :nodoc: @@ -31,126 +13,16 @@ module Arguments # as-is. Arrays/Hashes are serialized element by element. # All other types are serialized using GlobalID. def serialize(arguments) - arguments.map { |argument| serialize_argument(argument) } + ActiveJob::Serializers.serialize(arguments) end # Deserializes a set of arguments. Whitelisted types are returned # as-is. Arrays/Hashes are deserialized element by element. # All other types are deserialized using GlobalID. def deserialize(arguments) - arguments.map { |argument| deserialize_argument(argument) } + ActiveJob::Serializers.deserialize(arguments) rescue raise DeserializationError end - - private - # :nodoc: - GLOBALID_KEY = "_aj_globalid".freeze - # :nodoc: - SYMBOL_KEYS_KEY = "_aj_symbol_keys".freeze - # :nodoc: - WITH_INDIFFERENT_ACCESS_KEY = "_aj_hash_with_indifferent_access".freeze - private_constant :GLOBALID_KEY, :SYMBOL_KEYS_KEY, :WITH_INDIFFERENT_ACCESS_KEY - - def serialize_argument(argument) - case argument - when *TYPE_WHITELIST - argument - when GlobalID::Identification - convert_to_global_id_hash(argument) - when Array - argument.map { |arg| serialize_argument(arg) } - when ActiveSupport::HashWithIndifferentAccess - result = serialize_hash(argument) - result[WITH_INDIFFERENT_ACCESS_KEY] = serialize_argument(true) - result - when Hash - symbol_keys = argument.each_key.grep(Symbol).map(&:to_s) - result = serialize_hash(argument) - result[SYMBOL_KEYS_KEY] = symbol_keys - result - else - raise SerializationError.new("Unsupported argument type: #{argument.class.name}") - end - end - - def deserialize_argument(argument) - case argument - when String - GlobalID::Locator.locate(argument) || argument - when *TYPE_WHITELIST - argument - when Array - argument.map { |arg| deserialize_argument(arg) } - when Hash - if serialized_global_id?(argument) - deserialize_global_id argument - else - deserialize_hash(argument) - end - else - raise ArgumentError, "Can only deserialize primitive arguments: #{argument.inspect}" - end - end - - def serialized_global_id?(hash) - hash.size == 1 && hash.include?(GLOBALID_KEY) - end - - def deserialize_global_id(hash) - GlobalID::Locator.locate hash[GLOBALID_KEY] - end - - def serialize_hash(argument) - argument.each_with_object({}) do |(key, value), hash| - hash[serialize_hash_key(key)] = serialize_argument(value) - end - end - - def deserialize_hash(serialized_hash) - result = serialized_hash.transform_values { |v| deserialize_argument(v) } - if result.delete(WITH_INDIFFERENT_ACCESS_KEY) - result = result.with_indifferent_access - elsif symbol_keys = result.delete(SYMBOL_KEYS_KEY) - result = transform_symbol_keys(result, symbol_keys) - end - result - end - - # :nodoc: - RESERVED_KEYS = [ - GLOBALID_KEY, GLOBALID_KEY.to_sym, - SYMBOL_KEYS_KEY, SYMBOL_KEYS_KEY.to_sym, - WITH_INDIFFERENT_ACCESS_KEY, WITH_INDIFFERENT_ACCESS_KEY.to_sym, - ] - private_constant :RESERVED_KEYS - - def serialize_hash_key(key) - case key - when *RESERVED_KEYS - raise SerializationError.new("Can't serialize a Hash with reserved key #{key.inspect}") - when String, Symbol - key.to_s - else - raise SerializationError.new("Only string and symbol hash keys may be serialized as job arguments, but #{key.inspect} is a #{key.class}") - end - end - - def transform_symbol_keys(hash, symbol_keys) - hash.transform_keys do |key| - if symbol_keys.include?(key) - key.to_sym - else - key - end - end - end - - def convert_to_global_id_hash(argument) - { GLOBALID_KEY => argument.to_global_id.to_s } - rescue URI::GID::MissingModelIdError - raise SerializationError, "Unable to serialize #{argument.class} " \ - "without an id. (Maybe you forgot to call save?)" - end end end diff --git a/activejob/lib/active_job/base.rb b/activejob/lib/active_job/base.rb index ae112abb2c201..82757768203f0 100644 --- a/activejob/lib/active_job/base.rb +++ b/activejob/lib/active_job/base.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "active_job/core" +require "active_job/serializers" require "active_job/queue_adapter" require "active_job/queue_name" require "active_job/queue_priority" @@ -59,6 +60,7 @@ module ActiveJob #:nodoc: # * SerializationError - Error class for serialization errors. class Base include Core + include Serializers include QueueAdapter include QueueName include QueuePriority diff --git a/activejob/lib/active_job/serializers.rb b/activejob/lib/active_job/serializers.rb new file mode 100644 index 0000000000000..ec8606514931b --- /dev/null +++ b/activejob/lib/active_job/serializers.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +module ActiveJob + # Raised when an exception is raised during job arguments deserialization. + # + # Wraps the original exception raised as +cause+. + class DeserializationError < StandardError + def initialize #:nodoc: + super("Error while trying to deserialize arguments: #{$!.message}") + set_backtrace $!.backtrace + end + end + + # Raised when an unsupported argument type is set as a job argument. We + # currently support NilClass, Integer, Fixnum, Float, String, TrueClass, FalseClass, + # Bignum, BigDecimal, and objects that can be represented as GlobalIDs (ex: Active Record). + # Raised if you set the key for a Hash something else than a string or + # a symbol. Also raised when trying to serialize an object which can't be + # identified with a Global ID - such as an unpersisted Active Record model. + class SerializationError < ArgumentError; end + + # The ActiveJob::Serializers module is used to store a list of known serializers + # and to add new ones. It also has helpers to serialize/deserialize objects + module Serializers + extend ActiveSupport::Autoload + extend ActiveSupport::Concern + + autoload :ArraySerializer + autoload :BaseSerializer + autoload :ClassSerializer + autoload :DurationSerializer + autoload :GlobalIDSerializer + autoload :HashWithIndifferentAccessSerializer + autoload :HashSerializer + autoload :ObjectSerializer + autoload :StandardTypeSerializer + autoload :StructSerializer + autoload :SymbolSerializer + + included do + class_attribute :_additional_serializers, instance_accessor: false, instance_predicate: false + self._additional_serializers = [] + end + + # Includes the method to list known serializers and to add new ones + module ClassMethods + # Returns list of known serializers + def serializers + self._additional_serializers + SERIALIZERS + end + + # Adds a new serializer to a list of known serializers + def add_serializers(*serializers) + check_duplicate_serializer_keys!(serializers) + + @_additional_serializers = serializers + @_additional_serializers + end + + # Returns a list of reserved keys, which cannot be used as keys for a hash + def reserved_serializers_keys + serializers.select { |s| s.respond_to?(:key) }.map(&:key) + end + + private + + def check_duplicate_serializer_keys!(serializers) + keys_to_add = serializers.select { |s| s.respond_to?(:key) }.map(&:key) + + duplicate_keys = reserved_keys & keys_to_add + + raise ArgumentError.new("Can't add serializers because of keys duplication: #{duplicate_keys}") if duplicate_keys.any? + end + end + + # :nodoc: + SERIALIZERS = [ + ::ActiveJob::Serializers::GlobalIDSerializer, + ::ActiveJob::Serializers::DurationSerializer, + ::ActiveJob::Serializers::StructSerializer, + ::ActiveJob::Serializers::SymbolSerializer, + ::ActiveJob::Serializers::ClassSerializer, + ::ActiveJob::Serializers::StandardTypeSerializer, + ::ActiveJob::Serializers::HashWithIndifferentAccessSerializer, + ::ActiveJob::Serializers::HashSerializer, + ::ActiveJob::Serializers::ArraySerializer + ].freeze + private_constant :SERIALIZERS + + class << self + # Returns serialized representative of the passed object. + # Will look up through all known serializers. + # Raises `SerializationError` if it can't find a proper serializer. + def serialize(argument) + serializer = ::ActiveJob::Base.serializers.detect { |s| s.serialize?(argument) } + raise SerializationError.new("Unsupported argument type: #{argument.class.name}") unless serializer + serializer.serialize(argument) + end + + # Returns deserialized object. + # Will look up through all known serializers. + # If no serializers found will raise `ArgumentError` + def deserialize(argument) + serializer = ::ActiveJob::Base.serializers.detect { |s| s.deserialize?(argument) } + raise ArgumentError, "Can only deserialize primitive arguments: #{argument.inspect}" unless serializer + serializer.deserialize(argument) + end + end + end +end diff --git a/activejob/lib/active_job/serializers/array_serializer.rb b/activejob/lib/active_job/serializers/array_serializer.rb new file mode 100644 index 0000000000000..f0254f4488b48 --- /dev/null +++ b/activejob/lib/active_job/serializers/array_serializer.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module ActiveJob + module Serializers + # Provides methods to serialize and deserialize `Array` + class ArraySerializer < BaseSerializer + class << self + alias_method :deserialize?, :serialize? + + def serialize(array) + array.map { |arg| ::ActiveJob::Serializers.serialize(arg) } + end + + def deserialize(array) + array.map { |arg| ::ActiveJob::Serializers.deserialize(arg) } + end + + private + + def klass + ::Array + end + end + end + end +end diff --git a/activejob/lib/active_job/serializers/base_serializer.rb b/activejob/lib/active_job/serializers/base_serializer.rb new file mode 100644 index 0000000000000..98f7852fd6c6d --- /dev/null +++ b/activejob/lib/active_job/serializers/base_serializer.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module ActiveJob + module Serializers + class BaseSerializer + class << self + def serialize?(argument) + argument.is_a?(klass) + end + end + end + end +end diff --git a/activejob/lib/active_job/serializers/class_serializer.rb b/activejob/lib/active_job/serializers/class_serializer.rb new file mode 100644 index 0000000000000..d36e8c0ebc514 --- /dev/null +++ b/activejob/lib/active_job/serializers/class_serializer.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module ActiveJob + module Serializers + # Provides methods to serialize and deserialize `Class` (`ActiveRecord::Base`, `MySpecialService`, ...) + class ClassSerializer < ObjectSerializer + class << self + def serialize(argument_klass) + { key => "::#{argument_klass.name}" } + end + + def key + "_aj_class" + end + + private + + def klass + ::Class + end + end + end + end +end diff --git a/activejob/lib/active_job/serializers/duration_serializer.rb b/activejob/lib/active_job/serializers/duration_serializer.rb new file mode 100644 index 0000000000000..72b7b9528aac6 --- /dev/null +++ b/activejob/lib/active_job/serializers/duration_serializer.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module ActiveJob + module Serializers + # Provides methods to serialize and deserialize `ActiveSupport::Duration` (`1.day`, `2.weeks`, ...) + class DurationSerializer < ObjectSerializer + class << self + def serialize(duration) + { + key => duration.value, + parts_key => ::ActiveJob::Serializers.serialize(duration.parts) + } + end + + def deserialize(hash) + value = hash[key] + parts = ::ActiveJob::Serializers.deserialize(hash[parts_key]) + + klass.new(value, parts) + end + + def key + "_aj_activesupport_duration" + end + + private + + def klass + ::ActiveSupport::Duration + end + + def keys + super.push parts_key + end + + def parts_key + "parts" + end + end + end + end +end diff --git a/activejob/lib/active_job/serializers/global_id_serializer.rb b/activejob/lib/active_job/serializers/global_id_serializer.rb new file mode 100644 index 0000000000000..1961e43fca387 --- /dev/null +++ b/activejob/lib/active_job/serializers/global_id_serializer.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module ActiveJob + module Serializers + # Provides methods to serialize and deserialize objects which mixes `GlobalID::Identification`, + # including `ActiveRecord::Base` models + class GlobalIDSerializer < ObjectSerializer + class << self + def serialize(object) + { key => object.to_global_id.to_s } + rescue URI::GID::MissingModelIdError + raise SerializationError, "Unable to serialize #{object.class} " \ + "without an id. (Maybe you forgot to call save?)" + end + + def deserialize(hash) + GlobalID::Locator.locate(hash[key]) + end + + def key + "_aj_globalid" + end + + private + + def klass + ::GlobalID::Identification + end + end + end + end +end diff --git a/activejob/lib/active_job/serializers/hash_serializer.rb b/activejob/lib/active_job/serializers/hash_serializer.rb new file mode 100644 index 0000000000000..eee081de7c58a --- /dev/null +++ b/activejob/lib/active_job/serializers/hash_serializer.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module ActiveJob + module Serializers + # Provides methods to serialize and deserialize `Hash` (`{key: field, ...}`) + # Only `String` or `Symbol` can be used as a key. Values will be serialized by known serializers + class HashSerializer < BaseSerializer + class << self + def serialize(hash) + symbol_keys = hash.each_key.grep(Symbol).map(&:to_s) + result = serialize_hash(hash) + result[key] = symbol_keys + result + end + + def deserialize?(argument) + argument.is_a?(Hash) && argument[key] + end + + def deserialize(hash) + result = hash.transform_values { |v| ::ActiveJob::Serializers::deserialize(v) } + symbol_keys = result.delete(key) + transform_symbol_keys(result, symbol_keys) + end + + def key + "_aj_symbol_keys" + end + + private + + def serialize_hash(hash) + hash.each_with_object({}) do |(key, value), result| + result[serialize_hash_key(key)] = ::ActiveJob::Serializers.serialize(value) + end + end + + def serialize_hash_key(key) + raise SerializationError.new("Only string and symbol hash keys may be serialized as job arguments, but #{key.inspect} is a #{key.class}") unless [String, Symbol].include?(key.class) + + raise SerializationError.new("Can't serialize a Hash with reserved key #{key.inspect}") if ActiveJob::Base.reserved_serializers_keys.include?(key.to_s) + + key.to_s + end + + def transform_symbol_keys(hash, symbol_keys) + hash.transform_keys do |key| + if symbol_keys.include?(key) + key.to_sym + else + key + end + end + end + + def klass + ::Hash + end + end + end + end +end diff --git a/activejob/lib/active_job/serializers/hash_with_indifferent_access_serializer.rb b/activejob/lib/active_job/serializers/hash_with_indifferent_access_serializer.rb new file mode 100644 index 0000000000000..50e80757cd904 --- /dev/null +++ b/activejob/lib/active_job/serializers/hash_with_indifferent_access_serializer.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module ActiveJob + module Serializers + # Provides methods to serialize and deserialize `ActiveSupport::HashWithIndifferentAccess` + # Values will be serialized by known serializers + class HashWithIndifferentAccessSerializer < HashSerializer + class << self + def serialize(hash) + result = serialize_hash(hash) + result[key] = ::ActiveJob::Serializers.serialize(true) + result + end + + def deserialize?(argument) + argument.is_a?(Hash) && argument[key] + end + + def deserialize(hash) + result = hash.transform_values { |v| ::ActiveJob::Serializers.deserialize(v) } + result.delete(key) + result.with_indifferent_access + end + + def key + "_aj_hash_with_indifferent_access" + end + + private + + def klass + ::ActiveSupport::HashWithIndifferentAccess + end + end + end + end +end diff --git a/activejob/lib/active_job/serializers/object_serializer.rb b/activejob/lib/active_job/serializers/object_serializer.rb new file mode 100644 index 0000000000000..075360b26e901 --- /dev/null +++ b/activejob/lib/active_job/serializers/object_serializer.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module ActiveJob + module Serializers + class ObjectSerializer < BaseSerializer + class << self + def serialize(object) + { key => object.class.name } + end + + def deserialize?(argument) + argument.respond_to?(:keys) && argument.keys == keys + end + + def deserialize(hash) + hash[key].constantize + end + + private + + def keys + [key] + end + end + end + end +end diff --git a/activejob/lib/active_job/serializers/standard_type_serializer.rb b/activejob/lib/active_job/serializers/standard_type_serializer.rb new file mode 100644 index 0000000000000..8969b31d6b87d --- /dev/null +++ b/activejob/lib/active_job/serializers/standard_type_serializer.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module ActiveJob + module Serializers + # Provides methods to serialize and deserialize standard types + # (`NilClass`, `String`, `Integer`, `Fixnum`, `Bignum`, `Float`, `BigDecimal`, `TrueClass`, `FalseClass`) + class StandardTypeSerializer < BaseSerializer + class << self + def serialize?(argument) + ::ActiveJob::Arguments::TYPE_WHITELIST.include? argument.class + end + + def serialize(argument) + argument + end + + alias_method :deserialize?, :serialize? + + def deserialize(argument) + object = GlobalID::Locator.locate(argument) if argument.is_a? String + object || argument + end + end + end + end +end diff --git a/activejob/lib/active_job/serializers/struct_serializer.rb b/activejob/lib/active_job/serializers/struct_serializer.rb new file mode 100644 index 0000000000000..f6791611ed68b --- /dev/null +++ b/activejob/lib/active_job/serializers/struct_serializer.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module ActiveJob + module Serializers + # Provides methods to serialize and deserialize struct instances + # (`Struct.new('Rectangle', :width, :height).new(12, 20)`) + class StructSerializer < ObjectSerializer + class << self + def serialize(object) + super.merge values_key => ::ActiveJob::Serializers.serialize(object.values) + end + + def deserialize(hash) + values = ::ActiveJob::Serializers.deserialize(hash[values_key]) + super.new(*values) + end + + def key + "_aj_struct" + end + + private + + def klass + ::Struct + end + + def keys + super.push values_key + end + + def values_key + "values" + end + end + end + end +end diff --git a/activejob/lib/active_job/serializers/symbol_serializer.rb b/activejob/lib/active_job/serializers/symbol_serializer.rb new file mode 100644 index 0000000000000..f128ae82846e7 --- /dev/null +++ b/activejob/lib/active_job/serializers/symbol_serializer.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module ActiveJob + module Serializers + # Provides methods to serialize and deserialize `Symbol` (`:foo`, `:bar`, ...) + class SymbolSerializer < ObjectSerializer + class << self + def serialize(symbol) + { key => symbol.to_s } + end + + def deserialize(hash) + hash[key].to_sym + end + + def key + "_aj_symbol" + end + + private + + def klass + ::Symbol + end + end + end + end +end diff --git a/activejob/lib/rails/generators/job/job_generator.rb b/activejob/lib/rails/generators/job/job_generator.rb index 69b4fe7d26e4f..c940cd154cea3 100644 --- a/activejob/lib/rails/generators/job/job_generator.rb +++ b/activejob/lib/rails/generators/job/job_generator.rb @@ -30,7 +30,7 @@ def create_job_file private def application_job_file_name @application_job_file_name ||= if mountable_engine? - "app/jobs/#{namespaced_path}/application_job.rb" + "app/jobs/#{namespaced_path}/application_job.rb" else "app/jobs/application_job.rb" end diff --git a/activejob/test/cases/argument_serialization_test.rb b/activejob/test/cases/argument_serialization_test.rb index 7e7f854da0bd6..20296038a00cf 100644 --- a/activejob/test/cases/argument_serialization_test.rb +++ b/activejob/test/cases/argument_serialization_test.rb @@ -4,6 +4,7 @@ require "active_job/arguments" require "models/person" require "active_support/core_ext/hash/indifferent_access" +require "active_support/duration" require "jobs/kwargs_job" class ArgumentSerializationTest < ActiveSupport::TestCase @@ -12,7 +13,8 @@ class ArgumentSerializationTest < ActiveSupport::TestCase end [ nil, 1, 1.0, 1_000_000_000_000_000_000_000, - "a", true, false, BigDecimal(5), + "a", true, false, BigDecimal.new(5), + :a, self, 1.day, [ 1, "a" ], { "a" => 1 } ].each do |arg| @@ -21,7 +23,7 @@ class ArgumentSerializationTest < ActiveSupport::TestCase end end - [ :a, Object.new, self, Person.find("5").to_gid ].each do |arg| + [ Object.new, Person.find("5").to_gid ].each do |arg| test "does not serialize #{arg.class}" do assert_raises ActiveJob::SerializationError do ActiveJob::Arguments.serialize [ arg ] @@ -33,6 +35,10 @@ class ArgumentSerializationTest < ActiveSupport::TestCase end end + test "serializes Struct" do + assert_arguments_unchanged Struct.new("Rectangle", :width, :height).new(10, 15) + end + test "should convert records to Global IDs" do assert_arguments_roundtrip [@person] end diff --git a/guides/source/active_job_basics.md b/guides/source/active_job_basics.md index 914ef2c327ee5..a7067cb97d383 100644 --- a/guides/source/active_job_basics.md +++ b/guides/source/active_job_basics.md @@ -339,8 +339,21 @@ UserMailer.welcome(@user).deliver_later # Email will be localized to Esperanto. ``` -GlobalID --------- +Supported types for arguments +---------------------------- + +ActiveJob supports the following types of arguments by default: + + - Basic types (`NilClass`, `String`, `Integer`, `Fixnum`, `Bignum`, `Float`, `BigDecimal`, `TrueClass`, `FalseClass`) + - `Symbol` (`:foo`, `:bar`, ...) + - `ActiveSupport::Duration` (`1.day`, `2.weeks`, ...) + - Classes constants (`ActiveRecord::Base`, `MySpecialService`, ...) + - Struct instances (`Struct.new('Rectangle', :width, :height).new(12, 20)`, ...) + - `Hash`. Keys should be of `String` or `Symbol` type + - `ActiveSupport::HashWithIndifferentAccess` + - `Array` + +### GlobalID Active Job supports GlobalID for parameters. This makes it possible to pass live Active Record objects to your job instead of class/id pairs, which you then have @@ -368,6 +381,54 @@ end This works with any class that mixes in `GlobalID::Identification`, which by default has been mixed into Active Record classes. +### Serializers + +You can extend list of supported types for arguments. You just need to define your own serializer. + +```ruby +class MySpecialSerializer + class << self + # Check if this object should be serialized using this serializer + def serialize?(argument) + argument.is_a? MySpecialValueObject + end + + # Convert an object to a simpler representative using supported object types. + # The recommended representative is a Hash with a specific key. Keys can be of basic types only + def serialize(object) + { + key => ActiveJob::Serializers.serialize(object.value) + 'another_attribute' => ActiveJob::Serializers.serialize(object.another_attribute) + } + end + + # Check if this serialized value be deserialized using this serializer + def deserialize?(argument) + argument.is_a?(Hash) && argument.keys == [key, 'another_attribute'] + end + + # Convert serialized value into a proper object + def deserialize(object) + value = ActiveJob::Serializers.deserialize(object[key]) + another_attribute = ActiveJob::Serializers.deserialize(object['another_attribute']) + MySpecialValueObject.new value, another_attribute + end + + # Define this method if you are using a hash as a representative. + # This key will be added to a list of restricted keys for hashes. Use basic types only + def key + "_aj_custom_dummy_value_object" + end + end +end +``` + +And now you just need to add this serializer to a list: + +```ruby +ActiveJob::Base.add_serializers(MySpecialSerializer) +``` + Exceptions ---------- From 3785a5729959a838bb13f2d298a59e12e1844f74 Mon Sep 17 00:00:00 2001 From: Evgenii Pecherkin Date: Mon, 23 Oct 2017 17:29:28 +0400 Subject: [PATCH 02/15] Remove non-default serializers --- activejob/README.md | 4 -- activejob/lib/active_job/serializers.rb | 21 ++---- .../serializers/class_serializer.rb | 24 ------- .../serializers/duration_serializer.rb | 42 ------------ .../serializers/struct_serializer.rb | 38 ----------- .../serializers/symbol_serializer.rb | 28 -------- .../lib/rails/generators/job/job_generator.rb | 2 +- .../test/cases/argument_serialization_test.rb | 8 +-- activejob/test/cases/serializers_test.rb | 64 +++++++++++++++++++ guides/source/active_job_basics.md | 4 -- 10 files changed, 72 insertions(+), 163 deletions(-) delete mode 100644 activejob/lib/active_job/serializers/class_serializer.rb delete mode 100644 activejob/lib/active_job/serializers/duration_serializer.rb delete mode 100644 activejob/lib/active_job/serializers/struct_serializer.rb delete mode 100644 activejob/lib/active_job/serializers/symbol_serializer.rb create mode 100644 activejob/test/cases/serializers_test.rb diff --git a/activejob/README.md b/activejob/README.md index 152f924525228..56562d870bb4b 100644 --- a/activejob/README.md +++ b/activejob/README.md @@ -57,10 +57,6 @@ That's it! ActiveJob supports the following types of arguments by default: - Standard types (`NilClass`, `String`, `Integer`, `Fixnum`, `Bignum`, `Float`, `BigDecimal`, `TrueClass`, `FalseClass`) - - `Symbol` (`:foo`, `:bar`, ...) - - `ActiveSupport::Duration` (`1.day`, `2.weeks`, ...) - - Classes constants (`ActiveRecord::Base`, `MySpecialService`, ...) - - Struct instances (`Struct.new('Rectangle', :width, :height).new(12, 20)`, ...) - `Hash`. Keys should be of `String` or `Symbol` type - `ActiveSupport::HashWithIndifferentAccess` - `Array` diff --git a/activejob/lib/active_job/serializers.rb b/activejob/lib/active_job/serializers.rb index ec8606514931b..68ed94896ea42 100644 --- a/activejob/lib/active_job/serializers.rb +++ b/activejob/lib/active_job/serializers.rb @@ -27,15 +27,11 @@ module Serializers autoload :ArraySerializer autoload :BaseSerializer - autoload :ClassSerializer - autoload :DurationSerializer autoload :GlobalIDSerializer autoload :HashWithIndifferentAccessSerializer autoload :HashSerializer autoload :ObjectSerializer autoload :StandardTypeSerializer - autoload :StructSerializer - autoload :SymbolSerializer included do class_attribute :_additional_serializers, instance_accessor: false, instance_predicate: false @@ -46,14 +42,14 @@ module Serializers module ClassMethods # Returns list of known serializers def serializers - self._additional_serializers + SERIALIZERS + self._additional_serializers + ActiveJob::Serializers::SERIALIZERS end # Adds a new serializer to a list of known serializers - def add_serializers(*serializers) - check_duplicate_serializer_keys!(serializers) + def add_serializers(*new_serializers) + check_duplicate_serializer_keys!(new_serializers) - @_additional_serializers = serializers + @_additional_serializers + self._additional_serializers = new_serializers + self._additional_serializers end # Returns a list of reserved keys, which cannot be used as keys for a hash @@ -66,7 +62,7 @@ def reserved_serializers_keys def check_duplicate_serializer_keys!(serializers) keys_to_add = serializers.select { |s| s.respond_to?(:key) }.map(&:key) - duplicate_keys = reserved_keys & keys_to_add + duplicate_keys = reserved_serializers_keys & keys_to_add raise ArgumentError.new("Can't add serializers because of keys duplication: #{duplicate_keys}") if duplicate_keys.any? end @@ -75,21 +71,16 @@ def check_duplicate_serializer_keys!(serializers) # :nodoc: SERIALIZERS = [ ::ActiveJob::Serializers::GlobalIDSerializer, - ::ActiveJob::Serializers::DurationSerializer, - ::ActiveJob::Serializers::StructSerializer, - ::ActiveJob::Serializers::SymbolSerializer, - ::ActiveJob::Serializers::ClassSerializer, ::ActiveJob::Serializers::StandardTypeSerializer, ::ActiveJob::Serializers::HashWithIndifferentAccessSerializer, ::ActiveJob::Serializers::HashSerializer, ::ActiveJob::Serializers::ArraySerializer ].freeze - private_constant :SERIALIZERS class << self # Returns serialized representative of the passed object. # Will look up through all known serializers. - # Raises `SerializationError` if it can't find a proper serializer. + # Raises `ActiveJob::SerializationError` if it can't find a proper serializer. def serialize(argument) serializer = ::ActiveJob::Base.serializers.detect { |s| s.serialize?(argument) } raise SerializationError.new("Unsupported argument type: #{argument.class.name}") unless serializer diff --git a/activejob/lib/active_job/serializers/class_serializer.rb b/activejob/lib/active_job/serializers/class_serializer.rb deleted file mode 100644 index d36e8c0ebc514..0000000000000 --- a/activejob/lib/active_job/serializers/class_serializer.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -module ActiveJob - module Serializers - # Provides methods to serialize and deserialize `Class` (`ActiveRecord::Base`, `MySpecialService`, ...) - class ClassSerializer < ObjectSerializer - class << self - def serialize(argument_klass) - { key => "::#{argument_klass.name}" } - end - - def key - "_aj_class" - end - - private - - def klass - ::Class - end - end - end - end -end diff --git a/activejob/lib/active_job/serializers/duration_serializer.rb b/activejob/lib/active_job/serializers/duration_serializer.rb deleted file mode 100644 index 72b7b9528aac6..0000000000000 --- a/activejob/lib/active_job/serializers/duration_serializer.rb +++ /dev/null @@ -1,42 +0,0 @@ -# frozen_string_literal: true - -module ActiveJob - module Serializers - # Provides methods to serialize and deserialize `ActiveSupport::Duration` (`1.day`, `2.weeks`, ...) - class DurationSerializer < ObjectSerializer - class << self - def serialize(duration) - { - key => duration.value, - parts_key => ::ActiveJob::Serializers.serialize(duration.parts) - } - end - - def deserialize(hash) - value = hash[key] - parts = ::ActiveJob::Serializers.deserialize(hash[parts_key]) - - klass.new(value, parts) - end - - def key - "_aj_activesupport_duration" - end - - private - - def klass - ::ActiveSupport::Duration - end - - def keys - super.push parts_key - end - - def parts_key - "parts" - end - end - end - end -end diff --git a/activejob/lib/active_job/serializers/struct_serializer.rb b/activejob/lib/active_job/serializers/struct_serializer.rb deleted file mode 100644 index f6791611ed68b..0000000000000 --- a/activejob/lib/active_job/serializers/struct_serializer.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -module ActiveJob - module Serializers - # Provides methods to serialize and deserialize struct instances - # (`Struct.new('Rectangle', :width, :height).new(12, 20)`) - class StructSerializer < ObjectSerializer - class << self - def serialize(object) - super.merge values_key => ::ActiveJob::Serializers.serialize(object.values) - end - - def deserialize(hash) - values = ::ActiveJob::Serializers.deserialize(hash[values_key]) - super.new(*values) - end - - def key - "_aj_struct" - end - - private - - def klass - ::Struct - end - - def keys - super.push values_key - end - - def values_key - "values" - end - end - end - end -end diff --git a/activejob/lib/active_job/serializers/symbol_serializer.rb b/activejob/lib/active_job/serializers/symbol_serializer.rb deleted file mode 100644 index f128ae82846e7..0000000000000 --- a/activejob/lib/active_job/serializers/symbol_serializer.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -module ActiveJob - module Serializers - # Provides methods to serialize and deserialize `Symbol` (`:foo`, `:bar`, ...) - class SymbolSerializer < ObjectSerializer - class << self - def serialize(symbol) - { key => symbol.to_s } - end - - def deserialize(hash) - hash[key].to_sym - end - - def key - "_aj_symbol" - end - - private - - def klass - ::Symbol - end - end - end - end -end diff --git a/activejob/lib/rails/generators/job/job_generator.rb b/activejob/lib/rails/generators/job/job_generator.rb index c940cd154cea3..69b4fe7d26e4f 100644 --- a/activejob/lib/rails/generators/job/job_generator.rb +++ b/activejob/lib/rails/generators/job/job_generator.rb @@ -30,7 +30,7 @@ def create_job_file private def application_job_file_name @application_job_file_name ||= if mountable_engine? - "app/jobs/#{namespaced_path}/application_job.rb" + "app/jobs/#{namespaced_path}/application_job.rb" else "app/jobs/application_job.rb" end diff --git a/activejob/test/cases/argument_serialization_test.rb b/activejob/test/cases/argument_serialization_test.rb index 20296038a00cf..13e6fcb727f2f 100644 --- a/activejob/test/cases/argument_serialization_test.rb +++ b/activejob/test/cases/argument_serialization_test.rb @@ -4,7 +4,6 @@ require "active_job/arguments" require "models/person" require "active_support/core_ext/hash/indifferent_access" -require "active_support/duration" require "jobs/kwargs_job" class ArgumentSerializationTest < ActiveSupport::TestCase @@ -14,7 +13,6 @@ class ArgumentSerializationTest < ActiveSupport::TestCase [ nil, 1, 1.0, 1_000_000_000_000_000_000_000, "a", true, false, BigDecimal.new(5), - :a, self, 1.day, [ 1, "a" ], { "a" => 1 } ].each do |arg| @@ -23,7 +21,7 @@ class ArgumentSerializationTest < ActiveSupport::TestCase end end - [ Object.new, Person.find("5").to_gid ].each do |arg| + [ :a, Object.new, self, Person.find("5").to_gid ].each do |arg| test "does not serialize #{arg.class}" do assert_raises ActiveJob::SerializationError do ActiveJob::Arguments.serialize [ arg ] @@ -35,10 +33,6 @@ class ArgumentSerializationTest < ActiveSupport::TestCase end end - test "serializes Struct" do - assert_arguments_unchanged Struct.new("Rectangle", :width, :height).new(10, 15) - end - test "should convert records to Global IDs" do assert_arguments_roundtrip [@person] end diff --git a/activejob/test/cases/serializers_test.rb b/activejob/test/cases/serializers_test.rb new file mode 100644 index 0000000000000..90d4155b3bfe0 --- /dev/null +++ b/activejob/test/cases/serializers_test.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require "helper" +require "active_job/serializers" + +class SerializersTest < ActiveSupport::TestCase + class DummyValueObject + attr_accessor :value + + def initialize(value) + @value = value + end + end + + class DummySerializer < ActiveJob::Serializers::ObjectSerializer + class << self + def serialize(object) + { key => object.value } + end + + def deserialize(hash) + DummyValueObject.new(hash[key]) + end + + def key + "_dummy_serializer" + end + + private + + def klass + DummyValueObject + end + end + end + + setup do + @value_object = DummyValueObject.new 123 + ActiveJob::Base._additional_serializers = [] + end + + test "can't serialize unknown object" do + assert_raises ActiveJob::SerializationError do + ActiveJob::Serializers.serialize @value_object + end + end + + test "won't deserialize unknown hash" do + hash = { "_dummy_serializer" => 123, "_aj_symbol_keys" => [] } + assert ActiveJob::Serializers.deserialize(hash), hash.except("_aj_symbol_keys") + end + + test "adds new serializer" do + ActiveJob::Base.add_serializers DummySerializer + assert ActiveJob::Base.serializers.include?(DummySerializer) + end + + test "can't add serializer with the same key twice" do + ActiveJob::Base.add_serializers DummySerializer + assert_raises ArgumentError do + ActiveJob::Base.add_serializers DummySerializer + end + end +end diff --git a/guides/source/active_job_basics.md b/guides/source/active_job_basics.md index a7067cb97d383..eea64f9367dfa 100644 --- a/guides/source/active_job_basics.md +++ b/guides/source/active_job_basics.md @@ -345,10 +345,6 @@ Supported types for arguments ActiveJob supports the following types of arguments by default: - Basic types (`NilClass`, `String`, `Integer`, `Fixnum`, `Bignum`, `Float`, `BigDecimal`, `TrueClass`, `FalseClass`) - - `Symbol` (`:foo`, `:bar`, ...) - - `ActiveSupport::Duration` (`1.day`, `2.weeks`, ...) - - Classes constants (`ActiveRecord::Base`, `MySpecialService`, ...) - - Struct instances (`Struct.new('Rectangle', :width, :height).new(12, 20)`, ...) - `Hash`. Keys should be of `String` or `Symbol` type - `ActiveSupport::HashWithIndifferentAccess` - `Array` From ec686a471e0a54194fc9ec72e639785606597704 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Fri, 9 Feb 2018 14:24:55 -0500 Subject: [PATCH 03/15] Simplify the implementation of custom serialziers Right now it is only possible to define serializers globally so we don't need to use a class attribute in the job class. --- activejob/lib/active_job/serializers.rb | 62 ++++++++----------- .../active_job/serializers/hash_serializer.rb | 2 +- activejob/test/cases/serializers_test.rb | 14 +++-- 3 files changed, 37 insertions(+), 41 deletions(-) diff --git a/activejob/lib/active_job/serializers.rb b/activejob/lib/active_job/serializers.rb index 68ed94896ea42..41113c521c8fb 100644 --- a/activejob/lib/active_job/serializers.rb +++ b/activejob/lib/active_job/serializers.rb @@ -33,16 +33,31 @@ module Serializers autoload :ObjectSerializer autoload :StandardTypeSerializer - included do - class_attribute :_additional_serializers, instance_accessor: false, instance_predicate: false - self._additional_serializers = [] - end + mattr_accessor :_additional_serializers + self._additional_serializers = [] + + class << self + # Returns serialized representative of the passed object. + # Will look up through all known serializers. + # Raises `ActiveJob::SerializationError` if it can't find a proper serializer. + def serialize(argument) + serializer = serializers.detect { |s| s.serialize?(argument) } + raise SerializationError.new("Unsupported argument type: #{argument.class.name}") unless serializer + serializer.serialize(argument) + end + + # Returns deserialized object. + # Will look up through all known serializers. + # If no serializers found will raise `ArgumentError` + def deserialize(argument) + serializer = serializers.detect { |s| s.deserialize?(argument) } + raise ArgumentError, "Can only deserialize primitive arguments: #{argument.inspect}" unless serializer + serializer.deserialize(argument) + end - # Includes the method to list known serializers and to add new ones - module ClassMethods # Returns list of known serializers def serializers - self._additional_serializers + ActiveJob::Serializers::SERIALIZERS + self._additional_serializers end # Adds a new serializer to a list of known serializers @@ -68,33 +83,10 @@ def check_duplicate_serializer_keys!(serializers) end end - # :nodoc: - SERIALIZERS = [ - ::ActiveJob::Serializers::GlobalIDSerializer, - ::ActiveJob::Serializers::StandardTypeSerializer, - ::ActiveJob::Serializers::HashWithIndifferentAccessSerializer, - ::ActiveJob::Serializers::HashSerializer, - ::ActiveJob::Serializers::ArraySerializer - ].freeze - - class << self - # Returns serialized representative of the passed object. - # Will look up through all known serializers. - # Raises `ActiveJob::SerializationError` if it can't find a proper serializer. - def serialize(argument) - serializer = ::ActiveJob::Base.serializers.detect { |s| s.serialize?(argument) } - raise SerializationError.new("Unsupported argument type: #{argument.class.name}") unless serializer - serializer.serialize(argument) - end - - # Returns deserialized object. - # Will look up through all known serializers. - # If no serializers found will raise `ArgumentError` - def deserialize(argument) - serializer = ::ActiveJob::Base.serializers.detect { |s| s.deserialize?(argument) } - raise ArgumentError, "Can only deserialize primitive arguments: #{argument.inspect}" unless serializer - serializer.deserialize(argument) - end - end + add_serializers GlobalIDSerializer, + StandardTypeSerializer, + HashWithIndifferentAccessSerializer, + HashSerializer, + ArraySerializer end end diff --git a/activejob/lib/active_job/serializers/hash_serializer.rb b/activejob/lib/active_job/serializers/hash_serializer.rb index eee081de7c58a..c4dcfaf094745 100644 --- a/activejob/lib/active_job/serializers/hash_serializer.rb +++ b/activejob/lib/active_job/serializers/hash_serializer.rb @@ -38,7 +38,7 @@ def serialize_hash(hash) def serialize_hash_key(key) raise SerializationError.new("Only string and symbol hash keys may be serialized as job arguments, but #{key.inspect} is a #{key.class}") unless [String, Symbol].include?(key.class) - raise SerializationError.new("Can't serialize a Hash with reserved key #{key.inspect}") if ActiveJob::Base.reserved_serializers_keys.include?(key.to_s) + raise SerializationError.new("Can't serialize a Hash with reserved key #{key.inspect}") if ActiveJob::Serializers.reserved_serializers_keys.include?(key.to_s) key.to_s end diff --git a/activejob/test/cases/serializers_test.rb b/activejob/test/cases/serializers_test.rb index 90d4155b3bfe0..3b526c932bc57 100644 --- a/activejob/test/cases/serializers_test.rb +++ b/activejob/test/cases/serializers_test.rb @@ -36,7 +36,11 @@ def klass setup do @value_object = DummyValueObject.new 123 - ActiveJob::Base._additional_serializers = [] + @original_serializers = ActiveJob::Serializers.serializers + end + + teardown do + ActiveJob::Serializers._additional_serializers = @original_serializers end test "can't serialize unknown object" do @@ -51,14 +55,14 @@ def klass end test "adds new serializer" do - ActiveJob::Base.add_serializers DummySerializer - assert ActiveJob::Base.serializers.include?(DummySerializer) + ActiveJob::Serializers.add_serializers DummySerializer + assert ActiveJob::Serializers.serializers.include?(DummySerializer) end test "can't add serializer with the same key twice" do - ActiveJob::Base.add_serializers DummySerializer + ActiveJob::Serializers.add_serializers DummySerializer assert_raises ArgumentError do - ActiveJob::Base.add_serializers DummySerializer + ActiveJob::Serializers.add_serializers DummySerializer end end end From 803f4385c6c30217e3d2cf81cbaba92c7bc58476 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Fri, 9 Feb 2018 14:31:00 -0500 Subject: [PATCH 04/15] Remove unnecessary qualified constant lookups --- activejob/lib/active_job/serializers/array_serializer.rb | 6 +++--- .../lib/active_job/serializers/global_id_serializer.rb | 2 +- activejob/lib/active_job/serializers/hash_serializer.rb | 6 +++--- .../serializers/hash_with_indifferent_access_serializer.rb | 6 +++--- .../lib/active_job/serializers/standard_type_serializer.rb | 2 +- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/activejob/lib/active_job/serializers/array_serializer.rb b/activejob/lib/active_job/serializers/array_serializer.rb index f0254f4488b48..1b3c3b2ce3de9 100644 --- a/activejob/lib/active_job/serializers/array_serializer.rb +++ b/activejob/lib/active_job/serializers/array_serializer.rb @@ -8,17 +8,17 @@ class << self alias_method :deserialize?, :serialize? def serialize(array) - array.map { |arg| ::ActiveJob::Serializers.serialize(arg) } + array.map { |arg| Serializers.serialize(arg) } end def deserialize(array) - array.map { |arg| ::ActiveJob::Serializers.deserialize(arg) } + array.map { |arg| Serializers.deserialize(arg) } end private def klass - ::Array + Array end end end diff --git a/activejob/lib/active_job/serializers/global_id_serializer.rb b/activejob/lib/active_job/serializers/global_id_serializer.rb index 1961e43fca387..ec20cf04f7b3b 100644 --- a/activejob/lib/active_job/serializers/global_id_serializer.rb +++ b/activejob/lib/active_job/serializers/global_id_serializer.rb @@ -24,7 +24,7 @@ def key private def klass - ::GlobalID::Identification + GlobalID::Identification end end end diff --git a/activejob/lib/active_job/serializers/hash_serializer.rb b/activejob/lib/active_job/serializers/hash_serializer.rb index c4dcfaf094745..ca39a81ae9196 100644 --- a/activejob/lib/active_job/serializers/hash_serializer.rb +++ b/activejob/lib/active_job/serializers/hash_serializer.rb @@ -18,7 +18,7 @@ def deserialize?(argument) end def deserialize(hash) - result = hash.transform_values { |v| ::ActiveJob::Serializers::deserialize(v) } + result = hash.transform_values { |v| Serializers::deserialize(v) } symbol_keys = result.delete(key) transform_symbol_keys(result, symbol_keys) end @@ -31,7 +31,7 @@ def key def serialize_hash(hash) hash.each_with_object({}) do |(key, value), result| - result[serialize_hash_key(key)] = ::ActiveJob::Serializers.serialize(value) + result[serialize_hash_key(key)] = Serializers.serialize(value) end end @@ -54,7 +54,7 @@ def transform_symbol_keys(hash, symbol_keys) end def klass - ::Hash + Hash end end end diff --git a/activejob/lib/active_job/serializers/hash_with_indifferent_access_serializer.rb b/activejob/lib/active_job/serializers/hash_with_indifferent_access_serializer.rb index 50e80757cd904..b0fb29d58bb12 100644 --- a/activejob/lib/active_job/serializers/hash_with_indifferent_access_serializer.rb +++ b/activejob/lib/active_job/serializers/hash_with_indifferent_access_serializer.rb @@ -8,7 +8,7 @@ class HashWithIndifferentAccessSerializer < HashSerializer class << self def serialize(hash) result = serialize_hash(hash) - result[key] = ::ActiveJob::Serializers.serialize(true) + result[key] = Serializers.serialize(true) result end @@ -17,7 +17,7 @@ def deserialize?(argument) end def deserialize(hash) - result = hash.transform_values { |v| ::ActiveJob::Serializers.deserialize(v) } + result = hash.transform_values { |v| Serializers.deserialize(v) } result.delete(key) result.with_indifferent_access end @@ -29,7 +29,7 @@ def key private def klass - ::ActiveSupport::HashWithIndifferentAccess + ActiveSupport::HashWithIndifferentAccess end end end diff --git a/activejob/lib/active_job/serializers/standard_type_serializer.rb b/activejob/lib/active_job/serializers/standard_type_serializer.rb index 8969b31d6b87d..efc02adcf01c3 100644 --- a/activejob/lib/active_job/serializers/standard_type_serializer.rb +++ b/activejob/lib/active_job/serializers/standard_type_serializer.rb @@ -7,7 +7,7 @@ module Serializers class StandardTypeSerializer < BaseSerializer class << self def serialize?(argument) - ::ActiveJob::Arguments::TYPE_WHITELIST.include? argument.class + Arguments::TYPE_WHITELIST.include? argument.class end def serialize(argument) From 9bc8b4bbde4634e0e4bddcffa25e0bf8d74d19cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Fri, 9 Feb 2018 14:34:29 -0500 Subject: [PATCH 05/15] Define the interface of a Serializer --- .../active_job/serializers/base_serializer.rb | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/activejob/lib/active_job/serializers/base_serializer.rb b/activejob/lib/active_job/serializers/base_serializer.rb index 98f7852fd6c6d..8b891cca48ae6 100644 --- a/activejob/lib/active_job/serializers/base_serializer.rb +++ b/activejob/lib/active_job/serializers/base_serializer.rb @@ -7,6 +7,24 @@ class << self def serialize?(argument) argument.is_a?(klass) end + + def deserialize?(_argument) + raise NotImplementedError + end + + def serialize(_argument) + raise NotImplementedError + end + + def deserialize(_argument) + raise NotImplementedError + end + + private + + def klass + raise NotImplementedError + end end end end From ea615332452e6860872020aa161c5d34e81f1eea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Fri, 9 Feb 2018 15:23:05 -0500 Subject: [PATCH 06/15] Only add one more custom key in the serialized hash Now custom serialziers can register itself in the serialized hash using the "_aj_serialized" key that constains the serializer name. This way we can avoid poluting the hash with many reserved keys. --- activejob/lib/active_job/serializers.rb | 37 +++++++++++-------- .../serializers/global_id_serializer.rb | 10 ++--- .../active_job/serializers/hash_serializer.rb | 6 +-- ...hash_with_indifferent_access_serializer.rb | 10 ++--- .../serializers/object_serializer.rb | 16 ++------ activejob/test/cases/serializers_test.rb | 31 ++++++++++++---- 6 files changed, 59 insertions(+), 51 deletions(-) diff --git a/activejob/lib/active_job/serializers.rb b/activejob/lib/active_job/serializers.rb index 41113c521c8fb..12458ea572415 100644 --- a/activejob/lib/active_job/serializers.rb +++ b/activejob/lib/active_job/serializers.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "set" + module ActiveJob # Raised when an exception is raised during job arguments deserialization. # @@ -34,7 +36,7 @@ module Serializers autoload :StandardTypeSerializer mattr_accessor :_additional_serializers - self._additional_serializers = [] + self._additional_serializers = Set.new class << self # Returns serialized representative of the passed object. @@ -62,27 +64,32 @@ def serializers # Adds a new serializer to a list of known serializers def add_serializers(*new_serializers) - check_duplicate_serializer_keys!(new_serializers) - - self._additional_serializers = new_serializers + self._additional_serializers + self._additional_serializers += new_serializers end # Returns a list of reserved keys, which cannot be used as keys for a hash def reserved_serializers_keys - serializers.select { |s| s.respond_to?(:key) }.map(&:key) + RESERVED_KEYS end - - private - - def check_duplicate_serializer_keys!(serializers) - keys_to_add = serializers.select { |s| s.respond_to?(:key) }.map(&:key) - - duplicate_keys = reserved_serializers_keys & keys_to_add - - raise ArgumentError.new("Can't add serializers because of keys duplication: #{duplicate_keys}") if duplicate_keys.any? - end end + # :nodoc: + GLOBALID_KEY = "_aj_globalid".freeze + # :nodoc: + SYMBOL_KEYS_KEY = "_aj_symbol_keys".freeze + # :nodoc: + WITH_INDIFFERENT_ACCESS_KEY = "_aj_hash_with_indifferent_access".freeze + # :nodoc: + OBJECT_SERIALIZER_KEY = "_aj_serialized" + + # :nodoc: + RESERVED_KEYS = [ + GLOBALID_KEY, GLOBALID_KEY.to_sym, + SYMBOL_KEYS_KEY, SYMBOL_KEYS_KEY.to_sym, + WITH_INDIFFERENT_ACCESS_KEY, WITH_INDIFFERENT_ACCESS_KEY.to_sym, + ] + private_constant :RESERVED_KEYS + add_serializers GlobalIDSerializer, StandardTypeSerializer, HashWithIndifferentAccessSerializer, diff --git a/activejob/lib/active_job/serializers/global_id_serializer.rb b/activejob/lib/active_job/serializers/global_id_serializer.rb index ec20cf04f7b3b..84ed33ef9913f 100644 --- a/activejob/lib/active_job/serializers/global_id_serializer.rb +++ b/activejob/lib/active_job/serializers/global_id_serializer.rb @@ -4,21 +4,21 @@ module ActiveJob module Serializers # Provides methods to serialize and deserialize objects which mixes `GlobalID::Identification`, # including `ActiveRecord::Base` models - class GlobalIDSerializer < ObjectSerializer + class GlobalIDSerializer < BaseSerializer class << self def serialize(object) - { key => object.to_global_id.to_s } + { GLOBALID_KEY => object.to_global_id.to_s } rescue URI::GID::MissingModelIdError raise SerializationError, "Unable to serialize #{object.class} " \ "without an id. (Maybe you forgot to call save?)" end def deserialize(hash) - GlobalID::Locator.locate(hash[key]) + GlobalID::Locator.locate(hash[GLOBALID_KEY]) end - def key - "_aj_globalid" + def deserialize?(argument) + argument.is_a?(Hash) && argument[GLOBALID_KEY] end private diff --git a/activejob/lib/active_job/serializers/hash_serializer.rb b/activejob/lib/active_job/serializers/hash_serializer.rb index ca39a81ae9196..2bbb31946d4b8 100644 --- a/activejob/lib/active_job/serializers/hash_serializer.rb +++ b/activejob/lib/active_job/serializers/hash_serializer.rb @@ -23,12 +23,12 @@ def deserialize(hash) transform_symbol_keys(result, symbol_keys) end + private + def key - "_aj_symbol_keys" + SYMBOL_KEYS_KEY end - private - def serialize_hash(hash) hash.each_with_object({}) do |(key, value), result| result[serialize_hash_key(key)] = Serializers.serialize(value) diff --git a/activejob/lib/active_job/serializers/hash_with_indifferent_access_serializer.rb b/activejob/lib/active_job/serializers/hash_with_indifferent_access_serializer.rb index b0fb29d58bb12..af3576dd57342 100644 --- a/activejob/lib/active_job/serializers/hash_with_indifferent_access_serializer.rb +++ b/activejob/lib/active_job/serializers/hash_with_indifferent_access_serializer.rb @@ -12,22 +12,18 @@ def serialize(hash) result end - def deserialize?(argument) - argument.is_a?(Hash) && argument[key] - end - def deserialize(hash) result = hash.transform_values { |v| Serializers.deserialize(v) } result.delete(key) result.with_indifferent_access end + private + def key - "_aj_hash_with_indifferent_access" + WITH_INDIFFERENT_ACCESS_KEY end - private - def klass ActiveSupport::HashWithIndifferentAccess end diff --git a/activejob/lib/active_job/serializers/object_serializer.rb b/activejob/lib/active_job/serializers/object_serializer.rb index 075360b26e901..d5ff8c91f1266 100644 --- a/activejob/lib/active_job/serializers/object_serializer.rb +++ b/activejob/lib/active_job/serializers/object_serializer.rb @@ -4,22 +4,12 @@ module ActiveJob module Serializers class ObjectSerializer < BaseSerializer class << self - def serialize(object) - { key => object.class.name } + def serialize(hash) + { OBJECT_SERIALIZER_KEY => self.name }.merge!(hash) end def deserialize?(argument) - argument.respond_to?(:keys) && argument.keys == keys - end - - def deserialize(hash) - hash[key].constantize - end - - private - - def keys - [key] + argument.is_a?(Hash) && argument[OBJECT_SERIALIZER_KEY] == self.name end end end diff --git a/activejob/test/cases/serializers_test.rb b/activejob/test/cases/serializers_test.rb index 3b526c932bc57..fb0e6ecae69d8 100644 --- a/activejob/test/cases/serializers_test.rb +++ b/activejob/test/cases/serializers_test.rb @@ -10,20 +10,20 @@ class DummyValueObject def initialize(value) @value = value end + + def ==(other) + self.value == other.value + end end class DummySerializer < ActiveJob::Serializers::ObjectSerializer class << self def serialize(object) - { key => object.value } + super({ "value" => object.value }) end def deserialize(hash) - DummyValueObject.new(hash[key]) - end - - def key - "_dummy_serializer" + DummyValueObject.new(hash["value"]) end private @@ -49,9 +49,24 @@ def klass end end + test "will serialize objects with serializers registered" do + ActiveJob::Serializers.add_serializers DummySerializer + + assert_equal( + { "_aj_serialized" => "SerializersTest::DummySerializer", "value" => 123 }, + ActiveJob::Serializers.serialize(@value_object) + ) + end + test "won't deserialize unknown hash" do hash = { "_dummy_serializer" => 123, "_aj_symbol_keys" => [] } - assert ActiveJob::Serializers.deserialize(hash), hash.except("_aj_symbol_keys") + assert_equal({ "_dummy_serializer" => 123 }, ActiveJob::Serializers.deserialize(hash)) + end + + test "will deserialize know serialized objects" do + ActiveJob::Serializers.add_serializers DummySerializer + hash = { "_aj_serialized" => "SerializersTest::DummySerializer", "value" => 123 } + assert_equal DummyValueObject.new(123), ActiveJob::Serializers.deserialize(hash) end test "adds new serializer" do @@ -61,7 +76,7 @@ def klass test "can't add serializer with the same key twice" do ActiveJob::Serializers.add_serializers DummySerializer - assert_raises ArgumentError do + assert_no_difference(-> { ActiveJob::Serializers.serializers.size } ) do ActiveJob::Serializers.add_serializers DummySerializer end end From b098584f63521d214c1107e6eaa24f292b8e4df8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Fri, 9 Feb 2018 15:45:11 -0500 Subject: [PATCH 07/15] Add symbol and duration serializers --- activejob/lib/active_job/serializers.rb | 6 ++++- .../serializers/duration_serializer.rb | 24 +++++++++++++++++++ .../serializers/symbol_serializer.rb | 21 ++++++++++++++++ .../test/cases/argument_serialization_test.rb | 3 ++- 4 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 activejob/lib/active_job/serializers/duration_serializer.rb create mode 100644 activejob/lib/active_job/serializers/symbol_serializer.rb diff --git a/activejob/lib/active_job/serializers.rb b/activejob/lib/active_job/serializers.rb index 12458ea572415..9e3fcda28d133 100644 --- a/activejob/lib/active_job/serializers.rb +++ b/activejob/lib/active_job/serializers.rb @@ -34,6 +34,8 @@ module Serializers autoload :HashSerializer autoload :ObjectSerializer autoload :StandardTypeSerializer + autoload :SymbolSerializer + autoload :DurationSerializer mattr_accessor :_additional_serializers self._additional_serializers = Set.new @@ -94,6 +96,8 @@ def reserved_serializers_keys StandardTypeSerializer, HashWithIndifferentAccessSerializer, HashSerializer, - ArraySerializer + ArraySerializer, + SymbolSerializer, + DurationSerializer end end diff --git a/activejob/lib/active_job/serializers/duration_serializer.rb b/activejob/lib/active_job/serializers/duration_serializer.rb new file mode 100644 index 0000000000000..46543cc30d468 --- /dev/null +++ b/activejob/lib/active_job/serializers/duration_serializer.rb @@ -0,0 +1,24 @@ +module ActiveJob + module Serializers + class DurationSerializer < ObjectSerializer + class << self + def serialize(duration) + super("value" => duration.value, "parts" => Serializers.serialize(duration.parts)) + end + + def deserialize(hash) + value = hash["value"] + parts = Serializers.deserialize(hash["parts"]) + + klass.new(value, parts) + end + + private + + def klass + ActiveSupport::Duration + end + end + end + end +end diff --git a/activejob/lib/active_job/serializers/symbol_serializer.rb b/activejob/lib/active_job/serializers/symbol_serializer.rb new file mode 100644 index 0000000000000..ec27f6828a677 --- /dev/null +++ b/activejob/lib/active_job/serializers/symbol_serializer.rb @@ -0,0 +1,21 @@ +module ActiveJob + module Serializers + class SymbolSerializer < ObjectSerializer + class << self + def serialize(argument) + super("value" => argument.to_s) + end + + def deserialize(argument) + argument["value"].to_sym + end + + private + + def klass + Symbol + end + end + end + end +end diff --git a/activejob/test/cases/argument_serialization_test.rb b/activejob/test/cases/argument_serialization_test.rb index 13e6fcb727f2f..442384f2c3360 100644 --- a/activejob/test/cases/argument_serialization_test.rb +++ b/activejob/test/cases/argument_serialization_test.rb @@ -13,6 +13,7 @@ class ArgumentSerializationTest < ActiveSupport::TestCase [ nil, 1, 1.0, 1_000_000_000_000_000_000_000, "a", true, false, BigDecimal.new(5), + :a, 1.day, [ 1, "a" ], { "a" => 1 } ].each do |arg| @@ -21,7 +22,7 @@ class ArgumentSerializationTest < ActiveSupport::TestCase end end - [ :a, Object.new, self, Person.find("5").to_gid ].each do |arg| + [ Object.new, self, Person.find("5").to_gid ].each do |arg| test "does not serialize #{arg.class}" do assert_raises ActiveJob::SerializationError do ActiveJob::Arguments.serialize [ arg ] From d2d98d69468bf34a39794496beb8f9f7b69088c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Fri, 9 Feb 2018 16:32:35 -0500 Subject: [PATCH 08/15] Allow serializers to be used either as classes or objects --- .../serializers/array_serializer.rb | 20 +++++------ .../active_job/serializers/base_serializer.rb | 32 +++++++++-------- .../serializers/duration_serializer.rb | 22 ++++++------ .../serializers/global_id_serializer.rb | 30 ++++++++-------- .../active_job/serializers/hash_serializer.rb | 34 +++++++++---------- ...hash_with_indifferent_access_serializer.rb | 26 +++++++------- .../serializers/object_serializer.rb | 12 +++---- .../serializers/standard_type_serializer.rb | 24 ++++++------- .../serializers/symbol_serializer.rb | 18 +++++----- activejob/test/cases/serializers_test.rb | 16 ++++----- 10 files changed, 110 insertions(+), 124 deletions(-) diff --git a/activejob/lib/active_job/serializers/array_serializer.rb b/activejob/lib/active_job/serializers/array_serializer.rb index 1b3c3b2ce3de9..9db4edea99044 100644 --- a/activejob/lib/active_job/serializers/array_serializer.rb +++ b/activejob/lib/active_job/serializers/array_serializer.rb @@ -3,24 +3,22 @@ module ActiveJob module Serializers # Provides methods to serialize and deserialize `Array` - class ArraySerializer < BaseSerializer - class << self - alias_method :deserialize?, :serialize? + class ArraySerializer < BaseSerializer # :nodoc: + alias_method :deserialize?, :serialize? - def serialize(array) - array.map { |arg| Serializers.serialize(arg) } - end + def serialize(array) + array.map { |arg| Serializers.serialize(arg) } + end - def deserialize(array) - array.map { |arg| Serializers.deserialize(arg) } - end + def deserialize(array) + array.map { |arg| Serializers.deserialize(arg) } + end - private + private def klass Array end - end end end end diff --git a/activejob/lib/active_job/serializers/base_serializer.rb b/activejob/lib/active_job/serializers/base_serializer.rb index 8b891cca48ae6..2e510781cfe74 100644 --- a/activejob/lib/active_job/serializers/base_serializer.rb +++ b/activejob/lib/active_job/serializers/base_serializer.rb @@ -3,29 +3,33 @@ module ActiveJob module Serializers class BaseSerializer + include Singleton + class << self - def serialize?(argument) - argument.is_a?(klass) - end + delegate :serialize?, :deserialize?, :serialize, :deserialize, to: :instance + end - def deserialize?(_argument) - raise NotImplementedError - end + def serialize?(argument) + argument.is_a?(klass) + end - def serialize(_argument) - raise NotImplementedError - end + def deserialize?(_argument) + raise NotImplementedError + end - def deserialize(_argument) - raise NotImplementedError - end + def serialize(_argument) + raise NotImplementedError + end - private + def deserialize(_argument) + raise NotImplementedError + end + + protected def klass raise NotImplementedError end - end end end end diff --git a/activejob/lib/active_job/serializers/duration_serializer.rb b/activejob/lib/active_job/serializers/duration_serializer.rb index 46543cc30d468..94b0d0407a8c4 100644 --- a/activejob/lib/active_job/serializers/duration_serializer.rb +++ b/activejob/lib/active_job/serializers/duration_serializer.rb @@ -1,24 +1,22 @@ module ActiveJob module Serializers - class DurationSerializer < ObjectSerializer - class << self - def serialize(duration) - super("value" => duration.value, "parts" => Serializers.serialize(duration.parts)) - end + class DurationSerializer < ObjectSerializer # :nodoc: + def serialize(duration) + super("value" => duration.value, "parts" => Serializers.serialize(duration.parts)) + end - def deserialize(hash) - value = hash["value"] - parts = Serializers.deserialize(hash["parts"]) + def deserialize(hash) + value = hash["value"] + parts = Serializers.deserialize(hash["parts"]) - klass.new(value, parts) - end + klass.new(value, parts) + end - private + private def klass ActiveSupport::Duration end - end end end end diff --git a/activejob/lib/active_job/serializers/global_id_serializer.rb b/activejob/lib/active_job/serializers/global_id_serializer.rb index 84ed33ef9913f..2d8629ef022b8 100644 --- a/activejob/lib/active_job/serializers/global_id_serializer.rb +++ b/activejob/lib/active_job/serializers/global_id_serializer.rb @@ -4,29 +4,27 @@ module ActiveJob module Serializers # Provides methods to serialize and deserialize objects which mixes `GlobalID::Identification`, # including `ActiveRecord::Base` models - class GlobalIDSerializer < BaseSerializer - class << self - def serialize(object) - { GLOBALID_KEY => object.to_global_id.to_s } - rescue URI::GID::MissingModelIdError - raise SerializationError, "Unable to serialize #{object.class} " \ - "without an id. (Maybe you forgot to call save?)" - end + class GlobalIDSerializer < BaseSerializer # :nodoc: + def serialize(object) + { GLOBALID_KEY => object.to_global_id.to_s } + rescue URI::GID::MissingModelIdError + raise SerializationError, "Unable to serialize #{object.class} " \ + "without an id. (Maybe you forgot to call save?)" + end - def deserialize(hash) - GlobalID::Locator.locate(hash[GLOBALID_KEY]) - end + def deserialize(hash) + GlobalID::Locator.locate(hash[GLOBALID_KEY]) + end - def deserialize?(argument) - argument.is_a?(Hash) && argument[GLOBALID_KEY] - end + def deserialize?(argument) + argument.is_a?(Hash) && argument[GLOBALID_KEY] + end - private + private def klass GlobalID::Identification end - end end end end diff --git a/activejob/lib/active_job/serializers/hash_serializer.rb b/activejob/lib/active_job/serializers/hash_serializer.rb index 2bbb31946d4b8..e569fe7501087 100644 --- a/activejob/lib/active_job/serializers/hash_serializer.rb +++ b/activejob/lib/active_job/serializers/hash_serializer.rb @@ -4,26 +4,25 @@ module ActiveJob module Serializers # Provides methods to serialize and deserialize `Hash` (`{key: field, ...}`) # Only `String` or `Symbol` can be used as a key. Values will be serialized by known serializers - class HashSerializer < BaseSerializer - class << self - def serialize(hash) - symbol_keys = hash.each_key.grep(Symbol).map(&:to_s) - result = serialize_hash(hash) - result[key] = symbol_keys - result - end + class HashSerializer < BaseSerializer # :nodoc: + def serialize(hash) + symbol_keys = hash.each_key.grep(Symbol).map(&:to_s) + result = serialize_hash(hash) + result[key] = symbol_keys + result + end - def deserialize?(argument) - argument.is_a?(Hash) && argument[key] - end + def deserialize?(argument) + argument.is_a?(Hash) && argument[key] + end - def deserialize(hash) - result = hash.transform_values { |v| Serializers::deserialize(v) } - symbol_keys = result.delete(key) - transform_symbol_keys(result, symbol_keys) - end + def deserialize(hash) + result = hash.transform_values { |v| Serializers::deserialize(v) } + symbol_keys = result.delete(key) + transform_symbol_keys(result, symbol_keys) + end - private + private def key SYMBOL_KEYS_KEY @@ -56,7 +55,6 @@ def transform_symbol_keys(hash, symbol_keys) def klass Hash end - end end end end diff --git a/activejob/lib/active_job/serializers/hash_with_indifferent_access_serializer.rb b/activejob/lib/active_job/serializers/hash_with_indifferent_access_serializer.rb index af3576dd57342..3b812ba30436e 100644 --- a/activejob/lib/active_job/serializers/hash_with_indifferent_access_serializer.rb +++ b/activejob/lib/active_job/serializers/hash_with_indifferent_access_serializer.rb @@ -4,21 +4,20 @@ module ActiveJob module Serializers # Provides methods to serialize and deserialize `ActiveSupport::HashWithIndifferentAccess` # Values will be serialized by known serializers - class HashWithIndifferentAccessSerializer < HashSerializer - class << self - def serialize(hash) - result = serialize_hash(hash) - result[key] = Serializers.serialize(true) - result - end + class HashWithIndifferentAccessSerializer < HashSerializer # :nodoc: + def serialize(hash) + result = serialize_hash(hash) + result[key] = Serializers.serialize(true) + result + end - def deserialize(hash) - result = hash.transform_values { |v| Serializers.deserialize(v) } - result.delete(key) - result.with_indifferent_access - end + def deserialize(hash) + result = hash.transform_values { |v| Serializers.deserialize(v) } + result.delete(key) + result.with_indifferent_access + end - private + private def key WITH_INDIFFERENT_ACCESS_KEY @@ -27,7 +26,6 @@ def key def klass ActiveSupport::HashWithIndifferentAccess end - end end end end diff --git a/activejob/lib/active_job/serializers/object_serializer.rb b/activejob/lib/active_job/serializers/object_serializer.rb index d5ff8c91f1266..318eabebdf0b3 100644 --- a/activejob/lib/active_job/serializers/object_serializer.rb +++ b/activejob/lib/active_job/serializers/object_serializer.rb @@ -3,14 +3,12 @@ module ActiveJob module Serializers class ObjectSerializer < BaseSerializer - class << self - def serialize(hash) - { OBJECT_SERIALIZER_KEY => self.name }.merge!(hash) - end + def serialize(hash) + { OBJECT_SERIALIZER_KEY => self.class.name }.merge!(hash) + end - def deserialize?(argument) - argument.is_a?(Hash) && argument[OBJECT_SERIALIZER_KEY] == self.name - end + def deserialize?(argument) + argument.is_a?(Hash) && argument[OBJECT_SERIALIZER_KEY] == self.class.name end end end diff --git a/activejob/lib/active_job/serializers/standard_type_serializer.rb b/activejob/lib/active_job/serializers/standard_type_serializer.rb index efc02adcf01c3..1db4f3937d882 100644 --- a/activejob/lib/active_job/serializers/standard_type_serializer.rb +++ b/activejob/lib/active_job/serializers/standard_type_serializer.rb @@ -4,22 +4,20 @@ module ActiveJob module Serializers # Provides methods to serialize and deserialize standard types # (`NilClass`, `String`, `Integer`, `Fixnum`, `Bignum`, `Float`, `BigDecimal`, `TrueClass`, `FalseClass`) - class StandardTypeSerializer < BaseSerializer - class << self - def serialize?(argument) - Arguments::TYPE_WHITELIST.include? argument.class - end + class StandardTypeSerializer < BaseSerializer # :nodoc: + def serialize?(argument) + Arguments::TYPE_WHITELIST.include? argument.class + end - def serialize(argument) - argument - end + def serialize(argument) + argument + end - alias_method :deserialize?, :serialize? + alias_method :deserialize?, :serialize? - def deserialize(argument) - object = GlobalID::Locator.locate(argument) if argument.is_a? String - object || argument - end + def deserialize(argument) + object = GlobalID::Locator.locate(argument) if argument.is_a? String + object || argument end end end diff --git a/activejob/lib/active_job/serializers/symbol_serializer.rb b/activejob/lib/active_job/serializers/symbol_serializer.rb index ec27f6828a677..c8900de9d6fe8 100644 --- a/activejob/lib/active_job/serializers/symbol_serializer.rb +++ b/activejob/lib/active_job/serializers/symbol_serializer.rb @@ -1,21 +1,19 @@ module ActiveJob module Serializers - class SymbolSerializer < ObjectSerializer - class << self - def serialize(argument) - super("value" => argument.to_s) - end + class SymbolSerializer < ObjectSerializer # :nodoc: + def serialize(argument) + super("value" => argument.to_s) + end - def deserialize(argument) - argument["value"].to_sym - end + def deserialize(argument) + argument["value"].to_sym + end - private + private def klass Symbol end - end end end end diff --git a/activejob/test/cases/serializers_test.rb b/activejob/test/cases/serializers_test.rb index fb0e6ecae69d8..207ae55b4902a 100644 --- a/activejob/test/cases/serializers_test.rb +++ b/activejob/test/cases/serializers_test.rb @@ -17,21 +17,19 @@ def ==(other) end class DummySerializer < ActiveJob::Serializers::ObjectSerializer - class << self - def serialize(object) - super({ "value" => object.value }) - end + def serialize(object) + super({ "value" => object.value }) + end - def deserialize(hash) - DummyValueObject.new(hash["value"]) - end + def deserialize(hash) + DummyValueObject.new(hash["value"]) + end - private + private def klass DummyValueObject end - end end setup do From d9a5c7011f62dd771a2fa430090e068b1f9785f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Fri, 9 Feb 2018 16:50:57 -0500 Subject: [PATCH 09/15] Add serializers for Time, Date and DateTime --- activejob/lib/active_job/serializers.rb | 8 ++++++- .../active_job/serializers/date_serializer.rb | 21 +++++++++++++++++++ .../serializers/date_time_serializer.rb | 21 +++++++++++++++++++ .../serializers/duration_serializer.rb | 2 ++ .../serializers/symbol_serializer.rb | 2 ++ .../active_job/serializers/time_serializer.rb | 21 +++++++++++++++++++ .../test/cases/argument_serialization_test.rb | 4 +++- 7 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 activejob/lib/active_job/serializers/date_serializer.rb create mode 100644 activejob/lib/active_job/serializers/date_time_serializer.rb create mode 100644 activejob/lib/active_job/serializers/time_serializer.rb diff --git a/activejob/lib/active_job/serializers.rb b/activejob/lib/active_job/serializers.rb index 9e3fcda28d133..dfd654175da0b 100644 --- a/activejob/lib/active_job/serializers.rb +++ b/activejob/lib/active_job/serializers.rb @@ -36,6 +36,9 @@ module Serializers autoload :StandardTypeSerializer autoload :SymbolSerializer autoload :DurationSerializer + autoload :DateSerializer + autoload :TimeSerializer + autoload :DateTimeSerializer mattr_accessor :_additional_serializers self._additional_serializers = Set.new @@ -98,6 +101,9 @@ def reserved_serializers_keys HashSerializer, ArraySerializer, SymbolSerializer, - DurationSerializer + DurationSerializer, + DateTimeSerializer, + DateSerializer, + TimeSerializer end end diff --git a/activejob/lib/active_job/serializers/date_serializer.rb b/activejob/lib/active_job/serializers/date_serializer.rb new file mode 100644 index 0000000000000..e995d30faaafb --- /dev/null +++ b/activejob/lib/active_job/serializers/date_serializer.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module ActiveJob + module Serializers + class DateSerializer < ObjectSerializer # :nodoc: + def serialize(date) + super("value" => date.iso8601) + end + + def deserialize(hash) + Date.iso8601(hash["value"]) + end + + private + + def klass + Date + end + end + end +end diff --git a/activejob/lib/active_job/serializers/date_time_serializer.rb b/activejob/lib/active_job/serializers/date_time_serializer.rb new file mode 100644 index 0000000000000..fe780a1978b78 --- /dev/null +++ b/activejob/lib/active_job/serializers/date_time_serializer.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module ActiveJob + module Serializers + class DateTimeSerializer < ObjectSerializer # :nodoc: + def serialize(time) + super("value" => time.iso8601) + end + + def deserialize(hash) + DateTime.iso8601(hash["value"]) + end + + private + + def klass + DateTime + end + end + end +end diff --git a/activejob/lib/active_job/serializers/duration_serializer.rb b/activejob/lib/active_job/serializers/duration_serializer.rb index 94b0d0407a8c4..a3c4c5d1c2f1a 100644 --- a/activejob/lib/active_job/serializers/duration_serializer.rb +++ b/activejob/lib/active_job/serializers/duration_serializer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveJob module Serializers class DurationSerializer < ObjectSerializer # :nodoc: diff --git a/activejob/lib/active_job/serializers/symbol_serializer.rb b/activejob/lib/active_job/serializers/symbol_serializer.rb index c8900de9d6fe8..7e1f9553a20d2 100644 --- a/activejob/lib/active_job/serializers/symbol_serializer.rb +++ b/activejob/lib/active_job/serializers/symbol_serializer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveJob module Serializers class SymbolSerializer < ObjectSerializer # :nodoc: diff --git a/activejob/lib/active_job/serializers/time_serializer.rb b/activejob/lib/active_job/serializers/time_serializer.rb new file mode 100644 index 0000000000000..fe20772f356e4 --- /dev/null +++ b/activejob/lib/active_job/serializers/time_serializer.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module ActiveJob + module Serializers + class TimeSerializer < ObjectSerializer # :nodoc: + def serialize(time) + super("value" => time.iso8601) + end + + def deserialize(hash) + Time.iso8601(hash["value"]) + end + + private + + def klass + Time + end + end + end +end diff --git a/activejob/test/cases/argument_serialization_test.rb b/activejob/test/cases/argument_serialization_test.rb index 442384f2c3360..8f51a2d238dd4 100644 --- a/activejob/test/cases/argument_serialization_test.rb +++ b/activejob/test/cases/argument_serialization_test.rb @@ -13,7 +13,9 @@ class ArgumentSerializationTest < ActiveSupport::TestCase [ nil, 1, 1.0, 1_000_000_000_000_000_000_000, "a", true, false, BigDecimal.new(5), - :a, 1.day, + :a, 1.day, Date.new(2001, 2, 3), Time.new(2002, 10, 31, 2, 2, 2, "+02:00"), + DateTime.new(2001, 2, 3, 4, 5, 6, '+03:00'), + ActiveSupport::TimeWithZone.new(Time.utc(1999, 12, 31, 23, 59, 59), ActiveSupport::TimeZone["UTC"]), [ 1, "a" ], { "a" => 1 } ].each do |arg| From 2fe467091b3743627d52a3e2ae357f0b5fd6d157 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Fri, 9 Feb 2018 17:26:35 -0500 Subject: [PATCH 10/15] No need to require a autoloaded constant --- activejob/lib/active_job/base.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/activejob/lib/active_job/base.rb b/activejob/lib/active_job/base.rb index 82757768203f0..6194f89956346 100644 --- a/activejob/lib/active_job/base.rb +++ b/activejob/lib/active_job/base.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require "active_job/core" -require "active_job/serializers" require "active_job/queue_adapter" require "active_job/queue_name" require "active_job/queue_priority" From a5f7357a3dff2617ba13a274feb8d8ac2492f26a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Fri, 9 Feb 2018 17:27:01 -0500 Subject: [PATCH 11/15] Add configuration to set custom serializers --- activejob/lib/active_job/railtie.rb | 6 ++++++ guides/source/configuring.md | 2 ++ 2 files changed, 8 insertions(+) diff --git a/activejob/lib/active_job/railtie.rb b/activejob/lib/active_job/railtie.rb index 7b0742a6d2897..427ad1e3afc00 100644 --- a/activejob/lib/active_job/railtie.rb +++ b/activejob/lib/active_job/railtie.rb @@ -7,11 +7,17 @@ module ActiveJob # = Active Job Railtie class Railtie < Rails::Railtie # :nodoc: config.active_job = ActiveSupport::OrderedOptions.new + config.active_job.custom_serializers = [] initializer "active_job.logger" do ActiveSupport.on_load(:active_job) { self.logger = ::Rails.logger } end + initializer "active_job.custom_serializers" do |app| + custom_serializers = app.config.active_job.delete(:custom_serializers) + ActiveJob::Serializers.add_serializers custom_serializers + end + initializer "active_job.set_configs" do |app| options = app.config.active_job options.queue_adapter ||= :async diff --git a/guides/source/configuring.md b/guides/source/configuring.md index b0f39e7ab5353..fd747c1686549 100644 --- a/guides/source/configuring.md +++ b/guides/source/configuring.md @@ -741,6 +741,8 @@ There are a few configuration options available in Active Support: * `config.active_job.logger` accepts a logger conforming to the interface of Log4r or the default Ruby Logger class, which is then used to log information from Active Job. You can retrieve this logger by calling `logger` on either an Active Job class or an Active Job instance. Set to `nil` to disable logging. +* `config.active_job.custom_serializers` allows to set custom argument serializers. Defaults to `[]`. + ### Configuring Action Cable * `config.action_cable.url` accepts a string for the URL for where From 71721dc1c9b769d3c06317122dc88cad4a346580 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Fri, 9 Feb 2018 17:27:39 -0500 Subject: [PATCH 12/15] Improve documentation on custom serializers --- activejob/README.md | 58 +------------------ .../active_job/serializers/base_serializer.rb | 6 ++ .../serializers/object_serializer.rb | 19 ++++++ guides/source/active_job_basics.md | 43 +++++++------- 4 files changed, 47 insertions(+), 79 deletions(-) diff --git a/activejob/README.md b/activejob/README.md index 56562d870bb4b..f1ebb76e08a93 100644 --- a/activejob/README.md +++ b/activejob/README.md @@ -52,17 +52,8 @@ MyJob.set(wait: 1.week).perform_later(record) # Enqueue a job to be performed 1 That's it! -## Supported types for arguments -ActiveJob supports the following types of arguments by default: - - - Standard types (`NilClass`, `String`, `Integer`, `Fixnum`, `Bignum`, `Float`, `BigDecimal`, `TrueClass`, `FalseClass`) - - `Hash`. Keys should be of `String` or `Symbol` type - - `ActiveSupport::HashWithIndifferentAccess` - - `Array` - - -### GlobalID support +## GlobalID support Active Job supports [GlobalID serialization](https://github.com/rails/globalid/) for parameters. This makes it possible to pass live Active Record objects to your job instead of class/id pairs, which @@ -90,53 +81,6 @@ end This works with any class that mixes in GlobalID::Identification, which by default has been mixed into Active Record classes. -### Serializers - -You can extend list of supported types for arguments. You just need to define your own serializer. - -```ruby -class MySpecialSerializer - class << self - # Check if this object should be serialized using this serializer - def serialize?(argument) - object.is_a? MySpecialValueObject - end - - # Convert an object to a simpler representative using supported object types. - # The recommended representative is a Hash with a specific key. Keys can be of basic types only - def serialize(object) - { - key => ActiveJob::Serializers.serialize(object.value) - 'another_attribute' => ActiveJob::Serializers.serialize(object.another_attribute) - } - end - - # Check if this serialized value be deserialized using this serializer - def deserialize?(argument) - object.is_a?(Hash) && object.keys == [key, 'another_attribute'] - end - - # Convert serialized value into a proper object - def deserialize(object) - value = ActiveJob::Serializers.deserialize(object[key]) - another_attribute = ActiveJob::Serializers.deserialize(object['another_attribute']) - MySpecialValueObject.new value, another_attribute - end - - # Define this method if you are using a hash as a representative. - # This key will be added to a list of restricted keys for hashes. Use basic types only - def key - "_aj_custom_dummy_value_object" - end - end -end -``` - -And now you just need to add this serializer to a list: - -```ruby -ActiveJob::Base.add_serializers(MySpecialSerializer) -``` ## Supported queueing systems diff --git a/activejob/lib/active_job/serializers/base_serializer.rb b/activejob/lib/active_job/serializers/base_serializer.rb index 2e510781cfe74..155eeb29c3f1a 100644 --- a/activejob/lib/active_job/serializers/base_serializer.rb +++ b/activejob/lib/active_job/serializers/base_serializer.rb @@ -2,6 +2,7 @@ module ActiveJob module Serializers + # Implement the basic interface for Active Job arguments serializers. class BaseSerializer include Singleton @@ -9,24 +10,29 @@ class << self delegate :serialize?, :deserialize?, :serialize, :deserialize, to: :instance end + # Determines if an argument should be serialized by a serializer. def serialize?(argument) argument.is_a?(klass) end + # Determines if an argument should be deserialized by a serializer. def deserialize?(_argument) raise NotImplementedError end + # Serializes an argument to a JSON primitive type. def serialize(_argument) raise NotImplementedError end + # Deserilizes an argument form a JSON primiteve type. def deserialize(_argument) raise NotImplementedError end protected + # The class of the object that will be serialized. def klass raise NotImplementedError end diff --git a/activejob/lib/active_job/serializers/object_serializer.rb b/activejob/lib/active_job/serializers/object_serializer.rb index 318eabebdf0b3..940b6ff95d8c0 100644 --- a/activejob/lib/active_job/serializers/object_serializer.rb +++ b/activejob/lib/active_job/serializers/object_serializer.rb @@ -2,6 +2,25 @@ module ActiveJob module Serializers + # Base class for serializing and deserializing custom times. + # + # Example + # + # class MoneySerializer < ActiveJob::Serializers::ObjectSerializer + # def serialize(money) + # super("cents" => money.cents, "currency" => money.currency) + # end + # + # def deserialize(hash) + # Money.new(hash["cents"], hash["currency"]) + # end + # + # private + # + # def klass + # Money + # end + # end class ObjectSerializer < BaseSerializer def serialize(hash) { OBJECT_SERIALIZER_KEY => self.class.name }.merge!(hash) diff --git a/guides/source/active_job_basics.md b/guides/source/active_job_basics.md index eea64f9367dfa..0ee522e23dd44 100644 --- a/guides/source/active_job_basics.md +++ b/guides/source/active_job_basics.md @@ -345,6 +345,12 @@ Supported types for arguments ActiveJob supports the following types of arguments by default: - Basic types (`NilClass`, `String`, `Integer`, `Fixnum`, `Bignum`, `Float`, `BigDecimal`, `TrueClass`, `FalseClass`) + - `Symbol + - `ActiveSupport::Duration` + - `Date` + - `Time` + - `DateTime` + - `ActiveSupport::TimeWithZone` - `Hash`. Keys should be of `String` or `Symbol` type - `ActiveSupport::HashWithIndifferentAccess` - `Array` @@ -382,38 +388,31 @@ by default has been mixed into Active Record classes. You can extend list of supported types for arguments. You just need to define your own serializer. ```ruby -class MySpecialSerializer - class << self - # Check if this object should be serialized using this serializer +class MoneySerializer < ActiveJob::Serializers::ObjectSerializer + # Check if this object should be serialized using this serializer. def serialize?(argument) - argument.is_a? MySpecialValueObject + argument.is_a? Money end # Convert an object to a simpler representative using supported object types. - # The recommended representative is a Hash with a specific key. Keys can be of basic types only + # The recommended representative is a Hash with a specific key. Keys can be of basic types only. + # You should call `super` to add the custom serializer type to the hash def serialize(object) - { - key => ActiveJob::Serializers.serialize(object.value) - 'another_attribute' => ActiveJob::Serializers.serialize(object.another_attribute) - } + super( + "cents" => object.cents, + "currency" => object.currency + ) end - # Check if this serialized value be deserialized using this serializer + # Check if this serialized value be deserialized using this serializer. + # ActiveJob::Serializers::ObjectSerializer#deserialize? already take care of this. def deserialize?(argument) - argument.is_a?(Hash) && argument.keys == [key, 'another_attribute'] + super end # Convert serialized value into a proper object - def deserialize(object) - value = ActiveJob::Serializers.deserialize(object[key]) - another_attribute = ActiveJob::Serializers.deserialize(object['another_attribute']) - MySpecialValueObject.new value, another_attribute - end - - # Define this method if you are using a hash as a representative. - # This key will be added to a list of restricted keys for hashes. Use basic types only - def key - "_aj_custom_dummy_value_object" + def deserialize(hash) + Money.new hash["cents"], hash["currency"] end end end @@ -422,7 +421,7 @@ end And now you just need to add this serializer to a list: ```ruby -ActiveJob::Base.add_serializers(MySpecialSerializer) +Rails.application.config.active_job.custom_serializers << MySpecialSerializer ``` From 69645cba727dfa1c18c666d2a2f1c0dedffde938 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Mon, 12 Feb 2018 14:16:41 -0500 Subject: [PATCH 13/15] Simplify the implementation of custom argument serializers We can speed up things for the supported types by keeping the code in the way it was. We can also avoid to loop trough all serializers in the deserialization by trying to access the class already in the Hash. We could also speed up the custom serialization if we define the class that is going to be serialized when registering the serializers, but that will remove the possibility of defining a serialzer for a superclass and have the subclass serialized using it. --- activejob/lib/active_job/arguments.rb | 141 +++++++++++++++++- activejob/lib/active_job/serializers.rb | 61 +------- .../serializers/array_serializer.rb | 24 --- .../active_job/serializers/base_serializer.rb | 41 ----- .../serializers/duration_serializer.rb | 4 +- .../serializers/global_id_serializer.rb | 30 ---- .../active_job/serializers/hash_serializer.rb | 60 -------- ...hash_with_indifferent_access_serializer.rb | 31 ---- .../serializers/object_serializer.rb | 28 +++- .../serializers/standard_type_serializer.rb | 24 --- .../test/cases/argument_serialization_test.rb | 2 +- activejob/test/cases/serializers_test.rb | 21 ++- guides/source/active_job_basics.md | 6 - 13 files changed, 192 insertions(+), 281 deletions(-) delete mode 100644 activejob/lib/active_job/serializers/array_serializer.rb delete mode 100644 activejob/lib/active_job/serializers/base_serializer.rb delete mode 100644 activejob/lib/active_job/serializers/global_id_serializer.rb delete mode 100644 activejob/lib/active_job/serializers/hash_serializer.rb delete mode 100644 activejob/lib/active_job/serializers/hash_with_indifferent_access_serializer.rb delete mode 100644 activejob/lib/active_job/serializers/standard_type_serializer.rb diff --git a/activejob/lib/active_job/arguments.rb b/activejob/lib/active_job/arguments.rb index 9d4713186416c..e6ada163e8f44 100644 --- a/activejob/lib/active_job/arguments.rb +++ b/activejob/lib/active_job/arguments.rb @@ -3,6 +3,24 @@ require "active_support/core_ext/hash" module ActiveJob + # Raised when an exception is raised during job arguments deserialization. + # + # Wraps the original exception raised as +cause+. + class DeserializationError < StandardError + def initialize #:nodoc: + super("Error while trying to deserialize arguments: #{$!.message}") + set_backtrace $!.backtrace + end + end + + # Raised when an unsupported argument type is set as a job argument. We + # currently support NilClass, Integer, Fixnum, Float, String, TrueClass, FalseClass, + # Bignum, BigDecimal, and objects that can be represented as GlobalIDs (ex: Active Record). + # Raised if you set the key for a Hash something else than a string or + # a symbol. Also raised when trying to serialize an object which can't be + # identified with a Global ID - such as an unpersisted Active Record model. + class SerializationError < ArgumentError; end + module Arguments extend self # :nodoc: @@ -13,16 +31,135 @@ module Arguments # as-is. Arrays/Hashes are serialized element by element. # All other types are serialized using GlobalID. def serialize(arguments) - ActiveJob::Serializers.serialize(arguments) + arguments.map { |argument| serialize_argument(argument) } end # Deserializes a set of arguments. Whitelisted types are returned # as-is. Arrays/Hashes are deserialized element by element. # All other types are deserialized using GlobalID. def deserialize(arguments) - ActiveJob::Serializers.deserialize(arguments) + arguments.map { |argument| deserialize_argument(argument) } rescue raise DeserializationError end + + private + + # :nodoc: + GLOBALID_KEY = "_aj_globalid".freeze + # :nodoc: + SYMBOL_KEYS_KEY = "_aj_symbol_keys".freeze + # :nodoc: + WITH_INDIFFERENT_ACCESS_KEY = "_aj_hash_with_indifferent_access".freeze + # :nodoc: + OBJECT_SERIALIZER_KEY = "_aj_serialized" + + # :nodoc: + RESERVED_KEYS = [ + GLOBALID_KEY, GLOBALID_KEY.to_sym, + SYMBOL_KEYS_KEY, SYMBOL_KEYS_KEY.to_sym, + OBJECT_SERIALIZER_KEY, OBJECT_SERIALIZER_KEY.to_sym, + WITH_INDIFFERENT_ACCESS_KEY, WITH_INDIFFERENT_ACCESS_KEY.to_sym, + ] + private_constant :RESERVED_KEYS + + def serialize_argument(argument) + case argument + when *TYPE_WHITELIST + argument + when GlobalID::Identification + convert_to_global_id_hash(argument) + when Array + argument.map { |arg| serialize_argument(arg) } + when ActiveSupport::HashWithIndifferentAccess + result = serialize_hash(argument) + result[WITH_INDIFFERENT_ACCESS_KEY] = serialize_argument(true) + result + when Hash + symbol_keys = argument.each_key.grep(Symbol).map(&:to_s) + result = serialize_hash(argument) + result[SYMBOL_KEYS_KEY] = symbol_keys + result + else + Serializers.serialize(argument) + end + end + + def deserialize_argument(argument) + case argument + when String + GlobalID::Locator.locate(argument) || argument + when *TYPE_WHITELIST + argument + when Array + argument.map { |arg| deserialize_argument(arg) } + when Hash + if serialized_global_id?(argument) + deserialize_global_id argument + elsif custom_serialized?(argument) + Serializers.deserialize(argument) + else + deserialize_hash(argument) + end + else + raise ArgumentError, "Can only deserialize primitive arguments: #{argument.inspect}" + end + end + + def serialized_global_id?(hash) + hash.size == 1 && hash.include?(GLOBALID_KEY) + end + + def deserialize_global_id(hash) + GlobalID::Locator.locate hash[GLOBALID_KEY] + end + + def custom_serialized?(hash) + hash.key?(OBJECT_SERIALIZER_KEY) + end + + def serialize_hash(argument) + argument.each_with_object({}) do |(key, value), hash| + hash[serialize_hash_key(key)] = serialize_argument(value) + end + end + + def deserialize_hash(serialized_hash) + result = serialized_hash.transform_values { |v| deserialize_argument(v) } + if result.delete(WITH_INDIFFERENT_ACCESS_KEY) + result = result.with_indifferent_access + elsif symbol_keys = result.delete(SYMBOL_KEYS_KEY) + result = transform_symbol_keys(result, symbol_keys) + end + result + end + + def serialize_hash_key(key) + case key + when *RESERVED_KEYS + raise SerializationError.new("Can't serialize a Hash with reserved key #{key.inspect}") + when String, Symbol + key.to_s + else + raise SerializationError.new("Only string and symbol hash keys may be serialized as job arguments, but #{key.inspect} is a #{key.class}") + end + end + + def transform_symbol_keys(hash, symbol_keys) + hash.transform_keys do |key| + if symbol_keys.include?(key) + key.to_sym + else + key + end + end + end + + def convert_to_global_id_hash(argument) + { GLOBALID_KEY => argument.to_global_id.to_s } + rescue URI::GID::MissingModelIdError + raise SerializationError, "Unable to serialize #{argument.class} " \ + "without an id. (Maybe you forgot to call save?)" + end end end diff --git a/activejob/lib/active_job/serializers.rb b/activejob/lib/active_job/serializers.rb index dfd654175da0b..d9a130fa73cac 100644 --- a/activejob/lib/active_job/serializers.rb +++ b/activejob/lib/active_job/serializers.rb @@ -3,37 +3,13 @@ require "set" module ActiveJob - # Raised when an exception is raised during job arguments deserialization. - # - # Wraps the original exception raised as +cause+. - class DeserializationError < StandardError - def initialize #:nodoc: - super("Error while trying to deserialize arguments: #{$!.message}") - set_backtrace $!.backtrace - end - end - - # Raised when an unsupported argument type is set as a job argument. We - # currently support NilClass, Integer, Fixnum, Float, String, TrueClass, FalseClass, - # Bignum, BigDecimal, and objects that can be represented as GlobalIDs (ex: Active Record). - # Raised if you set the key for a Hash something else than a string or - # a symbol. Also raised when trying to serialize an object which can't be - # identified with a Global ID - such as an unpersisted Active Record model. - class SerializationError < ArgumentError; end - # The ActiveJob::Serializers module is used to store a list of known serializers # and to add new ones. It also has helpers to serialize/deserialize objects module Serializers extend ActiveSupport::Autoload extend ActiveSupport::Concern - autoload :ArraySerializer - autoload :BaseSerializer - autoload :GlobalIDSerializer - autoload :HashWithIndifferentAccessSerializer - autoload :HashSerializer autoload :ObjectSerializer - autoload :StandardTypeSerializer autoload :SymbolSerializer autoload :DurationSerializer autoload :DateSerializer @@ -57,8 +33,12 @@ def serialize(argument) # Will look up through all known serializers. # If no serializers found will raise `ArgumentError` def deserialize(argument) - serializer = serializers.detect { |s| s.deserialize?(argument) } - raise ArgumentError, "Can only deserialize primitive arguments: #{argument.inspect}" unless serializer + serializer_name = argument[Arguments::OBJECT_SERIALIZER_KEY] + raise ArgumentError, "Serializer name is not present in the argument: #{argument.inspect}" unless serializer_name + + serializer = serializer_name.safe_constantize + raise ArgumentError, "Serializer #{serializer_name} is not know" unless serializer + serializer.deserialize(argument) end @@ -71,36 +51,9 @@ def serializers def add_serializers(*new_serializers) self._additional_serializers += new_serializers end - - # Returns a list of reserved keys, which cannot be used as keys for a hash - def reserved_serializers_keys - RESERVED_KEYS - end end - # :nodoc: - GLOBALID_KEY = "_aj_globalid".freeze - # :nodoc: - SYMBOL_KEYS_KEY = "_aj_symbol_keys".freeze - # :nodoc: - WITH_INDIFFERENT_ACCESS_KEY = "_aj_hash_with_indifferent_access".freeze - # :nodoc: - OBJECT_SERIALIZER_KEY = "_aj_serialized" - - # :nodoc: - RESERVED_KEYS = [ - GLOBALID_KEY, GLOBALID_KEY.to_sym, - SYMBOL_KEYS_KEY, SYMBOL_KEYS_KEY.to_sym, - WITH_INDIFFERENT_ACCESS_KEY, WITH_INDIFFERENT_ACCESS_KEY.to_sym, - ] - private_constant :RESERVED_KEYS - - add_serializers GlobalIDSerializer, - StandardTypeSerializer, - HashWithIndifferentAccessSerializer, - HashSerializer, - ArraySerializer, - SymbolSerializer, + add_serializers SymbolSerializer, DurationSerializer, DateTimeSerializer, DateSerializer, diff --git a/activejob/lib/active_job/serializers/array_serializer.rb b/activejob/lib/active_job/serializers/array_serializer.rb deleted file mode 100644 index 9db4edea99044..0000000000000 --- a/activejob/lib/active_job/serializers/array_serializer.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -module ActiveJob - module Serializers - # Provides methods to serialize and deserialize `Array` - class ArraySerializer < BaseSerializer # :nodoc: - alias_method :deserialize?, :serialize? - - def serialize(array) - array.map { |arg| Serializers.serialize(arg) } - end - - def deserialize(array) - array.map { |arg| Serializers.deserialize(arg) } - end - - private - - def klass - Array - end - end - end -end diff --git a/activejob/lib/active_job/serializers/base_serializer.rb b/activejob/lib/active_job/serializers/base_serializer.rb deleted file mode 100644 index 155eeb29c3f1a..0000000000000 --- a/activejob/lib/active_job/serializers/base_serializer.rb +++ /dev/null @@ -1,41 +0,0 @@ -# frozen_string_literal: true - -module ActiveJob - module Serializers - # Implement the basic interface for Active Job arguments serializers. - class BaseSerializer - include Singleton - - class << self - delegate :serialize?, :deserialize?, :serialize, :deserialize, to: :instance - end - - # Determines if an argument should be serialized by a serializer. - def serialize?(argument) - argument.is_a?(klass) - end - - # Determines if an argument should be deserialized by a serializer. - def deserialize?(_argument) - raise NotImplementedError - end - - # Serializes an argument to a JSON primitive type. - def serialize(_argument) - raise NotImplementedError - end - - # Deserilizes an argument form a JSON primiteve type. - def deserialize(_argument) - raise NotImplementedError - end - - protected - - # The class of the object that will be serialized. - def klass - raise NotImplementedError - end - end - end -end diff --git a/activejob/lib/active_job/serializers/duration_serializer.rb b/activejob/lib/active_job/serializers/duration_serializer.rb index a3c4c5d1c2f1a..715fe27a5cca0 100644 --- a/activejob/lib/active_job/serializers/duration_serializer.rb +++ b/activejob/lib/active_job/serializers/duration_serializer.rb @@ -4,12 +4,12 @@ module ActiveJob module Serializers class DurationSerializer < ObjectSerializer # :nodoc: def serialize(duration) - super("value" => duration.value, "parts" => Serializers.serialize(duration.parts)) + super("value" => duration.value, "parts" => Arguments.serialize(duration.parts)) end def deserialize(hash) value = hash["value"] - parts = Serializers.deserialize(hash["parts"]) + parts = Arguments.deserialize(hash["parts"]) klass.new(value, parts) end diff --git a/activejob/lib/active_job/serializers/global_id_serializer.rb b/activejob/lib/active_job/serializers/global_id_serializer.rb deleted file mode 100644 index 2d8629ef022b8..0000000000000 --- a/activejob/lib/active_job/serializers/global_id_serializer.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -module ActiveJob - module Serializers - # Provides methods to serialize and deserialize objects which mixes `GlobalID::Identification`, - # including `ActiveRecord::Base` models - class GlobalIDSerializer < BaseSerializer # :nodoc: - def serialize(object) - { GLOBALID_KEY => object.to_global_id.to_s } - rescue URI::GID::MissingModelIdError - raise SerializationError, "Unable to serialize #{object.class} " \ - "without an id. (Maybe you forgot to call save?)" - end - - def deserialize(hash) - GlobalID::Locator.locate(hash[GLOBALID_KEY]) - end - - def deserialize?(argument) - argument.is_a?(Hash) && argument[GLOBALID_KEY] - end - - private - - def klass - GlobalID::Identification - end - end - end -end diff --git a/activejob/lib/active_job/serializers/hash_serializer.rb b/activejob/lib/active_job/serializers/hash_serializer.rb deleted file mode 100644 index e569fe7501087..0000000000000 --- a/activejob/lib/active_job/serializers/hash_serializer.rb +++ /dev/null @@ -1,60 +0,0 @@ -# frozen_string_literal: true - -module ActiveJob - module Serializers - # Provides methods to serialize and deserialize `Hash` (`{key: field, ...}`) - # Only `String` or `Symbol` can be used as a key. Values will be serialized by known serializers - class HashSerializer < BaseSerializer # :nodoc: - def serialize(hash) - symbol_keys = hash.each_key.grep(Symbol).map(&:to_s) - result = serialize_hash(hash) - result[key] = symbol_keys - result - end - - def deserialize?(argument) - argument.is_a?(Hash) && argument[key] - end - - def deserialize(hash) - result = hash.transform_values { |v| Serializers::deserialize(v) } - symbol_keys = result.delete(key) - transform_symbol_keys(result, symbol_keys) - end - - private - - def key - SYMBOL_KEYS_KEY - end - - def serialize_hash(hash) - hash.each_with_object({}) do |(key, value), result| - result[serialize_hash_key(key)] = Serializers.serialize(value) - end - end - - def serialize_hash_key(key) - raise SerializationError.new("Only string and symbol hash keys may be serialized as job arguments, but #{key.inspect} is a #{key.class}") unless [String, Symbol].include?(key.class) - - raise SerializationError.new("Can't serialize a Hash with reserved key #{key.inspect}") if ActiveJob::Serializers.reserved_serializers_keys.include?(key.to_s) - - key.to_s - end - - def transform_symbol_keys(hash, symbol_keys) - hash.transform_keys do |key| - if symbol_keys.include?(key) - key.to_sym - else - key - end - end - end - - def klass - Hash - end - end - end -end diff --git a/activejob/lib/active_job/serializers/hash_with_indifferent_access_serializer.rb b/activejob/lib/active_job/serializers/hash_with_indifferent_access_serializer.rb deleted file mode 100644 index 3b812ba30436e..0000000000000 --- a/activejob/lib/active_job/serializers/hash_with_indifferent_access_serializer.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true - -module ActiveJob - module Serializers - # Provides methods to serialize and deserialize `ActiveSupport::HashWithIndifferentAccess` - # Values will be serialized by known serializers - class HashWithIndifferentAccessSerializer < HashSerializer # :nodoc: - def serialize(hash) - result = serialize_hash(hash) - result[key] = Serializers.serialize(true) - result - end - - def deserialize(hash) - result = hash.transform_values { |v| Serializers.deserialize(v) } - result.delete(key) - result.with_indifferent_access - end - - private - - def key - WITH_INDIFFERENT_ACCESS_KEY - end - - def klass - ActiveSupport::HashWithIndifferentAccess - end - end - end -end diff --git a/activejob/lib/active_job/serializers/object_serializer.rb b/activejob/lib/active_job/serializers/object_serializer.rb index 940b6ff95d8c0..9f59e8236f4cb 100644 --- a/activejob/lib/active_job/serializers/object_serializer.rb +++ b/activejob/lib/active_job/serializers/object_serializer.rb @@ -21,14 +21,34 @@ module Serializers # Money # end # end - class ObjectSerializer < BaseSerializer + class ObjectSerializer + include Singleton + + class << self + delegate :serialize?, :serialize, :deserialize, to: :instance + end + + # Determines if an argument should be serialized by a serializer. + def serialize?(argument) + argument.is_a?(klass) + end + + # Serializes an argument to a JSON primitive type. def serialize(hash) - { OBJECT_SERIALIZER_KEY => self.class.name }.merge!(hash) + { Arguments::OBJECT_SERIALIZER_KEY => self.class.name }.merge!(hash) end - def deserialize?(argument) - argument.is_a?(Hash) && argument[OBJECT_SERIALIZER_KEY] == self.class.name + # Deserilizes an argument form a JSON primiteve type. + def deserialize(_argument) + raise NotImplementedError end + + protected + + # The class of the object that will be serialized. + def klass + raise NotImplementedError + end end end end diff --git a/activejob/lib/active_job/serializers/standard_type_serializer.rb b/activejob/lib/active_job/serializers/standard_type_serializer.rb deleted file mode 100644 index 1db4f3937d882..0000000000000 --- a/activejob/lib/active_job/serializers/standard_type_serializer.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -module ActiveJob - module Serializers - # Provides methods to serialize and deserialize standard types - # (`NilClass`, `String`, `Integer`, `Fixnum`, `Bignum`, `Float`, `BigDecimal`, `TrueClass`, `FalseClass`) - class StandardTypeSerializer < BaseSerializer # :nodoc: - def serialize?(argument) - Arguments::TYPE_WHITELIST.include? argument.class - end - - def serialize(argument) - argument - end - - alias_method :deserialize?, :serialize? - - def deserialize(argument) - object = GlobalID::Locator.locate(argument) if argument.is_a? String - object || argument - end - end - end -end diff --git a/activejob/test/cases/argument_serialization_test.rb b/activejob/test/cases/argument_serialization_test.rb index 8f51a2d238dd4..4e26b9a178edf 100644 --- a/activejob/test/cases/argument_serialization_test.rb +++ b/activejob/test/cases/argument_serialization_test.rb @@ -14,7 +14,7 @@ class ArgumentSerializationTest < ActiveSupport::TestCase [ nil, 1, 1.0, 1_000_000_000_000_000_000_000, "a", true, false, BigDecimal.new(5), :a, 1.day, Date.new(2001, 2, 3), Time.new(2002, 10, 31, 2, 2, 2, "+02:00"), - DateTime.new(2001, 2, 3, 4, 5, 6, '+03:00'), + DateTime.new(2001, 2, 3, 4, 5, 6, "+03:00"), ActiveSupport::TimeWithZone.new(Time.utc(1999, 12, 31, 23, 59, 59), ActiveSupport::TimeZone["UTC"]), [ 1, "a" ], { "a" => 1 } diff --git a/activejob/test/cases/serializers_test.rb b/activejob/test/cases/serializers_test.rb index 207ae55b4902a..a86f168d037b2 100644 --- a/activejob/test/cases/serializers_test.rb +++ b/activejob/test/cases/serializers_test.rb @@ -58,7 +58,24 @@ def klass test "won't deserialize unknown hash" do hash = { "_dummy_serializer" => 123, "_aj_symbol_keys" => [] } - assert_equal({ "_dummy_serializer" => 123 }, ActiveJob::Serializers.deserialize(hash)) + error = assert_raises(ArgumentError) do + ActiveJob::Serializers.deserialize(hash) + end + assert_equal( + 'Serializer name is not present in the argument: {"_dummy_serializer"=>123, "_aj_symbol_keys"=>[]}', + error.message + ) + end + + test "won't deserialize unknown serializer" do + hash = { "_aj_serialized" => "DoNotExist", "value" => 123 } + error = assert_raises(ArgumentError) do + ActiveJob::Serializers.deserialize(hash) + end + assert_equal( + "Serializer DoNotExist is not know", + error.message + ) end test "will deserialize know serialized objects" do @@ -74,7 +91,7 @@ def klass test "can't add serializer with the same key twice" do ActiveJob::Serializers.add_serializers DummySerializer - assert_no_difference(-> { ActiveJob::Serializers.serializers.size } ) do + assert_no_difference(-> { ActiveJob::Serializers.serializers.size }) do ActiveJob::Serializers.add_serializers DummySerializer end end diff --git a/guides/source/active_job_basics.md b/guides/source/active_job_basics.md index 0ee522e23dd44..92a04c585fa15 100644 --- a/guides/source/active_job_basics.md +++ b/guides/source/active_job_basics.md @@ -404,12 +404,6 @@ class MoneySerializer < ActiveJob::Serializers::ObjectSerializer ) end - # Check if this serialized value be deserialized using this serializer. - # ActiveJob::Serializers::ObjectSerializer#deserialize? already take care of this. - def deserialize?(argument) - super - end - # Convert serialized value into a proper object def deserialize(hash) Money.new hash["cents"], hash["currency"] From b59c7c7e69144bafd6d45f1be68f885e8995b6f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Mon, 12 Feb 2018 22:36:51 -0500 Subject: [PATCH 14/15] Add tests to serialize and deserialze individually This will make easier to be backwards compatible when changing the serialization implementation. --- .../test/cases/argument_serialization_test.rb | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/activejob/test/cases/argument_serialization_test.rb b/activejob/test/cases/argument_serialization_test.rb index 4e26b9a178edf..ff6ac6fc4325e 100644 --- a/activejob/test/cases/argument_serialization_test.rb +++ b/activejob/test/cases/argument_serialization_test.rb @@ -49,6 +49,49 @@ class ArgumentSerializationTest < ActiveSupport::TestCase assert_arguments_roundtrip([a: 1, "b" => 2]) end + test "serialize a hash" do + symbol_key = { a: 1 } + string_key = { "a" => 1 } + indifferent_access = { a: 1 }.with_indifferent_access + + assert_equal( + { "a" => 1, "_aj_symbol_keys" => ["a"] }, + ActiveJob::Arguments.serialize([symbol_key]).first + ) + assert_equal( + { "a" => 1, "_aj_symbol_keys" => [] }, + ActiveJob::Arguments.serialize([string_key]).first + ) + assert_equal( + { "a" => 1, "_aj_hash_with_indifferent_access" => true }, + ActiveJob::Arguments.serialize([indifferent_access]).first + ) + end + + test "deserialize a hash" do + symbol_key = { "a" => 1, "_aj_symbol_keys" => ["a"] } + string_key = { "a" => 1, "_aj_symbol_keys" => [] } + another_string_key = { "a" => 1 } + indifferent_access = { "a" => 1, "_aj_hash_with_indifferent_access" => true } + + assert_equal( + { a: 1 }, + ActiveJob::Arguments.deserialize([symbol_key]).first + ) + assert_equal( + { "a" => 1 }, + ActiveJob::Arguments.deserialize([string_key]).first + ) + assert_equal( + { "a" => 1 }, + ActiveJob::Arguments.deserialize([another_string_key]).first + ) + assert_equal( + { "a" => 1 }, + ActiveJob::Arguments.deserialize([indifferent_access]).first + ) + end + test "should maintain hash with indifferent access" do symbol_key = { a: 1 } string_key = { "a" => 1 } From 25a14bf2bde90224debee343ebfbb882c02b9588 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Wed, 14 Feb 2018 13:13:51 -0500 Subject: [PATCH 15/15] Add CHANGELOG entry --- activejob/CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/activejob/CHANGELOG.md b/activejob/CHANGELOG.md index ec99b5d975bad..e4768eb3d4caf 100644 --- a/activejob/CHANGELOG.md +++ b/activejob/CHANGELOG.md @@ -1,3 +1,6 @@ +* Add support to define custom argument serializers. + + *Evgenii Pecherkin*, *Rafael Mendonça França* Please check [5-2-stable](https://github.com/rails/rails/blob/5-2-stable/activejob/CHANGELOG.md) for previous changes.