Module: GraphQL::Models::MutationHelpers

Defined in:
lib/graphql/models/mutation_helpers/validation.rb,
lib/graphql/models/mutation_helpers/apply_changes.rb,
lib/graphql/models/mutation_helpers/authorization.rb,
lib/graphql/models/mutation_helpers/validation_error.rb,
lib/graphql/models/mutation_helpers/print_input_fields.rb

Defined Under Namespace

Classes: ValidationError

Class Method Summary collapse

Class Method Details

.add_error(_attribute, message, path, invalid_fields) ⇒ Object



45
46
47
48
49
50
51
52
53
54
# File 'lib/graphql/models/mutation_helpers/validation.rb', line 45

def self.add_error(_attribute, message, path, invalid_fields)
  path = Array.wrap(path)

  current = invalid_fields
  path[0..-2].each do |ps|
    current = current[ps] ||= {}
  end

  current[path[-1]] = message
end

.apply_changes(field_map, model, inputs, context) ⇒ Object



4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# File 'lib/graphql/models/mutation_helpers/apply_changes.rb', line 4

def self.apply_changes(field_map, model, inputs, context)
  # This will hold a flattened list of attributes/models that we actually changed
  changes = []

  # Values will now contain the list of inputs that we should actually act on. Any null values should actually
  # be set to null, and missing fields should be skipped.
  values = field_map.leave_null_unchanged? ? prep_leave_unchanged(inputs) : prep_set_null(field_map, inputs)

  values.each do |name, value|
    field_def = field_map.fields.detect { |f| f[:name] == name }

    # Skip this value unless it's a field on the model. Nested fields are handled later.
    next unless field_def

    # Advance to the model that we actually need to change
    change_model = model_to_change(model, field_def[:path], changes, create_if_missing: !value.nil?)
    next if change_model.nil?

    # Apply the change to this model
    apply_field_value(change_model, field_def, value, context, changes)
  end

  # Handle the value nested fields now.
  field_map.nested_maps.each do |child_map|
    next if inputs[child_map.name].nil? && field_map.leave_null_unchanged?

    # Advance to the model that contains the nested fields
    change_model = model_to_change(model, child_map.path, changes, create_if_missing: !inputs[child_map.name].nil?)
    next if change_model.nil?

    # Apply the changes to the nested models
    child_changes = handle_nested_map(field_map, change_model, inputs, context, child_map)

    # Merge the changes with the parent, but prepend the input field path
    child_changes.each do |cc|
      cc[:input_path] = [child_map.name] + Array.wrap(cc[:input_path]) if cc[:input_path]
      changes.push(cc)
    end
  end

  changes
end

.apply_field_value(model, field_def, value, context, changes) ⇒ Object



214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
# File 'lib/graphql/models/mutation_helpers/apply_changes.rb', line 214

def self.apply_field_value(model, field_def, value, context, changes)
  # Special case: If this is an ID field, get the ID from the target model
  if value.present? && field_def[:type].unwrap == GraphQL::ID_TYPE
    value = relay_id_to_model_id(value, context)
    unless value
      raise GraphQL::ExecutionError, "The value provided for #{field_def[:name]} does not refer to a valid model."
    end
  end

  unless model.public_send(field_def[:attribute]) == value
    model.public_send("#{field_def[:attribute]}=", value)

    changes.push({
      model_instance: model,
      input_path: field_def[:name],
      attribute: field_def[:attribute],
      action: model.new_record? ? :create : :update,
    })
  end
end

.authorize_changes(context, all_changes) ⇒ Object



4
5
6
7
8
9
10
11
12
# File 'lib/graphql/models/mutation_helpers/authorization.rb', line 4

def self.authorize_changes(context, all_changes)
  changed_models = all_changes.group_by { |c| c[:model_instance] }

  changed_models.each do |model, changes|
    changes.map { |c| c[:action] }.uniq.each do |action|
      GraphQL::Models.authorize!(context, action, model)
    end
  end
end

.build_input_type(field_map, name) ⇒ Object



50
51
52
53
54
55
# File 'lib/graphql/models/mutation_helpers/print_input_fields.rb', line 50

def self.build_input_type(field_map, name)
  type = GraphQL::InputObjectType.define do
    name(name)
    GraphQL::Models::MutationHelpers.print_input_fields(field_map, self, name)
  end
end

.detect_input_path_for_attribute(target_model, attribute, inputs, field_map, starting_model) ⇒ Object

Given a model and an attribute, returns the path of the input field that would modify that attribute



57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
# File 'lib/graphql/models/mutation_helpers/validation.rb', line 57

def self.detect_input_path_for_attribute(target_model, attribute, inputs, field_map, starting_model)
  # Case 1: The input field is inside of this field map.
  candidate_fields = field_map.fields.select { |f| f[:attribute] == attribute }

  candidate_fields.each do |field|
    # Walk to this field. If the model we get is the same as the target model, we found a match.
    candidate_model = model_to_change(starting_model, field[:path], [], create_if_missing: false)
    return Array.wrap(field[:name]) if candidate_model == target_model
  end

  # Case 2: The input field *is* a nested map
  candidate_maps = field_map.nested_maps.select { |m| m.association == attribute.to_s }

  candidate_maps.each do |map|
    # Walk to this field. If the model we get is the same as the target model, we found a match.
    candidate_model = model_to_change(starting_model, map.path, [], create_if_missing: false)
    return Array.wrap(map.name) if candidate_model == target_model
  end

  # Case 3: The input field is somewhere inside of a nested field map.
  field_map.nested_maps.each do |child_map|
    # If we don't have the values for this map, it can't be the right one.
    next if inputs[child_map.name].blank?

    # Walk to the model that contains the nested field
    candidate_model = model_to_change(starting_model, child_map.path, [], create_if_missing: false)

    # If the model for this map doesn't exist, it can't be the one we need, because the target_model does exist.
    next if candidate_model.nil?

    # Match up the inputs with the models, and then check each of them.
    candidate_matches = match_inputs_to_models(candidate_model, child_map, inputs[child_map.name], [])

    candidate_matches.each do |m|
      result = detect_input_path_for_attribute(target_model, attribute, m[:child_inputs], child_map, m[:child_model])
      next if result.nil?

      path = Array.wrap(result)
      path.unshift(m[:input_path]) if m[:input_path]
      return path
    end
  end

  nil
end

.handle_nested_map(parent_map, parent_model, inputs, context, child_map) ⇒ Object



47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
# File 'lib/graphql/models/mutation_helpers/apply_changes.rb', line 47

def self.handle_nested_map(parent_map, parent_model, inputs, context, child_map)
  next_inputs = inputs[child_map.name]

  # Don't do anything if the value is null, and we leave null fields unchanged
  return [] if next_inputs.nil? && parent_map.leave_null_unchanged?

  changes = []
  matches = match_inputs_to_models(parent_model, child_map, next_inputs, changes, context)

  matches.each do |match|
    next if match[:child_model].nil? && match[:child_inputs].nil?

    child_changes = apply_changes(child_map, match[:child_model], match[:child_inputs], context)

    if match[:input_path]
      child_changes.select { |cc| cc[:input_path] }.each do |cc|
        cc[:input_path] = [match[:input_path]] + Array.wrap(cc[:input_path])
      end
    end

    changes.concat(child_changes)
  end

  changes
end

.match_inputs_by_fields(_model, child_map, next_inputs, changes, associated_models, find_by, context) ⇒ Object



137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
# File 'lib/graphql/models/mutation_helpers/apply_changes.rb', line 137

def self.match_inputs_by_fields(_model, child_map, next_inputs, changes, associated_models, find_by, context)
  # Convert the find_by into the field definitions, so that we properly unmap aliased fields
  find_by_defs = find_by.map { |name| child_map.fields.detect { |f| f[:attribute].to_s == name.to_s } }
  name_to_attr = find_by_defs.map { |f| [f[:name], f[:attribute].to_s] }.to_h

  indexed_models = associated_models.index_by { |m| m.attributes.slice(*find_by) }

  # Inputs are a little nasty, the keys have to be converted from camelCase back to snake_case
  indexed_inputs = next_inputs.index_by { |ni| ni.to_h.slice(*name_to_attr.keys) }

  indexed_inputs = indexed_inputs.map do |key, inputs|
    attr_key = {}
    key.each do |name, val|
      # If the input is a Relay ID, convert it to the model's ordinary ID first. Note, this is
      # less than optimal, because it has to fetch the model and then just get its id :(
      field_def = find_by_defs.detect { |d| d[:name] == name }
      if val && field_def[:type].unwrap == GraphQL::ID_TYPE
        val = relay_id_to_model_id(val, context)
        raise GraphQL::ExecutionError, "The value provided for #{field_def[:name]} does not refer to a valid model." unless val
      end

      attr_key[name_to_attr[name]] = val
    end

    [attr_key, inputs]
  end

  indexed_inputs = indexed_inputs.to_h

  # Match each model to its input. If there is no input for it, mark that the model should be destroyed.
  matches = []

  indexed_models.each do |key_attrs, child_model|
    inputs = indexed_inputs[key_attrs]

    if inputs.nil?
      child_model.mark_for_destruction
      changes.push({ model_instance: child_model, action: :destroy })
    else
      matches.push({ child_model: child_model, child_inputs: inputs, input_path: next_inputs.index(inputs) })
    end
  end

  # Build a new model for each input that doesn't have a model
  indexed_inputs.each do |key_attrs, inputs|
    next if indexed_models.include?(key_attrs)

    child_model = associated_models.build
    changes.push({ model_instance: child_model, action: :create })
    matches.push({ child_model: child_model, child_inputs: inputs, input_path: next_inputs.index(inputs) })
  end

  matches
end

.match_inputs_by_position(_model, _child_map, next_inputs, changes, associated_models) ⇒ Object



112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
# File 'lib/graphql/models/mutation_helpers/apply_changes.rb', line 112

def self.match_inputs_by_position(_model, _child_map, next_inputs, changes, associated_models)
  count = [associated_models.length, next_inputs.length].max

  matches = []

  # This will give us an array of [number, model, inputs].
  # Either the model or the inputs could be nil, but not both.
  count.times.zip(associated_models.to_a, next_inputs) do |(idx, child_model, inputs)|
    if child_model.nil?
      child_model = associated_models.build
      changes.push({ model_instance: child_model, action: :create })
    end

    if inputs.nil?
      child_model.mark_for_destruction
      changes.push({ model_instance: child_model, action: :destroy })
      next
    end

    matches.push({ child_model: child_model, child_inputs: inputs, input_path: idx })
  end

  matches
end

.match_inputs_to_models(model, child_map, next_inputs, changes, context) ⇒ Object



73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
# File 'lib/graphql/models/mutation_helpers/apply_changes.rb', line 73

def self.match_inputs_to_models(model, child_map, next_inputs, changes, context)
  if !child_map.has_many
    child_model = model.public_send(child_map.association)

    if next_inputs.nil? && !child_model.nil?
      child_model.mark_for_destruction
      changes.push({ model_instance: child_model, action: :destroy })
    elsif child_model.nil? && !next_inputs.nil?
      child_model = model.public_send("build_#{child_map.association}")

      assoc = model.association(child_map.association)
      refl = assoc.reflection

      if refl.options.include?(:as)
        inverse_name = refl.options[:as]
        inverse_assoc = child_model.association(inverse_name)
        inverse_assoc.target = model
        inverse_assoc.inversed = true
      end

      changes.push({ model_instance: child_model, action: :create })
    end

    [{ child_model: child_model, child_inputs: next_inputs }]
  else
    next_inputs = [] if next_inputs.nil?

    # Match up each of the elements in next_inputs with one of the models, based on the `find_by` value.
    associated_models = model.public_send(child_map.association)
    find_by = Array.wrap(child_map.find_by).map(&:to_s)

    if find_by.empty?
      return match_inputs_by_position(model, child_map, next_inputs, changes, associated_models)
    else
      return match_inputs_by_fields(model, child_map, next_inputs, changes, associated_models, find_by, context)
    end
  end
end

.model_to_change(starting_model, path, changes, create_if_missing: true) ⇒ Object

Returns the instance of the model that will be changed for this field. If new models are created along the way, they are added to the list of changes.



194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
# File 'lib/graphql/models/mutation_helpers/apply_changes.rb', line 194

def self.model_to_change(starting_model, path, changes, create_if_missing: true)
  model_to_change = starting_model

  Array.wrap(path).each do |ps|
    next_model = model_to_change.public_send(ps)

    return nil if next_model.nil? && !create_if_missing

    unless next_model
      next_model = model_to_change.public_send("build_#{ps}")
      # Even though we may not be changing anything on this model, record it as a change, since it's a new model.
      changes.push({ model_instance: next_model, action: :create })
    end

    model_to_change = next_model
  end

  model_to_change
end

.prep_leave_unchanged(inputs) ⇒ Object

If the field map has the option leave_null_unchanged, there’s an ‘unsetFields` string array that contains the name of inputs that should be treated as if they are null. We handle that by removing null inputs, and then adding back any unsetFields with null values.



238
239
240
241
242
243
244
245
246
247
248
249
250
# File 'lib/graphql/models/mutation_helpers/apply_changes.rb', line 238

def self.prep_leave_unchanged(inputs)
  # String key hash
  values = inputs.to_h.compact

  unset = Array.wrap(values['unsetFields'])
  values.delete('unsetFields')

  unset.each do |name|
    values[name] = nil
  end

  values
end

.prep_set_null(field_map, inputs) ⇒ Object

Field map has the option to set_null. Any field that has the value null, or is missing, will be set to null.



253
254
255
256
257
258
259
260
# File 'lib/graphql/models/mutation_helpers/apply_changes.rb', line 253

def self.prep_set_null(field_map, inputs)
  values = inputs.to_h.compact

  field_map.fields.reject { |f| values.include?(f[:name]) }.each { |f| values[f[:name]] = nil }
  field_map.nested_maps.reject { |m| values.include?(m.name) }.each { |m| values[m.name] = nil }

  values
end


4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# File 'lib/graphql/models/mutation_helpers/print_input_fields.rb', line 4

def self.print_input_fields(field_map, definer, map_name_prefix)
  definer.instance_exec do
    field_map.fields.each do |f|
      field_type = f[:type]

      if f[:required] && !field_map.leave_null_unchanged?
        field_type = field_type.to_non_null_type
      end

      input_field(f[:name], field_type)
    end

    if field_map.leave_null_unchanged?
      field_names = field_map.fields.select { |f| !f[:required] }.map { |f| f[:name] }
      field_names += field_map.nested_maps.reject(&:required).map(&:name)
      field_names = field_names.sort

      unless field_names.empty?
        enum = GraphQL::EnumType.define do
          name "#{map_name_prefix}UnsettableFields"
          field_names.each { |n| value(n, n.titleize, value: n) }
        end

        input_field('unsetFields', types[!enum])
      end
    end
  end

  # Build the input types for the nested input maps
  field_map.nested_maps.each do |child_map|
    type = build_input_type(child_map, "#{map_name_prefix}#{child_map.name.to_s.classify}")

    if child_map.has_many
      type = type.to_non_null_type.to_list_type
    end

    if child_map.required && !field_map.leave_null_unchanged?
      type = type.to_non_null_type
    end

    definer.instance_exec do
      input_field(child_map.name, type)
    end
  end
end

.relay_id_to_model_id(relay_id, context) ⇒ Object



262
263
264
265
# File 'lib/graphql/models/mutation_helpers/apply_changes.rb', line 262

def self.relay_id_to_model_id(relay_id, context)
  target_model = GraphQL::Models.model_from_id.call(relay_id, context)
  target_model&.id
end

.validate_changes(inputs, field_map, root_model, _context, all_changes) ⇒ Object



4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# File 'lib/graphql/models/mutation_helpers/validation.rb', line 4

def self.validate_changes(inputs, field_map, root_model, _context, all_changes)
  invalid_fields = {}
  unknown_errors = []

  changed_models = all_changes.group_by { |c| c[:model_instance] }

  changed_models.reject { |m, _v| m.valid? }.each do |model, changes|
    attrs_to_field = changes
      .select { |c| c[:attribute] && c[:input_path] }
      .map { |c| [c[:attribute], c[:input_path]] }
      .to_h

    model.errors.each do |attribute, message|
      attribute = attribute.to_sym if attribute.is_a?(String)

      # Cheap check, see if this is a field that the user provided a value for...
      if attrs_to_field.include?(attribute)
        add_error(attribute, message, attrs_to_field[attribute], invalid_fields)
      else
        # Didn't provide a value, expensive check... trace down the input field
        path = detect_input_path_for_attribute(model, attribute, inputs, field_map, root_model)

        if path
          add_error(attribute, message, path, invalid_fields)
        else
          unknown_errors.push({
            modelType: model.class.name,
            modelRid: model.id,
            attribute: attribute,
            message: message,
          })
        end
      end
    end
  end

  unless invalid_fields.empty? && unknown_errors.empty?
    raise ValidationError.new(invalid_fields, unknown_errors)
  end
end