diff --git a/README.md b/README.md index e7f85ae1..e87adb7d 100644 --- a/README.md +++ b/README.md @@ -219,7 +219,7 @@ type Query { } ``` -The `@stitch` directive is also repeatable, allowing a single query to associate with multiple keys: +The `@stitch` directive is also repeatable (_requires graphql-ruby v2.0.15_), allowing a single query to associate with multiple keys: ```graphql type Product { diff --git a/graphql-stitching.gemspec b/graphql-stitching.gemspec index 9b497173..8ece2f70 100644 --- a/graphql-stitching.gemspec +++ b/graphql-stitching.gemspec @@ -26,7 +26,7 @@ Gem::Specification.new do |spec| end spec.require_paths = ['lib'] - spec.add_runtime_dependency 'graphql', '~> 2.0.16' + spec.add_runtime_dependency 'graphql', '~> 2.0.3' spec.add_development_dependency 'bundler', '~> 2.0' spec.add_development_dependency 'rake', '~> 12.0' diff --git a/lib/graphql/stitching/planner.rb b/lib/graphql/stitching/planner.rb index 0d74b360..4a551b02 100644 --- a/lib/graphql/stitching/planner.rb +++ b/lib/graphql/stitching/planner.rb @@ -139,7 +139,7 @@ def add_operation( # extracts a selection tree that can all be fulfilled through the current planning location. # adjoining remote selections will fork new insertion points and extract selections at those locations. def extract_locale_selections(current_location, parent_type, input_selections, insertion_path, after_key, locale_variables) - remote_selections = [] + remote_selections = nil locale_selections = [] implements_fragments = false @@ -157,6 +157,7 @@ def extract_locale_selections(current_location, parent_type, input_selections, i possible_locations = @supergraph.locations_by_type_and_field[parent_type.graphql_name][node.name] || SUPERGRAPH_LOCATIONS unless possible_locations.include?(current_location) + remote_selections ||= [] remote_selections << node next end @@ -196,7 +197,7 @@ def extract_locale_selections(current_location, parent_type, input_selections, i end end - if remote_selections.any? + if remote_selections delegate_remote_selections( current_location, parent_type, diff --git a/lib/graphql/stitching/supergraph.rb b/lib/graphql/stitching/supergraph.rb index b3cf3b23..52035a4d 100644 --- a/lib/graphql/stitching/supergraph.rb +++ b/lib/graphql/stitching/supergraph.rb @@ -29,6 +29,7 @@ def initialize(schema:, fields:, boundaries:, executables: {}) end end + @possible_keys_by_type = {} @possible_keys_by_type_and_location = {} @executables = { LOCATION => @schema }.merge!(executables) end @@ -72,7 +73,7 @@ def execute_at_location(location, query, variables, context) executable.execute( query: query, variables: variables, - context: context, + context: context.frozen? ? context.dup : context, validate: false, ) elsif executable.respond_to?(:call) @@ -95,27 +96,55 @@ def fields_by_type_and_location end end - # "Type" => ["location1", "location2", ...] + # { "Type" => ["location1", "location2", ...] } def locations_by_type @locations_by_type ||= @locations_by_type_and_field.each_with_object({}) do |(type_name, fields), memo| memo[type_name] = fields.values.flatten.uniq end end + # collects all possible boundary keys for a given type + # { "Type" => ["id", ...] } + def possible_keys_for_type(type_name) + @possible_keys_by_type[type_name] ||= begin + keys = @boundaries[type_name].map { _1["selection"] } + keys.uniq! + keys + end + end + # collects possible boundary keys for a given type and location # ("Type", "location") => ["id", ...] def possible_keys_for_type_and_location(type_name, location) possible_keys_by_type = @possible_keys_by_type_and_location[type_name] ||= {} possible_keys_by_type[location] ||= begin location_fields = fields_by_type_and_location[type_name][location] || [] - location_fields & @boundaries[type_name].map { _1["selection"] } + location_fields & possible_keys_for_type(type_name) end end - # For a given type, route from one origin service to one or more remote locations. - # Tunes a-star search to favor paths with fewest joining locations, ie: - # favor longer paths through target locations over shorter paths with additional locations. + # For a given type, route from one origin location to one or more remote locations + # used to connect a partial type across locations via boundary queries def route_type_to_locations(type_name, start_location, goal_locations) + if possible_keys_for_type(type_name).length > 1 + # multiple keys use an a-star search to traverse intermediary locations + return route_type_to_locations_via_search(type_name, start_location, goal_locations) + end + + # types with a single key attribute must all be within a single hop of each other, + # so can use a simple match to collect boundaries for the goal locations. + @boundaries[type_name].each_with_object({}) do |boundary, memo| + if goal_locations.include?(boundary["location"]) + memo[boundary["location"]] = [boundary] + end + end + end + + private + + # tunes a-star search to favor paths with fewest joining locations, ie: + # favor longer paths through target locations over shorter paths with additional locations. + def route_type_to_locations_via_search(type_name, start_location, goal_locations) results = {} costs = {} diff --git a/test/graphql/stitching/composer/merge_boundaries_test.rb b/test/graphql/stitching/composer/merge_boundaries_test.rb index 81733520..96cba35b 100644 --- a/test/graphql/stitching/composer/merge_boundaries_test.rb +++ b/test/graphql/stitching/composer/merge_boundaries_test.rb @@ -19,6 +19,9 @@ def test_creates_boundary_map end def test_merges_boundaries_with_multiple_keys + # repeatable directives don't work before v2.0.15 + skip if before_graphql_version?("2.0.15") + a = %{ type T { upc:ID! } type Query { a(upc:ID!):T @stitch(key: "upc") } diff --git a/test/graphql/stitching/integration/example_test.rb b/test/graphql/stitching/integration/multiple_generations_test.rb similarity index 97% rename from test/graphql/stitching/integration/example_test.rb rename to test/graphql/stitching/integration/multiple_generations_test.rb index 8ebbb875..ec8d34ca 100644 --- a/test/graphql/stitching/integration/example_test.rb +++ b/test/graphql/stitching/integration/multiple_generations_test.rb @@ -3,7 +3,7 @@ require "test_helper" require_relative "../../../schemas/example" -describe 'GraphQL::Stitching, example' do +describe 'GraphQL::Stitching, multiple generations' do def setup @supergraph = compose_definitions({ "products" => Schemas::Example::Products, diff --git a/test/graphql/stitching/integration/shareables_test.rb b/test/graphql/stitching/integration/shareables_test.rb new file mode 100644 index 00000000..c8a59e7c --- /dev/null +++ b/test/graphql/stitching/integration/shareables_test.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "test_helper" +require_relative "../../../schemas/shareables" + +describe 'GraphQL::Stitching, shareables' do + def setup + @supergraph = compose_definitions({ + "a" => Schemas::Shareables::ShareableA, + "b" => Schemas::Shareables::ShareableB, + }) + end + + def test_mutates_serially_and_stitches_results + query = <<~GRAPHQL + query { + gadgetA(id: "1") { + id + name + gizmo { a b c } + uniqueToA + uniqueToB + } + gadgetB(id: "1") { + id + name + gizmo { a b c } + uniqueToA + uniqueToB + } + } + GRAPHQL + + result = plan_and_execute(@supergraph, query) + + expected = { + "data" => { + "gadgetA" => { + "id" => "1", + "name" => "A1", + "gizmo" => { "a" => "apple", "b" => "banana", "c" => "coconut" }, + "uniqueToA" => "AA", + "uniqueToB" => "BB", + }, + "gadgetB" => { + "id" => "1", + "name" => "B1", + "gizmo" => { "a" => "aardvark", "b" => "bat", "c" => "cat" }, + "uniqueToA" => "AA", + "uniqueToB" => "BB", + }, + }, + } + + assert_equal expected, result + end +end diff --git a/test/schemas/shareables.rb b/test/schemas/shareables.rb new file mode 100644 index 00000000..43c5ac19 --- /dev/null +++ b/test/schemas/shareables.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +module Schemas + module Shareables + class Boundary < GraphQL::Schema::Directive + graphql_name "stitch" + locations FIELD_DEFINITION + argument :key, String + repeatable true + end + + # ShareableA + + class ShareableA < GraphQL::Schema + class Gizmo < GraphQL::Schema::Object + field :a, String, null: false + field :b, String, null: false + field :c, String, null: false + end + + class Gadget < GraphQL::Schema::Object + field :id, ID, null: false + field :name, String, null: false + field :gizmo, Gizmo, null: false + field :unique_to_a, String, null: false + + def gizmo + { a: "apple", b: "banana", c: "coconut" } + end + end + + class Query < GraphQL::Schema::Object + field :gadget_a, Gadget, null: false do + directive Boundary, key: "id" + argument :id, ID, required: true + end + + def gadget_a(id:) + { id: id, name: "A#{id}", unique_to_a: "AA" } + end + end + + query Query + end + + # ShareableB + + class ShareableB < GraphQL::Schema + class Gizmo < GraphQL::Schema::Object + field :a, String, null: false + field :b, String, null: false + field :c, String, null: false + end + + class Gadget < GraphQL::Schema::Object + field :id, ID, null: false + field :name, String, null: false + field :gizmo, Gizmo, null: false + field :unique_to_b, String, null: false + + def gizmo + { a: "aardvark", b: "bat", c: "cat" } + end + end + + class Query < GraphQL::Schema::Object + field :gadget_b, Gadget, null: false do + directive Boundary, key: "id" + argument :id, ID, required: true + end + + def gadget_b(id:) + { id: id, name: "B#{id}", unique_to_b: "BB" } + end + end + + query Query + end + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index f17c706c..328cbd84 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -22,6 +22,14 @@ def squish_string(str) str.gsub(/\s+/, " ").strip end +def before_graphql_version?(versioning) + lib_versioning = GraphQL::VERSION.split(".").map(&:to_i) + versioning.split(".").map(&:to_i).each_with_index.any? do |version, i| + lib_version = lib_versioning[i] || 0 + lib_version < version + end +end + def compose_definitions(schemas, options={}) schemas = schemas.each_with_object({}) do |(location, schema_or_sdl), memo| memo[location] = if schema_or_sdl.is_a?(String)