diff --git a/lib/active_resource/base.rb b/lib/active_resource/base.rb
index a53b6befee..e55fd77017 100644
--- a/lib/active_resource/base.rb
+++ b/lib/active_resource/base.rb
@@ -1162,6 +1162,14 @@ def exists?(id, options = {})
false
end
+ def instantiate_collection(collection, original_params = {}, prefix_options = {}) # :nodoc:
+ collection_parser.new(collection).tap do |parser|
+ parser.resource_class = self
+ parser.original_params = original_params
+ parser.original_parsed = collection
+ end.collect! { |record| instantiate_record(record, prefix_options) }
+ end
+
private
def check_prefix_options(prefix_options)
p_options = HashWithIndifferentAccess.new(prefix_options)
@@ -1212,13 +1220,6 @@ def find_single(scope, options)
instantiate_record(format.decode(connection.get(path, headers).body), prefix_options)
end
- def instantiate_collection(collection, original_params = {}, prefix_options = {})
- collection_parser.new(collection).tap do |parser|
- parser.resource_class = self
- parser.original_params = original_params
- end.collect! { |record| instantiate_record(record, prefix_options) }
- end
-
def instantiate_record(record, prefix_options = {})
new(record, true).tap do |resource|
resource.prefix_options = prefix_options
diff --git a/lib/active_resource/coder.rb b/lib/active_resource/coder.rb
index 8cc8b2f224..9272a9191a 100644
--- a/lib/active_resource/coder.rb
+++ b/lib/active_resource/coder.rb
@@ -47,23 +47,56 @@ module ActiveResource
#
# coder = ActiveResource::Coder.new(Person) { |person| person.serializable_hash }
# coder.dump(person) # => { "name" => "Matz" }
+ #
+ # === Collections
+ #
+ # To encode ActiveResource::Collection instances, construct an instance with +collection:
+ # true+.
+ #
+ # class Team < ActiveRecord::Base
+ # serialize :people, coder: ActiveResource::Coder.new(Person, collection: true)
+ # end
+ #
+ # team = Team.new
+ # team.people = Person.all
+ # team.people.map(&:attributes) # => [{ "id" => 1, "name" => "Matz" }]
+ #
+ # By default, #dump serializes the instance to a string value by
+ # calling Collection#encode:
+ #
+ # team.people_before_type_cast # => "[{\"id\":1,\"name\":\"Matz\"}]"
+ #
+ # To customize serialization, pass a block that accepts the collection as the second argument:
+ #
+ # people = Person.all
+ #
+ # coder = ActiveResource::Coder.new(Person) { |collection| collection.original_parsed }
+ # coder.dump(people) # => [{ "id" => 1, "name" => "Matz" }]
class Coder
- attr_accessor :resource_class, :encoder
+ attr_accessor :resource_class, :encoder, :collection
# ==== Arguments
# * resource_class Active Resource class that to be coded
# * encoder_method the method to invoke on the instance to encode
# it. Defaults to ActiveResource::Base#encode.
- def initialize(resource_class, encoder_method = :encode, &block)
+ #
+ # ==== Options
+ #
+ # * :collection - Whether or not the values represent an
+ # ActiveResource::Collection Defaults to false.
+ def initialize(resource_class, encoder_method = :encode, collection: false, &block)
@resource_class = resource_class
@encoder = block || encoder_method
+ @collection = collection
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.new("expected value to be #{resource_class}, but was #{value.class}") unless value.is_a?(resource_class)
+
+ expected_class = collection ? resource_class.collection_parser : resource_class
+ raise ArgumentError.new("expected value to be #{expected_class}, but was #{value.class}") unless value.is_a?(expected_class)
value.yield_self(&encoder)
end
@@ -73,10 +106,15 @@ def dump(value)
def load(value)
return if value.nil?
value = resource_class.format.decode(value) if value.is_a?(String)
- raise ArgumentError.new("expected value to be Hash, but was #{value.class}") unless value.is_a?(Hash)
- value = Formats.remove_root(value) if value.keys.first.to_s == resource_class.element_name
- resource_class.new(value, value[resource_class.primary_key])
+ if collection
+ raise ArgumentError.new("expected value to be Hash or Array, but was #{value.class}") unless value.is_a?(Hash) || value.is_a?(Array)
+ resource_class.instantiate_collection(value)
+ else
+ raise ArgumentError.new("expected value to be Hash, but was #{value.class}") unless value.is_a?(Hash)
+ value = Formats.remove_root(value) if value.keys.first.to_s == resource_class.element_name
+ resource_class.new(value, value[resource_class.primary_key])
+ end
end
end
end
diff --git a/lib/active_resource/collection.rb b/lib/active_resource/collection.rb
index 17d508ce9d..87d4e19346 100644
--- a/lib/active_resource/collection.rb
+++ b/lib/active_resource/collection.rb
@@ -10,7 +10,7 @@ class Collection # :nodoc:
delegate :to_yaml, :all?, *(Array.instance_methods(false) - SELF_DEFINE_METHODS), to: :to_a
# The array of actual elements returned by index actions
- attr_accessor :elements, :resource_class, :original_params
+ attr_accessor :elements, :resource_class, :original_params, :original_parsed
# ActiveResource::Collection is a wrapper to handle parsing index responses that
# do not directly map to Rails conventions.
@@ -90,5 +90,9 @@ def where(clauses = {})
new_clauses = original_params.merge(clauses)
resource_class.where(new_clauses)
end
+
+ def encode
+ resource_class.format.encode(original_parsed)
+ end
end
end
diff --git a/lib/active_resource/serialization.rb b/lib/active_resource/serialization.rb
index e42746c2c0..1b8804db19 100644
--- a/lib/active_resource/serialization.rb
+++ b/lib/active_resource/serialization.rb
@@ -66,11 +66,25 @@ module ActiveResource
# user.person.name # => "Matz"
#
# user.person_before_type_cast # => {"name"=>"Matz"}
+ #
+ # === Collections
+ #
+ # To encode ActiveResource::Collection instances, pass the resource class
+ # +collection_coder+ as the +:coder+ option:
+ #
+ # class Team < ActiveRecord::Base
+ # serialize :people, coder: Person.collection_coder
+ # end
+ #
+ # team = Team.new
+ # team.people = Person.all
+ # team.people.map(&:attributes) # => [{ "id" => 1, "name" => "Matz" }]
module Serialization
extend ActiveSupport::Concern
included do
class_attribute :coder, instance_accessor: false, instance_predicate: false
+ class_attribute :collection_coder, instance_accessor: false, instance_predicate: false
end
module ClassMethods
@@ -79,6 +93,7 @@ module ClassMethods
def inherited(subclass) # :nodoc:
super
subclass.coder = Coder.new(subclass)
+ subclass.collection_coder = Coder.new(subclass, collection: true)
end
end
end
diff --git a/lib/active_resource/where_clause.rb b/lib/active_resource/where_clause.rb
index bd9d3ad5fb..62bc318c7a 100644
--- a/lib/active_resource/where_clause.rb
+++ b/lib/active_resource/where_clause.rb
@@ -2,6 +2,7 @@
module ActiveResource
class WhereClause < BasicObject # :nodoc:
+ delegate :==, to: :resources
delegate_missing_to :resources
def initialize(resource_class, options = {})
diff --git a/test/cases/base/serialization_test.rb b/test/cases/base/serialization_test.rb
index 703669e57f..11c00f69f3 100644
--- a/test/cases/base/serialization_test.rb
+++ b/test/cases/base/serialization_test.rb
@@ -2,6 +2,49 @@
require "abstract_unit"
require "fixtures/person"
+require "fixtures/paginated_collection"
+
+require "active_record"
+
+ENV["DATABASE_URL"] = "sqlite3::memory:"
+
+ActiveRecord::Base.establish_connection
+
+ActiveRecord::Schema.define do
+ create_table :teams, force: true do |t|
+ t.text :people_text
+ t.text :paginated_people_text
+ t.json :people_json
+ t.json :paginated_people_json
+
+ t.check_constraint <<~SQL, name: "people_json_is_object"
+ JSON_TYPE(people_json) = 'array'
+ SQL
+ t.check_constraint <<~SQL, name: "paginated_people_json_is_object"
+ JSON_TYPE(paginated_people_json) = 'object'
+ SQL
+ end
+end
+
+class PaginatedPerson < Person
+ self.collection_parser = "PaginatedCollection"
+end
+
+class Team < ActiveRecord::Base
+ if ActiveSupport::VERSION::MAJOR < 8 && ActiveSupport::VERSION::MINOR < 1
+ serialize :people_text, Person.collection_coder
+ serialize :people_json, ActiveResource::Coder.new(Person, :original_parsed, collection: true)
+
+ serialize :paginated_people_text, PaginatedPerson.collection_coder
+ serialize :paginated_people_json, ActiveResource::Coder.new(PaginatedPerson, :original_parsed, collection: true)
+ else
+ serialize :people_text, coder: Person.collection_coder
+ serialize :people_json, coder: ActiveResource::Coder.new(Person, :original_parsed, collection: true)
+
+ serialize :paginated_people_text, coder: PaginatedPerson.collection_coder
+ serialize :paginated_people_json, coder: ActiveResource::Coder.new(PaginatedPerson, :original_parsed, collection: true)
+ end
+end
require "active_record"
@@ -309,3 +352,221 @@ class SerializationTest < ActiveSupport::TestCase
assert_equal resource.serializable_hash, encoded
end
end
+
+class CollectionSerializationTest < ActiveSupport::TestCase
+ include ActiveRecord::TestFixtures
+
+ def setup
+ @person = { "id" => 1, "title" => "Matz" }
+ @people = [ @person ]
+ @paginated_people = { "results" => [ @person ], "next_page" => "/paginated_people?page=2" }
+
+ ActiveResource::HttpMock.respond_to do |mock|
+ mock.get "/people", { "Accept" => "application/json" }, @people.to_json
+ mock.get "/people", { "Accept" => "application/xml" }, @people.to_xml
+ mock.get "/paginated_people", { "Accept" => "application/json" }, @paginated_people.to_json
+ mock.get "/paginated_people", { "Accept" => "application/xml" }, @paginated_people.to_xml
+ end
+
+ ActiveResource::Base.include_format_in_path = false
+ end
+
+ def teardown
+ ActiveResource::Base.include_format_in_path = true
+ end
+
+ test "dumps ActiveResource::Collection to a text column" do
+ collection = Person.all
+
+ team = Team.create!(people_text: collection)
+
+ assert_equal collection.encode, team.people_text_before_type_cast
+ end
+
+ test "dumps ActiveResource::Collection to a json column" do
+ collection = Person.all
+
+ team = Team.create!(people_json: collection)
+
+ assert_equal collection.encode, team.people_json_before_type_cast
+ end
+
+ test "dumps ActiveResource::Collection subclass to a text column" do
+ collection = PaginatedPerson.all
+
+ team = Team.create!(paginated_people_text: collection)
+
+ assert_equal collection.encode, team.paginated_people_text_before_type_cast
+ assert_equal @paginated_people["next_page"], team.paginated_people_text.next_page
+ end
+
+ test "dumps ActiveResource::Collection subclass to a json column" do
+ collection = PaginatedPerson.all
+
+ team = Team.create!(paginated_people_json: collection)
+
+ assert_equal collection.encode, team.paginated_people_json_before_type_cast
+ assert_equal @paginated_people["next_page"], team.paginated_people_json.next_page
+ end
+
+ test "loads ActiveResource::Collection from a text column" do
+ collection = Person.all
+
+ Team.connection.execute(<<~SQL)
+ INSERT INTO teams(people_text)
+ VALUES ('#{collection.encode}')
+ SQL
+
+ team = Team.sole
+ assert team.people_text.all?(&:persisted?)
+ assert_equal collection, team.people_text
+ assert_equal @people, team.people_text.map(&:attributes)
+ end
+
+ test "loads ActiveResource::Collection from a json column" do
+ collection = Person.all
+
+ Team.connection.execute(<<~SQL)
+ INSERT INTO teams(people_json)
+ VALUES ('#{collection.encode}')
+ SQL
+
+ team = Team.sole
+ assert team.people_json.all?(&:persisted?)
+ assert_equal collection, team.people_json
+ assert_equal @people, team.people_json.map(&:attributes)
+ end
+
+ test "loads ActiveResource::Collection subclass from a text column" do
+ collection = PaginatedPerson.all
+
+ Team.connection.execute(<<~SQL)
+ INSERT INTO teams(paginated_people_text)
+ VALUES ('#{collection.encode}')
+ SQL
+
+ team = Team.sole
+ assert team.paginated_people_text.all?(&:persisted?)
+ assert_equal collection, team.paginated_people_text
+ assert_equal @paginated_people["results"], team.paginated_people_text.map(&:attributes)
+ assert_equal @paginated_people["next_page"], team.paginated_people_text.next_page
+ end
+
+ test "loads ActiveResource::Collection subclass from a json column" do
+ collection = PaginatedPerson.all
+
+ Team.connection.execute(<<~SQL)
+ INSERT INTO teams(paginated_people_json)
+ VALUES ('#{collection.encode}')
+ SQL
+
+ team = Team.sole
+ assert team.paginated_people_json.all?(&:persisted?)
+ assert_equal collection, team.paginated_people_json
+ assert_equal @paginated_people["results"], team.paginated_people_json.map(&:attributes)
+ assert_equal @paginated_people["next_page"], team.paginated_people_json.next_page
+ end
+
+ test "#load decodes an Array collection into an instance" do
+ [ :json, :xml ].each do |format|
+ using_format Person, format do
+ collection = Person.all
+
+ decoded = Person.collection_coder.load(collection.encode)
+
+ assert_equal @people, decoded.original_parsed
+ assert_equal collection.to_a, decoded
+ assert_equal collection.map(&:attributes), decoded.map(&:attributes)
+ end
+ end
+ end
+
+ test "#load decodes a Hash collection into an instance" do
+ [ :json, :xml ].each do |format|
+ using_format PaginatedPerson, format do
+ collection = PaginatedPerson.all
+
+ decoded = PaginatedPerson.collection_coder.load(collection.encode)
+
+ assert_equal @paginated_people, decoded.original_parsed
+ assert_equal collection.to_a, decoded
+ assert_equal collection.map(&:attributes), decoded.map(&:attributes)
+ end
+ end
+ end
+
+ test "#load raises an ArgumentError when passed anything but a String or Hash" do
+ assert_raises(ArgumentError, match: "expected value to be Hash or Array, but was Integer") { Person.collection_coder.load(1) }
+ end
+
+ test "#dump encodes an Array collection into a String" do
+ [ :json, :xml ].each do |format|
+ using_format Person, format do
+ collection = Person.all
+
+ encoded = Person.collection_coder.dump(collection)
+
+ assert_equal collection.encode, encoded
+ assert_equal @people.send("to_#{format}"), encoded
+ end
+ end
+ end
+
+ test "#dump encodes an Array collection with a custom encoder" do
+ [ :json, :xml ].each do |format|
+ using_format Person, format do
+ coder = ActiveResource::Coder.new(Person, collection: true) { |value| value.map(&:serializable_hash) }
+ collection = Person.all
+
+ encoded = coder.dump(collection)
+
+ assert_equal collection.map(&:serializable_hash), encoded
+ end
+ end
+ end
+
+ test "#dump encodes a Hash collection into a String" do
+ [ :json, :xml ].each do |format|
+ using_format PaginatedPerson, format do
+ collection = PaginatedPerson.all
+
+ encoded = PaginatedPerson.collection_coder.dump(collection)
+
+ assert_equal collection.encode, encoded
+ assert_equal @paginated_people.send("to_#{format}"), encoded
+ end
+ end
+ end
+
+ test "#dump encodes a Hash collection with a custom encoder" do
+ [ :json, :xml ].each do |format|
+ using_format Person, format do
+ coder = ActiveResource::Coder.new(Person, :original_parsed, collection: true)
+ collection = Person.all
+
+ encoded = coder.dump(collection)
+
+ assert_equal collection.original_parsed, encoded
+ end
+ end
+ end
+
+ test "#dump raises an ArgumentError is passed anything but an ActiveResource::Base" do
+ assert_raises ArgumentError, match: "expected value to be ActiveResource::Collection, but was Integer" do
+ Person.collection_coder.dump(1)
+ end
+ assert_raises ArgumentError, match: "expected value to be PaginatedCollection, but was Integer" do
+ PaginatedPerson.collection_coder.dump(1)
+ end
+ end
+
+ private
+ def using_format(klass, mime_type_reference)
+ previous_format = klass.format
+ klass.format = mime_type_reference
+
+ yield
+ ensure
+ klass.format = previous_format
+ end
+end
diff --git a/test/cases/collection_test.rb b/test/cases/collection_test.rb
index fcf75d5bec..c172a8c2e6 100644
--- a/test/cases/collection_test.rb
+++ b/test/cases/collection_test.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
require "abstract_unit"
+require "fixtures/paginated_collection"
class CollectionTest < ActiveSupport::TestCase
def setup
@@ -51,14 +52,6 @@ def respond_to_where
end
end
-class PaginatedCollection < ActiveResource::Collection
- attr_accessor :next_page
- def initialize(parsed = {})
- @elements = parsed["results"]
- @next_page = parsed["next_page"]
- end
-end
-
class PaginatedPost < ActiveResource::Base
self.site = "http://37s.sunrise.i:3000"
self.collection_parser = "PaginatedCollection"
diff --git a/test/fixtures/paginated_collection.rb b/test/fixtures/paginated_collection.rb
new file mode 100644
index 0000000000..0fbb3a6f75
--- /dev/null
+++ b/test/fixtures/paginated_collection.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class PaginatedCollection < ActiveResource::Collection
+ attr_accessor :next_page
+ def initialize(parsed = {})
+ @elements = parsed["results"]
+ @next_page = parsed["next_page"]
+ end
+end