Skip to content

Commit

Permalink
Integrate with Active Record's .serialize
Browse files Browse the repository at this point in the history
Define `ActiveResource::Base.dump` and `ActiveResource::Base.load` to
support passing classes directly to [serialize][] 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.

```ruby
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"
user.person_before_type_cast # => "{\"name\":\"Matz\"}"
```

Writing string values incorporates the Base.format:

```ruby
Person.format = :xml

user.person = Person.new name: "Matz"
user.person_before_type_cast # => "<?xml version=\"1.0\" encoding=\"UTF-8\"?><person><name>Matz</name></person>"
```

Instances are loaded as persisted when decoded from data containing a
primary key value, and new records when missing a primary key value:

```ruby
user.person = Person.new
user.person.persisted? # => false

user.person = Person.find(1)
user.person.persisted? # => true
```

Writing to JSON and JSONB columns
---

```ruby
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_before_type_cast # => {"name"=>"Matz"}

user.person.name # => "Matz"
```

The `ActiveResource::Coder` class
===

By default, `#dump` serializes the instance to a string value by
calling `ActiveResource::Base#encode`:

```ruby
user.person_before_type_cast # => "{\"name\":\"Matz\"}"
```

To customize serialization, pass the method name or a block as the second
argument:

```ruby
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"}
```

[serialize]: https://api.rubyonrails.org/classes/ActiveRecord/AttributeMethods/Serialization/ClassMethods.html#method-i-serialize
  • Loading branch information
seanpdoyle committed Jan 26, 2025
1 parent 9c8a2ee commit 5f795d3
Show file tree
Hide file tree
Showing 5 changed files with 317 additions and 1 deletion.
2 changes: 2 additions & 0 deletions lib/active_resource.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/active_resource/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
80 changes: 80 additions & 0 deletions lib/active_resource/coder.rb
Original file line number Diff line number Diff line change
@@ -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 <tt>:coder</tt> 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, <tt>#dump</tt> 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
81 changes: 81 additions & 0 deletions lib/active_resource/serialization.rb
Original file line number Diff line number Diff line change
@@ -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 <tt>:coder</tt> 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 # => "<?xml version=\"1.0\" encoding=\"UTF-8\"?><person><name>Matz</name></person>"
#
# 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
153 changes: 153 additions & 0 deletions test/cases/base/serialization_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# frozen_string_literal: true

require "abstract_unit"
require "fixtures/person"
require "active_support/core_ext/object/with"

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.with coder: ActiveResource::Coder.new(Person, :serializable_hash) do
resource = Person.new(@matz)

encoded = Person.dump(resource)

assert_equal resource.serializable_hash, encoded
end
end

test ".dump delegates to a configured .coder callable" do
Person.with coder: ActiveResource::Coder.new(Person) { |value| value.serializable_hash } do
resource = Person.new(@matz)

encoded = Person.dump(resource)

assert_equal resource.serializable_hash, encoded
end
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

0 comments on commit 5f795d3

Please sign in to comment.