From e2eff74a913e30f0803f272fc0a89a06718c0ab2 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Wed, 1 Apr 2026 08:42:02 -0400 Subject: [PATCH 1/6] Migrate to RuntimeError for error checks, add Finalizers API --- lib/graphql.rb | 4 +--- lib/graphql/execution/next.rb | 7 +++++++ lib/graphql/execution/next/field_resolve_step.rb | 8 ++++---- lib/graphql/execution/next/load_argument_step.rb | 2 +- lib/graphql/execution/next/prepare_object_step.rb | 2 +- lib/graphql/execution/next/runner.rb | 10 +++++----- lib/graphql/query.rb | 13 +++++++++++++ 7 files changed, 32 insertions(+), 14 deletions(-) diff --git a/lib/graphql.rb b/lib/graphql.rb index bdc734178b..110a40a54d 100644 --- a/lib/graphql.rb +++ b/lib/graphql.rb @@ -21,9 +21,6 @@ def self.eager_load! class Error < StandardError end - class RuntimeError < Error - end - # This error is raised when GraphQL-Ruby encounters a situation # that it *thought* would never happen. Please report this bug! class InvariantError < Error @@ -122,6 +119,7 @@ class << self autoload :ParseError, "graphql/parse_error" autoload :Backtrace, "graphql/backtrace" + autoload :RuntimeError, "graphql/runtime_error" autoload :UnauthorizedError, "graphql/unauthorized_error" autoload :UnauthorizedEnumValueError, "graphql/unauthorized_enum_value_error" autoload :UnauthorizedFieldError, "graphql/unauthorized_field_error" diff --git a/lib/graphql/execution/next.rb b/lib/graphql/execution/next.rb index 45fdf699d5..9fdcc8a205 100644 --- a/lib/graphql/execution/next.rb +++ b/lib/graphql/execution/next.rb @@ -67,6 +67,13 @@ def self.run_all(schema, query_options, context: {}, max_complexity: schema.max_ runner = Runner.new(multiplex, **schema.execution_next_options) runner.execute end + + module Finalizer + attr_accessor :path + def assign_graphql_result(query, result_data, result_key) + raise RequiredImplementationMissingError + end + end end end end diff --git a/lib/graphql/execution/next/field_resolve_step.rb b/lib/graphql/execution/next/field_resolve_step.rb index adcd5a5b74..bc28ab3ff0 100644 --- a/lib/graphql/execution/next/field_resolve_step.rb +++ b/lib/graphql/execution/next/field_resolve_step.rb @@ -171,7 +171,7 @@ def coerce_argument_value(arguments, arg_defn, arg_value, run_loads, target_keyw end end - if arg_value.is_a?(GraphQL::Error) + if arg_value.is_a?(GraphQL::RuntimeError) @arguments = arg_value elsif run_loads && arg_defn.loads && as_type.nil? && !arg_value.nil? # This is for legacy compat: @@ -292,7 +292,7 @@ def build_arguments def execute_field objects = @selections_step.objects # TODO not as good because only one error? - if @arguments.is_a?(GraphQL::Error) + if @arguments.is_a?(GraphQL::RuntimeError) @field_results = Array.new(objects.size, @arguments) @object_is_authorized = AlwaysAuthorized build_results @@ -520,7 +520,7 @@ def build_results else nil end - elsif field_result.is_a?(GraphQL::Error) + elsif field_result.is_a?(GraphQL::RuntimeError) add_graphql_error(field_result) else # TODO `nil`s in [T!] types aren't handled @@ -596,7 +596,7 @@ def build_graphql_result(graphql_result, key, field_result, return_type, is_nn, else graphql_result[key] = nil end - elsif field_result.is_a?(GraphQL::Error) + elsif field_result.is_a?(GraphQL::RuntimeError) graphql_result[key] = add_graphql_error(field_result) elsif is_list if is_nn diff --git a/lib/graphql/execution/next/load_argument_step.rb b/lib/graphql/execution/next/load_argument_step.rb index 86d062d3ce..485ceb73e3 100644 --- a/lib/graphql/execution/next/load_argument_step.rb +++ b/lib/graphql/execution/next/load_argument_step.rb @@ -45,7 +45,7 @@ def call private def assign_value - if @loaded_value.is_a?(GraphQL::Error) + if @loaded_value.is_a?(GraphQL::RuntimeError) @loaded_value.path = @field_resolve_step.path @field_resolve_step.arguments = @loaded_value else diff --git a/lib/graphql/execution/next/prepare_object_step.rb b/lib/graphql/execution/next/prepare_object_step.rb index 5898e91d42..2fd6075509 100644 --- a/lib/graphql/execution/next/prepare_object_step.rb +++ b/lib/graphql/execution/next/prepare_object_step.rb @@ -103,7 +103,7 @@ def create_result else @graphql_result[@key] = @field_resolve_step.add_graphql_error(@authorization_error) end - rescue GraphQL::Error => err + rescue GraphQL::RuntimeError => err if @is_non_null @graphql_result[@key] = @field_resolve_step.add_non_null_error(@is_from_array) else diff --git a/lib/graphql/execution/next/runner.rb b/lib/graphql/execution/next/runner.rb index 24748842e1..dbedbccc28 100644 --- a/lib/graphql/execution/next/runner.rb +++ b/lib/graphql/execution/next/runner.rb @@ -184,7 +184,7 @@ def execute result else data = result["data"] - data = propagate_errors(data, query) + data = run_finalizers(data, query) errors = [] query.context.errors.each do |err| if err.respond_to?(:to_h) @@ -252,8 +252,8 @@ def lazy?(object) private - def propagate_errors(data, query) - paths_to_check = query.context.errors.map(&:path) + def run_finalizers(data, query) + paths_to_check = query.finalizers.map(&:path) paths_to_check.compact! # root-level auth errors currently come without a path # TODO dry with above? # This is also where a query-level "Step" would be used? @@ -287,7 +287,7 @@ def check_object_result(query, result_h, static_type, ast_selections, current_ex result_type = result_type.of_type end - new_result_value = if result_value.is_a?(GraphQL::Error) + new_result_value = if result_value.is_a?(Finalizer) result_value.path = current_result_path.dup result_value.assign_graphql_result(query, result_h, key) result_h.key?(key) ? result_h[key] : :unassigned @@ -340,7 +340,7 @@ def check_list_result(query, result_arr, inner_type, ast_selections, current_exe new_invalid_null = false result_arr.each_with_index do |result_item, idx| current_result_path << idx - new_result = if result_item.is_a?(GraphQL::Error) + new_result = if result_item.is_a?(Finalizer) result_item.path = current_result_path.dup result_item.assign_graphql_result(query, result_arr, idx) result_arr[idx] diff --git a/lib/graphql/query.rb b/lib/graphql/query.rb index 200c520089..b875223dec 100644 --- a/lib/graphql/query.rb +++ b/lib/graphql/query.rb @@ -298,6 +298,19 @@ def selected_operation with_prepared_ast { @selected_operation } end + # @return [Array] + def finalizers + @finalizers ? (@finalizers + context.errors) : context.errors + end + + # @param finalizer [Execution::Next::Finalizer] + # @return [Execution::NextFinalizer] `finalizer` + def add_finalizer(finalizer) + f = @finalizers ||= [] + f << finalizer + f + end + # Determine the values for variables of this query, using default values # if a value isn't provided at runtime. # From f0ca079f5143b077c0a73aca93d5bcd988090d6c Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Wed, 1 Apr 2026 08:59:32 -0400 Subject: [PATCH 2/6] migrate RawValue to use finalizer --- lib/graphql/execution.rb | 2 +- .../interpreter/handles_raw_value.rb | 6 +++++ .../execution/next/field_resolve_step.rb | 27 +++++++++---------- lib/graphql/execution/next/runner.rb | 6 ++--- lib/graphql/query.rb | 2 +- lib/graphql/query/context.rb | 7 +++++ lib/graphql/runtime_error.rb | 6 +++++ lib/graphql/schema.rb | 8 ------ lib/graphql/schema/object.rb | 3 ++- spec/graphql/execution/interpreter_spec.rb | 1 - 10 files changed, 39 insertions(+), 29 deletions(-) create mode 100644 lib/graphql/runtime_error.rb diff --git a/lib/graphql/execution.rb b/lib/graphql/execution.rb index 5088edb604..d07159db2f 100644 --- a/lib/graphql/execution.rb +++ b/lib/graphql/execution.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true require "graphql/execution/directive_checks" +require "graphql/execution/next" require "graphql/execution/interpreter" require "graphql/execution/lazy" require "graphql/execution/lookahead" require "graphql/execution/multiplex" -require "graphql/execution/next" require "graphql/execution/errors" module GraphQL diff --git a/lib/graphql/execution/interpreter/handles_raw_value.rb b/lib/graphql/execution/interpreter/handles_raw_value.rb index 21f62eba96..1656453709 100644 --- a/lib/graphql/execution/interpreter/handles_raw_value.rb +++ b/lib/graphql/execution/interpreter/handles_raw_value.rb @@ -5,6 +5,12 @@ module Execution class Interpreter # Wrapper for raw values class RawValue + include GraphQL::Execution::Next::Finalizer + + def assign_graphql_result(query, result_data, result_key) + result_data[result_key] = @object + end + def initialize(obj = nil) @object = obj end diff --git a/lib/graphql/execution/next/field_resolve_step.rb b/lib/graphql/execution/next/field_resolve_step.rb index bc28ab3ff0..3a23155f6a 100644 --- a/lib/graphql/execution/next/field_resolve_step.rb +++ b/lib/graphql/execution/next/field_resolve_step.rb @@ -520,8 +520,12 @@ def build_results else nil end - elsif field_result.is_a?(GraphQL::RuntimeError) - add_graphql_error(field_result) + elsif field_result.is_a?(Finalizer) + if field_result.is_a?(GraphQL::RuntimeError) + add_graphql_error(field_result) + else + field_result.path = path + end else # TODO `nil`s in [T!] types aren't handled return_type.coerce_result(field_result, ctx) @@ -596,8 +600,13 @@ def build_graphql_result(graphql_result, key, field_result, return_type, is_nn, else graphql_result[key] = nil end - elsif field_result.is_a?(GraphQL::RuntimeError) - graphql_result[key] = add_graphql_error(field_result) + elsif field_result.is_a?(Finalizer) + graphql_result[key] = if field_result.is_a?(GraphQL::RuntimeError) + add_graphql_error(field_result) + else + field_result.path = path + field_result + end elsif is_list if is_nn return_type = return_type.of_type @@ -728,16 +737,6 @@ def resolve_batch(objects, context, args_hash) end end end - - class RawValueFieldResolveStep < FieldResolveStep - def build_graphql_result(graphql_result, key, field_result, return_type, is_nn, is_list, is_from_array) # rubocop:disable Metrics/ParameterLists - if field_result.is_a?(Interpreter::RawValue) - graphql_result[key] = field_result.resolve - else - super - end - end - end end end end diff --git a/lib/graphql/execution/next/runner.rb b/lib/graphql/execution/next/runner.rb index dbedbccc28..51197747b8 100644 --- a/lib/graphql/execution/next/runner.rb +++ b/lib/graphql/execution/next/runner.rb @@ -13,7 +13,6 @@ def initialize(multiplex, authorization:) @dataloader = multiplex.context[:dataloader] ||= @schema.dataloader_class.new @resolves_lazies = @schema.resolves_lazies? @lazy_cache = resolves_lazies ? {}.compare_by_identity : nil - @field_resolve_step_class = @schema.uses_raw_value? ? RawValueFieldResolveStep : FieldResolveStep @authorization = authorization if @authorization @authorizes_cache = Hash.new do |h, query_context| @@ -180,7 +179,8 @@ def execute @schema.subscriptions.finish_subscriptions(query) end - fin_result = if query.context.errors.empty? + finalizers = query.finalizers + fin_result = if finalizers.empty? result else data = result["data"] @@ -216,7 +216,7 @@ def gather_selections(type_defn, ast_selections, selections_step, query, prototy step = into[key] ||= begin prototype_result[key] = nil - @field_resolve_step_class.new( + FieldResolveStep.new( selections_step: selections_step, key: key, parent_type: type_defn, diff --git a/lib/graphql/query.rb b/lib/graphql/query.rb index b875223dec..3ad0d67eb0 100644 --- a/lib/graphql/query.rb +++ b/lib/graphql/query.rb @@ -308,7 +308,7 @@ def finalizers def add_finalizer(finalizer) f = @finalizers ||= [] f << finalizer - f + finalizer end # Determine the values for variables of this query, using default values diff --git a/lib/graphql/query/context.rb b/lib/graphql/query/context.rb index da454f8c8f..536a8bdc22 100644 --- a/lib/graphql/query/context.rb +++ b/lib/graphql/query/context.rb @@ -126,6 +126,13 @@ def add_error(error) nil end + # @param value [Object] Any object to be inserted directly into the final response + # @return [GraphQL::Execution::Interpreter::RawValue] Return this from the field + def raw_value(value) + rv = GraphQL::Execution::Interpreter::RawValue.new(rv) + query.add_finalizer(rv) + end + # @example Print the GraphQL backtrace during field resolution # puts ctx.backtrace # diff --git a/lib/graphql/runtime_error.rb b/lib/graphql/runtime_error.rb new file mode 100644 index 0000000000..110752d6cf --- /dev/null +++ b/lib/graphql/runtime_error.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true +module GraphQL + class RuntimeError < Error + include GraphQL::Execution::Next::Finalizer + end +end diff --git a/lib/graphql/schema.rb b/lib/graphql/schema.rb index bd29ad0b5c..18958bd360 100644 --- a/lib/graphql/schema.rb +++ b/lib/graphql/schema.rb @@ -1362,14 +1362,6 @@ def lazy_resolve(lazy_class, value_method) lazy_methods.set(lazy_class, value_method) end - def uses_raw_value? - !!@uses_raw_value - end - - def uses_raw_value(new_val) - @uses_raw_value = new_val - end - def resolves_lazies? lazy_method_count = 0 lazy_methods.each do |k, v| diff --git a/lib/graphql/schema/object.rb b/lib/graphql/schema/object.rb index b728299987..aed97e6a68 100644 --- a/lib/graphql/schema/object.rb +++ b/lib/graphql/schema/object.rb @@ -32,7 +32,8 @@ def dataloader # Call this in a field method to return a value that should be returned to the client # without any further handling by GraphQL. def raw_value(obj) - GraphQL::Execution::Interpreter::RawValue.new(obj) + rv = GraphQL::Execution::Interpreter::RawValue.new(obj) + context.query.add_finalizer(rv) end class << self diff --git a/spec/graphql/execution/interpreter_spec.rb b/spec/graphql/execution/interpreter_spec.rb index c534dcc811..9476c3d133 100644 --- a/spec/graphql/execution/interpreter_spec.rb +++ b/spec/graphql/execution/interpreter_spec.rb @@ -296,7 +296,6 @@ class Schema < GraphQL::Schema query(Query) mutation(Mutation) lazy_resolve(Box, :value) - uses_raw_value(true) use GraphQL::Schema::AlwaysVisible use(GraphQL::Execution::Next) if TESTING_EXEC_NEXT From d77333c0c09e5c940ffba5da87cb6eb3bf3bff44 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Wed, 1 Apr 2026 09:24:17 -0400 Subject: [PATCH 3/6] Update docs --- guides/execution/migration.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/guides/execution/migration.md b/guides/execution/migration.md index 498d365201..e90928a183 100644 --- a/guides/execution/migration.md +++ b/guides/execution/migration.md @@ -242,12 +242,13 @@ Resolver classes are called. ### `raw_value` 🟡 -Supported but requires a manual opt-in at schema level. Support for this will probably get better somehow in a future version. +Supported, but the `raw_value` call must be made on `context`, for example: ```ruby -class MyAppSchema < GraphQL::Schema - uses_raw_value(true) # TODO This configuration will be improved in a future GraphQL-Ruby version - use GraphQL::Execution::Next +field :values, SomeObjectType, resolve_static: true + +def self.values(context) + context.raw_value(...) end ``` From f5ae21ae898fd337039eae26c5167a156cce8e6c Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Fri, 3 Apr 2026 11:01:23 -0400 Subject: [PATCH 4/6] Support finalizers which don't appear in the result; add some directive hooks; add Interface.resolver_methods --- lib/graphql/execution.rb | 2 +- .../interpreter/handles_raw_value.rb | 2 +- lib/graphql/execution/next.rb | 2 +- .../execution/next/field_resolve_step.rb | 31 ++++++ lib/graphql/execution/next/runner.rb | 90 ++++++++++++---- lib/graphql/execution/next/selections_step.rb | 39 +++++-- lib/graphql/execution_error.rb | 2 +- lib/graphql/schema/directive.rb | 31 ++++-- lib/graphql/schema/interface.rb | 26 +++++ lib/graphql/unauthorized_error.rb | 2 +- spec/graphql/directive_spec.rb | 8 +- spec/graphql/schema/directive_spec.rb | 100 +++++++++++++++--- spec/support/dummy/schema.rb | 1 + 13 files changed, 281 insertions(+), 55 deletions(-) diff --git a/lib/graphql/execution.rb b/lib/graphql/execution.rb index d07159db2f..edc2410ac8 100644 --- a/lib/graphql/execution.rb +++ b/lib/graphql/execution.rb @@ -14,7 +14,7 @@ class Skip < GraphQL::RuntimeError attr_accessor :path def ast_nodes=(_ignored); end - def assign_graphql_result(query, result_data, key) + def finalize_graphql_result(query, result_data, key) result_data.delete(key) end end diff --git a/lib/graphql/execution/interpreter/handles_raw_value.rb b/lib/graphql/execution/interpreter/handles_raw_value.rb index 1656453709..c1cad5ed49 100644 --- a/lib/graphql/execution/interpreter/handles_raw_value.rb +++ b/lib/graphql/execution/interpreter/handles_raw_value.rb @@ -7,7 +7,7 @@ class Interpreter class RawValue include GraphQL::Execution::Next::Finalizer - def assign_graphql_result(query, result_data, result_key) + def finalize_graphql_result(query, result_data, result_key) result_data[result_key] = @object end diff --git a/lib/graphql/execution/next.rb b/lib/graphql/execution/next.rb index 9fdcc8a205..c3922e645f 100644 --- a/lib/graphql/execution/next.rb +++ b/lib/graphql/execution/next.rb @@ -70,7 +70,7 @@ def self.run_all(schema, query_options, context: {}, max_complexity: schema.max_ module Finalizer attr_accessor :path - def assign_graphql_result(query, result_data, result_key) + def finalize_graphql_result(query, result_data, result_key) raise RequiredImplementationMissingError end end diff --git a/lib/graphql/execution/next/field_resolve_step.rb b/lib/graphql/execution/next/field_resolve_step.rb index 3a23155f6a..66db105730 100644 --- a/lib/graphql/execution/next/field_resolve_step.rb +++ b/lib/graphql/execution/next/field_resolve_step.rb @@ -368,6 +368,37 @@ def execute_field end query.current_trace.begin_execute_field(@field_definition, @arguments, authorized_objects, query) + + if @runner.uses_runtime_directives + if @ast_nodes.nil? || @ast_nodes.size == 1 + directives = if @ast_node.directives.any? + @ast_node.directives + else + nil + end + else + directives = nil + @ast_nodes.each do |n| + if (d = n.directives).any? # rubocop:disable Development/NoneWithoutBlockCop + directives ||= [] + directives.concat(d) + end + end + end + + if directives + directives.each do |dir_node| + if (dir_defn = @runner.runtime_directives[dir_node.name]) + # Skip or include won't be present + result = dir_defn.resolve_field(ast_nodes, @parent_type, field_definition, authorized_objects, @arguments, ctx) + if result.is_a?(Finalizer) + result.path = path + end + end + end + end + end + has_extensions = @field_definition.extensions.size > 0 if has_extensions @extended = GraphQL::Schema::Field::ExtendedState.new(@arguments, authorized_objects) diff --git a/lib/graphql/execution/next/runner.rb b/lib/graphql/execution/next/runner.rb index 51197747b8..68e37dfec6 100644 --- a/lib/graphql/execution/next/runner.rb +++ b/lib/graphql/execution/next/runner.rb @@ -12,6 +12,22 @@ def initialize(multiplex, authorization:) @selected_operation = nil @dataloader = multiplex.context[:dataloader] ||= @schema.dataloader_class.new @resolves_lazies = @schema.resolves_lazies? + + @runtime_directives = nil + @schema.directives.each do |name, dir_class| + if dir_class.runtime? && name != "if" && name != "skip" + @runtime_directives ||= {} + @runtime_directives[dir_class.graphql_name] = dir_class + end + end + + if @runtime_directives.nil? + @uses_runtime_directives = false + @runtime_directives = EmptyObjects::EMPTY_HASH + else + @uses_runtime_directives = true + end + @lazy_cache = resolves_lazies ? {}.compare_by_identity : nil @authorization = authorization if @authorization @@ -21,6 +37,8 @@ def initialize(multiplex, authorization:) end end + attr_reader :runtime_directives, :uses_runtime_directives + def resolve_type(type, object, query) query.current_trace.begin_resolve_type(type, object, query.context) resolved_type, _ignored_new_value = query.resolve_type(type, object) @@ -184,7 +202,7 @@ def execute result else data = result["data"] - data = run_finalizers(data, query) + data = run_finalizers(data, query, finalizers) errors = [] query.context.errors.each do |err| if err.respond_to?(:to_h) @@ -207,9 +225,10 @@ def execute Fiber[:__graphql_current_multiplex] = nil end - def gather_selections(type_defn, ast_selections, selections_step, query, prototype_result, into:) + def gather_selections(type_defn, ast_selections, selections_step, query, all_selections, prototype_result, into:) ast_selections.each do |ast_selection| next if !directives_include?(query, ast_selection) + case ast_selection when GraphQL::Language::Nodes::Field key = ast_selection.alias || ast_selection.name @@ -227,13 +246,21 @@ def gather_selections(type_defn, ast_selections, selections_step, query, prototy when GraphQL::Language::Nodes::InlineFragment type_condition = ast_selection.type&.name if type_condition.nil? || type_condition_applies?(query.context, type_defn, type_condition) - gather_selections(type_defn, ast_selection.selections, selections_step, query, prototype_result, into: into) + if uses_runtime_directives && ast_selection.directives.any? + all_selections << (into = { __node: ast_selection }) + all_selections << (prototype_result = {}) + end + gather_selections(type_defn, ast_selection.selections, selections_step, query, all_selections, prototype_result, into: into) end when GraphQL::Language::Nodes::FragmentSpread - fragment_definition = query.document.definitions.find { |defn| defn.is_a?(GraphQL::Language::Nodes::FragmentDefinition) && defn.name == ast_selection.name } + fragment_definition = query.fragments[ast_selection.name] type_condition = fragment_definition.type.name if type_condition_applies?(query.context, type_defn, type_condition) - gather_selections(type_defn, fragment_definition.selections, selections_step, query, prototype_result, into: into) + if uses_runtime_directives && ast_selection.directives.any? + all_selections << (into = { __node: ast_selection }) + all_selections << (prototype_result = {}) + end + gather_selections(type_defn, fragment_definition.selections, selections_step, query, all_selections, prototype_result, into: into) end else raise ArgumentError, "Unsupported graphql selection node: #{ast_selection.class} (#{ast_selection.inspect})" @@ -252,8 +279,8 @@ def lazy?(object) private - def run_finalizers(data, query) - paths_to_check = query.finalizers.map(&:path) + def run_finalizers(data, query, finalizers) + paths_to_check = finalizers.map(&:path) paths_to_check.compact! # root-level auth errors currently come without a path # TODO dry with above? # This is also where a query-level "Step" would be used? @@ -266,11 +293,19 @@ def run_finalizers(data, query) when "subscription" query.schema.subscription end - check_object_result(query, data, root_type, selected_operation.selections, [], [], paths_to_check) + paths_to_check.each_with_index do |path_to_check, idx| + if path_to_check.empty? + finalizer = finalizers[idx] + # Path is already `[]` + finalizer.finalize_graphql_result(query, data, nil) + finalizers[idx] = nil + end + end + check_object_result(query, data, root_type, selected_operation.selections, [], [], paths_to_check, finalizers) end end - def check_object_result(query, result_h, static_type, ast_selections, current_exec_path, current_result_path, paths_to_check) + def check_object_result(query, result_h, static_type, ast_selections, current_exec_path, current_result_path, paths_to_check, finalizers) # rubocop:disable Metrics/ParameterLists current_path_len = current_exec_path.length ast_selections.each do |ast_selection| case ast_selection @@ -279,7 +314,15 @@ def check_object_result(query, result_h, static_type, ast_selections, current_ex key = ast_selection.alias || ast_selection.name current_exec_path << key current_result_path << key - if paths_to_check.any? { |path_to_check| path_to_check[current_path_len] == key } + this_finalizer_idx = nil + should_continue = false + paths_to_check.each_with_index do |path_to_check, idx| + if this_finalizer_idx.nil? && current_exec_path == path_to_check + this_finalizer_idx = idx + end + should_continue ||= path_to_check[current_path_len] == key + end + if should_continue result_value = result_h[key] field_defn = query.context.types.field(static_type, ast_selection.name) result_type = field_defn.type @@ -289,13 +332,20 @@ def check_object_result(query, result_h, static_type, ast_selections, current_ex new_result_value = if result_value.is_a?(Finalizer) result_value.path = current_result_path.dup - result_value.assign_graphql_result(query, result_h, key) + result_value.finalize_graphql_result(query, result_h, key) result_h.key?(key) ? result_h[key] : :unassigned + elsif this_finalizer_idx + if (finalizer = finalizers[this_finalizer_idx]) + finalizer.path = current_result_path.dup + finalizer.finalize_graphql_result(query, result_h, key) + finalizers[this_finalizer_idx] = nil + end + result_value else if result_type.list? - check_list_result(query, result_value, result_type.of_type, ast_selection.selections, current_exec_path, current_result_path, paths_to_check) + check_list_result(query, result_value, result_type.of_type, ast_selection.selections, current_exec_path, current_result_path, paths_to_check, finalizers) elsif !result_type.kind.leaf? - check_object_result(query, result_value, result_type, ast_selection.selections, current_exec_path, current_result_path, paths_to_check) + check_object_result(query, result_value, result_type, ast_selection.selections, current_exec_path, current_result_path, paths_to_check, finalizers) else result_value end @@ -315,14 +365,14 @@ def check_object_result(query, result_h, static_type, ast_selections, current_ex end when Language::Nodes::InlineFragment static_type_at_result = @static_type_at[result_h] - if static_type_at_result && type_condition_applies?(query.context, static_type_at_result, ast_selection.type.name) - result_h = check_object_result(query, result_h, static_type, ast_selection.selections, current_exec_path, current_result_path, paths_to_check) + if static_type_at_result && ((t = ast_selection.type).nil? || type_condition_applies?(query.context, static_type_at_result, t.name)) + result_h = check_object_result(query, result_h, static_type, ast_selection.selections, current_exec_path, current_result_path, paths_to_check, finalizers) end when Language::Nodes::FragmentSpread fragment_defn = query.document.definitions.find { |defn| defn.is_a?(Language::Nodes::FragmentDefinition) && defn.name == ast_selection.name } static_type_at_result = @static_type_at[result_h] if static_type_at_result && type_condition_applies?(query.context, static_type_at_result, fragment_defn.type.name) - result_h = check_object_result(query, result_h, static_type, fragment_defn.selections, current_exec_path, current_result_path, paths_to_check) + result_h = check_object_result(query, result_h, static_type, fragment_defn.selections, current_exec_path, current_result_path, paths_to_check, finalizers) end end end @@ -330,7 +380,7 @@ def check_object_result(query, result_h, static_type, ast_selections, current_ex result_h end - def check_list_result(query, result_arr, inner_type, ast_selections, current_exec_path, current_result_path, paths_to_check) + def check_list_result(query, result_arr, inner_type, ast_selections, current_exec_path, current_result_path, paths_to_check, finalizers) # rubocop:disable Metrics/ParameterLists inner_type_non_null = false if inner_type.non_null? inner_type_non_null = true @@ -342,12 +392,12 @@ def check_list_result(query, result_arr, inner_type, ast_selections, current_exe current_result_path << idx new_result = if result_item.is_a?(Finalizer) result_item.path = current_result_path.dup - result_item.assign_graphql_result(query, result_arr, idx) + result_item.finalize_graphql_result(query, result_arr, idx) result_arr[idx] elsif inner_type.list? - check_list_result(query, result_item, inner_type.of_type, ast_selections, current_exec_path, current_result_path, paths_to_check) + check_list_result(query, result_item, inner_type.of_type, ast_selections, current_exec_path, current_result_path, paths_to_check, finalizers) elsif !inner_type.kind.leaf? - check_object_result(query, result_item, inner_type, ast_selections, current_exec_path, current_result_path, paths_to_check) + check_object_result(query, result_item, inner_type, ast_selections, current_exec_path, current_result_path, paths_to_check, finalizers) else result_item end diff --git a/lib/graphql/execution/next/selections_step.rb b/lib/graphql/execution/next/selections_step.rb index 15b1bd813d..27b8d74055 100644 --- a/lib/graphql/execution/next/selections_step.rb +++ b/lib/graphql/execution/next/selections_step.rb @@ -23,12 +23,39 @@ def graphql_objects end def call - grouped_selections = {} - prototype_result = @results.first - @runner.gather_selections(@parent_type, @selections, self, self.query, prototype_result, into: grouped_selections) - @results.each { |r| r.replace(prototype_result) } - grouped_selections.each_value do |frs| - @runner.add_step(frs) + all_selections = [{}, {}] + @runner.gather_selections(@parent_type, @selections, self, self.query, all_selections, all_selections[1], into: all_selections[0]) + replaced = false + all_selections.each_slice(2) do |(grouped_selections, prototype_result)| + if !replaced + replaced = true + @results.each { |r| r.replace(prototype_result) } + else + # TODO -- this is here to keep response order the same. + # Should there only be a single prototype_result instead? + # Or should it not do this here? + @results.each { |r| r.merge!(prototype_result) } + end + if (directives_owner = grouped_selections.delete(:__node)) + directives = directives_owner.directives + directives.each do |dir_node| + dir_defn = @runner.runtime_directives[dir_node.name] + result = case directives_owner + when Language::Nodes::FragmentSpread + dir_defn.resolve_fragment_spread(directives_owner, @parent_type, @objects, self.query.context) + when Language::Nodes::InlineFragment + dir_defn.resolve_inline_fragment(directives_owner, @parent_type, @objects, self.query.context) + else + raise ArgumentError, "Unhandled directive owner (#{directives_owner.class}): #{directives_owner.inspect}" + end + if result.is_a?(Finalizer) + result.path = path + end + end + end + grouped_selections.each_value do |frs| + @runner.add_step(frs) + end end end end diff --git a/lib/graphql/execution_error.rb b/lib/graphql/execution_error.rb index 7b26dee5fd..dfb39ff932 100644 --- a/lib/graphql/execution_error.rb +++ b/lib/graphql/execution_error.rb @@ -36,7 +36,7 @@ def initialize(message, ast_node: nil, ast_nodes: nil, options: nil, extensions: super(message) end - def assign_graphql_result(query, result_data, key) + def finalize_graphql_result(query, result_data, key) result_data[key] = nil end diff --git a/lib/graphql/schema/directive.rb b/lib/graphql/schema/directive.rb index 5689de73b5..5802f86f0b 100644 --- a/lib/graphql/schema/directive.rb +++ b/lib/graphql/schema/directive.rb @@ -31,17 +31,25 @@ def default_graphql_name def locations(*new_locations) if !new_locations.empty? + is_runtime = false new_locations.each do |new_loc| - if !LOCATIONS.include?(new_loc.to_sym) + loc_sym = new_loc.to_sym + if !LOCATIONS.include?(loc_sym) raise ArgumentError, "#{self} (#{self.graphql_name}) has an invalid directive location: `locations #{new_loc}` " end + is_runtime ||= RUNTIME_LOCATIONS.include?(loc_sym) end @locations = new_locations + @is_runtime = is_runtime else @locations ||= (superclass.respond_to?(:locations) ? superclass.locations : []) end end + def runtime? + @is_runtime + end + def default_directive(new_default_directive = nil) if new_default_directive != nil @default_directive = new_default_directive @@ -106,6 +114,9 @@ def inherited(subclass) super subclass.class_exec do @default_graphql_name ||= nil + @locations = locations + @is_runtime = runtime? + @repeatable = false end end end @@ -177,13 +188,16 @@ def graphql_name end LOCATIONS = [ - QUERY = :QUERY, - MUTATION = :MUTATION, - SUBSCRIPTION = :SUBSCRIPTION, - FIELD = :FIELD, - FRAGMENT_DEFINITION = :FRAGMENT_DEFINITION, - FRAGMENT_SPREAD = :FRAGMENT_SPREAD, - INLINE_FRAGMENT = :INLINE_FRAGMENT, + *(RUNTIME_LOCATIONS = [ + QUERY = :QUERY, + MUTATION = :MUTATION, + SUBSCRIPTION = :SUBSCRIPTION, + FIELD = :FIELD, + FRAGMENT_DEFINITION = :FRAGMENT_DEFINITION, + FRAGMENT_SPREAD = :FRAGMENT_SPREAD, + INLINE_FRAGMENT = :INLINE_FRAGMENT, + VARIABLE_DEFINITION = :VARIABLE_DEFINITION, + ]), SCHEMA = :SCHEMA, SCALAR = :SCALAR, OBJECT = :OBJECT, @@ -195,7 +209,6 @@ def graphql_name ENUM_VALUE = :ENUM_VALUE, INPUT_OBJECT = :INPUT_OBJECT, INPUT_FIELD_DEFINITION = :INPUT_FIELD_DEFINITION, - VARIABLE_DEFINITION = :VARIABLE_DEFINITION, ] DEFAULT_DEPRECATION_REASON = 'No longer supported' diff --git a/lib/graphql/schema/interface.rb b/lib/graphql/schema/interface.rb index 160d51693a..1c11e1e0ac 100644 --- a/lib/graphql/schema/interface.rb +++ b/lib/graphql/schema/interface.rb @@ -33,6 +33,26 @@ def definition_methods(&block) self::DefinitionMethods.module_exec(&block) end + # Instance methods defined in this block will become class methods on objects that implement this interface. + # Use it to implement `resolve_each:`, `resolve_batch:`, and `resolve_static:` fields. + # @example + # field :thing, String, resolve_static: true + # + # resolver_methods do + # def thing + # Somehow.get.thing + # end + # end + def resolver_methods(&block) + if !defined?(@_resolver_methods) + resolver_methods_module = Module.new + @_resolver_methods = resolver_methods_module + const_set(:ResolverMethods, resolver_methods_module) + extend(self::ResolverMethods) + end + self::ResolverMethods.module_exec(&block) + end + # @see {Schema::Warden} hides interfaces without visible implementations def visible?(context) true @@ -79,6 +99,12 @@ def included(child_class) if !backtrace_line raise "Attach interfaces using `implements(#{self})`, not `include(#{self})`" end + + child_class.ancestors.reverse_each do |ancestor| + if ancestor.const_defined?(:ResolverMethods) + child_class.extend(ancestor::ResolverMethods) + end + end end super diff --git a/lib/graphql/unauthorized_error.rb b/lib/graphql/unauthorized_error.rb index 71ee783328..56cf79b2b4 100644 --- a/lib/graphql/unauthorized_error.rb +++ b/lib/graphql/unauthorized_error.rb @@ -30,7 +30,7 @@ def initialize(message = nil, object: nil, type: nil, context: nil) attr_accessor :path, :ast_nodes - def assign_graphql_result(query, result_data, key) + def finalize_graphql_result(query, result_data, key) result_data[key] = nil end end diff --git a/spec/graphql/directive_spec.rb b/spec/graphql/directive_spec.rb index 8f1b78e3ab..f12831a5b3 100644 --- a/spec/graphql/directive_spec.rb +++ b/spec/graphql/directive_spec.rb @@ -3,7 +3,13 @@ describe "GraphQL::Directive" do let(:variables) { {"t" => true, "f" => false} } - let(:result) { Dummy::Schema.execute(query_string, variables: variables) } + let(:result) { + if TESTING_EXEC_NEXT + Dummy::Schema.execute_next(query_string, variables: variables) + else + Dummy::Schema.execute(query_string, variables: variables) + end + } describe "on fields" do let(:query_string) { %|query directives($t: Boolean!, $f: Boolean!) { cheese(id: 1) { diff --git a/spec/graphql/schema/directive_spec.rb b/spec/graphql/schema/directive_spec.rb index 9957332545..500a24ce9c 100644 --- a/spec/graphql/schema/directive_spec.rb +++ b/spec/graphql/schema/directive_spec.rb @@ -141,35 +141,98 @@ def self.resolve(obj, args, ctx) ctx[:count_fields][path] << field_count nil # this does nothing end + + class FieldCount + include GraphQL::Execution::Next::Finalizer + + def initialize(ast_node) + @ast_node = ast_node + end + + def finalize_graphql_result(query, result_data, result_key) + counts = query.context[:count_fields] ||= Hash.new { |h, k| h[k] = [] } + count = case @ast_node + when GraphQL::Language::Nodes::Field + 1 + when GraphQL::Language::Nodes::InlineFragment + @ast_node.selections.size + when GraphQL::Language::Nodes::FragmentSpread + frag = query.fragments[@ast_node.name] + frag.selections.size + else + raise ArgumentError, "Unexpected ast_node: #{ast_node.inspect}" + end + counts[path] << count + end + end + + def self.resolve_field(ast_nodes, parent_type, field_defn, objects, arguments, context) + context.query.add_finalizer(FieldCount.new(ast_nodes.first)) + end + + def self.resolve_inline_fragment(ast_node, parent_type, objects, context) + context.query.add_finalizer(FieldCount.new(ast_node)) + end + + def self.resolve_fragment_spread(ast_node, parent_type, objects, context) + context.query.add_finalizer(FieldCount.new(ast_node)) + end end class Thing < GraphQL::Schema::Object - field :name, String, null: false + field :name, String, null: false, hash_key: :name end module HasThings include GraphQL::Schema::Interface - field :thing, Thing, null: false, extras: [:ast_node] + field :thing, Thing, null: false, extras: [:ast_node], resolve_static: true def thing(ast_node:) - context[:name_resolved_count] ||= 0 - context[:name_resolved_count] += 1 - { name: ast_node.alias || ast_node.name } + self.class.thing(ast_node: ast_node) + end + + resolver_methods do + def thing(context, ast_node:) + context[:name_resolved_count] ||= 0 + context[:name_resolved_count] += 1 + { name: ast_node.alias || ast_node.name } + end end - field :lazy_thing, Thing, null: false, extras: [:ast_node] + field :lazy_thing, Thing, null: false, extras: [:ast_node], resolve_static: true + def lazy_thing(ast_node:) - -> { thing(ast_node: ast_node) } + self.class.lazy_thing(context, ast_node: ast_node) + end + + resolver_methods do + def lazy_thing(context, ast_node:) + -> { self.thing(context, ast_node: ast_node) } + end end - field :dataloaded_thing, Thing, null: false, extras: [:ast_node] + field :dataloaded_thing, Thing, null: false, extras: [:ast_node], resolve_static: true + def dataloaded_thing(ast_node:) - dataloader.with(ThingSource).load(ast_node.alias || ast_node.name) + self.class.dataloaded_thing(context, ast_node:) + end + + resolver_methods do + def dataloaded_thing(context, ast_node:) + context.dataload(ThingSource, ast_node.alias || ast_node.name) + end end - field :lazy_things, [Thing], extras: [:ast_node] + field :lazy_things, [Thing], extras: [:ast_node], resolve_static: true + def lazy_things(ast_node:) - -> { [thing(ast_node: ast_node), thing(ast_node: ast_node)]} + self.class.lazy_things(context, ast_node: ast_node) + end + + resolver_methods do + def lazy_things(context, ast_node:) + -> { [self.thing(context, ast_node: ast_node), self.thing(context, ast_node: ast_node)]} + end end end @@ -190,6 +253,15 @@ class Schema < GraphQL::Schema directive(CountFields) lazy_resolve(Proc, :call) use GraphQL::Dataloader + use GraphQL::Execution::Next + end + end + + def exec_query(...) + if TESTING_EXEC_NEXT + RuntimeDirectiveTest::Schema.execute_next(...) + else + RuntimeDirectiveTest::Schema.execute(...) end end @@ -226,7 +298,7 @@ class Schema < GraphQL::Schema } GRAPHQL - res = RuntimeDirectiveTest::Schema.execute(query_str) + res = exec_query(query_str) expected_data = { "t1" => { "t1n" => "t1", @@ -268,7 +340,7 @@ class Schema < GraphQL::Schema } } GRAPHQL - res = RuntimeDirectiveTest::Schema.execute(query_str) + res = exec_query(query_str) expected_data = { "t1" => { "name" => "t1"}, "t2" => { "name" => "t2" }, "t3" => { "name" => "t3" } } assert_graphql_equal expected_data, res["data"] @@ -288,7 +360,7 @@ class Schema < GraphQL::Schema } } " - res = RuntimeDirectiveTest::Schema.execute(query_str, context: { backtrace: true }) + res = exec_query(query_str, context: { backtrace: true }) assert_equal 2, res["data"]["lazyThings"].size end end diff --git a/spec/support/dummy/schema.rb b/spec/support/dummy/schema.rb index 0fbcb6c7f1..de083b2e85 100644 --- a/spec/support/dummy/schema.rb +++ b/spec/support/dummy/schema.rb @@ -550,6 +550,7 @@ class Schema < GraphQL::Schema orphan_types Honey trace_with GraphQL::Tracing::CallLegacyTracers directives(DirectiveForVariableDefinition) + use GraphQL::Execution::Next rescue_from(NoSuchDairyError) { |err| raise GraphQL::ExecutionError, err.message } From ed0fb5e475a08b0ad4a65c10a2bea94b75394d2b Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Tue, 7 Apr 2026 15:11:05 -0400 Subject: [PATCH 5/6] Improve directives, make optimizations --- guides/_plugins/api_doc.rb | 3 + guides/execution/migration.md | 16 +- lib/graphql/analysis.rb | 34 ++- lib/graphql/execution/next.rb | 4 + .../execution/next/field_resolve_step.rb | 53 ++-- lib/graphql/execution/next/runner.rb | 237 ++++++++++-------- lib/graphql/execution/next/selections_step.rb | 75 ++++-- lib/graphql/query.rb | 46 ++-- lib/graphql/query/partial.rb | 21 +- lib/graphql/schema.rb | 14 +- lib/graphql/schema/directive.rb | 5 +- lib/graphql/schema/list.rb | 4 + spec/graphql/query/partial_spec.rb | 92 +++++-- spec/graphql/schema/directive_spec.rb | 17 +- 14 files changed, 399 insertions(+), 222 deletions(-) diff --git a/guides/_plugins/api_doc.rb b/guides/_plugins/api_doc.rb index ab927cafe8..8a66547956 100644 --- a/guides/_plugins/api_doc.rb +++ b/guides/_plugins/api_doc.rb @@ -131,9 +131,12 @@ def render(context) end text = h.gsub(/^#+ /, "") target = text.downcase + .gsub("🟡", "00emoji00") + .gsub("❌", "00emoji00") .gsub(/[^a-z0-9_]+/, "-") .sub(/-$/, "") .sub(/^-/, "") + .gsub("-00emoji00", "-") rendered_text = Kramdown::Document.new(text, auto_ids: false) .to_html diff --git a/guides/execution/migration.md b/guides/execution/migration.md index e90928a183..fef35c5d76 100644 --- a/guides/execution/migration.md +++ b/guides/execution/migration.md @@ -195,9 +195,23 @@ This is not supported because the new runtime doesn't actually produce `current_ It is theoretically possible to support this but it will be a ton of work. If you use this for core runtime functions, please share your use case in a GitHub issue and we can investigate future options. +### Scoped context ❌ + +This is currently implemented with `current_path`. Another implementation is probably possible but not implemented yet. Please open an issue to discuss. + ### `@defer` and `@stream` ❌ -This depends on `current_path` so isn't possible yet. +`@defer` is supported with an implementation difference that _probably_ doesn't affect your application: previously, `@defer` worked by pausing and resuming the _same `GraphQL::Query` instance_. However, with `Execution::Next`, `@defer` takes a different approach. Instead, when a `GraphQL::Query` encounters `@defer`, it notes the location in the document and stops executing that branch. Later, when you request the deferred result, that branch of the query is resumed using a new instance of `GraphQL::Query::Partial`. + +This might matter if you're modifying `context` at runtime because those new instances _also_ have fresh `Query::Context` instances. The original query context _will_ get copied into the `@defer` branches using `Query::Context.new(**original_query.context.to_h)`, so any custom values will be available. But if you _assign new keys_ after the context is copied, those keys won't appear when running later `@defer`ed branches. + +To handle this, you can refactor how you accumulate data during execution. Instead of `||=`'ing into `context[...]` during execution, assign a new accumulator object _before_ starting the query, then call methods on that object to make any necessary state changes. That new object _will_ be copied into `@defer` partials, and since the object is shared between the different branches, any necessary state changes will still be "seen" everywhere. + +If this gives you trouble, please feel free to email me or open an issue on GitHub to discuss a migration strategy. + +##### GraphQL-Batch support + +When using `Execution::Next`, no custom code is required to support `graphql-batch`. ### ObjectCache ❌ diff --git a/lib/graphql/analysis.rb b/lib/graphql/analysis.rb index b90ebf8e25..6dffc35fb2 100644 --- a/lib/graphql/analysis.rb +++ b/lib/graphql/analysis.rb @@ -40,13 +40,13 @@ def analyze_multiplex(multiplex, analyzers) end end - multiplex_results = multiplex_analyzers.map(&:result) - multiplex_errors = analysis_errors(multiplex_results) + multiplex_analyzers.map!(&:result) + multiplex_errors = analysis_errors(EmptyObjects::EMPTY_ARRAY, multiplex_analyzers) multiplex.queries.each_with_index do |query, idx| - query.analysis_errors = multiplex_errors + analysis_errors(query_results[idx]) + query.analysis_errors = analysis_errors(multiplex_errors, query_results[idx]) end - multiplex_results + multiplex_analyzers end end @@ -55,11 +55,12 @@ def analyze_multiplex(multiplex, analyzers) # @return [Array] Results from those analyzers def analyze_query(query, analyzers, multiplex_analyzers: []) query.current_trace.analyze_query(query: query) do - query_analyzers = analyzers - .map { |analyzer| analyzer.new(query) } - .tap { _1.select!(&:analyze?) } + query_analyzers = analyzers.map { |analyzer| analyzer.new(query) } + + query_analyzers.select!(&:analyze?) + + analyzers_to_run = multiplex_analyzers.empty? ? query_analyzers : query_analyzers + multiplex_analyzers - analyzers_to_run = query_analyzers + multiplex_analyzers if !analyzers_to_run.empty? analyzers_to_run.select!(&:visit?) @@ -79,18 +80,27 @@ def analyze_query(query, analyzers, multiplex_analyzers: []) query_analyzers.map(&:result) else - [] + EmptyObjects::EMPTY_ARRAY end end rescue TimeoutError => err [err] rescue GraphQL::UnauthorizedError, GraphQL::ExecutionError # This error was raised during analysis and will be returned the client before execution - [] + EmptyObjects::EMPTY_ARRAY end - def analysis_errors(results) - results.flatten.tap { _1.select! { |r| r.is_a?(GraphQL::AnalysisError) } } + def analysis_errors(parent_errors, results) + if !results.empty? + results.flatten! + results.select! { |r| r.is_a?(GraphQL::AnalysisError) } + end + + if parent_errors.empty? + results + else + parent_errors + results + end end end end diff --git a/lib/graphql/execution/next.rb b/lib/graphql/execution/next.rb index c3922e645f..c0ed0ea8be 100644 --- a/lib/graphql/execution/next.rb +++ b/lib/graphql/execution/next.rb @@ -73,6 +73,10 @@ module Finalizer def finalize_graphql_result(query, result_data, result_key) raise RequiredImplementationMissingError end + + def continue_execution? + true + end end end end diff --git a/lib/graphql/execution/next/field_resolve_step.rb b/lib/graphql/execution/next/field_resolve_step.rb index 66db105730..2ebd3ad420 100644 --- a/lib/graphql/execution/next/field_resolve_step.rb +++ b/lib/graphql/execution/next/field_resolve_step.rb @@ -279,7 +279,7 @@ def self.[](_key) def build_arguments query = @selections_step.query field_name = @ast_node.name - @field_definition = query.get_field(@parent_type, field_name) || raise("Invariant: no field found for #{@parent_type.to_type_signature}.#{ast_node.name}") + @field_definition = query.types.field(@parent_type, field_name) || raise("Invariant: no field found for #{@parent_type.to_type_signature}.#{ast_node.name}") arguments = coerce_arguments(@field_definition, @ast_node.arguments) # rubocop:disable Development/ContextIsPassedCop @arguments ||= arguments # may have already been set to an error @@ -389,10 +389,16 @@ def execute_field if directives directives.each do |dir_node| if (dir_defn = @runner.runtime_directives[dir_node.name]) - # Skip or include won't be present - result = dir_defn.resolve_field(ast_nodes, @parent_type, field_definition, authorized_objects, @arguments, ctx) + # TODO: `coerce_arguments` modifies self, assuming it's field arguments. Extract to pure function for use + # here and with fragments. + dir_args = coerce_arguments(dir_defn, dir_node.arguments, false) + result = dir_defn.resolve_field(ast_nodes, @parent_type, field_definition, authorized_objects, dir_args, ctx) if result.is_a?(Finalizer) result.path = path + query.add_finalizer(result, true) + if !result.continue_execution? + return + end end end end @@ -468,7 +474,7 @@ def finish_extensions if @was_scoped.nil? if (rt = @field_definition.type.unwrap).respond_to?(:scope_items) @was_scoped = true - @field_results = @field_results.map { |v| v.nil? ? v : rt.scope_items(v, ctx) } + @field_results.map! { |v| v.nil? ? v : rt.scope_items(v, ctx) } else @was_scoped = false end @@ -556,6 +562,7 @@ def build_results add_graphql_error(field_result) else field_result.path = path + ctx.query.add_finalizer(finalizer) end else # TODO `nil`s in [T!] types aren't handled @@ -636,6 +643,7 @@ def build_graphql_result(graphql_result, key, field_result, return_type, is_nn, add_graphql_error(field_result) else field_result.path = path + @selections_step.query.add_finalizer(field_result) field_result end elsif is_list @@ -673,10 +681,11 @@ def build_graphql_result(graphql_result, key, field_result, return_type, is_nn, ps << obj_step @runner.add_step(obj_step) else - next_result_h = {} + next_result_h = {}.compare_by_identity @all_next_results << next_result_h @all_next_objects << field_result - @runner.static_type_at[next_result_h] = @static_type + st = @runner.static_type_at + st[next_result_h] = @static_type graphql_result[key] = next_result_h end end @@ -691,7 +700,11 @@ def resolve_batch(objects, context, args_hash) Array.new(objects.size, exec_err) end when :resolve_static - result = method_receiver.public_send(@field_definition.execution_next_mode_key, context, **args_hash) + result = begin + method_receiver.public_send(@field_definition.execution_next_mode_key, context, **args_hash) + rescue GraphQL::ExecutionError => err + err + end Array.new(objects.size, result) when :resolve_each objects.map do |o| @@ -700,22 +713,20 @@ def resolve_batch(objects, context, args_hash) err end when :hash_key - objects.map { |o| o[@field_definition.execution_next_mode_key] } + k = @field_definition.execution_next_mode_key + objects.map { |o| o[k] } when :direct_send - if args_hash.empty? - objects.map do |o| - o.public_send(@field_definition.execution_next_mode_key) - rescue GraphQL::ExecutionError => err - err - rescue StandardError => stderr - begin - @selections_step.query.handle_or_reraise(stderr) - rescue GraphQL::ExecutionError => ex_err - ex_err - end + m = @field_definition.execution_next_mode_key + objects.map do |o| + o.public_send(m, **args_hash) + rescue GraphQL::ExecutionError => err + err + rescue StandardError => stderr + begin + @selections_step.query.handle_or_reraise(stderr) + rescue GraphQL::ExecutionError => ex_err + ex_err end - else - objects.map { |o| o.public_send(@field_definition.execution_next_mode_key, **args_hash) } end when :dig objects.map { |o| o.dig(*@field_definition.execution_next_mode_key) } diff --git a/lib/graphql/execution/next/runner.rb b/lib/graphql/execution/next/runner.rb index 68e37dfec6..8dd44aef20 100644 --- a/lib/graphql/execution/next/runner.rb +++ b/lib/graphql/execution/next/runner.rb @@ -15,7 +15,7 @@ def initialize(multiplex, authorization:) @runtime_directives = nil @schema.directives.each do |name, dir_class| - if dir_class.runtime? && name != "if" && name != "skip" + if dir_class.runtime? && name != "include" && name != "skip" @runtime_directives ||= {} @runtime_directives[dir_class.graphql_name] = dir_class end @@ -86,91 +86,103 @@ def execute next end - selected_operation = query.document.definitions.first # TODO select named operation + selected_operation = query.selected_operation + root_type = query.root_type data = {} - root_type = case selected_operation.operation_type - when nil, "query" - @schema.query - when "mutation" - @schema.mutation - when "subscription" - @schema.subscription - else - raise ArgumentError, "Unknown operation type: #{selected_operation.operation_type.inspect}" - end + beginning_path = EmptyObjects::EMPTY_ARRAY - if self.authorization && authorizes?(root_type, query.context) - query.current_trace.begin_authorized(root_type, query.root_value, query.context) - auth_check = schema.sync_lazy(root_type.authorized?(query.root_value, query.context)) - query.current_trace.end_authorized(root_type, query.root_value, query.context, auth_check) - root_value = if auth_check - query.root_value - else - begin - auth_err = GraphQL::UnauthorizedError.new(object: query.root_value, type: root_type, context: query.context) - new_val = schema.unauthorized_object(auth_err) - if new_val - auth_check = true + case root_type.kind.name + when "OBJECT" + if self.authorization && authorizes?(root_type, query.context) + query.current_trace.begin_authorized(root_type, query.root_value, query.context) + auth_check = schema.sync_lazy(root_type.authorized?(query.root_value, query.context)) + query.current_trace.end_authorized(root_type, query.root_value, query.context, auth_check) + root_value = if auth_check + query.root_value + else + begin + auth_err = GraphQL::UnauthorizedError.new(object: query.root_value, type: root_type, context: query.context) + new_val = schema.unauthorized_object(auth_err) + if new_val + auth_check = true + end + new_val + rescue GraphQL::ExecutionError => ex_err + # The old runtime didn't add path and ast_nodes to this + query.context.add_error(ex_err) + nil end - new_val - rescue GraphQL::ExecutionError => ex_err - # The old runtime didn't add path and ast_nodes to this - query.context.add_error(ex_err) - nil end - end - if !auth_check - results << {} - next + if !auth_check + results << {} + next + end + else + root_value = query.root_value end - else - root_value = query.root_value - end - results << { "data" => data } + results << { "data" => data } - case selected_operation.operation_type - when nil, "query" - isolated_steps[0] << SelectionsStep.new( - parent_type: root_type, - selections: selected_operation.selections, - objects: [root_value], - results: [data], - path: EmptyObjects::EMPTY_ARRAY, - runner: self, - query: query, - ) - when "mutation" - fields = {} - gather_selections(root_type, selected_operation.selections, nil, query, {}, into: fields) - fields.each_value do |field_resolve_step| - isolated_steps << [SelectionsStep.new( + if query.query? + isolated_steps[0] << SelectionsStep.new( parent_type: root_type, - selections: field_resolve_step.ast_nodes || Array(field_resolve_step.ast_node), + selections: selected_operation.selections, objects: [root_value], results: [data], - path: EmptyObjects::EMPTY_ARRAY, + path: beginning_path, runner: self, query: query, - )] + ) + elsif query.mutation? + fields = {} + all_selections = [fields, (prototype_result = {})] + gather_selections(root_type, selected_operation.selections, nil, query, all_selections, prototype_result, into: fields) + if all_selections.length > 2 + # TODO DRY with SelectionsStep with directive handling + raise "Directives on root mutation type not implemented yet" + end + fields.each_value do |field_resolve_step| + isolated_steps << [SelectionsStep.new( + clobber: false, # `data` is being shared among several selections steps + parent_type: root_type, + selections: field_resolve_step.ast_nodes || Array(field_resolve_step.ast_node), + objects: [root_value], + results: [data], + path: beginning_path, + runner: self, + query: query, + )] + end + elsif query.subscription? + if !query.subscription_update? + schema.subscriptions.initialize_subscriptions(query) + end + isolated_steps[0] << SelectionsStep.new( + parent_type: root_type, + selections: selected_operation.selections, + objects: [root_value], + results: [data], + path: beginning_path, + runner: self, + query: query, + ) + else + raise ArgumentError, "Unknown operation type (not query, mutation or subscription): #{query.query_string}" end - when "subscription" - if !query.subscription_update? - schema.subscriptions.initialize_subscriptions(query) + when "LIST" + inner_type = root_type.unwrap + case inner_type.kind.name + when "SCALAR", "ENUM" + results << run_isolated_scalar(root_type, query.root_value, query.context) + else + raise "Not implemented list type: #{root_type.to_type_signature}" end - isolated_steps[0] << SelectionsStep.new( - parent_type: root_type, - selections: selected_operation.selections, - objects: [root_value], - results: [data], - path: EmptyObjects::EMPTY_ARRAY, - runner: self, - query: query, - ) + when "SCALAR", "ENUM" + results << run_isolated_scalar(root_type, query.root_value, query.context) else - raise ArgumentError, "Unhandled operation type: #{operation.operation_type.inspect}" + raise "Unhandled root type kind: #{root_type.kind.name.inspect}" end @static_type_at[data] = root_type @@ -197,12 +209,11 @@ def execute @schema.subscriptions.finish_subscriptions(query) end - finalizers = query.finalizers - fin_result = if finalizers.empty? + fin_result = if !query.finalizers? result else data = result["data"] - data = run_finalizers(data, query, finalizers) + data = run_finalizers(data, query) errors = [] query.context.errors.each do |err| if err.respond_to?(:to_h) @@ -279,33 +290,32 @@ def lazy?(object) private - def run_finalizers(data, query, finalizers) - paths_to_check = finalizers.map(&:path) - paths_to_check.compact! # root-level auth errors currently come without a path + def run_finalizers(data, query) + query_finalizers = query.top_level_finalizers + field_finalizers = query.field_finalizers + field_paths_to_check = field_finalizers.map(&:path) + query_paths_to_check = query_finalizers.map(&:path) + + # root-level auth errors currently come without a path + field_paths_to_check.compact! + query_paths_to_check.compact! + # TODO dry with above? # This is also where a query-level "Step" would be used? if (selected_operation = query.selected_operation) - root_type = case selected_operation.operation_type - when nil, "query" - query.schema.query - when "mutation" - query.schema.mutation - when "subscription" - query.schema.subscription - end - paths_to_check.each_with_index do |path_to_check, idx| + root_type = query.root_type + query_paths_to_check.each_with_index do |path_to_check, idx| if path_to_check.empty? - finalizer = finalizers[idx] + finalizer = query_finalizers[idx] # Path is already `[]` finalizer.finalize_graphql_result(query, data, nil) - finalizers[idx] = nil end end - check_object_result(query, data, root_type, selected_operation.selections, [], [], paths_to_check, finalizers) + check_object_result(query, data, root_type, selected_operation.selections, [], [], field_paths_to_check, field_finalizers, query_paths_to_check, query_finalizers) end end - def check_object_result(query, result_h, static_type, ast_selections, current_exec_path, current_result_path, paths_to_check, finalizers) # rubocop:disable Metrics/ParameterLists + def check_object_result(query, result_h, static_type, ast_selections, current_exec_path, current_result_path, field_paths_to_check, field_finalizers, query_paths_to_check, query_finalizers) # rubocop:disable Metrics/ParameterLists current_path_len = current_exec_path.length ast_selections.each do |ast_selection| case ast_selection @@ -314,14 +324,8 @@ def check_object_result(query, result_h, static_type, ast_selections, current_ex key = ast_selection.alias || ast_selection.name current_exec_path << key current_result_path << key - this_finalizer_idx = nil - should_continue = false - paths_to_check.each_with_index do |path_to_check, idx| - if this_finalizer_idx.nil? && current_exec_path == path_to_check - this_finalizer_idx = idx - end - should_continue ||= path_to_check[current_path_len] == key - end + should_continue = field_paths_to_check.any? { |ptc| ptc[current_path_len] == key } || query_paths_to_check.any? { |ptc| ptc[current_path_len] == key } + if should_continue result_value = result_h[key] field_defn = query.context.types.field(static_type, ast_selection.name) @@ -330,24 +334,30 @@ def check_object_result(query, result_h, static_type, ast_selections, current_ex result_type = result_type.of_type end + query_paths_to_check.each_with_index do |path_to_check, idx| + if current_exec_path == path_to_check + finalizer = query_finalizers.delete_at(idx) + query_paths_to_check.delete_at(idx) + finalizer.path = current_result_path.dup + finalizer.finalize_graphql_result(query, result_h, key) + result_value = result_h.key?(key) ? result_h[key] : :unassigned + end + end + + new_result_value = if result_value.is_a?(Finalizer) result_value.path = current_result_path.dup result_value.finalize_graphql_result(query, result_h, key) result_h.key?(key) ? result_h[key] : :unassigned - elsif this_finalizer_idx - if (finalizer = finalizers[this_finalizer_idx]) - finalizer.path = current_result_path.dup - finalizer.finalize_graphql_result(query, result_h, key) - finalizers[this_finalizer_idx] = nil - end + elsif :unassigned.equal?(result_value) result_value else if result_type.list? - check_list_result(query, result_value, result_type.of_type, ast_selection.selections, current_exec_path, current_result_path, paths_to_check, finalizers) + check_list_result(query, result_value, result_type.of_type, ast_selection.selections, current_exec_path, current_result_path, field_paths_to_check, field_finalizers, query_paths_to_check, query_finalizers) elsif !result_type.kind.leaf? - check_object_result(query, result_value, result_type, ast_selection.selections, current_exec_path, current_result_path, paths_to_check, finalizers) + check_object_result(query, result_value, result_type, ast_selection.selections, current_exec_path, current_result_path, field_paths_to_check, field_finalizers, query_paths_to_check, query_finalizers) else - result_value + new_result_value || result_value end end @@ -366,13 +376,13 @@ def check_object_result(query, result_h, static_type, ast_selections, current_ex when Language::Nodes::InlineFragment static_type_at_result = @static_type_at[result_h] if static_type_at_result && ((t = ast_selection.type).nil? || type_condition_applies?(query.context, static_type_at_result, t.name)) - result_h = check_object_result(query, result_h, static_type, ast_selection.selections, current_exec_path, current_result_path, paths_to_check, finalizers) + result_h = check_object_result(query, result_h, static_type, ast_selection.selections, current_exec_path, current_result_path, field_paths_to_check, field_finalizers, query_paths_to_check, query_finalizers) end when Language::Nodes::FragmentSpread fragment_defn = query.document.definitions.find { |defn| defn.is_a?(Language::Nodes::FragmentDefinition) && defn.name == ast_selection.name } static_type_at_result = @static_type_at[result_h] if static_type_at_result && type_condition_applies?(query.context, static_type_at_result, fragment_defn.type.name) - result_h = check_object_result(query, result_h, static_type, fragment_defn.selections, current_exec_path, current_result_path, paths_to_check, finalizers) + result_h = check_object_result(query, result_h, static_type, fragment_defn.selections, current_exec_path, current_result_path, field_paths_to_check, field_finalizers, query_paths_to_check, query_finalizers) end end end @@ -380,7 +390,7 @@ def check_object_result(query, result_h, static_type, ast_selections, current_ex result_h end - def check_list_result(query, result_arr, inner_type, ast_selections, current_exec_path, current_result_path, paths_to_check, finalizers) # rubocop:disable Metrics/ParameterLists + def check_list_result(query, result_arr, inner_type, ast_selections, current_exec_path, current_result_path, field_paths_to_check, field_finalizers, query_paths_to_check, query_finalizers) # rubocop:disable Metrics/ParameterLists inner_type_non_null = false if inner_type.non_null? inner_type_non_null = true @@ -395,9 +405,9 @@ def check_list_result(query, result_arr, inner_type, ast_selections, current_exe result_item.finalize_graphql_result(query, result_arr, idx) result_arr[idx] elsif inner_type.list? - check_list_result(query, result_item, inner_type.of_type, ast_selections, current_exec_path, current_result_path, paths_to_check, finalizers) + check_list_result(query, result_item, inner_type.of_type, ast_selections, current_exec_path, current_result_path, field_paths_to_check, field_finalizers, query_paths_to_check, query_finalizers) elsif !inner_type.kind.leaf? - check_object_result(query, result_item, inner_type, ast_selections, current_exec_path, current_result_path, paths_to_check, finalizers) + check_object_result(query, result_item, inner_type, ast_selections, current_exec_path, current_result_path, field_paths_to_check, field_finalizers, query_paths_to_check, query_finalizers) else result_item end @@ -455,6 +465,13 @@ def type_condition_applies?(context, concrete_type, type_name) p_types.any? { |t| c_p_types.include?(t) } end end + + def run_isolated_scalar(type, value, context) + if lazy?(value) + value = @schema.sync_lazy(value) + end + { "data" => type.coerce_result(value, context) } + end end end end diff --git a/lib/graphql/execution/next/selections_step.rb b/lib/graphql/execution/next/selections_step.rb index 27b8d74055..97531f669a 100644 --- a/lib/graphql/execution/next/selections_step.rb +++ b/lib/graphql/execution/next/selections_step.rb @@ -3,7 +3,7 @@ module GraphQL module Execution module Next class SelectionsStep - def initialize(parent_type:, selections:, objects:, results:, runner:, query:, path:) + def initialize(parent_type:, selections:, objects:, results:, runner:, query:, path:, clobber: true) @path = path @parent_type = parent_type @selections = selections @@ -12,6 +12,8 @@ def initialize(parent_type:, selections:, objects:, results:, runner:, query:, p @results = results @query = query @graphql_objects = nil + @all_selections = nil + @clobber = clobber end attr_reader :path, :query, :objects, :results @@ -23,39 +25,64 @@ def graphql_objects end def call - all_selections = [{}, {}] - @runner.gather_selections(@parent_type, @selections, self, self.query, all_selections, all_selections[1], into: all_selections[0]) - replaced = false - all_selections.each_slice(2) do |(grouped_selections, prototype_result)| - if !replaced - replaced = true - @results.each { |r| r.replace(prototype_result) } - else - # TODO -- this is here to keep response order the same. - # Should there only be a single prototype_result instead? - # Or should it not do this here? - @results.each { |r| r.merge!(prototype_result) } - end + @all_selections = [{}, (prototype_result = {})] + @runner.gather_selections(@parent_type, @selections, self, self.query, @all_selections, @all_selections[1], into: @all_selections[0]) + continue_selections = [] + i = 0 + l = @all_selections.length + while i < l + grouped_selections = @all_selections[i] + selections_prototype_result = @all_selections[i + 1] if (directives_owner = grouped_selections.delete(:__node)) directives = directives_owner.directives directives.each do |dir_node| dir_defn = @runner.runtime_directives[dir_node.name] - result = case directives_owner - when Language::Nodes::FragmentSpread - dir_defn.resolve_fragment_spread(directives_owner, @parent_type, @objects, self.query.context) - when Language::Nodes::InlineFragment - dir_defn.resolve_inline_fragment(directives_owner, @parent_type, @objects, self.query.context) + if dir_defn # not present for `skip` or `include` + dummy_frs = FieldResolveStep.new( + selections_step: self, + key: nil, + parent_type: @parent_type, + runner: @runner, + ) + dir_args = dummy_frs.coerce_arguments(dir_defn, dir_node.arguments, false) + result = case directives_owner + when Language::Nodes::FragmentSpread + dir_defn.resolve_fragment_spread(directives_owner, @parent_type, @objects, dir_args, self.query.context) + when Language::Nodes::InlineFragment + dir_defn.resolve_inline_fragment(directives_owner, @parent_type, @objects, dir_args, self.query.context) + else + raise ArgumentError, "Unhandled directive owner (#{directives_owner.class}): #{directives_owner.inspect}" + end + if result.is_a?(Finalizer) + result.path = path + query.add_finalizer(result, true) + if result.continue_execution? + prototype_result.merge!(selections_prototype_result) + grouped_selections.each_value { |v| continue_selections << v } + end + end else - raise ArgumentError, "Unhandled directive owner (#{directives_owner.class}): #{directives_owner.inspect}" - end - if result.is_a?(Finalizer) - result.path = path + grouped_selections.each_value { |v| continue_selections << v } end end + else + grouped_selections.each_value { |v| continue_selections << v } end - grouped_selections.each_value do |frs| + + if @clobber + i2 = 0 + l2 = @results.length + while i2 < l2 + @results[i2].replace(prototype_result) + i2 += 1 + end + end + + continue_selections.each do |frs| @runner.add_step(frs) end + + i += 2 end end end diff --git a/lib/graphql/query.rb b/lib/graphql/query.rb index 3ad0d67eb0..dff304f992 100644 --- a/lib/graphql/query.rb +++ b/lib/graphql/query.rb @@ -48,6 +48,28 @@ def arguments_cache def handle_or_reraise(err) @schema.handle_or_reraise(context, err) end + + def finalizers? + @finalizers || @top_level_finalizers || context.errors.any? # rubocop:disable Development/NoneWithoutBlockCop + end + + # @return [Array] + def field_finalizers + @finalizers ? (@finalizers + context.errors) : context.errors + end + + def top_level_finalizers + @top_level_finalizers || EmptyObjects::EMPTY_ARRAY + end + + # @api private + # @param finalizer [Execution::Next::Finalizer] + # @return [Execution::NextFinalizer] `finalizer` + def add_finalizer(finalizer, top_level = false) + f = top_level ? (@top_level_finalizers ||= []) : (@finalizers ||= []) + f << finalizer + finalizer + end end include Runnable @@ -159,6 +181,7 @@ def initialize(schema, query_string = nil, query: nil, document: nil, context: n @root_value = root_value @fragments = nil @operations = nil + @finalizers = @top_level_finalizers = nil @validate = validate self.static_validator = static_validator if static_validator context_tracers = (context ? context.fetch(:tracers, []) : []) @@ -262,6 +285,10 @@ def operations with_prepared_ast { @operations } end + def path + EmptyObjects::EMPTY_ARRAY + 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 @@ -271,7 +298,11 @@ def operations # @return [Array] 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) + if context[:__graphql_execute_next] + Execution::Next.run_all(@schema, partials, context: @context) + else + Execution::Interpreter.run_all(@schema, partials, context: @context) + end end # Get the result for this query, executing it once @@ -298,19 +329,6 @@ def selected_operation with_prepared_ast { @selected_operation } end - # @return [Array] - def finalizers - @finalizers ? (@finalizers + context.errors) : context.errors - end - - # @param finalizer [Execution::Next::Finalizer] - # @return [Execution::NextFinalizer] `finalizer` - def add_finalizer(finalizer) - f = @finalizers ||= [] - f << finalizer - finalizer - end - # Determine the values for variables of this query, using default values # if a value isn't provided at runtime. # diff --git a/lib/graphql/query/partial.rb b/lib/graphql/query/partial.rb index 31bd6abff6..6e4c0e1755 100644 --- a/lib/graphql/query/partial.rb +++ b/lib/graphql/query/partial.rb @@ -32,6 +32,7 @@ def initialize(path: nil, object:, query:, context: nil, fragment_node: nil, typ @multiplex = nil @result_values = nil @result = nil + @finalizers = @top_level_finalizers = nil if fragment_node @ast_nodes = [fragment_node] @@ -51,6 +52,10 @@ def leaf? @leaf end + def root_value + object + end + attr_reader :context, :query, :ast_nodes, :root_type, :object, :field_definition, :path, :schema attr_accessor :multiplex, :result_values @@ -90,10 +95,22 @@ def fragments @query.fragments end + def validate + @query.validate + end + def valid? @query.valid? end + def query? + true + end + + def run_partials(...) + @query.run_partials(...) + end + def analyzers EmptyObjects::EMPTY_ARRAY end @@ -107,7 +124,7 @@ def subscription? end def selected_operation - ast_nodes.first + Language::Nodes::OperationDefinition.new(selections: ast_nodes.flat_map(&:selections)) end def static_errors @@ -123,7 +140,6 @@ def selected_operation_name def set_type_info_from_path selections = [@query.selected_operation] type = @query.root_type - parent_type = nil field_defn = nil @path.each do |name_in_doc| @@ -162,7 +178,6 @@ def set_type_info_from_path end field_name = next_selections.first.name field_defn = @schema.get_field(type, 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 diff --git a/lib/graphql/schema.rb b/lib/graphql/schema.rb index 18958bd360..1257ee7493 100644 --- a/lib/graphql/schema.rb +++ b/lib/graphql/schema.rb @@ -1442,7 +1442,16 @@ def tracer(new_tracer, silence_deprecation_warning: false) end def tracers - find_inherited_value(:tracers, EMPTY_ARRAY) + own_tracers + inherited = find_inherited_value(:tracers, EMPTY_ARRAY) + if inherited.length > 0 + if own_tracers.length > 0 + inherited + own_tracers + else + inherited + end + else + own_tracers + end end # Mix `trace_mod` into this schema's `Trace` class so that its methods will be called at runtime. @@ -1552,7 +1561,8 @@ def query_analyzer(new_analyzer) end def query_analyzers - find_inherited_value(:query_analyzers, EMPTY_ARRAY) + own_query_analyzers + inherited_qa = find_inherited_value(:query_analyzers, EMPTY_ARRAY) + inherited_qa.length > 0 ? (inherited_qa + own_query_analyzers) : own_query_analyzers end # @param new_analyzer [Class] An analyzer to run on multiplexes to this schema diff --git a/lib/graphql/schema/directive.rb b/lib/graphql/schema/directive.rb index 5802f86f0b..22eb1d1ca6 100644 --- a/lib/graphql/schema/directive.rb +++ b/lib/graphql/schema/directive.rb @@ -112,10 +112,11 @@ def repeatable(new_value) def inherited(subclass) super + parent_class = self subclass.class_exec do @default_graphql_name ||= nil - @locations = locations - @is_runtime = runtime? + @locations = parent_class.locations + @is_runtime = parent_class.runtime? @repeatable = false end end diff --git a/lib/graphql/schema/list.rb b/lib/graphql/schema/list.rb index e5764c9e50..56e0928f58 100644 --- a/lib/graphql/schema/list.rb +++ b/lib/graphql/schema/list.rb @@ -22,6 +22,10 @@ def to_type_signature @type_signature ||= -"[#{@of_type.to_type_signature}]" end + def authorizes?(ctx) + of_type.authorizes?(ctx) + end + # This is for introspection, where it's expected the name will be `null` def graphql_name nil diff --git a/spec/graphql/query/partial_spec.rb b/spec/graphql/query/partial_spec.rb index 68132069aa..0203f73508 100644 --- a/spec/graphql/query/partial_spec.rb +++ b/spec/graphql/query/partial_spec.rb @@ -45,20 +45,28 @@ class FarmProduct < GraphQL::Schema::Enum module Entity include GraphQL::Schema::Interface - field :name, String + field :name, String, resolve_each: true end class Farm < GraphQL::Schema::Object implements Entity field :name, String field :products, [FarmProduct] - field :error, Int + field :error, Int, resolve_static: true - def error + def self.error(context) raise GraphQL::ExecutionError, "This is a field error" end - field :neighboring_farm, Farm + def error + self.class.error(context) + end + + field :neighboring_farm, Farm, resolve_batch: true + + def self.neighboring_farm(objects, context) + context.dataload_all(FarmSource, objects.map(&:neighboring_farm_id)) + end def neighboring_farm dataloader.with(FarmSource).load(object.neighboring_farm_id) @@ -66,11 +74,17 @@ def neighboring_farm end class UpcasedFarm < GraphQL::Schema::Object - field :name, String + field :name, String, resolve_each: true - def name + graphql_name "UpcasedFarm" + + def self.name(object, context) object[:name].upcase end + + def name + self.class.name(object, context) + end end class Market < GraphQL::Schema::Object @@ -85,57 +99,83 @@ class Thing < GraphQL::Schema::Union class Query < GraphQL::Schema::Object field :farms, [Farm], fallback_value: Database::FARMS.values - field :farm, Farm do + field :farm, Farm, resolve_static: true do argument :id, ID, loads: Farm, as: :farm end - def farm(farm:) + def self.farm(context, farm:) farm end + def farm(farm:) + self.class.farm(context, farm: farm) + end + field :farm_names, [String], fallback_value: Database::FARMS.each_value.map(&:name) field :query, Query, fallback_value: true - field :thing, Thing + field :thing, Thing, resolve_static: true + + def self.thing(context) + Database.get("1") + end def thing + self.class.thing(context) + end + + field :entity, Entity, resolve_static: true + def self.entity(context) Database.get("1") end - field :entity, Entity - def entity; Database.get("1"); end + def entity + self.class.entity(context) + end - field :read_context, String do + field :read_context, String, resolve_static: true do argument :key, String end - def read_context(key:) + def self.read_context(context, key:) -> { context[key].to_s } end - field :current_path, [String] + def read_context(key:) + self.class.read_context(context, key: key) + end + + field :current_path, [String], resolve_legacy_instance_method: true def current_path context.current_path end - field :current_values, [String] - def current_values + field :current_values, [String], resolve_static: true + def self.current_values(context) [ GraphQL::Current.operation_name, GraphQL::Current.field.path, GraphQL::Current.dataloader_source_class.inspect, ] end + + def current_values + self.class.current_values(context) + end end class Mutation < GraphQL::Schema::Object - field :update_farm, Farm do + field :update_farm, Farm, resolve_static: true do argument :name, String end + def self.update_farm(context, name:) + OpenStruct.new({ name: name }) + end + def update_farm(name:) - { name: name } + self.class.update_farm(context, name: name) end end @@ -151,6 +191,7 @@ def self.resolve_type(abs_type, object, ctx) end use GraphQL::Dataloader + use GraphQL::Execution::Next lazy_resolve Proc, :call end @@ -160,6 +201,9 @@ def self.resolve_type(abs_type, object, ctx) def run_partials(string, partial_configs, **query_kwargs) query = GraphQL::Query.new(PartialSchema, string, **query_kwargs) + if TESTING_EXEC_NEXT + query.context[:__graphql_execute_next] = true + end query.run_partials(partial_configs) end @@ -172,7 +216,7 @@ def run_partials(string, partial_configs, **query_kwargs) results = run_partials(str, [ { path: ["farm1"], object: PartialSchema::Database::FARMS["1"] }, { path: ["farm2"], object: OpenStruct.new(name: "Injected Farm") }, - { path: ["farms", 0], object: { name: "Kestrel Hollow", products: [:__MEAT__, "EGGS"]} }, + { path: ["farms", 0], object: OpenStruct.new({ name: "Kestrel Hollow", products: [:__MEAT__, "EGGS"]}) }, ]) assert_equal [ @@ -198,8 +242,8 @@ def run_partials(string, partial_configs, **query_kwargs) fragment_node = document.definitions.first.selections.first.selections.first other_fragment_node = fragment_node.selections[1] results = run_partials(str, [ - { fragment_node: fragment_node, type: PartialSchema::Farm, object: { name: "Belair Farm" } }, - { fragment_node: other_fragment_node, type: PartialSchema::UpcasedFarm, object: { name: "Free Union Grass Farm" } } + { fragment_node: fragment_node, type: PartialSchema::Farm, object: OpenStruct.new({ name: "Belair Farm" }) }, + { fragment_node: other_fragment_node, type: PartialSchema::UpcasedFarm, object: OpenStruct.new({ name: "Free Union Grass Farm" }) } ]) assert_equal({ "name" => "Belair Farm", "n2" => "Belair Farm" }, results[0]["data"]) assert_equal({ "n2" => "FREE UNION GRASS FARM" }, results[1]["data"]) @@ -215,7 +259,7 @@ def run_partials(string, partial_configs, **query_kwargs) }" node = GraphQL.parse(str).definitions.last - results = run_partials(str, [{ fragment_node: node, type: PartialSchema::Farm, object: { name: "Clovertop Creamery" } }]) + results = run_partials(str, [{ fragment_node: node, type: PartialSchema::Farm, object: OpenStruct.new({ name: "Clovertop Creamery" }) }]) assert_equal({ "farmName" => "Clovertop Creamery" }, results[0]["data"]) end @@ -299,7 +343,7 @@ def run_partials(string, partial_configs, **query_kwargs) it "runs arrays and returns useful metadata in the result" do str = "{ farms { name } }" - results = run_partials(str, [{ path: ["farms"], object: [{ name: "Twenty Paces" }, { name: "Spring Creek Blooms" }]}]) + results = run_partials(str, [{ path: ["farms"], object: [OpenStruct.new({ name: "Twenty Paces" }), OpenStruct.new({ name: "Spring Creek Blooms" })]}]) result = results.first assert_equal [{ "name" => "Twenty Paces" }, { "name" => "Spring Creek Blooms" }], result["data"] assert_equal ["farms"], result.path @@ -466,7 +510,7 @@ def run_partials(string, partial_configs, **query_kwargs) str = "mutation { updateFarm(name: \"Brawndo Acres\") { name } }" results = run_partials(str, [ { path: [], object: nil }, - { path: ["updateFarm"], object: { name: "Georgetown Farm" } }, + { path: ["updateFarm"], object: OpenStruct.new({ name: "Georgetown Farm" }) }, { path: ["updateFarm", "name"], object: "Notta Farm" }, ]) diff --git a/spec/graphql/schema/directive_spec.rb b/spec/graphql/schema/directive_spec.rb index 500a24ce9c..d784220a5d 100644 --- a/spec/graphql/schema/directive_spec.rb +++ b/spec/graphql/schema/directive_spec.rb @@ -150,8 +150,8 @@ def initialize(ast_node) end def finalize_graphql_result(query, result_data, result_key) - counts = query.context[:count_fields] ||= Hash.new { |h, k| h[k] = [] } - count = case @ast_node + counts = query.context[:count_fields] ||= {} + counts[path] ||= [case @ast_node when GraphQL::Language::Nodes::Field 1 when GraphQL::Language::Nodes::InlineFragment @@ -161,21 +161,20 @@ def finalize_graphql_result(query, result_data, result_key) frag.selections.size else raise ArgumentError, "Unexpected ast_node: #{ast_node.inspect}" - end - counts[path] << count + end] end end def self.resolve_field(ast_nodes, parent_type, field_defn, objects, arguments, context) - context.query.add_finalizer(FieldCount.new(ast_nodes.first)) + FieldCount.new(ast_nodes.first) end - def self.resolve_inline_fragment(ast_node, parent_type, objects, context) - context.query.add_finalizer(FieldCount.new(ast_node)) + def self.resolve_inline_fragment(ast_node, parent_type, objects, _args, context) + FieldCount.new(ast_node) end - def self.resolve_fragment_spread(ast_node, parent_type, objects, context) - context.query.add_finalizer(FieldCount.new(ast_node)) + def self.resolve_fragment_spread(ast_node, parent_type, objects, _args, context) + FieldCount.new(ast_node) end end From 316392dd9295f85613b87b3d585ee41f76ae83ef Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Tue, 7 Apr 2026 15:40:48 -0400 Subject: [PATCH 6/6] revert some optimizations --- lib/graphql/analysis.rb | 9 +++------ lib/graphql/schema.rb | 2 +- spec/graphql/schema/directive_spec.rb | 2 +- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/lib/graphql/analysis.rb b/lib/graphql/analysis.rb index 6dffc35fb2..36f14019f8 100644 --- a/lib/graphql/analysis.rb +++ b/lib/graphql/analysis.rb @@ -40,9 +40,9 @@ def analyze_multiplex(multiplex, analyzers) end end + multiplex_analyzers.map!(&:result) multiplex_errors = analysis_errors(EmptyObjects::EMPTY_ARRAY, multiplex_analyzers) - multiplex.queries.each_with_index do |query, idx| query.analysis_errors = analysis_errors(multiplex_errors, query_results[idx]) end @@ -56,13 +56,10 @@ def analyze_multiplex(multiplex, analyzers) def analyze_query(query, analyzers, multiplex_analyzers: []) query.current_trace.analyze_query(query: query) do query_analyzers = analyzers.map { |analyzer| analyzer.new(query) } - query_analyzers.select!(&:analyze?) - - analyzers_to_run = multiplex_analyzers.empty? ? query_analyzers : query_analyzers + multiplex_analyzers + analyzers_to_run = query_analyzers + multiplex_analyzers if !analyzers_to_run.empty? - analyzers_to_run.select!(&:visit?) if !analyzers_to_run.empty? visitor = GraphQL::Analysis::Visitor.new( @@ -92,7 +89,7 @@ def analyze_query(query, analyzers, multiplex_analyzers: []) def analysis_errors(parent_errors, results) if !results.empty? - results.flatten! + results = results.flatten results.select! { |r| r.is_a?(GraphQL::AnalysisError) } end diff --git a/lib/graphql/schema.rb b/lib/graphql/schema.rb index 1257ee7493..e21a6ffa4f 100644 --- a/lib/graphql/schema.rb +++ b/lib/graphql/schema.rb @@ -1562,7 +1562,7 @@ def query_analyzer(new_analyzer) def query_analyzers inherited_qa = find_inherited_value(:query_analyzers, EMPTY_ARRAY) - inherited_qa.length > 0 ? (inherited_qa + own_query_analyzers) : own_query_analyzers + inherited_qa.empty? ? own_query_analyzers : (inherited_qa + own_query_analyzers) end # @param new_analyzer [Class] An analyzer to run on multiplexes to this schema diff --git a/spec/graphql/schema/directive_spec.rb b/spec/graphql/schema/directive_spec.rb index d784220a5d..c4ddb1f623 100644 --- a/spec/graphql/schema/directive_spec.rb +++ b/spec/graphql/schema/directive_spec.rb @@ -187,7 +187,7 @@ module HasThings field :thing, Thing, null: false, extras: [:ast_node], resolve_static: true def thing(ast_node:) - self.class.thing(ast_node: ast_node) + self.class.thing(context, ast_node: ast_node) end resolver_methods do