diff --git a/guides/_layouts/default.html b/guides/_layouts/default.html index 8053cb4d82..2404663b23 100644 --- a/guides/_layouts/default.html +++ b/guides/_layouts/default.html @@ -44,6 +44,7 @@

Guides

  • Configuration Options
  • Code Reuse
  • Instrumentation
  • +
  • Lazy Execution
  • Limiting Visibility
  • Testing
  • diff --git a/guides/schema/lazy_execution.md b/guides/schema/lazy_execution.md new file mode 100644 index 0000000000..d9af8e01e2 --- /dev/null +++ b/guides/schema/lazy_execution.md @@ -0,0 +1,84 @@ +--- +title: Schema — Lazy Execution +--- + +With lazy execution, you can optimize access to external services (such as databases) by making batched calls. Building a lazy loader has three steps: + +- Define a lazy-loading class with _one_ method for loading & returning a value +- Connect it to your schema with {{ "GraphQL::Schema#lazy_resolve" | api_doc }} +- In `resolve` functions, return instances of the lazy-loading class + +[`graphql-batch`](https://github.com/shopify/graphql-batch) provides a powerful, flexible toolkit for lazy resolution with GraphQL. + +## Example: Batched Find + +Here's a way to find many objects by ID using one database call, preventing N+1 queries. + +1. Lazy-loading class which finds models by ID. + + ```ruby + class LazyFindPerson + def initialize(query_ctx, person_id) + @person_id = person_id + # Initialize the loading state for this query, + # or get the previously-initiated state + @lazy_state = query_ctx[:lazy_find_person] ||= { + pending_ids: [], + loaded_ids: {}, + } + # Register this ID to be loaded later: + @lazy_state[:pending_ids] << person_id + end + + # Return the loaded record, hitting the database if needed + def person + # Check if the record was already loaded: + loaded_record = @lazy_state[:loaded_ids][@person_id] + if loaded_record + # The pending IDs were already loaded, + # so return the result of that previous load + loaded_record + else + # The record hasn't been loaded yet, so + # hit the database with all pending IDs + pending_ids = @lazy_state[:pending_ids] + people = Person.where(id: pending_ids) + people.each { |person| @lazy_state[:loaded_ids][person.id] = person } + # Now, get the matching person from the loaded result: + @lazy_state[:loaded_ids][@person_id] + end + end + ``` + +2. Connect the lazy resolve method + + ```ruby + MySchema = GraphQL::Schema.define do + # ... + lazy_resolve(LazyFindPerson, :person) + end + ``` + +3. Return lazy objects from `resolve` + + ```ruby + field :author, PersonType do + resolve ->(obj, args, ctx) { + LazyFindPerson(ctx, obj.author_id) + } + end + ``` + +Now, calls to `author` will use batched database access. For example, this query: + +```graphql +{ + p1: post(id: 1) { author { name } } + p2: post(id: 2) { author { name } } + p3: post(id: 3) { author { name } } +} +``` + +Will only make one query to load the `author` values. + +The example above is simple and has some shortcomings. Consider the `graphql-batch` gem for a robust solution to batched resolution. diff --git a/lib/graphql.rb b/lib/graphql.rb index ed42b2c6ac..7143f15016 100644 --- a/lib/graphql.rb +++ b/lib/graphql.rb @@ -67,6 +67,7 @@ def self.scan_with_ragel(query_string) require "graphql/introspection" require "graphql/language" require "graphql/analysis" +require "graphql/execution" require "graphql/schema" require "graphql/schema/loader" require "graphql/schema/printer" @@ -81,5 +82,4 @@ def self.scan_with_ragel(query_string) require "graphql/static_validation" require "graphql/version" require "graphql/relay" -require "graphql/execution" require "graphql/compatibility" diff --git a/lib/graphql/compatibility.rb b/lib/graphql/compatibility.rb index 4068dc71a7..443d68f4af 100644 --- a/lib/graphql/compatibility.rb +++ b/lib/graphql/compatibility.rb @@ -1,3 +1,4 @@ require "graphql/compatibility/execution_specification" +require "graphql/compatibility/lazy_execution_specification" require "graphql/compatibility/query_parser_specification" require "graphql/compatibility/schema_parser_specification" diff --git a/lib/graphql/compatibility/execution_specification.rb b/lib/graphql/compatibility/execution_specification.rb index c8091551ef..61dd8fc321 100644 --- a/lib/graphql/compatibility/execution_specification.rb +++ b/lib/graphql/compatibility/execution_specification.rb @@ -1,3 +1,6 @@ +require "graphql/compatibility/execution_specification/counter_schema" +require "graphql/compatibility/execution_specification/specification_schema" + module GraphQL module Compatibility # Test an execution strategy. This spec is not meant as a development aid. @@ -22,164 +25,22 @@ module Compatibility # - Relay features # module ExecutionSpecification - DATA = { - "1001" => OpenStruct.new({ - name: "Fannie Lou Hamer", - birthdate: Time.new(1917, 10, 6), - organization_ids: [], - }), - "1002" => OpenStruct.new({ - name: "John Lewis", - birthdate: Time.new(1940, 2, 21), - organization_ids: ["2001"], - }), - "1003" => OpenStruct.new({ - name: "Diane Nash", - birthdate: Time.new(1938, 5, 15), - organization_ids: ["2001", "2002"], - }), - "1004" => OpenStruct.new({ - name: "Ralph Abernathy", - birthdate: Time.new(1926, 3, 11), - organization_ids: ["2002"], - }), - "2001" => OpenStruct.new({ - name: "SNCC", - leader_id: nil, # fail on purpose - }), - "2002" => OpenStruct.new({ - name: "SCLC", - leader_id: "1004", - }), - } - # Make a minitest suite for this execution strategy, making sure it # fulfills all the requirements of this library. # @param execution_strategy [<#new, #execute>] An execution strategy class # @return [Class] A test suite for this execution strategy def self.build_suite(execution_strategy) Class.new(Minitest::Test) do - def self.build_schema(execution_strategy) - organization_type = nil - - timestamp_type = GraphQL::ScalarType.define do - name "Timestamp" - coerce_input ->(value) { Time.at(value.to_i) } - coerce_result ->(value) { value.to_i } - end - - named_entity_interface_type = GraphQL::InterfaceType.define do - name "NamedEntity" - field :name, !types.String - end - - person_type = GraphQL::ObjectType.define do - name "Person" - interfaces [named_entity_interface_type] - field :name, !types.String - field :birthdate, timestamp_type - field :age, types.Int do - argument :on, !timestamp_type - resolve ->(obj, args, ctx) { - if obj.birthdate.nil? - nil - else - age_on = args[:on] - age_years = age_on.year - obj.birthdate.year - this_year_birthday = Time.new(age_on.year, obj.birthdate.month, obj.birthdate.day) - if this_year_birthday > age_on - age_years -= 1 - end - end - age_years - } - end - field :organizations, types[organization_type] do - resolve ->(obj, args, ctx) { - obj.organization_ids.map { |id| DATA[id] } - } - end - field :first_organization, !organization_type do - resolve ->(obj, args, ctx) { - DATA[obj.organization_ids.first] - } - end - end - - organization_type = GraphQL::ObjectType.define do - name "Organization" - interfaces [named_entity_interface_type] - field :name, !types.String - field :leader, !person_type do - resolve ->(obj, args, ctx) { - DATA[obj.leader_id] || (ctx[:return_error] ? ExecutionError.new("Error on Nullable") : nil) - } - end - field :returnedError, types.String do - resolve ->(o, a, c) { - GraphQL::ExecutionError.new("This error was returned") - } - end - field :raisedError, types.String do - resolve ->(o, a, c) { - raise GraphQL::ExecutionError.new("This error was raised") - } - end - - field :nodePresence, !types[!types.Boolean] do - resolve ->(o, a, ctx) { - [ - ctx.irep_node.is_a?(GraphQL::InternalRepresentation::Node), - ctx.ast_node.is_a?(GraphQL::Language::Nodes::AbstractNode), - false, # just testing - ] - } - end - end - - node_union_type = GraphQL::UnionType.define do - name "Node" - possible_types [person_type, organization_type] - end - - query_type = GraphQL::ObjectType.define do - name "Query" - field :node, node_union_type do - argument :id, !types.ID - resolve ->(obj, args, ctx) { - obj[args[:id]] - } - end - - field :organization, !organization_type do - argument :id, !types.ID - resolve ->(obj, args, ctx) { - args[:id].start_with?("2") && obj[args[:id]] - } - end - - field :organizations, types[organization_type] do - resolve ->(obj, args, ctx) { - [obj["2001"], obj["2002"]] - } - end - end - - GraphQL::Schema.define do - query_execution_strategy execution_strategy - query query_type - - resolve_type ->(obj, ctx) { - obj.respond_to?(:birthdate) ? person_type : organization_type - } - end + class << self + attr_accessor :counter_schema, :specification_schema end - @@schema = build_schema(execution_strategy) + self.specification_schema = SpecificationSchema.build(execution_strategy) + self.counter_schema = CounterSchema.build(execution_strategy) def execute_query(query_string, **kwargs) - kwargs[:root_value] = DATA - @@schema.execute(query_string, **kwargs) + kwargs[:root_value] = SpecificationSchema::DATA + self.class.specification_schema.execute(query_string, **kwargs) end def test_it_fetches_data @@ -409,47 +270,7 @@ def test_it_doesnt_add_errors_for_invalid_nulls_from_execution_errors end def test_it_only_resolves_fields_once_on_typed_fragments - count = 0 - counter_type = nil - - has_count_interface = GraphQL::InterfaceType.define do - name "HasCount" - field :count, types.Int - field :counter, ->{ counter_type } - end - - counter_type = GraphQL::ObjectType.define do - name "Counter" - interfaces [has_count_interface] - field :count, types.Int, resolve: ->(o,a,c) { count += 1 } - field :counter, has_count_interface, resolve: ->(o,a,c) { :counter } - end - - alt_counter_type = GraphQL::ObjectType.define do - name "AltCounter" - interfaces [has_count_interface] - field :count, types.Int, resolve: ->(o,a,c) { count += 1 } - field :counter, has_count_interface, resolve: ->(o,a,c) { :counter } - end - - has_counter_interface = GraphQL::InterfaceType.define do - name "HasCounter" - field :counter, counter_type - end - - query_type = GraphQL::ObjectType.define do - name "Query" - interfaces [has_counter_interface] - field :counter, has_count_interface, resolve: ->(o,a,c) { :counter } - end - - schema = GraphQL::Schema.define( - query: query_type, - resolve_type: ->(o, c) { o == :counter ? counter_type : nil }, - orphan_types: [alt_counter_type], - ) - - res = schema.execute(" + res = self.class.counter_schema.execute(" { counter { count } ... on HasCounter { @@ -462,10 +283,10 @@ def test_it_only_resolves_fields_once_on_typed_fragments "counter" => { "count" => 1 } } assert_equal expected_data, res["data"] - assert_equal 1, count + assert_equal 1, self.class.counter_schema.metadata[:count] # Deep typed children are correctly distinguished: - res = schema.execute(" + res = self.class.counter_schema.execute(" { counter { ... on Counter { diff --git a/lib/graphql/compatibility/execution_specification/counter_schema.rb b/lib/graphql/compatibility/execution_specification/counter_schema.rb new file mode 100644 index 0000000000..94f87fc79c --- /dev/null +++ b/lib/graphql/compatibility/execution_specification/counter_schema.rb @@ -0,0 +1,52 @@ +module GraphQL + module Compatibility + module ExecutionSpecification + module CounterSchema + def self.build(execution_strategy) + counter_type = nil + schema = nil + + has_count_interface = GraphQL::InterfaceType.define do + name "HasCount" + field :count, types.Int + field :counter, ->{ counter_type } + end + + counter_type = GraphQL::ObjectType.define do + name "Counter" + interfaces [has_count_interface] + field :count, types.Int, resolve: ->(o,a,c) { schema.metadata[:count] += 1 } + field :counter, has_count_interface, resolve: ->(o,a,c) { :counter } + end + + alt_counter_type = GraphQL::ObjectType.define do + name "AltCounter" + interfaces [has_count_interface] + field :count, types.Int, resolve: ->(o,a,c) { schema.metadata[:count] += 1 } + field :counter, has_count_interface, resolve: ->(o,a,c) { :counter } + end + + has_counter_interface = GraphQL::InterfaceType.define do + name "HasCounter" + field :counter, counter_type + end + + query_type = GraphQL::ObjectType.define do + name "Query" + interfaces [has_counter_interface] + field :counter, has_count_interface, resolve: ->(o,a,c) { :counter } + end + + schema = GraphQL::Schema.define( + query: query_type, + resolve_type: ->(o, c) { o == :counter ? counter_type : nil }, + orphan_types: [alt_counter_type], + query_execution_strategy: execution_strategy, + ) + schema.metadata[:count] = 0 + schema + end + end + end + end +end diff --git a/lib/graphql/compatibility/execution_specification/specification_schema.rb b/lib/graphql/compatibility/execution_specification/specification_schema.rb new file mode 100644 index 0000000000..aa01aa408d --- /dev/null +++ b/lib/graphql/compatibility/execution_specification/specification_schema.rb @@ -0,0 +1,154 @@ +module GraphQL + module Compatibility + module ExecutionSpecification + module SpecificationSchema + DATA = { + "1001" => OpenStruct.new({ + name: "Fannie Lou Hamer", + birthdate: Time.new(1917, 10, 6), + organization_ids: [], + }), + "1002" => OpenStruct.new({ + name: "John Lewis", + birthdate: Time.new(1940, 2, 21), + organization_ids: ["2001"], + }), + "1003" => OpenStruct.new({ + name: "Diane Nash", + birthdate: Time.new(1938, 5, 15), + organization_ids: ["2001", "2002"], + }), + "1004" => OpenStruct.new({ + name: "Ralph Abernathy", + birthdate: Time.new(1926, 3, 11), + organization_ids: ["2002"], + }), + "2001" => OpenStruct.new({ + name: "SNCC", + leader_id: nil, # fail on purpose + }), + "2002" => OpenStruct.new({ + name: "SCLC", + leader_id: "1004", + }), + } + + def self.build(execution_strategy) + organization_type = nil + + timestamp_type = GraphQL::ScalarType.define do + name "Timestamp" + coerce_input ->(value) { Time.at(value.to_i) } + coerce_result ->(value) { value.to_i } + end + + named_entity_interface_type = GraphQL::InterfaceType.define do + name "NamedEntity" + field :name, !types.String + end + + person_type = GraphQL::ObjectType.define do + name "Person" + interfaces [named_entity_interface_type] + field :name, !types.String + field :birthdate, timestamp_type + field :age, types.Int do + argument :on, !timestamp_type + resolve ->(obj, args, ctx) { + if obj.birthdate.nil? + nil + else + age_on = args[:on] + age_years = age_on.year - obj.birthdate.year + this_year_birthday = Time.new(age_on.year, obj.birthdate.month, obj.birthdate.day) + if this_year_birthday > age_on + age_years -= 1 + end + end + age_years + } + end + field :organizations, types[organization_type] do + resolve ->(obj, args, ctx) { + obj.organization_ids.map { |id| DATA[id] } + } + end + field :first_organization, !organization_type do + resolve ->(obj, args, ctx) { + DATA[obj.organization_ids.first] + } + end + end + + organization_type = GraphQL::ObjectType.define do + name "Organization" + interfaces [named_entity_interface_type] + field :name, !types.String + field :leader, !person_type do + resolve ->(obj, args, ctx) { + DATA[obj.leader_id] || (ctx[:return_error] ? ExecutionError.new("Error on Nullable") : nil) + } + end + field :returnedError, types.String do + resolve ->(o, a, c) { + GraphQL::ExecutionError.new("This error was returned") + } + end + field :raisedError, types.String do + resolve ->(o, a, c) { + raise GraphQL::ExecutionError.new("This error was raised") + } + end + + field :nodePresence, !types[!types.Boolean] do + resolve ->(o, a, ctx) { + [ + ctx.irep_node.is_a?(GraphQL::InternalRepresentation::Node), + ctx.ast_node.is_a?(GraphQL::Language::Nodes::AbstractNode), + false, # just testing + ] + } + end + end + + node_union_type = GraphQL::UnionType.define do + name "Node" + possible_types [person_type, organization_type] + end + + query_type = GraphQL::ObjectType.define do + name "Query" + field :node, node_union_type do + argument :id, !types.ID + resolve ->(obj, args, ctx) { + obj[args[:id]] + } + end + + field :organization, !organization_type do + argument :id, !types.ID + resolve ->(obj, args, ctx) { + args[:id].start_with?("2") && obj[args[:id]] + } + end + + field :organizations, types[organization_type] do + resolve ->(obj, args, ctx) { + [obj["2001"], obj["2002"]] + } + end + end + + GraphQL::Schema.define do + query_execution_strategy execution_strategy + query query_type + + resolve_type ->(obj, ctx) { + obj.respond_to?(:birthdate) ? person_type : organization_type + } + end + end + end + end + end +end diff --git a/lib/graphql/compatibility/lazy_execution_specification.rb b/lib/graphql/compatibility/lazy_execution_specification.rb new file mode 100644 index 0000000000..1a2408fa28 --- /dev/null +++ b/lib/graphql/compatibility/lazy_execution_specification.rb @@ -0,0 +1,54 @@ +require "graphql/compatibility/lazy_execution_specification/lazy_schema" + +module GraphQL + module Compatibility + module LazyExecutionSpecification + # @param execution_strategy [<#new, #execute>] An execution strategy class + # @return [Class] A test suite for this execution strategy + def self.build_suite(execution_strategy) + Class.new(Minitest::Test) do + class << self + attr_accessor :lazy_schema + end + + self.lazy_schema = LazySchema.build(execution_strategy) + + def test_it_resolves_lazy_values + pushes = [] + query_str = %| + { + p1: push(value: 1) { + value + } + p2: push(value: 2) { + push(value: 3) { + value + } + } + p3: push(value: 4) { + push(value: 5) { + value + } + } + } + | + res = self.class.lazy_schema.execute(query_str, context: {pushes: pushes}) + + expected_data = { + "p1"=>{"value"=>1}, + "p2"=>{"push"=>{"value"=>3}}, + "p3"=>{"push"=>{"value"=>5}}, + } + assert_equal expected_data, res["data"] + + expected_pushes = [ + [1,2,4], # first level + [3,5], # second level + ] + assert_equal expected_pushes, pushes + end + end + end + end + end +end diff --git a/lib/graphql/compatibility/lazy_execution_specification/lazy_schema.rb b/lib/graphql/compatibility/lazy_execution_specification/lazy_schema.rb new file mode 100644 index 0000000000..ceef78c50a --- /dev/null +++ b/lib/graphql/compatibility/lazy_execution_specification/lazy_schema.rb @@ -0,0 +1,54 @@ +module GraphQL + module Compatibility + module LazyExecutionSpecification + module LazySchema + class LazyPush + attr_reader :value + def initialize(ctx, value) + @value = value + @context = ctx + pushes = @context[:lazy_pushes] ||= [] + pushes << @value + end + + def push + if @context[:lazy_pushes].include?(@value) + @context[:pushes] << @context[:lazy_pushes] + @context[:lazy_pushes] = [] + end + self + end + end + + def self.build(execution_strategy) + lazy_push_type = GraphQL::ObjectType.define do + name "LazyPush" + field :value, types.Int + field :push, lazy_push_type do + argument :value, types.Int + resolve ->(o, a, c) { + LazyPush.new(c, a[:value]) + } + end + end + + query_type = GraphQL::ObjectType.define do + name "Query" + field :push, lazy_push_type do + argument :value, types.Int + resolve ->(o, a, c) { + LazyPush.new(c, a[:value]) + } + end + end + + GraphQL::Schema.define do + query(query_type) + query_execution_strategy(execution_strategy) + lazy_resolve(LazyPush, :push) + end + end + end + end + end +end diff --git a/lib/graphql/execution.rb b/lib/graphql/execution.rb index c409a72235..8a7157c2cf 100644 --- a/lib/graphql/execution.rb +++ b/lib/graphql/execution.rb @@ -1,2 +1,6 @@ require "graphql/execution/directive_checks" +require "graphql/execution/execute" +require "graphql/execution/field_result" +require "graphql/execution/lazy" +require "graphql/execution/selection_result" require "graphql/execution/typecast" diff --git a/lib/graphql/execution/execute.rb b/lib/graphql/execution/execute.rb new file mode 100644 index 0000000000..be6db118f5 --- /dev/null +++ b/lib/graphql/execution/execute.rb @@ -0,0 +1,219 @@ +module GraphQL + module Execution + # A valid execution strategy + class Execute + PROPAGATE_NULL = :__graphql_propagate_null__ + + def execute(ast_operation, root_type, query) + irep_root = query.internal_representation[ast_operation.name] + + result = resolve_selection( + query.root_value, + root_type, + [irep_root], + query.context, + mutation: query.mutation? + ) + + GraphQL::Execution::Lazy.resolve(result) + + result.to_h + end + + private + + def resolve_selection(object, current_type, irep_nodes, query_ctx, mutation: false ) + query = query_ctx.query + own_selections = query.selections(irep_nodes, current_type) + + selection_result = SelectionResult.new + + own_selections.each do |name, child_irep_nodes| + field = query.get_field(current_type, child_irep_nodes.first.definition_name) + field_result = resolve_field( + selection_result, + child_irep_nodes, + current_type, + field, + object, + query_ctx + ) + + if mutation + GraphQL::Execution::Lazy.resolve(field_result) + end + + selection_result.set(name, field_result) + end + + selection_result + end + + def resolve_field(owner, irep_nodes, parent_type, field, object, query_ctx) + irep_node = irep_nodes.first + query = query_ctx.query + field_ctx = query_ctx.spawn( + parent_type: parent_type, + field: field, + path: query_ctx.path + [irep_node.name], + irep_node: irep_node, + irep_nodes: irep_nodes, + ) + + arguments = query.arguments_for(irep_node, field) + middlewares = query.schema.middleware + resolve_arguments = [parent_type, object, field, arguments, field_ctx] + + raw_value = begin + # only run a middleware chain if there are any middleware + if middlewares.any? + chain = GraphQL::Schema::MiddlewareChain.new( + steps: middlewares + [FieldResolveStep], + arguments: resolve_arguments + ) + chain.call + else + FieldResolveStep.call(*resolve_arguments) + end + rescue GraphQL::ExecutionError => err + err + end + + lazy_method_name = query.lazy_method(raw_value) + result = if lazy_method_name + GraphQL::Execution::Lazy.new { raw_value.public_send(lazy_method_name) }.then { |inner_value| + continue_resolve_field(irep_nodes, parent_type, field, inner_value, field_ctx) + } + else + continue_resolve_field(irep_nodes, parent_type, field, raw_value, field_ctx) + end + + FieldResult.new( + owner: owner, + field: field, + value: result, + ) + end + + def continue_resolve_field(irep_nodes, parent_type, field, raw_value, field_ctx) + irep_node = irep_nodes.first + query = field_ctx.query + + case raw_value + when GraphQL::ExecutionError + raw_value.ast_node = irep_node.ast_node + raw_value.path = field_ctx.path + query.context.errors.push(raw_value) + when Array + list_errors = raw_value.each_with_index.select { |value, _| value.is_a?(GraphQL::ExecutionError) } + if list_errors.any? + list_errors.each do |error, index| + error.ast_node = irep_node.ast_node + error.path = field_ctx.path + [index] + query.context.errors.push(error) + end + end + end + + resolve_value( + parent_type, + field, + field.type, + raw_value, + irep_nodes, + field_ctx, + ) + end + + def resolve_value(parent_type, field_defn, field_type, value, irep_nodes, field_ctx) + if value.nil? + if field_type.kind.non_null? + field_ctx.add_error(GraphQL::ExecutionError.new("Cannot return null for non-nullable field #{parent_type.name}.#{field_defn.name}")) + PROPAGATE_NULL + else + nil + end + elsif value.is_a?(GraphQL::ExecutionError) + if field_type.kind.non_null? + PROPAGATE_NULL + else + nil + end + else + case field_type.kind + when GraphQL::TypeKinds::SCALAR + field_type.coerce_result(value) + when GraphQL::TypeKinds::ENUM + field_type.coerce_result(value, field_ctx.query.warden) + when GraphQL::TypeKinds::LIST + wrapped_type = field_type.of_type + result = value.each_with_index.map do |inner_value, index| + inner_ctx = field_ctx.spawn( + path: field_ctx.path + [index], + irep_node: field_ctx.irep_node, + irep_nodes: irep_nodes, + parent_type: parent_type, + field: field_defn, + ) + + inner_result = resolve_value( + parent_type, + field_defn, + wrapped_type, + inner_value, + irep_nodes, + inner_ctx, + ) + inner_result + end + result + when GraphQL::TypeKinds::NON_NULL + wrapped_type = field_type.of_type + resolve_value( + parent_type, + field_defn, + wrapped_type, + value, + irep_nodes, + field_ctx, + ) + when GraphQL::TypeKinds::OBJECT + resolve_selection( + value, + field_type, + irep_nodes, + field_ctx + ) + when GraphQL::TypeKinds::UNION, GraphQL::TypeKinds::INTERFACE + query = field_ctx.query + resolved_type = query.resolve_type(value) + possible_types = query.possible_types(field_type) + + if !possible_types.include?(resolved_type) + raise GraphQL::UnresolvedTypeError.new(irep_nodes.first.definition_name, field_type, parent_type, resolved_type, possible_types) + else + resolve_value( + parent_type, + field_defn, + resolved_type, + value, + irep_nodes, + field_ctx, + ) + end + else + raise("Unknown type kind: #{field_type.kind}") + end + end + end + + # A `.call`-able suitable to be the last step in a middleware chain + module FieldResolveStep + # Execute the field's resolve method + def self.call(_parent_type, parent_object, field_definition, field_args, context, _next = nil) + field_definition.resolve(parent_object, field_args, context) + end + end + end + end +end diff --git a/lib/graphql/execution/field_result.rb b/lib/graphql/execution/field_result.rb new file mode 100644 index 0000000000..c49ba9f414 --- /dev/null +++ b/lib/graphql/execution/field_result.rb @@ -0,0 +1,51 @@ +module GraphQL + module Execution + # This is one key-value pair in a GraphQL response. + class FieldResult + # @return [Any, Lazy] the GraphQL-ready response value, or a {Lazy} instance + attr_reader :value + + # @return [GraphQL::Field] The field which resolved this value + attr_reader :field + + # @return [SelectionResult] The result object that this field belongs to + attr_reader :owner + + def initialize(field:, value:, owner:) + @field = field + @owner = owner + self.value = value + end + + # Set a new value for this field in the response. + # It may be updated after resolving a {Lazy}. + # If it is {Execute::PROPAGATE_NULL}, tell the owner to propagate null. + # If the value is a {SelectionResult}, make a link with it, and if it's already null, + # propagate the null as needed. + # @param new_value [Any] The GraphQL-ready value + def value=(new_value) + if new_value.is_a?(SelectionResult) + if new_value.invalid_null? + new_value = GraphQL::Execution::Execute::PROPAGATE_NULL + else + new_value.owner = self + end + end + + if new_value == GraphQL::Execution::Execute::PROPAGATE_NULL + if field.type.kind.non_null? + @owner.propagate_null + else + @value = nil + end + else + @value = new_value + end + end + + def inspect + "#" + end + end + end +end diff --git a/lib/graphql/execution/lazy.rb b/lib/graphql/execution/lazy.rb new file mode 100644 index 0000000000..6b7323aee0 --- /dev/null +++ b/lib/graphql/execution/lazy.rb @@ -0,0 +1,47 @@ +require "graphql/execution/lazy/lazy_method_map" +require "graphql/execution/lazy/resolve" +module GraphQL + module Execution + # This wraps a value which is available, but not yet calculated, like a promise or future. + # + # Calling `#value` will trigger calculation & return the "lazy" value. + # + # This is an itty-bitty promise-like object, with key differences: + # - It has only two states, not-resolved and resolved + # - It has no error-catching functionality + class Lazy + # Traverse `val`, lazily resolving any values along the way + # @param val [Object] A data structure containing mixed plain values and `Lazy` instances + # @return void + def self.resolve(val) + Resolve.resolve(val) + end + + # Create a {Lazy} which will get its inner value by calling the block + # @param get_value_func [Proc] a block to get the inner value (later) + def initialize(&get_value_func) + @get_value_func = get_value_func + @resolved = false + end + + # @return [Object] The wrapped value, calling the lazy block if necessary + def value + if !@resolved + @resolved = true + @value = @get_value_func.call + end + @value + rescue GraphQL::ExecutionError => err + @resolved = true + @value = err + end + + # @return [Lazy] A {Lazy} whose value depends on another {Lazy}, plus any transformations in `block` + def then(&block) + self.class.new { + next_val = block.call(value) + } + end + end + end +end diff --git a/lib/graphql/execution/lazy/lazy_method_map.rb b/lib/graphql/execution/lazy/lazy_method_map.rb new file mode 100644 index 0000000000..2f51b68d09 --- /dev/null +++ b/lib/graphql/execution/lazy/lazy_method_map.rb @@ -0,0 +1,32 @@ +module GraphQL + module Execution + class Lazy + # {GraphQL::Schema} uses this to match returned values to lazy resolution methods. + # Methods may be registered for classes, they apply to its subclasses also. + # The result of this lookup is cached for future resolutions. + class LazyMethodMap + def initialize + @storage = {} + end + + # @param lazy_class [Class] A class which represents a lazy value (subclasses may also be used) + # @param lazy_value_method [Symbol] The method to call on this class to get its value + def set(lazy_class, lazy_value_method) + @storage[lazy_class] = lazy_value_method + end + + # @param value [Object] an object which may have a `lazy_value_method` registered for its class or superclasses + # @return [Symbol, nil] The `lazy_value_method` for this object, or nil + def get(value) + if @storage.key?(value.class) + @storage[value.class] + else + value_class = value.class + registered_superclass = @storage.each_key.find { |lazy_class| value_class < lazy_class } + @storage[value_class] = @storage[registered_superclass] + end + end + end + end + end +end diff --git a/lib/graphql/execution/lazy/resolve.rb b/lib/graphql/execution/lazy/resolve.rb new file mode 100644 index 0000000000..25a41a69a1 --- /dev/null +++ b/lib/graphql/execution/lazy/resolve.rb @@ -0,0 +1,67 @@ +module GraphQL + module Execution + class Lazy + # Helpers for dealing with data structures containing {Lazy} instances + module Resolve + # Mutate `value`, replacing {Lazy} instances in place with their resolved values + # @return [void] + def self.resolve(value) + lazies = resolve_in_place(value) + deep_sync(lazies) + end + + def self.resolve_in_place(value) + lazies = [] + + each_lazy(value) do |field_result| + inner_lazy = field_result.value.then do |inner_v| + field_result.value = inner_v + resolve_in_place(inner_v) + end + lazies.push(inner_lazy) + end + + Lazy.new { lazies.map(&:value) } + end + + # If `value` is a collection, call `block` + # with any {Lazy} instances in the collection + # @return [void] + def self.each_lazy(value, &block) + case value + when SelectionResult + value.each do |key, field_result| + each_lazy(field_result, &block) + end + when Array + value.each do |field_result| + each_lazy(field_result, &block) + end + when FieldResult + field_value = value.value + if field_value.is_a?(Lazy) + yield(value) + else + each_lazy(field_value, &block) + end + end + end + + # Traverse `val`, triggering resolution for each {Lazy}. + # These {Lazy}s are expected to mutate their owner data structures + # during resolution! (They're created with the `.then` calls in `resolve_in_place`). + # @return [void] + def self.deep_sync(val) + case val + when Lazy + deep_sync(val.value) + when Array + val.each { |v| deep_sync(v.value) } + when Hash + val.each { |k, v| deep_sync(v.value) } + end + end + end + end + end +end diff --git a/lib/graphql/execution/selection_result.rb b/lib/graphql/execution/selection_result.rb new file mode 100644 index 0000000000..d876479284 --- /dev/null +++ b/lib/graphql/execution/selection_result.rb @@ -0,0 +1,83 @@ +module GraphQL + module Execution + # A set of key-value pairs suitable for a GraphQL response. + class SelectionResult + def initialize + @storage = {} + @owner = nil + @invalid_null = false + end + + # @param key [String] The name for this value in the result + # @param field_result [FieldResult] The result for this field + def set(key, field_result) + @storage[key] = field_result + end + + # @param key [String] The name of an already-defined result + # @return [FieldResult] The result for this field + def fetch(key) + @storage.fetch(key) + end + + # Visit each key-result pair in this result + def each + @storage.each do |key, field_res| + yield(key, field_res) + end + end + + # @return [Hash] A plain Hash representation of this result + def to_h + if @invalid_null + nil + else + flatten(self) + end + end + + # A field has been unexpectedly nullified. + # Tell the owner {FieldResult} if it is present. + # Record {#invalid_null} in case an owner is added later. + def propagate_null + if @owner + @owner.value = GraphQL::Execution::Execute::PROPAGATE_NULL + end + @invalid_null = true + end + + # @return [Boolean] True if this selection has been nullified by a null child + def invalid_null? + @invalid_null + end + + # @param field_result [FieldResult] The field that this selection belongs to (used for propagating nulls) + def owner=(field_result) + if @owner + raise("Can't change owners of SelectionResult") + else + @owner = field_result + end + end + + private + + def flatten(obj) + case obj + when SelectionResult + flattened = {} + obj.each do |key, val| + flattened[key] = flatten(val) + end + flattened + when Array + obj.map { |v| flatten(v) } + when FieldResult + flatten(obj.value) + else + obj + end + end + end + end +end diff --git a/lib/graphql/query.rb b/lib/graphql/query.rb index 99a39d2041..8c91badc0c 100644 --- a/lib/graphql/query.rb +++ b/lib/graphql/query.rb @@ -82,12 +82,14 @@ def initialize(schema, query_string = nil, document: nil, context: nil, variable # Trying to execute a document # with no operations returns an empty hash @ast_variables = [] + @mutation = false if @operations.any? @selected_operation = find_operation(@operations, @operation_name) if @selected_operation.nil? @validation_errors << GraphQL::Query::OperationNameMissingError.new(@operations.keys) else @ast_variables = @selected_operation.variables + @mutation = @selected_operation.operation_type == "mutation" end end end @@ -192,6 +194,14 @@ def resolve_type(type) @schema.resolve_type(type, @context) end + def lazy_method(value) + @schema.lazy_methods.get(value) + end + + def mutation? + @mutation + end + private # Assert that the passed-in query string is internally consistent diff --git a/lib/graphql/query/context.rb b/lib/graphql/query/context.rb index d7ed9922f7..78bd968956 100644 --- a/lib/graphql/query/context.rb +++ b/lib/graphql/query/context.rb @@ -59,19 +59,29 @@ def []=(key, value) @values[key] = value end - def spawn(path:, irep_node:) - FieldResolutionContext.new(context: self, path: path, irep_node: irep_node) + def spawn(path:, irep_node:, parent_type:, field:, irep_nodes:) + FieldResolutionContext.new( + context: self, + path: path, + irep_node: irep_node, + parent_type: parent_type, + field: field, + irep_nodes: irep_nodes, + ) end class FieldResolutionContext extend Forwardable - attr_reader :path, :irep_node + attr_reader :path, :irep_node, :field, :parent_type, :irep_nodes - def initialize(context:, path:, irep_node:) + def initialize(context:, path:, irep_node:, field:, parent_type:, irep_nodes:) @context = context @path = path @irep_node = irep_node + @field = field + @parent_type = parent_type + @irep_nodes = irep_nodes end def_delegators :@context, :[], :[]=, :spawn, :query, :schema, :warden, :errors, :execution_strategy, :strategy @@ -85,7 +95,7 @@ def ast_node # @param error [GraphQL::ExecutionError] an execution error # @return [void] def add_error(error) - unless error.is_a?(ExecutionError) + if !error.is_a?(ExecutionError) raise TypeError, "expected error to be a ExecutionError, but was #{error.class}" end diff --git a/lib/graphql/query/serial_execution/field_resolution.rb b/lib/graphql/query/serial_execution/field_resolution.rb index e241816e27..2c0b12d628 100644 --- a/lib/graphql/query/serial_execution/field_resolution.rb +++ b/lib/graphql/query/serial_execution/field_resolution.rb @@ -9,9 +9,15 @@ def initialize(irep_nodes, parent_type, target, query_ctx) @irep_nodes = irep_nodes @parent_type = parent_type @target = target - @field_ctx = query_ctx.spawn(path: query_ctx.path + [irep_node.name], irep_node: irep_node) @query = query_ctx.query @field = @query.get_field(parent_type, irep_node.definition_name) + @field_ctx = query_ctx.spawn( + path: query_ctx.path + [irep_node.name], + irep_node: irep_node, + parent_type: parent_type, + field: field, + irep_nodes: irep_nodes + ) @arguments = @query.arguments_for(irep_node, @field) end diff --git a/lib/graphql/query/serial_execution/value_resolution.rb b/lib/graphql/query/serial_execution/value_resolution.rb index 8925a48e38..7e547fd1a8 100644 --- a/lib/graphql/query/serial_execution/value_resolution.rb +++ b/lib/graphql/query/serial_execution/value_resolution.rb @@ -18,7 +18,14 @@ def self.resolve(parent_type, field_defn, field_type, value, irep_nodes, query_c when GraphQL::TypeKinds::LIST wrapped_type = field_type.of_type result = value.each_with_index.map do |inner_value, index| - inner_ctx = query_ctx.spawn(path: query_ctx.path + [index], irep_node: query_ctx.irep_node) + inner_ctx = query_ctx.spawn( + path: query_ctx.path + [index], + irep_node: query_ctx.irep_node, + parent_type: wrapped_type, + field: field_defn, + irep_nodes: irep_nodes, + ) + inner_result = resolve( parent_type, field_defn, diff --git a/lib/graphql/schema.rb b/lib/graphql/schema.rb index f819238c51..92998b075c 100644 --- a/lib/graphql/schema.rb +++ b/lib/graphql/schema.rb @@ -57,6 +57,7 @@ class Schema instrument: -> (schema, type, instrumenter) { schema.instrumenters[type] << instrumenter }, query_analyzer: ->(schema, analyzer) { schema.query_analyzers << analyzer }, middleware: ->(schema, middleware) { schema.middleware << middleware }, + lazy_resolve: ->(schema, lazy_class, lazy_value_method) { schema.lazy_methods.set(lazy_class, lazy_value_method) }, rescue_from: ->(schema, err_class, &block) { schema.rescue_from(err_class, &block)} attr_accessor \ @@ -64,7 +65,13 @@ class Schema :query_execution_strategy, :mutation_execution_strategy, :subscription_execution_strategy, :max_depth, :max_complexity, :orphan_types, :directives, - :query_analyzers, :middleware, :instrumenters + :query_analyzers, :middleware, :instrumenters, :lazy_methods + + class << self + attr_accessor :default_execution_strategy + end + + self.default_execution_strategy = GraphQL::Execution::Execute 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] @@ -90,10 +97,11 @@ def initialize @object_from_id_proc = nil @id_from_object_proc = nil @instrumenters = Hash.new { |h, k| h[k] = [] } + @lazy_methods = GraphQL::Execution::Lazy::LazyMethodMap.new # Default to the built-in execution strategy: - @query_execution_strategy = GraphQL::Query::SerialExecution - @mutation_execution_strategy = GraphQL::Query::SerialExecution - @subscription_execution_strategy = GraphQL::Query::SerialExecution + @query_execution_strategy = self.class.default_execution_strategy + @mutation_execution_strategy = self.class.default_execution_strategy + @subscription_execution_strategy = self.class.default_execution_strategy end def rescue_from(*args, &block) diff --git a/spec/graphql/compatibility/lazy_execution_specification_spec.rb b/spec/graphql/compatibility/lazy_execution_specification_spec.rb new file mode 100644 index 0000000000..8b2c09fefd --- /dev/null +++ b/spec/graphql/compatibility/lazy_execution_specification_spec.rb @@ -0,0 +1,3 @@ +require "spec_helper" + +LazySpecSuite = GraphQL::Compatibility::LazyExecutionSpecification.build_suite(GraphQL::Execution::Execute) diff --git a/spec/graphql/execution/execute_spec.rb b/spec/graphql/execution/execute_spec.rb new file mode 100644 index 0000000000..4b33c9ccff --- /dev/null +++ b/spec/graphql/execution/execute_spec.rb @@ -0,0 +1,4 @@ +require "spec_helper" + +ExecuteSuite = GraphQL::Compatibility::ExecutionSpecification.build_suite(GraphQL::Execution::Execute) +LazyExecuteSuite = GraphQL::Compatibility::LazyExecutionSpecification.build_suite(GraphQL::Execution::Execute) diff --git a/spec/graphql/execution/lazy_spec.rb b/spec/graphql/execution/lazy_spec.rb new file mode 100644 index 0000000000..337d1b400f --- /dev/null +++ b/spec/graphql/execution/lazy_spec.rb @@ -0,0 +1,249 @@ +require "spec_helper" + +describe GraphQL::Execution::Lazy do + class Wrapper + def initialize(item = nil, &block) + if block + @block = block + else + @item = item + end + end + + def item + if @block + @item = @block.call() + @block = nil + end + @item + end + end + + class SumAll + attr_reader :own_value + attr_accessor :value + + def initialize(ctx, own_value) + @own_value = own_value + @all = ctx[:__sum_all__] ||= [] + @all << self + end + + def value + @value ||= begin + total_value = @all.map(&:own_value).reduce(&:+) + @all.each { |v| v.value = total_value} + @all.clear + total_value + end + @value + end + end + + LazySum = GraphQL::ObjectType.define do + name "LazySum" + field :value, types.Int do + resolve ->(o, a, c) { o == 13 ? nil : o } + end + field :nestedSum, !LazySum do + argument :value, !types.Int + resolve ->(o, args, c) { + if args[:value] == 13 + Wrapper.new(nil) + else + SumAll.new(c, o + args[:value]) + end + } + end + + field :nullableNestedSum, LazySum do + argument :value, types.Int + resolve ->(o, args, c) { + if args[:value] == 13 + Wrapper.new(nil) + else + SumAll.new(c, o + args[:value]) + end + } + end + end + + LazyQuery = GraphQL::ObjectType.define do + name "Query" + field :int, !types.Int do + argument :value, !types.Int + argument :plus, types.Int, default_value: 0 + resolve ->(o, a, c) { Wrapper.new(a[:value] + a[:plus])} + end + + field :nestedSum, !LazySum do + argument :value, !types.Int + resolve ->(o, args, c) { SumAll.new(c, args[:value]) } + end + + field :nullableNestedSum, LazySum do + argument :value, types.Int + resolve ->(o, args, c) { + if args[:value] == 13 + Wrapper.new { raise GraphQL::ExecutionError.new("13 is unlucky") } + else + SumAll.new(c, args[:value]) + end + } + end + + field :listSum, types[LazySum] do + argument :values, types[types.Int] + resolve ->(o, args, c) { args[:values] } + end + end + + LazySchema = GraphQL::Schema.define do + query(LazyQuery) + mutation(LazyQuery) + lazy_resolve(Wrapper, :item) + lazy_resolve(SumAll, :value) + end + + def run_query(query_str) + LazySchema.execute(query_str) + end + + describe "resolving" do + it "calls value handlers" do + res = run_query('{ int(value: 2, plus: 1)}') + assert_equal 3, res["data"]["int"] + end + + it "can do nested lazy values" do + res = run_query %| + { + a: nestedSum(value: 3) { + value + nestedSum(value: 7) { + value + } + } + b: nestedSum(value: 2) { + value + nestedSum(value: 11) { + value + } + } + + c: listSum(values: [1,2]) { + nestedSum(value: 3) { + value + } + } + } + | + + expected_data = { + "a"=>{"value"=>14, "nestedSum"=>{"value"=>46}}, + "b"=>{"value"=>14, "nestedSum"=>{"value"=>46}}, + "c"=>[{"nestedSum"=>{"value"=>14}}, {"nestedSum"=>{"value"=>14}}], + } + + assert_equal expected_data, res["data"] + end + + it "propagates nulls" do + res = run_query %| + { + nestedSum(value: 1) { + value + nestedSum(value: 13) { + value + } + } + }| + + assert_equal(nil, res["data"]) + assert_equal 1, res["errors"].length + + + res = run_query %| + { + nullableNestedSum(value: 1) { + value + nullableNestedSum(value: 2) { + nestedSum(value: 13) { + value + } + } + } + }| + + expected_data = { + "nullableNestedSum" => { + "value" => 1, + "nullableNestedSum" => nil, + } + } + assert_equal(expected_data, res["data"]) + assert_equal 1, res["errors"].length + end + + it "handles raised errors" do + res = run_query %| + { + a: nullableNestedSum(value: 1) { value } + b: nullableNestedSum(value: 13) { value } + c: nullableNestedSum(value: 2) { value } + }| + + expected_data = { + "a" => { "value" => 3 }, + "b" => nil, + "c" => { "value" => 3 }, + } + assert_equal expected_data, res["data"] + + expected_errors = [{ + "message"=>"13 is unlucky", + "locations"=>[{"line"=>4, "column"=>9}], + "path"=>["b"], + }] + assert_equal expected_errors, res["errors"] + end + + it "resolves mutation fields right away" do + res = run_query %| + { + a: nestedSum(value: 2) { value } + b: nestedSum(value: 4) { value } + c: nestedSum(value: 6) { value } + }| + + assert_equal [12, 12, 12], res["data"].values.map { |d| d["value"] } + + res = run_query %| + mutation { + a: nestedSum(value: 2) { value } + b: nestedSum(value: 4) { value } + c: nestedSum(value: 6) { value } + } + | + + assert_equal [2, 4, 6], res["data"].values.map { |d| d["value"] } + end + end + + describe "LazyMethodMap" do + class SubWrapper < Wrapper; end + + let(:map) { GraphQL::Execution::Lazy::LazyMethodMap.new } + + it "finds methods for classes and subclasses" do + map.set(Wrapper, :item) + map.set(SumAll, :value) + b = Wrapper.new(1) + sub_b = Wrapper.new(2) + s = SumAll.new({}, 3) + assert_equal(:item, map.get(b)) + assert_equal(:item, map.get(sub_b)) + assert_equal(:value, map.get(s)) + end + end +end diff --git a/spec/graphql/non_null_type_spec.rb b/spec/graphql/non_null_type_spec.rb index 6da5d1fa69..edd9c547aa 100644 --- a/spec/graphql/non_null_type_spec.rb +++ b/spec/graphql/non_null_type_spec.rb @@ -6,7 +6,11 @@ query_string = %|{ cow { name cantBeNullButIs } }| result = DummySchema.execute(query_string) assert_equal({"cow" => nil }, result["data"]) - assert_equal([{"message"=>"Cannot return null for non-nullable field Cow.cantBeNullButIs"}], result["errors"]) + assert_equal([{ + "message"=>"Cannot return null for non-nullable field Cow.cantBeNullButIs", + "locations"=>[{"line"=>1, "column"=>14}], + "path"=>["cow", "cantBeNullButIs"], + }], result["errors"]) end it "propagates the null up to the next nullable field" do @@ -25,7 +29,11 @@ | result = DummySchema.execute(query_string) assert_equal(nil, result["data"]) - assert_equal([{"message"=>"Cannot return null for non-nullable field DeepNonNull.nonNullInt"}], result["errors"]) + assert_equal([{ + "message"=>"Cannot return null for non-nullable field DeepNonNull.nonNullInt", + "locations"=>[{"line"=>8, "column"=>15}], + "path"=>["nn1", "nn2", "nn3", "nni3"], + }], result["errors"]) end end end diff --git a/spec/graphql/query/executor_spec.rb b/spec/graphql/query/executor_spec.rb index adf74ec452..a802ce705a 100644 --- a/spec/graphql/query/executor_spec.rb +++ b/spec/graphql/query/executor_spec.rb @@ -127,7 +127,9 @@ "data" => { "cow" => nil }, "errors" => [ { - "message" => "Cannot return null for non-nullable field Cow.cantBeNullButIs" + "message" => "Cannot return null for non-nullable field Cow.cantBeNullButIs", + "locations"=>[{"line"=>1, "column"=>28}], + "path"=>["cow", "cantBeNullButIs"], } ] }