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, context) ⇒ Object
- .match_inputs_by_position(_model, _child_map, next_inputs, changes, associated_models) ⇒ Object
- .match_inputs_to_models(model, child_map, next_inputs, changes, context) ⇒ 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
- .relay_id_to_model_id(relay_id, context) ⇒ Object
- .validate_changes(inputs, field_map, root_model, _context, all_changes) ⇒ Object
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, , 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
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.(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
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..include?(:as) inverse_name = refl.[: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 |
.print_input_fields(field_map, definer, map_name_prefix) ⇒ 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 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, | 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? raise ValidationError.new(invalid_fields, unknown_errors) end end |