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
90 changes: 61 additions & 29 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ The core execution algorithm is proven at scale in production. Subscriptions and
executor = GraphQL::Breadth::Executor.new(
MyGraphQLSchema,
GraphQL.parse(document),
operation_name: "GetWidgets",
root_object: { ... },
variables: { ... },
context: { ... },
Expand Down Expand Up @@ -82,7 +83,8 @@ This taxonomy provides the following API, which is useful while writing resolver
- `path`: the selection path leading to the field, composed of namespaces with no list indices.
- `key`: the namespace assigned by the field's selection alias or definition name.
- `type`: the GraphQL return type of the field, may be abstract with non-null and list wrappers.
- `arguments`: a frozen hash of arguments provided to the selection. Argument keys are `:snake_case` symbols. Argument transformations are intentionally do not supported (i.e. the input "prepare" hook); argument formatting should be done holistically in the resolver.
- `objects`: the field's frozen object set. All fields share this set with their scope.
- `arguments`: a frozen hash of arguments provided to the selection. Argument keys are `:snake_case` symbols. Argument "prepare" hooks are intentionally not supported; argument formatting should be done holistically in the resolver.
- `mutable_arguments`: a mutable clone of the arguments hash that can be modified.
- `definition`: the associated GraphQL field definition. For schema reference only (avoid repurposing legacy implementation details).
- `scope`: the parent execution scope that this field belongs to.
Expand All @@ -94,6 +96,7 @@ This taxonomy provides the following API, which is useful while writing resolver
- `attribute?(<name>)`: checks an attribute without allocating storage.
* **`ExecutionScope`**: represents a resolved object scope with a known concrete object type.
- `path`: selection path leading to the scope, composed of namespaces with no list indices.
- `objects`: the scopes's frozen object set.
- `parent`: the execution scope above this one.
- `parent_field`: the execution field in the parent scope that opened this scope.
- `parent_type`: the GraphQL object type of the scope. This is always a resolved object type, never an abstract interface or union.
Expand All @@ -107,7 +110,7 @@ This taxonomy provides the following API, which is useful while writing resolver

## Field resolvers

For each field implementation, set up a `GraphQL::Breadth::FieldResolver` or use a [keyword helper](#resolver-keywords):
For each field implementation, set up a `GraphQL::Breadth::FieldResolver`:

```ruby
class MyFieldResolver < GraphQL::Breadth::FieldResolver
Expand All @@ -117,12 +120,7 @@ class MyFieldResolver < GraphQL::Breadth::FieldResolver
end
```

A field resolver receives:

* `exec_field`: the execution field providing resources for the scope with [many useful properties](#execution-taxonomy), most importantly:
- `exec_field.objects`: the set of objects being resolved.
- `exec_field.arguments`: a hash of resolved arguments provided to the field.
* `context`: the request context.
A field resolver receives `exec_field` and `context`. The execution field provides `objects` and `arguments`, along with [many other useful properties](#execution-taxonomy).

A resolver **must return a mapped set of results** for the field's objects, or invoke a [lazy resolver hook](#lazy-resolvers). To attach a field resolver to a field, use the `GraphQL::Breadth::HasBreadthResolver` field mixin:

Expand All @@ -142,6 +140,25 @@ class MyObject < BaseObject
end
```

Alternatively, you can manage field implementations using a resolver map:

```ruby
RESOLVER_MAP = {
"Query" => {
"widget" => WidgetResolver.new,
},
"Widget" => {
"id" => GraphQL::Cardinal::MethodResolver.new(:id),
}
}.freeze

executor = GraphQL::Breadth::Executor.new(
MyGraphQLSchema,
GraphQL.parse(document),
resolvers: RESOLVER_MAP,
)
```

### Built-in resolvers

The core library includes several basic resolvers for common needs:
Expand All @@ -165,25 +182,6 @@ class MyFieldResolver < GraphQL::Breadth::FieldResolver
end
```

### Attribute caches

Field resolvers may build resources that can be shared with other fields across their scope. It's easy to share this sort of data using `attributes`. Both execution fields [and their scopes](#execution-taxonomy) support setting attributes:

```ruby
class MyFieldResolver < GraphQL::Breadth::FieldResolver
def resolve(exec_field, context)
# cache wrapped objects on the parent scope...
wrapped_objects = exec_field.scope.attributes[:wrapped_objects] ||= begin
exec_field.objects.map { |obj| MyWrapper.new(obj) }
end

wrapped_objects.map(&:wrapper_method)
end
end
```

When reading attributes, prefer using `element.attribute(key)` to avoid allocating unnecessary storage.

### Error handling

To error out specific object positions within a field, error instances must be mapped into the field's result set. Use the `handle_or_reraise` helper within a StandardError rescue block to optimally handle raised mapping errors:
Expand Down Expand Up @@ -222,6 +220,25 @@ class MyFieldResolver < GraphQL::Breadth::FieldResolver
end
```

### Resolver caches

Field resolvers may build and cache resources to be shared with other fields across their scope using `attributes`. Both execution fields and their scopes support setting attributes:

```ruby
class MyFieldResolver < GraphQL::Breadth::FieldResolver
def resolve(exec_field, context)
# cache wrapped objects on the parent scope...
wrapped_objects = exec_field.scope.attributes[:wrapped_objects] ||= begin
exec_field.objects.map { |obj| MyWrapper.new(obj) }
end

wrapped_objects.map(&:wrapper_method)
end
end
```

When reading attributes, prefer using `element.attribute(key)` to avoid allocating unnecessary storage.

## Lazy resolvers

Breadth field resolvers receive sets, which provides implicit batching for a single field instance. However, this doesn't take into account the same field loading at multiple document positions, or different fields sharing a query. For example:
Expand Down Expand Up @@ -388,7 +405,7 @@ end

### Loader concurrency

Lazy loaders may engage Ruby's fiber-based concurrency to asynchronously queue scheduler-compatible I/O, such as `async-http` requests (CPU-bound work and thread-blocking clients cannot be parallelized). Running the scheduler is not free, so loaders operate synchronously by default and must manually opt into async workflows when built to leverage them.
Lazy loaders may engage Ruby's fiber-based concurrency to asynchronously queue scheduler-compatible I/O, such as `async-http` requests (CPU-bound work and thread-blocking clients cannot be parallelized). Running the Ruby scheduler is not free, so loaders must manually opt into async workflows when designed to leverage them.

#### Install

Expand Down Expand Up @@ -464,7 +481,11 @@ class RemoteInventoryLoader < GraphQL::Breadth::LazyLoader
end
```

These fan-out instance methods share resource budgeting with their loader class by default. They can also specify their own `resource:`, `limit:`, and/or `timeout:` arguments to manage separate budgets from their loader class.
These fan-out instance methods share resource budgeting with their loader class by default. They can also specify their own `resource:`, `limit:`, and/or `timeout:` arguments to manage separate budgets from their loader class:

```ruby
async_map(keys, resource: :sprockets, limit: 3) { |key| client.fetch_inventory(key) }
```

## Query planning

Expand Down Expand Up @@ -661,6 +682,17 @@ class Language < GraphQL::Schema::Directive
end
```

You can also install directive resolvers via a resolver map:

```ruby
RESOLVER_MAP = {
"@language" => LanguageDirectiveResolver.new,
"Query" => {
"widgets" => WidgetsFieldResolver.new,
},
}
```

### Wrapping directives

Directive resolvers can be configured as block wrappers around all of GraphQL execution (QUERY / MUTATION), or around the execution of a field (FIELD). Wrapping is disabled by default because it adds overhead. To enable wrapping for a specific directive, enable it for the resolver and include a `yield` in its resolver, or pass the resolver `&block` forward:
Expand Down
25 changes: 10 additions & 15 deletions lib/graphql/breadth/executor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,15 @@ class Executor
#| ?root_object: untyped,
#| ?variables: Hash[String | Symbol, untyped],
#| ?context: Hash[String | Symbol, untyped],
#| ?operation_name: String?,
#| ?tracers: Array[Tracer],
#| ?authorization: singleton(Authorization),
#| ) -> void
def initialize(schema, document, resolvers: EMPTY_OBJECT, root_object: nil, variables: {}, context: {}, tracers: [], authorization: Authorization)
def initialize(schema, document, resolvers: EMPTY_OBJECT, root_object: nil, variables: {}, context: {}, operation_name: nil, tracers: [], authorization: Authorization)
@provided_variables = variables.each_with_object({}) { |(key, value), out| out[key.to_s] = value }
@schema = schema
@resolvers = resolvers
@query = GraphQL::Query.new(schema, document: document, variables: @provided_variables, context: context) # << for schema reference
@query = GraphQL::Query.new(schema, document: document, variables: @provided_variables, context: context, operation_name: operation_name) # << for schema reference
@context = @query.context
@input = InputFormatter.new(@context)
@planner = ExecutionPlanner.new(executor: self, resolvers: @resolvers)
Expand All @@ -93,17 +94,17 @@ def initialize(schema, document, resolvers: EMPTY_OBJECT, root_object: nil, vari

#: -> bool
def query?
@query.selected_operation.operation_type == ExecutionPlanner::QUERY_OPERATION
@query.selected_operation&.operation_type == ExecutionPlanner::QUERY_OPERATION
end

#: -> bool
def mutation?
@query.selected_operation.operation_type == ExecutionPlanner::MUTATION_OPERATION
@query.selected_operation&.operation_type == ExecutionPlanner::MUTATION_OPERATION
end

#: -> bool
def subscription?
@query.selected_operation.operation_type == ExecutionPlanner::SUBSCRIPTION_OPERATION
@query.selected_operation&.operation_type == ExecutionPlanner::SUBSCRIPTION_OPERATION
end

#: -> Hash[String, GraphQL::Language::Nodes::FragmentDefinition]
Expand Down Expand Up @@ -175,6 +176,7 @@ def execute_subscription_event(object)
root_object: object,
variables: @provided_variables,
context: @context_value,
operation_name: @query.operation_name,
tracers: @tracers,
authorization: @authorization_class,
).execute
Expand Down Expand Up @@ -267,13 +269,8 @@ def execute
@tracers.each { _1.start(self, @context) }
end

# reference selected operation first to trigger AST evaluation
operation = @query.selected_operation

if !@context.errors.empty?
# Return any parsing errors
return build_result(errors: @context.errors.map(&:to_h))
end
return build_result(errors: @query.static_errors.map(&:to_h)) unless operation

begin
@input.coerce_variable_values(operation.variables, @query.provided_variables || EMPTY_OBJECT)
Expand Down Expand Up @@ -966,11 +963,9 @@ def build_abstract_scopes(exec_field, return_type, next_objects, next_results)
#: -> (SubscriptionResponseStream | graphql_result)
def execute_subscription
@executed = true
operation = @query.selected_operation

unless @context.errors.empty?
return build_result(errors: @context.errors.map(&:to_h))
end
operation = @query.selected_operation
return build_result(errors: @query.static_errors.map(&:to_h)) unless operation

begin
@input.coerce_variable_values(operation.variables, @query.provided_variables || EMPTY_OBJECT)
Expand Down
52 changes: 52 additions & 0 deletions test/graphql/breadth/executor_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,58 @@ def test_resolves_typename_field
assert_equal expected, breadth(document, source).dig("data")
end

def test_selects_named_operation
document = %|
query ProductList {
products(first: 1) {
nodes {
title
}
}
}

query CurrentNode {
node(id: "Product/1") {
__typename
}
}
|

source = {
"products" => { "nodes" => [{ "title" => "GraphQL T-Shirt" }] },
"node" => { "__typename__" => "Product" },
}

expected = {
"node" => { "__typename" => "Product" },
}

assert_equal expected, breadth(document, source, operation_name: "CurrentNode").dig("data")
end

def test_returns_error_when_operation_name_is_required
document = %|
query ProductList {
products(first: 1) {
nodes {
title
}
}
}

query CurrentNode {
node(id: "Product/1") {
__typename
}
}
|

result = breadth(document, {})

assert_equal "An operation name is required", result.dig("errors", 0, "message")
refute result.key?("data")
end

def test_mutations_run_serially
document = %|mutation {
a: writeValue(value: "test1") {
Expand Down
3 changes: 2 additions & 1 deletion test/test_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
require_relative './fixtures'
require_relative './star_wars_fixtures'

def breadth(query, source, variables: {}, context: {}, tracers: [GraphQL::Breadth::Tracer.new])
def breadth(query, source, variables: {}, context: {}, operation_name: nil, tracers: [GraphQL::Breadth::Tracer.new])
GraphQL::Breadth::Executor.new(
SCHEMA,
GraphQL.parse(query),
Expand All @@ -30,6 +30,7 @@ def breadth(query, source, variables: {}, context: {}, tracers: [GraphQL::Breadt
tracers: tracers,
variables: variables,
context: context,
operation_name: operation_name,
).result
end

Expand Down
Loading