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
- .add_error(attribute, message, path, invalid_fields) ⇒ Object
- .apply_changes(field_map, model, inputs, context) ⇒ Object
- .apply_field_value(model, field_def, value, context, changes) ⇒ Object
- .authorize_changes(context, all_changes) ⇒ Object
- .build_input_type(field_map, name) ⇒ Object
-
.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.
- .handle_nested_map(parent_map, parent_model, inputs, context, child_map) ⇒ Object
- .match_inputs_by_fields(model, child_map, next_inputs, changes, associated_models, find_by) ⇒ Object
- .match_inputs_by_position(model, child_map, next_inputs, changes, associated_models) ⇒ Object
- .match_inputs_to_models(model, child_map, next_inputs, changes) ⇒ Object
-
.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.
-
.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.
-
.prep_set_null(field_map, inputs) ⇒ Object
Field map has the option to set_null.
- .print_input_fields(field_map, definer, map_name_prefix) ⇒ Object
- .validate_changes(inputs, field_map, root_model, context, all_changes) ⇒ Object
Class Method Details
.add_error(attribute, message, path, invalid_fields) ⇒ Object
44 45 46 47 48 49 50 51 52 53 |
# File 'lib/graphql/models/mutation_helpers/validation.rb', line 44 def self.add_error(attribute, , path, invalid_fields) path = Array.wrap(path) current = invalid_fields path[0..-2].each do |ps| current = current[ps] ||= {} end current[path[-1]] = end |
.apply_changes(field_map, model, inputs, context) ⇒ Object
3 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 |
# File 'lib/graphql/models/mutation_helpers/apply_changes.rb', line 3 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 return changes end |
.apply_field_value(model, field_def, value, context, changes) ⇒ Object
192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 |
# File 'lib/graphql/models/mutation_helpers/apply_changes.rb', line 192 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 target_model = GraphQL::Models.model_from_id.call(value, context) unless target_model fail GraphQL::ExecutionError.new("The value provided for #{field_def[:name]} does not refer to a valid model.") end value = target_model.id 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
3 4 5 6 7 8 9 10 11 |
# File 'lib/graphql/models/mutation_helpers/authorization.rb', line 3 def self.(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.(context, action, model) end end end |
.build_input_type(field_map, name) ⇒ Object
46 47 48 49 50 51 |
# File 'lib/graphql/models/mutation_helpers/print_input_fields.rb', line 46 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
56 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 |
# File 'lib/graphql/models/mutation_helpers/validation.rb', line 56 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 return nil end |
.handle_nested_map(parent_map, parent_model, inputs, context, child_map) ⇒ Object
46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 |
# File 'lib/graphql/models/mutation_helpers/apply_changes.rb', line 46 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) 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 return changes end |
.match_inputs_by_fields(model, child_map, next_inputs, changes, associated_models, find_by) ⇒ Object
125 126 127 128 129 130 131 132 133 134 135 136 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 |
# File 'lib/graphql/models/mutation_helpers/apply_changes.rb', line 125 def self.match_inputs_by_fields(model, child_map, next_inputs, changes, associated_models, find_by) # 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| key = key.map { |name, val| [name_to_attr[name], val] }.to_h [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 = [] # TODO: Support for finding by an ID field, that needs to be untranslated from a Relay ID into a model ID 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 return matches end |
.match_inputs_by_position(model, child_map, next_inputs, changes, associated_models) ⇒ Object
100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 |
# File 'lib/graphql/models/mutation_helpers/apply_changes.rb', line 100 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 return matches end |
.match_inputs_to_models(model, child_map, next_inputs, changes) ⇒ Object
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 |
# File 'lib/graphql/models/mutation_helpers/apply_changes.rb', line 72 def self.match_inputs_to_models(model, child_map, next_inputs, changes) 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}") changes.push({ model_instance: child_model, action: :create }) end return [{ 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) 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.
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 172 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 return 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.
219 220 221 222 223 224 225 226 227 228 229 230 231 |
# File 'lib/graphql/models/mutation_helpers/apply_changes.rb', line 219 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.
234 235 236 237 238 239 240 241 |
# File 'lib/graphql/models/mutation_helpers/apply_changes.rb', line 234 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 |
.print_input_fields(field_map, definer, map_name_prefix) ⇒ Object
3 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 |
# File 'lib/graphql/models/mutation_helpers/print_input_fields.rb', line 3 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.map { |f| f[:name] } + field_map.nested_maps.map { |m| m.name } field_names = field_names.sort_by { |s| s } 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 # 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 |
.validate_changes(inputs, field_map, root_model, context, all_changes) ⇒ Object
3 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 |
# File 'lib/graphql/models/mutation_helpers/validation.rb', line 3 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, | 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, , 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, , path, invalid_fields) else unknown_errors.push({ modelType: model.class.name, modelRid: model.id, attribute: attribute, message: }) end end end end unless invalid_fields.empty? && unknown_errors.empty? fail ValidationError.new(invalid_fields, unknown_errors) end end |