Skip to content

Add GraphQL::Query::Partial #5183

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

Merged
merged 32 commits into from
May 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
236cbe9
Add GraphQL::Query::Partial
rmosolgo Dec 3, 2024
5b73d6e
Add tests with args, dataloader, errorsg
rmosolgo Dec 6, 2024
f1a8fb2
Merge branch 'master' into partial-execution
rmosolgo Dec 6, 2024
20fd744
Add test for running on Arrays
rmosolgo Dec 6, 2024
38e19d7
Add dedicated run_partials method
rmosolgo Dec 9, 2024
4090291
Start on run_partial_eager
rmosolgo Dec 9, 2024
93952e7
Build out partial execution with list types
rmosolgo Dec 9, 2024
87c244e
Fix typos
rmosolgo Dec 9, 2024
babf68f
Implement partial execution for list types
rmosolgo Dec 9, 2024
9cc47f5
Support AST branches that match the same path
rmosolgo Dec 9, 2024
385ee3b
Add multiple errors test
rmosolgo Dec 10, 2024
dcd9541
Support isolated execution of scalar fields
rmosolgo Dec 16, 2024
2f9b1db
Add some resolve_type
rmosolgo Dec 16, 2024
6f81134
Support inline fragments and fragment spreads
rmosolgo Jan 9, 2025
f84a511
Merge branch 'master' into partial-execution
rmosolgo Apr 2, 2025
34870ea
Update tests
rmosolgo Apr 2, 2025
54399bc
Merge branch 'master' into partial-execution
rmosolgo Apr 21, 2025
2d38a4d
Update for ordered result keys
rmosolgo Apr 21, 2025
94d9b74
Add basic Union and Interface support
rmosolgo Apr 21, 2025
c37a670
Add lazy resolve test and custom context support
rmosolgo Apr 29, 2025
749b670
Dry Partial with Query
rmosolgo Apr 29, 2025
c93c215
Add partial tracing
rmosolgo Apr 29, 2025
3e8c718
fix handle_or_reraise
rmosolgo Apr 29, 2025
60a1b8e
Improve error message, handle partials on list items
rmosolgo Apr 30, 2025
09df6e3
Test scalars on abstract types; support introspection fields; support…
rmosolgo May 1, 2025
c3e3919
Merge run_eager and run_partials_eager
rmosolgo May 1, 2025
1a5d650
merge run_partials into run_all
rmosolgo May 1, 2025
73e62d9
Resolve lazy resolved type if necessary
rmosolgo May 1, 2025
23fdcd8
Make list items run as items, make scalars return scalars directly
rmosolgo May 2, 2025
c238842
Use a full path in Partial responses, handle runtime errors in scalars
rmosolgo May 2, 2025
e222f81
Add tests for lazy starting object and current_path usage
rmosolgo May 5, 2025
e456268
Move Async up to Ruby 3.2+ because it doesn't support 3.1 anymore
rmosolgo May 5, 2025
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
2 changes: 1 addition & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ if RUBY_VERSION >= "3.0"
gem "evt"
end

if RUBY_VERSION >= "3.1.1"
if RUBY_VERSION >= "3.2.0"
gem "async", "~>2.0"
end

Expand Down
2 changes: 1 addition & 1 deletion 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
157 changes: 123 additions & 34 deletions lib/graphql/execution/interpreter/runtime.rb
Original file line number Diff line number Diff line change
Expand Up @@ -57,53 +57,142 @@ def initialize(query:, lazies_at_depth:)
end

def final_result
@response && @response.graphql_result_data
@response.respond_to?(:graphql_result_data) ? @response.graphql_result_data : @response
end

def inspect
"#<#{self.class.name} response=#{@response.inspect}>"
end

# This _begins_ the execution. Some deferred work
# might be stored up in lazies.
# @return [void]
def run_eager
root_operation = query.selected_operation
root_op_type = root_operation.operation_type || "query"
root_type = schema.root_type_for_operation(root_op_type)
runtime_object = root_type.wrap(query.root_value, context)
runtime_object = schema.sync_lazy(runtime_object)
is_eager = root_op_type == "mutation"
@response = GraphQLResultHash.new(nil, root_type, runtime_object, nil, false, root_operation.selections, is_eager, root_operation, nil, nil)
st = get_current_runtime_state
st.current_result = @response

if runtime_object.nil?
# Root .authorized? returned false.
@response = nil
root_type = query.root_type
case query
when GraphQL::Query
ast_node = query.selected_operation
selections = ast_node.selections
object = query.root_value
is_eager = ast_node.operation_type == "mutation"
base_path = nil
when GraphQL::Query::Partial
ast_node = query.ast_nodes.first
selections = query.ast_nodes.map(&:selections).inject(&:+)
object = query.object
is_eager = false
base_path = query.path
else
call_method_on_directives(:resolve, runtime_object, root_operation.directives) do # execute query level directives
each_gathered_selections(@response) do |selections, is_selection_array, ordered_result_keys|
@response.ordered_result_keys ||= ordered_result_keys
if is_selection_array
selection_response = GraphQLResultHash.new(nil, root_type, runtime_object, nil, false, selections, is_eager, root_operation, nil, nil)
selection_response.ordered_result_keys = ordered_result_keys
final_response = @response
else
selection_response = @response
final_response = nil
end
raise ArgumentError, "Unexpected Runnable, can't execute: #{query.class} (#{query.inspect})"
end
object = schema.sync_lazy(object) # TODO test query partial with lazy root object
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)
if object_proxy.nil?
@response = nil
else
@response = GraphQLResultHash.new(nil, root_type, object_proxy, nil, false, selections, is_eager, ast_node, nil, nil)
@response.base_path = base_path
runtime_state.current_result = @response
call_method_on_directives(:resolve, object, ast_node.directives) do
each_gathered_selections(@response) do |selections, is_selection_array, ordered_result_keys|
@response.ordered_result_keys ||= ordered_result_keys
if is_selection_array
selection_response = GraphQLResultHash.new(nil, root_type, object_proxy, nil, false, selections, is_eager, ast_node, nil, nil)
selection_response.ordered_result_keys = ordered_result_keys
final_response = @response
else
selection_response = @response
final_response = nil
end

@dataloader.append_job {
evaluate_selections(
selections,
selection_response,
final_response,
nil,
@dataloader.append_job {
evaluate_selections(
selections,
selection_response,
final_response,
nil,
)
}
end
end
end
when "LIST"
inner_type = root_type.unwrap
case inner_type.kind.name
when "SCALAR", "ENUM"
result_name = ast_node.alias || ast_node.name
owner_type = query.field_definition.owner
selection_result = GraphQLResultHash.new(nil, owner_type, nil, nil, false, EmptyObjects::EMPTY_ARRAY, false, ast_node, nil, nil)
selection_result.base_path = base_path
selection_result.ordered_result_keys = [result_name]
runtime_state = get_current_runtime_state
runtime_state.current_result = selection_result
runtime_state.current_result_name = result_name
field_defn = query.field_definition
continue_value = continue_value(object, field_defn, false, ast_node, result_name, selection_result)
if HALT != continue_value
continue_field(continue_value, owner_type, field_defn, root_type, ast_node, nil, false, nil, nil, result_name, selection_result, false, runtime_state) # rubocop:disable Metrics/ParameterLists
end
@response = selection_result[result_name]
else
@response = GraphQLResultArray.new(nil, root_type, nil, nil, false, selections, false, ast_node, nil, nil)
@response.base_path = base_path
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"
result_name = ast_node.alias || ast_node.name
owner_type = query.field_definition.owner
selection_result = GraphQLResultHash.new(nil, query.parent_type, nil, nil, false, EmptyObjects::EMPTY_ARRAY, false, ast_node, nil, nil)
selection_result.ordered_result_keys = [result_name]
selection_result.base_path = base_path
runtime_state = get_current_runtime_state
runtime_state.current_result = selection_result
runtime_state.current_result_name = result_name
field_defn = query.field_definition
continue_value = continue_value(object, field_defn, false, ast_node, result_name, selection_result)
if HALT != continue_value
continue_field(continue_value, owner_type, field_defn, query.root_type, ast_node, nil, false, nil, nil, result_name, selection_result, false, runtime_state) # rubocop:disable Metrics/ParameterLists
end
@response = selection_result[result_name]
when "UNION", "INTERFACE"
resolved_type, _resolved_obj = resolve_type(root_type, object)
resolved_type = schema.sync_lazy(resolved_type)
object_proxy = resolved_type.wrap(object, context)
object_proxy = schema.sync_lazy(object_proxy)
@response = GraphQLResultHash.new(nil, resolved_type, object_proxy, nil, false, selections, false, query.ast_nodes.first, nil, nil)
@response.base_path = base_path
each_gathered_selections(@response) do |selections, is_selection_array, ordered_result_keys|
@response.ordered_result_keys ||= ordered_result_keys
if is_selection_array == true
raise "This isn't supported yet"
end

@dataloader.append_job {
evaluate_selections(
selections,
@response,
nil,
runtime_state,
)
}
end
else
raise "Invariant: unsupported type kind for partial execution: #{root_type.kind.inspect} (#{root_type})"
end
nil
end
Expand Down
12 changes: 11 additions & 1 deletion lib/graphql/execution/interpreter/runtime/graphql_result.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,25 @@ def initialize(result_name, result_type, application_value, parent_result, is_no
@graphql_metadata = nil
@graphql_selections = selections
@graphql_is_eager = is_eager
@base_path = nil
end

# TODO test full path in Partial
attr_writer :base_path

def path
@path ||= build_path([])
end

def build_path(path_array)
graphql_result_name && path_array.unshift(graphql_result_name)
@graphql_parent ? @graphql_parent.build_path(path_array) : path_array
if @graphql_parent
@graphql_parent.build_path(path_array)
elsif @base_path
@base_path + path_array
else
path_array
end
end

attr_accessor :graphql_dead
Expand Down
98 changes: 55 additions & 43 deletions lib/graphql/query.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,47 @@ 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"
autoload :VariableValidationError, "graphql/query/variable_validation_error"
autoload :ValidationPipeline, "graphql/query/validation_pipeline"

# Code shared with {Partial}
module Runnable
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

# Node-level cache for calculating arguments. Used during execution and query analysis.
# @param ast_node [GraphQL::Language::Nodes::AbstractNode]
# @param definition [GraphQL::Schema::Field]
# @param parent_object [GraphQL::Schema::Object]
# @return [Hash{Symbol => Object}]
def arguments_for(ast_node, definition, parent_object: nil)
arguments_cache.fetch(ast_node, definition, parent_object)
end

def arguments_cache
@arguments_cache ||= Execution::Interpreter::ArgumentsCache.new(self)
end

# @api private
def handle_or_reraise(err)
@schema.handle_or_reraise(context, err)
end
end

include Runnable
class OperationNameMissingError < GraphQL::ExecutionError
def initialize(name)
msg = if name.nil?
Expand Down Expand Up @@ -198,19 +233,10 @@ def subscription_update?
# @return [GraphQL::Execution::Lookahead]
def lookahead
@lookahead ||= begin
ast_node = selected_operation
if ast_node.nil?
if selected_operation.nil?
GraphQL::Execution::Lookahead::NULL_LOOKAHEAD
else
root_type = case ast_node.operation_type
when nil, "query"
types.query_root # rubocop:disable Development/ContextIsPassedCop
when "mutation"
types.mutation_root # rubocop:disable Development/ContextIsPassedCop
when "subscription"
types.subscription_root # rubocop:disable Development/ContextIsPassedCop
end
GraphQL::Execution::Lookahead.new(query: self, root_type: root_type, ast_nodes: [ast_node])
GraphQL::Execution::Lookahead.new(query: self, root_type: root_type, ast_nodes: [selected_operation])
end
end
end
Expand All @@ -236,6 +262,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_all(@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 Expand Up @@ -278,19 +316,6 @@ def variables
end
end

# Node-level cache for calculating arguments. Used during execution and query analysis.
# @param ast_node [GraphQL::Language::Nodes::AbstractNode]
# @param definition [GraphQL::Schema::Field]
# @param parent_object [GraphQL::Schema::Object]
# @return [Hash{Symbol => Object}]
def arguments_for(ast_node, definition, parent_object: nil)
arguments_cache.fetch(ast_node, definition, parent_object)
end

def arguments_cache
@arguments_cache ||= Execution::Interpreter::ArgumentsCache.new(self)
end

# A version of the given query string, with:
# - Variables inlined to the query
# - Strings replaced with `<REDACTED>`
Expand Down Expand Up @@ -357,17 +382,21 @@ def possible_types(type)

def root_type_for_operation(op_type)
case op_type
when "query"
when "query", nil
types.query_root # rubocop:disable Development/ContextIsPassedCop
when "mutation"
types.mutation_root # rubocop:disable Development/ContextIsPassedCop
when "subscription"
types.subscription_root # rubocop:disable Development/ContextIsPassedCop
else
raise ArgumentError, "unexpected root type name: #{op_type.inspect}; expected 'query', 'mutation', or 'subscription'"
raise ArgumentError, "unexpected root type name: #{op_type.inspect}; expected nil, 'query', 'mutation', or 'subscription'"
end
end

def root_type
root_type_for_operation(selected_operation.operation_type)
end

def types
@visibility_profile || warden.visibility_profile
end
Expand Down Expand Up @@ -400,23 +429,6 @@ def subscription?
with_prepared_ast { @subscription }
end

# @api private
def handle_or_reraise(err)
schema.handle_or_reraise(context, err)
end

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

attr_reader :logger

private
Expand Down
Loading