jsonapi-resources 0.10.0.beta1 → 0.10.0.beta2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|