From 23a467db3b725393e04e73c622fafbf2dcb6c19d Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Tue, 9 Oct 2018 15:38:29 -0400 Subject: [PATCH 1/4] Add a basic lookahead object --- lib/graphql/execution.rb | 1 + lib/graphql/execution/lookahead.rb | 262 +++++++++++++++++++++++ lib/graphql/query/context.rb | 5 + lib/graphql/schema/argument.rb | 8 + lib/graphql/schema/member/has_fields.rb | 9 + spec/graphql/execution/lookahead_spec.rb | 174 +++++++++++++++ 6 files changed, 459 insertions(+) create mode 100644 lib/graphql/execution/lookahead.rb create mode 100644 spec/graphql/execution/lookahead_spec.rb diff --git a/lib/graphql/execution.rb b/lib/graphql/execution.rb index 951cc6ae55..9f25d6f71b 100644 --- a/lib/graphql/execution.rb +++ b/lib/graphql/execution.rb @@ -4,5 +4,6 @@ require "graphql/execution/flatten" require "graphql/execution/instrumentation" require "graphql/execution/lazy" +require "graphql/execution/lookahead" require "graphql/execution/multiplex" require "graphql/execution/typecast" diff --git a/lib/graphql/execution/lookahead.rb b/lib/graphql/execution/lookahead.rb new file mode 100644 index 0000000000..702128d8ec --- /dev/null +++ b/lib/graphql/execution/lookahead.rb @@ -0,0 +1,262 @@ +# frozen_string_literal: true +module GraphQL + module Execution + # Lookahead creates a uniform interface to inspect the forthcoming selections. + # + # It assumes that the AST it's working with is valid. (So, it's safe to use + # during execution, but if you're using it directly, be sure to validate first.) + # + # A field may get access to its lookahead by adding `extras: [:lookahead]` + # to its configuration. + # + # @example looking ahead in a field + # field :articles, [Types::Article], null: false, + # extras: [:lookahead] + # + # # For example, imagine a faster database call + # # may be issued when only some fields are requested. + # # + # # Imagine that _full_ fetch must be made to satisfy `fullContent`, + # # we can look ahead to see if we need that field. If we do, + # # we make the expensive database call instead of the cheap one. + # def articles(lookahead:) + # if lookahead.selects?(:full_content) + # fetch_full_articles(object) + # else + # fetch_preview_articles(object) + # end + # end + class Lookahead + # @param query [GraphQL::Query] + # @param ast_node [GraphQL::Language::Nodes::Field] + # @param owner [Class] A type definition + def initialize(query:, ast_node:, owner:) + @ast_node = ast_node + @owner = owner + @query = query + end + + # True if this node has a selection on `field_name`. + # If `field_name` is a String, it is treated as a GraphQL-style (camelized) + # field name and used verbatim. If `field_name` is a Symbol, it is + # treated as a Ruby-style (underscored) name and camelized before comparing. + # + # If `arguments:` is provided, each provided key/value will be matched + # against the arguments in the next selection. This method will return false + # if any of the given `arguments:` are not present and matching in the next selection. + # (But, the next selection may contain _more_ than the given arguments.) + # @param field_name [String, Symbol] + # @param arguments [Hash] Arguments which must match in the selection + # @return [Boolean] + def selects?(field_name, arguments: nil) + selection(field_name, arguments: arguments).selected? + end + + # @return [Boolean] True if this lookahead represents a field that was requested + def selected? + true + end + + # Like {#selects?}, but can be used for chaining. + # It returns a null object (check with {#selected?}) + # @return [GraphQL::Execution::Lookahead] + def selection(field_name, arguments: nil) + field_name = normalize_name(field_name) + next_field = FieldHelpers.get_field(@query.schema, @owner, field_name) + if next_field + next_node = @ast_node.selections.find { |s| find_selected_node(s, field_name, next_field, arguments: arguments) } + if next_node + next_owner = next_field.type.unwrap + Lookahead.new(query: @query, ast_node: next_node, owner: next_owner) + else + NULL_LOOKAHEAD + end + else + NULL_LOOKAHEAD + end + end + + # This is returned for {Lookahead#selection} when a non-existent field is passed + class NullLookahead < Lookahead + # No inputs required here. + def initialize + end + + def selected? + false + end + + def selects?(*) + false + end + + def selection(*) + NULL_LOOKAHEAD + end + end + + # A singleton, so that misses don't come with overhead. + NULL_LOOKAHEAD = NullLookahead.new + + private + + # If it's a symbol, stringify and camelize it + def normalize_name(name) + if name.is_a?(Symbol) + Schema::Member::BuildType.camelize(name.to_s) + else + name + end + end + + def normalize_keyword(keyword) + if keyword.is_a?(String) + Schema::Member::BuildType.underscore(keyword).to_sym + else + keyword + end + end + + # If a selection on `node` matches `field_name` (which is backed by `field_defn`) + # and matches the `arguments:` constraints, then return that node. + # @return [GraphQL::Language::Nodes::Field, nil] + def find_selected_node(node, field_name, field_defn, arguments:) + case node + when GraphQL::Language::Nodes::Field + if node.name == field_name + if arguments.nil? || arguments.none? + # No constraint applied + node + else + query_kwargs = ArgumentHelpers.arguments(@query, nil, field_defn, node) + passes_args = arguments.all? do |arg_name, arg_value| + arg_name = normalize_keyword(arg_name) + # Make sure the constraint is present with a matching value + query_kwargs.key?(arg_name) && query_kwargs[arg_name] == arg_value + end + if passes_args + node + end + end + else + nil + end + when GraphQL::Language::Nodes::InlineFragment + node.selections.find { |s| find_selected_node(s, field_name, field_defn, arguments: arguments) } + when GraphQL::Language::Nodes::FragmentSpread + frag_defn = query.document.fragments[node.name] + frag_defn.selections.find { |s| find_selected_node(s, field_name, field_defn, arguments: arguments) } + else + raise "Unexpected selection comparison on #{node.class.name} (#{node})" + end + end + + # TODO Dedup with interpreter + module ArgumentHelpers + module_function + + def arguments(query, graphql_object, arg_owner, ast_node) + kwarg_arguments = {} + arg_defns = arg_owner.arguments + ast_node.arguments.each do |arg| + arg_defn = arg_defns[arg.name] || raise("Invariant: missing argument definition for #{arg.name.inspect} in #{arg_defns.keys} from #{arg_owner}") + # Need to distinguish between client-provided `nil` + # and nothing-at-all + is_present, value = arg_to_value(query, graphql_object, arg_defn.type, arg.value) + if is_present + # This doesn't apply to directives, which are legacy + # Can remove this when Skip and Include use classes or something. + if graphql_object + value = arg_defn.prepare_value(graphql_object, value) + end + kwarg_arguments[arg_defn.keyword] = value + end + end + arg_defns.each do |name, arg_defn| + if arg_defn.default_value? && !kwarg_arguments.key?(arg_defn.keyword) + kwarg_arguments[arg_defn.keyword] = arg_defn.default_value + end + end + kwarg_arguments + end + + # Get a Ruby-ready value from a client query. + # @param graphql_object [Object] The owner of the field whose argument this is + # @param arg_type [Class, GraphQL::Schema::NonNull, GraphQL::Schema::List] + # @param ast_value [GraphQL::Language::Nodes::VariableIdentifier, String, Integer, Float, Boolean] + # @return [Array(is_present, value)] + def arg_to_value(query, graphql_object, arg_type, ast_value) + if ast_value.is_a?(GraphQL::Language::Nodes::VariableIdentifier) + # If it's not here, it will get added later + if query.variables.key?(ast_value.name) + return true, query.variables[ast_value.name] + else + return false, nil + end + elsif ast_value.is_a?(GraphQL::Language::Nodes::NullValue) + return true, nil + elsif arg_type.is_a?(GraphQL::Schema::NonNull) + arg_to_value(query, graphql_object, arg_type.of_type, ast_value) + elsif arg_type.is_a?(GraphQL::Schema::List) + # Treat a single value like a list + arg_value = Array(ast_value) + list = [] + arg_value.map do |inner_v| + _present, value = arg_to_value(query, graphql_object, arg_type.of_type, inner_v) + list << value + end + return true, list + elsif arg_type.is_a?(Class) && arg_type < GraphQL::Schema::InputObject + # For these, `prepare` is applied during `#initialize`. + # Pass `nil` so it will be skipped in `#arguments`. + # What a mess. + args = arguments(query, nil, arg_type, ast_value) + # We're not tracking defaults_used, but for our purposes + # we compare the value to the default value. + return true, arg_type.new(ruby_kwargs: args, context: query.context, defaults_used: nil) + else + flat_value = flatten_ast_value(query, ast_value) + return true, arg_type.coerce_input(flat_value, query.context) + end + end + + def flatten_ast_value(query, v) + case v + when GraphQL::Language::Nodes::Enum + v.name + when GraphQL::Language::Nodes::InputObject + h = {} + v.arguments.each do |arg| + h[arg.name] = flatten_ast_value(query, arg.value) + end + h + when Array + v.map { |v2| flatten_ast_value(query, v2) } + when GraphQL::Language::Nodes::VariableIdentifier + flatten_ast_value(query.variables[v.name]) + else + v + end + end + end + + # TODO dedup with interpreter + module FieldHelpers + module_function + + def get_field(schema, owner_type, field_name ) + field_defn = owner_type.get_field(field_name) + field_defn ||= if owner_type == schema.query.metadata[:type_class] && (entry_point_field = schema.introspection_system.entry_point(name: field_name)) + entry_point_field.metadata[:type_class] + elsif (dynamic_field = schema.introspection_system.dynamic_field(name: field_name)) + dynamic_field.metadata[:type_class] + else + raise "Invariant: no field for #{owner_type}.#{field_name}" + end + + field_defn + end + end + end + end +end diff --git a/lib/graphql/query/context.rb b/lib/graphql/query/context.rb index 4cb79bc1e6..26af2d6ca5 100644 --- a/lib/graphql/query/context.rb +++ b/lib/graphql/query/context.rb @@ -74,6 +74,11 @@ def backtrace def execution_errors @execution_errors ||= ExecutionErrors.new(self) end + + def lookahead + owner_type = irep_node.owner_type.metadata[:type_class] || raise("Lookahead is only compatible with class-based schemas") + Execution::Lookahead.new(query: query, ast_node: ast_node, owner: owner_type) + end end class ExecutionErrors diff --git a/lib/graphql/schema/argument.rb b/lib/graphql/schema/argument.rb index 80151751ac..45a11442d8 100644 --- a/lib/graphql/schema/argument.rb +++ b/lib/graphql/schema/argument.rb @@ -51,6 +51,14 @@ def initialize(arg_name = nil, type_expr = nil, desc = nil, required:, type: nil end end + # @return [Object] the value used when the client doesn't provide a value for this argument + attr_reader :default_value + + # @return [Boolean] True if this argument has a default value + def default_value? + @default_value != NO_DEFAULT + end + attr_writer :description # @return [String] Documentation for this argument diff --git a/lib/graphql/schema/member/has_fields.rb b/lib/graphql/schema/member/has_fields.rb index 96a0b08f8a..0f38f089cc 100644 --- a/lib/graphql/schema/member/has_fields.rb +++ b/lib/graphql/schema/member/has_fields.rb @@ -66,6 +66,15 @@ def fields all_fields end + def get_field(field_name) + for ancestor in ancestors + if ancestor.respond_to?(:own_fields) && f = ancestor.own_fields[field_name] + return f + end + end + nil + end + # Register this field with the class, overriding a previous one if needed. # Also, add a parent method for resolving this field. # @param field_defn [GraphQL::Schema::Field] diff --git a/spec/graphql/execution/lookahead_spec.rb b/spec/graphql/execution/lookahead_spec.rb new file mode 100644 index 0000000000..9c3a4f14b6 --- /dev/null +++ b/spec/graphql/execution/lookahead_spec.rb @@ -0,0 +1,174 @@ +# frozen_string_literal: true +require "spec_helper" + +describe GraphQL::Execution::Lookahead do + module LookaheadTest + DATA = [ + OpenStruct.new(name: "Cardinal", is_waterfowl: false, similar_species_names: ["Scarlet Tanager"]), + OpenStruct.new(name: "Scarlet Tanager", is_waterfowl: false, similar_species_names: ["Cardinal"]), + OpenStruct.new(name: "Great Egret", is_waterfowl: false, similar_species_names: ["Great Blue Heron"]), + OpenStruct.new(name: "Great Blue Heron", is_waterfowl: true, similar_species_names: ["Great Egret"]), + ] + + def DATA.find_by_name(name) + DATA.find { |b| b.name == name } + end + + class BirdSpecies < GraphQL::Schema::Object + field :name, String, null: false + field :is_waterfowl, Boolean, null: false + field :similar_species, [BirdSpecies], null: false, + extras: [:lookahead] + + def similar_species(lookahead:) + if lookahead.selects?(:__typename) + context[:lookahead_typename] += 1 + end + + object.similar_species_names.map { |n| DATA.find_by_name(n) } + end + end + + class Query < GraphQL::Schema::Object + field :find_bird_species, BirdSpecies, null: true do + argument :by_name, String, required: true + end + + def find_bird_species(by_name:) + DATA.find_by_name(by_name) + end + end + + class Schema < GraphQL::Schema + query(Query) + end + # Cause everything to be loaded + # TODO remove this + Schema.graphql_definition + end + + describe "looking ahead" do + let(:document) { + GraphQL.parse <<-GRAPHQL + query($name: String!){ + findBirdSpecies(byName: $name) { + name + similarSpecies { + likesWater: isWaterfowl + } + } + t: __typename + } + GRAPHQL + } + let(:query) { + GraphQL::Query.new(LookaheadTest::Schema, document: document, variables: { name: "Cardinal" }) + } + + it "has a good test setup" do + res = query.result + assert_equal [false], res["data"]["findBirdSpecies"]["similarSpecies"].map { |s| s["likesWater"] } + end + + it "can detect fields on objects with symbol or string" do + ast_node = document.definitions.first.selections.first + owner = LookaheadTest::BirdSpecies + lookahead = GraphQL::Execution::Lookahead.new(query: query, ast_node: ast_node, owner: owner) + assert_equal true, lookahead.selects?("similarSpecies") + assert_equal true, lookahead.selects?(:similar_species) + assert_equal false, lookahead.selects?("isWaterfowl") + assert_equal false, lookahead.selects?(:is_waterfowl) + end + + it "detects by name, not by alias" do + ast_node = document.definitions.first + owner = LookaheadTest::Query + lookahead = GraphQL::Execution::Lookahead.new(query: query, ast_node: ast_node, owner: owner) + assert_equal true, lookahead.selects?("__typename") + end + + describe "constraints by arguments" do + let(:lookahead) do + ast_node = document.definitions.first + owner = LookaheadTest::Query + GraphQL::Execution::Lookahead.new(query: query, ast_node: ast_node, owner: owner) + end + + it "is true without constraints" do + assert_equal true, lookahead.selects?("findBirdSpecies") + end + + it "is true when all given constraints are satisfied" do + assert_equal true, lookahead.selects?(:find_bird_species, arguments: { by_name: "Cardinal" }) + assert_equal true, lookahead.selects?("findBirdSpecies", arguments: { "byName" => "Cardinal" }) + end + + it "is true when no constraints are given" do + assert_equal true, lookahead.selects?(:find_bird_species, arguments: {}) + assert_equal true, lookahead.selects?("__typename", arguments: {}) + end + + it "is false when some given constraints aren't satisfied" do + assert_equal false, lookahead.selects?(:find_bird_species, arguments: { by_name: "Chickadee" }) + assert_equal false, lookahead.selects?(:find_bird_species, arguments: { by_name: "Cardinal", other: "Nonsense" }) + end + + describe "with literal values" do + let(:document) { + GraphQL.parse <<-GRAPHQL + { + findBirdSpecies(byName: "Great Blue Heron") { + isWaterfowl + } + } + GRAPHQL + } + + it "works" do + assert_equal true, lookahead.selects?(:find_bird_species, arguments: { by_name: "Great Blue Heron" }) + end + end + end + + it "can do a chained lookahead" do + ast_node = document.definitions.first + owner = LookaheadTest::Query + lookahead = GraphQL::Execution::Lookahead.new(query: query, ast_node: ast_node, owner: owner) + next_lookahead = lookahead.selection(:find_bird_species, arguments: { by_name: "Cardinal" }) + assert_equal true, next_lookahead.selected? + nested_selection = next_lookahead.selection(:similar_species).selection(:is_waterfowl, arguments: {}) + assert_equal true, nested_selection.selected? + assert_equal false, next_lookahead.selection(:similar_species).selection(:name).selected? + end + + it "can detect fields on lists with symbol or string" do + ast_node = document.definitions.first + owner = LookaheadTest::Query + lookahead = GraphQL::Execution::Lookahead.new(query: query, ast_node: ast_node, owner: owner) + assert_equal true, lookahead.selection(:find_bird_species).selection(:similar_species).selection(:is_waterfowl).selected? + assert_equal true, lookahead.selection("findBirdSpecies").selection("similarSpecies").selection("isWaterfowl").selected? + end + end + + describe "in queries" do + it "can be an extra" do + query_str = <<-GRAPHQL + { + cardinal: findBirdSpecies(byName: "Cardinal") { + similarSpecies { __typename } + } + scarletTanager: findBirdSpecies(byName: "ScarletTanager") { + similarSpecies { name } + } + greatBlueHeron: findBirdSpecies(byName: "Great Blue Heron") { + similarSpecies { __typename } + } + } + GRAPHQL + context = {lookahead_typename: 0} + res = LookaheadTest::Schema.execute(query_str, context: context) + refute res.key?("errors") + assert_equal 2, context[:lookahead_typename] + end + end +end From b0e0fa6a528d0f3b065a6d16a7f20c2badaaa2b1 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Wed, 10 Oct 2018 13:59:37 -0400 Subject: [PATCH 2/4] Fix Lookahead with merging branches --- lib/graphql/execution/lookahead.rb | 36 +++++++++-------- lib/graphql/query/context.rb | 3 +- spec/graphql/execution/lookahead_spec.rb | 50 +++++++++++++++++++++--- 3 files changed, 67 insertions(+), 22 deletions(-) diff --git a/lib/graphql/execution/lookahead.rb b/lib/graphql/execution/lookahead.rb index 702128d8ec..8e2aef6745 100644 --- a/lib/graphql/execution/lookahead.rb +++ b/lib/graphql/execution/lookahead.rb @@ -28,10 +28,10 @@ module Execution # end class Lookahead # @param query [GraphQL::Query] - # @param ast_node [GraphQL::Language::Nodes::Field] + # @param ast_nodes [Array] # @param owner [Class] A type definition - def initialize(query:, ast_node:, owner:) - @ast_node = ast_node + def initialize(query:, ast_nodes:, owner:) + @ast_nodes = ast_nodes @owner = owner @query = query end @@ -64,10 +64,17 @@ def selection(field_name, arguments: nil) field_name = normalize_name(field_name) next_field = FieldHelpers.get_field(@query.schema, @owner, field_name) if next_field - next_node = @ast_node.selections.find { |s| find_selected_node(s, field_name, next_field, arguments: arguments) } - if next_node + + next_nodes = [] + @ast_nodes.each do |ast_node| + ast_node.selections.each do |selection| + find_selected_nodes(selection, field_name, next_field, arguments: arguments, matches: next_nodes) + end + end + + if next_nodes.any? next_owner = next_field.type.unwrap - Lookahead.new(query: @query, ast_node: next_node, owner: next_owner) + Lookahead.new(query: @query, ast_nodes: next_nodes, owner: next_owner) else NULL_LOOKAHEAD end @@ -118,15 +125,14 @@ def normalize_keyword(keyword) end # If a selection on `node` matches `field_name` (which is backed by `field_defn`) - # and matches the `arguments:` constraints, then return that node. - # @return [GraphQL::Language::Nodes::Field, nil] - def find_selected_node(node, field_name, field_defn, arguments:) + # and matches the `arguments:` constraints, then add that node to `matches` + def find_selected_nodes(node, field_name, field_defn, arguments:, matches:) case node when GraphQL::Language::Nodes::Field if node.name == field_name if arguments.nil? || arguments.none? # No constraint applied - node + matches << node else query_kwargs = ArgumentHelpers.arguments(@query, nil, field_defn, node) passes_args = arguments.all? do |arg_name, arg_value| @@ -135,17 +141,15 @@ def find_selected_node(node, field_name, field_defn, arguments:) query_kwargs.key?(arg_name) && query_kwargs[arg_name] == arg_value end if passes_args - node + matches << node end end - else - nil end when GraphQL::Language::Nodes::InlineFragment - node.selections.find { |s| find_selected_node(s, field_name, field_defn, arguments: arguments) } + node.selections.find { |s| find_selected_nodes(s, field_name, field_defn, arguments: arguments, matches: matches) } when GraphQL::Language::Nodes::FragmentSpread - frag_defn = query.document.fragments[node.name] - frag_defn.selections.find { |s| find_selected_node(s, field_name, field_defn, arguments: arguments) } + frag_defn = @query.fragments[node.name] + frag_defn.selections.find { |s| find_selected_nodes(s, field_name, field_defn, arguments: arguments, matches: matches) } else raise "Unexpected selection comparison on #{node.class.name} (#{node})" end diff --git a/lib/graphql/query/context.rb b/lib/graphql/query/context.rb index 26af2d6ca5..b6a36e5de8 100644 --- a/lib/graphql/query/context.rb +++ b/lib/graphql/query/context.rb @@ -77,7 +77,8 @@ def execution_errors def lookahead owner_type = irep_node.owner_type.metadata[:type_class] || raise("Lookahead is only compatible with class-based schemas") - Execution::Lookahead.new(query: query, ast_node: ast_node, owner: owner_type) + ast_nodes = irep_node.ast_nodes + Execution::Lookahead.new(query: query, ast_nodes: ast_nodes, owner: owner_type) end end diff --git a/spec/graphql/execution/lookahead_spec.rb b/spec/graphql/execution/lookahead_spec.rb index 9c3a4f14b6..12fc306ca2 100644 --- a/spec/graphql/execution/lookahead_spec.rb +++ b/spec/graphql/execution/lookahead_spec.rb @@ -73,7 +73,7 @@ class Schema < GraphQL::Schema it "can detect fields on objects with symbol or string" do ast_node = document.definitions.first.selections.first owner = LookaheadTest::BirdSpecies - lookahead = GraphQL::Execution::Lookahead.new(query: query, ast_node: ast_node, owner: owner) + lookahead = GraphQL::Execution::Lookahead.new(query: query, ast_nodes: [ast_node], owner: owner) assert_equal true, lookahead.selects?("similarSpecies") assert_equal true, lookahead.selects?(:similar_species) assert_equal false, lookahead.selects?("isWaterfowl") @@ -83,7 +83,7 @@ class Schema < GraphQL::Schema it "detects by name, not by alias" do ast_node = document.definitions.first owner = LookaheadTest::Query - lookahead = GraphQL::Execution::Lookahead.new(query: query, ast_node: ast_node, owner: owner) + lookahead = GraphQL::Execution::Lookahead.new(query: query, ast_nodes: [ast_node], owner: owner) assert_equal true, lookahead.selects?("__typename") end @@ -91,7 +91,7 @@ class Schema < GraphQL::Schema let(:lookahead) do ast_node = document.definitions.first owner = LookaheadTest::Query - GraphQL::Execution::Lookahead.new(query: query, ast_node: ast_node, owner: owner) + GraphQL::Execution::Lookahead.new(query: query, ast_nodes: [ast_node], owner: owner) end it "is true without constraints" do @@ -133,7 +133,7 @@ class Schema < GraphQL::Schema it "can do a chained lookahead" do ast_node = document.definitions.first owner = LookaheadTest::Query - lookahead = GraphQL::Execution::Lookahead.new(query: query, ast_node: ast_node, owner: owner) + lookahead = GraphQL::Execution::Lookahead.new(query: query, ast_nodes: [ast_node], owner: owner) next_lookahead = lookahead.selection(:find_bird_species, arguments: { by_name: "Cardinal" }) assert_equal true, next_lookahead.selected? nested_selection = next_lookahead.selection(:similar_species).selection(:is_waterfowl, arguments: {}) @@ -144,10 +144,50 @@ class Schema < GraphQL::Schema it "can detect fields on lists with symbol or string" do ast_node = document.definitions.first owner = LookaheadTest::Query - lookahead = GraphQL::Execution::Lookahead.new(query: query, ast_node: ast_node, owner: owner) + lookahead = GraphQL::Execution::Lookahead.new(query: query, ast_nodes: [ast_node], owner: owner) assert_equal true, lookahead.selection(:find_bird_species).selection(:similar_species).selection(:is_waterfowl).selected? assert_equal true, lookahead.selection("findBirdSpecies").selection("similarSpecies").selection("isWaterfowl").selected? end + + describe "merging branches and fragments" do + let(:document) { + GraphQL.parse <<-GRAPHQL + { + findBirdSpecies(name: "Cardinal") { + similarSpecies { + __typename + } + } + ...F + ... { + findBirdSpecies(name: "Cardinal") { + similarSpecies { + isWaterfowl + } + } + } + } + + fragment F on Query { + findBirdSpecies(name: "Cardinal") { + similarSpecies { + name + } + } + } + GRAPHQL + } + + it "finds selections using merging" do + ast_node = document.definitions.first + owner = LookaheadTest::Query + lookahead = GraphQL::Execution::Lookahead.new(query: query, ast_nodes: [ast_node], owner: owner) + merged_lookahead = lookahead.selection(:find_bird_species).selection(:similar_species) + assert merged_lookahead.selects?(:__typename) + assert merged_lookahead.selects?(:is_waterfowl) + assert merged_lookahead.selects?(:name) + end + end end describe "in queries" do From c93f366632f0c0fd83d740d35229427f199bf29c Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Thu, 11 Oct 2018 09:42:34 -0400 Subject: [PATCH 3/4] Fix nested lookaheads --- lib/graphql/execution/lookahead.rb | 27 +++++++---- lib/graphql/query/context.rb | 4 +- spec/graphql/execution/lookahead_spec.rb | 60 +++++++++++++----------- 3 files changed, 51 insertions(+), 40 deletions(-) diff --git a/lib/graphql/execution/lookahead.rb b/lib/graphql/execution/lookahead.rb index 8e2aef6745..4222f5f150 100644 --- a/lib/graphql/execution/lookahead.rb +++ b/lib/graphql/execution/lookahead.rb @@ -28,11 +28,13 @@ module Execution # end class Lookahead # @param query [GraphQL::Query] - # @param ast_nodes [Array] - # @param owner [Class] A type definition - def initialize(query:, ast_nodes:, owner:) + # @param ast_nodes [Array, Array] + # @param field [GraphQL::Schema::Field] if `ast_nodes` are fields, this is the field definition matching those nodes + # @param root_type [Class] if `ast_nodes` are operation definition, this is the root type for that operation + def initialize(query:, ast_nodes:, field: nil, root_type: nil) @ast_nodes = ast_nodes - @owner = owner + @field = field + @root_type = root_type @query = query end @@ -61,20 +63,25 @@ def selected? # It returns a null object (check with {#selected?}) # @return [GraphQL::Execution::Lookahead] def selection(field_name, arguments: nil) - field_name = normalize_name(field_name) - next_field = FieldHelpers.get_field(@query.schema, @owner, field_name) - if next_field + next_field_name = normalize_name(field_name) + next_field_owner = if @field + @field.type.unwrap + else + @root_type + end + + next_field_defn = FieldHelpers.get_field(@query.schema, next_field_owner, next_field_name) + if next_field_defn next_nodes = [] @ast_nodes.each do |ast_node| ast_node.selections.each do |selection| - find_selected_nodes(selection, field_name, next_field, arguments: arguments, matches: next_nodes) + find_selected_nodes(selection, next_field_name, next_field_defn, arguments: arguments, matches: next_nodes) end end if next_nodes.any? - next_owner = next_field.type.unwrap - Lookahead.new(query: @query, ast_nodes: next_nodes, owner: next_owner) + Lookahead.new(query: @query, ast_nodes: next_nodes, field: next_field_defn) else NULL_LOOKAHEAD end diff --git a/lib/graphql/query/context.rb b/lib/graphql/query/context.rb index b6a36e5de8..40c7896711 100644 --- a/lib/graphql/query/context.rb +++ b/lib/graphql/query/context.rb @@ -76,9 +76,9 @@ def execution_errors end def lookahead - owner_type = irep_node.owner_type.metadata[:type_class] || raise("Lookahead is only compatible with class-based schemas") ast_nodes = irep_node.ast_nodes - Execution::Lookahead.new(query: query, ast_nodes: ast_nodes, owner: owner_type) + field = irep_node.definition.metadata[:type_class] || raise("Lookahead is only compatible with class-based schemas") + Execution::Lookahead.new(query: query, ast_nodes: ast_nodes, field: field) end end diff --git a/spec/graphql/execution/lookahead_spec.rb b/spec/graphql/execution/lookahead_spec.rb index 12fc306ca2..c5e248d4ee 100644 --- a/spec/graphql/execution/lookahead_spec.rb +++ b/spec/graphql/execution/lookahead_spec.rb @@ -4,28 +4,37 @@ describe GraphQL::Execution::Lookahead do module LookaheadTest DATA = [ - OpenStruct.new(name: "Cardinal", is_waterfowl: false, similar_species_names: ["Scarlet Tanager"]), - OpenStruct.new(name: "Scarlet Tanager", is_waterfowl: false, similar_species_names: ["Cardinal"]), - OpenStruct.new(name: "Great Egret", is_waterfowl: false, similar_species_names: ["Great Blue Heron"]), - OpenStruct.new(name: "Great Blue Heron", is_waterfowl: true, similar_species_names: ["Great Egret"]), + OpenStruct.new(name: "Cardinal", is_waterfowl: false, similar_species_names: ["Scarlet Tanager"], genus: OpenStruct.new(latin_name: "Piranga")), + OpenStruct.new(name: "Scarlet Tanager", is_waterfowl: false, similar_species_names: ["Cardinal"], genus: OpenStruct.new(latin_name: "Cardinalis")), + OpenStruct.new(name: "Great Egret", is_waterfowl: false, similar_species_names: ["Great Blue Heron"], genus: OpenStruct.new(latin_name: "Ardea")), + OpenStruct.new(name: "Great Blue Heron", is_waterfowl: true, similar_species_names: ["Great Egret"], genus: OpenStruct.new(latin_name: "Ardea")), ] def DATA.find_by_name(name) DATA.find { |b| b.name == name } end + class BirdGenus < GraphQL::Schema::Object + field :latin_name, String, null: false + end + class BirdSpecies < GraphQL::Schema::Object field :name, String, null: false field :is_waterfowl, Boolean, null: false - field :similar_species, [BirdSpecies], null: false, + field :similar_species, [BirdSpecies], null: false + + def similar_species + object.similar_species_names.map { |n| DATA.find_by_name(n) } + end + + field :genus, BirdGenus, null: false, extras: [:lookahead] - def similar_species(lookahead:) - if lookahead.selects?(:__typename) - context[:lookahead_typename] += 1 + def genus(lookahead:) + if lookahead.selects?(:latin_name) + context[:lookahead_latin_name] += 1 end - - object.similar_species_names.map { |n| DATA.find_by_name(n) } + object.genus end end @@ -72,8 +81,8 @@ class Schema < GraphQL::Schema it "can detect fields on objects with symbol or string" do ast_node = document.definitions.first.selections.first - owner = LookaheadTest::BirdSpecies - lookahead = GraphQL::Execution::Lookahead.new(query: query, ast_nodes: [ast_node], owner: owner) + field = LookaheadTest::Query.fields["findBirdSpecies"] + lookahead = GraphQL::Execution::Lookahead.new(query: query, ast_nodes: [ast_node], field: field) assert_equal true, lookahead.selects?("similarSpecies") assert_equal true, lookahead.selects?(:similar_species) assert_equal false, lookahead.selects?("isWaterfowl") @@ -82,16 +91,14 @@ class Schema < GraphQL::Schema it "detects by name, not by alias" do ast_node = document.definitions.first - owner = LookaheadTest::Query - lookahead = GraphQL::Execution::Lookahead.new(query: query, ast_nodes: [ast_node], owner: owner) + lookahead = GraphQL::Execution::Lookahead.new(query: query, ast_nodes: [ast_node], root_type: LookaheadTest::Query) assert_equal true, lookahead.selects?("__typename") end describe "constraints by arguments" do let(:lookahead) do ast_node = document.definitions.first - owner = LookaheadTest::Query - GraphQL::Execution::Lookahead.new(query: query, ast_nodes: [ast_node], owner: owner) + GraphQL::Execution::Lookahead.new(query: query, ast_nodes: [ast_node], root_type: LookaheadTest::Query) end it "is true without constraints" do @@ -132,8 +139,7 @@ class Schema < GraphQL::Schema it "can do a chained lookahead" do ast_node = document.definitions.first - owner = LookaheadTest::Query - lookahead = GraphQL::Execution::Lookahead.new(query: query, ast_nodes: [ast_node], owner: owner) + lookahead = GraphQL::Execution::Lookahead.new(query: query, ast_nodes: [ast_node], root_type: LookaheadTest::Query) next_lookahead = lookahead.selection(:find_bird_species, arguments: { by_name: "Cardinal" }) assert_equal true, next_lookahead.selected? nested_selection = next_lookahead.selection(:similar_species).selection(:is_waterfowl, arguments: {}) @@ -143,8 +149,7 @@ class Schema < GraphQL::Schema it "can detect fields on lists with symbol or string" do ast_node = document.definitions.first - owner = LookaheadTest::Query - lookahead = GraphQL::Execution::Lookahead.new(query: query, ast_nodes: [ast_node], owner: owner) + lookahead = GraphQL::Execution::Lookahead.new(query: query, ast_nodes: [ast_node], root_type: LookaheadTest::Query) assert_equal true, lookahead.selection(:find_bird_species).selection(:similar_species).selection(:is_waterfowl).selected? assert_equal true, lookahead.selection("findBirdSpecies").selection("similarSpecies").selection("isWaterfowl").selected? end @@ -180,8 +185,7 @@ class Schema < GraphQL::Schema it "finds selections using merging" do ast_node = document.definitions.first - owner = LookaheadTest::Query - lookahead = GraphQL::Execution::Lookahead.new(query: query, ast_nodes: [ast_node], owner: owner) + lookahead = GraphQL::Execution::Lookahead.new(query: query, ast_nodes: [ast_node], root_type: LookaheadTest::Query) merged_lookahead = lookahead.selection(:find_bird_species).selection(:similar_species) assert merged_lookahead.selects?(:__typename) assert merged_lookahead.selects?(:is_waterfowl) @@ -195,20 +199,20 @@ class Schema < GraphQL::Schema query_str = <<-GRAPHQL { cardinal: findBirdSpecies(byName: "Cardinal") { - similarSpecies { __typename } + genus { __typename } } - scarletTanager: findBirdSpecies(byName: "ScarletTanager") { - similarSpecies { name } + scarletTanager: findBirdSpecies(byName: "Scarlet Tanager") { + genus { latinName } } greatBlueHeron: findBirdSpecies(byName: "Great Blue Heron") { - similarSpecies { __typename } + genus { latinName } } } GRAPHQL - context = {lookahead_typename: 0} + context = {lookahead_latin_name: 0} res = LookaheadTest::Schema.execute(query_str, context: context) refute res.key?("errors") - assert_equal 2, context[:lookahead_typename] + assert_equal 2, context[:lookahead_latin_name] end end end From 694f9aed902cc5ee94ede25cd94e147f658cbd59 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Thu, 11 Oct 2018 13:22:09 -0400 Subject: [PATCH 4/4] document the shortcoming --- lib/graphql/execution/lookahead.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/graphql/execution/lookahead.rb b/lib/graphql/execution/lookahead.rb index 4222f5f150..dd2526f35d 100644 --- a/lib/graphql/execution/lookahead.rb +++ b/lib/graphql/execution/lookahead.rb @@ -9,6 +9,10 @@ module Execution # A field may get access to its lookahead by adding `extras: [:lookahead]` # to its configuration. # + # __NOTE__: Lookahead for typed fragments (eg `node { ... on Thing { ... } }`) + # hasn't been implemented yet. It's possible, I just didn't need it yet. + # Feel free to open a PR or an issue if you want to add it. + # # @example looking ahead in a field # field :articles, [Types::Article], null: false, # extras: [:lookahead]