From d34ff728f57ef4f7bbc83e31a8e8a2dad58489d0 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Mon, 23 Mar 2026 15:28:01 -0400 Subject: [PATCH 1/5] Exec-next: some runtime fixes --- CHANGELOG.md | 2 +- guides/execution/migration.md | 8 ++++---- .../execution/next/field_resolve_step.rb | 18 ++++++------------ .../execution/next/prepare_object_step.rb | 2 +- lib/graphql/execution/next/runner.rb | 7 ++++--- 5 files changed, 16 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d50b79b04b..69aea345e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -99,7 +99,7 @@ - Runtime: add hooks for experimental custom runtimes #5425, #5429 - Lazy handling and Dataloader have been merged under the hood #5422 -- Doc: merk `load_application_object_failed` as public #5426 +- Doc: mark `load_application_object_failed` as public #5426 # 2.5.11 (9 Jul 2025) diff --git a/guides/execution/migration.md b/guides/execution/migration.md index 997fb61787..498d365201 100644 --- a/guides/execution/migration.md +++ b/guides/execution/migration.md @@ -48,9 +48,9 @@ Adopting a feature flag system (described below) can also make this easier. When all tests pass on `.execute_next`, you're ready to try it out in production. -## COMING SOON: Migration and Clean-Up Script +## Migration and Clean-Up Script -Migrating field implementations can be automated in many cases. A script to analyze and execute these cases is in the works: [Pull Request](https://github.com/rmosolgo/graphql-ruby/pull/5531). This script will also be able to clean up unused instance methods when the migration is complete. +`graphql_migrate_execution` is a command-line development tool that can automate many common GraphQL-Ruby field resolver patterns. Check out its docs and try out: https://rmosolgo.github.io/graphql_migrate_execution/ ## Production Considerations @@ -159,7 +159,7 @@ Previously, GraphQL-Ruby would check `type_object.respond_to?(:title)`, `object. Now, GraphQL-Ruby simply calls `object.title` and allows the `NoMethodError` to bubble up if one is raised. -### Query Analyzers, including complexity 🌕 +### Query Analyzers, including complexity 🟡 Support is identical; this runs before execution using the exact same code. @@ -219,7 +219,7 @@ Not supported yet. This will need some new kind of integration. These methods/procs are called. -### `validates:` ❌ +### `validates:` 🟡 Built-in validators are supported. Custom validators will always receive `nil` as the `object`. (`object` is no longer available; this API will probably change before this is fully released.) diff --git a/lib/graphql/execution/next/field_resolve_step.rb b/lib/graphql/execution/next/field_resolve_step.rb index a8abb3af2f..40eb51f17d 100644 --- a/lib/graphql/execution/next/field_resolve_step.rb +++ b/lib/graphql/execution/next/field_resolve_step.rb @@ -49,7 +49,7 @@ def append_selection(ast_node) end def coerce_arguments(argument_owner, ast_arguments_or_hash) - arg_defns = argument_owner.arguments(@selections_step.query.context) + arg_defns = @selections_step.query.types.arguments(argument_owner) if arg_defns.empty? return EmptyObjects::EMPTY_HASH end @@ -57,19 +57,19 @@ def coerce_arguments(argument_owner, ast_arguments_or_hash) if ast_arguments_or_hash.is_a?(Hash) ast_arguments_or_hash.each do |key, value| key_s = nil - arg_defn = arg_defns.each_value.find { |a| + arg_defn = arg_defns.find { |a| a.keyword == key || a.graphql_name == (key_s ||= String(key)) } coerce_argument_value(args_hash, arg_defn, value) end else ast_arguments_or_hash.each { |arg_node| - arg_defn = arg_defns[arg_node.name] + arg_defn = arg_defns.find { |ad| ad.graphql_name == arg_node.name } coerce_argument_value(args_hash, arg_defn, arg_node.value) } end # TODO refactor the loop above into this one - arg_defns.each do |arg_graphql_name, arg_defn| + arg_defns.each do |arg_defn| if arg_defn.default_value? && !args_hash.key?(arg_defn.keyword) coerce_argument_value(args_hash, arg_defn, arg_defn.default_value) end @@ -252,14 +252,6 @@ 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}") - if field_name == "__typename" - # TODO handle custom introspection - @field_results = Array.new(@selections_step.objects.size, @parent_type.graphql_name) - @object_is_authorized = AlwaysAuthorized - build_results - return - end - arguments = coerce_arguments(@field_definition, @ast_node.arguments) # rubocop:disable Development/ContextIsPassedCop @arguments ||= arguments # may have already been set to an error @@ -689,6 +681,8 @@ def resolve_batch(objects, context, args_hash) else obj_inst.public_send(@field_definition.execution_next_mode_key, **args_hash) end + rescue GraphQL::ExecutionError => exec_err + exec_err end else raise "Batching execution for #{path} not implemented (execution_next_mode: #{@execution_next_mode.inspect}); provide `resolve_static:`, `resolve_batch:`, `hash_key:`, `method:`, or use a compatibility plug-in" diff --git a/lib/graphql/execution/next/prepare_object_step.rb b/lib/graphql/execution/next/prepare_object_step.rb index 528f81402e..d9a0016bd5 100644 --- a/lib/graphql/execution/next/prepare_object_step.rb +++ b/lib/graphql/execution/next/prepare_object_step.rb @@ -72,7 +72,7 @@ def authorize begin query.current_trace.begin_authorized(@resolved_type, @object, query.context) @authorized_value = @resolved_type.authorized?(@object, query.context) - query.current_trace.end_authorized(@resolve_type, @object, query.context, @authorized_value) + query.current_trace.end_authorized(@resolved_type, @object, query.context, @authorized_value) rescue GraphQL::UnauthorizedError => auth_err @authorization_error = auth_err end diff --git a/lib/graphql/execution/next/runner.rb b/lib/graphql/execution/next/runner.rb index eed554807e..6fc3190905 100644 --- a/lib/graphql/execution/next/runner.rb +++ b/lib/graphql/execution/next/runner.rb @@ -23,9 +23,9 @@ def initialize(multiplex, authorization:) end def resolve_type(type, object, query) - query.current_trace.begin_resolve_type(@static_type, object, query.context) + query.current_trace.begin_resolve_type(type, object, query.context) resolved_type, _ignored_new_value = query.resolve_type(type, object) - query.current_trace.end_resolve_type(@static_type, object, query.context, resolved_type) + query.current_trace.end_resolve_type(type, object, query.context, resolved_type) resolved_type end @@ -184,7 +184,8 @@ def execute res_h end - GraphQL::Query::Result.new(query: query, values: fin_result) + query.result_values = fin_result + query.result end end ensure From 903dce48fda1bf0a5b1a4fc3e301c0e8bc669a4b Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Tue, 24 Mar 2026 12:19:11 -0400 Subject: [PATCH 2/5] Add some fixes --- lib/graphql/execution/next/field_resolve_step.rb | 3 ++- lib/graphql/execution/next/load_argument_step.rb | 6 +++++- lib/graphql/execution/next/prepare_object_step.rb | 2 +- lib/graphql/execution_error.rb | 2 +- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/lib/graphql/execution/next/field_resolve_step.rb b/lib/graphql/execution/next/field_resolve_step.rb index 40eb51f17d..fa7ae72283 100644 --- a/lib/graphql/execution/next/field_resolve_step.rb +++ b/lib/graphql/execution/next/field_resolve_step.rb @@ -255,7 +255,8 @@ def build_arguments arguments = coerce_arguments(@field_definition, @ast_node.arguments) # rubocop:disable Development/ContextIsPassedCop @arguments ||= arguments # may have already been set to an error - if @pending_steps.nil? || @pending_steps.size == 0 + if (@pending_steps.nil? || @pending_steps.size == 0) && + @field_results.nil? # Make sure this wasn't continue via inline dataloader execution execute_field end end diff --git a/lib/graphql/execution/next/load_argument_step.rb b/lib/graphql/execution/next/load_argument_step.rb index 7bae5f2f38..86d062d3ce 100644 --- a/lib/graphql/execution/next/load_argument_step.rb +++ b/lib/graphql/execution/next/load_argument_step.rb @@ -20,7 +20,11 @@ def value def call context = @field_resolve_step.selections_step.query.context - @loaded_value = @load_receiver.load_and_authorize_application_object(@argument_definition, @argument_value, context) + @loaded_value = begin + @load_receiver.load_and_authorize_application_object(@argument_definition, @argument_value, context) + rescue GraphQL::UnauthorizedError => auth_err + context.schema.unauthorized_object(auth_err) + end if (runner = @field_resolve_step.runner).resolves_lazies && runner.lazy?(@loaded_value) runner.dataloader.lazy_at_depth(@field_resolve_step.path.size, self) else diff --git a/lib/graphql/execution/next/prepare_object_step.rb b/lib/graphql/execution/next/prepare_object_step.rb index d9a0016bd5..5898e91d42 100644 --- a/lib/graphql/execution/next/prepare_object_step.rb +++ b/lib/graphql/execution/next/prepare_object_step.rb @@ -83,7 +83,7 @@ def authorize else create_result end - rescue GraphQL::Error => err + rescue GraphQL::RuntimeError => err @graphql_result[@key] = @field_resolve_step.add_graphql_error(err) end diff --git a/lib/graphql/execution_error.rb b/lib/graphql/execution_error.rb index ce3f5cf6d3..39f622ecbe 100644 --- a/lib/graphql/execution_error.rb +++ b/lib/graphql/execution_error.rb @@ -6,7 +6,7 @@ module GraphQL class ExecutionError < GraphQL::RuntimeError # @return [GraphQL::Language::Nodes::Field] the field where the error occurred def ast_node - ast_nodes.first + ast_nodes&.first end def ast_node=(new_node) From de18ad205cfe5edbb12ba0fe402bb6763daffa72 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Tue, 24 Mar 2026 13:27:46 -0400 Subject: [PATCH 3/5] Handle auth erros --- lib/graphql/schema/resolver.rb | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/graphql/schema/resolver.rb b/lib/graphql/schema/resolver.rb index 46e56565cf..a14e3e4b89 100644 --- a/lib/graphql/schema/resolver.rb +++ b/lib/graphql/schema/resolver.rb @@ -66,7 +66,12 @@ def call q = context.query trace_objs = [object] q.current_trace.begin_execute_field(field, @prepared_arguments, trace_objs, q) - is_authed, new_return_value = authorized?(**@prepared_arguments) + begin + is_authed, new_return_value = authorized?(**@prepared_arguments) + rescue GraphQL::UnauthorizedError => err + new_return_value = q.schema.unauthorized_object(err) + is_authed = true # the error was handled + end if (runner = @field_resolve_step.runner).resolves_lazies && runner.schema.lazy?(is_authed) is_authed, new_return_value = runner.schema.sync_lazy(is_authed) From fce77a0efb7afd64b34f237eb036b2046f6e51e2 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Mon, 30 Mar 2026 14:43:17 -0400 Subject: [PATCH 4/5] Fix call signature --- lib/graphql/execution/next/field_resolve_step.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/graphql/execution/next/field_resolve_step.rb b/lib/graphql/execution/next/field_resolve_step.rb index ae1bf7e451..adcd5a5b74 100644 --- a/lib/graphql/execution/next/field_resolve_step.rb +++ b/lib/graphql/execution/next/field_resolve_step.rb @@ -54,14 +54,14 @@ def coerce_arguments(argument_owner, ast_arguments_or_hash, run_loads = true) return EmptyObjects::EMPTY_HASH end args_hash = {} - + if ast_arguments_or_hash.nil? # This can happen with `.trigger` return args_hash end arg_inputs_are_h = ast_arguments_or_hash.is_a?(Hash) - arg_defns.each do |arg_graphql_name, arg_defn| + arg_defns.each do |arg_defn| arg_value = nil was_found = false if arg_inputs_are_h From dc3b87f0b0ea98463cee3b262639dd89d0e9aeb1 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Mon, 30 Mar 2026 15:07:29 -0400 Subject: [PATCH 5/5] generate id method for exec-next --- lib/graphql/schema/member/has_fields.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/graphql/schema/member/has_fields.rb b/lib/graphql/schema/member/has_fields.rb index a45e959115..6e328a70e6 100644 --- a/lib/graphql/schema/member/has_fields.rb +++ b/lib/graphql/schema/member/has_fields.rb @@ -150,10 +150,14 @@ def field_class(new_field_class = nil) def global_id_field(field_name, **kwargs) type = self - field field_name, "ID", **kwargs, null: false + field field_name, "ID", **kwargs, null: false, resolve_each: true define_method(field_name) do context.schema.id_from_object(object, type, context) end + + define_singleton_method(field_name) do |object, context| + context.schema.id_from_object(object, type, context) + end end # @param new_has_no_fields [Boolean] Call with `true` to make this Object type ignore the requirement to have any defined fields.