diff --git a/README.md b/README.md index 93cfc43..9f4d9e9 100644 --- a/README.md +++ b/README.md @@ -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: { ... }, @@ -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. @@ -94,6 +96,7 @@ This taxonomy provides the following API, which is useful while writing resolver - `attribute?()`: 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. @@ -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 @@ -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: @@ -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: @@ -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: @@ -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: @@ -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 @@ -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 @@ -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: diff --git a/lib/graphql/breadth/executor.rb b/lib/graphql/breadth/executor.rb index 6d2dec6..541d6db 100644 --- a/lib/graphql/breadth/executor.rb +++ b/lib/graphql/breadth/executor.rb @@ -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) @@ -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] @@ -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 @@ -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) @@ -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) diff --git a/test/graphql/breadth/executor_test.rb b/test/graphql/breadth/executor_test.rb index 8be1582..26e8fc1 100644 --- a/test/graphql/breadth/executor_test.rb +++ b/test/graphql/breadth/executor_test.rb @@ -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") { diff --git a/test/test_helper.rb b/test/test_helper.rb index 983c09e..d87579a 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -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), @@ -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