Skip to content

Commit e7a5a45

Browse files
authored
Guided error formatter
2 parents 0a40196 + 570f205 commit e7a5a45

15 files changed

Lines changed: 925 additions & 262 deletions

lib/graphql/cardinal/executor.rb

Lines changed: 33 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,12 @@
55
require_relative "./executor/authorization"
66
require_relative "./executor/hot_paths"
77
require_relative "./executor/response_hash"
8-
require_relative "./executor/error_formatting"
8+
require_relative "./executor/error_formatter"
99

1010
module GraphQL
1111
module Cardinal
1212
class Executor
1313
include HotPaths
14-
include ErrorFormatting
1514

1615
TYPENAME_FIELD = "__typename"
1716
TYPENAME_FIELD_RESOLVER = TypenameResolver.new
@@ -28,7 +27,6 @@ def initialize(schema, resolvers, document, root_object, variables: {}, context:
2827
@context = context
2928
@data = {}
3029
@errors = []
31-
@path = []
3230
@exec_queue = []
3331
@exec_count = 0
3432
@context[:query] = @query
@@ -69,7 +67,7 @@ def perform
6967
execute_scope(@exec_queue.shift) until @exec_queue.empty?
7068
end
7169

72-
response = { "data" => @errors.empty? ? @data : format_inline_errors(@data, @errors) }
70+
response = { "data" => @errors.empty? ? @data : ErrorFormatter.new(@query, @data, @errors).perform }
7371
response["errors"] = @errors.map(&:to_h) unless @errors.empty?
7472
response
7573
end
@@ -78,14 +76,13 @@ def perform
7876

7977
def execute_scope(exec_scope)
8078
unless exec_scope.fields
81-
lazy_field_keys = []
8279
exec_scope.fields = execution_fields_by_key(exec_scope.parent_type, exec_scope.selections)
8380
exec_scope.fields.each_value do |exec_field|
84-
@path.push(exec_field.key)
8581
parent_type = exec_scope.parent_type
8682
parent_sources = exec_scope.sources
8783
field_name = exec_field.name
8884

85+
exec_field.scope = exec_scope
8986
exec_field.type = @query.get_field(parent_type, field_name).type
9087
value_type = exec_field.type.unwrap
9188

@@ -94,71 +91,61 @@ def execute_scope(exec_scope)
9491
if field_name == TYPENAME_FIELD
9592
field_resolver = TYPENAME_FIELD_RESOLVER
9693
else
97-
raise NotImplementedError, "No field resolver for `#{parent_type.graphql_name}.#{field_name}`"
94+
raise NotImplementedError, "No field resolver for '#{parent_type.graphql_name}.#{field_name}'"
9895
end
9996
end
10097

101-
resolved_sources = if !field_resolver.authorized?(@context)
102-
@errors << AuthorizationError.new(type_name: parent_type.graphql_name, field_name: field_name, path: @path.dup, base: true)
98+
exec_field.result = if !field_resolver.authorized?(@context)
99+
@errors << AuthorizationError.new(type_name: parent_type.graphql_name, field_name: field_name, path: exec_field.path, base: true)
103100
Array.new(parent_sources.length, @errors.last)
104101
elsif !Authorization.can_access_type?(value_type, @context)
105-
@errors << AuthorizationError.new(type_name: value_type.graphql_name, path: @path.dup, base: true)
102+
@errors << AuthorizationError.new(type_name: value_type.graphql_name, path: exec_field.path, base: true)
106103
Array.new(parent_sources.length, @errors.last)
107104
else
108105
begin
109106
@tracers.each { _1.before_resolve_field(parent_type, field_name, parent_sources.length, @context) }
110107
field_resolver.resolve(parent_sources, exec_field.arguments(@variables), @context, exec_scope)
111108
rescue StandardError => e
112-
report_exception(error: e)
113-
@errors << InternalError.new(path: @path.dup, base: true)
109+
report_exception(error: e, field: exec_field)
110+
@errors << InternalError.new(path: exec_field.path, base: true)
114111
Array.new(parent_sources.length, @errors.last)
115112
ensure
116113
@tracers.each { _1.after_resolve_field(parent_type, field_name, parent_sources.length, @context) }
117114
@exec_count += 1
118115
end
119116
end
120-
121-
if resolved_sources.is_a?(Promise)
122-
exec_field.promise = resolved_sources
123-
lazy_field_keys << exec_field.key
124-
else
125-
resolve_execution_field(exec_scope, exec_field, resolved_sources, lazy_field_keys)
126-
lazy_field_keys.clear
127-
end
128-
129-
@path.pop
130117
end
131118
end
132119

133-
if exec_scope.lazy_fields_pending?
120+
if exec_scope.lazy_fields?
134121
if exec_scope.lazy_fields_ready?
135-
exec_scope.method(:lazy_exec!).call # << noop for loaders that have already run
122+
exec_scope.send(:lazy_exec!) # << noop for loaders that have already run
136123
exec_scope.fields.each_value do |exec_field|
137-
next unless exec_field.promise
138-
139-
@path.push(exec_field.key)
140-
resolve_execution_field(exec_scope, exec_field, exec_field.promise.value)
141-
@path.pop
124+
sources = exec_field.result.is_a?(Promise) ? exec_field.result.value : exec_field.result
125+
resolve_execution_field(exec_field, sources)
142126
end
143127
else
144128
# requeue the scope to wait on others that haven't built fields yet
145129
@exec_queue << exec_scope
146130
end
131+
else
132+
exec_scope.fields.each_value do |exec_field|
133+
resolve_execution_field(exec_field, exec_field.result)
134+
end
147135
end
148136

149137
nil
150138
end
151139

152-
def resolve_execution_field(exec_scope, exec_field, resolved_sources, lazy_field_keys = nil)
153-
parent_sources = exec_scope.sources
154-
parent_responses = exec_scope.responses
140+
def resolve_execution_field(exec_field, resolved_sources)
141+
parent_sources = exec_field.scope.sources
142+
parent_responses = exec_field.scope.responses
155143
field_key = exec_field.key
156-
field_name = exec_field.name
157144
field_type = exec_field.type
158145
return_type = field_type.unwrap
159146

160147
if resolved_sources.length != parent_sources.length
161-
report_exception("Incorrect number of results resolved. Expected #{parent_sources.length}, got #{resolved_sources.length}")
148+
report_exception("Incorrect number of results resolved. Expected #{parent_sources.length}, got #{resolved_sources.length}", field: exec_field)
162149
resolved_sources = Array.new(parent_sources.length, nil)
163150
end
164151

@@ -168,9 +155,7 @@ def resolve_execution_field(exec_scope, exec_field, resolved_sources, lazy_field
168155
next_responses = []
169156
resolved_sources.each_with_index do |source, i|
170157
# DANGER: HOT PATH!
171-
response = parent_responses[i]
172-
lazy_field_keys.each { |k| response[k] = nil } if lazy_field_keys && !lazy_field_keys.empty?
173-
response[field_key] = build_composite_response(field_type, source, next_sources, next_responses)
158+
parent_responses[i][field_key] = build_composite_response(exec_field, field_type, source, next_sources, next_responses)
174159
end
175160

176161
if return_type.kind.abstract?
@@ -184,7 +169,7 @@ def resolve_execution_field(exec_scope, exec_field, resolved_sources, lazy_field
184169
next_sources.each_with_index do |source, i|
185170
# DANGER: HOT PATH!
186171
impl_type = type_resolver.call(source, @context)
187-
next_sources_by_type[impl_type] << (field_name == TYPENAME_FIELD ? impl_type.graphql_name : source)
172+
next_sources_by_type[impl_type] << (exec_field.name == TYPENAME_FIELD ? impl_type.graphql_name : source)
188173
next_responses_by_type[impl_type] << next_responses[i].tap { |r| r.typename = impl_type.graphql_name }
189174
end
190175

@@ -193,7 +178,7 @@ def resolve_execution_field(exec_scope, exec_field, resolved_sources, lazy_field
193178
next_sources_by_type.each do |impl_type, impl_type_sources|
194179
# check concrete type access only once per resolved type...
195180
unless Authorization.can_access_type?(impl_type, @context)
196-
@errors << AuthorizationError.new(type_name: impl_type.graphql_name, path: @path.dup, base: true)
181+
@errors << AuthorizationError.new(type_name: impl_type.graphql_name, path: exec_field.path, base: true)
197182
impl_type_sources = Array.new(impl_type_sources.length, @errors.last)
198183
end
199184

@@ -204,7 +189,8 @@ def resolve_execution_field(exec_scope, exec_field, resolved_sources, lazy_field
204189
responses: next_responses_by_type[impl_type],
205190
loader_cache: loader_cache,
206191
loader_group: loader_group,
207-
parent: exec_scope,
192+
path: exec_field.path,
193+
parent: exec_field.scope,
208194
)
209195
end
210196

@@ -215,17 +201,16 @@ def resolve_execution_field(exec_scope, exec_field, resolved_sources, lazy_field
215201
selections: exec_field.selections,
216202
sources: next_sources,
217203
responses: next_responses,
218-
parent: exec_scope,
204+
path: exec_field.path,
205+
parent: exec_field.scope,
219206
)
220207
end
221208
else
222209
# build leaf results
223210
resolved_sources.each_with_index do |val, i|
224211
# DANGER: HOT PATH!
225-
response = parent_responses[i]
226-
lazy_field_keys.each { |k| response[k] = nil } if lazy_field_keys && !lazy_field_keys.empty?
227-
response[field_key] = if val.nil? || val.is_a?(StandardError)
228-
build_missing_value(field_type, val)
212+
parent_responses[i][field_key] = if val.nil? || val.is_a?(StandardError)
213+
build_missing_value(exec_field, field_type, val)
229214
elsif return_type.kind.scalar?
230215
coerce_scalar_value(return_type, val)
231216
elsif return_type.kind.enum?
@@ -254,7 +239,7 @@ def execution_fields_by_key(parent_type, selections, map: Hash.new { |h, k| h[k]
254239
fragment = @query.fragments[node.name]
255240
fragment_type = @query.get_type(fragment.type.name)
256241
if @query.possible_types(fragment_type).include?(parent_type)
257-
execution_fields_by_key(parent_type, node.selections, map: map)
242+
execution_fields_by_key(parent_type, fragment.selections, map: map)
258243
end
259244

260245
else
@@ -286,9 +271,9 @@ def if_argument?(bool_arg)
286271
end
287272
end
288273

289-
def report_exception(message = nil, error: nil, path: @path.dup)
274+
def report_exception(message = nil, error: nil, field: nil)
290275
# todo: add real error reporting...
291-
puts "Error at #{path.join(".")}: #{message || error&.message}"
276+
puts "Error at #{field.path.join(".")}: #{message || error&.message}" if field
292277
puts error.backtrace.join("\n") if error
293278
end
294279
end

lib/graphql/cardinal/executor/error_formatting.rb renamed to lib/graphql/cardinal/executor/error_formatter.rb

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,35 +3,51 @@
33

44
module GraphQL::Cardinal
55
class Executor
6-
module ErrorFormatting
7-
private
6+
class ErrorFormatter
7+
def initialize(query, data, errors)
8+
@query = query
9+
@data = data
10+
@target_paths = errors.map(&:path).tap(&:compact!).tap(&:uniq!)
11+
@selection_path = []
12+
@actual_path = []
13+
end
14+
15+
def perform
16+
return @data if @target_paths.empty?
817

9-
def format_inline_errors(data, _errors)
10-
# todo: make this smarter to only traverse down actual error paths
11-
@path = []
1218
propagate_object_scope_errors(
13-
data,
19+
@data,
1420
@query.root_type_for_operation(@query.selected_operation.operation_type),
1521
@query.selected_operation.selections,
1622
)
1723
end
1824

25+
private
26+
1927
def propagate_object_scope_errors(raw_object, parent_type, selections)
2028
return nil if raw_object.nil?
2129

2230
selections.each do |node|
2331
case node
2432
when GraphQL::Language::Nodes::Field
25-
field_name = node.alias || node.name
26-
@path << field_name
33+
field_key = node.alias || node.name
34+
35+
return raw_object unless @target_paths.any? do |target_path|
36+
target_path[@selection_path.length] == field_key && @selection_path.each_with_index.all? do |part, i|
37+
part == target_path[i]
38+
end
39+
end
40+
41+
@selection_path << field_key
42+
@actual_path << field_key
2743

2844
begin
2945
node_type = @query.get_field(parent_type, node.name).type
3046
named_type = node_type.unwrap
31-
raw_value = raw_object[field_name]
47+
raw_value = raw_object[field_key]
3248

33-
raw_object[field_name] = if raw_value.is_a?(ExecutionError)
34-
raw_value.replace_path(@path.dup) unless raw_value.base_error?
49+
raw_object[field_key] = if raw_value.is_a?(ExecutionError)
50+
raw_value.replace_path(@actual_path.dup) unless raw_value.base_error?
3551
nil
3652
elsif node_type.list?
3753
node_type = node_type.of_type while node_type.non_null?
@@ -42,9 +58,10 @@ def propagate_object_scope_errors(raw_object, parent_type, selections)
4258
propagate_object_scope_errors(raw_value, named_type, node.selections)
4359
end
4460

45-
return nil if node_type.non_null? && raw_object[field_name].nil?
61+
return nil if node_type.non_null? && raw_object[field_key].nil?
4662
ensure
47-
@path.pop
63+
@selection_path.pop
64+
@actual_path.pop
4865
end
4966

5067
when GraphQL::Language::Nodes::InlineFragment
@@ -79,7 +96,7 @@ def propagate_list_scope_errors(raw_list, current_node_type, selections)
7996
contains_null = false
8097

8198
resolved_list = raw_list.map!.with_index do |raw_list_element, index|
82-
@path << index
99+
@actual_path << index
83100

84101
begin
85102
result = if next_node_type.list?
@@ -97,7 +114,7 @@ def propagate_list_scope_errors(raw_list, current_node_type, selections)
97114

98115
result
99116
ensure
100-
@path.pop
117+
@actual_path.pop
101118
end
102119
end
103120

lib/graphql/cardinal/executor/execution_field.rb

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,28 @@ module GraphQL::Cardinal
44
class Executor
55
class ExecutionField
66
attr_reader :key, :node
7-
attr_accessor :type, :promise
7+
attr_accessor :scope, :type, :result
88

9-
def initialize(key)
9+
def initialize(key, scope = nil)
1010
@key = key.freeze
11+
@scope = scope
12+
@name = nil
1113
@node = nil
1214
@nodes = nil
15+
@type = nil
16+
@result = nil
1317
@arguments = nil
14-
@promise = nil
18+
@path = nil
1519
end
1620

1721
def name
1822
@name ||= @node.name.freeze
1923
end
2024

25+
def path
26+
@path ||= (@scope ? [*@scope.path, @key] : []).freeze
27+
end
28+
2129
def add_node(n)
2230
if !@node
2331
@node = n

lib/graphql/cardinal/executor/execution_scope.rb

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
module GraphQL::Cardinal
44
class Executor
55
class ExecutionScope
6-
attr_reader :parent_type, :selections, :sources, :responses, :parent
6+
attr_reader :parent_type, :selections, :sources, :responses, :path, :parent
77
attr_accessor :fields
88

99
def initialize(
@@ -13,6 +13,7 @@ def initialize(
1313
responses:,
1414
loader_cache: nil,
1515
loader_group: nil,
16+
path: [],
1617
parent: nil
1718
)
1819
@parent_type = parent_type
@@ -21,6 +22,7 @@ def initialize(
2122
@responses = responses
2223
@loader_cache = loader_cache
2324
@loader_group = loader_group
25+
@path = path.freeze
2426
@parent = parent
2527
@fields = nil
2628
end
@@ -30,9 +32,8 @@ def defer(loader_class, keys:, group: nil)
3032
loader.load(keys)
3133
end
3234

33-
# does any field in this scope have a pending promise?
34-
def lazy_fields_pending?
35-
@fields&.each_value&.any? { _1.promise&.pending? } || false
35+
def lazy_fields?
36+
@fields&.each_value&.any? { _1.result.is_a?(Promise) } || false
3637
end
3738

3839
# is this scope ungrouped, or have all scopes in the group built their fields?
@@ -47,7 +48,7 @@ def loader_cache
4748
end
4849

4950
def lazy_exec!
50-
loader_cache.each_value { |loader| loader.method(:lazy_exec!).call }
51+
loader_cache.each_value { |loader| loader.send(:lazy_exec!) }
5152
end
5253
end
5354
end

0 commit comments

Comments
 (0)