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.
@@ -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
- # 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
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
- 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?
61
145
  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)
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 alow each
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 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)
102
179
  end
103
180
 
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
106
189
 
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
+ }
113
199
 
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)
118
203
 
119
- current_relation_path.pop
120
- current_relationship_path.pop
204
+ current_relation_path.pop
205
+ current_relationship_path.pop
206
+ end
121
207
  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('.')
123
224
  end
124
225
  end
125
226
  end
126
- end
227
+ end
@@ -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',
@@ -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 get_related(current_path)
37
- current = @include_directives_hash
38
- current_resource_klass = @resource_klass
39
- current_path.split('.').each do |fragment|
40
- fragment = fragment.to_sym
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
- if current_resource_klass
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][fragment] ||= { include: false, include_related: {} }
51
- current = current[:include_related][fragment]
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
- def parse_include(include)
57
- parts = include.split('.')
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
@@ -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