Skip to content

Commit c4242e3

Browse files
authored
Merge pull request #1218 from cerebris/always_includes
Always include to_one linkage
2 parents 69b1d73 + d82073c commit c4242e3

33 files changed

+2004
-3460
lines changed

Gemfile

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,5 @@ when 'master'
1919
when 'default'
2020
gem 'railties', '>= 5.0'
2121
else
22-
gem 'left_join' if version.start_with?('4.2')
2322
gem 'railties', "~> #{version}"
2423
end

lib/jsonapi-resources.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,5 @@
3030
require 'jsonapi/resource_fragment'
3131
require 'jsonapi/resource_id_tree'
3232
require 'jsonapi/resource_set'
33+
require 'jsonapi/path'
34+
require 'jsonapi/path_segment'

lib/jsonapi/active_relation_resource_finder.rb

Lines changed: 379 additions & 226 deletions
Large diffs are not rendered by default.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
module JSONAPI
2+
module ActiveRelationResourceFinder
3+
module Adapters
4+
module JoinLeftActiveRecordAdapter
5+
6+
# Extends left_joins functionality to rails 4, and uses the same logic for rails 5.0.x and 5.1.x
7+
# The default left_joins logic of rails 5.2.x is used. This results in and extra join in some cases. For
8+
# example Post.joins(:comments).joins_left(comments: :author) will join the comments table twice,
9+
# once inner and once left in 5.2, but only as inner in earlier versions.
10+
def joins_left(*columns)
11+
if Rails::VERSION::MAJOR >= 5 && ActiveRecord::VERSION::MINOR >= 2
12+
left_joins(columns)
13+
else
14+
join_dependency = ActiveRecord::Associations::JoinDependency.new(self, columns, [])
15+
joins(join_dependency)
16+
end
17+
end
18+
19+
alias_method :join_left, :joins_left
20+
end
21+
22+
if defined?(ActiveRecord)
23+
ActiveRecord::Base.extend JoinLeftActiveRecordAdapter
24+
end
25+
end
26+
end
27+
end

lib/jsonapi/active_relation_resource_finder/join_tree.rb

Lines changed: 165 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,121 @@ class JoinTree
44
# Stores relationship paths starting from the resource_klass. This allows consolidation of duplicate paths from
55
# relationships, filters and sorts. This enables the determination of table aliases as they are joined.
66

7-
attr_reader :resource_klass, :options, :source_relationship
7+
attr_reader :resource_klass, :options, :source_relationship, :resource_joins, :joins
8+
9+
def initialize(resource_klass:,
10+
options: {},
11+
source_relationship: nil,
12+
relationships: nil,
13+
filters: nil,
14+
sort_criteria: nil)
815

9-
def initialize(resource_klass:, options: {}, source_relationship: nil, filters: nil, sort_criteria: nil)
1016
@resource_klass = resource_klass
1117
@options = options
12-
@source_relationship = source_relationship
13-
14-
@join_relationships = {}
1518

19+
@resource_joins = {
20+
root: {
21+
join_type: :root,
22+
resource_klasses: {
23+
resource_klass => {
24+
relationships: {}
25+
}
26+
}
27+
}
28+
}
29+
add_source_relationship(source_relationship)
1630
add_sort_criteria(sort_criteria)
1731
add_filters(filters)
32+
add_relationships(relationships)
33+
34+
@joins = {}
35+
construct_joins(@resource_joins)
1836
end
1937

20-
# A hash of joins that can be used to create the required joins
21-
def get_joins
22-
walk_relation_node(@join_relationships)
38+
private
39+
40+
def add_join(path, default_type = :inner, default_polymorphic_join_type = :left)
41+
if source_relationship
42+
if source_relationship.polymorphic?
43+
# Polymorphic paths will come it with the resource_type as the first segment (for example `#documents.comments`)
44+
# We just need to prepend the relationship portion the
45+
sourced_path = "#{source_relationship.name}#{path}"
46+
else
47+
sourced_path = "#{source_relationship.name}.#{path}"
48+
end
49+
else
50+
sourced_path = path
51+
end
52+
53+
join_tree, _field = parse_path_to_tree(sourced_path, resource_klass, default_type, default_polymorphic_join_type)
54+
55+
@resource_joins[:root].deep_merge!(join_tree) { |key, val, other_val|
56+
if key == :join_type
57+
if val == other_val
58+
val
59+
else
60+
:inner
61+
end
62+
end
63+
}
64+
end
65+
66+
def process_path_to_tree(path_segments, resource_klass, default_join_type, default_polymorphic_join_type)
67+
node = {
68+
resource_klasses: {
69+
resource_klass => {
70+
relationships: {}
71+
}
72+
}
73+
}
74+
75+
segment = path_segments.shift
76+
77+
if segment.is_a?(PathSegment::Relationship)
78+
node[:resource_klasses][resource_klass][:relationships][segment.relationship] ||= {}
79+
80+
# join polymorphic as left joins
81+
node[:resource_klasses][resource_klass][:relationships][segment.relationship][:join_type] ||=
82+
segment.relationship.polymorphic? ? default_polymorphic_join_type : default_join_type
83+
84+
segment.relationship.resource_types.each do |related_resource_type|
85+
related_resource_klass = resource_klass.resource_klass_for(related_resource_type)
86+
87+
# If the resource type was specified in the path segment we want to only process the next segments for
88+
# that resource type, otherwise process for all
89+
process_all_types = !segment.path_specified_resource_klass?
90+
91+
if process_all_types || related_resource_klass == segment.resource_klass
92+
related_resource_tree = process_path_to_tree(path_segments.dup, related_resource_klass, default_join_type, default_polymorphic_join_type)
93+
node[:resource_klasses][resource_klass][:relationships][segment.relationship].deep_merge!(related_resource_tree)
94+
end
95+
end
96+
end
97+
node
98+
end
99+
100+
def parse_path_to_tree(path_string, resource_klass, default_join_type = :inner, default_polymorphic_join_type = :left)
101+
path = JSONAPI::Path.new(resource_klass: resource_klass, path_string: path_string)
102+
field = path.segments[-1]
103+
return process_path_to_tree(path.segments, resource_klass, default_join_type, default_polymorphic_join_type), field
104+
end
105+
106+
def add_source_relationship(source_relationship)
107+
@source_relationship = source_relationship
108+
109+
if @source_relationship
110+
resource_klasses = {}
111+
source_relationship.resource_types.each do |related_resource_type|
112+
related_resource_klass = resource_klass.resource_klass_for(related_resource_type)
113+
resource_klasses[related_resource_klass] = {relationships: {}}
114+
end
115+
116+
join_type = source_relationship.polymorphic? ? :left : :inner
117+
118+
@resource_joins[:root][:resource_klasses][resource_klass][:relationships][@source_relationship] = {
119+
source: true, resource_klasses: resource_klasses, join_type: join_type
120+
}
121+
end
23122
end
24123

25124
def add_filters(filters)
@@ -41,42 +140,10 @@ def add_sort_criteria(sort_criteria)
41140
end
42141
end
43142

44-
private
45-
46-
def add_join_relationship(parent_joins, join_name, relation_name, type)
47-
parent_joins[join_name] ||= {relation_name: relation_name, relationship: {}, type: type}
48-
if parent_joins[join_name][:type] == :left && type == :inner
49-
parent_joins[join_name][:type] = :inner
50-
end
51-
parent_joins[join_name][:relationship]
52-
end
53-
54-
def add_join(path, default_type = :inner)
55-
relationships, _field = resource_klass.parse_relationship_path(path)
56-
57-
current_joins = @join_relationships
58-
59-
terminated = false
60-
143+
def add_relationships(relationships)
144+
return if relationships.blank?
61145
relationships.each do |relationship|
62-
if terminated
63-
# ToDo: Relax this, if possible
64-
# :nocov:
65-
warn "Can not nest joins under polymorphic join"
66-
# :nocov:
67-
end
68-
69-
if relationship.polymorphic?
70-
relation_names = relationship.polymorphic_relations
71-
relation_names.each do |relation_name|
72-
join_name = "#{relationship.name}[#{relation_name}]"
73-
add_join_relationship(current_joins, join_name, relation_name, :left)
74-
end
75-
terminated = true
76-
else
77-
join_name = relationship.name
78-
current_joins = add_join_relationship(current_joins, join_name, relationship.relation_name(options), default_type)
79-
end
146+
add_join(relationship, :left)
80147
end
81148
end
82149

@@ -92,35 +159,69 @@ def relation_join_hash(path, path_hash = {})
92159
end
93160

94161
# Returns the paths from shortest to longest, allowing the capture of the table alias for earlier paths. For
95-
# example posts, posts.comments and then posts.comments.author joined in that order will alow each
162+
# example posts, posts.comments and then posts.comments.author joined in that order will allow each
96163
# alias to be determined whereas just joining posts.comments.author will only record the author alias.
97164
# ToDo: Dependence on this specialized logic should be removed in the future, if possible.
98-
def walk_relation_node(node, paths = {}, current_relation_path = [], current_relationship_path = [])
99-
node.each do |key, value|
100-
if current_relation_path.empty? && source_relationship
101-
current_relation_path << source_relationship.relation_name(options)
165+
def construct_joins(node, current_relation_path = [], current_relationship_path = [])
166+
node.each do |relationship, relationship_details|
167+
join_type = relationship_details[:join_type]
168+
if relationship == :root
169+
@joins[:root] = {alias: resource_klass._table_name, join_type: :root}
170+
171+
# alias to the default table unless a source_relationship is specified
172+
unless source_relationship
173+
@joins[''] = {alias: resource_klass._table_name, join_type: :root}
174+
end
175+
176+
return construct_joins(relationship_details[:resource_klasses].values[0][:relationships],
177+
current_relation_path,
178+
current_relationship_path)
102179
end
103180

104-
current_relation_path << value[:relation_name].to_s
105-
current_relationship_path << key.to_s
181+
relationship_details[:resource_klasses].each do |resource_klass, resource_details|
182+
if relationship.polymorphic? && relationship.belongs_to?
183+
current_relationship_path << "#{relationship.name.to_s}##{resource_klass._type.to_s}"
184+
relation_name = resource_klass._type.to_s.singularize
185+
else
186+
current_relationship_path << relationship.name.to_s
187+
relation_name = relationship.relation_name(options).to_s
188+
end
106189

107-
rel_path = current_relationship_path.join('.')
108-
paths[rel_path] ||= {
109-
alias: nil,
110-
join_type: value[:type],
111-
relation_join_hash: relation_join_hash(current_relation_path.dup)
112-
}
190+
current_relation_path << relation_name
191+
192+
rel_path = calc_path_string(current_relationship_path)
193+
194+
@joins[rel_path] = {
195+
alias: nil,
196+
join_type: join_type,
197+
relation_join_hash: relation_join_hash(current_relation_path.dup)
198+
}
113199

114-
walk_relation_node(value[:relationship],
115-
paths,
116-
current_relation_path,
117-
current_relationship_path)
200+
construct_joins(resource_details[:relationships],
201+
current_relation_path.dup,
202+
current_relationship_path.dup)
118203

119-
current_relation_path.pop
120-
current_relationship_path.pop
204+
current_relation_path.pop
205+
current_relationship_path.pop
206+
end
121207
end
122-
paths
208+
end
209+
210+
def calc_path_string(path_array)
211+
if source_relationship
212+
if source_relationship.polymorphic?
213+
_relationship_name, resource_name = path_array[0].split('#', 2)
214+
path = path_array.dup
215+
path[0] = "##{resource_name}"
216+
else
217+
path = path_array.dup.drop(1)
218+
end
219+
else
220+
path = path_array.dup
221+
end
222+
223+
path.join('.')
123224
end
124225
end
125226
end
126-
end
227+
end

lib/jsonapi/error_codes.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ module JSONAPI
2020
INVALID_FILTERS_SYNTAX = '120'
2121
SAVE_FAILED = '121'
2222
INVALID_DATA_FORMAT = '122'
23+
INVALID_RELATIONSHIP = '123'
2324
BAD_REQUEST = '400'
2425
FORBIDDEN = '403'
2526
RECORD_NOT_FOUND = '404'
@@ -50,6 +51,7 @@ module JSONAPI
5051
INVALID_FILTERS_SYNTAX => 'INVALID_FILTERS_SYNTAX',
5152
SAVE_FAILED => 'SAVE_FAILED',
5253
INVALID_DATA_FORMAT => 'INVALID_DATA_FORMAT',
54+
INVALID_RELATIONSHIP => 'INVALID_RELATIONSHIP',
5355
FORBIDDEN => 'FORBIDDEN',
5456
RECORD_NOT_FOUND => 'RECORD_NOT_FOUND',
5557
NOT_ACCEPTABLE => 'NOT_ACCEPTABLE',

lib/jsonapi/exceptions.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,26 @@ def errors
327327
end
328328
end
329329

330+
class InvalidRelationship < Error
331+
attr_accessor :relationship_name, :type
332+
333+
def initialize(type, relationship_name, error_object_overrides = {})
334+
@relationship_name = relationship_name
335+
@type = type
336+
super(error_object_overrides)
337+
end
338+
339+
def errors
340+
[create_error_object(code: JSONAPI::INVALID_RELATIONSHIP,
341+
status: :bad_request,
342+
title: I18n.translate('jsonapi-resources.exceptions.invalid_relationship.title',
343+
default: 'Invalid relationship'),
344+
detail: I18n.translate('jsonapi-resources.exceptions.invalid_relationship.detail',
345+
default: "#{relationship_name} is not a valid field for #{type}.",
346+
relationship_name: relationship_name, type: type))]
347+
end
348+
end
349+
330350
class InvalidInclude < Error
331351
attr_accessor :relationship, :resource
332352

0 commit comments

Comments
 (0)