From d2440f1e474e7a3d17313c2c1c7482c62113dfc2 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Sat, 11 Jun 2016 15:26:08 -0400 Subject: [PATCH 01/10] feat(Execution) add DeferredExecution, implement @stream and @defer --- lib/graphql/directive.rb | 2 + lib/graphql/directive/defer_directive.rb | 5 + lib/graphql/directive/stream_directive.rb | 5 + lib/graphql/execution.rb | 1 + lib/graphql/execution/deferred_execution.rb | 411 ++++++++++++++++++ lib/graphql/execution/directive_checks.rb | 12 + .../internal_representation/rewrite.rb | 2 +- lib/graphql/schema.rb | 12 +- .../execution/deferred_execution_spec.rb | 355 +++++++++++++++ .../introspection/directive_type_spec.rb | 16 + .../graphql/introspection/schema_type_spec.rb | 1 + spec/support/dairy_app.rb | 3 + 12 files changed, 822 insertions(+), 3 deletions(-) create mode 100644 lib/graphql/directive/defer_directive.rb create mode 100644 lib/graphql/directive/stream_directive.rb create mode 100644 lib/graphql/execution/deferred_execution.rb create mode 100644 spec/graphql/execution/deferred_execution_spec.rb diff --git a/lib/graphql/directive.rb b/lib/graphql/directive.rb index 70d5ce195d..9bda4a1507 100644 --- a/lib/graphql/directive.rb +++ b/lib/graphql/directive.rb @@ -77,6 +77,8 @@ def on_operation? end end +require "graphql/directive/defer_directive" require "graphql/directive/include_directive" require "graphql/directive/skip_directive" require "graphql/directive/deprecated_directive" +require "graphql/directive/stream_directive" diff --git a/lib/graphql/directive/defer_directive.rb b/lib/graphql/directive/defer_directive.rb new file mode 100644 index 0000000000..6117720a87 --- /dev/null +++ b/lib/graphql/directive/defer_directive.rb @@ -0,0 +1,5 @@ +GraphQL::Directive::DeferDirective = GraphQL::Directive.define do + name "defer" + description "Push this part of the query in a later patch" + locations([GraphQL::Directive::FIELD, GraphQL::Directive::FRAGMENT_SPREAD, GraphQL::Directive::INLINE_FRAGMENT]) +end diff --git a/lib/graphql/directive/stream_directive.rb b/lib/graphql/directive/stream_directive.rb new file mode 100644 index 0000000000..0e583d913d --- /dev/null +++ b/lib/graphql/directive/stream_directive.rb @@ -0,0 +1,5 @@ +GraphQL::Directive::StreamDirective = GraphQL::Directive.define do + name "stream" + description "Push items from this list in sequential patches" + locations([GraphQL::Directive::FIELD]) +end diff --git a/lib/graphql/execution.rb b/lib/graphql/execution.rb index c409a72235..28fc850453 100644 --- a/lib/graphql/execution.rb +++ b/lib/graphql/execution.rb @@ -1,2 +1,3 @@ +require "graphql/execution/deferred_execution" require "graphql/execution/directive_checks" require "graphql/execution/typecast" diff --git a/lib/graphql/execution/deferred_execution.rb b/lib/graphql/execution/deferred_execution.rb new file mode 100644 index 0000000000..59c88440f1 --- /dev/null +++ b/lib/graphql/execution/deferred_execution.rb @@ -0,0 +1,411 @@ +module GraphQL + module Execution + # A query execution strategy that emits + # `{ path: [...], value: ... }` patches as it + # resolves the query. + # + # @example Using DeferredExecution for your schema + # MySchema.query_execution_strategy = GraphQL::Execution::DeferredExecution + # + # @example A "collector" class which accepts patches and forwards them + # # Take patches from GraphQL and forward them to clients over a websocket. + # # (This is pseudo-code, I don't know a websocket library that works this way.) + # class WebsocketCollector + # # Accept `query_id` from the client, which allows the + # # client to identify patches as they come in. + # def initialize(query_id, websocket_conn) + # @query_id = query_id + # @websocket_conn = websocket_conn + # end + # + # # Accept a patch from GraphQL and send it to the client. + # # Include `query_id` so that the client knows which + # # response to merge this patch into. + # def patch(path:, value:) + # @websocket_conn.send({ + # query_id: @query_id, + # path: path, + # value: value, + # }) + # end + # end + # + # @example Executing a query with a collector + # collector = WebsocketCollector.new(params[:query_id], websocket_conn) + # query_ctx = {collector: collector, current_user: current_user} + # MySchema.execute(query_string, variables: variables, context: query_ctx) + # # Ignore the return value -- it will be emitted via `WebsocketCollector#patch` + # + # @example Executing a query WITHOUT a collector + # query_ctx = {collector: nil, current_user: current_user} + # result = MySchema.execute(query_string, variables: variables, context: query_ctx) + # # `result` contains any non-deferred fields + # render json: result + # + class DeferredExecution + include GraphQL::Language + # This key in context will be used to send patches. + CONTEXT_PATCH_TARGET = :collector + + # Returned by {.resolve_or_defer_frame} to signal that the frame's + # result was defered, so it should be empty in the patch. + DEFERRED_RESULT = :__deferred_result__ + + # Execute `ast_operation`, a member of `query_object`, starting from `root_type`. + # + # Results will be sent to `query_object.context[CONTEXT_PATCH_TARGET]` in the form + # of `#patch(path, value)`. + # + # If the patch target is absent, no patches will be sent. + # + # This method always returns the initial result + # and shoves any inital errors in to `query_object.context.errors`. + # For queries with no defers, you can leave out a patch target and simply + # use the return value. + # + # @return [Object] the initial result, without any defers + def execute(ast_operation, root_type, query_object) + collector = query_object.context[CONTEXT_PATCH_TARGET] + irep_root = query_object.internal_representation[ast_operation.name] + + scope = ExecScope.new(query_object) + initial_thread = ExecThread.new + initial_frame = ExecFrame.new( + node: irep_root, + value: query_object.root_value, + type: irep_root.return_type, + path: [] + ) + + initial_result = resolve_or_defer_frame(scope, initial_thread, initial_frame) + + if collector + initial_data = if initial_result == DEFERRED_RESULT + {} + else + initial_result + end + initial_patch = {"data" => initial_data} + + initial_errors = initial_thread.errors + query_object.context.errors + error_idx = initial_errors.length + + if initial_errors.any? + initial_patch["errors"] = initial_errors.map(&:to_h) + end + + collector.patch( + path: [], + value: initial_patch + ) + + defers = initial_thread.defers + while defers.any? + next_defers = [] + defers.each do |deferral| + deferred_thread = ExecThread.new + case deferral + when ExecFrame + deferred_frame = deferral + deferred_result = resolve_frame(scope, deferred_thread, deferred_frame) + + when ExecStream + begin + list_frame = deferral.frame + inner_type = deferral.type + item, idx = deferral.enumerator.next + deferred_frame = ExecFrame.new({ + node: list_frame.node, + path: list_frame.path + [idx], + type: inner_type, + value: item, + }) + deferred_result = resolve_value(scope, deferred_thread, deferred_frame, item, inner_type) + deferred_thread.defers << deferral + rescue StopIteration + # The enum is done + end + else + raise("Can't continue deferred #{deferred_frame.class.name}") + end + + # TODO: Should I patch nil? + if !deferred_result.nil? + collector.patch( + path: ["data"] + deferred_frame.path, + value: deferred_result + ) + end + + deferred_thread.errors.each do |deferred_error| + collector.patch( + path: ["errors", error_idx], + value: deferred_error.to_h + ) + error_idx += 1 + end + next_defers.push(*deferred_thread.defers) + end + defers = next_defers + end + else + query_object.context.errors.push(*initial_thread.errors) + end + + initial_result + end + + # Global, immutable environment for executing `query`. + # Passed through all execution to provide type, fragment and field lookup. + class ExecScope + attr_reader :query, :schema + + def initialize(query) + @query = query + @schema = query.schema + end + + def get_type(type) + @schema.types[type] + end + + def get_fragment(name) + @query.fragments[name] + end + + # This includes dynamic fields like __typename + def get_field(type, name) + @schema.get_field(type, name) || raise("No field named '#{name}' found for #{type}") + end + end + + # One serial stream of execution. One thread runs the initial query, + # then any deferred frames are restarted with their own threads. + # + # - {ExecThread#errors} contains errors during this part of the query + # - {ExecThread#defers} contains {ExecFrame}s which were marked as `@defer` + # and will be executed with their own threads later. + class ExecThread + attr_reader :errors, :defers + def initialize + @errors = [] + @defers = [] + end + end + + # One step of execution. Each step in execution gets its own frame. + # + # - {ExecFrame#node} is the IRep node which is being interpreted + # - {ExecFrame#path} is like a stack trace, it is used for patching deferred values + # - {ExecFrame#value} is the object being exposed by GraphQL at this point + # - {ExecFrame#type} is the GraphQL type which exposes {#value} at this point + class ExecFrame + attr_reader :node, :path, :type, :value + def initialize(node:, path:, type:, value:) + @node = node + @path = path + @type = type + @value = value + end + end + + # Contains the list field's ExecFrame + # And the enumerator which is being mapped + # - {ExecStream#enumerator} is an Enumerator which yields `item, idx` + # - {ExecStream#frame} is the {ExecFrame} for the list selection (where `@stream` was present) + # - {ExecStream#type} is the inner type of the list (the item's type) + class ExecStream + attr_reader :enumerator, :frame, :type + def initialize(enumerator:, frame:, type:) + @enumerator = enumerator + @frame = frame + @type = type + end + end + + private + + # If this `frame` is marked as defer, add it to `defers` + # and return {DEFERRED_RESULT} + # Otherwise, resolve it and return its value. + def resolve_or_defer_frame(scope, thread, frame) + if GraphQL::Execution::DirectiveChecks.defer?(frame.node) + thread.defers << frame + DEFERRED_RESULT + else + resolve_frame(scope, thread, frame) + end + end + + # Determine this frame's result and return it + # Any subselections marked as `@defer` will be deferred. + def resolve_frame(scope, thread, frame) + ast_node = frame.node.ast_node + case ast_node + when Nodes::OperationDefinition + resolve_selections(scope, thread, frame) + when Nodes::Field + type_defn = frame.type + field_defn = scope.get_field(type_defn, frame.node.definition_name) + field_result = resolve_field_frame(scope, thread, frame, field_defn) + return_type_defn = field_defn.type + + resolve_value( + scope, + thread, + frame, + field_result, + return_type_defn, + ) + else + raise("No defined resolution for #{ast_node.class.name} (#{ast_node})") + end + rescue GraphQL::InvalidNullError => err + if return_type_defn && return_type_defn.kind.non_null? + raise(err) + else + err.parent_error? || thread.errors << err + nil + end + end + + # Recursively resolve selections on `outer_frame.node`. + # Return a `Hash` of identifiers and results. + # Deferred fields will be absent from the result. + def resolve_selections(scope, thread, outer_frame) + merged_selections = outer_frame.node.children + query = scope.query + + resolved_selections = merged_selections.each_with_object({}) do |(name, irep_selection), memo| + field_applies_to_type = irep_selection.definitions.any? do |child_type, defn| + GraphQL::Execution::Typecast.compatible?(outer_frame.value, child_type, outer_frame.type, query.context) + end + if field_applies_to_type && !GraphQL::Execution::DirectiveChecks.skip?(irep_selection, query) + selection_key = irep_selection.name + + inner_frame = ExecFrame.new( + node: irep_selection, + path: outer_frame.path + [selection_key], + type: outer_frame.type, + value: outer_frame.value, + ) + + inner_result = resolve_or_defer_frame(scope, thread, inner_frame) + if inner_result != DEFERRED_RESULT + memo[selection_key] = inner_result + end + end + end + resolved_selections + end + + # Resolve `field_defn` on `frame.node`, returning the value + # of the {Field#resolve} proc. + # It might be an error or an object, not ready for serialization yet. + # @return [Object] the return value from `field_defn`'s resolve proc + def resolve_field_frame(scope, thread, frame, field_defn) + ast_node = frame.node.ast_node + type_defn = frame.type + value = frame.value + query = scope.query + + # Build arguments according to query-string literals, default values, and query variables + arguments = query.arguments_for(frame.node, field_defn) + + # This is the last call in the middleware chain; it actually calls the user's resolve proc + field_resolve_middleware_proc = -> (_parent_type, parent_object, field_definition, field_args, query_ctx, _next) { + query_ctx.ast_node = ast_node + query_ctx.irep_node = frame.node + value = field_definition.resolve(parent_object, field_args, query_ctx) + query_ctx.ast_node = nil + query_ctx.irep_node = frame.node + value + } + + # Send arguments through the middleware stack, + # ending with the field resolve call + steps = query.schema.middleware + [field_resolve_middleware_proc] + chain = GraphQL::Schema::MiddlewareChain.new( + steps: steps, + arguments: [type_defn, value, field_defn, arguments, query.context] + ) + + begin + resolve_fn_value = chain.call + rescue GraphQL::ExecutionError => err + resolve_fn_value = err + end + + if resolve_fn_value.is_a?(GraphQL::ExecutionError) + thread.errors << resolve_fn_value + resolve_fn_value.ast_node = ast_node + end + + resolve_fn_value + end + + # Recursively finish `value` which was returned from `frame`, + # expected to be an instance of `type_defn`. + # This coerces terminals and recursively resolves non-terminals (object, list, non-null). + # @return [Object] the response-ready version of `value` + def resolve_value(scope, thread, frame, value, type_defn) + if value.nil? || value.is_a?(GraphQL::ExecutionError) + if type_defn.kind.non_null? + raise GraphQL::InvalidNullError.new(frame.node.ast_node.name, value) + else + nil + end + else + case type_defn.kind + when GraphQL::TypeKinds::SCALAR, GraphQL::TypeKinds::ENUM + type_defn.coerce_result(value) + when GraphQL::TypeKinds::INTERFACE, GraphQL::TypeKinds::UNION + resolved_type = type_defn.resolve_type(value, scope) + + if !resolved_type.is_a?(GraphQL::ObjectType) + raise GraphQL::ObjectType::UnresolvedTypeError.new(frame.node.definition_name, type_defn, frame.node.parent.return_type) + else + resolve_value(scope, thread, frame, value, resolved_type) + end + when GraphQL::TypeKinds::NON_NULL + wrapped_type = type_defn.of_type + resolve_value(scope, thread, frame, value, wrapped_type) + when GraphQL::TypeKinds::LIST + wrapped_type = type_defn.of_type + items_enumerator = value.map.with_index + if GraphQL::Execution::DirectiveChecks.stream?(frame.node) + thread.defers << ExecStream.new( + enumerator: items_enumerator, + frame: frame, + type: wrapped_type, + ) + # The streamed list is empty in the initial resolve: + [] + else + resolved_values = items_enumerator.each do |item, idx| + inner_frame = ExecFrame.new({ + node: frame.node, + path: frame.path + [idx], + type: wrapped_type, + value: item, + }) + resolve_value(scope, thread, inner_frame, item, wrapped_type) + end + resolved_values + end + when GraphQL::TypeKinds::OBJECT + inner_frame = ExecFrame.new( + node: frame.node, + path: frame.path, + value: value, + type: type_defn, + ) + resolve_selections(scope, thread, inner_frame) + else + raise("No ResolveValue for kind: #{type_defn.kind.name} (#{type_defn})") + end + end + end + end + end +end diff --git a/lib/graphql/execution/directive_checks.rb b/lib/graphql/execution/directive_checks.rb index 0f013c72db..4bdeed80b7 100644 --- a/lib/graphql/execution/directive_checks.rb +++ b/lib/graphql/execution/directive_checks.rb @@ -5,9 +5,21 @@ module Execution module DirectiveChecks SKIP = "skip" INCLUDE = "include" + DEFER = "defer" + STREAM = "stream" module_function + # @return [Boolean] Should this AST node be deferred? + def defer?(irep_node) + irep_node.directives.any? { |dir| dir.parent.ast_node == irep_node.ast_node && dir.name == DEFER } + end + + # @return [Boolean] Should this AST node be streamed? + def stream?(irep_node) + irep_node.directives.any? { |dir| dir.name == STREAM } + end + # @return [Boolean] Should this node be included in the query? def include?(directive_irep_nodes, query) directive_irep_nodes.each do |directive_irep_node| diff --git a/lib/graphql/internal_representation/rewrite.rb b/lib/graphql/internal_representation/rewrite.rb index d1082cd57e..0dc14f5320 100644 --- a/lib/graphql/internal_representation/rewrite.rb +++ b/lib/graphql/internal_representation/rewrite.rb @@ -77,7 +77,7 @@ def validate(context) definition: context.directive_definition, definitions: {context.directive_definition => context.directive_definition}, # This isn't used, the directive may have many parents in the case of inline fragment - parent: nil, + parent: @nodes.last, ) @parent_directives.last.push(directive_irep_node) end diff --git a/lib/graphql/schema.rb b/lib/graphql/schema.rb index 4f22ebc10b..733a4b0bad 100644 --- a/lib/graphql/schema.rb +++ b/lib/graphql/schema.rb @@ -69,7 +69,15 @@ class Schema :query_analyzers, :middleware, :instrumenters BUILT_IN_TYPES = Hash[[INT_TYPE, STRING_TYPE, FLOAT_TYPE, BOOLEAN_TYPE, ID_TYPE].map{ |type| [type.name, type] }] - DIRECTIVES = [GraphQL::Directive::IncludeDirective, GraphQL::Directive::SkipDirective, GraphQL::Directive::DeprecatedDirective] + + DIRECTIVES = [ + GraphQL::Directive::SkipDirective, + GraphQL::Directive::IncludeDirective, + GraphQL::Directive::DeprecatedDirective, + GraphQL::Directive::DeferDirective, + GraphQL::Directive::StreamDirective, + ] + DYNAMIC_FIELDS = ["__type", "__typename", "__schema"] attr_reader :static_validator, :object_from_id_proc, :id_from_object_proc, :resolve_type_proc @@ -93,7 +101,7 @@ def initialize @id_from_object_proc = nil @instrumenters = Hash.new { |h, k| h[k] = [] } # Default to the built-in execution strategy: - @query_execution_strategy = GraphQL::Query::SerialExecution + @query_execution_strategy = GraphQL::Execution::DeferredExecution @mutation_execution_strategy = GraphQL::Query::SerialExecution @subscription_execution_strategy = GraphQL::Query::SerialExecution end diff --git a/spec/graphql/execution/deferred_execution_spec.rb b/spec/graphql/execution/deferred_execution_spec.rb new file mode 100644 index 0000000000..9644307ec9 --- /dev/null +++ b/spec/graphql/execution/deferred_execution_spec.rb @@ -0,0 +1,355 @@ +require "spec_helper" + +class ArrayCollector + attr_reader :patches + def initialize(logger: nil) + @patches = [] + @logger = logger + end + + def patch(path:, value:) + @logger && @logger.push({path: path, value: value}) + patches << {path: path, value: value} + end + + def merged_result + patches.each_with_object({}) do |patch, result| + path = patch[:path] + patch_value = patch[:value] + target_key = path.last + if target_key.nil? # first patch + result.merge!(patch_value) + else + target_hash = result + path_to_hash = path[0..-2] + # Move down the response, adding hashes if the key isn't found + path_to_hash.each do |part| + target_hash = target_hash[part] ||= {} + end + target_hash[target_key] = patch_value + end + end + end +end + +describe GraphQL::Execution::DeferredExecution do + before do + @prev_execution_strategy = DummySchema.query_execution_strategy + DummySchema.query_execution_strategy = GraphQL::Execution::DeferredExecution + end + + after do + DummySchema.query_execution_strategy = @prev_execution_strategy + end + + let(:collector) { ArrayCollector.new } + let(:result) { + DummySchema.execute(query_string, context: {collector: collector}) + } + + describe "@defer-ed fields" do + let(:query_string) {%| + { + cheese(id: 1) { + id + flavor + origin @defer + cheeseSource: source @defer + } + } + |} + + it "emits them later" do + result + assert_equal 3, collector.patches.length + + expected_first_patch = { + path: [], + value: {"data" => { + "cheese" => { + "id" => 1, + "flavor" => "Brie", + } + }} + } + expected_second_patch = { + path: ["data", "cheese", "origin"], + value: "France" + } + expected_third_patch = { + path: ["data", "cheese", "cheeseSource"], + value: "COW", + } + + assert_equal(expected_first_patch, collector.patches[0]) + assert_equal(expected_second_patch, collector.patches[1]) + assert_equal(expected_third_patch, collector.patches[2]) + end + + it "can be reassembled into a single response" do + result + expected_data = { + "cheese" => { + "id" => 1, + "flavor" => "Brie", + "origin" => "France", + "cheeseSource" => "COW", + } + } + assert_equal({"data" => expected_data }, collector.merged_result) + end + end + + describe "nested @defers" do + let(:query_string) {%| + { + cheese(id: 1) @defer { + id + flavor + origin @defer + } + } + |} + + it "patches the object, then the field" do + result + assert_equal 3, collector.patches.length + + assert_equal([], collector.patches[0][:path]) + assert_equal({ "data" => {} }, collector.patches[0][:value]) + + assert_equal(["data", "cheese"], collector.patches[1][:path]) + assert_equal({"id" => 1, "flavor" => "Brie"}, collector.patches[1][:value]) + + assert_equal(["data", "cheese", "origin"], collector.patches[2][:path]) + assert_equal("France", collector.patches[2][:value]) + end + end + + describe "@defer-ing a list" do + let(:query_string) {%| + { + cheeses @defer { + id + chzFlav: flavor @defer + similarCheese(source: COW) { + id + flavor @defer + } + } + } + |} + it "patches the whole list, then the member fields" do + result + assert_equal 8, collector.patches.length + expected_patches = [ + { + path: [], + value: { "data" => {} } + }, + { + path: ["data", "cheeses"], + value: [ + {"id"=>1, "similarCheese"=>{"id"=>1}}, + {"id"=>2, "similarCheese"=>{"id"=>1}}, + {"id"=>3, "similarCheese"=>{"id"=>1}} + ] + }, + { + path: ["data", "cheeses", 0, "chzFlav"], + value: "Brie" + }, + { + path: ["data", "cheeses", 0, "similarCheese", "flavor"], + value: "Brie" + }, + { + path: ["data", "cheeses", 1, "chzFlav"], + value: "Gouda" + }, + { + path: ["data", "cheeses", 1, "similarCheese", "flavor"], + value: "Brie" + }, + { + path: ["data", "cheeses", 2, "chzFlav"], + value: "Manchego" + }, + { + path: ["data", "cheeses", 2, "similarCheese", "flavor"], + value: "Brie" + }, + ] + + assert_equal(expected_patches, collector.patches) + expected_data = { + "cheeses" => [ + { + "id"=>1, + "chzFlav"=>"Brie", + "similarCheese"=>{"id"=>1, "flavor"=>"Brie"} + }, + { + "id"=>2, + "chzFlav"=>"Gouda", + "similarCheese"=>{"id"=>1, "flavor"=>"Brie"} + }, + { + "id"=>3, + "chzFlav"=>"Manchego", + "similarCheese"=>{"id"=>1, "flavor"=>"Brie"} + } + ] + } + assert_equal(expected_data, collector.merged_result["data"]) + end + end + + describe "@defer with errors" do + describe "when errors are handled" do + let(:query_string) {%| + { + error1: executionError + error2: executionError @defer + error3: executionError @defer + } + |} + it "patches errors to the errors key" do + result + assert_equal(3, collector.patches.length) + assert_equal([], collector.patches[0][:path]) + assert_equal([{"message" => "There was an execution error", "locations"=>[{"line"=>3, "column"=>11}]}], collector.patches[0][:value]["errors"]) + assert_equal({"error1"=>nil}, collector.patches[0][:value]["data"]) + assert_equal(["errors", 1], collector.patches[1][:path]) + assert_equal({"message"=>"There was an execution error", "locations"=>[{"line"=>4, "column"=>11}]}, collector.patches[1][:value]) + assert_equal(["errors", 2], collector.patches[2][:path]) + assert_equal({"message"=>"There was an execution error", "locations"=>[{"line"=>5, "column"=>11}]}, collector.patches[2][:value]) + end + end + + describe "when errors are raised" do + let(:query_string) {%| + { + error + cheese(id: 1) @defer { id } + } + |} + + it "dies altogether" do + assert_raises(RuntimeError) { result } + assert_equal 0, collector.patches.length + end + end + end + + describe "@stream-ed fields" do + let(:query_string) {%| + { + cheeses @stream { + id + } + } + |} + + it "pushes an empty list, then each item" do + result + + assert_equal(4, collector.patches.length) + assert_equal([], collector.patches[0][:path]) + assert_equal({"data" => { "cheeses" => [] } }, collector.patches[0][:value]) + assert_equal(["data", "cheeses", 0], collector.patches[1][:path]) + assert_equal({"id"=>1}, collector.patches[1][:value]) + assert_equal(["data", "cheeses", 1], collector.patches[2][:path]) + assert_equal({"id"=>2}, collector.patches[2][:value]) + assert_equal(["data", "cheeses", 2], collector.patches[3][:path]) + assert_equal({"id"=>3}, collector.patches[3][:value]) + end + + describe "nested defers" do + let(:query_string) {%| + { + cheeses @stream { + id + flavor @defer + } + } + |} + + it "pushes them after streaming items" do + result + + # - 1 empty-list + (1 id + 1 flavor) * list-items + assert_equal(7, collector.patches.length) + assert_equal([], collector.patches[0][:path]) + assert_equal({"data" => { "cheeses" => [] } }, collector.patches[0][:value]) + assert_equal(["data", "cheeses", 0], collector.patches[1][:path]) + assert_equal({"id"=>1}, collector.patches[1][:value]) + assert_equal(["data", "cheeses", 0, "flavor"], collector.patches[2][:path]) + assert_equal("Brie", collector.patches[2][:value]) + assert_equal(["data", "cheeses", 1], collector.patches[3][:path]) + assert_equal({"id"=>2}, collector.patches[3][:value]) + assert_equal(["data", "cheeses", 1, "flavor"], collector.patches[4][:path]) + assert_equal("Gouda", collector.patches[4][:value]) + assert_equal(["data", "cheeses", 2], collector.patches[5][:path]) + assert_equal({"id"=>3}, collector.patches[5][:value]) + assert_equal(["data", "cheeses", 2, "flavor"], collector.patches[6][:path]) + assert_equal("Manchego", collector.patches[6][:value]) + end + end + + describe "lazy enumerators" do + let(:event_log) { [] } + let(:collector) { ArrayCollector.new(logger: event_log) } + let(:lazy_schema) { + logger = event_log + query_type = GraphQL::ObjectType.define do + name("Query") + field :lazyInts, types[types.Int] do + argument :items, !types.Int + resolve -> (obj, args, ctx) { + Enumerator.new do |yielder| + i = 0 + while i < args[:items] + logger.push({yield: i}) + yielder.yield(i) + i += 1 + end + end + } + end + end + schema = GraphQL::Schema.new(query: query_type) + schema.query_execution_strategy = GraphQL::Execution::DeferredExecution + schema + } + + let(:result) { + lazy_schema.execute(query_string, context: {collector: collector}) + } + + let(:query_string) {%| + { + lazyInts(items: 4) @stream + } + |} + + it "evaluates them lazily" do + result + # The goal here is that the enumerator is called + # _after_ each patch, not all once ahead of time. + expected_events = [ + {path: [], value: {"data"=>{"lazyInts"=>[]}}}, + {yield: 0}, + {path: ["data", "lazyInts", 0], value: 0}, + {yield: 1}, + {path: ["data", "lazyInts", 1], value: 1}, + {yield: 2}, + {path: ["data", "lazyInts", 2], value: 2}, + {yield: 3}, + {path: ["data", "lazyInts", 3], value: 3} + ] + assert_equal expected_events, event_log + end + end + end +end diff --git a/spec/graphql/introspection/directive_type_spec.rb b/spec/graphql/introspection/directive_type_spec.rb index 4dea227e5d..709edc6d1d 100644 --- a/spec/graphql/introspection/directive_type_spec.rb +++ b/spec/graphql/introspection/directive_type_spec.rb @@ -52,6 +52,22 @@ "onFragment" => false, "onOperation" => false, }, + { + "name"=>"defer", + "args"=>[], + "locations"=>["FIELD", "FRAGMENT_SPREAD", "INLINE_FRAGMENT"], + "onField"=>true, + "onFragment"=>true, + "onOperation"=>false, + }, + { + "name"=>"stream", + "args"=>[], + "locations"=>["FIELD"], + "onField"=>true, + "onFragment"=>false, + "onOperation"=>false, + }, ] } }} diff --git a/spec/graphql/introspection/schema_type_spec.rb b/spec/graphql/introspection/schema_type_spec.rb index fc93f611e8..2e9f1c7a71 100644 --- a/spec/graphql/introspection/schema_type_spec.rb +++ b/spec/graphql/introspection/schema_type_spec.rb @@ -21,6 +21,7 @@ {"name"=>"allDairy"}, {"name"=>"allEdible"}, {"name"=>"cheese"}, + {"name"=>"cheeses"}, {"name"=>"cow"}, {"name"=>"dairy"}, {"name"=>"deepNonNull"}, diff --git a/spec/support/dairy_app.rb b/spec/support/dairy_app.rb index db3821b380..e956ed1d11 100644 --- a/spec/support/dairy_app.rb +++ b/spec/support/dairy_app.rb @@ -254,6 +254,9 @@ def self.create(type:, data:) resolve ->(root_value, args, c) { root_value } end field :cheese, field: FetchField.create(type: CheeseType, data: CHEESES) + field :cheeses, types[CheeseType] do + resolve -> (obj, args, ctx) { CHEESES.values } + end field :milk, field: FetchField.create(type: MilkType, data: MILKS, id_type: !types.ID) field :dairy, field: SingletonField.create(type: DairyType, data: DAIRY) field :fromSource, &SourceFieldDefn From cc6d5ca3d3730800d24397e5e14582c49ddf09a4 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Fri, 2 Sep 2016 16:02:04 -0500 Subject: [PATCH 02/10] test nested streams --- .../graphql/execution/deferred_execution_spec.rb | 16 ++++++++++++++++ spec/support/dairy_app.rb | 3 +++ spec/support/dairy_data.rb | 1 + 3 files changed, 20 insertions(+) diff --git a/spec/graphql/execution/deferred_execution_spec.rb b/spec/graphql/execution/deferred_execution_spec.rb index 9644307ec9..5634c8856e 100644 --- a/spec/graphql/execution/deferred_execution_spec.rb +++ b/spec/graphql/execution/deferred_execution_spec.rb @@ -265,6 +265,22 @@ def merged_result assert_equal({"id"=>3}, collector.patches[3][:value]) end + describe "lists inside @streamed lists" do + let(:query_string) {%| + { + milks @stream { + flavors + } + } + |} + + it "streams the outer list but sends the inner list wholesale" do + result + # One with an empty list, then 2 patches + assert_equal(3, collector.patches.length) + end + end + describe "nested defers" do let(:query_string) {%| { diff --git a/spec/support/dairy_app.rb b/spec/support/dairy_app.rb index e956ed1d11..9b63fe6691 100644 --- a/spec/support/dairy_app.rb +++ b/spec/support/dairy_app.rb @@ -258,6 +258,9 @@ def self.create(type:, data:) resolve -> (obj, args, ctx) { CHEESES.values } end field :milk, field: FetchField.create(type: MilkType, data: MILKS, id_type: !types.ID) + field :milks, types[MilkType] do + resolve -> (obj, args, ctx) { MILKS.values } + end field :dairy, field: SingletonField.create(type: DairyType, data: DAIRY) field :fromSource, &SourceFieldDefn field :favoriteEdible, FavoriteFieldDefn diff --git a/spec/support/dairy_data.rb b/spec/support/dairy_data.rb index 4dcf01895f..c1fc822b70 100644 --- a/spec/support/dairy_data.rb +++ b/spec/support/dairy_data.rb @@ -10,6 +10,7 @@ Milk = Struct.new(:id, :fatContent, :origin, :source, :flavors) MILKS = { 1 => Milk.new(1, 0.04, "Antiquity", 1, ["Natural", "Chocolate", "Strawberry"]), + 2 => Milk.new(2, 0.02, "Antiquity", 1, ["Natural"]), } DAIRY = OpenStruct.new( From 11498ac43ef196dd0e0f6b7d72c2ae6aea8d94ba Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Fri, 21 Oct 2016 18:03:03 -0400 Subject: [PATCH 03/10] feat(DeferredExecution) update for latest changes --- lib/graphql/execution/deferred_execution.rb | 43 +++++++++++++++---- lib/graphql/internal_representation/node.rb | 1 + lib/graphql/schema.rb | 2 +- lib/graphql/schema/printer.rb | 3 +- .../execution/deferred_execution_spec.rb | 8 ++-- spec/graphql/execution_error_spec.rb | 29 ++++++++++--- spec/graphql/interface_type_spec.rb | 1 + .../graphql/introspection/schema_type_spec.rb | 1 + spec/graphql/schema/printer_spec.rb | 6 +++ spec/graphql/union_type_spec.rb | 2 + spec/support/dairy_data.rb | 2 +- 11 files changed, 78 insertions(+), 20 deletions(-) diff --git a/lib/graphql/execution/deferred_execution.rb b/lib/graphql/execution/deferred_execution.rb index 59c88440f1..0c4ddb38f5 100644 --- a/lib/graphql/execution/deferred_execution.rb +++ b/lib/graphql/execution/deferred_execution.rb @@ -51,6 +51,16 @@ class DeferredExecution # result was defered, so it should be empty in the patch. DEFERRED_RESULT = :__deferred_result__ + # TODO: this is necessary to send the error to a context where + # the parent type name is available + class InnerInvalidNullError < GraphQL::Error + attr_reader :field_name, :value + def initialize(field_name, value) + @field_name = field_name + @value = value + end + end + # Execute `ast_operation`, a member of `query_object`, starting from `root_type`. # # Results will be sent to `query_object.context[CONTEXT_PATCH_TARGET]` in the form @@ -260,7 +270,14 @@ def resolve_frame(scope, thread, frame) else raise("No defined resolution for #{ast_node.class.name} (#{ast_node})") end - rescue GraphQL::InvalidNullError => err + rescue InvalidNullError, InnerInvalidNullError => inner_err + # TODO omg + err = if inner_err.is_a?(InnerInvalidNullError) + GraphQL::InvalidNullError.new(type_defn.name, inner_err.field_name, inner_err.value) + else + inner_err + end + if return_type_defn && return_type_defn.kind.non_null? raise(err) else @@ -278,9 +295,9 @@ def resolve_selections(scope, thread, outer_frame) resolved_selections = merged_selections.each_with_object({}) do |(name, irep_selection), memo| field_applies_to_type = irep_selection.definitions.any? do |child_type, defn| - GraphQL::Execution::Typecast.compatible?(outer_frame.value, child_type, outer_frame.type, query.context) + GraphQL::Execution::Typecast.compatible?(child_type, outer_frame.type, query.context) end - if field_applies_to_type && !GraphQL::Execution::DirectiveChecks.skip?(irep_selection, query) + if field_applies_to_type && irep_selection.included? selection_key = irep_selection.name inner_frame = ExecFrame.new( @@ -336,9 +353,19 @@ def resolve_field_frame(scope, thread, frame, field_defn) resolve_fn_value = err end - if resolve_fn_value.is_a?(GraphQL::ExecutionError) + case resolve_fn_value + when GraphQL::ExecutionError thread.errors << resolve_fn_value resolve_fn_value.ast_node = ast_node + resolve_fn_value.path = frame.path + when Array + resolve_fn_value.each_with_index do |item, idx| + if item.is_a?(GraphQL::ExecutionError) + item.ast_node = ast_node + item.path = frame.path + [idx] + thread.errors << item + end + end end resolve_fn_value @@ -351,7 +378,7 @@ def resolve_field_frame(scope, thread, frame, field_defn) def resolve_value(scope, thread, frame, value, type_defn) if value.nil? || value.is_a?(GraphQL::ExecutionError) if type_defn.kind.non_null? - raise GraphQL::InvalidNullError.new(frame.node.ast_node.name, value) + raise InnerInvalidNullError.new(frame.node.ast_node.name, value) else nil end @@ -360,10 +387,10 @@ def resolve_value(scope, thread, frame, value, type_defn) when GraphQL::TypeKinds::SCALAR, GraphQL::TypeKinds::ENUM type_defn.coerce_result(value) when GraphQL::TypeKinds::INTERFACE, GraphQL::TypeKinds::UNION - resolved_type = type_defn.resolve_type(value, scope) + resolved_type = scope.schema.resolve_type(value, scope.query.context) - if !resolved_type.is_a?(GraphQL::ObjectType) - raise GraphQL::ObjectType::UnresolvedTypeError.new(frame.node.definition_name, type_defn, frame.node.parent.return_type) + if !resolved_type.is_a?(GraphQL::ObjectType) || !scope.schema.possible_types(type_defn).include?(resolved_type) + raise GraphQL::UnresolvedTypeError.new(frame.node.definition_name, scope.schema, type_defn, frame.node.parent.return_type, resolved_type) else resolve_value(scope, thread, frame, value, resolved_type) end diff --git a/lib/graphql/internal_representation/node.rb b/lib/graphql/internal_representation/node.rb index ff788442a7..c03cc56dce 100644 --- a/lib/graphql/internal_representation/node.rb +++ b/lib/graphql/internal_representation/node.rb @@ -98,6 +98,7 @@ def owner end end + # TODO? This can be deprecated: ExecFrames track their paths def path if parent path = parent.path diff --git a/lib/graphql/schema.rb b/lib/graphql/schema.rb index 733a4b0bad..b6a43dd93a 100644 --- a/lib/graphql/schema.rb +++ b/lib/graphql/schema.rb @@ -71,8 +71,8 @@ class Schema BUILT_IN_TYPES = Hash[[INT_TYPE, STRING_TYPE, FLOAT_TYPE, BOOLEAN_TYPE, ID_TYPE].map{ |type| [type.name, type] }] DIRECTIVES = [ - GraphQL::Directive::SkipDirective, GraphQL::Directive::IncludeDirective, + GraphQL::Directive::SkipDirective, GraphQL::Directive::DeprecatedDirective, GraphQL::Directive::DeferDirective, GraphQL::Directive::StreamDirective, diff --git a/lib/graphql/schema/printer.rb b/lib/graphql/schema/printer.rb index 29abe5a3a2..f4c3af0952 100644 --- a/lib/graphql/schema/printer.rb +++ b/lib/graphql/schema/printer.rb @@ -56,7 +56,8 @@ def print_schema_definition(schema) private_constant :BUILTIN_SCALARS def is_spec_directive(directive) - ['skip', 'include', 'deprecated'].include?(directive.name) + # TODO: make defer & stream opt-in + ['skip', 'include', 'deprecated', 'defer', 'stream'].include?(directive.name) end def is_introspection_type(type) diff --git a/spec/graphql/execution/deferred_execution_spec.rb b/spec/graphql/execution/deferred_execution_spec.rb index 5634c8856e..dd61910901 100644 --- a/spec/graphql/execution/deferred_execution_spec.rb +++ b/spec/graphql/execution/deferred_execution_spec.rb @@ -218,12 +218,12 @@ def merged_result result assert_equal(3, collector.patches.length) assert_equal([], collector.patches[0][:path]) - assert_equal([{"message" => "There was an execution error", "locations"=>[{"line"=>3, "column"=>11}]}], collector.patches[0][:value]["errors"]) + assert_equal([{"message" => "There was an execution error", "locations"=>[{"line"=>3, "column"=>11}], "path"=>["error1"]}], collector.patches[0][:value]["errors"]) assert_equal({"error1"=>nil}, collector.patches[0][:value]["data"]) assert_equal(["errors", 1], collector.patches[1][:path]) - assert_equal({"message"=>"There was an execution error", "locations"=>[{"line"=>4, "column"=>11}]}, collector.patches[1][:value]) + assert_equal({"message"=>"There was an execution error", "locations"=>[{"line"=>4, "column"=>11}], "path"=>["error2"]}, collector.patches[1][:value]) assert_equal(["errors", 2], collector.patches[2][:path]) - assert_equal({"message"=>"There was an execution error", "locations"=>[{"line"=>5, "column"=>11}]}, collector.patches[2][:value]) + assert_equal({"message"=>"There was an execution error", "locations"=>[{"line"=>5, "column"=>11}], "path"=>["error3"]}, collector.patches[2][:value]) end end @@ -334,7 +334,7 @@ def merged_result } end end - schema = GraphQL::Schema.new(query: query_type) + schema = GraphQL::Schema.define(query: query_type) schema.query_execution_strategy = GraphQL::Execution::DeferredExecution schema } diff --git a/spec/graphql/execution_error_spec.rb b/spec/graphql/execution_error_spec.rb index 090a419cc1..a833d1d3f1 100644 --- a/spec/graphql/execution_error_spec.rb +++ b/spec/graphql/execution_error_spec.rb @@ -68,13 +68,15 @@ { "flavor" => "Brie" }, { "flavor" => "Gouda" }, { "flavor" => "Manchego" }, - { "source" => "COW", "executionError" => nil } + { "source" => "COW", "executionError" => nil }, + { "source" => "COW", "executionError" => nil }, ], "dairyErrors" => [ { "__typename" => "Cheese" }, nil, { "__typename" => "Cheese" }, - { "__typename" => "Milk" } + { "__typename" => "Milk" }, + { "__typename" => "Milk" }, ], "dairy" => { "milks" => [ @@ -85,7 +87,8 @@ { "__typename" => "Cheese" }, { "__typename" => "Cheese" }, { "__typename" => "Cheese" }, - { "__typename" => "Milk", "origin" => "Antiquity", "executionError" => nil } + { "__typename" => "Milk", "origin" => "Antiquity", "executionError" => nil }, + { "__typename" => "Milk", "origin" => "Modernity", "executionError" => nil }, ] } ] @@ -109,6 +112,11 @@ "locations"=>[{"line"=>22, "column"=>11}], "path"=>["allDairy", 3, "executionError"] }, + { + "message"=>"There was an execution error", + "locations"=>[{"line"=>22, "column"=>11}], + "path"=>["allDairy", 4, "executionError"] + }, { "message"=>"missing dairy", "locations"=>[{"line"=>25, "column"=>7}], @@ -124,6 +132,11 @@ "locations"=>[{"line"=>36, "column"=>15}], "path"=>["dairy", "milks", 0, "allDairy", 3, "executionError"] }, + { + "message"=>"There was an execution error", + "locations"=>[{"line"=>36, "column"=>15}], + "path"=>["dairy", "milks", 0, "allDairy", 4, "executionError"] + }, { "message"=>"There was an execution error", "locations"=>[{"line"=>41, "column"=>7}], @@ -170,7 +183,8 @@ { "__typename" => "Cheese" }, { "__typename" => "Cheese" }, { "__typename" => "Cheese" }, - { "__typename" => "Milk", "origin" => "Antiquity", "executionError" => nil } + { "__typename" => "Milk", "origin" => "Antiquity", "executionError" => nil }, + { "__typename" => "Milk", "origin" => "Modernity", "executionError" => nil }, ] } ] @@ -186,7 +200,12 @@ "message"=>"There was an execution error", "locations"=>[{"line"=>11, "column"=>15}], "path"=>["dairy", "milks", 0, "allDairy", 3, "executionError"] - } + }, + { + "message"=>"There was an execution error", + "locations"=>[{"line"=>11, "column"=>15}], + "path"=>["dairy", "milks", 0, "allDairy", 4, "executionError"] + }, ] } assert_equal(expected_result, result) diff --git a/spec/graphql/interface_type_spec.rb b/spec/graphql/interface_type_spec.rb index 4cdeb35902..e34f072ce7 100644 --- a/spec/graphql/interface_type_spec.rb +++ b/spec/graphql/interface_type_spec.rb @@ -77,6 +77,7 @@ {"__typename"=>"Cheese", "origin"=>"Netherlands"}, {"__typename"=>"Cheese", "origin"=>"Spain"}, {"__typename"=>"Milk", "origin"=>"Antiquity"}, + {"__typename"=>"Milk", "origin"=>"Modernity"}, ] assert_equal expected_data, result["data"]["allEdible"] diff --git a/spec/graphql/introspection/schema_type_spec.rb b/spec/graphql/introspection/schema_type_spec.rb index 2e9f1c7a71..1cf5614d68 100644 --- a/spec/graphql/introspection/schema_type_spec.rb +++ b/spec/graphql/introspection/schema_type_spec.rb @@ -31,6 +31,7 @@ {"name"=>"fromSource"}, {"name"=>"maybeNull"}, {"name"=>"milk"}, + {"name"=>"milks"}, {"name"=>"root"}, {"name"=>"searchDairy"}, {"name"=>"valueWithExecutionError"}, diff --git a/spec/graphql/schema/printer_spec.rb b/spec/graphql/schema/printer_spec.rb index f94906dbe3..f46966947e 100644 --- a/spec/graphql/schema/printer_spec.rb +++ b/spec/graphql/schema/printer_spec.rb @@ -154,6 +154,12 @@ reason: String = "No longer supported" ) on FIELD_DEFINITION | ENUM_VALUE +# Push this part of the query in a later patch +directive @defer on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT + +# Push items from this list in sequential patches +directive @stream on FIELD + # A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. # # In some cases, you need to provide options to alter GraphQL's execution behavior diff --git a/spec/graphql/union_type_spec.rb b/spec/graphql/union_type_spec.rb index b8ccffeab6..e9d0114cb6 100644 --- a/spec/graphql/union_type_spec.rb +++ b/spec/graphql/union_type_spec.rb @@ -47,7 +47,9 @@ {"dairyName"=>"Cheese"}, {"dairyName"=>"Cheese"}, {"dairyName"=>"Milk", "bevName"=>"Milk", "flavors"=>["Natural", "Chocolate", "Strawberry"]}, + {"dairyName"=>"Milk", "bevName"=>"Milk", "flavors"=>["Natural"]}, ] + assert_equal expected_result, result["data"]["allDairy"] end end diff --git a/spec/support/dairy_data.rb b/spec/support/dairy_data.rb index c1fc822b70..61a2a128f1 100644 --- a/spec/support/dairy_data.rb +++ b/spec/support/dairy_data.rb @@ -10,7 +10,7 @@ Milk = Struct.new(:id, :fatContent, :origin, :source, :flavors) MILKS = { 1 => Milk.new(1, 0.04, "Antiquity", 1, ["Natural", "Chocolate", "Strawberry"]), - 2 => Milk.new(2, 0.02, "Antiquity", 1, ["Natural"]), + 2 => Milk.new(2, 0.02, "Modernity", 1, ["Natural"]), } DAIRY = OpenStruct.new( From f1f35ef59664066ac5a7e3243364b8c38ad36c97 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Tue, 25 Oct 2016 22:05:35 -0400 Subject: [PATCH 04/10] feat(Schema) accept optional directives in definition --- lib/graphql/execution/deferred_execution.rb | 13 +++---- lib/graphql/schema.rb | 21 ++++++++--- .../execution/deferred_execution_spec.rb | 2 +- spec/graphql/schema/printer_spec.rb | 6 ---- spec/graphql/schema_spec.rb | 36 +++++++++++++++++++ spec/support/dairy_app.rb | 2 ++ 6 files changed, 63 insertions(+), 17 deletions(-) diff --git a/lib/graphql/execution/deferred_execution.rb b/lib/graphql/execution/deferred_execution.rb index 0c4ddb38f5..866902abbe 100644 --- a/lib/graphql/execution/deferred_execution.rb +++ b/lib/graphql/execution/deferred_execution.rb @@ -329,14 +329,12 @@ def resolve_field_frame(scope, thread, frame, field_defn) # Build arguments according to query-string literals, default values, and query variables arguments = query.arguments_for(frame.node, field_defn) + query.context.ast_node = ast_node + query.context.irep_node = frame.node + # This is the last call in the middleware chain; it actually calls the user's resolve proc field_resolve_middleware_proc = -> (_parent_type, parent_object, field_definition, field_args, query_ctx, _next) { - query_ctx.ast_node = ast_node - query_ctx.irep_node = frame.node - value = field_definition.resolve(parent_object, field_args, query_ctx) - query_ctx.ast_node = nil - query_ctx.irep_node = frame.node - value + field_definition.resolve(parent_object, field_args, query_ctx) } # Send arguments through the middleware stack, @@ -353,6 +351,9 @@ def resolve_field_frame(scope, thread, frame, field_defn) resolve_fn_value = err end + query.context.ast_node = nil + query.context.irep_node = nil + case resolve_fn_value when GraphQL::ExecutionError thread.errors << resolve_fn_value diff --git a/lib/graphql/schema.rb b/lib/graphql/schema.rb index b6a43dd93a..fb174a84ba 100644 --- a/lib/graphql/schema.rb +++ b/lib/graphql/schema.rb @@ -51,6 +51,7 @@ class Schema include GraphQL::Define::InstanceDefinable accepts_definitions \ :query, :mutation, :subscription, + :directives, :query_execution_strategy, :mutation_execution_strategy, :subscription_execution_strategy, :max_depth, :max_complexity, :orphan_types, :resolve_type, @@ -70,14 +71,17 @@ class Schema BUILT_IN_TYPES = Hash[[INT_TYPE, STRING_TYPE, FLOAT_TYPE, BOOLEAN_TYPE, ID_TYPE].map{ |type| [type.name, type] }] - DIRECTIVES = [ + BUILT_IN_DIRECTIVES = [ GraphQL::Directive::IncludeDirective, GraphQL::Directive::SkipDirective, GraphQL::Directive::DeprecatedDirective, - GraphQL::Directive::DeferDirective, - GraphQL::Directive::StreamDirective, ] + OPTIONAL_DIRECTIVES = { + "@defer" => GraphQL::Directive::DeferDirective, + "@stream" => GraphQL::Directive::StreamDirective, + } + DYNAMIC_FIELDS = ["__type", "__typename", "__schema"] attr_reader :static_validator, :object_from_id_proc, :id_from_object_proc, :resolve_type_proc @@ -92,7 +96,7 @@ class Schema # @param types [Array] additional types to include in this schema def initialize @orphan_types = [] - @directives = DIRECTIVES.reduce({}) { |m, d| m[d.name] = d; m } + @directives = BUILT_IN_DIRECTIVES.reduce({}) { |m, d| m[d.name] = d; m } @static_validator = GraphQL::StaticValidation::Validator.new(schema: self) @middleware = [] @query_analyzers = [] @@ -106,6 +110,15 @@ def initialize @subscription_execution_strategy = GraphQL::Query::SerialExecution end + def directives=(directive_names) + ensure_defined + directive_names.each do |name| + dir = OPTIONAL_DIRECTIVES.fetch(name) + @directives[dir.name] = dir + end + @directives + end + def rescue_from(*args, &block) rescue_middleware.rescue_from(*args, &block) end diff --git a/spec/graphql/execution/deferred_execution_spec.rb b/spec/graphql/execution/deferred_execution_spec.rb index dd61910901..a0b20e3807 100644 --- a/spec/graphql/execution/deferred_execution_spec.rb +++ b/spec/graphql/execution/deferred_execution_spec.rb @@ -334,7 +334,7 @@ def merged_result } end end - schema = GraphQL::Schema.define(query: query_type) + schema = GraphQL::Schema.define(query: query_type, directives: ["@stream", "@defer"]) schema.query_execution_strategy = GraphQL::Execution::DeferredExecution schema } diff --git a/spec/graphql/schema/printer_spec.rb b/spec/graphql/schema/printer_spec.rb index f46966947e..f94906dbe3 100644 --- a/spec/graphql/schema/printer_spec.rb +++ b/spec/graphql/schema/printer_spec.rb @@ -154,12 +154,6 @@ reason: String = "No longer supported" ) on FIELD_DEFINITION | ENUM_VALUE -# Push this part of the query in a later patch -directive @defer on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT - -# Push items from this list in sequential patches -directive @stream on FIELD - # A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. # # In some cases, you need to provide options to alter GraphQL's execution behavior diff --git a/spec/graphql/schema_spec.rb b/spec/graphql/schema_spec.rb index 59dffb7608..e20cc98dad 100644 --- a/spec/graphql/schema_spec.rb +++ b/spec/graphql/schema_spec.rb @@ -253,4 +253,40 @@ def after_query(query) assert_equal [1, 2], variable_counter.counts end end + + describe "#directives" do + let(:schema) { + query_type = GraphQL::ObjectType.define do + name "Query" + field :one, types.Int, resolve: -> (o,a,c) { 1 } + end + + schema_directives = directives + + GraphQL::Schema.define do + query(query_type) + directives(schema_directives) + end + } + + describe "when @defer is not provided" do + let(:directives) { [] } + it "doesn't execute queries with defer" do + res = schema.execute("{ one @defer }") + assert_equal nil, res["data"] + assert_equal 1, res["errors"].length + end + end + + describe "when @defer is provided" do + let(:directives) { ["@defer"] } + + it "executes queries with defer" do + res = schema.execute("{ one @defer, two: one }") + # The deferred field is left out ?? + assert_equal({ "two" => 1 }, res["data"]) + assert_equal nil, res["errors"] + end + end + end end diff --git a/spec/support/dairy_app.rb b/spec/support/dairy_app.rb index 9b63fe6691..4025128745 100644 --- a/spec/support/dairy_app.rb +++ b/spec/support/dairy_app.rb @@ -368,6 +368,8 @@ def self.create(type:, data:) rescue_from(NoSuchDairyError) { |err| err.message } + directives ["@defer", "@stream"] + resolve_type ->(obj, ctx) { DummySchema.types[obj.class.name] } From 3181bcd3783f080e89082952ca07b2c8173b3cd3 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Tue, 25 Oct 2016 22:23:02 -0400 Subject: [PATCH 05/10] refactor(test) parameterize the default execution strategy for testing --- .travis.yml | 1 + Rakefile | 6 ++++++ lib/graphql/schema.rb | 2 +- spec/graphql/analysis/query_complexity_spec.rb | 3 ++- spec/graphql/argument_spec.rb | 2 +- spec/graphql/introspection/introspection_query_spec.rb | 1 + spec/graphql/query/context_spec.rb | 2 +- spec/graphql/query/executor_spec.rb | 2 +- .../graphql/query/serial_execution/value_resolution_spec.rb | 1 + spec/graphql/schema/loader_spec.rb | 1 + spec/graphql/schema_spec.rb | 4 ++-- spec/spec_helper.rb | 4 +++- spec/support/dairy_app.rb | 2 ++ spec/support/star_wars_schema.rb | 2 +- 14 files changed, 24 insertions(+), 9 deletions(-) diff --git a/.travis.yml b/.travis.yml index 4b4449b395..681532fdba 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,6 +3,7 @@ language: ruby sudo: false cache: bundler env: CODECLIMATE_REPO_TOKEN=f5b27b2e25d3f4e199bb2566fb4fd4507144a004c71d4aa33a32c2df5f940333 +script: bundle exec test_both_strategies rvm: - 2.1.0 # Lowest version officially supported by that gem diff --git a/Rakefile b/Rakefile index 3b635cdc6f..5c27c4772e 100644 --- a/Rakefile +++ b/Rakefile @@ -9,6 +9,12 @@ Rake::TestTask.new do |t| t.warning = false end +desc "Run the test suite on both built-in execution strategies" +task :test_both_strategies do + system "bundle exec rake test" + system "GRAPHQL_EXEC_STRATEGY=serial bundle exec rake test" +end + require 'rubocop/rake_task' RuboCop::RakeTask.new(:rubocop) do |t| t.patterns = Rake::FileList['lib/**/{*}.rb', 'spec/**/*.rb'] diff --git a/lib/graphql/schema.rb b/lib/graphql/schema.rb index fb174a84ba..82cc04bd30 100644 --- a/lib/graphql/schema.rb +++ b/lib/graphql/schema.rb @@ -105,7 +105,7 @@ def initialize @id_from_object_proc = nil @instrumenters = Hash.new { |h, k| h[k] = [] } # Default to the built-in execution strategy: - @query_execution_strategy = GraphQL::Execution::DeferredExecution + @query_execution_strategy = GraphQL::Query::SerialExecution @mutation_execution_strategy = GraphQL::Query::SerialExecution @subscription_execution_strategy = GraphQL::Query::SerialExecution end diff --git a/spec/graphql/analysis/query_complexity_spec.rb b/spec/graphql/analysis/query_complexity_spec.rb index ece4bf3a4a..a18f616e03 100644 --- a/spec/graphql/analysis/query_complexity_spec.rb +++ b/spec/graphql/analysis/query_complexity_spec.rb @@ -258,7 +258,8 @@ GraphQL::Schema.define( query: query_type, orphan_types: [double_complexity_type], - resolve_type: :pass + resolve_type: :pass, + query_execution_strategy: DEFAULT_EXEC_STRATEGY, ) } let(:query_string) {%| diff --git a/spec/graphql/argument_spec.rb b/spec/graphql/argument_spec.rb index f25529898a..cdf0ff28a3 100644 --- a/spec/graphql/argument_spec.rb +++ b/spec/graphql/argument_spec.rb @@ -10,7 +10,7 @@ end err = assert_raises(GraphQL::Schema::InvalidTypeError) { - schema = GraphQL::Schema.define(query: query_type) + schema = GraphQL::Schema.define(query: query_type, query_execution_strategy: DEFAULT_EXEC_STRATEGY) schema.types } diff --git a/spec/graphql/introspection/introspection_query_spec.rb b/spec/graphql/introspection/introspection_query_spec.rb index 078efb3562..2dd6703551 100644 --- a/spec/graphql/introspection/introspection_query_spec.rb +++ b/spec/graphql/introspection/introspection_query_spec.rb @@ -24,6 +24,7 @@ deep_schema = GraphQL::Schema.define do query query_type + query_execution_strategy DEFAULT_EXEC_STRATEGY end result = deep_schema.execute(query_string) diff --git a/spec/graphql/query/context_spec.rb b/spec/graphql/query/context_spec.rb index 9eedcf67f5..b35213e188 100644 --- a/spec/graphql/query/context_spec.rb +++ b/spec/graphql/query/context_spec.rb @@ -17,7 +17,7 @@ resolve ->(target, args, ctx) { ctx.query.class.name } end }} - let(:schema) { GraphQL::Schema.define(query: query_type, mutation: nil)} + let(:schema) { GraphQL::Schema.define(query: query_type, mutation: nil, query_execution_strategy: DEFAULT_EXEC_STRATEGY)} let(:result) { schema.execute(query_string, context: {"some_key" => "some value"})} describe "access to passed-in values" do diff --git a/spec/graphql/query/executor_spec.rb b/spec/graphql/query/executor_spec.rb index adf74ec452..8042878083 100644 --- a/spec/graphql/query/executor_spec.rb +++ b/spec/graphql/query/executor_spec.rb @@ -85,7 +85,7 @@ end end - GraphQL::Schema.define(query: DummyQueryType, mutation: MutationType, resolve_type: :pass, id_from_object: :pass) + GraphQL::Schema.define(query: DummyQueryType, mutation: MutationType, resolve_type: :pass, id_from_object: :pass, query_execution_strategy: DEFAULT_EXEC_STRATEGY) } let(:variables) { nil } let(:query_string) { %| diff --git a/spec/graphql/query/serial_execution/value_resolution_spec.rb b/spec/graphql/query/serial_execution/value_resolution_spec.rb index fba14a04a7..da7c4e76a8 100644 --- a/spec/graphql/query/serial_execution/value_resolution_spec.rb +++ b/spec/graphql/query/serial_execution/value_resolution_spec.rb @@ -44,6 +44,7 @@ GraphQL::Schema.define do query(query_root) orphan_types [some_object] + query_execution_strategy DEFAULT_EXEC_STRATEGY resolve_type ->(obj, ctx) do if obj.is_a?(Symbol) other_object diff --git a/spec/graphql/schema/loader_spec.rb b/spec/graphql/schema/loader_spec.rb index b026f91bca..8015be3b90 100644 --- a/spec/graphql/schema/loader_spec.rb +++ b/spec/graphql/schema/loader_spec.rb @@ -111,6 +111,7 @@ mutation: mutation_root, orphan_types: [audio_type, video_type], resolve_type: :pass, + query_execution_strategy: DEFAULT_EXEC_STRATEGY, ) } diff --git a/spec/graphql/schema_spec.rb b/spec/graphql/schema_spec.rb index e20cc98dad..e739d32744 100644 --- a/spec/graphql/schema_spec.rb +++ b/spec/graphql/schema_spec.rb @@ -282,9 +282,9 @@ def after_query(query) let(:directives) { ["@defer"] } it "executes queries with defer" do - res = schema.execute("{ one @defer, two: one }") + res = schema.execute("{ deferred: one @defer, one }") # The deferred field is left out ?? - assert_equal({ "two" => 1 }, res["data"]) + assert_equal 1, res["data"]["one"] assert_equal nil, res["errors"] end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 44c2143ad4..555d596866 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -16,7 +16,6 @@ # to be shown. Minitest.backtrace_filter = Minitest::BacktraceFilter.new - # This is for convenient access to metadata in test definitions assign_metadata_key = -> (target, key, value) { target.metadata[key] = value } GraphQL::BaseType.accepts_definitions(metadata: assign_metadata_key) @@ -42,6 +41,9 @@ def self.enum_values(enum_type) end end +DEFAULT_EXEC_STRATEGY = ENV["GRAPHQL_EXEC_STRATEGY"] == "serial" ? GraphQL::Query::SerialExecution : GraphQL::Execution::DeferredExecution +puts "Default execution strategy: #{DEFAULT_EXEC_STRATEGY.name}" + # Load support files Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each { |f| require f } diff --git a/spec/support/dairy_app.rb b/spec/support/dairy_app.rb index 4025128745..11820ab75e 100644 --- a/spec/support/dairy_app.rb +++ b/spec/support/dairy_app.rb @@ -366,6 +366,8 @@ def self.create(type:, data:) max_depth 5 orphan_types [HoneyType, BeverageUnion] + query_execution_strategy DEFAULT_EXEC_STRATEGY + rescue_from(NoSuchDairyError) { |err| err.message } directives ["@defer", "@stream"] diff --git a/spec/support/star_wars_schema.rb b/spec/support/star_wars_schema.rb index d593c939a2..2325596326 100644 --- a/spec/support/star_wars_schema.rb +++ b/spec/support/star_wars_schema.rb @@ -192,7 +192,7 @@ def upcased_parent_name StarWarsSchema = GraphQL::Schema.define do query(QueryType) mutation(MutationType) - + query_execution_strategy DEFAULT_EXEC_STRATEGY resolve_type ->(object, ctx) { if object == :test_error :not_a_type From 64ff078c91029bf104fd53a7158eac69f2376370 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Tue, 25 Oct 2016 22:39:19 -0400 Subject: [PATCH 06/10] refactor(Execution) move exec_ objects to own files --- lib/graphql/execution.rb | 4 ++ lib/graphql/execution/deferred_execution.rb | 68 --------------------- lib/graphql/execution/exec_frame.rb | 19 ++++++ lib/graphql/execution/exec_scope.rb | 27 ++++++++ lib/graphql/execution/exec_stream.rb | 17 ++++++ lib/graphql/execution/exec_thread.rb | 17 ++++++ 6 files changed, 84 insertions(+), 68 deletions(-) create mode 100644 lib/graphql/execution/exec_frame.rb create mode 100644 lib/graphql/execution/exec_scope.rb create mode 100644 lib/graphql/execution/exec_stream.rb create mode 100644 lib/graphql/execution/exec_thread.rb diff --git a/lib/graphql/execution.rb b/lib/graphql/execution.rb index 28fc850453..1009564e6b 100644 --- a/lib/graphql/execution.rb +++ b/lib/graphql/execution.rb @@ -1,3 +1,7 @@ require "graphql/execution/deferred_execution" require "graphql/execution/directive_checks" +require "graphql/execution/exec_frame" +require "graphql/execution/exec_scope" +require "graphql/execution/exec_stream" +require "graphql/execution/exec_thread" require "graphql/execution/typecast" diff --git a/lib/graphql/execution/deferred_execution.rb b/lib/graphql/execution/deferred_execution.rb index 866902abbe..028d796feb 100644 --- a/lib/graphql/execution/deferred_execution.rb +++ b/lib/graphql/execution/deferred_execution.rb @@ -165,74 +165,6 @@ def execute(ast_operation, root_type, query_object) initial_result end - # Global, immutable environment for executing `query`. - # Passed through all execution to provide type, fragment and field lookup. - class ExecScope - attr_reader :query, :schema - - def initialize(query) - @query = query - @schema = query.schema - end - - def get_type(type) - @schema.types[type] - end - - def get_fragment(name) - @query.fragments[name] - end - - # This includes dynamic fields like __typename - def get_field(type, name) - @schema.get_field(type, name) || raise("No field named '#{name}' found for #{type}") - end - end - - # One serial stream of execution. One thread runs the initial query, - # then any deferred frames are restarted with their own threads. - # - # - {ExecThread#errors} contains errors during this part of the query - # - {ExecThread#defers} contains {ExecFrame}s which were marked as `@defer` - # and will be executed with their own threads later. - class ExecThread - attr_reader :errors, :defers - def initialize - @errors = [] - @defers = [] - end - end - - # One step of execution. Each step in execution gets its own frame. - # - # - {ExecFrame#node} is the IRep node which is being interpreted - # - {ExecFrame#path} is like a stack trace, it is used for patching deferred values - # - {ExecFrame#value} is the object being exposed by GraphQL at this point - # - {ExecFrame#type} is the GraphQL type which exposes {#value} at this point - class ExecFrame - attr_reader :node, :path, :type, :value - def initialize(node:, path:, type:, value:) - @node = node - @path = path - @type = type - @value = value - end - end - - # Contains the list field's ExecFrame - # And the enumerator which is being mapped - # - {ExecStream#enumerator} is an Enumerator which yields `item, idx` - # - {ExecStream#frame} is the {ExecFrame} for the list selection (where `@stream` was present) - # - {ExecStream#type} is the inner type of the list (the item's type) - class ExecStream - attr_reader :enumerator, :frame, :type - def initialize(enumerator:, frame:, type:) - @enumerator = enumerator - @frame = frame - @type = type - end - end - private # If this `frame` is marked as defer, add it to `defers` diff --git a/lib/graphql/execution/exec_frame.rb b/lib/graphql/execution/exec_frame.rb new file mode 100644 index 0000000000..a8bd171602 --- /dev/null +++ b/lib/graphql/execution/exec_frame.rb @@ -0,0 +1,19 @@ +module GraphQL + module Execution + # One step of execution. Each step in execution gets its own frame. + # + # - {ExecFrame#node} is the IRep node which is being interpreted + # - {ExecFrame#path} is like a stack trace, it is used for patching deferred values + # - {ExecFrame#value} is the object being exposed by GraphQL at this point + # - {ExecFrame#type} is the GraphQL type which exposes {#value} at this point + class ExecFrame + attr_reader :node, :path, :type, :value + def initialize(node:, path:, type:, value:) + @node = node + @path = path + @type = type + @value = value + end + end + end +end diff --git a/lib/graphql/execution/exec_scope.rb b/lib/graphql/execution/exec_scope.rb new file mode 100644 index 0000000000..6724a7c72a --- /dev/null +++ b/lib/graphql/execution/exec_scope.rb @@ -0,0 +1,27 @@ +module GraphQL + module Execution + # Global, immutable environment for executing `query`. + # Passed through all execution to provide type, fragment and field lookup. + class ExecScope + attr_reader :query, :schema + + def initialize(query) + @query = query + @schema = query.schema + end + + def get_type(type) + @schema.types[type] + end + + def get_fragment(name) + @query.fragments[name] + end + + # This includes dynamic fields like __typename + def get_field(type, name) + @schema.get_field(type, name) || raise("No field named '#{name}' found for #{type}") + end + end + end +end diff --git a/lib/graphql/execution/exec_stream.rb b/lib/graphql/execution/exec_stream.rb new file mode 100644 index 0000000000..807bac9222 --- /dev/null +++ b/lib/graphql/execution/exec_stream.rb @@ -0,0 +1,17 @@ +module GraphQL + module Execution + # Contains the list field's ExecFrame + # And the enumerator which is being mapped + # - {ExecStream#enumerator} is an Enumerator which yields `item, idx` + # - {ExecStream#frame} is the {ExecFrame} for the list selection (where `@stream` was present) + # - {ExecStream#type} is the inner type of the list (the item's type) + class ExecStream + attr_reader :enumerator, :frame, :type + def initialize(enumerator:, frame:, type:) + @enumerator = enumerator + @frame = frame + @type = type + end + end + end +end diff --git a/lib/graphql/execution/exec_thread.rb b/lib/graphql/execution/exec_thread.rb new file mode 100644 index 0000000000..68e76062dc --- /dev/null +++ b/lib/graphql/execution/exec_thread.rb @@ -0,0 +1,17 @@ +module GraphQL + module Execution + # One serial stream of execution. One thread runs the initial query, + # then any deferred frames are restarted with their own threads. + # + # - {ExecThread#errors} contains errors during this part of the query + # - {ExecThread#defers} contains {ExecFrame}s which were marked as `@defer` + # and will be executed with their own threads later. + class ExecThread + attr_reader :errors, :defers + def initialize + @errors = [] + @defers = [] + end + end + end +end From d91352ce32cb246b04fc70df45d7f6dd954cee5f Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Fri, 4 Nov 2016 09:20:22 -0400 Subject: [PATCH 07/10] fix(DeferredExecution) make some catch-ups with master --- lib/graphql/execution/deferred_execution.rb | 2 -- lib/graphql/schema/build_from_definition.rb | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/graphql/execution/deferred_execution.rb b/lib/graphql/execution/deferred_execution.rb index 028d796feb..bd1cb47dc0 100644 --- a/lib/graphql/execution/deferred_execution.rb +++ b/lib/graphql/execution/deferred_execution.rb @@ -261,7 +261,6 @@ def resolve_field_frame(scope, thread, frame, field_defn) # Build arguments according to query-string literals, default values, and query variables arguments = query.arguments_for(frame.node, field_defn) - query.context.ast_node = ast_node query.context.irep_node = frame.node # This is the last call in the middleware chain; it actually calls the user's resolve proc @@ -283,7 +282,6 @@ def resolve_field_frame(scope, thread, frame, field_defn) resolve_fn_value = err end - query.context.ast_node = nil query.context.irep_node = nil case resolve_fn_value diff --git a/lib/graphql/schema/build_from_definition.rb b/lib/graphql/schema/build_from_definition.rb index a95552bf34..f2ab093dbe 100644 --- a/lib/graphql/schema/build_from_definition.rb +++ b/lib/graphql/schema/build_from_definition.rb @@ -51,7 +51,7 @@ def build(document) end end - GraphQL::Schema::DIRECTIVES.each do |built_in_directive| + GraphQL::Schema::BUILT_IN_DIRECTIVES.each do |built_in_directive| directives[built_in_directive.name] = built_in_directive unless directives[built_in_directive.name] end From c3ac669909274d56a8e78e70f0dfcc6acc43b7d9 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Fri, 4 Nov 2016 10:35:45 -0400 Subject: [PATCH 08/10] fix(DeferredExecution) create backwards-compatible Schema#directives= API --- lib/graphql/execution.rb | 1 + lib/graphql/execution/deferred_execution.rb | 51 ++++++++++++------- lib/graphql/execution/directive_checks.rb | 1 + lib/graphql/execution/merge_branch_result.rb | 21 ++++++++ .../serial_execution/selection_resolution.rb | 18 +------ lib/graphql/schema.rb | 43 +++++++++++----- lib/graphql/schema/build_from_definition.rb | 2 +- .../execution/deferred_execution_spec.rb | 2 +- spec/graphql/schema_spec.rb | 2 +- spec/support/dairy_app.rb | 2 +- 10 files changed, 90 insertions(+), 53 deletions(-) create mode 100644 lib/graphql/execution/merge_branch_result.rb diff --git a/lib/graphql/execution.rb b/lib/graphql/execution.rb index 1009564e6b..b3715ccff4 100644 --- a/lib/graphql/execution.rb +++ b/lib/graphql/execution.rb @@ -4,4 +4,5 @@ require "graphql/execution/exec_scope" require "graphql/execution/exec_stream" require "graphql/execution/exec_thread" +require "graphql/execution/merge_branch_result" require "graphql/execution/typecast" diff --git a/lib/graphql/execution/deferred_execution.rb b/lib/graphql/execution/deferred_execution.rb index bd1cb47dc0..288071d5fc 100644 --- a/lib/graphql/execution/deferred_execution.rb +++ b/lib/graphql/execution/deferred_execution.rb @@ -222,30 +222,43 @@ def resolve_frame(scope, thread, frame) # Return a `Hash` of identifiers and results. # Deferred fields will be absent from the result. def resolve_selections(scope, thread, outer_frame) - merged_selections = outer_frame.node.children query = scope.query + selection_result = {} + + outer_frame.node.typed_children.each do |type_cond, children| + if GraphQL::Execution::Typecast.compatible?(outer_frame.type, type_cond, query.context) + children.each do |selection_name, irep_node| + if irep_node.included? + previous_result = selection_result.fetch(selection_name, :__graphql_not_resolved__) + + case previous_result + when :__graphql_not_resolved__, Hash + # There's no value for this yet, so we can assign it directly + # OR + # This field was also requested on a different type, so we need + # to deeply merge _this_ branch with the other branch + + inner_frame = ExecFrame.new( + node: irep_node, + path: outer_frame.path + [selection_name], + type: outer_frame.type, + value: outer_frame.value, + ) + + inner_result = resolve_or_defer_frame(scope, thread, inner_frame) + else + # This value has already been resolved in another type branch + end - resolved_selections = merged_selections.each_with_object({}) do |(name, irep_selection), memo| - field_applies_to_type = irep_selection.definitions.any? do |child_type, defn| - GraphQL::Execution::Typecast.compatible?(child_type, outer_frame.type, query.context) - end - if field_applies_to_type && irep_selection.included? - selection_key = irep_selection.name - - inner_frame = ExecFrame.new( - node: irep_selection, - path: outer_frame.path + [selection_key], - type: outer_frame.type, - value: outer_frame.value, - ) - - inner_result = resolve_or_defer_frame(scope, thread, inner_frame) - if inner_result != DEFERRED_RESULT - memo[selection_key] = inner_result + if inner_result != DEFERRED_RESULT + GraphQL::Execution::MergeBranchResult.merge(selection_result, { selection_name => inner_result }) + end + end end end end - resolved_selections + + selection_result end # Resolve `field_defn` on `frame.node`, returning the value diff --git a/lib/graphql/execution/directive_checks.rb b/lib/graphql/execution/directive_checks.rb index 4bdeed80b7..b16ab80500 100644 --- a/lib/graphql/execution/directive_checks.rb +++ b/lib/graphql/execution/directive_checks.rb @@ -24,6 +24,7 @@ def stream?(irep_node) def include?(directive_irep_nodes, query) directive_irep_nodes.each do |directive_irep_node| name = directive_irep_node.name + # Don't `.fetch` here, it would cause a runtime error in validation directive_defn = query.schema.directives[name] case name when SKIP diff --git a/lib/graphql/execution/merge_branch_result.rb b/lib/graphql/execution/merge_branch_result.rb new file mode 100644 index 0000000000..67fb7dbafe --- /dev/null +++ b/lib/graphql/execution/merge_branch_result.rb @@ -0,0 +1,21 @@ +module GraphQL + module Execution + module MergeBranchResult + # Modify `complete_result` by recursively merging `type_branch_result` + # @return [void] + def self.merge(complete_result, type_branch_result) + type_branch_result.each do |key, branch_value| + prev_value = complete_result[key] + case prev_value + when nil + complete_result[key] = branch_value + when Hash + merge(prev_value, branch_value) + else + # Sad, this was not needed. + end + end + end + end + end +end diff --git a/lib/graphql/query/serial_execution/selection_resolution.rb b/lib/graphql/query/serial_execution/selection_resolution.rb index 88b002e462..675eeeca37 100644 --- a/lib/graphql/query/serial_execution/selection_resolution.rb +++ b/lib/graphql/query/serial_execution/selection_resolution.rb @@ -28,7 +28,7 @@ def self.resolve(target, current_type, irep_node, execution_context) target, execution_context ).result - deeply_merge(selection_result, field_result) + GraphQL::Execution::MergeBranchResult.merge(selection_result, field_result) else # This value has already been resolved in another type branch end @@ -38,22 +38,6 @@ def self.resolve(target, current_type, irep_node, execution_context) end selection_result end - - # Modify `complete_result` by recursively merging `type_branch_result` - # @return [void] - def self.deeply_merge(complete_result, type_branch_result) - type_branch_result.each do |key, branch_value| - prev_value = complete_result[key] - case prev_value - when nil - complete_result[key] = branch_value - when Hash - deeply_merge(prev_value, branch_value) - else - # Sad, this was not needed. - end - end - end end end end diff --git a/lib/graphql/schema.rb b/lib/graphql/schema.rb index 82cc04bd30..70e6feeb2d 100644 --- a/lib/graphql/schema.rb +++ b/lib/graphql/schema.rb @@ -71,15 +71,15 @@ class Schema BUILT_IN_TYPES = Hash[[INT_TYPE, STRING_TYPE, FLOAT_TYPE, BOOLEAN_TYPE, ID_TYPE].map{ |type| [type.name, type] }] - BUILT_IN_DIRECTIVES = [ - GraphQL::Directive::IncludeDirective, - GraphQL::Directive::SkipDirective, - GraphQL::Directive::DeprecatedDirective, - ] + BUILT_IN_DIRECTIVES = { + "include" => GraphQL::Directive::IncludeDirective, + "skip" => GraphQL::Directive::SkipDirective, + "deprecated" => GraphQL::Directive::DeprecatedDirective, + } OPTIONAL_DIRECTIVES = { - "@defer" => GraphQL::Directive::DeferDirective, - "@stream" => GraphQL::Directive::StreamDirective, + "defer" => GraphQL::Directive::DeferDirective, + "stream" => GraphQL::Directive::StreamDirective, } DYNAMIC_FIELDS = ["__type", "__typename", "__schema"] @@ -96,7 +96,7 @@ class Schema # @param types [Array] additional types to include in this schema def initialize @orphan_types = [] - @directives = BUILT_IN_DIRECTIVES.reduce({}) { |m, d| m[d.name] = d; m } + @directives = BUILT_IN_DIRECTIVES.values.reduce({}) { |m, d| m[d.name] = d; m } @static_validator = GraphQL::StaticValidation::Validator.new(schema: self) @middleware = [] @query_analyzers = [] @@ -110,13 +110,30 @@ def initialize @subscription_execution_strategy = GraphQL::Query::SerialExecution end - def directives=(directive_names) + def directives=(new_directives) ensure_defined - directive_names.each do |name| - dir = OPTIONAL_DIRECTIVES.fetch(name) - @directives[dir.name] = dir + + next_directives = {} + new_directives.each do |directive| + case directive + when GraphQL::Directive + next_directives[directive.name] = directive + when String + dir = OPTIONAL_DIRECTIVES[directive] || BUILT_IN_DIRECTIVES[directive] || raise("No directive found for: #{directive}") + next_directives[dir.name] = dir + else + raise("Can't define a directive for #{directive.inspect} (expected String or GraphQL::Directive instance)") + end end - @directives + + explicit_built_in_directives = (next_directives.keys & BUILT_IN_DIRECTIVES.values.map(&:name)) + + if explicit_built_in_directives.none? + # This is optional directives only; assume a full set of built-in directives + next_directives = BUILT_IN_DIRECTIVES.merge(next_directives) + end + + @directives = next_directives end def rescue_from(*args, &block) diff --git a/lib/graphql/schema/build_from_definition.rb b/lib/graphql/schema/build_from_definition.rb index f2ab093dbe..520e4cac33 100644 --- a/lib/graphql/schema/build_from_definition.rb +++ b/lib/graphql/schema/build_from_definition.rb @@ -51,7 +51,7 @@ def build(document) end end - GraphQL::Schema::BUILT_IN_DIRECTIVES.each do |built_in_directive| + GraphQL::Schema::BUILT_IN_DIRECTIVES.each do |identifier, built_in_directive| directives[built_in_directive.name] = built_in_directive unless directives[built_in_directive.name] end diff --git a/spec/graphql/execution/deferred_execution_spec.rb b/spec/graphql/execution/deferred_execution_spec.rb index a0b20e3807..60ae4cbc89 100644 --- a/spec/graphql/execution/deferred_execution_spec.rb +++ b/spec/graphql/execution/deferred_execution_spec.rb @@ -334,7 +334,7 @@ def merged_result } end end - schema = GraphQL::Schema.define(query: query_type, directives: ["@stream", "@defer"]) + schema = GraphQL::Schema.define(query: query_type, directives: ["stream", "defer"]) schema.query_execution_strategy = GraphQL::Execution::DeferredExecution schema } diff --git a/spec/graphql/schema_spec.rb b/spec/graphql/schema_spec.rb index e739d32744..b9bb8377dc 100644 --- a/spec/graphql/schema_spec.rb +++ b/spec/graphql/schema_spec.rb @@ -279,7 +279,7 @@ def after_query(query) end describe "when @defer is provided" do - let(:directives) { ["@defer"] } + let(:directives) { ["defer"] } it "executes queries with defer" do res = schema.execute("{ deferred: one @defer, one }") diff --git a/spec/support/dairy_app.rb b/spec/support/dairy_app.rb index 11820ab75e..e9aa379587 100644 --- a/spec/support/dairy_app.rb +++ b/spec/support/dairy_app.rb @@ -370,7 +370,7 @@ def self.create(type:, data:) rescue_from(NoSuchDairyError) { |err| err.message } - directives ["@defer", "@stream"] + directives ["defer", "stream"] resolve_type ->(obj, ctx) { DummySchema.types[obj.class.name] From 8160538938d60626bfae5abb0eea65fbf2a5b9ad Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Fri, 4 Nov 2016 20:48:05 -0400 Subject: [PATCH 09/10] fix(defer) make tests pass --- Rakefile | 2 +- lib/graphql/execution/deferred_execution.rb | 7 +- spec/graphql/execution_error_spec.rb | 196 +++++++++--------- .../internal_representation/rewrite_spec.rb | 2 +- 4 files changed, 106 insertions(+), 101 deletions(-) diff --git a/Rakefile b/Rakefile index 5c27c4772e..617e412d4f 100644 --- a/Rakefile +++ b/Rakefile @@ -22,7 +22,7 @@ RuboCop::RakeTask.new(:rubocop) do |t| .exclude("lib/graphql/language/lexer.rb") end -task(default: [:test, :rubocop]) +task(default: [:test_both_strategies, :rubocop]) desc "Use Racc & Ragel to regenerate parser.rb & lexer.rb from configuration files" task :build_parser do diff --git a/lib/graphql/execution/deferred_execution.rb b/lib/graphql/execution/deferred_execution.rb index 288071d5fc..82a48e0220 100644 --- a/lib/graphql/execution/deferred_execution.rb +++ b/lib/graphql/execution/deferred_execution.rb @@ -159,7 +159,7 @@ def execute(ast_operation, root_type, query_object) defers = next_defers end else - query_object.context.errors.push(*initial_thread.errors) + query_object.context.errors.concat(initial_thread.errors) end initial_result @@ -333,8 +333,9 @@ def resolve_value(scope, thread, frame, value, type_defn) when GraphQL::TypeKinds::INTERFACE, GraphQL::TypeKinds::UNION resolved_type = scope.schema.resolve_type(value, scope.query.context) - if !resolved_type.is_a?(GraphQL::ObjectType) || !scope.schema.possible_types(type_defn).include?(resolved_type) - raise GraphQL::UnresolvedTypeError.new(frame.node.definition_name, scope.schema, type_defn, frame.node.parent.return_type, resolved_type) + possible_types = scope.schema.possible_types(type_defn) + if !resolved_type.is_a?(GraphQL::ObjectType) || !possible_types.include?(resolved_type) + raise GraphQL::UnresolvedTypeError.new(frame.node.definition_name, type_defn, frame.node.parent.return_type, resolved_type, possible_types) else resolve_value(scope, thread, frame, value, resolved_type) end diff --git a/spec/graphql/execution_error_spec.rb b/spec/graphql/execution_error_spec.rb index a833d1d3f1..8dad543f01 100644 --- a/spec/graphql/execution_error_spec.rb +++ b/spec/graphql/execution_error_spec.rb @@ -52,104 +52,108 @@ } |} it "the error is inserted into the errors key and the rest of the query is fulfilled" do - expected_result = { - "data"=>{ - "cheese"=>{ - "id" => 1, - "error1"=> nil, - "error2"=> nil, - "nonError"=> { - "id" => 3, - "flavor" => "Manchego", - }, - "flavor" => "Brie", - }, - "allDairy" => [ - { "flavor" => "Brie" }, - { "flavor" => "Gouda" }, - { "flavor" => "Manchego" }, - { "source" => "COW", "executionError" => nil }, - { "source" => "COW", "executionError" => nil }, - ], - "dairyErrors" => [ - { "__typename" => "Cheese" }, - nil, - { "__typename" => "Cheese" }, - { "__typename" => "Milk" }, - { "__typename" => "Milk" }, - ], - "dairy" => { - "milks" => [ - { - "source" => "COW", - "executionError" => nil, - "allDairy" => [ - { "__typename" => "Cheese" }, - { "__typename" => "Cheese" }, - { "__typename" => "Cheese" }, - { "__typename" => "Milk", "origin" => "Antiquity", "executionError" => nil }, - { "__typename" => "Milk", "origin" => "Modernity", "executionError" => nil }, - ] - } - ] - }, - "executionError" => nil, - "valueWithExecutionError" => 0 + expected_data = { + "cheese"=>{ + "id" => 1, + "error1"=> nil, + "error2"=> nil, + "nonError"=> { + "id" => 3, + "flavor" => "Manchego", }, - "errors"=>[ - { - "message"=>"No cheeses are made from Yak milk!", - "locations"=>[{"line"=>5, "column"=>9}], - "path"=>["cheese", "error1"] - }, - { - "message"=>"No cheeses are made from Yak milk!", - "locations"=>[{"line"=>8, "column"=>9}], - "path"=>["cheese", "error2"] - }, - { - "message"=>"There was an execution error", - "locations"=>[{"line"=>22, "column"=>11}], - "path"=>["allDairy", 3, "executionError"] - }, - { - "message"=>"There was an execution error", - "locations"=>[{"line"=>22, "column"=>11}], - "path"=>["allDairy", 4, "executionError"] - }, - { - "message"=>"missing dairy", - "locations"=>[{"line"=>25, "column"=>7}], - "path"=>["dairyErrors", 1] - }, - { - "message"=>"There was an execution error", - "locations"=>[{"line"=>31, "column"=>11}], - "path"=>["dairy", "milks", 0, "executionError"] - }, - { - "message"=>"There was an execution error", - "locations"=>[{"line"=>36, "column"=>15}], - "path"=>["dairy", "milks", 0, "allDairy", 3, "executionError"] - }, - { - "message"=>"There was an execution error", - "locations"=>[{"line"=>36, "column"=>15}], - "path"=>["dairy", "milks", 0, "allDairy", 4, "executionError"] - }, - { - "message"=>"There was an execution error", - "locations"=>[{"line"=>41, "column"=>7}], - "path"=>["executionError"] - }, - { - "message"=>"Could not fetch latest value", - "locations"=>[{"line"=>42, "column"=>7}], - "path"=>["valueWithExecutionError"] - }, - ] + "flavor" => "Brie", + }, + "allDairy" => [ + { "flavor" => "Brie" }, + { "flavor" => "Gouda" }, + { "flavor" => "Manchego" }, + { "source" => "COW", "executionError" => nil }, + { "source" => "COW", "executionError" => nil }, + ], + "dairyErrors" => [ + { "__typename" => "Cheese" }, + nil, + { "__typename" => "Cheese" }, + { "__typename" => "Milk" }, + { "__typename" => "Milk" }, + ], + "dairy" => { + "milks" => [ + { + "source" => "COW", + "executionError" => nil, + "allDairy" => [ + { "__typename" => "Cheese" }, + { "__typename" => "Cheese" }, + { "__typename" => "Cheese" }, + { "__typename" => "Milk", "origin" => "Antiquity", "executionError" => nil }, + { "__typename" => "Milk", "origin" => "Modernity", "executionError" => nil }, + ] + } + ] + }, + "executionError" => nil, + "valueWithExecutionError" => 0 } - assert_equal(expected_result, result) + + expected_errors = [ + { + "message"=>"Could not fetch latest value", + "locations"=>[{"line"=>42, "column"=>7}], + "path"=>["valueWithExecutionError"] + }, + { + "message"=>"No cheeses are made from Yak milk!", + "locations"=>[{"line"=>5, "column"=>9}], + "path"=>["cheese", "error1"] + }, + { + "message"=>"No cheeses are made from Yak milk!", + "locations"=>[{"line"=>8, "column"=>9}], + "path"=>["cheese", "error2"] + }, + { + "message"=>"There was an execution error", + "locations"=>[{"line"=>22, "column"=>11}], + "path"=>["allDairy", 3, "executionError"] + }, + { + "message"=>"There was an execution error", + "locations"=>[{"line"=>22, "column"=>11}], + "path"=>["allDairy", 4, "executionError"] + }, + { + "message"=>"missing dairy", + "locations"=>[{"line"=>25, "column"=>7}], + "path"=>["dairyErrors", 1] + }, + { + "message"=>"There was an execution error", + "locations"=>[{"line"=>31, "column"=>11}], + "path"=>["dairy", "milks", 0, "executionError"] + }, + { + "message"=>"There was an execution error", + "locations"=>[{"line"=>36, "column"=>15}], + "path"=>["dairy", "milks", 0, "allDairy", 3, "executionError"] + }, + { + "message"=>"There was an execution error", + "locations"=>[{"line"=>36, "column"=>15}], + "path"=>["dairy", "milks", 0, "allDairy", 4, "executionError"] + }, + { + "message"=>"There was an execution error", + "locations"=>[{"line"=>41, "column"=>7}], + "path"=>["executionError"] + }, + ] + assert_equal(expected_data, result["data"]) + # Different exec strategies may order errors differently: + assert_equal( + expected_errors.sort_by { |e| e["locations"].first.values }, + result["errors"].sort_by { |e| e["locations"].first.values} + ) end end diff --git a/spec/graphql/internal_representation/rewrite_spec.rb b/spec/graphql/internal_representation/rewrite_spec.rb index edcd0fbb36..9505fc19a0 100644 --- a/spec/graphql/internal_representation/rewrite_spec.rb +++ b/spec/graphql/internal_representation/rewrite_spec.rb @@ -216,7 +216,7 @@ # Make sure all the data is there: assert_equal 3, cheeses.length - assert_equal 1, milks.length + assert_equal 2, milks.length cheeses.each do |cheese| assert_equal ["cheeseInlineOrigin", "cheeseFragmentOrigin", "edibleInlineOrigin", "untypedInlineOrigin"], cheese["selfAsEdible"].keys From 9ace98d9d2321e675ccae7f27825d3f4020ad44c Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Fri, 4 Nov 2016 23:28:13 -0400 Subject: [PATCH 10/10] Add batching primitives --- lib/graphql/define.rb | 1 + lib/graphql/define/assign_batch_resolve.rb | 10 ++ lib/graphql/execution.rb | 2 + lib/graphql/execution/batch.rb | 63 +++++++ lib/graphql/execution/deferred_execution.rb | 189 +++++++++++++------- lib/graphql/execution/merge_collector.rb | 24 +++ lib/graphql/field.rb | 8 +- lib/graphql/query.rb | 4 +- lib/graphql/query/context.rb | 4 + spec/graphql/execution/batch_spec.rb | 189 ++++++++++++++++++++ 10 files changed, 421 insertions(+), 73 deletions(-) create mode 100644 lib/graphql/define/assign_batch_resolve.rb create mode 100644 lib/graphql/execution/batch.rb create mode 100644 lib/graphql/execution/merge_collector.rb create mode 100644 spec/graphql/execution/batch_spec.rb diff --git a/lib/graphql/define.rb b/lib/graphql/define.rb index 87cf6ba36b..b3264d7325 100644 --- a/lib/graphql/define.rb +++ b/lib/graphql/define.rb @@ -1,4 +1,5 @@ require "graphql/define/assign_argument" +require "graphql/define/assign_batch_resolve" require "graphql/define/assign_connection" require "graphql/define/assign_enum_value" require "graphql/define/assign_global_id_field" diff --git a/lib/graphql/define/assign_batch_resolve.rb b/lib/graphql/define/assign_batch_resolve.rb new file mode 100644 index 0000000000..b711db9268 --- /dev/null +++ b/lib/graphql/define/assign_batch_resolve.rb @@ -0,0 +1,10 @@ +module GraphQL + module Define + module AssignBatchResolve + def self.call(field_defn, loader, *loader_args, resolve_func) + field_defn.batch_loader = GraphQL::Execution::Batch::BatchLoader.new(loader, loader_args) + field_defn.resolve = resolve_func + end + end + end +end diff --git a/lib/graphql/execution.rb b/lib/graphql/execution.rb index b3715ccff4..8ebc65234c 100644 --- a/lib/graphql/execution.rb +++ b/lib/graphql/execution.rb @@ -1,3 +1,4 @@ +require "graphql/execution/batch" require "graphql/execution/deferred_execution" require "graphql/execution/directive_checks" require "graphql/execution/exec_frame" @@ -5,4 +6,5 @@ require "graphql/execution/exec_stream" require "graphql/execution/exec_thread" require "graphql/execution/merge_branch_result" +require "graphql/execution/merge_collector" require "graphql/execution/typecast" diff --git a/lib/graphql/execution/batch.rb b/lib/graphql/execution/batch.rb new file mode 100644 index 0000000000..0de82d4d1a --- /dev/null +++ b/lib/graphql/execution/batch.rb @@ -0,0 +1,63 @@ +module GraphQL + module Execution + module Batch + def self.resolve(item_arg, func) + BatchResolve.new(item_arg, func ) + end + + class BatchLoader + attr_reader :func, :args + def initialize(func, args) + @func = func + @args = args + end + end + + class BatchResolve + attr_reader :item_arg, :func + def initialize(item_arg, func) + @item_arg = item_arg + @func = func + end + end + + class Accumulator + def initialize + @storage = init_storage + end + + def register(loader, group_args, frame, batch_resolve) + key = [loader, group_args] + callback = [frame, batch_resolve.func] + @storage[key][batch_resolve.item_arg] << callback + end + + def any? + @storage.any? + end + + def resolve_all(&block) + batches = @storage + @storage = init_storage + batches.each do |(loader, group_args), item_arg_callbacks| + item_args = item_arg_callbacks.keys + loader.call(*group_args, item_args) { |item_arg, result| + callbacks = item_arg_callbacks[item_arg] + callbacks.each do |(frame, func)| + next_result = func.nil? ? result : func.call(result) + yield(frame, next_result) + end + } + end + end + + private + + def init_storage + # { [loader, group_args] => { item_arg => [callback, ...] } } + Hash.new { |h, k| h[k] = Hash.new { |h2, k2| h2[k2] = [] } } + end + end + end + end +end diff --git a/lib/graphql/execution/deferred_execution.rb b/lib/graphql/execution/deferred_execution.rb index 82a48e0220..7fe0bb3c2f 100644 --- a/lib/graphql/execution/deferred_execution.rb +++ b/lib/graphql/execution/deferred_execution.rb @@ -75,7 +75,7 @@ def initialize(field_name, value) # # @return [Object] the initial result, without any defers def execute(ast_operation, root_type, query_object) - collector = query_object.context[CONTEXT_PATCH_TARGET] + collector = query_object.context[CONTEXT_PATCH_TARGET] || MergeCollector.new irep_root = query_object.internal_representation[ast_operation.name] scope = ExecScope.new(query_object) @@ -88,81 +88,93 @@ def execute(ast_operation, root_type, query_object) ) initial_result = resolve_or_defer_frame(scope, initial_thread, initial_frame) + resolve_batches(scope, initial_thread, query_object, initial_result) - if collector - initial_data = if initial_result == DEFERRED_RESULT - {} - else - initial_result - end - initial_patch = {"data" => initial_data} + initial_data = if initial_result == DEFERRED_RESULT + {} + else + initial_result + end - initial_errors = initial_thread.errors + query_object.context.errors - error_idx = initial_errors.length + initial_patch = {"data" => initial_data} - if initial_errors.any? - initial_patch["errors"] = initial_errors.map(&:to_h) - end + initial_errors = initial_thread.errors + query_object.context.errors + error_idx = initial_errors.length - collector.patch( - path: [], - value: initial_patch - ) + if initial_errors.any? + initial_patch["errors"] = initial_errors.map(&:to_h) + end - defers = initial_thread.defers - while defers.any? - next_defers = [] - defers.each do |deferral| - deferred_thread = ExecThread.new - case deferral - when ExecFrame - deferred_frame = deferral - deferred_result = resolve_frame(scope, deferred_thread, deferred_frame) - - when ExecStream - begin - list_frame = deferral.frame - inner_type = deferral.type - item, idx = deferral.enumerator.next - deferred_frame = ExecFrame.new({ - node: list_frame.node, - path: list_frame.path + [idx], - type: inner_type, - value: item, - }) - deferred_result = resolve_value(scope, deferred_thread, deferred_frame, item, inner_type) - deferred_thread.defers << deferral - rescue StopIteration - # The enum is done - end - else - raise("Can't continue deferred #{deferred_frame.class.name}") - end + collector.patch( + path: [], + value: initial_patch + ) - # TODO: Should I patch nil? - if !deferred_result.nil? - collector.patch( - path: ["data"] + deferred_frame.path, - value: deferred_result - ) + defers = initial_thread.defers + while defers.any? + next_defers = [] + defers.each do |deferral| + deferred_thread = ExecThread.new + case deferral + when ExecFrame + deferred_frame = deferral + deferred_result = resolve_frame(scope, deferred_thread, deferred_frame) + if deferred_result.is_a?(GraphQL::Execution::Batch::BatchResolve) + # TODO: this will miss nested resolves + res = {} + resolve_batches(scope, deferred_thread, query_object, res) + deferred_result = get_in(res, deferred_frame.path) end + when ExecStream + begin + list_frame = deferral.frame + inner_type = deferral.type + item, idx = deferral.enumerator.next + deferred_frame = ExecFrame.new({ + node: list_frame.node, + path: list_frame.path + [idx], + type: inner_type, + value: item, + }) + field_defn = scope.get_field(list_frame.type, list_frame.node.definition_name) + deferred_result = resolve_value(scope, deferred_thread, deferred_frame, item, field_defn, inner_type) + + # TODO: deep merge ?? + res = {} + resolve_batches(scope, deferred_thread, query_object, res) + if res.any? + deferred_result = get_in(res, deferred_frame.path) + end - deferred_thread.errors.each do |deferred_error| - collector.patch( - path: ["errors", error_idx], - value: deferred_error.to_h - ) - error_idx += 1 + deferred_thread.defers << deferral + rescue StopIteration + # The enum is done end - next_defers.push(*deferred_thread.defers) + else + raise("Can't continue deferred #{deferred_frame.class.name}") + end + + # TODO: Should I patch nil? + if !deferred_result.nil? + collector.patch( + path: ["data"].concat(deferred_frame.path), + value: deferred_result + ) end - defers = next_defers + + deferred_thread.errors.each do |deferred_error| + collector.patch( + path: ["errors", error_idx], + value: deferred_error.to_h + ) + error_idx += 1 + end + next_defers.push(*deferred_thread.defers) end - else - query_object.context.errors.concat(initial_thread.errors) + defers = next_defers end - initial_result + initial_data end private @@ -197,6 +209,7 @@ def resolve_frame(scope, thread, frame) thread, frame, field_result, + field_defn, return_type_defn, ) else @@ -250,7 +263,9 @@ def resolve_selections(scope, thread, outer_frame) # This value has already been resolved in another type branch end - if inner_result != DEFERRED_RESULT + if inner_result == DEFERRED_RESULT || inner_result.is_a?(GraphQL::Execution::Batch::BatchResolve) + # This result was dealt with by the thread + else GraphQL::Execution::MergeBranchResult.merge(selection_result, { selection_name => inner_result }) end end @@ -319,13 +334,22 @@ def resolve_field_frame(scope, thread, frame, field_defn) # expected to be an instance of `type_defn`. # This coerces terminals and recursively resolves non-terminals (object, list, non-null). # @return [Object] the response-ready version of `value` - def resolve_value(scope, thread, frame, value, type_defn) - if value.nil? || value.is_a?(GraphQL::ExecutionError) + def resolve_value(scope, thread, frame, value, field_defn, type_defn) + case value + when nil, GraphQL::ExecutionError if type_defn.kind.non_null? raise InnerInvalidNullError.new(frame.node.ast_node.name, value) else nil end + when GraphQL::Execution::Batch::BatchResolve + scope.query.accumulator.register( + field_defn.batch_loader.func, + field_defn.batch_loader.args, + frame, + value + ) + value else case type_defn.kind when GraphQL::TypeKinds::SCALAR, GraphQL::TypeKinds::ENUM @@ -337,11 +361,11 @@ def resolve_value(scope, thread, frame, value, type_defn) if !resolved_type.is_a?(GraphQL::ObjectType) || !possible_types.include?(resolved_type) raise GraphQL::UnresolvedTypeError.new(frame.node.definition_name, type_defn, frame.node.parent.return_type, resolved_type, possible_types) else - resolve_value(scope, thread, frame, value, resolved_type) + resolve_value(scope, thread, frame, value, field_defn, resolved_type) end when GraphQL::TypeKinds::NON_NULL wrapped_type = type_defn.of_type - resolve_value(scope, thread, frame, value, wrapped_type) + resolve_value(scope, thread, frame, value, field_defn, wrapped_type) when GraphQL::TypeKinds::LIST wrapped_type = type_defn.of_type items_enumerator = value.map.with_index @@ -361,7 +385,7 @@ def resolve_value(scope, thread, frame, value, type_defn) type: wrapped_type, value: item, }) - resolve_value(scope, thread, inner_frame, item, wrapped_type) + resolve_value(scope, thread, inner_frame, item, field_defn, wrapped_type) end resolved_values end @@ -378,6 +402,33 @@ def resolve_value(scope, thread, frame, value, type_defn) end end end + + def set_in(data, path, value) + path = path.dup + last = path.pop + path.each do |key| + data = data[key] ||= {} + end + data[last] = value + end + + def get_in(data, path) + path.each do |key| + data = data[key] + end + data + end + + def resolve_batches(scope, thread, query_object, merge_target) + while query_object.accumulator.any? + query_object.accumulator.resolve_all do |frame, value| + # TODO cache on frame + field_defn = scope.get_field(frame.type, frame.node.definition_name) + finished_value = resolve_value(scope, thread, frame, value, field_defn, field_defn.type) + set_in(merge_target, frame.path, finished_value) + end + end + end end end end diff --git a/lib/graphql/execution/merge_collector.rb b/lib/graphql/execution/merge_collector.rb new file mode 100644 index 0000000000..12fa0eb728 --- /dev/null +++ b/lib/graphql/execution/merge_collector.rb @@ -0,0 +1,24 @@ +module GraphQL + module Execution + class MergeCollector + attr_reader :result + def initialize + @result = nil + end + + def patch(path:, value:) + if @result.nil? + # first patch + @result = value + else + last = path.pop + target = @result + path.each do |key| + target = target[key] + end + target[last] = value + end + end + end + end +end diff --git a/lib/graphql/field.rb b/lib/graphql/field.rb index 2c93f742c1..ca2fda9e2c 100644 --- a/lib/graphql/field.rb +++ b/lib/graphql/field.rb @@ -123,13 +123,15 @@ class Field accepts_definitions :name, :description, :deprecation_reason, :resolve, :type, :arguments, :property, :hash_key, :complexity, :mutation, - argument: GraphQL::Define::AssignArgument + argument: GraphQL::Define::AssignArgument, + batch_resolve: GraphQL::Define::AssignBatchResolve - attr_accessor :name, :deprecation_reason, :description, :property, :hash_key, :mutation, :arguments, :complexity + + attr_accessor :name, :deprecation_reason, :description, :property, :hash_key, :mutation, :arguments, :complexity, :batch_loader ensure_defined( :name, :deprecation_reason, :description, :property, :hash_key, :mutation, :arguments, :complexity, - :resolve, :resolve=, :type, :type=, :name=, :property=, :hash_key= + :resolve, :resolve=, :type, :type=, :name=, :property=, :hash_key=, :batch_loader ) # @!attribute [r] resolve_proc diff --git a/lib/graphql/query.rb b/lib/graphql/query.rb index 3de98fe663..802e1e33d6 100644 --- a/lib/graphql/query.rb +++ b/lib/graphql/query.rb @@ -25,7 +25,7 @@ def call(member) end end - attr_reader :schema, :document, :context, :fragments, :operations, :root_value, :max_depth, :query_string, :warden + attr_reader :schema, :document, :context, :fragments, :operations, :root_value, :max_depth, :query_string, :warden, :accumulator # Prepare query `query_string` on `schema` # @param schema [GraphQL::Schema] @@ -45,6 +45,8 @@ def initialize(schema, query_string = nil, document: nil, context: nil, variable @max_depth = max_depth || schema.max_depth @max_complexity = max_complexity || schema.max_complexity @query_analyzers = schema.query_analyzers.dup + @accumulator = GraphQL::Execution::Batch::Accumulator.new + if @max_depth @query_analyzers << GraphQL::Analysis::MaxQueryDepth.new(@max_depth) end diff --git a/lib/graphql/query/context.rb b/lib/graphql/query/context.rb index 404a1c5f9c..66a3af3c7e 100644 --- a/lib/graphql/query/context.rb +++ b/lib/graphql/query/context.rb @@ -60,6 +60,10 @@ def add_error(error) nil end + + def batch(item_param, &func) + GraphQL::Execution::Batch.resolve(item_param, func) + end end end end diff --git a/spec/graphql/execution/batch_spec.rb b/spec/graphql/execution/batch_spec.rb new file mode 100644 index 0000000000..68f5ad0bdd --- /dev/null +++ b/spec/graphql/execution/batch_spec.rb @@ -0,0 +1,189 @@ +require "spec_helper" + +describe GraphQL::Execution::Batch do + LOADS = [] + + module MultiplyLoader + def self.call(factor, ints) + LOADS << ints + ints.each do |int| + yield(int, int * factor) + end + end + end + + BatchQueryType = GraphQL::ObjectType.define do + name "Query" + field :int, types.Int do + argument :value, types.Int + batch_resolve MultiplyLoader, 1, -> (_obj, args, ctx) { + # It's ok to not pass a block + ctx.batch(args[:value]) + } + end + + field :batchInt, BatchIntType do + argument :value, types.Int + batch_resolve MultiplyLoader, 2, -> (_obj, args, ctx) { + ctx.batch(args[:value]) { |v| OpenStruct.new(val: v, query: :q) } + } + end + + field :self, BatchQueryType, resolve: ->(o, a, c) { :q } + field :selfs, types[BatchQueryType] do + argument :count, types.Int + resolve ->(o, args, c) { args[:count].times.map { :q } } + end + end + + BatchIntType = GraphQL::ObjectType.define do + name "BatchInt" + field :val, types.Int + field :query, BatchQueryType + end + + BatchSchema = GraphQL::Schema.define do + query(BatchQueryType) + directives(["defer", "stream"]) + query_execution_strategy(GraphQL::Execution::DeferredExecution) + end + + before do + LOADS.clear + end + + describe "batch resolving" do + it "makes one load with all values" do + res = BatchSchema.execute(%| + { + one: int(value: 1) + two: int(value: 2) + self { + three: int(value: 3) + three2: int(value: 3) + } + } + |) + + assert_equal [[1,2,3]], LOADS, "It called the loader once" + expected_data = { + "one" => 1, + "two" => 2, + "self" => { + "three" => 3, + "three2" => 3 + } + } + assert_equal expected_data, res["data"], "The loader modified values" + end + end + + describe "nested batch resolving" do + it "makes multiple batches" do + res = BatchSchema.execute(%| + { + one: int(value: 1), + two: int(value: 2), + six: batchInt(value: 6) { + val + query { + three: int(value: 3) + four: int(value: 4) + } + } + seven: batchInt(value: 7) { + val + query { + five: int(value: 5) + } + } + } + |) + + assert_equal [[1,2], [6,7], [3,4,5]], LOADS, "It called the loader multiple times" + + expected_data = { + "one" => 1, + "two" => 2, + "six" => { + "val" => 12, + "query" => { + "three" => 3, + "four" => 4, + } + }, + "seven" => { + "val" => 14, + "query" => { + "five" => 5, + } + } + } + assert_equal expected_data, res["data"], "It applies different loaders" + end + end + + describe "deferred batch resolving" do + it "does deferred batches" do + res = BatchSchema.execute(%| + { + one: batchInt(value: 1) { + val + } + two: batchInt(value: 2) @defer { + val + query { + three: int(value: 3) + four: int(value: 4) + } + } + five: batchInt(value: 5) @defer { + val + query { + six: int(value: 6) + } + } + } + |) + assert_equal [[1], [2], [3,4], [5], [6]], LOADS + expected_data = { + "one"=>{"val"=>2}, + "two"=>{"val"=>4, "query"=>{"three"=>3, "four"=>4}}, + "five"=>{"val"=>10, "query"=>{"six"=>6}}, + } + assert_equal expected_data, res["data"] + end + end + + describe "streamed batches" do + it "loads them one at a time" do + res = BatchSchema.execute(%| + { + selfs(count: 3) @stream { + t: __typename + one: int(value: 1) + two: int(value: 2) + } + } + |) + + pp res + + expected_loads = [[1,2], [1,2], [1,2]] + assert_equal expected_loads, LOADS + + expected_data = { + "selfs"=> [ + {"t" => "Query", "one"=>1, "two"=>2}, + {"t" => "Query", "one"=>1, "two"=>2}, + {"t" => "Query", "one"=>1, "two"=>2}, + ] + } + assert_equal expected_data, res["data"] + end + end + + describe "yielding errors in batches" do + it "treats it like a returned error" + end +end