From f16be954c2004965ebf82d70e74c4f9b57e856d7 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Wed, 17 Apr 2024 15:03:43 -0400 Subject: [PATCH] Support lazy enumerators --- lib/graphql/execution/interpreter/runtime.rb | 31 ++++++------ spec/graphql/pagination/connections_spec.rb | 2 +- spec/graphql/schema/list_spec.rb | 53 ++++++++++++++++++++ 3 files changed, 70 insertions(+), 16 deletions(-) diff --git a/lib/graphql/execution/interpreter/runtime.rb b/lib/graphql/execution/interpreter/runtime.rb index 560d800847..9c3c2c797b 100644 --- a/lib/graphql/execution/interpreter/runtime.rb +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -658,25 +658,21 @@ def continue_field(value, owner_type, field, current_type, ast_node, next_select inner_type_non_null = inner_type.non_null? response_list = GraphQLResultArray.new(result_name, selection_result, is_non_null) set_result(selection_result, result_name, response_list, true, is_non_null) - idx = nil + idx = 0 + any_items_resolved = false list_value = begin - value.each do |inner_value| - idx ||= 0 - this_idx = idx - idx += 1 - if use_dataloader_job - @dataloader.append_job do - resolve_list_item(inner_value, inner_type, inner_type_non_null, ast_node, field, owner_object, arguments, this_idx, response_list, next_selections, owner_type, was_scoped, runtime_state) - end - else - resolve_list_item(inner_value, inner_type, inner_type_non_null, ast_node, field, owner_object, arguments, this_idx, response_list, next_selections, owner_type, was_scoped, runtime_state) + enumerator = value.to_enum + if use_dataloader_job + @dataloader.append_job do + resolve_list_item(enumerator, inner_type, inner_type_non_null, ast_node, field, owner_object, arguments, idx, response_list, next_selections, owner_type, was_scoped, runtime_state) end + else + resolve_list_item(enumerator, inner_type, inner_type_non_null, ast_node, field, owner_object, arguments, idx, response_list, next_selections, owner_type, was_scoped, runtime_state) end response_list rescue NoMethodError => err - # Ruby 2.2 doesn't have NoMethodError#receiver, can't check that one in this case. (It's been EOL since 2017.) - if err.name == :each && (err.respond_to?(:receiver) ? err.receiver == value : true) + if err.receiver == value && (err.name == :each || err.name == :to_enum) # This happens when the GraphQL schema doesn't match the implementation. Help the dev debug. raise ListResultFailedError.new(value: value, field: field, path: current_path) else @@ -693,24 +689,29 @@ def continue_field(value, owner_type, field, current_type, ast_node, next_select end end # Detect whether this error came while calling `.each` (before `idx` is set) or while running list *items* (after `idx` is set) - error_is_non_null = idx.nil? ? is_non_null : inner_type.non_null? + error_is_non_null = get_current_runtime_state.current_result_name.is_a?(String) ? is_non_null : inner_type.non_null? continue_value(list_value, owner_type, field, error_is_non_null, ast_node, result_name, selection_result) else raise "Invariant: Unhandled type kind #{current_type.kind} (#{current_type})" end end - def resolve_list_item(inner_value, inner_type, inner_type_non_null, ast_node, field, owner_object, arguments, this_idx, response_list, next_selections, owner_type, was_scoped, runtime_state) # rubocop:disable Metrics/ParameterLists + def resolve_list_item(enumerator, inner_type, inner_type_non_null, ast_node, field, owner_object, arguments, this_idx, response_list, next_selections, owner_type, was_scoped, runtime_state) # rubocop:disable Metrics/ParameterLists + runtime_state = get_current_runtime_state runtime_state.current_result_name = this_idx runtime_state.current_result = response_list call_method_on_directives(:resolve_each, owner_object, ast_node.directives) do + inner_value = enumerator.next # This will update `response_list` with the lazy after_lazy(inner_value, ast_node: ast_node, field: field, owner_object: owner_object, arguments: arguments, result_name: this_idx, result: response_list, runtime_state: runtime_state) do |inner_inner_value, runtime_state| continue_value = continue_value(inner_inner_value, owner_type, field, inner_type_non_null, ast_node, this_idx, response_list) if HALT != continue_value continue_field(continue_value, owner_type, field, inner_type, ast_node, next_selections, false, owner_object, arguments, this_idx, response_list, was_scoped, runtime_state) end + resolve_list_item(enumerator, inner_type, inner_type_non_null, ast_node, field, owner_object, arguments, this_idx + 1, response_list, next_selections, owner_type, was_scoped, runtime_state) end + rescue StopIteration + nil end end diff --git a/spec/graphql/pagination/connections_spec.rb b/spec/graphql/pagination/connections_spec.rb index 000cfe3c5b..39b7fae07c 100644 --- a/spec/graphql/pagination/connections_spec.rb +++ b/spec/graphql/pagination/connections_spec.rb @@ -107,7 +107,7 @@ def things2 pp ConnectionErrorTestSchema.execute("{ things { name } }") end - assert_includes err.message, "Failed to build a GraphQL list result for field `Query.things` at path `things`." + assert_includes err.message, "Failed to build a GraphQL list result for field `Query.things` at path `things.0`." assert_includes err.message, "(GraphQL::Pagination::ArrayConnection) to implement `.each` to satisfy the GraphQL return type `[ThingConnection!]!`" assert_includes err.message, "This field was treated as a Relay-style connection; add `connection: false` to the `field(...)` to disable this behavior." end diff --git a/spec/graphql/schema/list_spec.rb b/spec/graphql/schema/list_spec.rb index 1488d0fbb3..2edf7cd396 100644 --- a/spec/graphql/schema/list_spec.rb +++ b/spec/graphql/schema/list_spec.rb @@ -187,4 +187,57 @@ def items(ids:) assert_equal 3, res["errors"][0]["extensions"]["problems"].count end end + + describe "Lazy Enumerators" do + class LazyEnumeratorSchema < GraphQL::Schema + class LazyItems + def initialize(log) + @log = log + end + + def each + @log << "yield 1" + yield(1) + @log << "yield 2" + yield(2) + @log << "yield 3" + yield(3) + self + end + end + + class Item < GraphQL::Schema::Object + field :name, String + + def name + context[:list_log] << "resolve #{object}.name" + "name-#{object}" + end + end + class Query < GraphQL::Schema::Object + field :items, [Item] + + def items + LazyItems.new(context[:list_log]) + end + end + + query(Query) + end + + it "resolves them lazily" do + query_str = "{ items { name } }" + log = [] + LazyEnumeratorSchema.execute(query_str, context: { list_log: log }) + expected_log = [ + "yield 1", + "resolve 1.name", + "yield 2", + "resolve 2.name", + "yield 3", + "resolve 3.name" + ] + assert_equal expected_log, log + end + end end