Skip to content

Commit dd72284

Browse files
committed
add runtime hook for breadth execution patterns.
1 parent 31c6cb9 commit dd72284

File tree

2 files changed

+331
-0
lines changed

2 files changed

+331
-0
lines changed

lib/graphql/execution/interpreter/runtime.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,7 @@ def evaluate_selection_with_resolved_keyword_args(kwarg_arguments, resolved_argu
472472
end
473473
@current_trace.end_execute_field(field_defn, object, kwarg_arguments, query, app_result)
474474
after_lazy(app_result, field: field_defn, ast_node: ast_node, owner_object: object, arguments: resolved_arguments, result_name: result_name, result: selection_result, runtime_state: runtime_state) do |inner_result, runtime_state|
475+
next if exit_with_inner_result?(inner_result, result_name, selection_result)
475476
owner_type = selection_result.graphql_result_type
476477
return_type = field_defn.type
477478
continue_value = continue_value(inner_result, field_defn, return_type.non_null?, ast_node, result_name, selection_result)
@@ -492,6 +493,11 @@ def evaluate_selection_with_resolved_keyword_args(kwarg_arguments, resolved_argu
492493
end
493494
end
494495

496+
# Hook for breadth-first implementations to exit after a single resolver generation.
497+
def exit_with_inner_result?(inner_result, result_name, selection_result)
498+
false
499+
end
500+
495501
def set_result(selection_result, result_name, value, is_child_result, is_non_null)
496502
if !selection_result.graphql_dead
497503
if value.nil? && is_non_null
Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
1+
# frozen_string_literal: true
2+
require "spec_helper"
3+
4+
describe "GraphQL::Execution::Interpreter for breadth-first execution" do
5+
# A breadth-first interpreter uses the following runtime interface:
6+
# - evaluate_selection(result_key, ast_nodes, selections_result)
7+
# - exit_with_inner_result?
8+
class SimpleBreadthRuntime < GraphQL::Execution::Interpreter::Runtime
9+
class BreadthObject < GraphQL::Execution::Interpreter::Runtime::GraphQLResultHash
10+
attr_accessor :breadth_index
11+
end
12+
13+
def initialize(query:)
14+
query.multiplex = GraphQL::Execution::Multiplex.new(
15+
schema: query.schema,
16+
queries: [query],
17+
context: query.context,
18+
max_complexity: nil,
19+
)
20+
21+
super(query: query, lazies_at_depth: Hash.new { |h, k| h[k] = [] })
22+
@breadth_results_by_key = {}
23+
end
24+
25+
def run
26+
result = nil
27+
query.current_trace.execute_multiplex(multiplex: query.multiplex) do
28+
query.current_trace.execute_query(query: query) do
29+
result = yield
30+
end
31+
end
32+
result
33+
ensure
34+
delete_all_interpreter_context
35+
end
36+
37+
def evaluate_breadth_selection(objects, parent_type, node)
38+
result_key = node.alias || node.name
39+
@breadth_results_by_key[result_key] = Array.new(objects.size)
40+
objects.each_with_index do |object, index|
41+
app_value = parent_type.wrap(object, query.context)
42+
breadth_object = BreadthObject.new(nil, parent_type, app_value, nil, false, node.selections, false, node, nil, nil)
43+
breadth_object.ordered_result_keys = []
44+
breadth_object.breadth_index = index
45+
46+
state = get_current_runtime_state
47+
state.current_result_name = nil
48+
state.current_result = breadth_object
49+
@dataloader.append_job { evaluate_selection(result_key, node, breadth_object) }
50+
end
51+
52+
@dataloader.run
53+
GraphQL::Execution::Interpreter::Resolve.resolve_each_depth(@lazies_at_depth, @dataloader)
54+
55+
@breadth_results_by_key[result_key]
56+
end
57+
58+
def exit_with_inner_result?(inner_result, result_key, breadth_object)
59+
@breadth_results_by_key[result_key][breadth_object.breadth_index] = inner_result
60+
true
61+
end
62+
end
63+
64+
class PassthroughLoader < GraphQL::Batch::Loader
65+
def perform(objects)
66+
objects.each { |obj| fulfill(obj, obj) }
67+
end
68+
end
69+
70+
class SimpleHashBatchLoader < GraphQL::Batch::Loader
71+
def initialize(key)
72+
super()
73+
@key = key
74+
end
75+
76+
def perform(objects)
77+
objects.each { |obj| fulfill(obj, obj.fetch(@key)) }
78+
end
79+
end
80+
81+
class UpcaseExtension < GraphQL::Schema::FieldExtension
82+
def after_resolve(value:, **rest)
83+
value&.upcase
84+
end
85+
end
86+
87+
class RangeInput < GraphQL::Schema::InputObject
88+
argument :min, Int
89+
argument :max, Int
90+
91+
def prepare
92+
min..max
93+
end
94+
end
95+
96+
class BaseField < GraphQL::Schema::Field
97+
def authorized?(obj, args, ctx)
98+
if !ctx[:field_auth].nil?
99+
ctx[:field_auth]
100+
elsif !ctx[:lazy_field_auth].nil?
101+
PassthroughLoader.load(ctx[:lazy_field_auth])
102+
elsif !ctx[:field_auth_with_error].nil?
103+
raise GraphQL::ExecutionError, "Not authorized" unless ctx[:field_auth_with_error]
104+
else
105+
true
106+
end
107+
end
108+
end
109+
110+
class BaseObject < GraphQL::Schema::Object
111+
field_class BaseField
112+
end
113+
114+
class Query < BaseObject
115+
field :foo, String
116+
117+
def foo
118+
object[:foo]
119+
end
120+
121+
field :lazy_foo, String
122+
123+
def lazy_foo
124+
SimpleHashBatchLoader.for(:foo).load(object)
125+
end
126+
127+
field :maybe_lazy_foo, String
128+
129+
def maybe_lazy_foo
130+
if object[:foo] == "beep"
131+
SimpleHashBatchLoader.for(:foo).load(object)
132+
else
133+
object[:foo]
134+
end
135+
end
136+
137+
field :nested_lazy_foo, String
138+
139+
def nested_lazy_foo
140+
PassthroughLoader
141+
.load(object)
142+
.then { |obj| SimpleHashBatchLoader.for(:foo).load(obj) }
143+
.then { |str| str }
144+
end
145+
146+
field :upcase_foo, String, extensions: [UpcaseExtension]
147+
148+
def upcase_foo
149+
object[:foo]
150+
end
151+
152+
field :lazy_upcase_foo, String, extensions: [UpcaseExtension]
153+
154+
def lazy_upcase_foo
155+
SimpleHashBatchLoader.for(:foo).load(object)
156+
end
157+
158+
field :go_boom, String
159+
160+
def go_boom
161+
raise GraphQL::ExecutionError, "boom"
162+
end
163+
164+
field :args, String do |f|
165+
f.argument :a, String
166+
f.argument :b, String
167+
end
168+
169+
def args(a:, b:)
170+
"#{a}#{b}"
171+
end
172+
173+
field :range, String do |f|
174+
f.argument :input, RangeInput
175+
end
176+
177+
def range(input:)
178+
"#{input.min}-#{input.max}"
179+
end
180+
181+
field :extras, String, extras: [:lookahead]
182+
183+
def extras(lookahead:)
184+
lookahead.field.name
185+
end
186+
187+
# uses default resolver...
188+
field :fizz, String
189+
end
190+
191+
class TestSchema < GraphQL::Schema
192+
use(GraphQL::Batch)
193+
query Query
194+
end
195+
196+
SCHEMA_FROM_DEF = GraphQL::Schema.from_definition(
197+
%|type Query { a: String }|,
198+
default_resolve: {
199+
"Query" => { "a" => ->(obj, _args, _ctx) { obj["a"] } },
200+
},
201+
)
202+
203+
OBJECTS = [{ foo: "fizz" }, { foo: "buzz" }, { foo: "beep" }, { foo: "boom" }].freeze
204+
EXPECTED_RESULTS = ["fizz", "buzz", "beep", "boom"].freeze
205+
206+
def test_maps_sync_results
207+
result = map_breadth_objects(OBJECTS, "{ foo }")
208+
assert_equal EXPECTED_RESULTS, result
209+
end
210+
211+
def test_maps_lazy_results
212+
result = map_breadth_objects(OBJECTS, "{ lazyFoo }")
213+
assert_equal EXPECTED_RESULTS, result
214+
end
215+
216+
def test_maps_sometimes_lazy_results
217+
result = map_breadth_objects(OBJECTS, "{ maybeLazyFoo }")
218+
assert_equal EXPECTED_RESULTS, result
219+
end
220+
221+
def test_maps_nested_lazy_results
222+
result = map_breadth_objects(OBJECTS, "{ nestedLazyFoo }")
223+
assert_equal EXPECTED_RESULTS, result
224+
end
225+
226+
def test_maps_field_extension_results
227+
result = map_breadth_objects(OBJECTS, "{ upcaseFoo }")
228+
assert_equal ["FIZZ", "BUZZ", "BEEP", "BOOM"], result
229+
end
230+
231+
def test_maps_lazy_field_extension_results
232+
result = map_breadth_objects(OBJECTS, "{ lazyUpcaseFoo }")
233+
assert_equal ["FIZZ", "BUZZ", "BEEP", "BOOM"], result
234+
end
235+
236+
def test_maps_fields_with_authorization
237+
context = { field_auth: false }
238+
result = map_breadth_objects(OBJECTS, "{ foo }", context: context)
239+
assert_equal [nil, nil, nil, nil], result
240+
end
241+
242+
def test_maps_fields_with_lazy_authorization
243+
context = { lazy_field_auth: false }
244+
result = map_breadth_objects(OBJECTS, "{ foo }", context: context)
245+
assert result.all? { |r| r.is_a?(GraphQL::UnauthorizedFieldError) }
246+
end
247+
248+
def test_maps_fields_with_authorization_errors
249+
context = { field_auth_with_error: false }
250+
result = map_breadth_objects(OBJECTS, "{ foo }", context: context)
251+
assert result.all? { |r| r.is_a?(GraphQL::ExecutionError) }
252+
end
253+
254+
def test_maps_field_errors
255+
result = map_breadth_objects(OBJECTS, "{ goBoom }")
256+
assert result.all? { |r| r.is_a?(GraphQL::ExecutionError) }
257+
assert_equal ["boom", "boom", "boom", "boom"], result.map(&:message)
258+
end
259+
260+
def test_maps_basic_arguments
261+
doc = %|{ args(a:"fizz", b:"buzz") }|
262+
result = map_breadth_objects([{}], doc)
263+
assert_equal ["fizzbuzz"], result
264+
end
265+
266+
def test_maps_basic_arguments_with_variables
267+
doc = %|query($b: String) { args(a:"fizz", b: $b) }|
268+
result = map_breadth_objects([{}], doc, variables: { b: "buzz" })
269+
assert_equal ["fizzbuzz"], result
270+
end
271+
272+
def test_maps_prepared_input_object
273+
doc = %|{ range(input: { min: 1, max: 2 }) }|
274+
result = map_breadth_objects([{}], doc)
275+
assert_equal ["1-2"], result
276+
end
277+
278+
def test_maps_prepared_input_object_with_variables
279+
doc = %|query($b: Int) { range(input: { min: 1, max: $b }) }|
280+
result = map_breadth_objects([{}], doc, variables: { b: 2 })
281+
assert_equal ["1-2"], result
282+
end
283+
284+
def test_maps_extras_arguments
285+
result = map_breadth_objects([{}], "{ extras }")
286+
assert_equal ["extras"], result
287+
end
288+
289+
def test_uses_default_resolver_for_hash_keys
290+
result = map_breadth_objects([{ fizz: "buzz" }], "{ fizz }")
291+
assert_equal ["buzz"], result
292+
end
293+
294+
def test_uses_default_resolver_for_method_calls
295+
entity = Struct.new(:fizz)
296+
result = map_breadth_objects([entity.new("buzz")], "{ fizz }")
297+
assert_equal ["buzz"], result
298+
end
299+
300+
def test_maps_schemas_from_definition
301+
objects = [{ "a" => "1" }, { "a" => "2" }]
302+
result = map_breadth_objects(objects, "{ a }", schema: SCHEMA_FROM_DEF)
303+
assert_equal ["1", "2"], result
304+
end
305+
306+
def test_maps_results_with_multiple_nodes
307+
result = map_breadth_objects(OBJECTS, "{ foo foo }")
308+
assert_equal EXPECTED_RESULTS, result
309+
end
310+
311+
private
312+
313+
def map_breadth_objects(objects, doc, schema: TestSchema, variables: {}, context: {})
314+
query = GraphQL::Query.new(
315+
schema,
316+
document: GraphQL.parse(doc),
317+
variables: variables,
318+
context: context,
319+
)
320+
321+
node = query.document.definitions.first.selections.first
322+
runtime = SimpleBreadthRuntime.new(query: query)
323+
runtime.run { runtime.evaluate_breadth_selection(objects, schema.query, node) }
324+
end
325+
end

0 commit comments

Comments
 (0)