Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 68 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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
```
10 changes: 7 additions & 3 deletions benchmark/run.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -49,7 +50,8 @@ def benchmark_execution
SCHEMA,
BREADTH_RESOLVERS,
DOCUMENT,
data_source
data_source,
tracers: [CARDINAL_TRACER],
).perform
end

Expand Down Expand Up @@ -81,7 +83,8 @@ def benchmark_lazy_execution
SCHEMA,
BREADTH_DEFERRED_RESOLVERS,
DOCUMENT,
data_source
data_source,
tracers: [CARDINAL_TRACER],
).perform
end

Expand Down Expand Up @@ -110,7 +113,8 @@ def memory_profile
SCHEMA,
BREADTH_RESOLVERS,
DOCUMENT,
data_source
data_source,
tracers: [CARDINAL_TRACER],
).perform
end

Expand Down
1 change: 1 addition & 0 deletions lib/graphql/cardinal.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
22 changes: 14 additions & 8 deletions lib/graphql/cardinal/errors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
50 changes: 24 additions & 26 deletions lib/graphql/cardinal/executor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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|
Expand All @@ -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?
Expand All @@ -56,26 +51,26 @@ 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
fragment = @request.fragment_definitions[node.name]
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?
Expand All @@ -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?
Expand Down
Loading