Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add GraphQL::Query::Partial #5183

Open
wants to merge 16 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 58 additions & 4 deletions lib/graphql/execution/interpreter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ def run_all(schema, query_options, context: {}, max_complexity: schema.max_compl
query = case opts
when Hash
schema.query_class.new(schema, nil, **opts)
when GraphQL::Query
when GraphQL::Query, GraphQL::Query::Partial
opts
else
raise "Expected Hash or GraphQL::Query, not #{opts.class} (#{opts.inspect})"
Expand Down Expand Up @@ -93,13 +93,11 @@ def run_all(schema, query_options, context: {}, max_complexity: schema.max_compl
# Then, work through lazy results in a breadth-first way
multiplex.dataloader.append_job {
query = multiplex.queries.length == 1 ? multiplex.queries[0] : nil
queries = multiplex ? multiplex.queries : [query]
final_values = queries.map do |query|
queries.each do |query|
runtime = query.context.namespace(:interpreter_runtime)[:runtime]
# it might not be present if the query has an error
runtime ? runtime.final_result : nil
end
final_values.compact!
multiplex.current_trace.execute_query_lazy(multiplex: multiplex, query: query) do
Interpreter::Resolve.resolve_each_depth(lazies_at_depth, multiplex.dataloader)
end
Expand Down Expand Up @@ -156,6 +154,62 @@ def run_all(schema, query_options, context: {}, max_complexity: schema.max_compl
end
end
end

def run_partials(schema, partials, context:)
multiplex = Execution::Multiplex.new(schema: schema, queries: partials, context: context, max_complexity: nil)
dataloader = multiplex.dataloader
lazies_at_depth = Hash.new { |h, k| h[k] = [] }

partials.each do |partial|
dataloader.append_job {
runtime = Runtime.new(query: partial, lazies_at_depth: lazies_at_depth)
partial.context.namespace(:interpreter_runtime)[:runtime] = runtime
# TODO tracing?
runtime.run_partial_eager
}
end

dataloader.run

dataloader.append_job {
partials.each do |partial|
runtime = partial.context.namespace(:interpreter_runtime)[:runtime]
runtime.final_result
end
# TODO tracing?
Interpreter::Resolve.resolve_each_depth(lazies_at_depth, multiplex.dataloader)
}

dataloader.run

partials.map do |partial|
# Assign the result so that it can be accessed in instrumentation
data_result = partial.context.namespace(:interpreter_runtime)[:runtime].final_result
partial.result_values = if data_result.equal?(NO_OPERATION)
if !partial.context.errors.empty?
{ "errors" => partial.context.errors.map(&:to_h) }
else
data_result
end
else
result = {}

if !partial.context.errors.empty?
error_result = partial.context.errors.map(&:to_h)
result["errors"] = error_result
end

result["data"] = data_result

result
end
if partial.context.namespace?(:__query_result_extensions__)
partial.result_values["extensions"] = partial.context.namespace(:__query_result_extensions__)
end
# Partial::Result
partial.result
end
end
end

class ListResultFailedError < GraphQL::Error
Expand Down
73 changes: 73 additions & 0 deletions lib/graphql/execution/interpreter/runtime.rb
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,79 @@ def run_eager
nil
end

# @return [void]
def run_partial_eager
# `query` is actually a GraphQL::Query::Partial
partial = query
root_type = partial.root_type
object = partial.object
selections = partial.ast_nodes.map(&:selections).inject(&:+)
runtime_state = get_current_runtime_state
case root_type.kind.name
when "OBJECT"
object_proxy = root_type.wrap(object, context)
object_proxy = schema.sync_lazy(object_proxy)
@response = GraphQLResultHash.new(nil, root_type, object_proxy, nil, false, selections, false, partial.ast_nodes.first, nil, nil)
each_gathered_selections(@response) do |selections, is_selection_array|
if is_selection_array == true
raise "This isn't supported yet"
end

@dataloader.append_job {
evaluate_selections(
selections,
@response,
nil,
runtime_state,
)
}
end
when "LIST"
inner_type = root_type.unwrap
case inner_type.kind.name
when "SCALAR", "ENUM"
parent_object_proxy = partial.parent_type.wrap(object, context)
parent_object_proxy = schema.sync_lazy(parent_object_proxy)
field_node = partial.ast_nodes.first
result_name = field_node.alias || field_node.name
@response = GraphQLResultHash.new(nil, partial.parent_type, parent_object_proxy, nil, false, nil, false, field_node, nil, nil)
evaluate_selection(result_name, partial.ast_nodes, @response)
else
@response = GraphQLResultArray.new(nil, root_type, nil, nil, false, selections, false, field_node, nil, nil)
idx = nil
object.each do |inner_value|
idx ||= 0
this_idx = idx
idx += 1
@dataloader.append_job do
runtime_state.current_result_name = this_idx
runtime_state.current_result = @response
continue_field(
inner_value, root_type, nil, inner_type, nil, @response.graphql_selections, false, object_proxy,
nil, this_idx, @response, false, runtime_state
)
end
end
end
when "SCALAR", "ENUM"
parent_type = partial.parent_type
# TODO what if not object type? Maybe returns a lazy here.
parent_object_type, object = resolve_type(parent_type, object)
parent_object_proxy = parent_object_type.wrap(object, context)
parent_object_proxy = schema.sync_lazy(parent_object_proxy)
field_node = partial.ast_nodes.first
result_name = field_node.alias || field_node.name
@response = GraphQLResultHash.new(nil, parent_object_type, parent_object_proxy, nil, false, selections, false, field_node, nil, nil)
@dataloader.append_job do
evaluate_selection(result_name, partial.ast_nodes, @response)
end
else
# TODO union, interface
raise "Invariant: unsupported type kind for partial execution: #{root_type.kind.inspect} (#{root_type})"
end
nil
end

def each_gathered_selections(response_hash)
gathered_selections = gather_selections(response_hash.graphql_application_value, response_hash.graphql_result_type, response_hash.graphql_selections)
if gathered_selections.is_a?(Array)
Expand Down
13 changes: 13 additions & 0 deletions lib/graphql/query.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class Query
autoload :Context, "graphql/query/context"
autoload :Fingerprint, "graphql/query/fingerprint"
autoload :NullContext, "graphql/query/null_context"
autoload :Partial, "graphql/query/partial"
autoload :Result, "graphql/query/result"
autoload :Variables, "graphql/query/variables"
autoload :InputValidationResult, "graphql/query/input_validation_result"
Expand Down Expand Up @@ -242,6 +243,18 @@ def operations
with_prepared_ast { @operations }
end

# Run subtree partials of this query and return their results.
# Each partial is identified with a `path:` and `object:`
# where the path references a field in the AST and the object will be treated
# as the return value from that field. Subfields of the field named by `path`
# will be executed with `object` as the starting point
# @param partials_hashes [Array<Hash{Symbol => Object}>] Hashes with `path:` and `object:` keys
# @return [Array<GraphQL::Query::Result>]
def run_partials(partials_hashes)
partials = partials_hashes.map { |partial_options| Partial.new(query: self, **partial_options) }
Execution::Interpreter.run_partials(@schema, partials, context: @context)
end

# Get the result for this query, executing it once
# @return [GraphQL::Query::Result] A Hash-like GraphQL response, with `"data"` and/or `"errors"` keys
def result
Expand Down
3 changes: 0 additions & 3 deletions lib/graphql/query/context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,6 @@ def initialize(query:, schema: query.schema, values:)
@storage = Hash.new { |h, k| h[k] = {} }
@storage[nil] = @provided_values
@errors = []
@path = []
@value = nil
@context = self # for SharedMethods TODO delete sharedmethods
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These were leftover from a previous GraphQL::Query::Context implementation. I rediscovered them while working on this feature because at first, I thought I was going to need to use query.context.dup. I didn't end up doing that (instead, Query::Context.new inside Partial#initialize) but I'm going to keep these clean-ups.

@scoped_context = ScopedContext.new(self)
end

Expand Down
136 changes: 136 additions & 0 deletions lib/graphql/query/partial.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# frozen_string_literal: true
module GraphQL
class Query
# This class is _like_ a {GraphQL::Query}, except
# @see Query#run_partials
class Partial
def initialize(path:, object:, query:)
@path = path
@object = object
@query = query
@context = GraphQL::Query::Context.new(query: self, schema: @query.schema, values: @query.context.to_h)
@multiplex = nil
@result_values = nil
@result = nil
selections = [@query.selected_operation]
type = @query.schema.query # TODO could be other?
parent_type = nil
field_defn = nil
@path.each do |name_in_doc|
next_selections = []
selections.each do |selection|
selections_to_check = []
selections_to_check.concat(selection.selections)
while (sel = selections_to_check.shift)
case sel
when GraphQL::Language::Nodes::InlineFragment
selections_to_check.concat(sel.selections)
when GraphQL::Language::Nodes::FragmentSpread
fragment = @query.fragments[sel.name]
selections_to_check.concat(fragment.selections)
when GraphQL::Language::Nodes::Field
if sel.alias == name_in_doc || sel.name == name_in_doc
next_selections << sel
end
else
raise "Unexpected selection in partial path: #{sel.class}, #{sel.inspect}"
end
end
end

if next_selections.empty?
raise ArgumentError, "Path `#{@path.inspect}` is not present in this query. `#{name_in_doc.inspect}` was not found. Try a different path or rewrite the query to include it."
end
field_name = next_selections.first.name
field_defn = type.get_field(field_name, @query.context) || raise("Invariant: no field called #{field_name} on #{type.graphql_name}")
parent_type = type
type = field_defn.type
if type.non_null?
type = type.of_type
end
selections = next_selections
end
@parent_type = parent_type
@ast_nodes = selections
@root_type = type
@field_definition = field_defn
@leaf = @root_type.unwrap.kind.leaf?
end

def leaf?
@leaf
end

attr_reader :context, :query, :ast_nodes, :root_type, :object, :field_definition, :path, :parent_type

attr_accessor :multiplex, :result_values

class Result < GraphQL::Query::Result
def path
@query.path
end

# @return [GraphQL::Query::Partial]
def partial
@query
end
end

def result
@result ||= Result.new(query: self, values: result_values)
end

def current_trace
@query.current_trace
end

def schema
@query.schema
end

def types
@query.types
end

# TODO dry with query
def after_lazy(value, &block)
if !defined?(@runtime_instance)
@runtime_instance = context.namespace(:interpreter_runtime)[:runtime]
end

if @runtime_instance
@runtime_instance.minimal_after_lazy(value, &block)
else
@schema.after_lazy(value, &block)
end
end

# TODO dry with query
def arguments_for(ast_node, definition, parent_object: nil)
arguments_cache.fetch(ast_node, definition, parent_object)
end

# TODO dry with query
def arguments_cache
@arguments_cache ||= Execution::Interpreter::ArgumentsCache.new(self)
end

# TODO dry
def handle_or_reraise(err)
@query.schema.handle_or_reraise(context, err)
end

def resolve_type(...)
@query.resolve_type(...)
end

def variables
@query.variables
end

def fragments
@query.fragments
end
end
end
end
Loading