Skip to content

Commit c5a9ed5

Browse files
committed
Integrate with Active Record's .serialize
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
1 parent 6dcd314 commit c5a9ed5

File tree

5 files changed

+368
-1
lines changed

5 files changed

+368
-1
lines changed

lib/active_resource.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,14 @@ module ActiveResource
3737

3838
autoload :Base
3939
autoload :Callbacks
40+
autoload :Coder
4041
autoload :Connection
4142
autoload :CustomMethods
4243
autoload :Formats
4344
autoload :HttpMock
4445
autoload :Rescuable
4546
autoload :Schema
47+
autoload :Serialization
4648
autoload :Singleton
4749
autoload :InheritingHash
4850
autoload :Validations

lib/active_resource/base.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1751,7 +1751,7 @@ class Base
17511751
extend ActiveModel::Naming
17521752
extend ActiveResource::Associations
17531753

1754-
include Callbacks, CustomMethods, Validations
1754+
include Callbacks, CustomMethods, Validations, Serialization
17551755
include ActiveModel::Conversion
17561756
include ActiveModel::ForbiddenAttributesProtection
17571757
include ActiveModel::Serializers::JSON

lib/active_resource/coder.rb

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# frozen_string_literal: true
2+
3+
module ActiveResource
4+
# Integrates with Active Record's
5+
# {serialize}[link:https://api.rubyonrails.org/classes/ActiveRecord/AttributeMethods/Serialization/ClassMethods.html#method-i-serialize]
6+
# method as the <tt>:coder</tt> option.
7+
#
8+
# Encodes Active Resource instances into a value to be stored in the
9+
# database. Decodes values read from the database into Active Resource
10+
# instances.
11+
#
12+
# class User < ActiveRecord::Base
13+
# serialize :person, coder: ActiveResource::Coder.new(Person)
14+
# end
15+
#
16+
# class Person < ActiveResource::Base
17+
# schema do
18+
# attribute :name, :string
19+
# end
20+
# end
21+
#
22+
# user = User.new
23+
# user.person = Person.new name: "Matz"
24+
# user.person.name # => "Matz"
25+
#
26+
# Values are loaded as persisted when decoded from data containing a
27+
# primary key value, and new records when missing a primary key value:
28+
#
29+
# user.person = Person.new
30+
# user.person.persisted? # => true
31+
#
32+
# user.person = Person.find(1)
33+
# user.person.persisted? # => true
34+
#
35+
# By default, <tt>#dump</tt> serializes the instance to a string value by
36+
# calling Base#encode:
37+
#
38+
# user.person_before_type_cast # => "{\"name\":\"Matz\"}"
39+
#
40+
# To customize serialization, pass the method name or a block as the second
41+
# argument:
42+
#
43+
# person = Person.new name: "Matz"
44+
#
45+
# coder = ActiveResource::Coder.new(Person, :serializable_hash)
46+
# coder.dump(person) # => { "name" => "Matz" }
47+
#
48+
# coder = ActiveResource::Coder.new(Person) { |person| person.serializable_hash }
49+
# coder.dump(person) # => { "name" => "Matz" }
50+
class Coder
51+
attr_accessor :resource_class, :encoder
52+
53+
def initialize(resource_class, encoder_method = :encode, &block)
54+
@resource_class = resource_class
55+
@encoder = block || encoder_method
56+
end
57+
58+
# Serializes a resource value to a value that will be stored in the database.
59+
# Returns nil when passed nil
60+
def dump(value)
61+
return if value.nil?
62+
raise ArgumentError.new("expected value to be #{resource_class}, but was #{value.class}") unless value.is_a?(resource_class)
63+
64+
value.yield_self(&encoder)
65+
end
66+
67+
# Deserializes a value from the database to a resource instance.
68+
# Returns nil when passed nil
69+
def load(value)
70+
return if value.nil?
71+
value = resource_class.format.decode(value) if value.is_a?(String)
72+
raise ArgumentError.new("expected value to be Hash, but was #{value.class}") unless value.is_a?(Hash)
73+
resource_class.new(value, value[resource_class.primary_key])
74+
end
75+
end
76+
end

lib/active_resource/serialization.rb

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# frozen_string_literal: true
2+
3+
module ActiveResource
4+
# Compatibilitiy with Active Record's
5+
# {serialize}[link:https://api.rubyonrails.org/classes/ActiveRecord/AttributeMethods/Serialization/ClassMethods.html#method-i-serialize]
6+
# method as the <tt>:coder</tt> option.
7+
#
8+
# === Writing to String columns
9+
#
10+
# Encodes Active Resource instances into a string to be stored in the
11+
# database. Decodes strings read from the database into Active Resource
12+
# instances.
13+
#
14+
# class User < ActiveRecord::Base
15+
# serialize :person, coder: Person
16+
# end
17+
#
18+
# class Person < ActiveResource::Base
19+
# schema do
20+
# attribute :name, :string
21+
# end
22+
# end
23+
#
24+
# user = User.new
25+
# user.person = Person.new name: "Matz"
26+
#
27+
# Writing string values incorporates the Base.format:
28+
#
29+
# Person.format = :json
30+
#
31+
# user.person = Person.new name: "Matz"
32+
# user.person_before_type_cast # => "{\"name\":\"Matz\"}"
33+
#
34+
# Person.format = :xml
35+
#
36+
# user.person = Person.new name: "Matz"
37+
# user.person_before_type_cast # => "<?xml version=\"1.0\" encoding=\"UTF-8\"?><person><name>Matz</name></person>"
38+
#
39+
# Instances are loaded as persisted when decoded from data containing a
40+
# primary key value, and new records when missing a primary key value:
41+
#
42+
# user.person = Person.new
43+
# user.person.persisted? # => false
44+
#
45+
# user.person = Person.find(1)
46+
# user.person.persisted? # => true
47+
#
48+
# === Writing to JSON and JSONB columns
49+
#
50+
# class User < ActiveRecord::Base
51+
# serialize :person, coder: ActiveResource::Coder.new(Person, :serializable_hash)
52+
# end
53+
#
54+
# class Person < ActiveResource::Base
55+
# schema do
56+
# attribute :name, :string
57+
# end
58+
# end
59+
#
60+
# user = User.new
61+
# user.person = Person.new name: "Matz"
62+
# user.person.name # => "Matz"
63+
#
64+
# user.person_before_type_cast # => {"name"=>"Matz"}
65+
module Serialization
66+
extend ActiveSupport::Concern
67+
68+
included do
69+
class_attribute :coder, instance_accessor: false, instance_predicate: false
70+
end
71+
72+
module ClassMethods
73+
delegate :dump, :load, to: :coder
74+
75+
def inherited(subclass) # :nodoc:
76+
super
77+
subclass.coder = Coder.new(subclass)
78+
end
79+
end
80+
end
81+
end

test/cases/base/serialization_test.rb

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
# frozen_string_literal: true
2+
3+
require "abstract_unit"
4+
require "fixtures/person"
5+
6+
class SerializationTest < ActiveSupport::TestCase
7+
test ".load delegates to the .coder" do
8+
resource = Person.new(id: 1, name: "Matz")
9+
10+
encoded = Person.load(resource.encode)
11+
12+
assert_equal resource.id, encoded.id
13+
assert_equal resource.name, encoded.name
14+
assert_equal resource.attributes, encoded.attributes
15+
end
16+
17+
test ".load decodes JSON" do
18+
previous_value, Person.format = Person.format, :json
19+
20+
resource = Person.new(id: 1, name: "Matz")
21+
json = resource.to_json
22+
23+
assert_equal resource, Person.load(json)
24+
ensure
25+
Person.format = previous_value
26+
end
27+
28+
test ".load decodes XML" do
29+
previous_value, Person.format = Person.format, :xml
30+
31+
resource = Person.new(id: 1, name: "Matz")
32+
xml = resource.to_xml
33+
34+
assert_equal resource, Person.load(xml)
35+
ensure
36+
Person.format = previous_value
37+
end
38+
39+
test ".dump delegates to the default .coder" do
40+
resource = Person.new(id: 1, name: "Matz")
41+
42+
encoded = Person.dump(resource)
43+
44+
assert_equal resource.encode, encoded
45+
assert_equal({ person: { id: 1, name: "Matz" } }.to_json, encoded)
46+
end
47+
48+
test ".dump delegates to a configured .coder method name" do
49+
previous_value, Person.coder = Person.coder, ActiveResource::Coder.new(Person, :serializable_hash)
50+
51+
resource = Person.new(id: 1, name: "Matz")
52+
53+
assert_equal resource.serializable_hash, Person.dump(resource)
54+
ensure
55+
Person.coder = previous_value
56+
end
57+
58+
test ".dump delegates to a configured .coder callable" do
59+
previous_value, Person.coder = Person.coder, ActiveResource::Coder.new(Person) { |value| value.serializable_hash }
60+
61+
resource = Person.new(id: 1, name: "Matz")
62+
63+
assert_equal resource.serializable_hash, Person.dump(resource)
64+
ensure
65+
Person.coder = previous_value
66+
end
67+
68+
test ".dump encodes JSON" do
69+
previous_value, Person.format = Person.format, :json
70+
71+
resource = Person.new(id: 1, name: "Matz")
72+
73+
assert_equal resource.to_json, Person.dump(resource)
74+
ensure
75+
Person.format = previous_value
76+
end
77+
78+
test ".dump encodes XML" do
79+
previous_value, Person.format = Person.format, :xml
80+
81+
resource = Person.new(id: 1, name: "Matz")
82+
83+
assert_equal resource.to_xml, Person.dump(resource)
84+
ensure
85+
Person.format = previous_value
86+
end
87+
88+
test "#load returns nil when the encoded value is nil" do
89+
assert_nil Person.coder.load(nil)
90+
end
91+
92+
test "#load decodes a String into an instance" do
93+
resource = Person.new(id: 1, name: "Matz")
94+
95+
decoded = Person.coder.load(resource.encode)
96+
97+
assert_equal resource, decoded
98+
end
99+
100+
test "#load decodes a Hash into an instance" do
101+
resource = Person.new(id: 1, name: "Matz")
102+
103+
decoded = Person.coder.load(resource.serializable_hash)
104+
105+
assert_equal resource.id, decoded.id
106+
assert_equal resource.name, decoded.name
107+
assert_equal resource.attributes, decoded.attributes
108+
end
109+
110+
test "#load builds the instance as persisted when the default primary key is present" do
111+
resource = Person.new(id: 1, name: "Matz")
112+
113+
decoded = Person.coder.load(resource.encode)
114+
115+
assert_predicate decoded, :persisted?
116+
assert_not_predicate decoded, :new_record?
117+
end
118+
119+
test "#load builds the instance as persisted when the configured primary key is present" do
120+
previous_value, Person.primary_key = Person.primary_key, "pk"
121+
resource = Person.new(pk: 1, name: "Matz")
122+
123+
decoded = Person.coder.load(resource.encode)
124+
125+
assert_equal 1, decoded.id
126+
assert_predicate decoded, :persisted?
127+
assert_not_predicate decoded, :new_record?
128+
ensure
129+
Person.primary_key = previous_value
130+
end
131+
132+
test "#load builds the instance as a new record when the default primary key is absent" do
133+
resource = Person.new(name: "Matz")
134+
135+
decoded = Person.coder.load(resource.encode)
136+
137+
assert_nil decoded.id
138+
assert_not_predicate decoded, :persisted?
139+
assert_predicate decoded, :new_record?
140+
end
141+
142+
test "#load builds the instance as a new record when the configured primary key is absent" do
143+
previous_value, Person.primary_key = Person.primary_key, "pk"
144+
145+
resource = Person.new(name: "Matz")
146+
147+
decoded = Person.coder.load(resource.encode)
148+
149+
assert_nil decoded.id
150+
assert_not_predicate decoded, :persisted?
151+
assert_predicate decoded, :new_record?
152+
ensure
153+
Person.primary_key = previous_value
154+
end
155+
156+
test "#load raises an ArgumentError when passed anything but a String or Hash" do
157+
resource = Person.new(name: "Matz")
158+
string_value = resource.encode
159+
hash_value = resource.attributes
160+
161+
assert_equal resource, Person.coder.load(string_value)
162+
assert_equal resource, Person.coder.load(hash_value)
163+
assert_raises(ArgumentError, match: "expected value to be Hash, but was Integer") { Person.coder.load(1) }
164+
end
165+
166+
test "#dump encodes resources" do
167+
resource = Person.new(id: 1, name: "Matz")
168+
169+
encoded = Person.coder.dump(resource)
170+
171+
assert_equal resource.encode, encoded
172+
assert_equal({ person: { id: 1, name: "Matz" } }.to_json, encoded)
173+
end
174+
175+
test "#dump raises an ArgumentError is passed anything but an ActiveResource::Base" do
176+
assert_raises ArgumentError, match: "expected value to be Person, but was Integer" do
177+
Person.coder.dump(1)
178+
end
179+
end
180+
181+
test "#dump returns nil when the resource is nil" do
182+
assert_nil Person.coder.dump(nil)
183+
end
184+
185+
test "#dump with an encoder method name returns nil when the resource is nil" do
186+
coder = ActiveResource::Coder.new(Person, :serializable_hash)
187+
188+
assert_nil coder.dump(nil)
189+
end
190+
191+
test "#dump with an encoder method name encodes resources" do
192+
coder = ActiveResource::Coder.new(Person, :serializable_hash)
193+
resource = Person.new(id: 1, name: "Matz")
194+
195+
encoded = coder.dump(resource)
196+
197+
assert_equal resource.serializable_hash, encoded
198+
end
199+
200+
test "#dump with an encoder block encodes resources" do
201+
coder = ActiveResource::Coder.new(Person) { |value| value.serializable_hash }
202+
resource = Person.new(id: 1, name: "Matz")
203+
204+
encoded = coder.dump(resource)
205+
206+
assert_equal resource.serializable_hash, encoded
207+
end
208+
end

0 commit comments

Comments
 (0)