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 +4 -4
- data/lib/graphql/activerecord.rb +19 -1
- data/lib/graphql/models/definition_helpers/attributes.rb +11 -4
- data/lib/graphql/models/definition_helpers.rb +15 -0
- data/lib/graphql/models/mutation_field_map.rb +155 -0
- data/lib/graphql/models/mutation_helpers/apply_changes.rb +229 -0
- data/lib/graphql/models/mutation_helpers/authorization.rb +13 -0
- data/lib/graphql/models/mutation_helpers/print_input_fields.rb +45 -0
- data/lib/graphql/models/mutation_helpers/validation.rb +95 -0
- data/lib/graphql/models/mutation_helpers/validation_error.rb +36 -0
- data/lib/graphql/models/mutator.rb +50 -0
- data/lib/graphql/models/version.rb +1 -1
- metadata +10 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: f66ba2bd587b9ec556a33c6c53be9feb2f0897d3
|
4
|
+
data.tar.gz: 30b46efe06e881c0237d5f624cd7376873e0b253
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 70e55f628ca3ce4d4c385bfdf090eca6905867b698a917cefacfbc172fe8f0778de945d99fbab8579007d3d1da573e47b4f23dcb7728e9ddfe11f4911084711a
|
7
|
+
data.tar.gz: b7d96cfc3e4a0bdbbb732ca49fc99e264b4589406f05894c450a62a5279df33204c3e063d6a672904304215f63260837f620f1c3ef015ce9d01580baa25aab4b
|
data/lib/graphql/activerecord.rb
CHANGED
@@ -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
|
-
|
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
|
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.
|
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-
|
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.
|
93
|
+
rubygems_version: 2.5.1
|
87
94
|
signing_key:
|
88
95
|
specification_version: 4
|
89
96
|
summary: ActiveRecord helpers for GraphQL + Relay
|