Skip to content

Commit

Permalink
support lower graphql version, use faster routing. (#43)
Browse files Browse the repository at this point in the history
  • Loading branch information
gmac authored Feb 21, 2023
1 parent 1671f62 commit 5ecbded
Show file tree
Hide file tree
Showing 9 changed files with 189 additions and 11 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion graphql-stitching.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
5 changes: 3 additions & 2 deletions lib/graphql/stitching/planner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
41 changes: 35 additions & 6 deletions lib/graphql/stitching/supergraph.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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 = {}

Expand Down
3 changes: 3 additions & 0 deletions test/graphql/stitching/composer/merge_boundaries_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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") }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
57 changes: 57 additions & 0 deletions test/graphql/stitching/integration/shareables_test.rb
Original file line number Diff line number Diff line change
@@ -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
80 changes: 80 additions & 0 deletions test/schemas/shareables.rb
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions test/test_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down

0 comments on commit 5ecbded

Please sign in to comment.