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+ # Joins are being tracked as they are added to the built up relation. If the same table is added to a
127+ # relation more than once subsequent versions will be assigned an alias. Depending on the order the joins
128+ # are made the computed aliases may change. The order this library performs the joins was chosen
129+ # to prevent this. However if the relation is reordered it should result in reusing on of the earlier
130+ # aliases (in this case a plain table name). The following check will catch this an raise an exception.
131+ # An exception is appropriate because not using the correct alias could leak data due to filters and
132+ # applied permissions being performed on the wrong data.
133+ if check_for_duplicate_alias && @collected_aliases . include? ( details [ :alias ] )
134+ fail "alias '#{ details [ :alias ] } ' has already been added. Possible relation reordering"
135+ end
136+
137+ @collected_aliases << details [ :alias ]
138+ end
139+
140+ def perform_joins ( records , options )
141+ join_array = flatten_join_tree_by_depth
142+
143+ join_array . each do |level_joins |
144+ level_joins . each do |join_details |
145+ relationship = join_details [ :relationship ]
146+ relationship_details = join_details [ :relationship_details ]
147+ related_resource_klass = join_details [ :related_resource_klass ]
148+ join_type = relationship_details [ :join_type ]
149+
150+ if relationship == :root
151+ unless source_relationship
152+ add_join_details ( '' , { alias : resource_klass . _table_name , join_type : :root } )
153+ end
154+ next
155+ end
156+
157+ records , join_node = self . class . get_join_arel_node ( records , options ) { |records , options |
158+ records = related_resource_klass . join_relationship (
159+ records : records ,
160+ resource_type : related_resource_klass . _type ,
161+ join_type : join_type ,
162+ relationship : relationship ,
163+ options : options )
164+ }
165+
166+ details = { alias : self . class . alias_from_arel_node ( join_node ) , join_type : join_type }
167+
168+ if relationship == source_relationship
169+ if relationship . polymorphic? && relationship . belongs_to?
170+ add_join_details ( "##{ related_resource_klass . _type } " , details )
171+ else
172+ add_join_details ( '' , details )
173+ end
174+ end
175+
176+ # We're adding the source alias with two keys. We only want the check for duplicate aliases once.
177+ # See the note in `add_join_details`.
178+ check_for_duplicate_alias = !( relationship == source_relationship )
179+ add_join_details ( PathSegment ::Relationship . new ( relationship : relationship , resource_klass : related_resource_klass ) , details , check_for_duplicate_alias )
180+ end
181+ end
182+ records
183+ end
184+
185+ def add_join ( path , default_type = :inner , default_polymorphic_join_type = :left )
186+ if source_relationship
187+ if source_relationship . polymorphic?
188+ # Polymorphic paths will come it with the resource_type as the first segment (for example `#documents.comments`)
189+ # We just need to prepend the relationship portion the
190+ sourced_path = "#{ source_relationship . name } #{ path } "
191+ else
192+ sourced_path = "#{ source_relationship . name } .#{ path } "
193+ end
194+ else
195+ sourced_path = path
196+ end
197+
198+ join_manager , _field = parse_path_to_tree ( sourced_path , resource_klass , default_type , default_polymorphic_join_type )
199+
200+ @resource_join_tree [ :root ] . deep_merge! ( join_manager ) { |key , val , other_val |
201+ if key == :join_type
202+ if val == other_val
203+ val
204+ else
205+ :inner
206+ end
207+ end
208+ }
209+ end
210+
211+ def process_path_to_tree ( path_segments , resource_klass , default_join_type , default_polymorphic_join_type )
212+ node = {
213+ resource_klasses : {
214+ resource_klass => {
215+ relationships : { }
216+ }
217+ }
218+ }
219+
220+ segment = path_segments . shift
221+
222+ if segment . is_a? ( PathSegment ::Relationship )
223+ node [ :resource_klasses ] [ resource_klass ] [ :relationships ] [ segment . relationship ] ||= { }
224+
225+ # join polymorphic as left joins
226+ node [ :resource_klasses ] [ resource_klass ] [ :relationships ] [ segment . relationship ] [ :join_type ] ||=
227+ segment . relationship . polymorphic? ? default_polymorphic_join_type : default_join_type
228+
229+ segment . relationship . resource_types . each do |related_resource_type |
230+ related_resource_klass = resource_klass . resource_klass_for ( related_resource_type )
231+
232+ # If the resource type was specified in the path segment we want to only process the next segments for
233+ # that resource type, otherwise process for all
234+ process_all_types = !segment . path_specified_resource_klass?
235+
236+ if process_all_types || related_resource_klass == segment . resource_klass
237+ related_resource_tree = process_path_to_tree ( path_segments . dup , related_resource_klass , default_join_type , default_polymorphic_join_type )
238+ node [ :resource_klasses ] [ resource_klass ] [ :relationships ] [ segment . relationship ] . deep_merge! ( related_resource_tree )
239+ end
240+ end
241+ end
242+ node
243+ end
244+
245+ def parse_path_to_tree ( path_string , resource_klass , default_join_type = :inner , default_polymorphic_join_type = :left )
246+ path = JSONAPI ::Path . new ( resource_klass : resource_klass , path_string : path_string )
247+
248+ field = path . segments [ -1 ]
249+ return process_path_to_tree ( path . segments , resource_klass , default_join_type , default_polymorphic_join_type ) , field
250+ end
251+
252+ def add_source_relationship ( source_relationship )
253+ @source_relationship = source_relationship
254+
255+ if @source_relationship
256+ resource_klasses = { }
257+ source_relationship . resource_types . each do |related_resource_type |
258+ related_resource_klass = resource_klass . resource_klass_for ( related_resource_type )
259+ resource_klasses [ related_resource_klass ] = { relationships : { } }
260+ end
261+
262+ join_type = source_relationship . polymorphic? ? :left : :inner
263+
264+ @resource_join_tree [ :root ] [ :resource_klasses ] [ resource_klass ] [ :relationships ] [ @source_relationship ] = {
265+ source : true , resource_klasses : resource_klasses , join_type : join_type
266+ }
267+ end
268+ end
269+
270+ def add_filters ( filters )
271+ return if filters . blank?
272+ filters . each_key do |filter |
273+ # Do not add joins for filters with an apply callable. This can be overridden by setting perform_joins to true
274+ next if resource_klass . _allowed_filters [ filter ] . try ( :[] , :apply ) &&
275+ !resource_klass . _allowed_filters [ filter ] . try ( :[] , :perform_joins )
276+
277+ add_join ( filter , :left )
278+ end
279+ end
280+
281+ def add_sort_criteria ( sort_criteria )
282+ return if sort_criteria . blank?
283+
284+ sort_criteria . each do |sort |
285+ add_join ( sort [ :field ] , :left )
286+ end
287+ end
288+
289+ def add_relationships ( relationships )
290+ return if relationships . blank?
291+ relationships . each do |relationship |
292+ add_join ( relationship , :left )
293+ end
294+ end
295+ end
296+ end
297+ end
0 commit comments