graphiti 1.0.alpha.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (94) 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 +121 -0
  33. data/lib/graphiti/adapters/abstract.rb +516 -0
  34. data/lib/graphiti/adapters/active_record.rb +6 -0
  35. data/lib/graphiti/adapters/active_record/base.rb +249 -0
  36. data/lib/graphiti/adapters/active_record/belongs_to_sideload.rb +17 -0
  37. data/lib/graphiti/adapters/active_record/has_many_sideload.rb +17 -0
  38. data/lib/graphiti/adapters/active_record/has_one_sideload.rb +17 -0
  39. data/lib/graphiti/adapters/active_record/inferrence.rb +12 -0
  40. data/lib/graphiti/adapters/active_record/many_to_many_sideload.rb +30 -0
  41. data/lib/graphiti/adapters/null.rb +236 -0
  42. data/lib/graphiti/base.rb +70 -0
  43. data/lib/graphiti/configuration.rb +21 -0
  44. data/lib/graphiti/context.rb +16 -0
  45. data/lib/graphiti/deserializer.rb +208 -0
  46. data/lib/graphiti/errors.rb +309 -0
  47. data/lib/graphiti/extensions/boolean_attribute.rb +33 -0
  48. data/lib/graphiti/extensions/extra_attribute.rb +70 -0
  49. data/lib/graphiti/extensions/temp_id.rb +26 -0
  50. data/lib/graphiti/filter_operators.rb +25 -0
  51. data/lib/graphiti/hash_renderer.rb +57 -0
  52. data/lib/graphiti/jsonapi_serializable_ext.rb +50 -0
  53. data/lib/graphiti/query.rb +251 -0
  54. data/lib/graphiti/rails.rb +28 -0
  55. data/lib/graphiti/railtie.rb +74 -0
  56. data/lib/graphiti/renderer.rb +60 -0
  57. data/lib/graphiti/resource.rb +110 -0
  58. data/lib/graphiti/resource/configuration.rb +239 -0
  59. data/lib/graphiti/resource/dsl.rb +138 -0
  60. data/lib/graphiti/resource/interface.rb +32 -0
  61. data/lib/graphiti/resource/polymorphism.rb +68 -0
  62. data/lib/graphiti/resource/sideloading.rb +102 -0
  63. data/lib/graphiti/resource_proxy.rb +127 -0
  64. data/lib/graphiti/responders.rb +19 -0
  65. data/lib/graphiti/runner.rb +25 -0
  66. data/lib/graphiti/scope.rb +98 -0
  67. data/lib/graphiti/scoping/base.rb +99 -0
  68. data/lib/graphiti/scoping/default_filter.rb +58 -0
  69. data/lib/graphiti/scoping/extra_attributes.rb +29 -0
  70. data/lib/graphiti/scoping/filter.rb +93 -0
  71. data/lib/graphiti/scoping/filterable.rb +36 -0
  72. data/lib/graphiti/scoping/paginate.rb +87 -0
  73. data/lib/graphiti/scoping/sort.rb +64 -0
  74. data/lib/graphiti/sideload.rb +281 -0
  75. data/lib/graphiti/sideload/belongs_to.rb +34 -0
  76. data/lib/graphiti/sideload/has_many.rb +16 -0
  77. data/lib/graphiti/sideload/has_one.rb +9 -0
  78. data/lib/graphiti/sideload/many_to_many.rb +24 -0
  79. data/lib/graphiti/sideload/polymorphic_belongs_to.rb +108 -0
  80. data/lib/graphiti/stats/dsl.rb +89 -0
  81. data/lib/graphiti/stats/payload.rb +49 -0
  82. data/lib/graphiti/types.rb +172 -0
  83. data/lib/graphiti/util/attribute_check.rb +88 -0
  84. data/lib/graphiti/util/field_params.rb +16 -0
  85. data/lib/graphiti/util/hash.rb +51 -0
  86. data/lib/graphiti/util/hooks.rb +33 -0
  87. data/lib/graphiti/util/include_params.rb +39 -0
  88. data/lib/graphiti/util/persistence.rb +219 -0
  89. data/lib/graphiti/util/relationship_payload.rb +64 -0
  90. data/lib/graphiti/util/serializer_attributes.rb +97 -0
  91. data/lib/graphiti/util/sideload.rb +33 -0
  92. data/lib/graphiti/util/validation_response.rb +78 -0
  93. data/lib/graphiti/version.rb +3 -0
  94. metadata +316 -0
@@ -0,0 +1,88 @@
1
+ # Private, tested in resource specs
2
+ module Graphiti
3
+ module Util
4
+ class AttributeCheck
5
+ attr_reader :resource, :name, :flag, :request, :raise_error
6
+
7
+ def self.run(resource, name, flag, request, raise_error)
8
+ new(resource, name, flag, request, raise_error).run
9
+ end
10
+
11
+ def initialize(resource, name, flag, request, raise_error)
12
+ @resource = resource
13
+ @name = name.to_sym
14
+ @flag = flag
15
+ @request = request
16
+ @raise_error = raise_error
17
+ end
18
+
19
+ def run
20
+ if attribute?
21
+ if supported?
22
+ if guarded?
23
+ if guard_passes?
24
+ attribute
25
+ else
26
+ maybe_raise(request: true, guard: attribute[flag])
27
+ end
28
+ else
29
+ attribute
30
+ end
31
+ else
32
+ maybe_raise(exists: true)
33
+ end
34
+ else
35
+ maybe_raise(exists: false)
36
+ end
37
+ end
38
+
39
+ def maybe_raise(opts = {})
40
+ default = { request: request, exists: true }
41
+ opts = default.merge(opts)
42
+ if raise_error?(opts[:exists])
43
+ raise error_class.new(resource, name, flag, opts)
44
+ else
45
+ false
46
+ end
47
+ end
48
+
49
+ def guard_passes?
50
+ !!resource.send(attribute[flag])
51
+ end
52
+
53
+ def guarded?
54
+ request? &&
55
+ attribute[flag].is_a?(Symbol) &&
56
+ attribute[flag] != :required
57
+ end
58
+
59
+ def error_class
60
+ Errors::AttributeError
61
+ end
62
+
63
+ def supported?
64
+ attribute[flag] != false
65
+ end
66
+
67
+ def attribute
68
+ resource.all_attributes[name]
69
+ end
70
+
71
+ def attribute?
72
+ !!attribute
73
+ end
74
+
75
+ def raise_error?(exists)
76
+ if raise_error == :only_unsupported
77
+ exists ? true : false
78
+ else
79
+ !!raise_error
80
+ end
81
+ end
82
+
83
+ def request?
84
+ !!request
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,16 @@
1
+ module Graphiti
2
+ module Util
3
+ # @api private
4
+ class FieldParams
5
+ def self.parse(params)
6
+ return {} if params.nil?
7
+
8
+ {}.tap do |parsed|
9
+ params.each_pair do |key, value|
10
+ parsed[key.to_sym] = value.split(',').map(&:to_sym)
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,51 @@
1
+ module Graphiti
2
+ module Util
3
+ # @api private
4
+ class Hash
5
+ # Grab all keys at any level of the hash.
6
+ #
7
+ # { foo: { bar: { baz: {} } } }
8
+ #
9
+ # Becomes
10
+ #
11
+ # [:foo, :bar, :bar]
12
+ #
13
+ # @param hash the hash we want to process
14
+ # @param [Array<Symbol, String>] collection the memoized collection of keys
15
+ # @return [Array<Symbol, String>] the keys
16
+ # @api private
17
+ def self.keys(hash, collection = [])
18
+ hash.each_pair do |key, value|
19
+ collection << key
20
+ keys(value, collection)
21
+ end
22
+
23
+ collection
24
+ end
25
+
26
+ # Like ActiveSupport's #deep_merge
27
+ # @return [Hash] the merged hash
28
+ # @api private
29
+ def self.deep_merge!(hash, other)
30
+ merger = proc { |key, v1, v2| Hash === v1 && Hash === v2 ? v1.merge(v2, &merger) : v2 }
31
+ hash.merge!(other, &merger)
32
+ end
33
+
34
+ # Like ActiveSupport's #deep_dup
35
+ # @api private
36
+ def self.deep_dup(hash)
37
+ if hash.respond_to?(:deep_dup)
38
+ hash.deep_dup
39
+ else
40
+ {}.tap do |duped|
41
+ hash.each_pair do |key, value|
42
+ value = deep_dup(value) if value.is_a?(Hash)
43
+ value = value.dup if value && value.respond_to?(:dup) && ![Symbol, Fixnum].include?(value.class)
44
+ duped[key] = value
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,33 @@
1
+ module Graphiti
2
+ module Util
3
+ class Hooks
4
+ def self.record
5
+ self.hooks = []
6
+ begin
7
+ yield.tap { run }
8
+ ensure
9
+ self.hooks = []
10
+ end
11
+ end
12
+
13
+ def self._hooks
14
+ Thread.current[:_compliable_hooks] ||= []
15
+ end
16
+ private_class_method :_hooks
17
+
18
+ def self.hooks=(val)
19
+ Thread.current[:_compliable_hooks] = val
20
+ end
21
+
22
+ # Because hooks will be added from the outer edges of
23
+ # the graph, working inwards
24
+ def self.add(prc)
25
+ _hooks.unshift(prc)
26
+ end
27
+
28
+ def self.run
29
+ _hooks.each { |h| h.call }
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,39 @@
1
+ module Graphiti
2
+ module Util
3
+ # Utility class for dealing with Include Directives
4
+ class IncludeParams
5
+ class << self
6
+ # Let's say the user requested these sideloads:
7
+ #
8
+ # GET /posts?include=comments.author
9
+ #
10
+ # But our resource had this code:
11
+ #
12
+ # sideload_whitelist({ index: [:comments] })
13
+ #
14
+ # We should drop the 'author' sideload from the request.
15
+ #
16
+ # Hashes become 'include directive hashes' within the library. ie
17
+ #
18
+ # [:tags, { comments: :author }]
19
+ #
20
+ # Becomes
21
+ #
22
+ # { tags: {}, comments: { author: {} } }
23
+ #
24
+ # @param [Hash] requested_includes the nested hash the user requested
25
+ # @param [Hash] allowed_includes the nested hash configured via DSL
26
+ # @return [Hash] the scrubbed hash
27
+ def scrub(requested_includes, allowed_includes)
28
+ {}.tap do |valid|
29
+ requested_includes.each_pair do |key, sub_hash|
30
+ if allowed_includes[key]
31
+ valid[key] = scrub(sub_hash, allowed_includes[key])
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,219 @@
1
+ # Save the given Resource#model, and all of its nested relationships.
2
+ # @api private
3
+ class Graphiti::Util::Persistence
4
+ # @param [Resource] resource the resource instance
5
+ # @param [Hash] meta see (Deserializer#meta)
6
+ # @param [Hash] attributes see (Deserializer#attributes)
7
+ # @param [Hash] relationships see (Deserializer#relationships)
8
+ def initialize(resource, meta, attributes, relationships, caller_model)
9
+ @resource = resource
10
+ @meta = meta
11
+ @attributes = attributes
12
+ @relationships = relationships
13
+ @caller_model = caller_model
14
+
15
+ @attributes.each_pair do |key, value|
16
+ @attributes[key] = @resource.typecast(key, value, :writable)
17
+ end
18
+ end
19
+
20
+ # Perform the actual save logic.
21
+ #
22
+ # belongs_to must be processed before/separately from has_many -
23
+ # we need to know the primary key value of the parent before
24
+ # persisting the child.
25
+ #
26
+ # Flow:
27
+ # * process parents
28
+ # * update attributes to reflect parent primary keys
29
+ # * persist current object
30
+ # * associate temp id with current object
31
+ # * associate parent objects with current object
32
+ # * process children
33
+ # * associate children
34
+ # * record hooks for later playback
35
+ # * run post-process sideload hooks
36
+ # * return current object
37
+ #
38
+ # @return a model instance
39
+ def run
40
+ parents = process_belongs_to(@relationships)
41
+ update_foreign_key_for_parents(parents)
42
+
43
+ persisted = persist_object(@meta[:method], @attributes)
44
+ persisted.instance_variable_set(:@__serializer_klass, @resource.serializer)
45
+ assign_temp_id(persisted, @meta[:temp_id])
46
+
47
+ associate_parents(persisted, parents)
48
+
49
+ children = process_has_many(@relationships, persisted) do |x|
50
+ update_foreign_key(persisted, x[:attributes], x)
51
+ end
52
+
53
+ associate_children(persisted, children) unless @meta[:method] == :destroy
54
+
55
+ post_process(persisted, parents)
56
+ post_process(persisted, children)
57
+ before_commit = -> { @resource.before_commit(persisted, @meta[:method]) }
58
+ add_hook(before_commit)
59
+ persisted
60
+ end
61
+
62
+ private
63
+
64
+ def add_hook(prc)
65
+ ::Graphiti::Util::Hooks.add(prc)
66
+ end
67
+
68
+ # The child's attributes should be modified to nil-out the
69
+ # foreign_key when the parent is being destroyed or disassociated
70
+ #
71
+ # This is not the case for HABTM, whose "foreign key" is a join table
72
+ def update_foreign_key(parent_object, attrs, x)
73
+ return if x[:sideload].type == :many_to_many
74
+
75
+ if [:destroy, :disassociate].include?(x[:meta][:method])
76
+ attrs[x[:foreign_key]] = nil
77
+ update_foreign_type(attrs, x, null: true) if x[:is_polymorphic]
78
+ else
79
+ attrs[x[:foreign_key]] = parent_object.send(x[:primary_key])
80
+ update_foreign_type(attrs, x) if x[:is_polymorphic]
81
+ end
82
+ end
83
+
84
+ def update_foreign_type(attrs, x, null: false)
85
+ grouping_field = x[:sideload].parent.grouper.field_name
86
+ attrs[grouping_field] = null ? nil : x[:sideload].group_name
87
+ end
88
+
89
+ def update_foreign_key_for_parents(parents)
90
+ parents.each do |x|
91
+ update_foreign_key(x[:object], @attributes, x)
92
+ end
93
+ end
94
+
95
+ def associate_parents(object, parents)
96
+ # No need to associate to destroyed objects
97
+ parents = parents.select { |x| x[:meta][:method] != :destroy }
98
+
99
+ parents.each do |x|
100
+ if x[:object] && object
101
+ if x[:meta][:method] == :disassociate
102
+ if x[:sideload].type == :belongs_to
103
+ x[:sideload].disassociate(object, x[:object])
104
+ else
105
+ x[:sideload].disassociate(x[:object], object)
106
+ end
107
+ else
108
+ if x[:sideload].type == :belongs_to
109
+ x[:sideload].associate(object, x[:object])
110
+ else
111
+ if [:has_many, :many_to_many].include?(x[:sideload].type)
112
+ x[:sideload].associate_all(object, Array(x[:object]))
113
+ else
114
+ x[:sideload].associate(x[:object], object)
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
121
+
122
+ def associate_children(object, children)
123
+ children.each do |x|
124
+ if x[:object] && object
125
+ if x[:meta][:method] == :disassociate
126
+ x[:sideload].disassociate(object, x[:object])
127
+ elsif x[:meta][:method] == :destroy
128
+ if x[:sideload].type == :many_to_many
129
+ x[:sideload].disassociate(object, x[:object])
130
+ end # otherwise, no need to disassociate destroyed objects
131
+ else
132
+ if [:has_many, :many_to_many].include?(x[:sideload].type)
133
+ x[:sideload].associate_all(object, Array(x[:object]))
134
+ else
135
+ x[:sideload].associate(object, x[:object])
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
141
+
142
+ def persist_object(method, attributes)
143
+ case method
144
+ when :destroy
145
+ call_resource_method(:destroy, attributes[:id], @caller_model)
146
+ when :update, nil, :disassociate
147
+ call_resource_method(:update, attributes, @caller_model)
148
+ else
149
+ call_resource_method(:create, attributes, @caller_model)
150
+ end
151
+ end
152
+
153
+ def process_has_many(relationships, caller_model)
154
+ [].tap do |processed|
155
+ iterate(except: [:polymorphic_belongs_to, :belongs_to]) do |x|
156
+ yield x
157
+
158
+ x[:object] = x[:sideload].resource
159
+ .persist_with_relationships(x[:meta], x[:attributes], x[:relationships], caller_model)
160
+ processed << x
161
+ end
162
+ end
163
+ end
164
+
165
+ def process_belongs_to(relationships)
166
+ [].tap do |processed|
167
+ iterate(only: [:polymorphic_belongs_to, :belongs_to]) do |x|
168
+ x[:object] = x[:sideload].resource
169
+ .persist_with_relationships(x[:meta], x[:attributes], x[:relationships])
170
+ processed << x
171
+ end
172
+ end
173
+ end
174
+
175
+ def post_process(caller_model, processed)
176
+ groups = processed.group_by { |x| x[:meta][:method] }
177
+ groups.each_pair do |method, group|
178
+ group.group_by { |g| g[:sideload] }.each_pair do |sideload, members|
179
+ objects = members.map { |x| x[:object] }
180
+ hook = -> { sideload.fire_hooks!(caller_model, objects, method) }
181
+ add_hook(hook)
182
+ end
183
+ end
184
+ end
185
+
186
+ def assign_temp_id(object, temp_id)
187
+ object.instance_variable_set(:@_jsonapi_temp_id, temp_id)
188
+ end
189
+
190
+ def iterate(only: [], except: [])
191
+ opts = {
192
+ resource: @resource,
193
+ relationships: @relationships,
194
+ }.merge(only: only, except: except)
195
+
196
+ Graphiti::Util::RelationshipPayload.iterate(opts) do |x|
197
+ yield x
198
+ end
199
+ end
200
+
201
+ # In the Resource, we want to allow:
202
+ #
203
+ # def create(attrs)
204
+ #
205
+ # and
206
+ #
207
+ # def create(attrs, parent = nil)
208
+ #
209
+ # 'parent' is an optional parameter that should not be part of the
210
+ # method signature in most use cases.
211
+ def call_resource_method(method_name, attributes, caller_model)
212
+ method = @resource.method(method_name)
213
+ if [2,-2].include?(method.arity)
214
+ method.call(attributes, caller_model)
215
+ else
216
+ method.call(attributes)
217
+ end
218
+ end
219
+ end