graphql-activerecord 0.3.1 → 0.4.0

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