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