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