graphql-activerecord 0.3.1 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: ef1f87b7695a19936a0776a5a9d1fe43364bbfe7
4
- data.tar.gz: c3aece35fd294936549655ea4d7a6282356803d9
3
+ metadata.gz: f66ba2bd587b9ec556a33c6c53be9feb2f0897d3
4
+ data.tar.gz: 30b46efe06e881c0237d5f624cd7376873e0b253
5
5
  SHA512:
6
- metadata.gz: 54edee615092c39ef2000be4519345706da498d4742d050b074a399b7ff8512b8e83846d2bb88ed51c3763e805b41ad32cf21a1b545b11a0fb42483d093661b8
7
- data.tar.gz: 8516a38b326591d06b28f94748f411b6c7c2d471efb4e9495fb88fd510e8b8523eaef3752a5bfc5d084850e5c15ce657c7dcb5e9ada387657c63428510d6e600
6
+ metadata.gz: 70e55f628ca3ce4d4c385bfdf090eca6905867b698a917cefacfbc172fe8f0778de945d99fbab8579007d3d1da573e47b4f23dcb7728e9ddfe11f4911084711a
7
+ data.tar.gz: b7d96cfc3e4a0bdbbb732ca49fc99e264b4589406f05894c450a62a5279df33204c3e063d6a672904304215f63260837f620f1c3ef015ce9d01580baa25aab4b
@@ -17,16 +17,23 @@ require 'graphql/models/scalar_types'
17
17
  require 'graphql/models/definition_helpers'
18
18
  require 'graphql/models/definition_helpers/associations'
19
19
  require 'graphql/models/definition_helpers/attributes'
20
+ require 'graphql/models/mutation_helpers/print_input_fields'
21
+ require 'graphql/models/mutation_helpers/apply_changes'
22
+ require 'graphql/models/mutation_helpers/authorization'
23
+ require 'graphql/models/mutation_helpers/validation_error'
24
+ require 'graphql/models/mutation_helpers/validation'
25
+ require 'graphql/models/mutation_field_map'
20
26
 
21
27
  require 'graphql/models/proxy_block'
22
28
  require 'graphql/models/backed_by_model'
23
29
  require 'graphql/models/object_type'
30
+ require 'graphql/models/mutator'
24
31
 
25
32
 
26
33
  module GraphQL
27
34
  module Models
28
35
  class << self
29
- attr_accessor :node_interface_proc
36
+ attr_accessor :node_interface_proc, :model_from_id, :authorize
30
37
  end
31
38
 
32
39
  # Returns a promise that will traverse the associations and resolve to the model at the end of the path.
@@ -50,5 +57,16 @@ module GraphQL
50
57
 
51
58
  meta[field_name]
52
59
  end
60
+
61
+ def self.authorize!(context, model, action)
62
+ authorize.call(context, model, action)
63
+ end
64
+
65
+ def self.define_mutator(definer, model_type, null_behavior:, &block)
66
+ mutator_definition = MutatorDefinition.new(model_type, null_behavior: null_behavior)
67
+ mutator_definition.field_map.instance_exec(&block)
68
+ MutationHelpers.print_input_fields(mutator_definition.field_map, definer, model_type.name)
69
+ mutator_definition
70
+ end
53
71
  end
54
72
  end
@@ -27,7 +27,7 @@ module GraphQL
27
27
 
28
28
  def self.get_column(model_type, name)
29
29
  col = model_type.columns.detect { |c| c.name == name.to_s }
30
- raise ArgumentError.new("The attribute #{name} wasn't found on model #{model_type.name}.") unless col
30
+ return nil unless col
31
31
 
32
32
  if model_type.graphql_enum_types.include?(name)
33
33
  graphql_type = model_type.graphql_enum_types[name]
@@ -42,10 +42,17 @@ module GraphQL
42
42
  return OpenStruct.new({
43
43
  is_range: /range\z/ === col.type.to_s,
44
44
  camel_name: name.to_s.camelize(:lower).to_sym,
45
- graphql_type: graphql_type
45
+ graphql_type: graphql_type,
46
+ nullable: col.null
46
47
  })
47
48
  end
48
49
 
50
+ def self.get_column!(model_type, name)
51
+ col = get_column(model_type, name)
52
+ raise ArgumentError.new("The attribute #{name} wasn't found on model #{model_type.name}.") unless col
53
+ col
54
+ end
55
+
49
56
  def self.range_to_graphql(value)
50
57
  return nil unless value
51
58
 
@@ -63,7 +70,7 @@ module GraphQL
63
70
  # @param path The associations (in order) that need to be loaded, starting from the graph_type's model
64
71
  # @param attribute The name of the attribute that is accessed on the target model_type
65
72
  def self.define_attribute(graph_type, base_model_type, model_type, path, attribute, object_to_model, options)
66
- column = get_column(model_type, attribute)
73
+ column = get_column!(model_type, attribute)
67
74
  field_name = options[:name] || column.camel_name
68
75
 
69
76
  DefinitionHelpers.register_field_metadata(graph_type, field_name, {
@@ -84,7 +91,7 @@ module GraphQL
84
91
 
85
92
  resolve -> (model, args, context) do
86
93
  return nil unless model
87
-
94
+
88
95
  if column.is_range
89
96
  DefinitionHelpers.range_to_graphql(model.public_send(attribute))
90
97
  else
@@ -103,6 +103,21 @@ module GraphQL
103
103
  return validators.map { |v| v.options[:in] }.reduce(:&)
104
104
  end
105
105
 
106
+ def self.detect_is_required(model_type, attr_or_assoc)
107
+ col = model_type.columns.detect { |c| c.name == attr_or_assoc.to_s }
108
+ return true if col && !col.null
109
+
110
+ validators = model_type.validators_on(attr_or_assoc)
111
+ .select { |v| v.is_a?(ActiveModel::Validations::PresenceValidator) }
112
+ .reject { |v| v.options.include?(:if) || v.options.include?(:unless) }
113
+
114
+ return true if validators.any?
115
+
116
+ # The column is nullable, and there are no unconditional presence validators,
117
+ # so it's at least sometimes optional
118
+ false
119
+ end
120
+
106
121
  # Stores metadata about GraphQL fields that are available on this model's GraphQL type.
107
122
  # @param metadata Should be a hash that contains information about the field's definition, including :macro and :type
108
123
  def self.register_field_metadata(graph_type, field_name, metadata)
@@ -0,0 +1,155 @@
1
+ module GraphQL::Models
2
+ class MutationFieldMap
3
+ attr_accessor :model_type, :find_by, :null_behavior, :fields, :nested_maps
4
+
5
+ # These are used when this is a proxy_to or a nested field map
6
+ attr_accessor :name, :association, :has_many, :required, :path
7
+
8
+ def initialize(model_type, find_by:, null_behavior:)
9
+ fail ArgumentError.new("model_type must be a model") if model_type && !(model_type <= ActiveRecord::Base)
10
+ fail ArgumentError.new("null_behavior must be :set_null or :leave_unchanged") unless [:set_null, :leave_unchanged].include?(null_behavior)
11
+
12
+ @fields = []
13
+ @nested_maps = []
14
+ @path = []
15
+ @model_type = model_type
16
+ @find_by = Array.wrap(find_by)
17
+ @null_behavior = null_behavior
18
+
19
+ @find_by.each { |f| attr(f) }
20
+ end
21
+
22
+ def types
23
+ GraphQL::Define::TypeDefiner.instance
24
+ end
25
+
26
+ def attr(attribute, type: nil, name: nil, required: false)
27
+ attribute = attribute.to_sym if attribute.is_a?(String)
28
+
29
+ if type.nil? && !model_type
30
+ fail ArgumentError.new("You must specify a type for attribute #{name}, because its model type is not known until runtime.")
31
+ end
32
+
33
+ if model_type
34
+ column = DefinitionHelpers.get_column(model_type, attribute)
35
+
36
+ if column.nil? && type.nil?
37
+ fail ArgumentError.new("You must specify a type for attribute #{name}, because it's not a column on #{model_type}.")
38
+ end
39
+
40
+ if column
41
+ type ||= begin
42
+ if attribute == :id || foreign_keys.include?(attribute)
43
+ type = types.ID
44
+ else
45
+ type = column.graphql_type
46
+ end
47
+ end
48
+
49
+ required = DefinitionHelpers.detect_is_required(model_type, attribute)
50
+ end
51
+ end
52
+
53
+ name ||= attribute.to_s.camelize(:lower)
54
+ name = name.to_s
55
+
56
+ detect_field_conflict(name)
57
+
58
+ fields.push({
59
+ name: name,
60
+ attribute: attribute,
61
+ type: type,
62
+ required: required
63
+ })
64
+ end
65
+
66
+ def proxy_to(association, &block)
67
+ association = association.to_sym if association.is_a?(String)
68
+
69
+ reflection = model_type&.reflect_on_association(association)
70
+
71
+ if reflection
72
+ unless [:belongs_to, :has_one].include?(reflection.macro)
73
+ fail ArgumentError.new("Cannot proxy to #{reflection.macro} association #{association} from #{model_type.name}")
74
+ end
75
+
76
+ klass = reflection.polymorphic? ? nil : reflection.klass
77
+ else
78
+ klass = nil
79
+ end
80
+
81
+ proxy = MutationFieldMap.new(klass, find_by: nil, null_behavior: null_behavior)
82
+ proxy.association = association
83
+ proxy.instance_exec(&block)
84
+
85
+ proxy.fields.each { |f| detect_field_conflict(f[:name]) }
86
+ proxy.nested_maps.each { |m| detect_field_conflict(m.name) }
87
+
88
+ proxy.fields.each do |field|
89
+ fields.push({
90
+ name: field[:name],
91
+ attribute: field[:attribute],
92
+ type: field[:type],
93
+ required: field[:required],
94
+ path: [association] + Array.wrap(field[:path])
95
+ })
96
+ end
97
+
98
+ proxy.nested_maps.each do |m|
99
+ m.path.unshift(association)
100
+ nested_maps.push(m)
101
+ end
102
+ end
103
+
104
+ def nested(association, find_by: nil, null_behavior:, name: nil, has_many: false, &block)
105
+ unless model_type
106
+ fail ArgumentError.new("Cannot use `nested` unless the model type is known at build time.")
107
+ end
108
+
109
+ association = association.to_sym if association.is_a?(String)
110
+ reflection = model_type.reflect_on_association(association)
111
+
112
+ unless reflection
113
+ fail ArgumentError.new("Could not find association #{association} on #{model_type.name}")
114
+ end
115
+
116
+ if reflection.polymorphic?
117
+ fail ArgumentError.new("Cannot used `nested` with polymorphic association #{association} on #{model_type.name}")
118
+ end
119
+
120
+ has_many = reflection.macro == :has_many
121
+ required = DefinitionHelpers.detect_is_required(model_type, association)
122
+
123
+ map = MutationFieldMap.new(reflection.klass, find_by: find_by, null_behavior: null_behavior)
124
+ map.name = name || association.to_s.camelize(:lower)
125
+ map.association = association.to_s
126
+ map.has_many = has_many
127
+ map.required = required
128
+
129
+ detect_field_conflict(map.name)
130
+
131
+ map.instance_exec(&block)
132
+
133
+ nested_maps.push(map)
134
+ end
135
+
136
+ def leave_null_unchanged?
137
+ null_behavior == :leave_unchanged
138
+ end
139
+
140
+ private
141
+
142
+ def detect_field_conflict(name)
143
+ if fields.any? { |f| f[name] == name } || nested_maps.any? { |n| n.name == name }
144
+ fail ArgumentError.new("The field #{name} is defined more than once.")
145
+ end
146
+ end
147
+
148
+ def foreign_keys
149
+ @foreign_keys ||= model_type.reflections.values
150
+ .select { |r| r.macro == :belongs_to }
151
+ .map(&:foreign_key)
152
+ .map(&:to_sym)
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,229 @@
1
+ module GraphQL::Models
2
+ module MutationHelpers
3
+ def self.apply_changes(field_map, model, inputs, context)
4
+ # This will hold a flattened list of attributes/models that we actually changed
5
+ changes = []
6
+
7
+ # Values will now contain the list of inputs that we should actually act on. Any null values should actually
8
+ # be set to null, and missing fields should be skipped.
9
+ values = field_map.leave_null_unchanged? ? prep_leave_unchanged(inputs) : prep_set_null(field_map, inputs)
10
+
11
+ values.each do |name, value|
12
+ field_def = field_map.fields.detect { |f| f[:name] == name }
13
+
14
+ # Skip this value unless it's a field on the model. Nested fields are handled later.
15
+ next unless field_def
16
+
17
+ # Advance to the model that we actually need to change
18
+ change_model = model_to_change(model, field_def[:path], changes, create_if_missing: !value.nil?)
19
+ next if change_model.nil?
20
+
21
+ # Apply the change to this model
22
+ apply_field_value(change_model, field_def, value, context, changes)
23
+ end
24
+
25
+ # Handle the value nested fields now.
26
+ field_map.nested_maps.each do |child_map|
27
+ next if inputs[child_map.name].nil? && field_map.leave_null_unchanged?
28
+
29
+ # Advance to the model that contains the nested fields
30
+ change_model = model_to_change(model, child_map.path, changes, create_if_missing: !inputs[child_map.name].nil?)
31
+ next if change_model.nil?
32
+
33
+ # Apply the changes to the nested models
34
+ child_changes = handle_nested_map(field_map, change_model, inputs, context, child_map)
35
+
36
+ # Merge the changes with the parent, but prepend the input field path
37
+ child_changes.each do |cc|
38
+ cc[:input_path] = [child_map.name] + Array.wrap(cc[:input_path]) if cc[:input_path]
39
+ changes.push(cc)
40
+ end
41
+ end
42
+
43
+ return changes
44
+ end
45
+
46
+ def self.handle_nested_map(parent_map, parent_model, inputs, context, child_map)
47
+ next_inputs = inputs[child_map.name]
48
+
49
+ # Don't do anything if the value is null, and we leave null fields unchanged
50
+ return [] if next_inputs.nil? && parent_map.leave_null_unchanged?
51
+
52
+ changes = []
53
+ matches = match_inputs_to_models(parent_model, child_map, next_inputs, changes)
54
+
55
+ matches.each do |match|
56
+ child_changes = apply_changes(child_map, match[:child_model], match[:child_inputs], context)
57
+
58
+ if match[:input_path]
59
+ child_changes.select { |cc| cc[:input_path] }.each do |cc|
60
+ cc[:input_path] = [match[:input_path]] + Array.wrap(cc[:input_path])
61
+ end
62
+ end
63
+
64
+ changes.concat(child_changes)
65
+ end
66
+
67
+ return changes
68
+ end
69
+
70
+ def self.match_inputs_to_models(model, child_map, next_inputs, changes)
71
+ if !child_map.has_many
72
+ child_model = model.public_send(child_map.association)
73
+
74
+ unless child_model
75
+ child_model = model.public_send("build_#{child_map.association}")
76
+ changes.push({ model_instance: child_model, action: :create })
77
+ end
78
+
79
+ return [{ child_model: child_model, child_inputs: next_inputs }]
80
+ else
81
+ next_inputs = [] if next_inputs.nil?
82
+
83
+ # Match up each of the elements in next_inputs with one of the models, based on the `find_by` value.
84
+ associated_models = model.public_send(child_map.association)
85
+ find_by = Array.wrap(child_map.find_by).map(&:to_s)
86
+
87
+ if find_by.empty?
88
+ return match_inputs_by_position(model, child_map, next_inputs, changes, associated_models, find_by)
89
+ else
90
+ return match_inputs_by_fields(model, child_map, next_inputs, changes, associated_models, find_by)
91
+ end
92
+ end
93
+ end
94
+
95
+ def self.match_inputs_by_position(model, child_map, next_inputs, changes, associated_models, find_by)
96
+ count = [associated_models.length, next_inputs.length].max
97
+
98
+ matches = []
99
+
100
+ # This will give us an array of [number, model, inputs].
101
+ # Either the model or the inputs could be nil, but not both.
102
+ count.times.zip(associated_models.to_a, next_inputs) do |(idx, child_model, inputs)|
103
+ if child_model.nil?
104
+ child_model = associated_models.build
105
+ changes.push({ model_instance: child_model, action: :create })
106
+ end
107
+
108
+ if inputs.nil?
109
+ child_model.mark_for_destruction
110
+ changes.push({ model_instance: child_model, action: :destroy })
111
+ next
112
+ end
113
+
114
+ matches.push({ child_model: child_model, child_inputs: inputs, input_path: idx })
115
+ end
116
+
117
+ return matches
118
+ end
119
+
120
+ def self.match_inputs_by_fields(model, child_map, next_inputs, changes, associated_models, find_by)
121
+ grouped_models = associated_models.group_by { |m| m.attributes.slice(*find_by) }
122
+ grouped_inputs = next_inputs.group_by { |ni| ni.to_h.slice(*find_by) }
123
+
124
+ # Match each model to its input. If there is no input for it, mark that the model should be destroyed.
125
+ matches = []
126
+
127
+ # TODO: Support for finding by an ID field, that needs to be untranslated from a Relay ID into a model ID
128
+
129
+ grouped_models.each do |key_attrs, vals|
130
+ child_model = vals[0]
131
+
132
+ inputs = grouped_inputs[key_attrs][0]
133
+
134
+ if inputs.nil?
135
+ child_model.mark_for_destruction
136
+ changes.push({ model_instance: child_model, action: :destroy })
137
+ else
138
+ matches.push({ child_model: child_model, child_inputs: inputs, input_path: next_inputs.index(inputs) })
139
+ end
140
+ end
141
+
142
+ # Build a new model for each input that doesn't have a model
143
+ grouped_inputs.each do |key_attrs, vals|
144
+ inputs = vals[0]
145
+
146
+ next if grouped_models.include?(key_attrs)
147
+ child_model = associated_models.build
148
+ changes.push({ model_instance: child_model, action: :create })
149
+ matches.push({ child_model: child_model, child_inputs: inputs, input_path: next_inputs.index(inputs) })
150
+ end
151
+
152
+ return matches
153
+ end
154
+
155
+ # Returns the instance of the model that will be changed for this field. If new models are created along the way,
156
+ # they are added to the list of changes.
157
+ def self.model_to_change(starting_model, path, changes, create_if_missing: true)
158
+ model_to_change = starting_model
159
+
160
+ Array.wrap(path).each do |ps|
161
+ next_model = model_to_change.public_send(ps)
162
+
163
+ return nil if next_model.nil? && !create_if_missing
164
+
165
+ unless next_model
166
+ next_model = model_to_change.public_send("build_#{ps}")
167
+ # Even though we may not be changing anything on this model, record it as a change, since it's a new model.
168
+ changes.push({ model_instance: next_model, action: :create })
169
+ end
170
+
171
+ model_to_change = next_model
172
+ end
173
+
174
+ return model_to_change
175
+ end
176
+
177
+ def self.apply_field_value(model, field_def, value, context, changes)
178
+ # Special case: If this is an ID field, get the ID from the target model
179
+ if value.present? && field_def[:type].unwrap == GraphQL::ID_TYPE
180
+ target_model = GraphQL::Models.model_from_id.call(value, context)
181
+
182
+ unless target_model
183
+ fail GraphQL::ExecutionError.new("The value provided for #{field_def[:name]} does not refer to a valid model.")
184
+ end
185
+
186
+ value = target_model.id
187
+ end
188
+
189
+ unless model.public_send(field_def[:attribute]) == value
190
+ model.public_send("#{field_def[:attribute]}=", value)
191
+
192
+ changes.push({
193
+ model_instance: model,
194
+ input_path: field_def[:name],
195
+ attribute: field_def[:attribute],
196
+ action: model.new_record? ? :create : :update
197
+ })
198
+ end
199
+ end
200
+
201
+ # If the field map has the option leave_null_unchanged, there's an `unsetFields` string array that contains the
202
+ # name of inputs that should be treated as if they are null. We handle that by removing null inputs, and then
203
+ # adding back any unsetFields with null values.
204
+ def self.prep_leave_unchanged(inputs)
205
+ # String key hash
206
+ values = inputs.to_h.compact
207
+
208
+ unset = Array.wrap(values['unsetFields'])
209
+ values.delete('unsetFields')
210
+
211
+ unset.each do |name|
212
+ values[name] = nil
213
+ end
214
+
215
+ values
216
+ end
217
+
218
+ # Field map has the option to set_null. Any field that has the value null, or is missing, will be set to null.
219
+ def self.prep_set_null(field_map, inputs)
220
+ values = inputs.to_h.compact
221
+
222
+ field_map.fields.each { |f| values[f[:name]] ||= nil }
223
+ field_map.nested_maps.each { |m| values[m.name] ||= nil }
224
+
225
+ values
226
+ end
227
+
228
+ end
229
+ end
@@ -0,0 +1,13 @@
1
+ module GraphQL::Models
2
+ module MutationHelpers
3
+ def self.authorize_changes(context, all_changes)
4
+ changed_models = all_changes.group_by { |c| c[:model_instance] }
5
+
6
+ changed_models.each do |model, changes|
7
+ changes.map { |c| c[:action] }.uniq.each do |action|
8
+ GraphQL::Models.authorize!(context, action, model)
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,45 @@
1
+ module GraphQL::Models
2
+ module MutationHelpers
3
+ def self.print_input_fields(field_map, definer, map_name_prefix)
4
+ definer.instance_exec do
5
+ field_map.fields.each do |f|
6
+ field_type = f[:type]
7
+
8
+ if f[:required] && !field_map.leave_null_unchanged?
9
+ field_type = field_type.to_non_null_type
10
+ end
11
+
12
+ input_field(f[:name], field_type)
13
+ end
14
+
15
+ if field_map.leave_null_unchanged?
16
+ input_field('unsetFields', types[!types.String])
17
+ end
18
+ end
19
+
20
+ # Build the input types for the nested input maps
21
+ field_map.nested_maps.each do |child_map|
22
+ type = build_input_type(child_map, "#{map_name_prefix}#{child_map.name.to_s.classify}")
23
+
24
+ if child_map.has_many
25
+ type = type.to_non_null_type.to_list_type
26
+ end
27
+
28
+ if child_map.required && !field_map.leave_null_unchanged?
29
+ type = type.to_non_null_type
30
+ end
31
+
32
+ definer.instance_exec do
33
+ input_field(child_map.name, type)
34
+ end
35
+ end
36
+ end
37
+
38
+ def self.build_input_type(field_map, name)
39
+ type = GraphQL::InputObjectType.define do
40
+ name(name)
41
+ GraphQL::Models::MutationHelpers.print_input_fields(field_map, self, name)
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,95 @@
1
+ module GraphQL::Models
2
+ module MutationHelpers
3
+ def self.validate_changes(inputs, field_map, root_model, context, all_changes)
4
+ invalid_fields = {}
5
+ unknown_errors = []
6
+
7
+ changed_models = all_changes.group_by { |c| c[:model_instance] }
8
+
9
+ changed_models.reject { |m, v| m.valid? }.each do |model, changes|
10
+ attrs_to_field = changes
11
+ .select { |c| c[:attribute] && c[:input_path] }
12
+ .map { |c| [c[:attribute], c[:input_path]] }
13
+ .to_h
14
+
15
+ model.errors.each do |attribute, message|
16
+ attribute = attribute.to_sym if attribute.is_a?(String)
17
+
18
+ # Cheap check, see if this is a field that the user provided a value for...
19
+ if attrs_to_field.include?(attribute)
20
+ add_error(attribute, message, attrs_to_field[attribute], invalid_fields)
21
+ else
22
+ # Didn't provide a value, expensive check... trace down the input field
23
+ path = detect_input_path_for_attribute(model, attribute, inputs, field_map, root_model)
24
+
25
+ if path
26
+ add_error(attribute, message, path, invalid_fields)
27
+ else
28
+ unknown_errors.push({
29
+ modelType: model.class.name,
30
+ modelRid: model.id,
31
+ attribute: attribute,
32
+ message: message
33
+ })
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ unless invalid_fields.empty? && unknown_errors.empty?
40
+ fail ValidationError.new(invalid_fields, unknown_errors)
41
+ end
42
+ end
43
+
44
+ def self.add_error(attribute, message, path, invalid_fields)
45
+ path = Array.wrap(path)
46
+
47
+ current = invalid_fields
48
+ path[0..-2].each do |ps|
49
+ current = current[ps] ||= {}
50
+ end
51
+
52
+ current[path[-1]] = message
53
+ end
54
+
55
+ # Given a model and an attribute, returns the path of the input field that would modify that attribute
56
+ def self.detect_input_path_for_attribute(target_model, attribute, inputs, field_map, starting_model)
57
+ # Case 1: The input field is inside of this field map.
58
+ candidate_fields = field_map.fields.select { |f| f[:attribute] == attribute }
59
+
60
+ candidate_fields.each do |field|
61
+ # Walk to this field. If the model we get is the same as the target model, we found a match.
62
+ candidate_model = model_to_change(starting_model, field[:path], [], create_if_missing: false)
63
+ return Array.wrap(field[:name]) if candidate_model == target_model
64
+ end
65
+
66
+ # Case 2: The input field is somewhere inside of a nested field map.
67
+ field_map.nested_maps.each do |child_map|
68
+ # If we don't have the values for this map, it can't be the right one.
69
+ next if inputs[child_map.name].blank?
70
+
71
+ # Walk to the model that contains the nested field
72
+ candidate_model = model_to_change(starting_model, child_map.path, [], create_if_missing: false)
73
+
74
+ # If the model for this map doesn't exist, it can't be the one we need, because the target_model does exist.
75
+ next if candidate_model.nil?
76
+
77
+ # Match up the inputs with the models, and then check each of them.
78
+ candidate_matches = match_inputs_to_models(candidate_model, child_map, inputs[child_map.name], [])
79
+
80
+ candidate_matches.each do |m|
81
+ result = detect_input_path_for_attribute(target_model, attribute, m[:child_inputs], child_map, m[:child_model])
82
+ next if result.nil?
83
+
84
+ path = Array.wrap(result)
85
+ path.unshift(m[:input_path]) if m[:input_path]
86
+ return path
87
+ end
88
+ end
89
+
90
+ return nil
91
+ end
92
+
93
+
94
+ end
95
+ end
@@ -0,0 +1,36 @@
1
+ module GraphQL::Models
2
+ module MutationHelpers
3
+ class ValidationError < GraphQL::ExecutionError
4
+ attr_accessor :invalid_arguments, :unknown_errors
5
+
6
+ def initialize(invalid_arguments, unknown_errors)
7
+ @invalid_arguments = invalid_arguments
8
+ @unknown_errors = unknown_errors
9
+ end
10
+
11
+ def to_h
12
+ values = {
13
+ 'message' => "Some of your changes could not be saved.",
14
+ 'kind' => "INVALID_ARGUMENTS",
15
+ 'invalidArguments' => invalid_arguments,
16
+ 'unknownErrors' => unknown_errors
17
+ }
18
+
19
+ if ast_node
20
+ values.merge!({
21
+ 'locations' => [{
22
+ "line" => ast_node.line,
23
+ "column" => ast_node.col,
24
+ }]
25
+ })
26
+ end
27
+
28
+ values
29
+ end
30
+
31
+ def to_s
32
+ "Some of your changes could not be saved."
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,50 @@
1
+ module GraphQL::Models
2
+ class Mutator
3
+ attr_accessor :field_map, :root_model, :inputs, :context
4
+
5
+ def initialize(field_map, root_model, inputs, context)
6
+ @field_map = field_map
7
+ @root_model = root_model
8
+ @inputs = inputs
9
+ @context = context
10
+ end
11
+
12
+ def apply_changes
13
+ fail StandardError.new("Called apply_changes twice for the same mutator") if @all_changes
14
+ @all_changes = MutationHelpers.apply_changes(field_map, root_model, inputs, context)
15
+ changed_models
16
+ end
17
+
18
+ def changed_models
19
+ fail StandardError.new("Need to call apply_changes before #{__method__}") unless @all_changes
20
+ @all_changes.map { |c| c[:model_instance] }.uniq
21
+ end
22
+
23
+ def validate!
24
+ fail StandardError.new("Need to call apply_changes before #{__method__}") unless @all_changes
25
+ MutationHelpers.validate_changes(inputs, field_map, root_model, context, @all_changes)
26
+ end
27
+
28
+ def authorize!
29
+ fail StandardError.new("Need to call apply_changes before #{__method__}") unless @all_changes
30
+ MutationHelpers.authorize_changes(context, @all_changes)
31
+ end
32
+
33
+ def save!
34
+ fail StandardError.new("Need to call apply_changes before #{__method__}") unless @all_changes
35
+ changed_models.each(&:save!).each(&:reload)
36
+ end
37
+ end
38
+
39
+ class MutatorDefinition
40
+ attr_accessor :field_map
41
+
42
+ def initialize(model_type, null_behavior:)
43
+ @field_map = MutationFieldMap.new(model_type, find_by: nil, null_behavior: null_behavior)
44
+ end
45
+
46
+ def mutator(root_model, inputs, context)
47
+ Mutator.new(field_map, root_model, inputs, context)
48
+ end
49
+ end
50
+ end
@@ -1,5 +1,5 @@
1
1
  module GraphQL
2
2
  module Models
3
- VERSION = "0.3.1"
3
+ VERSION = "0.4.0"
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: graphql-activerecord
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ryan Foster
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2016-05-05 00:00:00.000000000 Z
11
+ date: 2016-05-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: graphql
@@ -57,6 +57,13 @@ files:
57
57
  - lib/graphql/models/middleware.rb
58
58
  - lib/graphql/models/monkey_patches/graphql_query_context.rb
59
59
  - lib/graphql/models/monkey_patches/graphql_schema_middleware_chain.rb
60
+ - lib/graphql/models/mutation_field_map.rb
61
+ - lib/graphql/models/mutation_helpers/apply_changes.rb
62
+ - lib/graphql/models/mutation_helpers/authorization.rb
63
+ - lib/graphql/models/mutation_helpers/print_input_fields.rb
64
+ - lib/graphql/models/mutation_helpers/validation.rb
65
+ - lib/graphql/models/mutation_helpers/validation_error.rb
66
+ - lib/graphql/models/mutator.rb
60
67
  - lib/graphql/models/object_type.rb
61
68
  - lib/graphql/models/promise_relation_connection.rb
62
69
  - lib/graphql/models/proxy_block.rb
@@ -83,7 +90,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
83
90
  version: '0'
84
91
  requirements: []
85
92
  rubyforge_project:
86
- rubygems_version: 2.4.8
93
+ rubygems_version: 2.5.1
87
94
  signing_key:
88
95
  specification_version: 4
89
96
  summary: ActiveRecord helpers for GraphQL + Relay