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.
@@ -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