Skip to content

Commit 6ef031b

Browse files
authored
Merge pull request #1221 from cerebris/related_reworked
Add relationship apply_join callable for custom joins
2 parents c7872fd + 80285b9 commit 6ef031b

File tree

10 files changed

+765
-649
lines changed

10 files changed

+765
-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: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
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

Comments
 (0)