graphiti-rb 1.0.alpha.1

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.
Files changed (95) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +11 -0
  3. data/.rspec +3 -0
  4. data/.ruby-version +1 -0
  5. data/.travis.yml +20 -0
  6. data/.yardopts +2 -0
  7. data/Appraisals +11 -0
  8. data/CODE_OF_CONDUCT.md +49 -0
  9. data/Gemfile +12 -0
  10. data/Guardfile +32 -0
  11. data/LICENSE.txt +21 -0
  12. data/README.md +75 -0
  13. data/Rakefile +15 -0
  14. data/bin/appraisal +17 -0
  15. data/bin/console +14 -0
  16. data/bin/rspec +17 -0
  17. data/bin/setup +8 -0
  18. data/gemfiles/rails_4.gemfile +17 -0
  19. data/gemfiles/rails_5.gemfile +17 -0
  20. data/graphiti.gemspec +34 -0
  21. data/lib/generators/jsonapi/resource_generator.rb +169 -0
  22. data/lib/generators/jsonapi/templates/application_resource.rb.erb +15 -0
  23. data/lib/generators/jsonapi/templates/controller.rb.erb +61 -0
  24. data/lib/generators/jsonapi/templates/create_request_spec.rb.erb +30 -0
  25. data/lib/generators/jsonapi/templates/destroy_request_spec.rb.erb +20 -0
  26. data/lib/generators/jsonapi/templates/index_request_spec.rb.erb +22 -0
  27. data/lib/generators/jsonapi/templates/resource.rb.erb +11 -0
  28. data/lib/generators/jsonapi/templates/resource_reads_spec.rb.erb +62 -0
  29. data/lib/generators/jsonapi/templates/resource_writes_spec.rb.erb +63 -0
  30. data/lib/generators/jsonapi/templates/show_request_spec.rb.erb +21 -0
  31. data/lib/generators/jsonapi/templates/update_request_spec.rb.erb +34 -0
  32. data/lib/graphiti-rb.rb +1 -0
  33. data/lib/graphiti.rb +121 -0
  34. data/lib/graphiti/adapters/abstract.rb +516 -0
  35. data/lib/graphiti/adapters/active_record.rb +6 -0
  36. data/lib/graphiti/adapters/active_record/base.rb +249 -0
  37. data/lib/graphiti/adapters/active_record/belongs_to_sideload.rb +17 -0
  38. data/lib/graphiti/adapters/active_record/has_many_sideload.rb +17 -0
  39. data/lib/graphiti/adapters/active_record/has_one_sideload.rb +17 -0
  40. data/lib/graphiti/adapters/active_record/inferrence.rb +12 -0
  41. data/lib/graphiti/adapters/active_record/many_to_many_sideload.rb +30 -0
  42. data/lib/graphiti/adapters/null.rb +236 -0
  43. data/lib/graphiti/base.rb +70 -0
  44. data/lib/graphiti/configuration.rb +21 -0
  45. data/lib/graphiti/context.rb +16 -0
  46. data/lib/graphiti/deserializer.rb +208 -0
  47. data/lib/graphiti/errors.rb +309 -0
  48. data/lib/graphiti/extensions/boolean_attribute.rb +33 -0
  49. data/lib/graphiti/extensions/extra_attribute.rb +70 -0
  50. data/lib/graphiti/extensions/temp_id.rb +26 -0
  51. data/lib/graphiti/filter_operators.rb +25 -0
  52. data/lib/graphiti/hash_renderer.rb +57 -0
  53. data/lib/graphiti/jsonapi_serializable_ext.rb +50 -0
  54. data/lib/graphiti/query.rb +251 -0
  55. data/lib/graphiti/rails.rb +28 -0
  56. data/lib/graphiti/railtie.rb +74 -0
  57. data/lib/graphiti/renderer.rb +60 -0
  58. data/lib/graphiti/resource.rb +110 -0
  59. data/lib/graphiti/resource/configuration.rb +239 -0
  60. data/lib/graphiti/resource/dsl.rb +138 -0
  61. data/lib/graphiti/resource/interface.rb +32 -0
  62. data/lib/graphiti/resource/polymorphism.rb +68 -0
  63. data/lib/graphiti/resource/sideloading.rb +102 -0
  64. data/lib/graphiti/resource_proxy.rb +127 -0
  65. data/lib/graphiti/responders.rb +19 -0
  66. data/lib/graphiti/runner.rb +25 -0
  67. data/lib/graphiti/scope.rb +98 -0
  68. data/lib/graphiti/scoping/base.rb +99 -0
  69. data/lib/graphiti/scoping/default_filter.rb +58 -0
  70. data/lib/graphiti/scoping/extra_attributes.rb +29 -0
  71. data/lib/graphiti/scoping/filter.rb +93 -0
  72. data/lib/graphiti/scoping/filterable.rb +36 -0
  73. data/lib/graphiti/scoping/paginate.rb +87 -0
  74. data/lib/graphiti/scoping/sort.rb +64 -0
  75. data/lib/graphiti/sideload.rb +281 -0
  76. data/lib/graphiti/sideload/belongs_to.rb +34 -0
  77. data/lib/graphiti/sideload/has_many.rb +16 -0
  78. data/lib/graphiti/sideload/has_one.rb +9 -0
  79. data/lib/graphiti/sideload/many_to_many.rb +24 -0
  80. data/lib/graphiti/sideload/polymorphic_belongs_to.rb +108 -0
  81. data/lib/graphiti/stats/dsl.rb +89 -0
  82. data/lib/graphiti/stats/payload.rb +49 -0
  83. data/lib/graphiti/types.rb +172 -0
  84. data/lib/graphiti/util/attribute_check.rb +88 -0
  85. data/lib/graphiti/util/field_params.rb +16 -0
  86. data/lib/graphiti/util/hash.rb +51 -0
  87. data/lib/graphiti/util/hooks.rb +33 -0
  88. data/lib/graphiti/util/include_params.rb +39 -0
  89. data/lib/graphiti/util/persistence.rb +219 -0
  90. data/lib/graphiti/util/relationship_payload.rb +64 -0
  91. data/lib/graphiti/util/serializer_attributes.rb +97 -0
  92. data/lib/graphiti/util/sideload.rb +33 -0
  93. data/lib/graphiti/util/validation_response.rb +78 -0
  94. data/lib/graphiti/version.rb +3 -0
  95. metadata +317 -0
@@ -0,0 +1,87 @@
1
+ module Graphiti
2
+ # Apply pagination logic to the scope
3
+ #
4
+ # If the user requests a page size greater than +MAX_PAGE_SIZE+,
5
+ # a +Graphiti::Errors::UnsupportedPageSize+ error will be raised.
6
+ #
7
+ # Notably, this will not fire when the `default: false` option is passed.
8
+ # This is the case for sideloads - if the user requests "give me the post
9
+ # and its comments", we shouldn't implicitly limit those comments to 20.
10
+ # *BUT* if the user requests, "give me the post and 3 of its comments", we
11
+ # *should* honor that pagination.
12
+ #
13
+ # This can be confusing because there are also 'default' and 'customized'
14
+ # pagination procs. The default comes 'for free'. Customized pagination
15
+ # looks like
16
+ #
17
+ # class PostResource < ApplicationResource
18
+ # paginate do |scope, current_page, per_page|
19
+ # # ... the custom logic ...
20
+ # end
21
+ # end
22
+ #
23
+ # We should use the default unless the user has customized.
24
+ # @see Resource.paginate
25
+ class Scoping::Paginate < Scoping::Base
26
+ MAX_PAGE_SIZE = 1_000
27
+
28
+ # Apply the pagination logic. Raise error if over the max page size.
29
+ # @return the scope object we are chaining/modifying
30
+ def apply
31
+ if size > MAX_PAGE_SIZE
32
+ raise Graphiti::Errors::UnsupportedPageSize
33
+ .new(size, MAX_PAGE_SIZE)
34
+ elsif requested? && @opts[:sideload_parent_length].to_i > 1
35
+ raise Graphiti::Errors::UnsupportedPagination
36
+ else
37
+ super
38
+ end
39
+ end
40
+
41
+ # We want to apply this logic unless we've explicitly received the
42
+ # +default: false+ option. In that case, only apply if pagination
43
+ # was explicitly specified in the request.
44
+ #
45
+ # @return [Boolean] should we apply this logic?
46
+ def apply?
47
+ if @opts[:default_paginate] == false
48
+ requested?
49
+ else
50
+ true
51
+ end
52
+ end
53
+
54
+ # @return [Proc, Nil] the custom pagination proc
55
+ def custom_scope
56
+ resource.pagination
57
+ end
58
+
59
+ # Apply default pagination proc via the Resource adapter
60
+ def apply_standard_scope
61
+ resource.adapter.paginate(@scope, number, size)
62
+ end
63
+
64
+ # Apply the custom pagination proc
65
+ def apply_custom_scope
66
+ custom_scope.call(@scope, number, size, resource.context)
67
+ end
68
+
69
+ private
70
+
71
+ def requested?
72
+ not [page_param[:size], page_param[:number]].all?(&:nil?)
73
+ end
74
+
75
+ def page_param
76
+ @page_param ||= (query_hash[:page] || {})
77
+ end
78
+
79
+ def number
80
+ (page_param[:number] || 1).to_i
81
+ end
82
+
83
+ def size
84
+ (page_param[:size] || resource.default_page_size).to_i
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,64 @@
1
+ module Graphiti
2
+ # Apply sorting logic to the scope.
3
+ #
4
+ # By default, sorting comes 'for free'. To specify a custom sorting proc:
5
+ #
6
+ # class PostResource < ApplicationResource
7
+ # sort do |scope, att, dir|
8
+ # int = dir == :desc ? -1 : 1
9
+ # scope.sort_by { |x| x[att] * int }
10
+ # end
11
+ # end
12
+ #
13
+ # The sorting proc will be called once for each sort att/dir requested.
14
+ # @see Resource.sort
15
+ class Scoping::Sort < Scoping::Base
16
+ # @return [Proc, Nil] The custom proc specified by Resource DSL
17
+ def custom_scope
18
+ resource.sort_all
19
+ end
20
+
21
+ # Apply default scope logic via Resource adapter
22
+ # @return the scope we are chaining/modifying
23
+ def apply_standard_scope
24
+ each_sort do |attribute, direction|
25
+ if sort = resource.sorts[attribute]
26
+ @scope = sort.call(@scope, direction)
27
+ else
28
+ @scope = resource.adapter.order(@scope, attribute, direction)
29
+ end
30
+ end
31
+ @scope
32
+ end
33
+
34
+ # Apply custom scoping proc configured via Resource DSL
35
+ # @return the scope we are chaining/modifying
36
+ def apply_custom_scope
37
+ each_sort do |attribute, direction|
38
+ @scope = custom_scope
39
+ .call(@scope, attribute, direction, resource.context)
40
+ end
41
+ @scope
42
+ end
43
+
44
+ private
45
+
46
+ def each_sort
47
+ sort_param.each do |sort_hash|
48
+ attribute = sort_hash.keys.first
49
+ direction = sort_hash.values.first
50
+ yield attribute, direction
51
+ end
52
+ end
53
+
54
+ def sort_param
55
+ @sort_param ||= begin
56
+ if query_hash[:sort].blank?
57
+ resource.default_sort
58
+ else
59
+ query_hash[:sort]
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,281 @@
1
+ module Graphiti
2
+ class Sideload
3
+ HOOK_ACTIONS = [:save, :create, :update, :destroy, :disassociate]
4
+ TYPES = [:has_many, :belongs_to, :has_one, :many_to_many]
5
+
6
+ attr_reader :name,
7
+ :resource_class,
8
+ :parent_resource_class,
9
+ :foreign_key,
10
+ :primary_key,
11
+ :parent,
12
+ :group_name
13
+
14
+ class_attribute :scope_proc,
15
+ :assign_proc,
16
+ :assign_each_proc,
17
+ :params_proc,
18
+ :pre_load_proc
19
+
20
+ def initialize(name, opts)
21
+ @name = name
22
+ @parent_resource_class = opts[:parent_resource]
23
+ @resource_class = opts[:resource]
24
+ @primary_key = opts[:primary_key]
25
+ @foreign_key = opts[:foreign_key]
26
+ @type = opts[:type]
27
+ @base_scope = opts[:base_scope]
28
+ @readable = opts[:readable]
29
+ @writable = opts[:writable]
30
+ @as = opts[:as]
31
+
32
+ # polymorphic-specific
33
+ @group_name = opts[:group_name]
34
+ @polymorphic_child = opts[:polymorphic_child]
35
+ @parent = opts[:parent]
36
+ if polymorphic_child?
37
+ parent.resource.polymorphic << resource_class
38
+ end
39
+ end
40
+
41
+ def self.scope(&blk)
42
+ self.scope_proc = blk
43
+ end
44
+
45
+ def self.assign(&blk)
46
+ self.assign_proc = blk
47
+ end
48
+
49
+ def self.assign_each(&blk)
50
+ self.assign_each_proc = blk
51
+ end
52
+
53
+ def self.params(&blk)
54
+ self.params_proc = blk
55
+ end
56
+
57
+ def self.pre_load(&blk)
58
+ self.pre_load_proc = blk
59
+ end
60
+
61
+ def readable?
62
+ !!@readable
63
+ end
64
+
65
+ def writable?
66
+ !!@writable
67
+ end
68
+
69
+ def polymorphic_parent?
70
+ resource.polymorphic?
71
+ end
72
+
73
+ def polymorphic_child?
74
+ !!@polymorphic_child
75
+ end
76
+
77
+ def primary_key
78
+ @primary_key ||= :id
79
+ end
80
+
81
+ def foreign_key
82
+ @foreign_key ||= infer_foreign_key
83
+ end
84
+
85
+ def association_name
86
+ @as || name
87
+ end
88
+
89
+ def resource_class
90
+ @resource_class ||= infer_resource_class
91
+ end
92
+
93
+ def scope(parents)
94
+ raise "No #scope defined for sideload with name '#{name}'. Make sure to define this in your adapter, or pass a block that defines the scope."
95
+ end
96
+
97
+ def assign_each(parent, children)
98
+ raise 'Override #assign_each in subclass'
99
+ end
100
+
101
+ def type
102
+ @type || raise("Override #type in subclass. Should be one of #{TYPES.inspect}")
103
+ end
104
+
105
+ def load_params(parents, query)
106
+ raise 'Override #load_params in subclass'
107
+ end
108
+
109
+ def base_scope
110
+ if @base_scope
111
+ @base_scope.respond_to?(:call) ? @base_scope.call : @base_scope
112
+ else
113
+ resource.base_scope
114
+ end
115
+ end
116
+
117
+ def load(parents, query)
118
+ params = load_params(parents, query)
119
+ params_proc.call(params, parents, query) if params_proc
120
+ opts = load_options(parents, query)
121
+ proxy = resource.class._all(params, opts, base_scope)
122
+ pre_load_proc.call(proxy) if pre_load_proc
123
+ proxy.to_a
124
+ end
125
+
126
+ # Override in subclass
127
+ def infer_foreign_key
128
+ model = parent_resource_class.model
129
+ namespace = namespace_for(model)
130
+ model_name = model.name.gsub("#{namespace}::", '')
131
+ :"#{model_name.underscore}_id"
132
+ end
133
+
134
+ def resource
135
+ @resource ||= resource_class.new
136
+ end
137
+
138
+ def parent_resource
139
+ @parent_resource ||= parent_resource_class.new
140
+ end
141
+
142
+ def assign(parents, children)
143
+ associated = []
144
+ parents.each do |parent|
145
+ relevant_children = fire_assign_each(parent, children)
146
+ if relevant_children.is_a?(Array)
147
+ associated |= relevant_children
148
+ associate_all(parent, relevant_children)
149
+ else
150
+ associated << relevant_children
151
+ associate(parent, relevant_children)
152
+ end
153
+ end
154
+ (children - associated).each do |unassigned|
155
+ children.delete(unassigned)
156
+ end
157
+ end
158
+
159
+ def resolve(parents, query)
160
+ # legacy / custom / many-to-many
161
+ if self.class.scope_proc || type == :many_to_many
162
+ sideload_scope = fire_scope(parents)
163
+ sideload_scope = Scope.new sideload_scope,
164
+ resource,
165
+ query,
166
+ sideload_parent_length: parents.length,
167
+ default_paginate: false
168
+ sideload_scope.resolve do |sideload_results|
169
+ fire_assign(parents, sideload_results)
170
+ end
171
+ else
172
+ load(parents, query)
173
+ end
174
+ end
175
+
176
+ def self.after_save(only: [], except: [], &blk)
177
+ actions = HOOK_ACTIONS - except
178
+ actions = only & actions
179
+ actions = [:save] if only.empty? && except.empty?
180
+ actions.each do |a|
181
+ hooks[:"after_#{a}"] << blk
182
+ end
183
+ end
184
+
185
+ def self.hooks
186
+ @hooks ||= {}.tap do |h|
187
+ HOOK_ACTIONS.each do |a|
188
+ h[:"after_#{a}"] = []
189
+ h[:"before_#{a}"] = []
190
+ end
191
+ end
192
+ end
193
+
194
+ def fire_hooks!(parent, objects, method)
195
+ return unless self.class.hooks
196
+
197
+ all = self.class.hooks[:"after_#{method}"] + self.class.hooks[:after_save]
198
+ all.compact.each do |hook|
199
+ resource.instance_exec(parent, objects, &hook)
200
+ end
201
+ end
202
+
203
+ def associate_all(parent, children)
204
+ parent_resource.associate_all(parent, children, association_name, type)
205
+ end
206
+
207
+ def associate(parent, child)
208
+ parent_resource.associate(parent, child, association_name, type)
209
+ end
210
+
211
+ def disassociate(parent, child)
212
+ parent_resource.disassociate(parent, child, association_name, type)
213
+ end
214
+
215
+ def ids_for_parents(parents)
216
+ parent_ids = parents.map(&primary_key)
217
+ parent_ids.compact!
218
+ parent_ids.uniq!
219
+ parent_ids
220
+ end
221
+
222
+ private
223
+
224
+ def load_options(parents, query)
225
+ {}.tap do |opts|
226
+ opts[:default_paginate] = false
227
+ opts[:sideload_parent_length] = parents.length
228
+ opts[:after_resolve] = ->(results) {
229
+ fire_assign(parents, results)
230
+ }
231
+ end
232
+ end
233
+
234
+ def fire_assign_each(parent, children)
235
+ if self.class.assign_each_proc
236
+ instance_exec(parent, children, &self.class.assign_each_proc)
237
+ else
238
+ assign_each(parent, children)
239
+ end
240
+ end
241
+
242
+ def fire_assign(parents, children)
243
+ if self.class.assign_proc
244
+ instance_exec(parents, children, &self.class.assign_proc)
245
+ else
246
+ assign(parents, children)
247
+ end
248
+ end
249
+
250
+ def fire_scope(parents)
251
+ parent_ids = ids_for_parents(parents)
252
+ if self.class.scope_proc
253
+ instance_exec(parent_ids, parents, &self.class.scope_proc)
254
+ else
255
+ method = method(:scope)
256
+ if [2,-2].include?(method.arity)
257
+ scope(parent_ids, parents)
258
+ else
259
+ scope(parent_ids)
260
+ end
261
+ end
262
+ end
263
+
264
+ def namespace_for(klass)
265
+ namespace = klass.name
266
+ split = namespace.split('::')
267
+ split[0,split.length-1].join('::')
268
+ end
269
+
270
+ def infer_resource_class
271
+ namespace = namespace_for(parent_resource.class)
272
+ inferred_name = "#{name.to_s.singularize.classify}Resource"
273
+ klass = "#{namespace}::#{inferred_name}".safe_constantize
274
+ klass ||= inferred_name.safe_constantize
275
+ unless klass
276
+ raise Errors::ResourceNotFound.new(parent_resource, name)
277
+ end
278
+ klass
279
+ end
280
+ end
281
+ end
@@ -0,0 +1,34 @@
1
+ class Graphiti::Sideload::BelongsTo < Graphiti::Sideload
2
+ def type
3
+ :belongs_to
4
+ end
5
+
6
+ def load_params(parents, query)
7
+ query.to_hash.tap do |hash|
8
+ hash[:filter] ||= {}
9
+ hash[:filter][primary_key] = ids_for_parents(parents)
10
+ end
11
+ end
12
+
13
+ def assign_each(parent, children)
14
+ children.find { |c| c.send(primary_key) == parent.send(foreign_key) }
15
+ end
16
+
17
+ def ids_for_parents(parents)
18
+ parent_ids = parents.map(&foreign_key)
19
+ parent_ids.compact!
20
+ parent_ids.uniq!
21
+ parent_ids
22
+ end
23
+
24
+ def infer_foreign_key
25
+ if polymorphic_child?
26
+ parent.foreign_key
27
+ else
28
+ model = resource.model
29
+ namespace = namespace_for(model)
30
+ model_name = model.name.gsub("#{namespace}::", '')
31
+ :"#{model_name.underscore}_id"
32
+ end
33
+ end
34
+ end