Skip to content

Commit 08ac1c3

Browse files
authored
Merge pull request #3950 from rmosolgo/scoped-context-refactor
Refactor & fix scoped context with dataloader
2 parents b4e704f + e9dfc23 commit 08ac1c3

File tree

4 files changed

+230
-33
lines changed

4 files changed

+230
-33
lines changed

guides/queries/executing_queries.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,53 @@ end
123123

124124
Note that `context` is _not_ the hash that you passed it. It's an instance of {{ "GraphQL::Query::Context" | api_doc }}, but it delegates `#[]`, `#[]=`, and a few other methods to the hash you provide.
125125

126+
### Scoped Context
127+
128+
`context` is shared by the whole query. Anything you add to `context` will be accessible by any other field in the query (although GraphQL-Ruby's order of execution can vary).
129+
130+
However, "scoped context" is can be used to assign values into `context` that are only available in the current field and the _children_ of the current field. For example, in this query:
131+
132+
```graphql
133+
{
134+
posts {
135+
comments {
136+
author
137+
isOriginalPoster
138+
}
139+
}
140+
}
141+
```
142+
143+
You could use "scoped context" to implement `isOriginalPoster`, based on the parent `comments` field.
144+
145+
In `def comments`, add `:current_post` to scoped context using `context.scoped_set!`:
146+
147+
```ruby
148+
class Types::Post < Types::BaseObject
149+
# ...
150+
def comments
151+
context.scoped_set!(:current_post, object)
152+
object.comments
153+
end
154+
end
155+
```
156+
157+
Then, inside `User`, you can check `context[:current_post]`:
158+
159+
```ruby
160+
class Types::User < Types::BaseObject
161+
# ...
162+
def is_original_poster
163+
current_post = context[:current_post]
164+
current_post && current_post.author == object
165+
end
166+
end
167+
```
168+
169+
`context[:current_post]` will be present if an "upstream" field assigned it with `scoped_set!`.
170+
171+
`context.scoped_merge!({ ... })` is also available for setting multiple keys at once.
172+
126173
## Root Value
127174

128175
You can provide a root `object` value with `root_value:`. For example, to base the query off of the current organization:

lib/graphql/execution/interpreter/runtime.rb

Lines changed: 15 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,6 @@ def run_eager
230230
call_method_on_directives(:resolve, object_proxy, selections.graphql_directives) do
231231
evaluate_selections(
232232
path,
233-
context.scoped_context,
234233
object_proxy,
235234
root_type,
236235
root_op_type == "mutation",
@@ -349,15 +348,15 @@ def gather_selections(owner_object, owner_type, selections, selections_to_run =
349348
NO_ARGS = {}.freeze
350349

351350
# @return [void]
352-
def evaluate_selections(path, scoped_context, owner_object, owner_type, is_eager_selection, gathered_selections, selections_result, target_result, parent_object) # rubocop:disable Metrics/ParameterLists
351+
def evaluate_selections(path, owner_object, owner_type, is_eager_selection, gathered_selections, selections_result, target_result, parent_object) # rubocop:disable Metrics/ParameterLists
353352
set_all_interpreter_context(owner_object, nil, nil, path)
354353

355354
finished_jobs = 0
356355
enqueued_jobs = gathered_selections.size
357356
gathered_selections.each do |result_name, field_ast_nodes_or_ast_node|
358357
@dataloader.append_job {
359358
evaluate_selection(
360-
path, result_name, field_ast_nodes_or_ast_node, scoped_context, owner_object, owner_type, is_eager_selection, selections_result, parent_object
359+
path, result_name, field_ast_nodes_or_ast_node, owner_object, owner_type, is_eager_selection, selections_result, parent_object
361360
)
362361
finished_jobs += 1
363362
if target_result && finished_jobs == enqueued_jobs
@@ -372,7 +371,7 @@ def evaluate_selections(path, scoped_context, owner_object, owner_type, is_eager
372371
attr_reader :progress_path
373372

374373
# @return [void]
375-
def evaluate_selection(path, result_name, field_ast_nodes_or_ast_node, scoped_context, owner_object, owner_type, is_eager_field, selections_result, parent_object) # rubocop:disable Metrics/ParameterLists
374+
def evaluate_selection(path, result_name, field_ast_nodes_or_ast_node, owner_object, owner_type, is_eager_field, selections_result, parent_object) # rubocop:disable Metrics/ParameterLists
376375
return if dead_result?(selections_result)
377376
# As a performance optimization, the hash key will be a `Node` if
378377
# there's only one selection of the field. But if there are multiple
@@ -414,8 +413,6 @@ def evaluate_selection(path, result_name, field_ast_nodes_or_ast_node, scoped_co
414413
end
415414
# Set this before calling `run_with_directives`, so that the directive can have the latest path
416415
set_all_interpreter_context(nil, field_defn, nil, next_path)
417-
418-
context.scoped_context = scoped_context
419416
object = owner_object
420417

421418
if is_introspection
@@ -425,19 +422,18 @@ def evaluate_selection(path, result_name, field_ast_nodes_or_ast_node, scoped_co
425422
total_args_count = field_defn.arguments(context).size
426423
if total_args_count == 0
427424
resolved_arguments = GraphQL::Execution::Interpreter::Arguments::EMPTY
428-
evaluate_selection_with_args(resolved_arguments, field_defn, next_path, ast_node, field_ast_nodes, scoped_context, owner_type, object, is_eager_field, result_name, selections_result, parent_object)
425+
evaluate_selection_with_args(resolved_arguments, field_defn, next_path, ast_node, field_ast_nodes, owner_type, object, is_eager_field, result_name, selections_result, parent_object)
429426
else
430427
# TODO remove all arguments(...) usages?
431428
@query.arguments_cache.dataload_for(ast_node, field_defn, object) do |resolved_arguments|
432-
evaluate_selection_with_args(resolved_arguments, field_defn, next_path, ast_node, field_ast_nodes, scoped_context, owner_type, object, is_eager_field, result_name, selections_result, parent_object)
429+
evaluate_selection_with_args(resolved_arguments, field_defn, next_path, ast_node, field_ast_nodes, owner_type, object, is_eager_field, result_name, selections_result, parent_object)
433430
end
434431
end
435432
end
436433

437-
def evaluate_selection_with_args(arguments, field_defn, next_path, ast_node, field_ast_nodes, scoped_context, owner_type, object, is_eager_field, result_name, selection_result, parent_object) # rubocop:disable Metrics/ParameterLists
438-
context.scoped_context = scoped_context
434+
def evaluate_selection_with_args(arguments, field_defn, next_path, ast_node, field_ast_nodes, owner_type, object, is_eager_field, result_name, selection_result, parent_object) # rubocop:disable Metrics/ParameterLists
439435
return_type = field_defn.type
440-
after_lazy(arguments, owner: owner_type, field: field_defn, path: next_path, ast_node: ast_node, scoped_context: context.scoped_context, owner_object: object, arguments: arguments, result_name: result_name, result: selection_result) do |resolved_arguments|
436+
after_lazy(arguments, owner: owner_type, field: field_defn, path: next_path, ast_node: ast_node, owner_object: object, arguments: arguments, result_name: result_name, result: selection_result) do |resolved_arguments|
441437
if resolved_arguments.is_a?(GraphQL::ExecutionError) || resolved_arguments.is_a?(GraphQL::UnauthorizedError)
442438
continue_value(next_path, resolved_arguments, owner_type, field_defn, return_type.non_null?, ast_node, result_name, selection_result)
443439
next
@@ -510,7 +506,7 @@ def evaluate_selection_with_args(arguments, field_defn, next_path, ast_node, fie
510506
rescue GraphQL::ExecutionError => err
511507
err
512508
end
513-
after_lazy(app_result, owner: owner_type, field: field_defn, path: next_path, ast_node: ast_node, scoped_context: context.scoped_context, owner_object: object, arguments: resolved_arguments, result_name: result_name, result: selection_result) do |inner_result|
509+
after_lazy(app_result, owner: owner_type, field: field_defn, path: next_path, ast_node: ast_node, owner_object: object, arguments: resolved_arguments, result_name: result_name, result: selection_result) do |inner_result|
514510
continue_value = continue_value(next_path, inner_result, owner_type, field_defn, return_type.non_null?, ast_node, result_name, selection_result)
515511
if HALT != continue_value
516512
continue_field(next_path, continue_value, owner_type, field_defn, return_type, ast_node, next_selections, false, object, resolved_arguments, result_name, selection_result)
@@ -688,7 +684,7 @@ def continue_field(path, value, owner_type, field, current_type, ast_node, next_
688684
resolved_type_or_lazy, resolved_value = resolve_type(current_type, value, path)
689685
resolved_value ||= value
690686

691-
after_lazy(resolved_type_or_lazy, owner: current_type, path: path, ast_node: ast_node, scoped_context: context.scoped_context, field: field, owner_object: owner_object, arguments: arguments, trace: false, result_name: result_name, result: selection_result) do |resolved_type|
687+
after_lazy(resolved_type_or_lazy, owner: current_type, path: path, ast_node: ast_node, field: field, owner_object: owner_object, arguments: arguments, trace: false, result_name: result_name, result: selection_result) do |resolved_type|
692688
possible_types = query.possible_types(current_type)
693689

694690
if !possible_types.include?(resolved_type)
@@ -708,7 +704,7 @@ def continue_field(path, value, owner_type, field, current_type, ast_node, next_
708704
rescue GraphQL::ExecutionError => err
709705
err
710706
end
711-
after_lazy(object_proxy, owner: current_type, path: path, ast_node: ast_node, scoped_context: context.scoped_context, field: field, owner_object: owner_object, arguments: arguments, trace: false, result_name: result_name, result: selection_result) do |inner_object|
707+
after_lazy(object_proxy, owner: current_type, path: path, ast_node: ast_node, field: field, owner_object: owner_object, arguments: arguments, trace: false, result_name: result_name, result: selection_result) do |inner_object|
712708
continue_value = continue_value(path, inner_object, owner_type, field, is_non_null, ast_node, result_name, selection_result)
713709
if HALT != continue_value
714710
response_hash = GraphQLResultHash.new(result_name, selection_result)
@@ -734,7 +730,6 @@ def continue_field(path, value, owner_type, field, current_type, ast_node, next_
734730
call_method_on_directives(:resolve, continue_value, selections.graphql_directives) do
735731
evaluate_selections(
736732
path,
737-
context.scoped_context,
738733
continue_value,
739734
current_type,
740735
false,
@@ -757,7 +752,6 @@ def continue_field(path, value, owner_type, field, current_type, ast_node, next_
757752
set_result(selection_result, result_name, response_list)
758753

759754
idx = 0
760-
scoped_context = context.scoped_context
761755
begin
762756
value.each do |inner_value|
763757
break if dead_result?(response_list)
@@ -768,10 +762,10 @@ def continue_field(path, value, owner_type, field, current_type, ast_node, next_
768762
idx += 1
769763
if use_dataloader_job
770764
@dataloader.append_job do
771-
resolve_list_item(inner_value, inner_type, next_path, ast_node, scoped_context, field, owner_object, arguments, this_idx, response_list, next_selections, owner_type)
765+
resolve_list_item(inner_value, inner_type, next_path, ast_node, field, owner_object, arguments, this_idx, response_list, next_selections, owner_type)
772766
end
773767
else
774-
resolve_list_item(inner_value, inner_type, next_path, ast_node, scoped_context, field, owner_object, arguments, this_idx, response_list, next_selections, owner_type)
768+
resolve_list_item(inner_value, inner_type, next_path, ast_node, field, owner_object, arguments, this_idx, response_list, next_selections, owner_type)
775769
end
776770
end
777771
rescue NoMethodError => err
@@ -791,11 +785,11 @@ def continue_field(path, value, owner_type, field, current_type, ast_node, next_
791785
end
792786
end
793787

794-
def resolve_list_item(inner_value, inner_type, next_path, ast_node, scoped_context, field, owner_object, arguments, this_idx, response_list, next_selections, owner_type) # rubocop:disable Metrics/ParameterLists
788+
def resolve_list_item(inner_value, inner_type, next_path, ast_node, field, owner_object, arguments, this_idx, response_list, next_selections, owner_type) # rubocop:disable Metrics/ParameterLists
795789
set_all_interpreter_context(nil, nil, nil, next_path)
796790
call_method_on_directives(:resolve_each, owner_object, ast_node.directives) do
797791
# This will update `response_list` with the lazy
798-
after_lazy(inner_value, owner: inner_type, path: next_path, ast_node: ast_node, scoped_context: scoped_context, field: field, owner_object: owner_object, arguments: arguments, result_name: this_idx, result: response_list) do |inner_inner_value|
792+
after_lazy(inner_value, owner: inner_type, path: next_path, ast_node: ast_node, field: field, owner_object: owner_object, arguments: arguments, result_name: this_idx, result: response_list) do |inner_inner_value|
799793
continue_value = continue_value(next_path, inner_inner_value, owner_type, field, inner_type.non_null?, ast_node, this_idx, response_list)
800794
if HALT != continue_value
801795
continue_field(next_path, continue_value, owner_type, field, inner_type, ast_node, next_selections, false, owner_object, arguments, this_idx, response_list)
@@ -870,11 +864,10 @@ def set_all_interpreter_context(object, field, arguments, path)
870864
# @param eager [Boolean] Set to `true` for mutation root fields only
871865
# @param trace [Boolean] If `false`, don't wrap this with field tracing
872866
# @return [GraphQL::Execution::Lazy, Object] If loading `object` will be deferred, it's a wrapper over it.
873-
def after_lazy(lazy_obj, owner:, field:, path:, scoped_context:, owner_object:, arguments:, ast_node:, result:, result_name:, eager: false, trace: true, &block)
867+
def after_lazy(lazy_obj, owner:, field:, path:, owner_object:, arguments:, ast_node:, result:, result_name:, eager: false, trace: true, &block)
874868
if lazy?(lazy_obj)
875869
lazy = GraphQL::Execution::Lazy.new(path: path, field: field) do
876870
set_all_interpreter_context(owner_object, field, arguments, path)
877-
context.scoped_context = scoped_context
878871
# Wrap the execution of _this_ method with tracing,
879872
# but don't wrap the continuation below
880873
inner_obj = begin

lib/graphql/query/context.rb

Lines changed: 96 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,83 @@ def initialize(query:, schema: query.schema, values:, object:)
8686
@errors = []
8787
@path = []
8888
@value = nil
89-
@context = self # for SharedMethods
90-
@scoped_context = {}
89+
@context = self # for SharedMethods TODO delete sharedmethods
90+
@scoped_context = ScopedContext.new(self)
91+
end
92+
93+
class ScopedContext
94+
def initialize(query_context)
95+
@query_context = query_context
96+
@path_contexts = {}
97+
@no_path = [].freeze
98+
end
99+
100+
def merged_context
101+
merged_ctx = {}
102+
each_present_path_ctx do |path_ctx|
103+
merged_ctx = path_ctx.merge(merged_ctx)
104+
end
105+
merged_ctx
106+
end
107+
108+
def merge!(hash)
109+
current_ctx = @path_contexts[current_path] ||= {}
110+
current_ctx.merge!(hash)
111+
end
112+
113+
def current_path
114+
@query_context.namespace(:interpreter)[:current_path] || @no_path
115+
end
116+
117+
def key?(key)
118+
each_present_path_ctx do |path_ctx|
119+
if path_ctx.key?(key)
120+
return true
121+
end
122+
end
123+
false
124+
end
125+
126+
def [](key)
127+
each_present_path_ctx do |path_ctx|
128+
if path_ctx.key?(key)
129+
return path_ctx[key]
130+
end
131+
end
132+
nil
133+
end
134+
135+
def dig(key, *other_keys)
136+
each_present_path_ctx do |path_ctx|
137+
if path_ctx.key?(key)
138+
found_value = path_ctx[key]
139+
if other_keys.any?
140+
return found_value.dig(*other_keys)
141+
else
142+
return found_value
143+
end
144+
end
145+
end
146+
nil
147+
end
148+
149+
private
150+
151+
# Start at the current location,
152+
# but look up the tree for previously-assigned scoped values
153+
def each_present_path_ctx
154+
search_path = current_path.dup
155+
if (current_path_ctx = @path_contexts[search_path])
156+
yield(current_path_ctx)
157+
end
158+
159+
while search_path.size > 0
160+
search_path.pop # look one level higher
161+
if (search_path_ctx = @path_contexts[search_path])
162+
yield(search_path_ctx)
163+
end
164+
end
165+
end
91166
end
92167

93168
# @return [Hash] A hash that will be added verbatim to the result hash, as `"extensions" => { ... }`
@@ -106,7 +181,7 @@ def dataloader
106181
attr_writer :value
107182

108183
# @api private
109-
attr_accessor :scoped_context
184+
attr_reader :scoped_context
110185

111186
def []=(key, value)
112187
@provided_values[key] = value
@@ -119,8 +194,11 @@ def []=(key, value)
119194

120195
# Lookup `key` from the hash passed to {Schema#execute} as `context:`
121196
def [](key)
122-
return @scoped_context[key] if @scoped_context.key?(key)
123-
@provided_values[key]
197+
if @scoped_context.key?(key)
198+
@scoped_context[key]
199+
else
200+
@provided_values[key]
201+
end
124202
end
125203

126204
def delete(key)
@@ -135,7 +213,7 @@ def delete(key)
135213

136214
def fetch(key, default = UNSPECIFIED_FETCH_DEFAULT)
137215
if @scoped_context.key?(key)
138-
@scoped_context[key]
216+
scoped_context[key]
139217
elsif @provided_values.key?(key)
140218
@provided_values[key]
141219
elsif default != UNSPECIFIED_FETCH_DEFAULT
@@ -148,12 +226,21 @@ def fetch(key, default = UNSPECIFIED_FETCH_DEFAULT)
148226
end
149227

150228
def dig(key, *other_keys)
151-
@scoped_context.key?(key) ? @scoped_context.dig(key, *other_keys) : @provided_values.dig(key, *other_keys)
229+
if @scoped_context.key?(key)
230+
@scoped_context.dig(key, *other_keys)
231+
else
232+
@provided_values.dig(key, *other_keys)
233+
end
152234
end
153235

154236
def to_h
155-
@provided_values.merge(@scoped_context)
237+
if (current_scoped_context = @scoped_context.merged_context)
238+
@provided_values.merge(current_scoped_context)
239+
else
240+
@provided_values
241+
end
156242
end
243+
157244
alias :to_hash :to_h
158245

159246
def key?(key)
@@ -185,7 +272,7 @@ def inspect
185272
end
186273

187274
def scoped_merge!(hash)
188-
@scoped_context = @scoped_context.merge(hash)
275+
@scoped_context.merge!(hash)
189276
end
190277

191278
def scoped_set!(key, value)

0 commit comments

Comments
 (0)