module JSONAPI module ActiveRelationResourceFinder class JoinTree # Stores relationship paths starting from the resource_klass. This allows consolidation of duplicate paths from # relationships, filters and sorts. This enables the determination of table aliases as they are joined. attr_reader :resource_klass, :options, :source_relationship, :resource_joins, :joins def initialize(resource_klass:, options: {}, source_relationship: nil, relationships: nil, filters: nil, sort_criteria: nil) @resource_klass = resource_klass @options = options @resource_joins = { root: { join_type: :root, resource_klasses: { resource_klass => { relationships: {} } } } } add_source_relationship(source_relationship) add_sort_criteria(sort_criteria) add_filters(filters) add_relationships(relationships) @joins = {} construct_joins(@resource_joins) end private def add_join(path, default_type = :inner, default_polymorphic_join_type = :left) if source_relationship if source_relationship.polymorphic? # Polymorphic paths will come it with the resource_type as the first segment (for example `#documents.comments`) # We just need to prepend the relationship portion the sourced_path = "#{source_relationship.name}#{path}" else sourced_path = "#{source_relationship.name}.#{path}" end else sourced_path = path end join_tree, _field = parse_path_to_tree(sourced_path, resource_klass, default_type, default_polymorphic_join_type) @resource_joins[:root].deep_merge!(join_tree) { |key, val, other_val| if key == :join_type if val == other_val val else :inner end end } end def process_path_to_tree(path_segments, resource_klass, default_join_type, default_polymorphic_join_type) node = { resource_klasses: { resource_klass => { relationships: {} } } } segment = path_segments.shift if segment.is_a?(PathSegment::Relationship) node[:resource_klasses][resource_klass][:relationships][segment.relationship] ||= {} # join polymorphic as left joins node[:resource_klasses][resource_klass][:relationships][segment.relationship][:join_type] ||= segment.relationship.polymorphic? ? default_polymorphic_join_type : default_join_type segment.relationship.resource_types.each do |related_resource_type| related_resource_klass = resource_klass.resource_klass_for(related_resource_type) # If the resource type was specified in the path segment we want to only process the next segments for # that resource type, otherwise process for all process_all_types = !segment.path_specified_resource_klass? if process_all_types || related_resource_klass == segment.resource_klass related_resource_tree = process_path_to_tree(path_segments.dup, related_resource_klass, default_join_type, default_polymorphic_join_type) node[:resource_klasses][resource_klass][:relationships][segment.relationship].deep_merge!(related_resource_tree) end end end node end def parse_path_to_tree(path_string, resource_klass, default_join_type = :inner, default_polymorphic_join_type = :left) path = JSONAPI::Path.new(resource_klass: resource_klass, path_string: path_string) field = path.segments[-1] return process_path_to_tree(path.segments, resource_klass, default_join_type, default_polymorphic_join_type), field end def add_source_relationship(source_relationship) @source_relationship = source_relationship if @source_relationship resource_klasses = {} source_relationship.resource_types.each do |related_resource_type| related_resource_klass = resource_klass.resource_klass_for(related_resource_type) resource_klasses[related_resource_klass] = {relationships: {}} end join_type = source_relationship.polymorphic? ? :left : :inner @resource_joins[:root][:resource_klasses][resource_klass][:relationships][@source_relationship] = { source: true, resource_klasses: resource_klasses, join_type: join_type } end end def add_filters(filters) return if filters.blank? filters.each_key do |filter| # Do not add joins for filters with an apply callable. This can be overridden by setting perform_joins to true next if resource_klass._allowed_filters[filter].try(:[], :apply) && !resource_klass._allowed_filters[filter].try(:[], :perform_joins) add_join(filter) end end def add_sort_criteria(sort_criteria) return if sort_criteria.blank? sort_criteria.each do |sort| add_join(sort[:field], :left) end end def add_relationships(relationships) return if relationships.blank? relationships.each do |relationship| add_join(relationship, :left) end end # Create a nested set of hashes from an array of path components. This will be used by the `join` methods. # [post, comments] => { post: { comments: {} } def relation_join_hash(path, path_hash = {}) relation = path.shift if relation path_hash[relation] = {} relation_join_hash(path, path_hash[relation]) end path_hash end # Returns the paths from shortest to longest, allowing the capture of the table alias for earlier paths. For # example posts, posts.comments and then posts.comments.author joined in that order will allow each # alias to be determined whereas just joining posts.comments.author will only record the author alias. # ToDo: Dependence on this specialized logic should be removed in the future, if possible. def construct_joins(node, current_relation_path = [], current_relationship_path = []) node.each do |relationship, relationship_details| join_type = relationship_details[:join_type] if relationship == :root @joins[:root] = {alias: resource_klass._table_name, join_type: :root} # alias to the default table unless a source_relationship is specified unless source_relationship @joins[''] = {alias: resource_klass._table_name, join_type: :root} end return construct_joins(relationship_details[:resource_klasses].values[0][:relationships], current_relation_path, current_relationship_path) end relationship_details[:resource_klasses].each do |resource_klass, resource_details| if relationship.polymorphic? && relationship.belongs_to? current_relationship_path << "#{relationship.name.to_s}##{resource_klass._type.to_s}" relation_name = resource_klass._type.to_s.singularize else current_relationship_path << relationship.name.to_s relation_name = relationship.relation_name(options).to_s end current_relation_path << relation_name rel_path = calc_path_string(current_relationship_path) @joins[rel_path] = { alias: nil, join_type: join_type, relation_join_hash: relation_join_hash(current_relation_path.dup) } construct_joins(resource_details[:relationships], current_relation_path.dup, current_relationship_path.dup) current_relation_path.pop current_relationship_path.pop end end end def calc_path_string(path_array) if source_relationship if source_relationship.polymorphic? _relationship_name, resource_name = path_array[0].split('#', 2) path = path_array.dup path[0] = "##{resource_name}" else path = path_array.dup.drop(1) end else path = path_array.dup end path.join('.') end end end end