diff --git a/lib/fast_jsonapi/object_serializer.rb b/lib/fast_jsonapi/object_serializer.rb index 339ca028..a09dc03f 100644 --- a/lib/fast_jsonapi/object_serializer.rb +++ b/lib/fast_jsonapi/object_serializer.rb @@ -177,7 +177,8 @@ def has_many(relationship_name, options = {}) object_method_name: options[:object_method_name] || name, serializer: compute_serializer_name(serializer_key), relationship_type: :has_many, - cached: options[:cached] || false + cached: options[:cached] || false, + polymorphic: fetch_polymorphic_option(options) } add_relationship(name, relationship) end @@ -195,7 +196,8 @@ def belongs_to(relationship_name, options = {}) object_method_name: options[:object_method_name] || name, serializer: compute_serializer_name(serializer_key), relationship_type: :belongs_to, - cached: options[:cached] || true + cached: options[:cached] || true, + polymorphic: fetch_polymorphic_option(options) }) end @@ -212,7 +214,8 @@ def has_one(relationship_name, options = {}) object_method_name: options[:object_method_name] || name, serializer: compute_serializer_name(serializer_key), relationship_type: :has_one, - cached: options[:cached] || false + cached: options[:cached] || false, + polymorphic: fetch_polymorphic_option(options) }) end @@ -222,6 +225,13 @@ def compute_serializer_name(serializer_key) return (namespace + serializer_name).to_sym if namespace.present? (serializer_key.to_s.classify + 'Serializer').to_sym end + + def fetch_polymorphic_option(options) + option = options[:polymorphic] + return false unless option.present? + return option if option.respond_to? :keys + {} + end end end end diff --git a/lib/fast_jsonapi/serialization_core.rb b/lib/fast_jsonapi/serialization_core.rb index 7daea60a..95af4fe1 100644 --- a/lib/fast_jsonapi/serialization_core.rb +++ b/lib/fast_jsonapi/serialization_core.rb @@ -27,6 +27,30 @@ def ids_hash(ids, record_type) id_hash(ids, record_type) # ids variable is just a single id here end + def id_hash_from_record(record, record_types) + # memoize the record type within the record_types dictionary, then assigning to record_type: + record_type = record_types[record.class] ||= record.class.name.underscore.to_sym + { id: record.id.to_s, type: record_type } + end + + def ids_hash_from_record_and_relationship(record, relationship) + polymorphic = relationship[:polymorphic] + + return ids_hash( + record.public_send(relationship[:id_method_name]), + relationship[:record_type] + ) unless polymorphic + + object_method_name = relationship.fetch(:object_method_name, relationship[:name]) + return unless associated_object = record.send(object_method_name) + + return associated_object.map do |object| + id_hash_from_record object, polymorphic + end if associated_object.respond_to? :map + + id_hash_from_record associated_object, polymorphic + end + def attributes_hash(record) attributes_to_serialize.each_with_object({}) do |(key, method_name), attr_hash| attr_hash[key] = record.public_send(method_name) @@ -42,7 +66,7 @@ def relationships_hash(record, relationships = nil) record_type = relationship[:record_type] empty_case = relationship[:relationship_type] == :has_many ? [] : nil hash[name] = { - data: ids_hash(record.public_send(id_method_name), record_type) || empty_case + data: ids_hash_from_record_and_relationship(record, relationship) || empty_case } end end diff --git a/spec/lib/object_serializer_performance_spec.rb b/spec/lib/object_serializer_performance_spec.rb index 19118ca2..03e1ddd8 100644 --- a/spec/lib/object_serializer_performance_spec.rb +++ b/spec/lib/object_serializer_performance_spec.rb @@ -5,6 +5,10 @@ include_context 'ams movie class' include_context 'jsonapi movie class' + include_context 'group class' + include_context 'ams group class' + include_context 'jsonapi group class' + before(:all) { GC.disable } after(:all) { GC.enable } @@ -118,4 +122,28 @@ def run_json_benchmark(message, movie_count, our_serializer, ams_serializer, jso end end end + + context 'when comparing with AMS 0.10.x and with polymorphic has_many' do + [1, 25, 250, 1000].each do |group_count| + speed_factor = 25 + it "should serialize #{group_count} records at least #{speed_factor} times faster than AMS" do + ams_groups = build_ams_groups(group_count) + groups = build_groups(group_count) + options = {} + our_serializer = GroupSerializer.new(groups, options) + ams_serializer = ActiveModelSerializers::SerializableResource.new(ams_groups) + jsonapi_serializer = JSONAPISerializerB.new(jsonapi_groups) + + message = "Serialize to JSON string #{group_count} with polymorphic has_many" + our_json, ams_json, jsonapi_json = run_json_benchmark(message, group_count, our_serializer, ams_serializer, jsonapi_serializer) + + message = "Serialize to Ruby Hash #{group_count} with polymorphic has_many" + run_hash_benchmark(message, group_count, our_serializer, ams_serializer, jsonapi_serializer) + + expect(our_json.length).to eq ams_json.length + expect { our_serializer.serialized_json }.to perform_faster_than { ams_serializer.to_json }.at_least(speed_factor).times + expect { our_serializer.serializable_hash }.to perform_faster_than { ams_serializer.as_json }.at_least(speed_factor).times + end + end + end end diff --git a/spec/lib/serialization_core_spec.rb b/spec/lib/serialization_core_spec.rb index 2d2bc724..2d327d42 100644 --- a/spec/lib/serialization_core_spec.rb +++ b/spec/lib/serialization_core_spec.rb @@ -2,6 +2,7 @@ describe FastJsonapi::ObjectSerializer do include_context "movie class" + include_context 'group class' context 'when testing class methods of serialization core' do it 'returns correct hash when id_hash is called' do @@ -16,6 +17,12 @@ expect(result_hash).to be nil end + it 'returns the correct hash when ids_hash_from_record_and_relationship is called for a polymorphic association' do + relationship = { name: :groupees, relationship_type: :has_many, polymorphic: {} } + results = GroupSerializer.send :ids_hash_from_record_and_relationship, group, relationship + expect(results).to include({ id: "1", type: :person }, { id: "2", type: :group }) + end + it 'returns correct hash when ids_hash is called' do inputs = [{ids: %w(1 2 3), record_type: :movie}, {ids: %w(x y z), record_type: 'person'}] inputs.each do |hash| @@ -80,5 +87,4 @@ expect(included_records.size).to eq 3 end end - end diff --git a/spec/shared/contexts/ams_group_context.rb b/spec/shared/contexts/ams_group_context.rb new file mode 100644 index 00000000..5a36d66e --- /dev/null +++ b/spec/shared/contexts/ams_group_context.rb @@ -0,0 +1,87 @@ +RSpec.shared_context 'ams group class' do + before(:context) do + # models + class AMSPerson < ActiveModelSerializers::Model + attr_accessor :id, :first_name, :last_name + end + + class AMSGroup < ActiveModelSerializers::Model + attr_accessor :id, :name, :groupees + end + + # serializers + class AMSPersonSerializer < ActiveModel::Serializer + type 'person' + attributes :first_name, :last_name + end + + class AMSGroupSerializer < ActiveModel::Serializer + type 'group' + attributes :name + has_many :groupees + end + end + + after(:context) do + classes_to_remove = %i[AMSPerson AMSGroup AMSPersonSerializer AMSGroupSerializer] + classes_to_remove.each do |klass_name| + Object.send(:remove_const, klass_name) if Object.constants.include?(klass_name) + end + end + + let(:ams_groups) do + group_count = 0 + person_count = 0 + 3.times.map do |i| + group = AMSGroup.new + group.id = group_count + 1 + group.name = "Test Group #{group.id}" + group_count = group.id + + person = AMSPerson.new + person.id = person_count + 1 + person.last_name = "Last Name #{person.id}" + person.first_name = "First Name #{person.id}" + person_count = person.id + + child_group = AMSGroup.new + child_group.id = group_count + 1 + child_group.name = "Test Group #{child_group.id}" + group_count = child_group.id + + group.groupees = [person, child_group] + group + end + end + + let(:ams_person) do + ams_person = AMSPerson.new + ams_person.id = 3 + ams_person + end + + def build_ams_groups(count) + group_count = 0 + person_count = 0 + count.times.map do |i| + group = AMSGroup.new + group.id = group_count + 1 + group.name = "Test Group #{group.id}" + group_count = group.id + + person = AMSPerson.new + person.id = person_count + 1 + person.last_name = "Last Name #{person.id}" + person.first_name = "First Name #{person.id}" + person_count = person.id + + child_group = AMSGroup.new + child_group.id = group_count + 1 + child_group.name = "Test Group #{child_group.id}" + group_count = child_group.id + + group.groupees = [person, child_group] + group + end + end +end diff --git a/spec/shared/contexts/group_context.rb b/spec/shared/contexts/group_context.rb new file mode 100644 index 00000000..22f2919e --- /dev/null +++ b/spec/shared/contexts/group_context.rb @@ -0,0 +1,131 @@ +RSpec.shared_context 'group class' do + + # Person, Group Classes and serializers + before(:context) do + # models + class Person + attr_accessor :id, :first_name, :last_name + end + + class Group + attr_accessor :id, :name, :groupees # Let's assume groupees can be Person or Group objects + end + + # serializers + class PersonSerializer + include FastJsonapi::ObjectSerializer + set_type :person + attributes :first_name, :last_name + end + + class GroupSerializer + include FastJsonapi::ObjectSerializer + set_type :group + attributes :name + has_many :groupees, polymorphic: true + end + end + + + # Namespaced PersonSerializer + before(:context) do + # namespaced model stub + module AppName + module V1 + class PersonSerializer + include FastJsonapi::ObjectSerializer + # to test if compute_serializer_name works + end + end + end + end + + # Movie and Actor struct + before(:context) do + PersonStruct = Struct.new( + :id, :first_name, :last_name + ) + + GroupStruct = Struct.new( + :id, :name, :groupees, :groupee_ids + ) + end + + after(:context) do + classes_to_remove = %i[ + Person + PersonSerializer + Group + GroupSerializer + AppName::V1::PersonSerializer + PersonStruct + GroupStruct + ] + classes_to_remove.each do |klass_name| + Object.send(:remove_const, klass_name) if Object.constants.include?(klass_name) + end + end + + let(:group_struct) do + group = GroupStruct.new + group[:id] = 1 + group[:name] = 'Group 1' + group[:groupees] = [] + + person = PersonStruct.new + person[:id] = 1 + person[:last_name] = "Last Name 1" + person[:first_name] = "First Name 1" + + child_group = GroupStruct.new + child_group[:id] = 2 + child_group[:name] = 'Group 2' + + group.groupees = [person, child_group] + group + end + + let(:group) do + group = Group.new + group.id = 1 + group.name = 'Group 1' + + person = Person.new + person.id = 1 + person.last_name = "Last Name 1" + person.first_name = "First Name 1" + + child_group = Group.new + child_group.id = 2 + child_group.name = 'Group 2' + + group.groupees = [person, child_group] + group + end + + def build_groups(count) + group_count = 0 + person_count = 0 + + count.times.map do |i| + group = Group.new + group.id = group_count + 1 + group.name = "Test Group #{group.id}" + group_count = group.id + + person = Person.new + person.id = person_count + 1 + person.last_name = "Last Name #{person.id}" + person.first_name = "First Name #{person.id}" + person_count = person.id + + child_group = Group.new + child_group.id = group_count + 1 + child_group.name = "Test Group #{child_group.id}" + group_count = child_group.id + + group.groupees = [person, child_group] + group + end + end +end diff --git a/spec/shared/contexts/jsonapi_group_context.rb b/spec/shared/contexts/jsonapi_group_context.rb new file mode 100644 index 00000000..373faf1b --- /dev/null +++ b/spec/shared/contexts/jsonapi_group_context.rb @@ -0,0 +1,112 @@ +RSpec.shared_context 'jsonapi group class' do + + # Person, Group Classes and serializers + before(:context) do + # models + class JSONAPIPerson + attr_accessor :id, :first_name, :last_name + end + + class JSONAPIGroup + attr_accessor :id, :name, :groupees # Let's assume groupees can be Person or Group objects + end + + # serializers + class JSONAPIPersonSerializer < JSONAPI::Serializable::Resource + type 'person' + attributes :first_name, :last_name + end + + class JSONAPIGroupSerializer < JSONAPI::Serializable::Resource + type 'group' + attributes :name + has_many :groupees + end + + class JSONAPISerializerB + def initialize(data, options = {}) + @serializer = JSONAPI::Serializable::Renderer.new + @options = options.merge(class: { + JSONAPIPerson: JSONAPIPersonSerializer, + JSONAPIGroup: JSONAPIGroupSerializer + }) + @data = data + end + + def to_json + @serializer.render(@data, @options).to_json + end + + def to_hash + @serializer.render(@data, @options) + end + end + end + + after :context do + classes_to_remove = %i[ + JSONAPIPerson + JSONAPIGroup + JSONAPIPersonSerializer + JSONAPIGroupSerializer] + classes_to_remove.each do |klass_name| + Object.send(:remove_const, klass_name) if Object.constants.include?(klass_name) + end + end + + let(:jsonapi_groups) do + group_count = 0 + person_count = 0 + 3.times.map do |i| + group = JSONAPIGroup.new + group.id = group_count + 1 + group.name = "Test Group #{group.id}" + group_count = group.id + + person = JSONAPIPerson.new + person.id = person_count + 1 + person.last_name = "Last Name #{person.id}" + person.first_name = "First Name #{person.id}" + person_count = person.id + + child_group = JSONAPIGroup.new + child_group.id = group_count + 1 + child_group.name = "Test Group #{child_group.id}" + group_count = child_group.id + + group.groupees = [person, child_group] + group + end + end + + let(:jsonapi_person) do + person = JSONAPIPerson.new + person.id = 3 + person + end + + def build_jsonapi_groups(count) + group_count = 0 + person_count = 0 + count.times.map do |i| + group = JSONAPIGroup.new + group.id = group_count + 1 + group.name = "Test Group #{group.id}" + group_count = group.id + + person = JSONAPIPerson.new + person.id = person_count + 1 + person.last_name = "Last Name #{person.id}" + person.first_name = "First Name #{person.id}" + person_count = person.id + + child_group = JSONAPIGroup.new + child_group.id = group_count + 1 + child_group.name = "Test Group #{child_group.id}" + group_count = child_group.id + + group.groupees = [person, child_group] + group + end + end +end