diff --git a/lib/active_resource.rb b/lib/active_resource.rb index 8e4f34ecf4..be59253ce8 100644 --- a/lib/active_resource.rb +++ b/lib/active_resource.rb @@ -37,11 +37,13 @@ module ActiveResource autoload :Base autoload :Callbacks + autoload :Coder autoload :Connection autoload :CustomMethods autoload :Formats autoload :HttpMock autoload :Schema + autoload :Serialization autoload :Singleton autoload :InheritingHash autoload :Validations diff --git a/lib/active_resource/base.rb b/lib/active_resource/base.rb index 29376f081d..688f2bd6af 100644 --- a/lib/active_resource/base.rb +++ b/lib/active_resource/base.rb @@ -1721,7 +1721,7 @@ class Base extend ActiveModel::Naming extend ActiveResource::Associations - include Callbacks, CustomMethods, Validations + include Callbacks, CustomMethods, Validations, Serialization include ActiveModel::Conversion include ActiveModel::Serializers::JSON include ActiveModel::Serializers::Xml diff --git a/lib/active_resource/coder.rb b/lib/active_resource/coder.rb new file mode 100644 index 0000000000..565f506abd --- /dev/null +++ b/lib/active_resource/coder.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +module ActiveResource + # Integrates with Active Record's + # {serialize}[link:https://api.rubyonrails.org/classes/ActiveRecord/AttributeMethods/Serialization/ClassMethods.html#method-i-serialize] + # method as the :coder option. + # + # Encodes Active Resource instances into a value to be stored in the + # database. Decodes values read from the database into Active Resource + # instances. + # + # class User < ActiveRecord::Base + # serialize :person, coder: ActiveResource::Coder.new(Person) + # end + # + # class Person < ActiveResource::Base + # schema do + # attribute :name, :string + # end + # end + # + # user = User.new + # user.person = Person.new name: "Matz" + # user.person.name # => "Matz" + # + # Values are loaded as persisted when decoded from data containing a + # primary key value, and new records when missing a primary key value: + # + # user.person = Person.new + # user.person.persisted? # => true + # + # user.person = Person.find(1) + # user.person.persisted? # => true + # + # By default, #dump serializes the instance to a string value by + # calling Base#encode: + # + # user.person_before_type_cast # => "{\"name\":\"Matz\"}" + # + # To customize serialization, pass the method name or a block as the second + # argument: + # + # person = Person.new name: "Matz" + # + # coder = ActiveResource::Coder.new(Person, :serializable_hash) + # coder.dump(person) # => {"name"=>"Matz"} + # + # coder = ActiveResource::Coder.new(Person) { |person| person.serializable_hash } + # coder.dump(person) # => {"name"=>"Matz"} + class Coder + attr_accessor :resource_class, :encoder + + def initialize(resource_class, encoder_method = :encode, &block) + @resource_class = resource_class + @encoder = block || encoder_method + end + + # Serializes a resource value to a value that will be stored in the database. + # Returns nil when passed nil + def dump(value) + return if value.nil? + raise ArgumentError, "expected value to be #{resource_class}, but was #{value.class}" unless value.is_a?(resource_class) + + value.yield_self(&encoder) + end + + # Deserializes a value from the database to a resource instance. + # Returns nil when passed nil + def load(value) + return if value.nil? + + if value.is_a?(String) + load(resource_class.format.decode(value)) + else + persisted = value[resource_class.primary_key.to_s] + resource_class.new(value, persisted) + end + end + end +end diff --git a/lib/active_resource/serialization.rb b/lib/active_resource/serialization.rb new file mode 100644 index 0000000000..0f2dd46353 --- /dev/null +++ b/lib/active_resource/serialization.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +module ActiveResource + # Compatibilitiy with Active Record's + # {serialize}[link:https://api.rubyonrails.org/classes/ActiveRecord/AttributeMethods/Serialization/ClassMethods.html#method-i-serialize] + # method as the :coder option. + # + # === Writing to String columns + # + # Encodes Active Resource instances into a string to be stored in the + # database. Decodes strings read from the database into Active Resource + # instances. + # + # class User < ActiveRecord::Base + # serialize :person, coder: Person + # end + # + # class Person < ActiveResource::Base + # schema do + # attribute :name, :string + # end + # end + # + # user = User.new + # user.person = Person.new name: "Matz" + # + # Writing string values incorporates the Base.format: + # + # Person.format = :json + # + # user.person = Person.new name: "Matz" + # user.person_before_type_cast # => "{\"name\":\"Matz\"}" + # + # Person.format = :xml + # + # user.person = Person.new name: "Matz" + # user.person_before_type_cast # => "Matz" + # + # Instances are loaded as persisted when decoded from data containing a + # primary key value, and new records when missing a primary key value: + # + # user.person = Person.new + # user.person.persisted? # => false + # + # user.person = Person.find(1) + # user.person.persisted? # => true + # + # === Writing to JSON and JSONB columns + # + # class User < ActiveRecord::Base + # serialize :person, coder: ActiveResource::Coder.new(Person, :serializable_hash) + # end + # + # class Person < ActiveResource::Base + # schema do + # attribute :name, :string + # end + # end + # + # user = User.new + # user.person = Person.new name: "Matz" + # user.person.name # => "Matz" + # + # user.person_before_type_cast # => {"name"=>"Matz"} + module Serialization + extend ActiveSupport::Concern + + included do + class_attribute :coder, instance_accessor: false, instance_predicate: false + end + + module ClassMethods + delegate :dump, :load, to: :coder + + def inherited(subclass) # :nodoc: + super + subclass.coder = Coder.new(subclass) + end + end + end +end diff --git a/test/cases/base/serialization_test.rb b/test/cases/base/serialization_test.rb new file mode 100644 index 0000000000..126712bb99 --- /dev/null +++ b/test/cases/base/serialization_test.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +require "abstract_unit" +require "fixtures/person" + +class SerializationTest < ActiveSupport::TestCase + setup do + @matz = { id: 1, name: "Matz" } + end + + test ".load delegates to the .coder" do + resource = Person.new(@matz) + + encoded = Person.load(resource.encode) + + assert_equal resource, encoded + end + + test ".dump delegates to the default .coder" do + resource = Person.new(@matz) + + encoded = Person.dump(resource) + + assert_equal resource.encode, encoded + end + + test ".dump delegates to a configured .coder method name" do + Person.coder = ActiveResource::Coder.new(Person, :serializable_hash) + resource = Person.new(@matz) + + encoded = Person.dump(resource) + + assert_equal resource.serializable_hash, encoded + ensure + Person.coder = ActiveResource::Coder.new(Person) + end + + test ".dump delegates to a configured .coder callable" do + Person.coder = ActiveResource::Coder.new(Person) { |value| value.serializable_hash } + resource = Person.new(@matz) + + encoded = Person.dump(resource) + + assert_equal resource.serializable_hash, encoded + ensure + Person.coder = ActiveResource::Coder.new(Person) + end + + test "#load returns nil when the encoded value is nil" do + assert_nil Person.coder.load(nil) + end + + test "#load decodes a String into an instance" do + resource = Person.new(@matz) + + decoded = Person.coder.load(resource.encode) + + assert_equal resource, decoded + end + + test "#load decodes a Hash into an instance" do + resource = Person.new(@matz) + + decoded = Person.coder.load(resource.serializable_hash) + + assert_equal resource, decoded + end + + test "#load builds the instance as persisted when the default primary key is present" do + resource = Person.new(@matz) + + decoded = Person.coder.load(resource.encode) + + assert_predicate decoded, :persisted? + assert_not_predicate decoded, :new_record? + end + + test "#load builds the instance as persisted when the configured primary key is present" do + Person.primary_key = "pk" + resource = Person.new(@matz.merge!(pk: @matz.delete(:id))) + + decoded = Person.coder.load(resource.encode) + + assert_predicate decoded, :persisted? + assert_not_predicate decoded, :new_record? + ensure + Person.primary_key = "id" + end + + test "#load builds the instance as a new record when the default primary key is absent" do + resource = Person.new(@matz) + resource.id = nil + + decoded = Person.coder.load(resource.encode) + + assert_not_predicate decoded, :persisted? + assert_predicate decoded, :new_record? + end + + test "#load builds the instance as a new record when the configured primary key is absent" do + Person.primary_key = "pk" + resource = Person.new(@matz) + resource.id = nil + + decoded = Person.coder.load(resource.encode) + + assert_not_predicate decoded, :persisted? + assert_predicate decoded, :new_record? + + Person.primary_key = "id" + end + + test "#dump encodes resources" do + resource = Person.new(@matz) + + encoded = Person.coder.dump(resource) + + assert_equal resource.encode, encoded + end + + test "#dump raises an ArgumentError is passed anything but an ActiveResource::Base" do + assert_raises ArgumentError, match: "expected value to be Person, but was Integer" do + Person.coder.dump(1) + end + end + + test "#dump returns nil when the resource is nil" do + assert_nil Person.coder.dump(nil) + end + + test "#dump with an encoder method name returns nil when the resource is nil" do + coder = ActiveResource::Coder.new(Person, :serializable_hash) + + assert_nil coder.dump(nil) + end + + test "#dump with an encoder method name encodes resources" do + coder = ActiveResource::Coder.new(Person, :serializable_hash) + resource = Person.new(@matz) + + encoded = coder.dump(resource) + + assert_equal resource.serializable_hash, encoded + end + + test "#dump with an encoder block encodes resources" do + coder = ActiveResource::Coder.new(Person) { |value| value.serializable_hash } + resource = Person.new(@matz) + + encoded = coder.dump(resource) + + assert_equal resource.serializable_hash, encoded + end +end