jsonapi-resources 0.10.0.beta1 → 0.10.0.beta2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/lib/jsonapi-resources.rb +2 -0
- data/lib/jsonapi/active_relation_resource_finder.rb +379 -226
- data/lib/jsonapi/active_relation_resource_finder/adapters/join_left_active_record_adapter.rb +27 -0
- data/lib/jsonapi/active_relation_resource_finder/join_tree.rb +165 -64
- data/lib/jsonapi/error_codes.rb +2 -0
- data/lib/jsonapi/exceptions.rb +20 -0
- data/lib/jsonapi/include_directives.rb +12 -24
- data/lib/jsonapi/path.rb +35 -0
- data/lib/jsonapi/path_segment.rb +64 -0
- data/lib/jsonapi/processor.rb +15 -9
- data/lib/jsonapi/relationship.rb +37 -6
- data/lib/jsonapi/request_parser.rb +1 -3
- data/lib/jsonapi/resource.rb +80 -50
- data/lib/jsonapi/resource_set.rb +1 -2
- data/lib/jsonapi/resources/version.rb +1 -1
- metadata +6 -3
@@ -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
|
@@ -4,22 +4,121 @@ module JSONAPI
|
|
4
4
|
# Stores relationship paths starting from the resource_klass. This allows consolidation of duplicate paths from
|
5
5
|
# relationships, filters and sorts. This enables the determination of table aliases as they are joined.
|
6
6
|
|
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)
|
8
15
|
|
9
|
-
def initialize(resource_klass:, options: {}, source_relationship: nil, filters: nil, sort_criteria: nil)
|
10
16
|
@resource_klass = resource_klass
|
11
17
|
@options = options
|
12
|
-
@source_relationship = source_relationship
|
13
|
-
|
14
|
-
@join_relationships = {}
|
15
18
|
|
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)
|
16
30
|
add_sort_criteria(sort_criteria)
|
17
31
|
add_filters(filters)
|
32
|
+
add_relationships(relationships)
|
33
|
+
|
34
|
+
@joins = {}
|
35
|
+
construct_joins(@resource_joins)
|
18
36
|
end
|
19
37
|
|
20
|
-
|
21
|
-
|
22
|
-
|
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
|
23
122
|
end
|
24
123
|
|
25
124
|
def add_filters(filters)
|
@@ -41,42 +140,10 @@ module JSONAPI
|
|
41
140
|
end
|
42
141
|
end
|
43
142
|
|
44
|
-
|
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?
|
61
145
|
relationships.each do |relationship|
|
62
|
-
|
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)
|
80
147
|
end
|
81
148
|
end
|
82
149
|
|
@@ -92,35 +159,69 @@ module JSONAPI
|
|
92
159
|
end
|
93
160
|
|
94
161
|
# 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
|
162
|
+
# example posts, posts.comments and then posts.comments.author joined in that order will allow each
|
96
163
|
# alias to be determined whereas just joining posts.comments.author will only record the author alias.
|
97
164
|
# ToDo: Dependence on this specialized logic should be removed in the future, if possible.
|
98
|
-
def
|
99
|
-
node.each do |
|
100
|
-
|
101
|
-
|
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)
|
102
179
|
end
|
103
180
|
|
104
|
-
|
105
|
-
|
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
|
106
189
|
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
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
|
+
}
|
113
199
|
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
current_relationship_path)
|
200
|
+
construct_joins(resource_details[:relationships],
|
201
|
+
current_relation_path.dup,
|
202
|
+
current_relationship_path.dup)
|
118
203
|
|
119
|
-
|
120
|
-
|
204
|
+
current_relation_path.pop
|
205
|
+
current_relationship_path.pop
|
206
|
+
end
|
121
207
|
end
|
122
|
-
|
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('.')
|
123
224
|
end
|
124
225
|
end
|
125
226
|
end
|
126
|
-
end
|
227
|
+
end
|
data/lib/jsonapi/error_codes.rb
CHANGED
@@ -20,6 +20,7 @@ module JSONAPI
|
|
20
20
|
INVALID_FILTERS_SYNTAX = '120'
|
21
21
|
SAVE_FAILED = '121'
|
22
22
|
INVALID_DATA_FORMAT = '122'
|
23
|
+
INVALID_RELATIONSHIP = '123'
|
23
24
|
BAD_REQUEST = '400'
|
24
25
|
FORBIDDEN = '403'
|
25
26
|
RECORD_NOT_FOUND = '404'
|
@@ -50,6 +51,7 @@ module JSONAPI
|
|
50
51
|
INVALID_FILTERS_SYNTAX => 'INVALID_FILTERS_SYNTAX',
|
51
52
|
SAVE_FAILED => 'SAVE_FAILED',
|
52
53
|
INVALID_DATA_FORMAT => 'INVALID_DATA_FORMAT',
|
54
|
+
INVALID_RELATIONSHIP => 'INVALID_RELATIONSHIP',
|
53
55
|
FORBIDDEN => 'FORBIDDEN',
|
54
56
|
RECORD_NOT_FOUND => 'RECORD_NOT_FOUND',
|
55
57
|
NOT_ACCEPTABLE => 'NOT_ACCEPTABLE',
|
data/lib/jsonapi/exceptions.rb
CHANGED
@@ -327,6 +327,26 @@ module JSONAPI
|
|
327
327
|
end
|
328
328
|
end
|
329
329
|
|
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
|
+
|
330
350
|
class InvalidInclude < Error
|
331
351
|
attr_accessor :relationship, :resource
|
332
352
|
|
@@ -33,35 +33,23 @@ module JSONAPI
|
|
33
33
|
|
34
34
|
private
|
35
35
|
|
36
|
-
def
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
36
|
+
def parse_include(include)
|
37
|
+
path = JSONAPI::Path.new(resource_klass: @resource_klass,
|
38
|
+
path_string: include,
|
39
|
+
ensure_default_field: false,
|
40
|
+
parse_fields: false)
|
41
41
|
|
42
|
-
|
43
|
-
current_relationship = current_resource_klass._relationships[fragment]
|
44
|
-
current_resource_klass = current_relationship.try(:resource_klass)
|
45
|
-
else
|
46
|
-
raise JSONAPI::Exceptions::InvalidInclude.new(current_resource_klass, current_path)
|
47
|
-
end
|
42
|
+
current = @include_directives_hash
|
48
43
|
|
44
|
+
path.segments.each do |segment|
|
45
|
+
relationship_name = segment.relationship.name.to_sym
|
49
46
|
|
50
|
-
current[:include_related][
|
51
|
-
current = current[:include_related][
|
47
|
+
current[:include_related][relationship_name] ||= { include: true, include_related: {} }
|
48
|
+
current = current[:include_related][relationship_name]
|
52
49
|
end
|
53
|
-
current
|
54
|
-
end
|
55
50
|
|
56
|
-
|
57
|
-
|
58
|
-
local_path = ''
|
59
|
-
|
60
|
-
parts.each do |name|
|
61
|
-
local_path += local_path.length > 0 ? ".#{name}" : name
|
62
|
-
related = get_related(local_path)
|
63
|
-
related[:include] = true
|
64
|
-
end
|
51
|
+
rescue JSONAPI::Exceptions::InvalidRelationship => _e
|
52
|
+
raise JSONAPI::Exceptions::InvalidInclude.new(@resource_klass, include)
|
65
53
|
end
|
66
54
|
end
|
67
55
|
end
|
data/lib/jsonapi/path.rb
ADDED
@@ -0,0 +1,35 @@
|
|
1
|
+
module JSONAPI
|
2
|
+
class Path
|
3
|
+
attr_reader :segments, :resource_klass
|
4
|
+
def initialize(resource_klass:,
|
5
|
+
path_string:,
|
6
|
+
ensure_default_field: true,
|
7
|
+
parse_fields: true)
|
8
|
+
@resource_klass = resource_klass
|
9
|
+
|
10
|
+
current_resource_klass = resource_klass
|
11
|
+
@segments = path_string.to_s.split('.').collect do |segment_string|
|
12
|
+
segment = PathSegment.parse(source_resource_klass: current_resource_klass,
|
13
|
+
segment_string: segment_string,
|
14
|
+
parse_fields: parse_fields)
|
15
|
+
|
16
|
+
current_resource_klass = segment.resource_klass
|
17
|
+
segment
|
18
|
+
end
|
19
|
+
|
20
|
+
if ensure_default_field && parse_fields && @segments.last.is_a?(PathSegment::Relationship)
|
21
|
+
last = @segments.last
|
22
|
+
@segments << PathSegment::Field.new(resource_klass: last.resource_klass,
|
23
|
+
field_name: last.resource_klass._primary_key)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def relationship_segments
|
28
|
+
@segments.select {|p| p.is_a?(PathSegment::Relationship)}
|
29
|
+
end
|
30
|
+
|
31
|
+
def relationship_path_string
|
32
|
+
relationship_segments.collect(&:to_s).join('.')
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
module JSONAPI
|
2
|
+
class PathSegment
|
3
|
+
def self.parse(source_resource_klass:, segment_string:, parse_fields: true)
|
4
|
+
first_part, last_part = segment_string.split('#', 2)
|
5
|
+
relationship = source_resource_klass._relationship(first_part)
|
6
|
+
|
7
|
+
if relationship
|
8
|
+
if last_part
|
9
|
+
unless relationship.resource_types.include?(last_part)
|
10
|
+
raise JSONAPI::Exceptions::InvalidRelationship.new(source_resource_klass._type, segment_string)
|
11
|
+
end
|
12
|
+
resource_klass = source_resource_klass.resource_klass_for(last_part)
|
13
|
+
end
|
14
|
+
return PathSegment::Relationship.new(relationship: relationship, resource_klass: resource_klass)
|
15
|
+
else
|
16
|
+
if last_part.blank? && parse_fields
|
17
|
+
return PathSegment::Field.new(resource_klass: source_resource_klass, field_name: first_part)
|
18
|
+
else
|
19
|
+
raise JSONAPI::Exceptions::InvalidRelationship.new(source_resource_klass._type, segment_string)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
class Relationship
|
25
|
+
attr_reader :relationship
|
26
|
+
|
27
|
+
def initialize(relationship:, resource_klass:)
|
28
|
+
@relationship = relationship
|
29
|
+
@resource_klass = resource_klass
|
30
|
+
end
|
31
|
+
|
32
|
+
def to_s
|
33
|
+
@resource_klass ? "#{relationship.name}##{resource_klass._type}" : "#{relationship.name}"
|
34
|
+
end
|
35
|
+
|
36
|
+
def resource_klass
|
37
|
+
@resource_klass || @relationship.resource_klass
|
38
|
+
end
|
39
|
+
|
40
|
+
def path_specified_resource_klass?
|
41
|
+
!@resource_klass.nil?
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
class Field
|
46
|
+
attr_reader :resource_klass, :field_name
|
47
|
+
|
48
|
+
def initialize(resource_klass:, field_name:)
|
49
|
+
@resource_klass = resource_klass
|
50
|
+
@field_name = field_name
|
51
|
+
end
|
52
|
+
|
53
|
+
def delegated_field_name
|
54
|
+
resource_klass._attribute_delegated_name(field_name)
|
55
|
+
end
|
56
|
+
|
57
|
+
def to_s
|
58
|
+
# :nocov:
|
59
|
+
field_name.to_s
|
60
|
+
# :nocov:
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|