diff --git a/README.md b/README.md index 8957c9a..fc710e4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ![Cardinal](./images/cardinal.png) -**An (experimental) breadth-first GraphQL executor for Ruby** +**An (experimental) breadth-first GraphQL executor written in Ruby** Depth-first execution resolves every object field descending down a response tree, while breadth-first visits every _selection position_ once with an aggregated set of objects. The breadth-first approach is much faster due to fewer resolver calls and intermediary promises. @@ -45,3 +45,70 @@ While bigger responses will always take longer to process, the workload is your * Eliminates boilerplate need for DataLoader promises, because resolvers are inherently batched. * Executes via flat queuing without deep recursion and large call stacks. + +## API + +Setup a `GraphQL::Cardinal::FieldResolver`: + +```ruby +class MyFieldResolver < GraphQL::Cardinal::FieldResolver + def resolve(objects, args, ctx, scope) + map_sources(objects) { |obj| obj.my_field } + end +end +``` + +A field resolver provides: + +* `objects`: the array of objects to resolve the field on. +* `args`: the coerced arguments provided to this selection field. +* `ctx`: the request context. +* `scope`: (experimental) a handle to the execution scope that invokes lazy hooks. + +A resolver must return a mapped set of data for the provided objects. Always use the `map_sources` helper for your mapping loop to assure that exceptions are captured properly. You may return errors for a field position by mapping an `ExecutionError` into it: + +```ruby +class MyFieldResolver < GraphQL::Cardinal::FieldResolver + def resolve(objects, args, ctx, scope) + map_sources(objects) do |obj| + obj.valid? ? obj.my_field : GraphQL::Cardinal::ExecutionError.new("Object field not valid") + end + end +end +``` + +Now setup a resolver map: + +```ruby +RESOLVER_MAP = { + "MyType" => { + "myField" => MyFieldResolver.new, + }, + "Query" => { + "myType" => MyTypeResolver.new, + }, +}.freeze +``` + +Now parse your schema definition and execute requests: + +```ruby +SCHEMA = GraphQL::Schema.from_definition(%| + type MyType { + myField: String + } + type Query { + myType: MyType + } +|) + +result = GraphQL::Cardinal::Executor.new( + SCHEMA, + RESOLVER_MAP, + GraphQL.parse(query), + {}, # root object + variables: { ... }, + context: { ... }, + tracers: [ ... ], +).perform +``` diff --git a/benchmark/run.rb b/benchmark/run.rb index e417f59..059f8cf 100644 --- a/benchmark/run.rb +++ b/benchmark/run.rb @@ -12,6 +12,7 @@ class GraphQLBenchmark DOCUMENT = GraphQL.parse(BASIC_DOCUMENT) CARDINAL_SCHEMA = SCHEMA + CARDINAL_TRACER = GraphQL::Cardinal::Tracer.new class Schema < GraphQL::Schema lazy_resolve(Proc, :call) @@ -49,7 +50,8 @@ def benchmark_execution SCHEMA, BREADTH_RESOLVERS, DOCUMENT, - data_source + data_source, + tracers: [CARDINAL_TRACER], ).perform end @@ -81,7 +83,8 @@ def benchmark_lazy_execution SCHEMA, BREADTH_DEFERRED_RESOLVERS, DOCUMENT, - data_source + data_source, + tracers: [CARDINAL_TRACER], ).perform end @@ -110,7 +113,8 @@ def memory_profile SCHEMA, BREADTH_RESOLVERS, DOCUMENT, - data_source + data_source, + tracers: [CARDINAL_TRACER], ).perform end diff --git a/lib/graphql/cardinal.rb b/lib/graphql/cardinal.rb index 7828ad1..895eace 100644 --- a/lib/graphql/cardinal.rb +++ b/lib/graphql/cardinal.rb @@ -13,6 +13,7 @@ module Cardinal require_relative "cardinal/errors" require_relative "cardinal/promise" require_relative "cardinal/loader" +require_relative "cardinal/tracer" require_relative "cardinal/field_resolvers" require_relative "cardinal/executor" require_relative "cardinal/version" diff --git a/lib/graphql/cardinal/errors.rb b/lib/graphql/cardinal/errors.rb index 674bff4..39f1ca2 100644 --- a/lib/graphql/cardinal/errors.rb +++ b/lib/graphql/cardinal/errors.rb @@ -5,11 +5,20 @@ module GraphQL module Cardinal class ExecutionError < StandardError - attr_accessor :path + attr_reader :path - def initialize(message = "An unknown error occurred", path: nil) + def initialize(message = "An unknown error occurred", path: nil, base: false) super(message) @path = path + @base = base + end + + def base_error? + @base + end + + def replace_path(new_path) + @path = new_path end def to_h @@ -23,19 +32,16 @@ def to_h class AuthorizationError < ExecutionError attr_reader :type_name, :field_name - def initialize(message = nil, type_name: nil, field_name: nil, path: nil) - super(message, path: path) + def initialize(message = "Not authorized", type_name: nil, field_name: nil, path: nil, base: true) + super(message, path: path, base: base) @type_name = type_name @field_name = field_name end end class InvalidNullError < ExecutionError - attr_reader :original_error - - def initialize(message = "Cannot resolve value", path: nil, original_error: nil) + def initialize(message = "Failed to resolve expected value", path: nil) super(message, path: path) - @original_error = original_error end end diff --git a/lib/graphql/cardinal/executor.rb b/lib/graphql/cardinal/executor.rb index fa04a27..88fc069 100644 --- a/lib/graphql/cardinal/executor.rb +++ b/lib/graphql/cardinal/executor.rb @@ -3,36 +3,35 @@ require_relative "./executor/execution_scope" require_relative "./executor/execution_field" require_relative "./executor/authorization" -require_relative "./executor/tracer" require_relative "./executor/hot_paths" require_relative "./executor/response_hash" -require_relative "./executor/response_shape" +require_relative "./executor/error_formatting" module GraphQL module Cardinal class Executor include HotPaths - include ResponseShape + include ErrorFormatting TYPENAME_FIELD = "__typename" TYPENAME_FIELD_RESOLVER = TypenameResolver.new attr_reader :exec_count - def initialize(schema, resolvers, document, root_object) + def initialize(schema, resolvers, document, root_object, variables: {}, context: {}, tracers: []) @query = GraphQL::Query.new(schema, document: document) # << for schema reference @resolvers = resolvers @document = document @root_object = root_object - @tracer = Tracer.new - @variables = {} - @context = { query: @query } + @tracers = tracers + @variables = variables + @context = context @data = {} @errors = [] - @inline_errors = false @path = [] @exec_queue = [] @exec_count = 0 + @context[:query] = @query end def perform @@ -70,9 +69,7 @@ def perform execute_scope(@exec_queue.shift) until @exec_queue.empty? end - response = { - "data" => @inline_errors ? shape_response(@data) : @data, - } + response = { "data" => @errors.empty? ? @data : format_inline_errors(@data, @errors) } response["errors"] = @errors.map(&:to_h) unless @errors.empty? response end @@ -102,22 +99,21 @@ def execute_scope(exec_scope) end resolved_sources = if !field_resolver.authorized?(@context) - @errors << AuthorizationError.new(type_name: parent_type.graphql_name, field_name: field_name, path: @path.dup) - Array.new(parent_sources.length, nil) + @errors << AuthorizationError.new(type_name: parent_type.graphql_name, field_name: field_name, path: @path.dup, base: true) + Array.new(parent_sources.length, @errors.last) elsif !Authorization.can_access_type?(value_type, @context) - @errors << AuthorizationError.new(type_name: value_type.graphql_name, path: @path.dup) - Array.new(parent_sources.length, nil) + @errors << AuthorizationError.new(type_name: value_type.graphql_name, path: @path.dup, base: true) + Array.new(parent_sources.length, @errors.last) else begin - @tracer&.before_resolve_field(parent_type, field_name, parent_sources.length, @context) + @tracers.each { _1.before_resolve_field(parent_type, field_name, parent_sources.length, @context) } field_resolver.resolve(parent_sources, exec_field.arguments(@variables), @context, exec_scope) rescue StandardError => e - raise e - report_exception(e.message) - @errors << InternalError.new(e.message, path: @path.dup) - Array.new(parent_sources.length, nil) + report_exception(error: e) + @errors << InternalError.new(path: @path.dup, base: true) + Array.new(parent_sources.length, @errors.last) ensure - @tracer&.after_resolve_field(parent_type, field_name, parent_sources.length, @context) + @tracers.each { _1.after_resolve_field(parent_type, field_name, parent_sources.length, @context) } @exec_count += 1 end end @@ -197,8 +193,8 @@ def resolve_execution_field(exec_scope, exec_field, resolved_sources, lazy_field next_sources_by_type.each do |impl_type, impl_type_sources| # check concrete type access only once per resolved type... unless Authorization.can_access_type?(impl_type, @context) - @errors << AuthorizationError.new(type_name: impl_type.graphql_name, path: @path.dup) - impl_type_sources = Array.new(impl_type_sources.length, nil) + @errors << AuthorizationError.new(type_name: impl_type.graphql_name, path: @path.dup, base: true) + impl_type_sources = Array.new(impl_type_sources.length, @errors.last) end loader_group << ExecutionScope.new( @@ -262,7 +258,7 @@ def execution_fields_by_key(parent_type, selections, map: Hash.new { |h, k| h[k] end else - raise DocumentError.new("selection node type") + raise DocumentError.new("Invalid selection node type") end end map @@ -290,8 +286,10 @@ def if_argument?(bool_arg) end end - def report_exception(message, path: @path.dup) - # todo: hook up some kind of error reporting... + def report_exception(message = nil, error: nil, path: @path.dup) + # todo: add real error reporting... + puts "Error at #{path.join(".")}: #{message || error&.message}" + puts error.backtrace.join("\n") if error end end end diff --git a/lib/graphql/cardinal/executor/response_shape.rb b/lib/graphql/cardinal/executor/error_formatting.rb similarity index 68% rename from lib/graphql/cardinal/executor/response_shape.rb rename to lib/graphql/cardinal/executor/error_formatting.rb index 2e1adff..aaf1ca1 100644 --- a/lib/graphql/cardinal/executor/response_shape.rb +++ b/lib/graphql/cardinal/executor/error_formatting.rb @@ -3,18 +3,20 @@ module GraphQL::Cardinal class Executor - module ResponseShape + module ErrorFormatting private - def shape_response(data) - operation = @query.selected_operation - parent_type = @query.root_type_for_operation(operation.operation_type) - + def format_inline_errors(data, _errors) + # todo: make this smarter to only traverse down actual error paths @path = [] - resolve_object_scope(data, parent_type, operation.selections) + propagate_object_scope_errors( + data, + @query.root_type_for_operation(@query.selected_operation.operation_type), + @query.selected_operation.selections, + ) end - def resolve_object_scope(raw_object, parent_type, selections) + def propagate_object_scope_errors(raw_object, parent_type, selections) return nil if raw_object.nil? selections.each do |node| @@ -29,22 +31,15 @@ def resolve_object_scope(raw_object, parent_type, selections) raw_value = raw_object[field_name] raw_object[field_name] = if raw_value.is_a?(ExecutionError) - # capture errors encountered in the response with proper path - @errors << if raw_value.is_a?(InvalidNullError) && raw_value.original_error - raw_value.original_error.path = @path.dup - raw_value.original_error - else - raw_value.path = @path.dup - raw_value - end + raw_value.replace_path(@path.dup) unless raw_value.base_error? nil elsif node_type.list? node_type = node_type.of_type while node_type.non_null? - resolve_list_scope(raw_value, node_type, node.selections) + propagate_list_scope_errors(raw_value, node_type, node.selections) elsif named_type.kind.leaf? raw_value else - resolve_object_scope(raw_value, named_type, node.selections) + propagate_object_scope_errors(raw_value, named_type, node.selections) end return nil if node_type.non_null? && raw_object[field_name].nil? @@ -56,7 +51,7 @@ def resolve_object_scope(raw_object, parent_type, selections) fragment_type = node.type ? @query.get_type(node.type.name) : parent_type next unless typename_in_type?(raw_object.typename, fragment_type) - result = resolve_object_scope(raw_object, fragment_type, node.selections) + result = propagate_object_scope_errors(raw_object, fragment_type, node.selections) return nil if result.nil? when GraphQL::Language::Nodes::FragmentSpread @@ -64,18 +59,18 @@ def resolve_object_scope(raw_object, parent_type, selections) fragment_type = @query.get_type(fragment.type.name) next unless typename_in_type?(raw_object.typename, fragment_type) - result = resolve_object_scope(raw_object, fragment_type, fragment.selections) + result = propagate_object_scope_errors(raw_object, fragment_type, fragment.selections) return nil if result.nil? else - raise DocumentError.new("selection node type") + raise DocumentError.new("Invalid selection node type") end end raw_object end - def resolve_list_scope(raw_list, current_node_type, selections) + def propagate_list_scope_errors(raw_list, current_node_type, selections) return nil if raw_list.nil? current_node_type = current_node_type.of_type while current_node_type.non_null? @@ -88,11 +83,11 @@ def resolve_list_scope(raw_list, current_node_type, selections) begin result = if next_node_type.list? - resolve_list_scope(raw_list_element, next_node_type, selections) + propagate_list_scope_errors(raw_list_element, next_node_type, selections) elsif named_type.kind.leaf? raw_list_element else - resolve_object_scope(raw_list_element, named_type, selections) + propagate_object_scope_errors(raw_list_element, named_type, selections) end if result.nil? diff --git a/lib/graphql/cardinal/executor/hot_paths.rb b/lib/graphql/cardinal/executor/hot_paths.rb index d914c25..e26c81f 100644 --- a/lib/graphql/cardinal/executor/hot_paths.rb +++ b/lib/graphql/cardinal/executor/hot_paths.rb @@ -32,15 +32,15 @@ def build_composite_response(field_type, source, next_sources, next_responses) # DANGER: HOT PATH! # Overhead added here scales dramatically... def build_missing_value(field_type, val) + # the provided value should always be nil or an error object + if field_type.non_null? - # upgrade nil in non-null positions to an error - val = InvalidNullError.new(path: @path.dup, original_error: val) + val ||= InvalidNullError.new(path: @path.dup) end if val - # assure all errors have paths, and note inline error additions - val = val.path ? val : ExecutionError.new(val.message, path: @path.dup) - @inline_errors = true + val.replace_path(@path.dup) unless val.path + @errors << val unless val.base_error? end val diff --git a/lib/graphql/cardinal/field_resolvers.rb b/lib/graphql/cardinal/field_resolvers.rb index e2a9cb0..4a6040d 100644 --- a/lib/graphql/cardinal/field_resolvers.rb +++ b/lib/graphql/cardinal/field_resolvers.rb @@ -10,6 +10,18 @@ def authorized?(_ctx) def resolve(objects, _args, _ctx, _scope) raise NotImplementedError, "Resolver#resolve must be implemented." end + + def map_sources(objects) + objects.map do |obj| + yield(obj) + rescue StandardError => e + handle_positional_error(e, obj) + end + end + + def handle_positional_error(_err, _obj) + InternalError.new + end end class HashKeyResolver < FieldResolver @@ -18,10 +30,8 @@ def initialize(key) end def resolve(objects, _args, _ctx, _scope) - objects.map do |hash| + map_sources(objects) do |hash| hash[@key] - rescue StandardError => e - InternalError.new end end end @@ -29,7 +39,7 @@ def resolve(objects, _args, _ctx, _scope) class TypenameResolver < FieldResolver def resolve(objects, _args, _ctx, scope) typename = scope.parent_type.graphql_name.freeze - objects.map { typename } + map_sources(objects) { typename } end end end diff --git a/lib/graphql/cardinal/executor/tracer.rb b/lib/graphql/cardinal/tracer.rb similarity index 91% rename from lib/graphql/cardinal/executor/tracer.rb rename to lib/graphql/cardinal/tracer.rb index 8422d71..88b579a 100644 --- a/lib/graphql/cardinal/executor/tracer.rb +++ b/lib/graphql/cardinal/tracer.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true -module GraphQL::Cardinal - class Executor +module GraphQL + module Cardinal class Tracer def initialize @time = nil diff --git a/test/graphql/cardinal/executor/errors_test.rb b/test/graphql/cardinal/executor/errors_test.rb index a322a8f..e4dbfd9 100644 --- a/test/graphql/cardinal/executor/errors_test.rb +++ b/test/graphql/cardinal/executor/errors_test.rb @@ -67,7 +67,7 @@ def test_non_null_positional_errors }, }, "errors" => [{ - "message" => "Cannot resolve value", + "message" => "Failed to resolve expected value", "path" => ["products", "nodes", 1, "must"], }, { "message" => "An unknown error occurred", diff --git a/test/test_helper.rb b/test/test_helper.rb index 487bc42..0f50dbe 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -18,7 +18,14 @@ require 'graphql/batch' require_relative './fixtures' -def breadth_exec(query, source, variables: {}, context: {}) - executor = GraphQL::Cardinal::Executor.new(SCHEMA, BREADTH_RESOLVERS, GraphQL.parse(query), source) - executor.perform +def breadth_exec(query, source, variables: {}, context: {}, tracers: [GraphQL::Cardinal::Tracer.new]) + GraphQL::Cardinal::Executor.new( + SCHEMA, + BREADTH_RESOLVERS, + GraphQL.parse(query), + source, + tracers: tracers, + variables: variables, + context: context, + ).perform end