lib/jsonapi/resource_serializer.rb in jsonapi-resources-0.8.3 vs lib/jsonapi/resource_serializer.rb in jsonapi-resources-0.9.0.beta1

- old
+ new

@@ -1,9 +1,11 @@ module JSONAPI class ResourceSerializer - attr_reader :link_builder, :key_formatter, :serialization_options, :primary_class_name + attr_reader :link_builder, :key_formatter, :serialization_options, :primary_class_name, + :fields, :include_directives, :always_include_to_one_linkage_data, + :always_include_to_many_linkage_data # initialize # Options can include # include: # Purpose: determines which objects will be side loaded with the source objects in a linked section @@ -11,18 +13,19 @@ # fields: # Purpose: determines which fields are serialized for a resource type. This encompasses both attributes and # relationship ids in the links section for a resource. Fields are global for a resource type. # Example: { people: [:id, :email, :comments], posts: [:id, :title, :author], comments: [:id, :body, :post]} # key_formatter: KeyFormatter instance to override the default configuration - # serializer_options: additional options that will be passed to resource meta and links lambdas + # serialization_options: additional options that will be passed to resource meta and links lambdas def initialize(primary_resource_klass, options = {}) @primary_resource_klass = primary_resource_klass @primary_class_name = primary_resource_klass._type @fields = options.fetch(:fields, {}) @include = options.fetch(:include, []) @include_directives = options[:include_directives] + @include_directives ||= JSONAPI::IncludeDirectives.new(@primary_resource_klass, @include) @key_formatter = options.fetch(:key_formatter, JSONAPI.configuration.key_formatter) @id_formatter = ValueFormatter.value_formatter_for(:id) @link_builder = generate_link_builder(primary_resource_klass, options) @always_include_to_one_linkage_data = options.fetch(:always_include_to_one_linkage_data, JSONAPI.configuration.always_include_to_one_linkage_data) @@ -31,20 +34,23 @@ @serialization_options = options.fetch(:serialization_options, {}) # Warning: This makes ResourceSerializer non-thread-safe. That's not a problem with the # request-specific way it's currently used, though. @value_formatter_type_cache = NaiveCache.new{|arg| ValueFormatter.value_formatter_for(arg) } + + @_config_keys = {} + @_supplying_attribute_fields = {} + @_supplying_relationship_fields = {} end # Converts a single resource, or an array of resources to a hash, conforming to the JSONAPI structure def serialize_to_hash(source) - @top_level_sources = Set.new([source].flatten.compact.map {|s| top_level_source_key(s) }) + @top_level_sources = Set.new([source].flatten(1).compact.map {|s| top_level_source_key(s) }) is_resource_collection = source.respond_to?(:to_ary) @included_objects = {} - @include_directives ||= JSONAPI::IncludeDirectives.new(@primary_resource_klass, @include) process_primary(source, @include_directives.include_directives) included_objects = [] primary_objects = [] @@ -90,71 +96,124 @@ def format_value(value, format) @value_formatter_type_cache.get(format).format(value) end - private - - # Process the primary source object(s). This will then serialize associated object recursively based on the - # requested includes. Fields are controlled fields option for each resource type, such - # as fields: { people: [:id, :email, :comments], posts: [:id, :title, :author], comments: [:id, :body, :post]} - # The fields options controls both fields and included links references. - def process_primary(source, include_directives) - if source.respond_to?(:to_ary) - source.each { |resource| process_primary(resource, include_directives) } - else - return {} if source.nil? - - resource = source - id = resource.id - add_included_object(id, object_hash(source, include_directives), true) + def config_key(resource_klass) + @_config_keys.fetch resource_klass do + desc = self.config_description(resource_klass).map(&:inspect).join(",") + key = JSONAPI.configuration.resource_cache_digest_function.call(desc) + @_config_keys[resource_klass] = "SRLZ-#{key}" end end + def config_description(resource_klass) + { + class_name: self.class.name, + seriserialization_options: serialization_options.sort.map(&:as_json), + supplying_attribute_fields: supplying_attribute_fields(resource_klass).sort, + supplying_relationship_fields: supplying_relationship_fields(resource_klass).sort, + link_builder_base_url: link_builder.base_url, + route_formatter_class: link_builder.route_formatter.uncached.class.name, + key_formatter_class: key_formatter.uncached.class.name, + always_include_to_one_linkage_data: always_include_to_one_linkage_data, + always_include_to_many_linkage_data: always_include_to_many_linkage_data + } + end + # Returns a serialized hash for the source model - def object_hash(source, include_directives) + def object_hash(source, include_directives = {}) obj_hash = {} - id_format = source.class._attribute_options(:id)[:format] - # protect against ids that were declared as an attribute, but did not have a format set. - id_format = 'id' if id_format == :default - obj_hash['id'] = format_value(source.id, id_format) + if source.is_a?(JSONAPI::CachedResourceFragment) + obj_hash['id'] = source.id + obj_hash['type'] = source.type - obj_hash['type'] = format_key(source.class._type.to_s) + obj_hash['links'] = source.links_json if source.links_json + obj_hash['attributes'] = source.attributes_json if source.attributes_json - links = links_hash(source) - obj_hash['links'] = links unless links.empty? + relationships = cached_relationships_hash(source, include_directives) + obj_hash['relationships'] = relationships unless relationships.empty? - attributes = attributes_hash(source) - obj_hash['attributes'] = attributes unless attributes.empty? + obj_hash['meta'] = source.meta_json if source.meta_json + else + fetchable_fields = Set.new(source.fetchable_fields) - relationships = relationships_hash(source, include_directives) - obj_hash['relationships'] = relationships unless relationships.nil? || relationships.empty? + # TODO Should this maybe be using @id_formatter instead, for consistency? + id_format = source.class._attribute_options(:id)[:format] + # protect against ids that were declared as an attribute, but did not have a format set. + id_format = 'id' if id_format == :default + obj_hash['id'] = format_value(source.id, id_format) - meta = meta_hash(source) - obj_hash['meta'] = meta unless meta.empty? + obj_hash['type'] = format_key(source.class._type.to_s) + links = links_hash(source) + obj_hash['links'] = links unless links.empty? + + attributes = attributes_hash(source, fetchable_fields) + obj_hash['attributes'] = attributes unless attributes.empty? + + relationships = relationships_hash(source, fetchable_fields, include_directives) + obj_hash['relationships'] = relationships unless relationships.nil? || relationships.empty? + + meta = meta_hash(source) + obj_hash['meta'] = meta unless meta.empty? + end + obj_hash end - def requested_fields(klass) - return if @fields.nil? || @fields.empty? - if @fields[klass._type] - @fields[klass._type] - elsif klass.superclass != JSONAPI::Resource - requested_fields(klass.superclass) + private + + # Process the primary source object(s). This will then serialize associated object recursively based on the + # requested includes. Fields are controlled fields option for each resource type, such + # as fields: { people: [:id, :email, :comments], posts: [:id, :title, :author], comments: [:id, :body, :post]} + # The fields options controls both fields and included links references. + def process_primary(source, include_directives) + if source.respond_to?(:to_ary) + source.each { |resource| process_primary(resource, include_directives) } + else + return {} if source.nil? + add_resource(source, include_directives, true) end end - def attributes_hash(source) - requested = requested_fields(source.class) - fields = source.fetchable_fields & source.class._attributes.keys.to_a - fields = requested & fields unless requested.nil? + def supplying_attribute_fields(resource_klass) + @_supplying_attribute_fields.fetch resource_klass do + attrs = Set.new(resource_klass._attributes.keys.map(&:to_sym)) + cur = resource_klass + while cur != JSONAPI::Resource + if @fields.has_key?(cur._type) + attrs &= @fields[cur._type] + break + end + cur = cur.superclass + end + @_supplying_attribute_fields[resource_klass] = attrs + end + end + def supplying_relationship_fields(resource_klass) + @_supplying_relationship_fields.fetch resource_klass do + relationships = Set.new(resource_klass._relationships.keys.map(&:to_sym)) + cur = resource_klass + while cur != JSONAPI::Resource + if @fields.has_key?(cur._type) + relationships &= @fields[cur._type] + break + end + cur = cur.superclass + end + @_supplying_relationship_fields[resource_klass] = relationships + end + end + + def attributes_hash(source, fetchable_fields) + fields = fetchable_fields & supplying_attribute_fields(source.class) fields.each_with_object({}) do |name, hash| - format = source.class._attribute_options(name)[:format] unless name == :id + format = source.class._attribute_options(name)[:format] hash[format_key(name)] = format_value(source.public_send(name), format) end end end @@ -180,63 +239,105 @@ custom_links = source.custom_links(custom_generation_options) (custom_links.is_a?(Hash) && custom_links) || {} end def top_level_source_key(source) - "#{source.class}_#{source.id}" + case source + when CachedResourceFragment then "#{source.resource_klass}_#{source.id}" + when Resource then "#{source.class}_#{@id_formatter.format(source.id)}" + else raise "Unknown source type #{source.inspect}" + end end def self_referential_and_already_in_source(resource) resource && @top_level_sources.include?(top_level_source_key(resource)) end - def relationships_hash(source, include_directives) - relationships = source.class._relationships - requested = requested_fields(source.class) - fields = relationships.keys - fields = requested & fields unless requested.nil? + def relationships_hash(source, fetchable_fields, include_directives = {}) + if source.is_a?(CachedResourceFragment) + return cached_relationships_hash(source, include_directives) + end - field_set = Set.new(fields) + include_directives[:include_related] ||= {} - included_relationships = source.fetchable_fields & relationships.keys + relationships = source.class._relationships.select{|k,v| fetchable_fields.include?(k) } + field_set = supplying_relationship_fields(source.class) & relationships.keys - data = {} + relationships.each_with_object({}) do |(name, relationship), hash| + ia = include_directives[:include_related][name] + include_linkage = ia && ia[:include] + include_linked_children = ia && !ia[:include_related].empty? - relationships.each_with_object(data) do |(name, relationship), hash| - if included_relationships.include? name - ia = include_directives[:include_related][name] + if field_set.include?(name) + hash[format_key(name)] = link_object(source, relationship, include_linkage) + end - include_linkage = ia && ia[:include] - include_linked_children = ia && !ia[:include_related].empty? - resources = (include_linkage || include_linked_children) && [source.public_send(name)].flatten.compact + # If the object has been serialized once it will be in the related objects list, + # but it's possible all children won't have been captured. So we must still go + # through the relationships. + if include_linkage || include_linked_children + resources = if source.preloaded_fragments.has_key?(format_key(name)) + source.preloaded_fragments[format_key(name)].values + else + [source.public_send(name)].flatten(1).compact + end + resources.each do |resource| + next if self_referential_and_already_in_source(resource) + id = resource.id + relationships_only = already_serialized?(relationship.type, id) + if include_linkage && !relationships_only + add_resource(resource, ia) + elsif include_linked_children || relationships_only + relationships_hash(resource, fetchable_fields, ia) + end + end + end + end + end - if field_set.include?(name) - hash[format_key(name)] = link_object(source, relationship, include_linkage) + def cached_relationships_hash(source, include_directives) + h = source.relationships || {} + return h unless include_directives.has_key?(:include_related) + + relationships = source.resource_klass._relationships.select{|k,v| source.fetchable_fields.include?(k) } + + relationships.each do |rel_name, relationship| + key = @key_formatter.format(rel_name) + to_many = relationship.is_a? JSONAPI::Relationship::ToMany + + ia = include_directives[:include_related][rel_name] + if ia + if h.has_key?(key) + h[key][:data] = to_many ? [] : nil end - # If the object has been serialized once it will be in the related objects list, - # but it's possible all children won't have been captured. So we must still go - # through the relationships. - if include_linkage || include_linked_children - resources.each do |resource| - next if self_referential_and_already_in_source(resource) - id = resource.id - type = resource.class.resource_for_model(resource._model) - relationships_only = already_serialized?(type, id) - if include_linkage && !relationships_only - add_included_object(id, object_hash(resource, ia)) - elsif include_linked_children || relationships_only - relationships_hash(resource, ia) + source.preloaded_fragments[key].each do |id, f| + add_resource(f, ia) + + if h.has_key?(key) + # The hash already has everything we need except the :data field + data = { + type: format_key(f.is_a?(Resource) ? f.class._type : f.type), + id: @id_formatter.format(id) + } + + if to_many + h[key][:data] << data + else + h[key][:data] = data end end end end end + + return h end def already_serialized?(type, id) type = format_key(type) + id = @id_formatter.format(id) @included_objects.key?(type) && @included_objects[type].key?(id) end def self_link(source, relationship) link_builder.relationships_self_link(source, relationship) @@ -245,21 +346,43 @@ def related_link(source, relationship) link_builder.relationships_related_link(source, relationship) end def to_one_linkage(source, relationship) - return unless linkage_id = foreign_key_value(source, relationship) - return unless linkage_type = format_key(relationship.type_for_source(source)) + linkage_id = foreign_key_value(source, relationship) + linkage_type = format_key(relationship.type_for_source(source)) + return unless linkage_id.present? && linkage_type.present? + { type: linkage_type, id: linkage_id, } end def to_many_linkage(source, relationship) linkage = [] - linkage_types_and_values = foreign_key_types_and_values(source, relationship) + linkage_types_and_values = if source.preloaded_fragments.has_key?(format_key(relationship.name)) + source.preloaded_fragments[format_key(relationship.name)].map do |_, resource| + [relationship.type, resource.id] + end + elsif relationship.polymorphic? + assoc = source._model.public_send(relationship.name) + # Avoid hitting the database again for values already pre-loaded + if assoc.respond_to?(:loaded?) and assoc.loaded? + assoc.map do |obj| + [obj.type.underscore.pluralize, obj.id] + end + else + assoc.pluck(:type, :id).map do |type, id| + [type.underscore.pluralize, id] + end + end + else + source.public_send(relationship.name).map do |value| + [relationship.type, value.id] + end + end linkage_types_and_values.each do |type, value| if type && value linkage.append({type: format_key(type), id: @id_formatter.format(value)}) end @@ -295,22 +418,22 @@ end end # Extracts the foreign key value for a to_one relationship. def foreign_key_value(source, relationship) - # If you have direct access to the underlying id, you don't have to load the relationship - # which can save quite a lot of time when loading a lot of data. - # This does not apply to e.g. has_one :through relationships. - if source._model.respond_to?("#{relationship.name}_id") - related_resource_id = source._model.public_send("#{relationship.name}_id") - return nil unless related_resource_id - @id_formatter.format(related_resource_id) + related_resource_id = if source.preloaded_fragments.has_key?(format_key(relationship.name)) + source.preloaded_fragments[format_key(relationship.name)].values.first.try(:id) + elsif source.respond_to?("#{relationship.name}_id") + # If you have direct access to the underlying id, you don't have to load the relationship + # which can save quite a lot of time when loading a lot of data. + # This does not apply to e.g. has_one :through relationships. + source.public_send("#{relationship.name}_id") else - related_resource = source.public_send(relationship.name) - return nil unless related_resource - @id_formatter.format(related_resource.id) + source.public_send(relationship.name).try(:id) end + return nil unless related_resource_id + @id_formatter.format(related_resource_id) end def foreign_key_types_and_values(source, relationship) if relationship.is_a?(JSONAPI::Relationship::ToMany) if relationship.polymorphic? @@ -337,20 +460,31 @@ def set_primary(type, id) type = format_key(type) @included_objects[type][id][:primary] = true end - # Collects the hashes for all objects processed by the serializer - def add_included_object(id, object_hash, primary = false) - type = object_hash['type'] + def add_resource(source, include_directives, primary = false) + type = source.is_a?(JSONAPI::CachedResourceFragment) ? source.type : source.class._type + id = source.id - @included_objects[type] = {} unless @included_objects.key?(type) + @included_objects[type] ||= {} + existing = @included_objects[type][id] - if already_serialized?(type, id) - @included_objects[type][id][:object_hash].deep_merge!(object_hash) - set_primary(type, id) if primary + if existing.nil? + obj_hash = object_hash(source, include_directives) + @included_objects[type][id] = { + primary: primary, + object_hash: obj_hash, + includes: Set.new(include_directives[:include_related].keys) + } else - @included_objects[type].store(id, primary: primary, object_hash: object_hash) + include_related = Set.new(include_directives[:include_related].keys) + unless existing[:includes].superset?(include_related) + obj_hash = object_hash(source, include_directives) + @included_objects[type][id][:object_hash].deep_merge!(obj_hash) + @included_objects[type][id][:includes].add(include_related) + @included_objects[type][id][:primary] = existing[:primary] | primary + end end end def generate_link_builder(primary_resource_klass, options) LinkBuilder.new(