Skip to content

Commit 0bb3067

Browse files
committed
Add relationship apply callable for custom joins
Reworks the JoinTree and renamed to JoinManager
1 parent d2db72b commit 0bb3067

File tree

10 files changed

+756
-649
lines changed

10 files changed

+756
-649
lines changed

lib/jsonapi-resources.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
require 'jsonapi/callbacks'
2626
require 'jsonapi/link_builder'
2727
require 'jsonapi/active_relation_resource_finder'
28-
require 'jsonapi/active_relation_resource_finder/join_tree'
28+
require 'jsonapi/active_relation_resource_finder/join_manager'
2929
require 'jsonapi/resource_identity'
3030
require 'jsonapi/resource_fragment'
3131
require 'jsonapi/resource_id_tree'

lib/jsonapi/active_relation_resource_finder.rb

Lines changed: 108 additions & 120 deletions
Large diffs are not rendered by default.
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
module JSONAPI
2+
module ActiveRelationResourceFinder
3+
4+
# Stores relationship paths starting from the resource_klass, consolidating duplicate paths from
5+
# relationships, filters and sorts. When joins are made the table aliases are tracked in join_details
6+
class JoinManager
7+
attr_reader :resource_klass,
8+
:source_relationship,
9+
:resource_join_tree,
10+
:join_details
11+
12+
def initialize(resource_klass:,
13+
source_relationship: nil,
14+
relationships: nil,
15+
filters: nil,
16+
sort_criteria: nil)
17+
18+
@resource_klass = resource_klass
19+
@join_details = nil
20+
@collected_aliases = Set.new
21+
22+
@resource_join_tree = {
23+
root: {
24+
join_type: :root,
25+
resource_klasses: {
26+
resource_klass => {
27+
relationships: {}
28+
}
29+
}
30+
}
31+
}
32+
add_source_relationship(source_relationship)
33+
add_sort_criteria(sort_criteria)
34+
add_filters(filters)
35+
add_relationships(relationships)
36+
end
37+
38+
def join(records, options)
39+
fail "can't be joined again" if @join_details
40+
@join_details = {}
41+
perform_joins(records, options)
42+
end
43+
44+
# source details will only be on a relationship if the source_relationship is set
45+
# this method gets the join details whether they are on a relationship or are just pseudo details for the base
46+
# resource. Specify the resource type for polymorphic relationships
47+
#
48+
def source_join_details(type=nil)
49+
if source_relationship
50+
related_resource_klass = type ? resource_klass.resource_klass_for(type) : source_relationship.resource_klass
51+
segment = PathSegment::Relationship.new(relationship: source_relationship, resource_klass: related_resource_klass)
52+
details = @join_details[segment]
53+
else
54+
if type
55+
details = @join_details["##{type}"]
56+
else
57+
details = @join_details['']
58+
end
59+
end
60+
details
61+
end
62+
63+
def join_details_by_polymorphic_relationship(relationship, type)
64+
segment = PathSegment::Relationship.new(relationship: relationship, resource_klass: resource_klass.resource_klass_for(type))
65+
@join_details[segment]
66+
end
67+
68+
def join_details_by_relationship(relationship)
69+
segment = PathSegment::Relationship.new(relationship: relationship, resource_klass: relationship.resource_klass)
70+
@join_details[segment]
71+
end
72+
73+
def self.get_join_arel_node(records, options = {})
74+
init_join_sources = records.arel.join_sources
75+
init_join_sources_length = init_join_sources.length
76+
77+
records = yield(records, options)
78+
79+
join_sources = records.arel.join_sources
80+
if join_sources.length > init_join_sources_length
81+
last_join = (join_sources - init_join_sources).last
82+
else
83+
# :nocov:
84+
warn "get_join_arel_node: No join added"
85+
last_join = nil
86+
# :nocov:
87+
end
88+
89+
return records, last_join
90+
end
91+
92+
def self.alias_from_arel_node(node)
93+
case node.left
94+
when Arel::Table
95+
node.left.name
96+
when Arel::Nodes::TableAlias
97+
node.left.right
98+
when Arel::Nodes::StringJoin
99+
# :nocov:
100+
warn "alias_from_arel_node: Unsupported join type - use custom filtering and sorting"
101+
nil
102+
# :nocov:
103+
end
104+
end
105+
106+
private
107+
108+
def flatten_join_tree_by_depth(join_array = [], node = @resource_join_tree, level = 0)
109+
join_array[level] = [] unless join_array[level]
110+
111+
node.each do |relationship, relationship_details|
112+
relationship_details[:resource_klasses].each do |related_resource_klass, resource_details|
113+
join_array[level] << { relationship: relationship,
114+
relationship_details: relationship_details,
115+
related_resource_klass: related_resource_klass}
116+
flatten_join_tree_by_depth(join_array, resource_details[:relationships], level+1)
117+
end
118+
end
119+
join_array
120+
end
121+
122+
def add_join_details(join_key, details, check_for_duplicate_alias = true)
123+
fail "details already set" if @join_details.has_key?(join_key)
124+
@join_details[join_key] = details
125+
126+
if check_for_duplicate_alias && @collected_aliases.include?(details[:alias])
127+
fail "alias '#{details[:alias]}' has already been added. Possible relation reordering"
128+
end
129+
130+
@collected_aliases << details[:alias]
131+
end
132+
133+
def perform_joins(records, options)
134+
join_array = flatten_join_tree_by_depth
135+
136+
join_array.each do |level_joins|
137+
level_joins.each do |join_details|
138+
relationship = join_details[:relationship]
139+
relationship_details = join_details[:relationship_details]
140+
related_resource_klass = join_details[:related_resource_klass]
141+
join_type = relationship_details[:join_type]
142+
143+
if relationship == :root
144+
unless source_relationship
145+
add_join_details('', {alias: resource_klass._table_name, join_type: :root})
146+
end
147+
next
148+
end
149+
150+
records, join_node = self.class.get_join_arel_node(records, options) {|records, options|
151+
records = related_resource_klass.join_relationship(
152+
records: records,
153+
resource_type: related_resource_klass._type,
154+
join_type: join_type,
155+
relationship: relationship,
156+
options: options)
157+
}
158+
159+
details = {alias: self.class.alias_from_arel_node(join_node), join_type: join_type}
160+
161+
if relationship == source_relationship
162+
if relationship.polymorphic? && relationship.belongs_to?
163+
add_join_details("##{related_resource_klass._type}", details)
164+
else
165+
add_join_details('', details)
166+
end
167+
end
168+
169+
check_for_duplicate_alias = !(relationship == source_relationship)
170+
add_join_details(PathSegment::Relationship.new(relationship: relationship, resource_klass: related_resource_klass), details, check_for_duplicate_alias)
171+
end
172+
end
173+
records
174+
end
175+
176+
def add_join(path, default_type = :inner, default_polymorphic_join_type = :left)
177+
if source_relationship
178+
if source_relationship.polymorphic?
179+
# Polymorphic paths will come it with the resource_type as the first segment (for example `#documents.comments`)
180+
# We just need to prepend the relationship portion the
181+
sourced_path = "#{source_relationship.name}#{path}"
182+
else
183+
sourced_path = "#{source_relationship.name}.#{path}"
184+
end
185+
else
186+
sourced_path = path
187+
end
188+
189+
join_manager, _field = parse_path_to_tree(sourced_path, resource_klass, default_type, default_polymorphic_join_type)
190+
191+
@resource_join_tree[:root].deep_merge!(join_manager) { |key, val, other_val|
192+
if key == :join_type
193+
if val == other_val
194+
val
195+
else
196+
:inner
197+
end
198+
end
199+
}
200+
end
201+
202+
def process_path_to_tree(path_segments, resource_klass, default_join_type, default_polymorphic_join_type)
203+
node = {
204+
resource_klasses: {
205+
resource_klass => {
206+
relationships: {}
207+
}
208+
}
209+
}
210+
211+
segment = path_segments.shift
212+
213+
if segment.is_a?(PathSegment::Relationship)
214+
node[:resource_klasses][resource_klass][:relationships][segment.relationship] ||= {}
215+
216+
# join polymorphic as left joins
217+
node[:resource_klasses][resource_klass][:relationships][segment.relationship][:join_type] ||=
218+
segment.relationship.polymorphic? ? default_polymorphic_join_type : default_join_type
219+
220+
segment.relationship.resource_types.each do |related_resource_type|
221+
related_resource_klass = resource_klass.resource_klass_for(related_resource_type)
222+
223+
# If the resource type was specified in the path segment we want to only process the next segments for
224+
# that resource type, otherwise process for all
225+
process_all_types = !segment.path_specified_resource_klass?
226+
227+
if process_all_types || related_resource_klass == segment.resource_klass
228+
related_resource_tree = process_path_to_tree(path_segments.dup, related_resource_klass, default_join_type, default_polymorphic_join_type)
229+
node[:resource_klasses][resource_klass][:relationships][segment.relationship].deep_merge!(related_resource_tree)
230+
end
231+
end
232+
end
233+
node
234+
end
235+
236+
def parse_path_to_tree(path_string, resource_klass, default_join_type = :inner, default_polymorphic_join_type = :left)
237+
path = JSONAPI::Path.new(resource_klass: resource_klass, path_string: path_string)
238+
239+
field = path.segments[-1]
240+
return process_path_to_tree(path.segments, resource_klass, default_join_type, default_polymorphic_join_type), field
241+
end
242+
243+
def add_source_relationship(source_relationship)
244+
@source_relationship = source_relationship
245+
246+
if @source_relationship
247+
resource_klasses = {}
248+
source_relationship.resource_types.each do |related_resource_type|
249+
related_resource_klass = resource_klass.resource_klass_for(related_resource_type)
250+
resource_klasses[related_resource_klass] = {relationships: {}}
251+
end
252+
253+
join_type = source_relationship.polymorphic? ? :left : :inner
254+
255+
@resource_join_tree[:root][:resource_klasses][resource_klass][:relationships][@source_relationship] = {
256+
source: true, resource_klasses: resource_klasses, join_type: join_type
257+
}
258+
end
259+
end
260+
261+
def add_filters(filters)
262+
return if filters.blank?
263+
filters.each_key do |filter|
264+
# Do not add joins for filters with an apply callable. This can be overridden by setting perform_joins to true
265+
next if resource_klass._allowed_filters[filter].try(:[], :apply) &&
266+
!resource_klass._allowed_filters[filter].try(:[], :perform_joins)
267+
268+
add_join(filter, :left)
269+
end
270+
end
271+
272+
def add_sort_criteria(sort_criteria)
273+
return if sort_criteria.blank?
274+
275+
sort_criteria.each do |sort|
276+
add_join(sort[:field], :left)
277+
end
278+
end
279+
280+
def add_relationships(relationships)
281+
return if relationships.blank?
282+
relationships.each do |relationship|
283+
add_join(relationship, :left)
284+
end
285+
end
286+
end
287+
end
288+
end

0 commit comments

Comments
 (0)