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