Skip to content

Commit a05fc12

Browse files
author
Robert Mosolgo
authored
Merge pull request #2634 from lancelafontaine/master
Adds scoped context for propagating values specific to a field and its children
2 parents 734ad4b + 5ac1842 commit a05fc12

File tree

3 files changed

+270
-13
lines changed

3 files changed

+270
-13
lines changed

lib/graphql/execution/interpreter/runtime.rb

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ def run_eager
5858
write_in_response(path, nil)
5959
nil
6060
else
61-
evaluate_selections(path, object_proxy, root_type, root_operation.selections, root_operation_type: root_op_type)
61+
evaluate_selections(path, context.scoped_context, object_proxy, root_type, root_operation.selections, root_operation_type: root_op_type)
6262
nil
6363
end
6464
end
@@ -118,7 +118,7 @@ def gather_selections(owner_object, owner_type, selections, selections_by_name)
118118
end
119119
end
120120

121-
def evaluate_selections(path, owner_object, owner_type, selections, root_operation_type: nil)
121+
def evaluate_selections(path, scoped_context, owner_object, owner_type, selections, root_operation_type: nil)
122122
@interpreter_context[:current_object] = owner_object
123123
@interpreter_context[:current_path] = path
124124
selections_by_name = {}
@@ -163,6 +163,7 @@ def evaluate_selections(path, owner_object, owner_type, selections, root_operati
163163
@interpreter_context[:current_path] = next_path
164164
@interpreter_context[:current_field] = field_defn
165165

166+
context.scoped_context = scoped_context
166167
object = owner_object
167168

168169
if is_introspection
@@ -221,7 +222,7 @@ def evaluate_selections(path, owner_object, owner_type, selections, root_operati
221222
rescue GraphQL::ExecutionError => err
222223
err
223224
end
224-
after_lazy(app_result, owner: owner_type, field: field_defn, path: next_path, owner_object: object, arguments: kwarg_arguments) do |inner_result|
225+
after_lazy(app_result, owner: owner_type, field: field_defn, path: next_path, scoped_context: context.scoped_context, owner_object: object, arguments: kwarg_arguments) do |inner_result|
225226
continue_value = continue_value(next_path, inner_result, field_defn, return_type.non_null?, ast_node)
226227
if HALT != continue_value
227228
continue_field(next_path, continue_value, field_defn, return_type, ast_node, next_selections, false, object, kwarg_arguments)
@@ -295,7 +296,7 @@ def continue_field(path, value, field, type, ast_node, next_selections, is_non_n
295296
r
296297
when "UNION", "INTERFACE"
297298
resolved_type_or_lazy = query.resolve_type(type, value)
298-
after_lazy(resolved_type_or_lazy, owner: type, path: path, field: field, owner_object: owner_object, arguments: arguments) do |resolved_type|
299+
after_lazy(resolved_type_or_lazy, owner: type, path: path, scoped_context: context.scoped_context, field: field, owner_object: owner_object, arguments: arguments) do |resolved_type|
299300
possible_types = query.possible_types(type)
300301

301302
if !possible_types.include?(resolved_type)
@@ -315,12 +316,12 @@ def continue_field(path, value, field, type, ast_node, next_selections, is_non_n
315316
rescue GraphQL::ExecutionError => err
316317
err
317318
end
318-
after_lazy(object_proxy, owner: type, path: path, field: field, owner_object: owner_object, arguments: arguments) do |inner_object|
319+
after_lazy(object_proxy, owner: type, path: path, scoped_context: context.scoped_context, field: field, owner_object: owner_object, arguments: arguments) do |inner_object|
319320
continue_value = continue_value(path, inner_object, field, is_non_null, ast_node)
320321
if HALT != continue_value
321322
response_hash = {}
322323
write_in_response(path, response_hash)
323-
evaluate_selections(path, continue_value, type, next_selections)
324+
evaluate_selections(path, context.scoped_context, continue_value, type, next_selections)
324325
response_hash
325326
end
326327
end
@@ -329,14 +330,15 @@ def continue_field(path, value, field, type, ast_node, next_selections, is_non_n
329330
write_in_response(path, response_list)
330331
inner_type = type.of_type
331332
idx = 0
333+
scoped_context = context.scoped_context
332334
value.each do |inner_value|
333335
next_path = path.dup
334336
next_path << idx
335337
next_path.freeze
336338
idx += 1
337339
set_type_at_path(next_path, inner_type)
338340
# This will update `response_list` with the lazy
339-
after_lazy(inner_value, owner: inner_type, path: next_path, field: field, owner_object: owner_object, arguments: arguments) do |inner_inner_value|
341+
after_lazy(inner_value, owner: inner_type, path: next_path, scoped_context: scoped_context, field: field, owner_object: owner_object, arguments: arguments) do |inner_inner_value|
340342
# reset `is_non_null` here and below, because the inner type will have its own nullability constraint
341343
continue_value = continue_value(next_path, inner_inner_value, field, false, ast_node)
342344
if HALT != continue_value
@@ -402,7 +404,7 @@ def resolve_if_late_bound_type(type)
402404
# @param field [GraphQL::Schema::Field]
403405
# @param eager [Boolean] Set to `true` for mutation root fields only
404406
# @return [GraphQL::Execution::Lazy, Object] If loading `object` will be deferred, it's a wrapper over it.
405-
def after_lazy(lazy_obj, owner:, field:, path:, owner_object:, arguments:, eager: false)
407+
def after_lazy(lazy_obj, owner:, field:, path:, scoped_context:, owner_object:, arguments:, eager: false)
406408
@interpreter_context[:current_object] = owner_object
407409
@interpreter_context[:current_arguments] = arguments
408410
@interpreter_context[:current_path] = path
@@ -413,6 +415,7 @@ def after_lazy(lazy_obj, owner:, field:, path:, owner_object:, arguments:, eager
413415
@interpreter_context[:current_field] = field
414416
@interpreter_context[:current_object] = owner_object
415417
@interpreter_context[:current_arguments] = arguments
418+
context.scoped_context = scoped_context
416419
# Wrap the execution of _this_ method with tracing,
417420
# but don't wrap the continuation below
418421
inner_obj = begin
@@ -424,7 +427,7 @@ def after_lazy(lazy_obj, owner:, field:, path:, owner_object:, arguments:, eager
424427
rescue GraphQL::ExecutionError, GraphQL::UnauthorizedError => err
425428
yield(err)
426429
end
427-
after_lazy(inner_obj, owner: owner, field: field, path: path, owner_object: owner_object, arguments: arguments, eager: eager) do |really_inner_obj|
430+
after_lazy(inner_obj, owner: owner, field: field, path: path, scoped_context: context.scoped_context, owner_object: owner_object, arguments: arguments, eager: eager) do |really_inner_obj|
428431
yield(really_inner_obj)
429432
end
430433
end

lib/graphql/query/context.rb

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ def initialize(query:, values: , object:)
155155
@path = []
156156
@value = nil
157157
@context = self # for SharedMethods
158+
@scoped_context = {}
158159
end
159160

160161
# @api private
@@ -163,15 +164,30 @@ def initialize(query:, values: , object:)
163164
# @api private
164165
attr_writer :value
165166

166-
def_delegators :@provided_values, :[], :[]=, :to_h, :to_hash, :key?, :fetch, :dig
167-
def_delegators :@query, :trace, :interpreter?
167+
# @api private
168+
attr_accessor :scoped_context
168169

169-
# @!method [](key)
170-
# Lookup `key` from the hash passed to {Schema#execute} as `context:`
170+
def_delegators :@provided_values, :[]=
171+
def_delegators :to_h, :fetch, :dig
172+
def_delegators :@query, :trace, :interpreter?
171173

172174
# @!method []=(key, value)
173175
# Reassign `key` to the hash passed to {Schema#execute} as `context:`
174176

177+
# Lookup `key` from the hash passed to {Schema#execute} as `context:`
178+
def [](key)
179+
return @scoped_context[key] if @scoped_context.key?(key)
180+
@provided_values[key]
181+
end
182+
183+
def to_h
184+
@provided_values.merge(@scoped_context)
185+
end
186+
alias :to_hash :to_h
187+
188+
def key?(key)
189+
@scoped_context.key?(key) || @provided_values.key?(key)
190+
end
175191

176192
# @return [GraphQL::Schema::Warden]
177193
def warden
@@ -195,6 +211,15 @@ def received_null_child
195211
@value = nil
196212
end
197213

214+
def scoped_merge!(hash)
215+
@scoped_context = @scoped_context.merge(hash)
216+
end
217+
218+
def scoped_set!(key, value)
219+
scoped_merge!(key => value)
220+
nil
221+
end
222+
198223
class FieldResolutionContext
199224
include SharedMethods
200225
include Tracing::Traceable

spec/graphql/query/context_spec.rb

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,4 +289,233 @@
289289
assert_equal expected_values_with_nil, res["data"]["find"]["inspectContext"]
290290
end
291291
end
292+
293+
describe "scoped context" do
294+
class LazyBlock
295+
def initialize
296+
@get_value = Proc.new
297+
end
298+
299+
def value
300+
@get_value.call
301+
end
302+
end
303+
304+
class ContextQuery < GraphQL::Schema::Object
305+
field :get_scoped_context, String, null: true do
306+
argument :key, String, required: true
307+
argument :lazy, Boolean, required: false, default_value: false
308+
end
309+
310+
def get_scoped_context(key:, lazy:)
311+
result = LazyBlock.new {
312+
context[key]
313+
}
314+
return result if lazy
315+
result.value
316+
end
317+
318+
field :set_scoped_context, ContextQuery, null: false do
319+
argument :key, String, required: true
320+
argument :value, String, required: true
321+
argument :lazy, Boolean, required: false, default_value: false
322+
end
323+
324+
def set_scoped_context(key:, value:, lazy:)
325+
if lazy
326+
LazyBlock.new {
327+
context.scoped_merge!(key => value)
328+
LazyBlock.new {
329+
self
330+
}
331+
}
332+
else
333+
context.scoped_merge!(key => value)
334+
self
335+
end
336+
end
337+
end
338+
339+
class ContextSchema < GraphQL::Schema
340+
use GraphQL::Execution::Interpreter
341+
use GraphQL::Analysis::AST
342+
query(ContextQuery)
343+
lazy_resolve(LazyBlock, :value)
344+
end
345+
346+
it "can be set and does not leak to sibling fields" do
347+
query_str = %|
348+
{
349+
before: getScopedContext(key: "a")
350+
firstSetOuter: setScopedContext(key: "a", value: "1") {
351+
before: getScopedContext(key: "a")
352+
setInner: setScopedContext(key: "a", value: "2") {
353+
only: getScopedContext(key: "a")
354+
}
355+
after: getScopedContext(key: "a")
356+
}
357+
secondSetOuter: setScopedContext(key: "a", value: "3") {
358+
before: getScopedContext(key: "a")
359+
setInner: setScopedContext(key: "a", value: "4") {
360+
only: getScopedContext(key: "a")
361+
}
362+
after: getScopedContext(key: "a")
363+
}
364+
after: getScopedContext(key: "a")
365+
}
366+
|
367+
368+
expected = {
369+
'before' => nil,
370+
'firstSetOuter' => {
371+
'before' => '1',
372+
'setInner' => {
373+
'only' => '2',
374+
},
375+
'after' => '1',
376+
},
377+
'secondSetOuter' => {
378+
'before' => '3',
379+
'setInner' => {
380+
'only' => '4',
381+
},
382+
'after' => '3',
383+
},
384+
'after' => nil,
385+
}
386+
result = ContextSchema.execute(query_str).to_h['data']
387+
assert_equal(expected, result)
388+
end
389+
390+
it "can be set and does not leak to sibling fields when all resolvers are lazy values" do
391+
query_str = %|
392+
{
393+
before: getScopedContext(key: "a", lazy: true)
394+
setOuter: setScopedContext(key: "a", value: "1", lazy: true) {
395+
before: getScopedContext(key: "a", lazy: true)
396+
setInner: setScopedContext(key: "a", value: "2", lazy: true) {
397+
only: getScopedContext(key: "a", lazy: true)
398+
}
399+
after: getScopedContext(key: "a", lazy: true)
400+
}
401+
after: getScopedContext(key: "a", lazy: true)
402+
}
403+
|
404+
expected = {
405+
'before' => nil,
406+
'setOuter' => {
407+
'before' => '1',
408+
'setInner' => {
409+
'only' => '2',
410+
},
411+
'after' => '1',
412+
},
413+
'after' => nil,
414+
}
415+
416+
result = ContextSchema.execute(query_str).to_h['data']
417+
assert_equal(expected, result)
418+
end
419+
420+
it "can be set and does not leak to sibling fields when all get resolvers are lazy values" do
421+
query_str = %|
422+
{
423+
before: getScopedContext(key: "a", lazy: true)
424+
setOuter: setScopedContext(key: "a", value: "1") {
425+
before: getScopedContext(key: "a", lazy: true)
426+
setInner: setScopedContext(key: "a", value: "2") {
427+
only: getScopedContext(key: "a", lazy: true)
428+
}
429+
after: getScopedContext(key: "a", lazy: true)
430+
}
431+
after: getScopedContext(key: "a", lazy: true)
432+
}
433+
|
434+
expected = {
435+
'before' => nil,
436+
'setOuter' => {
437+
'before' => '1',
438+
'setInner' => {
439+
'only' => '2',
440+
},
441+
'after' => '1',
442+
},
443+
'after' => nil,
444+
}
445+
446+
result = ContextSchema.execute(query_str).to_h['data']
447+
assert_equal(expected, result)
448+
end
449+
450+
it "can be set and does not leak to sibling fields when all set resolvers are lazy values" do
451+
query_str = %|
452+
{
453+
before: getScopedContext(key: "a")
454+
setOuter: setScopedContext(key: "a", value: "1", lazy: true) {
455+
before: getScopedContext(key: "a")
456+
setInner: setScopedContext(key: "a", value: "2", lazy: true) {
457+
only: getScopedContext(key: "a")
458+
}
459+
after: getScopedContext(key: "a")
460+
}
461+
after: getScopedContext(key: "a")
462+
}
463+
|
464+
expected = {
465+
'before' => nil,
466+
'setOuter' => {
467+
'before' => '1',
468+
'setInner' => {
469+
'only' => '2',
470+
},
471+
'after' => '1',
472+
},
473+
'after' => nil,
474+
}
475+
476+
result = ContextSchema.execute(query_str).to_h['data']
477+
assert_equal(expected, result)
478+
end
479+
480+
it "always retrieves a scoped context value if set" do
481+
context = GraphQL::Query::Context.new(query: OpenStruct.new(schema: schema), values: nil, object: nil)
482+
expected_key = :a
483+
expected_value = :test
484+
485+
assert_equal(nil, context[expected_key])
486+
assert_equal({}, context.to_h)
487+
refute(context.key?(expected_key))
488+
assert_raises(KeyError) { context.fetch(expected_key) }
489+
assert_nil(context.fetch(expected_key, nil))
490+
assert_nil(context.dig(expected_key)) if RUBY_VERSION >= '2.3.0'
491+
492+
context.scoped_merge!(expected_key => nil)
493+
context[expected_key] = expected_value
494+
495+
assert_nil(context[expected_key])
496+
assert_equal({ expected_key => nil }, context.to_h)
497+
assert(context.key?(expected_key))
498+
assert_nil(context.fetch(expected_key))
499+
assert_nil(context.dig(expected_key)) if RUBY_VERSION >= '2.3.0'
500+
501+
context.scoped_context = {}
502+
503+
assert_equal(expected_value, context[expected_key])
504+
assert_equal({ expected_key => expected_value}, context.to_h)
505+
assert(context.key?(expected_key))
506+
assert_equal(expected_value, context.fetch(expected_key))
507+
assert_equal(expected_value, context.dig(expected_key)) if RUBY_VERSION >= '2.3.0'
508+
end
509+
510+
it "sets a value using #scoped_set!" do
511+
expected_key = :a
512+
expected_value = :test
513+
514+
context = GraphQL::Query::Context.new(query: OpenStruct.new(schema: schema), values: nil, object: nil)
515+
assert_nil(context[expected_key])
516+
517+
context.scoped_set!(expected_key, expected_value)
518+
assert_equal(expected_value, context[expected_key])
519+
end
520+
end
292521
end

0 commit comments

Comments
 (0)