From 29a43c3700ac3301afdbee89d233ebfa5941281c Mon Sep 17 00:00:00 2001 From: lgebhardt Date: Tue, 23 Apr 2024 13:46:17 -0400 Subject: [PATCH] Allow related resources to be retrieved by inverse or primary on a per relationship basis Add `find_related_through` to relationships with defaults controlled by the resource classes Move find related fragments through primary methods to shared module to allow sharing between the default and v10 strategies --- lib/jsonapi-resources.rb | 1 + lib/jsonapi/active_relation_retrieval.rb | 102 +++++-- .../find_related_through_primary.rb | 271 ++++++++++++++++++ lib/jsonapi/active_relation_retrieval_v10.rb | 264 +---------------- lib/jsonapi/configuration.rb | 35 ++- lib/jsonapi/relationship.rb | 34 ++- lib/jsonapi/resource_common.rb | 4 + test/controllers/controller_test.rb | 106 ++++++- test/test_helper.rb | 4 + 9 files changed, 511 insertions(+), 310 deletions(-) create mode 100644 lib/jsonapi/active_relation_retrieval/find_related_through_primary.rb diff --git a/lib/jsonapi-resources.rb b/lib/jsonapi-resources.rb index 20a94930..eab7c7c6 100644 --- a/lib/jsonapi-resources.rb +++ b/lib/jsonapi-resources.rb @@ -5,6 +5,7 @@ require 'jsonapi/naive_cache' require 'jsonapi/compiled_json' require 'jsonapi/relation_retrieval' +require 'jsonapi/active_relation_retrieval/find_related_through_primary' require 'jsonapi/active_relation_retrieval' require 'jsonapi/active_relation_retrieval_v09' require 'jsonapi/active_relation_retrieval_v10' diff --git a/lib/jsonapi/active_relation_retrieval.rb b/lib/jsonapi/active_relation_retrieval.rb index d0d16ec9..ee1d92a6 100644 --- a/lib/jsonapi/active_relation_retrieval.rb +++ b/lib/jsonapi/active_relation_retrieval.rb @@ -3,12 +3,23 @@ module JSONAPI module ActiveRelationRetrieval include ::JSONAPI::RelationRetrieval + include ::JSONAPI::ActiveRelationRetrieval::FindRelatedThroughPrimary def find_related_ids(relationship, options) self.class.find_related_fragments(self, relationship, options).keys.collect { |rid| rid.id } end module ClassMethods + include JSONAPI::ActiveRelationRetrieval::FindRelatedThroughPrimary::ClassMethods + + def default_find_related_through(polymorphic = false) + if polymorphic + JSONAPI.configuration.default_find_related_through_polymorphic + else + JSONAPI.configuration.default_find_related_through + end + end + # Finds Resources using the `filters`. Pagination and sort options are used when provided # # @param filters [Hash] the filters hash @@ -119,6 +130,11 @@ def find_fragments(filters, options) options: options) if options[:cache] + # When using caching the a two step process is used. First the records ids are retrieved and then the + # records are retrieved using the ids. Then the ids are used to query the database again to get the + # cache misses. In the second phase the records are not sorted or paginated and the `records_for_populate` + # method is used to ensure any dependent includes or custom database fields are calculated. + # This alias is going to be resolve down to the model's table name and will not actually be an alias resource_table_alias = resource_klass._table_name @@ -189,6 +205,10 @@ def find_fragments(filters, options) warn "Performance issue detected: `#{self.name.to_s}.records` returned non-normalized results in `#{self.name.to_s}.find_fragments`." end else + # When not using caching resources can be generated after querying. The `records_for_populate` + # method is merged in to ensure any dependent includes or custom database fields are calculated. + records = records.merge(records_for_populate(options)) + linkage_fields = [] linkage_relationships.each do |linkage_relationship| @@ -260,50 +280,74 @@ def find_fragments(filters, options) # the ResourceInstances matching the filters, sorting, and pagination rules along with any request # additional_field values def find_related_fragments(source_fragment, relationship, options) - if relationship.polymorphic? # && relationship.foreign_key_on == :self - source_resource_klasses = if relationship.foreign_key_on == :self - relationship.polymorphic_types.collect do |polymorphic_type| - resource_klass_for(polymorphic_type) + case relationship.find_related_through + when :primary + if relationship.polymorphic? + find_related_polymorphic_fragments_through_primary([source_fragment], relationship, options, false) + else + find_related_monomorphic_fragments_through_primary([source_fragment], relationship, options, false) + end + when :inverse + if relationship.polymorphic? # && relationship.foreign_key_on == :self + source_resource_klasses = if relationship.foreign_key_on == :self + relationship.polymorphic_types.collect do |polymorphic_type| + resource_klass_for(polymorphic_type) + end + else + source.collect { |fragment| fragment.identity.resource_klass }.to_set end - else - source.collect { |fragment| fragment.identity.resource_klass }.to_set - end - fragments = {} - source_resource_klasses.each do |resource_klass| - inverse_direct_relationship = _relationship(resource_klass._type.to_s.singularize) + fragments = {} + source_resource_klasses.each do |resource_klass| + inverse_direct_relationship = _relationship(resource_klass._type.to_s.singularize) - fragments.merge!(resource_klass.find_related_fragments_from_inverse([source_fragment], inverse_direct_relationship, options, false)) + fragments.merge!(resource_klass.find_related_fragments_through_inverse([source_fragment], inverse_direct_relationship, options, false)) + end + fragments + else + relationship.resource_klass.find_related_fragments_through_inverse([source_fragment], relationship, options, false) end - fragments else - relationship.resource_klass.find_related_fragments_from_inverse([source_fragment], relationship, options, false) + raise "Unknown find_related_through: #{relationship.find_related_through}" + {} end end def find_included_fragments(source_fragments, relationship, options) - if relationship.polymorphic? # && relationship.foreign_key_on == :self - source_resource_klasses = if relationship.foreign_key_on == :self - relationship.polymorphic_types.collect do |polymorphic_type| - resource_klass_for(polymorphic_type) + case relationship.find_related_through + when :primary + if relationship.polymorphic? + find_related_polymorphic_fragments_through_primary(source_fragments, relationship, options, true) + else + find_related_monomorphic_fragments_through_primary(source_fragments, relationship, options, true) + end + when :inverse + if relationship.polymorphic? # && relationship.foreign_key_on == :self + source_resource_klasses = if relationship.foreign_key_on == :self + relationship.polymorphic_types.collect do |polymorphic_type| + resource_klass_for(polymorphic_type) + end + else + source.collect { |fragment| fragment.identity.resource_klass }.to_set end - else - source_fragments.collect { |fragment| fragment.identity.resource_klass }.to_set - end - fragments = {} - source_resource_klasses.each do |resource_klass| - inverse_direct_relationship = _relationship(resource_klass._type.to_s.singularize) + fragments = {} + source_resource_klasses.each do |resource_klass| + inverse_direct_relationship = _relationship(resource_klass._type.to_s.singularize) - fragments.merge!(resource_klass.find_related_fragments_from_inverse(source_fragments, inverse_direct_relationship, options, true)) + fragments.merge!(resource_klass.find_related_fragments_through_inverse(source_fragments, inverse_direct_relationship, options, true)) + end + fragments + else + relationship.resource_klass.find_related_fragments_through_inverse(source_fragments, relationship, options, true) end - fragments else - relationship.resource_klass.find_related_fragments_from_inverse(source_fragments, relationship, options, true) + raise "Unknown find_related_through: #{relationship.options[:find_related_through]}" + {} end end - def find_related_fragments_from_inverse(source, source_relationship, options, connect_source_identity) + def find_related_fragments_through_inverse(source, source_relationship, options, connect_source_identity) inverse_relationship = source_relationship._inverse_relationship return {} if inverse_relationship.blank? @@ -499,10 +543,10 @@ def find_related_fragments_from_inverse(source, source_relationship, options, co # @return [Integer] the count def count_related(source, relationship, options) - relationship.resource_klass.count_related_from_inverse(source, relationship, options) + relationship.resource_klass.count_related_through_inverse(source, relationship, options) end - def count_related_from_inverse(source_resource, source_relationship, options) + def count_related_through_inverse(source_resource, source_relationship, options) inverse_relationship = source_relationship._inverse_relationship return -1 if inverse_relationship.blank? diff --git a/lib/jsonapi/active_relation_retrieval/find_related_through_primary.rb b/lib/jsonapi/active_relation_retrieval/find_related_through_primary.rb new file mode 100644 index 00000000..fe3adfbe --- /dev/null +++ b/lib/jsonapi/active_relation_retrieval/find_related_through_primary.rb @@ -0,0 +1,271 @@ +# frozen_string_literal: true + +module JSONAPI + module ActiveRelationRetrieval + module FindRelatedThroughPrimary + module ClassMethods + def find_related_monomorphic_fragments_through_primary(source_fragments, relationship, options, connect_source_identity) + filters = options.fetch(:filters, {}) + source_ids = source_fragments.collect {|item| item.identity.id} + + include_directives = options.fetch(:include_directives, {}) + resource_klass = relationship.resource_klass + linkage_relationships = resource_klass.to_one_relationships_for_linkage(include_directives[:include_related]) + + sort_criteria = [] + options[:sort_criteria].try(:each) do |sort| + field = sort[:field].to_s == 'id' ? resource_klass._primary_key : sort[:field] + sort_criteria << { field: field, direction: sort[:direction] } + end + + join_manager = ActiveRelation::JoinManagerThroughPrimary.new(resource_klass: self, + source_relationship: relationship, + relationships: linkage_relationships.collect(&:name), + sort_criteria: sort_criteria, + filters: filters) + + paginator = options[:paginator] + + records = apply_request_settings_to_records(records: records_for_source_to_related(options), + resource_klass: resource_klass, + sort_criteria: sort_criteria, + primary_keys: source_ids, + paginator: paginator, + filters: filters, + join_manager: join_manager, + options: options) + + resource_table_alias = join_manager.join_details_by_relationship(relationship)[:alias] + + pluck_fields = [ + Arel.sql("#{_table_name}.#{_primary_key} AS \"source_id\""), + sql_field_with_alias(resource_table_alias, resource_klass._primary_key) + ] + + cache_field = resource_klass.attribute_to_model_field(:_cache_field) if options[:cache] + if cache_field + pluck_fields << sql_field_with_alias(resource_table_alias, cache_field[:name]) + end + + linkage_fields = [] + + linkage_relationships.each do |linkage_relationship| + linkage_relationship_name = linkage_relationship.name + + if linkage_relationship.polymorphic? && linkage_relationship.belongs_to? + linkage_relationship.resource_types.each do |resource_type| + klass = resource_klass_for(resource_type) + linkage_fields << {relationship_name: linkage_relationship_name, resource_klass: klass} + + linkage_table_alias = join_manager.join_details_by_polymorphic_relationship(linkage_relationship, resource_type)[:alias] + primary_key = klass._primary_key + pluck_fields << sql_field_with_alias(linkage_table_alias, primary_key) + end + else + klass = linkage_relationship.resource_klass + linkage_fields << {relationship_name: linkage_relationship_name, resource_klass: klass} + + linkage_table_alias = join_manager.join_details_by_relationship(linkage_relationship)[:alias] + primary_key = klass._primary_key + pluck_fields << sql_field_with_alias(linkage_table_alias, primary_key) + end + end + + sort_fields = options.dig(:_relation_helper_options, :sort_fields) + sort_fields.try(:each) do |field| + pluck_fields << Arel.sql(field) + end + + fragments = {} + rows = records.distinct.pluck(*pluck_fields) + rows.each do |row| + rid = JSONAPI::ResourceIdentity.new(resource_klass, row[1]) + + fragments[rid] ||= JSONAPI::ResourceFragment.new(rid) + + attributes_offset = 2 + + if cache_field + fragments[rid].cache = cast_to_attribute_type(row[attributes_offset], cache_field[:type]) + attributes_offset+= 1 + end + + source_rid = JSONAPI::ResourceIdentity.new(self, row[0]) + + fragments[rid].add_related_from(source_rid) + + linkage_fields.each do |linkage_field| + fragments[rid].initialize_related(linkage_field[:relationship_name]) + related_id = row[attributes_offset] + if related_id + related_rid = JSONAPI::ResourceIdentity.new(linkage_field[:resource_klass], related_id) + fragments[rid].add_related_identity(linkage_field[:relationship_name], related_rid) + end + attributes_offset+= 1 + end + + if connect_source_identity + inverse_relationship = relationship._inverse_relationship + fragments[rid].add_related_identity(inverse_relationship.name, source_rid) if inverse_relationship.present? + end + end + + fragments + end + + # Gets resource identities where the related resource is polymorphic and the resource type and id + # are stored on the primary resources. Cache fields will always be on the related resources. + def find_related_polymorphic_fragments_through_primary(source_fragments, relationship, options, connect_source_identity) + filters = options.fetch(:filters, {}) + source_ids = source_fragments.collect {|item| item.identity.id} + + resource_klass = relationship.resource_klass + include_directives = options.fetch(:include_directives, {}) + + linkage_relationship_paths = [] + + resource_types = relationship.resource_types + + resource_types.each do |resource_type| + related_resource_klass = resource_klass_for(resource_type) + relationships = related_resource_klass.to_one_relationships_for_linkage(include_directives[:include_related]) + relationships.each do |r| + linkage_relationship_paths << "##{resource_type}.#{r.name}" + end + end + + join_manager = ActiveRelation::JoinManagerThroughPrimary.new(resource_klass: self, + source_relationship: relationship, + relationships: linkage_relationship_paths, + filters: filters) + + paginator = options[:paginator] + + # Note: We will sort by the source table. Without using unions we can't sort on a polymorphic relationship + # in any manner that makes sense + records = apply_request_settings_to_records(records: records_for_source_to_related(options), + resource_klass: resource_klass, + sort_primary: true, + primary_keys: source_ids, + paginator: paginator, + filters: filters, + join_manager: join_manager, + options: options) + + primary_key = concat_table_field(_table_name, _primary_key) + related_key = concat_table_field(_table_name, relationship.foreign_key) + related_type = concat_table_field(_table_name, relationship.polymorphic_type) + + pluck_fields = [ + Arel.sql("#{primary_key} AS #{alias_table_field(_table_name, _primary_key)}"), + Arel.sql("#{related_key} AS #{alias_table_field(_table_name, relationship.foreign_key)}"), + Arel.sql("#{related_type} AS #{alias_table_field(_table_name, relationship.polymorphic_type)}") + ] + + # Get the additional fields from each relation. There's a limitation that the fields must exist in each relation + + relation_positions = {} + relation_index = pluck_fields.length + + # Add resource specific fields + if resource_types.nil? || resource_types.length == 0 + # :nocov: + warn "No resource types found for polymorphic relationship." + # :nocov: + else + resource_types.try(:each) do |type| + related_klass = resource_klass_for(type.to_s) + + cache_field = related_klass.attribute_to_model_field(:_cache_field) if options[:cache] + + table_alias = join_manager.source_join_details(type)[:alias] + + cache_offset = relation_index + if cache_field + pluck_fields << sql_field_with_alias(table_alias, cache_field[:name]) + relation_index+= 1 + end + + relation_positions[type] = {relation_klass: related_klass, + cache_field: cache_field, + cache_offset: cache_offset} + end + end + + # Add to_one linkage fields + linkage_fields = [] + linkage_offset = relation_index + + linkage_relationship_paths.each do |linkage_relationship_path| + path = JSONAPI::Path.new(resource_klass: self, + path_string: "#{relationship.name}#{linkage_relationship_path}", + ensure_default_field: false) + + linkage_relationship = path.segments[-1].relationship + + if linkage_relationship.polymorphic? && linkage_relationship.belongs_to? + linkage_relationship.resource_types.each do |resource_type| + klass = resource_klass_for(resource_type) + linkage_fields << {relationship: linkage_relationship, resource_klass: klass} + + linkage_table_alias = join_manager.join_details_by_polymorphic_relationship(linkage_relationship, resource_type)[:alias] + primary_key = klass._primary_key + pluck_fields << sql_field_with_alias(linkage_table_alias, primary_key) + end + else + klass = linkage_relationship.resource_klass + linkage_fields << {relationship: linkage_relationship, resource_klass: klass} + + linkage_table_alias = join_manager.join_details_by_relationship(linkage_relationship)[:alias] + primary_key = klass._primary_key + pluck_fields << sql_field_with_alias(linkage_table_alias, primary_key) + end + end + + rows = records.distinct.pluck(*pluck_fields) + + related_fragments = {} + + rows.each do |row| + unless row[1].nil? || row[2].nil? + related_klass = resource_klass_for(row[2]) + + rid = JSONAPI::ResourceIdentity.new(related_klass, row[1]) + related_fragments[rid] ||= JSONAPI::ResourceFragment.new(rid) + + source_rid = JSONAPI::ResourceIdentity.new(self, row[0]) + related_fragments[rid].add_related_from(source_rid) + + if connect_source_identity + inverse_relationship = relationship._inverse_relationship + related_fragments[rid].add_related_identity(inverse_relationship.name, source_rid) if inverse_relationship.present? + end + + relation_position = relation_positions[row[2].underscore.pluralize] + model_fields = relation_position[:model_fields] + cache_field = relation_position[:cache_field] + cache_offset = relation_position[:cache_offset] + field_offset = relation_position[:field_offset] + + if cache_field + related_fragments[rid].cache = cast_to_attribute_type(row[cache_offset], cache_field[:type]) + end + + linkage_fields.each_with_index do |linkage_field_details, idx| + relationship = linkage_field_details[:relationship] + related_fragments[rid].initialize_related(relationship.name) + related_id = row[linkage_offset + idx] + if related_id + related_rid = JSONAPI::ResourceIdentity.new(linkage_field_details[:resource_klass], related_id) + related_fragments[rid].add_related_identity(relationship.name, related_rid) + end + end + end + end + + related_fragments + end + end + end + end +end diff --git a/lib/jsonapi/active_relation_retrieval_v10.rb b/lib/jsonapi/active_relation_retrieval_v10.rb index ff4f58c1..d29e7351 100644 --- a/lib/jsonapi/active_relation_retrieval_v10.rb +++ b/lib/jsonapi/active_relation_retrieval_v10.rb @@ -9,6 +9,8 @@ def find_related_ids(relationship, options) end module ClassMethods + include JSONAPI::ActiveRelationRetrieval::FindRelatedThroughPrimary::ClassMethods + # Finds Resources using the `filters`. Pagination and sort options are used when provided # # @param filters [Hash] the filters hash @@ -363,268 +365,6 @@ def find_records_by_keys(keys, options) apply_request_settings_to_records(records: records(options), primary_keys: keys, options: options) end - def find_related_monomorphic_fragments(source_fragments, relationship, options, connect_source_identity) - filters = options.fetch(:filters, {}) - source_ids = source_fragments.collect {|item| item.identity.id} - - include_directives = options.fetch(:include_directives, {}) - resource_klass = relationship.resource_klass - linkage_relationships = resource_klass.to_one_relationships_for_linkage(include_directives[:include_related]) - - sort_criteria = [] - options[:sort_criteria].try(:each) do |sort| - field = sort[:field].to_s == 'id' ? resource_klass._primary_key : sort[:field] - sort_criteria << { field: field, direction: sort[:direction] } - end - - join_manager = ActiveRelation::JoinManagerThroughPrimary.new(resource_klass: self, - source_relationship: relationship, - relationships: linkage_relationships.collect(&:name), - sort_criteria: sort_criteria, - filters: filters) - - paginator = options[:paginator] - - records = apply_request_settings_to_records(records: records_for_source_to_related(options), - resource_klass: resource_klass, - sort_criteria: sort_criteria, - primary_keys: source_ids, - paginator: paginator, - filters: filters, - join_manager: join_manager, - options: options) - - resource_table_alias = join_manager.join_details_by_relationship(relationship)[:alias] - - pluck_fields = [ - Arel.sql("#{_table_name}.#{_primary_key} AS \"source_id\""), - sql_field_with_alias(resource_table_alias, resource_klass._primary_key) - ] - - cache_field = resource_klass.attribute_to_model_field(:_cache_field) if options[:cache] - if cache_field - pluck_fields << sql_field_with_alias(resource_table_alias, cache_field[:name]) - end - - linkage_fields = [] - - linkage_relationships.each do |linkage_relationship| - linkage_relationship_name = linkage_relationship.name - - if linkage_relationship.polymorphic? && linkage_relationship.belongs_to? - linkage_relationship.resource_types.each do |resource_type| - klass = resource_klass_for(resource_type) - linkage_fields << {relationship_name: linkage_relationship_name, resource_klass: klass} - - linkage_table_alias = join_manager.join_details_by_polymorphic_relationship(linkage_relationship, resource_type)[:alias] - primary_key = klass._primary_key - pluck_fields << sql_field_with_alias(linkage_table_alias, primary_key) - end - else - klass = linkage_relationship.resource_klass - linkage_fields << {relationship_name: linkage_relationship_name, resource_klass: klass} - - linkage_table_alias = join_manager.join_details_by_relationship(linkage_relationship)[:alias] - primary_key = klass._primary_key - pluck_fields << sql_field_with_alias(linkage_table_alias, primary_key) - end - end - - sort_fields = options.dig(:_relation_helper_options, :sort_fields) - sort_fields.try(:each) do |field| - pluck_fields << Arel.sql(field) - end - - fragments = {} - rows = records.distinct.pluck(*pluck_fields) - rows.each do |row| - rid = JSONAPI::ResourceIdentity.new(resource_klass, row[1]) - - fragments[rid] ||= JSONAPI::ResourceFragment.new(rid) - - attributes_offset = 2 - - if cache_field - fragments[rid].cache = cast_to_attribute_type(row[attributes_offset], cache_field[:type]) - attributes_offset+= 1 - end - - source_rid = JSONAPI::ResourceIdentity.new(self, row[0]) - - fragments[rid].add_related_from(source_rid) - - linkage_fields.each do |linkage_field| - fragments[rid].initialize_related(linkage_field[:relationship_name]) - related_id = row[attributes_offset] - if related_id - related_rid = JSONAPI::ResourceIdentity.new(linkage_field[:resource_klass], related_id) - fragments[rid].add_related_identity(linkage_field[:relationship_name], related_rid) - end - attributes_offset+= 1 - end - - if connect_source_identity - inverse_relationship = relationship._inverse_relationship - fragments[rid].add_related_identity(inverse_relationship.name, source_rid) if inverse_relationship.present? - end - end - - fragments - end - - # Gets resource identities where the related resource is polymorphic and the resource type and id - # are stored on the primary resources. Cache fields will always be on the related resources. - def find_related_polymorphic_fragments(source_fragments, relationship, options, connect_source_identity) - filters = options.fetch(:filters, {}) - source_ids = source_fragments.collect {|item| item.identity.id} - - resource_klass = relationship.resource_klass - include_directives = options.fetch(:include_directives, {}) - - linkage_relationship_paths = [] - - resource_types = relationship.resource_types - - resource_types.each do |resource_type| - related_resource_klass = resource_klass_for(resource_type) - relationships = related_resource_klass.to_one_relationships_for_linkage(include_directives[:include_related]) - relationships.each do |r| - linkage_relationship_paths << "##{resource_type}.#{r.name}" - end - end - - join_manager = ActiveRelation::JoinManagerThroughPrimary.new(resource_klass: self, - source_relationship: relationship, - relationships: linkage_relationship_paths, - filters: filters) - - paginator = options[:paginator] - - # Note: We will sort by the source table. Without using unions we can't sort on a polymorphic relationship - # in any manner that makes sense - records = apply_request_settings_to_records(records: records_for_source_to_related(options), - resource_klass: resource_klass, - sort_primary: true, - primary_keys: source_ids, - paginator: paginator, - filters: filters, - join_manager: join_manager, - options: options) - - primary_key = concat_table_field(_table_name, _primary_key) - related_key = concat_table_field(_table_name, relationship.foreign_key) - related_type = concat_table_field(_table_name, relationship.polymorphic_type) - - pluck_fields = [ - Arel.sql("#{primary_key} AS #{alias_table_field(_table_name, _primary_key)}"), - Arel.sql("#{related_key} AS #{alias_table_field(_table_name, relationship.foreign_key)}"), - Arel.sql("#{related_type} AS #{alias_table_field(_table_name, relationship.polymorphic_type)}") - ] - - # Get the additional fields from each relation. There's a limitation that the fields must exist in each relation - - relation_positions = {} - relation_index = pluck_fields.length - - # Add resource specific fields - if resource_types.nil? || resource_types.length == 0 - # :nocov: - warn "No resource types found for polymorphic relationship." - # :nocov: - else - resource_types.try(:each) do |type| - related_klass = resource_klass_for(type.to_s) - - cache_field = related_klass.attribute_to_model_field(:_cache_field) if options[:cache] - - table_alias = join_manager.source_join_details(type)[:alias] - - cache_offset = relation_index - if cache_field - pluck_fields << sql_field_with_alias(table_alias, cache_field[:name]) - relation_index+= 1 - end - - relation_positions[type] = {relation_klass: related_klass, - cache_field: cache_field, - cache_offset: cache_offset} - end - end - - # Add to_one linkage fields - linkage_fields = [] - linkage_offset = relation_index - - linkage_relationship_paths.each do |linkage_relationship_path| - path = JSONAPI::Path.new(resource_klass: self, - path_string: "#{relationship.name}#{linkage_relationship_path}", - ensure_default_field: false) - - linkage_relationship = path.segments[-1].relationship - - if linkage_relationship.polymorphic? && linkage_relationship.belongs_to? - linkage_relationship.resource_types.each do |resource_type| - klass = resource_klass_for(resource_type) - linkage_fields << {relationship: linkage_relationship, resource_klass: klass} - - linkage_table_alias = join_manager.join_details_by_polymorphic_relationship(linkage_relationship, resource_type)[:alias] - primary_key = klass._primary_key - pluck_fields << sql_field_with_alias(linkage_table_alias, primary_key) - end - else - klass = linkage_relationship.resource_klass - linkage_fields << {relationship: linkage_relationship, resource_klass: klass} - - linkage_table_alias = join_manager.join_details_by_relationship(linkage_relationship)[:alias] - primary_key = klass._primary_key - pluck_fields << sql_field_with_alias(linkage_table_alias, primary_key) - end - end - - rows = records.distinct.pluck(*pluck_fields) - - related_fragments = {} - - rows.each do |row| - unless row[1].nil? || row[2].nil? - related_klass = resource_klass_for(row[2]) - - rid = JSONAPI::ResourceIdentity.new(related_klass, row[1]) - related_fragments[rid] ||= JSONAPI::ResourceFragment.new(rid) - - source_rid = JSONAPI::ResourceIdentity.new(self, row[0]) - related_fragments[rid].add_related_from(source_rid) - - if connect_source_identity - inverse_relationship = relationship._inverse_relationship - related_fragments[rid].add_related_identity(inverse_relationship.name, source_rid) if inverse_relationship.present? - end - - relation_position = relation_positions[row[2].underscore.pluralize] - model_fields = relation_position[:model_fields] - cache_field = relation_position[:cache_field] - cache_offset = relation_position[:cache_offset] - field_offset = relation_position[:field_offset] - - if cache_field - related_fragments[rid].cache = cast_to_attribute_type(row[cache_offset], cache_field[:type]) - end - - linkage_fields.each_with_index do |linkage_field_details, idx| - relationship = linkage_field_details[:relationship] - related_fragments[rid].initialize_related(relationship.name) - related_id = row[linkage_offset + idx] - if related_id - related_rid = JSONAPI::ResourceIdentity.new(linkage_field_details[:resource_klass], related_id) - related_fragments[rid].add_related_identity(relationship.name, related_rid) - end - end - end - end - - related_fragments - end - def apply_request_settings_to_records(records:, join_manager: ActiveRelation::JoinManagerThroughPrimary.new(resource_klass: self), resource_klass: self, diff --git a/lib/jsonapi/configuration.rb b/lib/jsonapi/configuration.rb index bfaeb5fd..e75287f3 100644 --- a/lib/jsonapi/configuration.rb +++ b/lib/jsonapi/configuration.rb @@ -47,6 +47,8 @@ class Configuration :default_exclude_links, :default_resource_retrieval_strategy, :use_related_resource_records_for_joins, + :default_find_related_through, + :default_find_related_through_polymorphic, :related_identities_set def initialize @@ -181,13 +183,36 @@ def initialize # per resource (or base resource) using the class method `load_resource_retrieval_strategy`. # # Available strategies: - # 'JSONAPI::ActiveRelationRetrieval' - # 'JSONAPI::ActiveRelationRetrievalV09' - # 'JSONAPI::ActiveRelationRetrievalV10' + # 'JSONAPI::ActiveRelationRetrieval' - This is the default strategy. In addition, this strategy allows for will + # use a single phased approach to retrieve primary resources when caching is not enabled for a resource class. + # When caching is enabled, the retrieval of the primary resources is a two phased approach. The first phase gets + # the ids and cache fields. The second phase gets any cache misses from the related resource. Retrieval of related + # resources is configurable with the `default_find_related_through` and `default_find_related_through_polymorphic` + # described below. + # 'JSONAPI::ActiveRelationRetrievalV09' - Retrieves resources using the v0.9.x approach. This uses rails' + # `includes` method to retrieve related models. This requires overriding the `records_for` method on the resource + # to control filtering of included resources. + # 'JSONAPI::ActiveRelationRetrievalV10' - Retrieves resources using the v0.10.x approach. This always retrieves + # related resources through the primary resource joined to the related resource (through_primary). + # Custom - Specify the a custom retrieval strategy module name as a string # :none # :self self.default_resource_retrieval_strategy = 'JSONAPI::ActiveRelationRetrieval' + # For 'JSONAPI::ActiveRelationRetrieval' we can refine how related resources are retrieved with options for + # monomorphic and polymorphic relationships. The default is :inverse for both. + # :inverse - use the inverse relationship on the related resource. This joins the related resource to the + # primary resource table. To use this a relationship to the primary resource must be defined on the related + # resource. + # :primary - use the primary resource joined with the related resources table. This results in a two phased + # querying approach. The first phase gets the ids and cache fields. The second phase gets any cache misses + # from the related resource. In the second phase permissions are not applied since they were already applied in + # the first phase. This behavior is consistent with JR v0.10.x, with the exception that when caching is disabled + # the retrieval of the primary resources does not need to be done in two phases. + + self.default_find_related_through = :inverse + self.default_find_related_through_polymorphic = :inverse + # For 'JSONAPI::ActiveRelationRetrievalV10': use a related resource's `records` when performing joins. # This setting allows included resources to account for permission scopes. It can be overridden explicitly per # relationship. Furthermore, specifying a `relation_name` on a relationship will cause this setting to be ignored. @@ -359,6 +384,10 @@ def allow_include=(allow_include) attr_writer :use_related_resource_records_for_joins + attr_writer :default_find_related_through + + attr_writer :default_find_related_through_polymorphic + attr_writer :related_identities_set end diff --git a/lib/jsonapi/relationship.rb b/lib/jsonapi/relationship.rb index 8f7d0fb1..ea12231b 100644 --- a/lib/jsonapi/relationship.rb +++ b/lib/jsonapi/relationship.rb @@ -2,14 +2,25 @@ module JSONAPI class Relationship - attr_reader :acts_as_set, :foreign_key, :options, :name, - :class_name, :polymorphic, :always_include_optional_linkage_data, :exclude_linkage_data, - :parent_resource, :eager_load_on_include, :custom_methods, - :inverse_relationship, :allow_include, :hidden, :use_related_resource_records_for_joins - - attr_writer :allow_include - - attr_accessor :_routed, :_warned_missing_route, :_warned_missing_inverse_relationship + attr_reader :acts_as_set, + :foreign_key, + :options, + :name, + :class_name, + :polymorphic, + :always_include_optional_linkage_data, + :exclude_linkage_data, + :parent_resource, + :eager_load_on_include, + :custom_methods, + :inverse_relationship, + :hidden, + :use_related_resource_records_for_joins, + :find_related_through + + attr_accessor :allow_include, + :_routed, + :_warned_missing_route, :_warned_missing_inverse_relationship def initialize(name, options = {}) @name = name.to_s @@ -43,6 +54,9 @@ def initialize(name, options = {}) @allow_include = options[:allow_include] @class_name = nil + find_related_through = options.fetch(:find_related_through, parent_resource_klass&.default_find_related_through) + @find_related_through = find_related_through&.to_sym + @inverse_relationship = options[:inverse_relationship]&.to_sym @_routed = false @@ -88,6 +102,10 @@ def inverse_relationship @inverse_relationship end + def inverse_relationship_klass + @inverse_relationship_klass ||= resource_klass._relationship(inverse_relationship) + end + def polymorphic_types return @polymorphic_types if @polymorphic_types diff --git a/lib/jsonapi/resource_common.rb b/lib/jsonapi/resource_common.rb index a96f4e49..7f06fc1c 100644 --- a/lib/jsonapi/resource_common.rb +++ b/lib/jsonapi/resource_common.rb @@ -430,6 +430,10 @@ def find_related_ids(relationship, _options) end module ClassMethods + def default_find_related_through(_polymorphic = false) + nil + end + def resource_retrieval_strategy(module_name = JSONAPI.configuration.default_resource_retrieval_strategy) module_name = module_name.to_s diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index 2fe181d4..ef555425 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -296,7 +296,18 @@ def test_index_filter_not_allowed end def test_index_include_one_level_query_count - assert_query_count(testing_v10? ? 4 : 2) do + expected_count = case + when testing_v09? + 2 + when testing_v10? + 4 + when through_primary? + 3 + else + 2 + end + + assert_query_count(expected_count) do assert_cacheable_get :index, params: {include: 'author'} end @@ -304,7 +315,18 @@ def test_index_include_one_level_query_count end def test_index_include_two_levels_query_count - assert_query_count(testing_v10? ? 6 : 3) do + expected_count = case + when testing_v09? + 3 + when testing_v10? + 6 + when through_primary? + 5 + else + 3 + end + + assert_query_count(expected_count) do assert_cacheable_get :index, params: { include: 'author,author.comments' } end assert_response :success @@ -3303,7 +3325,18 @@ def test_books_offset_pagination_no_params_includes_query_count_one_level with_jsonapi_config_changes do JSONAPI.configuration.json_key_format = :dasherized_key - assert_query_count(testing_v10? ? 5 : 3) do + expected_count = case + when testing_v09? + 3 + when testing_v10? + 5 + when through_primary? + 4 + else + 3 + end + + assert_query_count(expected_count) do assert_cacheable_get :index, params: { include: 'book-comments' } end assert_response :success @@ -3317,7 +3350,18 @@ def test_books_offset_pagination_no_params_includes_query_count_two_levels with_jsonapi_config_changes do JSONAPI.configuration.json_key_format = :dasherized_key - assert_query_count(testing_v10? ? 7 : 4) do + expected_count = case + when testing_v09? + 4 + when testing_v10? + 7 + when through_primary? + 6 + else + 4 + end + + assert_query_count(expected_count) do assert_cacheable_get :index, params: { include: 'book-comments,book-comments.author' } end assert_response :success @@ -3451,7 +3495,18 @@ def test_books_included_paged with_jsonapi_config_changes do JSONAPI.configuration.json_key_format = :dasherized_key - assert_query_count(testing_v10? ? 5 : 3) do + expected_count = case + when testing_v09? + 3 + when testing_v10? + 5 + when through_primary? + 4 + else + 3 + end + + assert_query_count(expected_count) do assert_cacheable_get :index, params: { filter: { id: '0' }, include: 'book-comments' } assert_response :success assert_equal 1, json_response['data'].size @@ -3485,7 +3540,19 @@ def test_books_banned_non_book_admin_includes_switched Api::V2::BookResource.paginator :offset JSONAPI.configuration.top_level_meta_include_record_count = true - assert_query_count(testing_v10? ? 5 : 3) do + + expected_count = case + when testing_v09? + 3 + when testing_v10? + 5 + when through_primary? + 4 + else + 3 + end + + assert_query_count(expected_count) do assert_cacheable_get :index, params: { page: { offset: 0, limit: 12 }, include: 'book-comments' } assert_response :success assert_equal 12, json_response['data'].size @@ -3504,7 +3571,19 @@ def test_books_banned_non_book_admin_includes_nested_includes JSONAPI.configuration.json_key_format = :dasherized_key JSONAPI.configuration.top_level_meta_include_record_count = true Api::V2::BookResource.paginator :offset - assert_query_count(testing_v10? ? 7 : 4) do + + expected_count = case + when testing_v09? + 4 + when testing_v10? + 7 + when through_primary? + 6 + else + 4 + end + + assert_query_count(expected_count) do assert_cacheable_get :index, params: { page: { offset: 0, limit: 12 }, include: 'book-comments.author' } assert_response :success assert_equal 12, json_response['data'].size @@ -3574,7 +3653,18 @@ def test_books_included_exclude_unapproved with_jsonapi_config_changes do JSONAPI.configuration.json_key_format = :dasherized_key - assert_query_count(testing_v10? ? 4 : 2) do + expected_count = case + when testing_v09? + 2 + when testing_v10? + 4 + when through_primary? + 3 + else + 2 + end + + assert_query_count(expected_count) do assert_cacheable_get :index, params: { filter: { id: '0,1,2,3,4' }, include: 'book-comments' } end assert_response :success diff --git a/test/test_helper.rb b/test/test_helper.rb index c61f8d2e..d0d44699 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -699,6 +699,10 @@ def assert_cacheable_get(action, **args) @queries = orig_queries end + def through_primary? + JSONAPI.configuration.default_find_related_through == :primary + end + def testing_v10? JSONAPI.configuration.default_resource_retrieval_strategy == 'JSONAPI::ActiveRelationRetrievalV10' end