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