graphql-stitching 0.2.1 → 0.2.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +3 -3
- data/docs/executor.md +21 -3
- data/docs/planner.md +5 -1
- data/docs/request.md +4 -1
- data/graphql-stitching.gemspec +1 -1
- data/lib/graphql/stitching/composer.rb +9 -9
- data/lib/graphql/stitching/executor.rb +40 -13
- data/lib/graphql/stitching/planner.rb +195 -148
- data/lib/graphql/stitching/request.rb +12 -12
- data/lib/graphql/stitching/shaper.rb +2 -2
- data/lib/graphql/stitching/supergraph.rb +38 -5
- data/lib/graphql/stitching/util.rb +28 -35
- data/lib/graphql/stitching/version.rb +1 -1
- data/lib/graphql/stitching.rb +2 -0
- metadata +4 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: f67a4b892fba612e2e38552ae0a0f681ed0fdc136c04ecef4d2c01692eed9eb8
|
4
|
+
data.tar.gz: 3db164c53dc67d64b7364ba5a17186e3eb612074993b21df5f9a8b059fca9f89
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 8d687747a19a25a69b1c998910265a183bdb22338fec5e9787412ca5c1cdfc5e0b838bb49ce4942ce41dec0e000e0ddc35bdf07368a7a772751ffc5342b7ee48
|
7
|
+
data.tar.gz: f4419aa525964b37db62ce2343d11914ac56677825c8f1450c5187465b2f28e3a740acf6779309bdb0a5fbd41829054101aed4c9d0eb764882759c50bbf1b641
|
data/README.md
CHANGED
@@ -214,12 +214,12 @@ type Product {
|
|
214
214
|
upc: ID!
|
215
215
|
}
|
216
216
|
type Query {
|
217
|
-
productById(id: ID): Product @stitch(key: "id")
|
218
|
-
productByUpc(upc: ID): Product @stitch(key: "upc")
|
217
|
+
productById(id: ID!): Product @stitch(key: "id")
|
218
|
+
productByUpc(upc: ID!): Product @stitch(key: "upc")
|
219
219
|
}
|
220
220
|
```
|
221
221
|
|
222
|
-
The `@stitch` directive is also repeatable, allowing a single query to associate with multiple keys:
|
222
|
+
The `@stitch` directive is also repeatable (_requires graphql-ruby v2.0.15_), allowing a single query to associate with multiple keys:
|
223
223
|
|
224
224
|
```graphql
|
225
225
|
type Product {
|
data/docs/executor.md
CHANGED
@@ -12,7 +12,11 @@ query = <<~GRAPHQL
|
|
12
12
|
}
|
13
13
|
GRAPHQL
|
14
14
|
|
15
|
-
request = GraphQL::Stitching::Request.new(
|
15
|
+
request = GraphQL::Stitching::Request.new(
|
16
|
+
query,
|
17
|
+
variables: { "id" => "123" },
|
18
|
+
operation_name: "MyQuery",
|
19
|
+
)
|
16
20
|
|
17
21
|
plan = GraphQL::Stitching::Planner.new(
|
18
22
|
supergraph: supergraph,
|
@@ -21,8 +25,8 @@ plan = GraphQL::Stitching::Planner.new(
|
|
21
25
|
|
22
26
|
result = GraphQL::Stitching::Executor.new(
|
23
27
|
supergraph: supergraph,
|
24
|
-
plan: plan.to_h,
|
25
28
|
request: request,
|
29
|
+
plan: plan.to_h,
|
26
30
|
).perform
|
27
31
|
```
|
28
32
|
|
@@ -34,7 +38,21 @@ By default, execution results are always returned with document shaping (stitchi
|
|
34
38
|
# get the raw result without shaping
|
35
39
|
raw_result = GraphQL::Stitching::Executor.new(
|
36
40
|
supergraph: supergraph,
|
37
|
-
plan: plan.to_h,
|
38
41
|
request: request,
|
42
|
+
plan: plan.to_h,
|
39
43
|
).perform(raw: true)
|
40
44
|
```
|
45
|
+
|
46
|
+
### Batching
|
47
|
+
|
48
|
+
The Executor batches together as many requests as possible to a given location at a given time. Batched queries are written with the operation name suffixed by all operation keys in the batch, and root stitching fields are each prefixed by their batch index and collection index (for non-list fields):
|
49
|
+
|
50
|
+
```graphql
|
51
|
+
query MyOperation_2_3($lang:String!,$currency:Currency!){
|
52
|
+
_0_result: storefronts(ids:["7","8"]) { name(lang:$lang) }
|
53
|
+
_1_0_result: product(upc:"abc") { price(currency:$currency) }
|
54
|
+
_1_1_result: product(upc:"xyz") { price(currency:$currency) }
|
55
|
+
}
|
56
|
+
```
|
57
|
+
|
58
|
+
All told, the executor will make one request per location per generation of data. Generations started on separate forks of the resolution tree will be resolved independently.
|
data/docs/planner.md
CHANGED
@@ -12,7 +12,11 @@ document = <<~GRAPHQL
|
|
12
12
|
}
|
13
13
|
GRAPHQL
|
14
14
|
|
15
|
-
request = GraphQL::Stitching::Request.new(
|
15
|
+
request = GraphQL::Stitching::Request.new(
|
16
|
+
document,
|
17
|
+
variables: { "id" => "1" },
|
18
|
+
operation_name: "MyQuery",
|
19
|
+
).prepare!
|
16
20
|
|
17
21
|
plan = GraphQL::Stitching::Planner.new(
|
18
22
|
supergraph: supergraph,
|
data/docs/request.md
CHANGED
@@ -17,7 +17,7 @@ request.fragment_definitions # a mapping of fragment names to their fragment def
|
|
17
17
|
|
18
18
|
### Preparing requests
|
19
19
|
|
20
|
-
A request should be prepared using the `prepare!` method
|
20
|
+
A request should be prepared for stitching using the `prepare!` method _after_ validations have been run:
|
21
21
|
|
22
22
|
```ruby
|
23
23
|
document = <<~GRAPHQL
|
@@ -38,6 +38,9 @@ request = GraphQL::Stitching::Request.new(
|
|
38
38
|
operation_name: "FetchMovie",
|
39
39
|
)
|
40
40
|
|
41
|
+
errors = MySchema.validate(request.document)
|
42
|
+
# return early with any static validation errors...
|
43
|
+
|
41
44
|
request.prepare!
|
42
45
|
```
|
43
46
|
|
data/graphql-stitching.gemspec
CHANGED
@@ -26,7 +26,7 @@ Gem::Specification.new do |spec|
|
|
26
26
|
end
|
27
27
|
spec.require_paths = ['lib']
|
28
28
|
|
29
|
-
spec.add_runtime_dependency 'graphql', '~> 2.0.
|
29
|
+
spec.add_runtime_dependency 'graphql', '~> 2.0.3'
|
30
30
|
|
31
31
|
spec.add_development_dependency 'bundler', '~> 2.0'
|
32
32
|
spec.add_development_dependency 'rake', '~> 12.0'
|
@@ -365,7 +365,7 @@ module GraphQL
|
|
365
365
|
|
366
366
|
def merge_value_types(type_name, type_candidates, field_name: nil, argument_name: nil)
|
367
367
|
path = [type_name, field_name, argument_name].compact.join(".")
|
368
|
-
named_types = type_candidates.map {
|
368
|
+
named_types = type_candidates.map { _1.unwrap.graphql_name }.uniq
|
369
369
|
|
370
370
|
unless named_types.all? { _1 == named_types.first }
|
371
371
|
raise ComposerError, "Cannot compose mixed types at `#{path}`. Found: #{named_types.join(", ")}."
|
@@ -422,7 +422,7 @@ module GraphQL
|
|
422
422
|
def extract_boundaries(type_name, types_by_location)
|
423
423
|
types_by_location.each do |location, type_candidate|
|
424
424
|
type_candidate.fields.each do |field_name, field_candidate|
|
425
|
-
boundary_type_name =
|
425
|
+
boundary_type_name = field_candidate.type.unwrap.graphql_name
|
426
426
|
boundary_list = Util.get_list_structure(field_candidate.type)
|
427
427
|
|
428
428
|
field_candidate.directives.each do |directive|
|
@@ -470,10 +470,10 @@ module GraphQL
|
|
470
470
|
boundary_type = schema.types[type_name]
|
471
471
|
next unless boundary_type.kind.abstract?
|
472
472
|
|
473
|
-
|
474
|
-
|
475
|
-
@boundary_map[
|
476
|
-
@boundary_map[
|
473
|
+
expanded_types = Util.expand_abstract_type(schema, boundary_type)
|
474
|
+
expanded_types.select { @subschema_types_by_name_and_location[_1.graphql_name].length > 1 }.each do |expanded_type|
|
475
|
+
@boundary_map[expanded_type.graphql_name] ||= []
|
476
|
+
@boundary_map[expanded_type.graphql_name].push(*@boundary_map[type_name])
|
477
477
|
end
|
478
478
|
end
|
479
479
|
end
|
@@ -488,18 +488,18 @@ module GraphQL
|
|
488
488
|
|
489
489
|
if type.kind.object? || type.kind.interface?
|
490
490
|
type.fields.values.each do |field|
|
491
|
-
field_type =
|
491
|
+
field_type = field.type.unwrap
|
492
492
|
reads << field_type.graphql_name if field_type.kind.enum?
|
493
493
|
|
494
494
|
field.arguments.values.each do |argument|
|
495
|
-
argument_type =
|
495
|
+
argument_type = argument.type.unwrap
|
496
496
|
writes << argument_type.graphql_name if argument_type.kind.enum?
|
497
497
|
end
|
498
498
|
end
|
499
499
|
|
500
500
|
elsif type.kind.input_object?
|
501
501
|
type.arguments.values.each do |argument|
|
502
|
-
argument_type =
|
502
|
+
argument_type = argument.type.unwrap
|
503
503
|
writes << argument_type.graphql_name if argument_type.kind.enum?
|
504
504
|
end
|
505
505
|
end
|
@@ -15,7 +15,7 @@ module GraphQL
|
|
15
15
|
def fetch(ops)
|
16
16
|
op = ops.first # There should only ever be one per location at a time
|
17
17
|
|
18
|
-
query_document =
|
18
|
+
query_document = build_document(op, @executor.request.operation_name)
|
19
19
|
query_variables = @executor.request.variables.slice(*op["variables"].keys)
|
20
20
|
result = @executor.supergraph.execute_at_location(op["location"], query_document, query_variables, @executor.request.context)
|
21
21
|
@executor.query_count += 1
|
@@ -29,13 +29,23 @@ module GraphQL
|
|
29
29
|
ops.map { op["key"] }
|
30
30
|
end
|
31
31
|
|
32
|
-
|
32
|
+
# Builds root source documents
|
33
|
+
# "query MyOperation_1($var:VarType) { rootSelections ... }"
|
34
|
+
def build_document(op, operation_name = nil)
|
35
|
+
doc = String.new
|
36
|
+
doc << op["operation_type"]
|
37
|
+
|
38
|
+
if operation_name
|
39
|
+
doc << " " << operation_name << "_" << op["key"].to_s
|
40
|
+
end
|
41
|
+
|
33
42
|
if op["variables"].any?
|
34
43
|
variable_defs = op["variables"].map { |k, v| "$#{k}:#{v}" }.join(",")
|
35
|
-
"
|
36
|
-
else
|
37
|
-
"#{op["operation_type"]}#{op["selections"]}"
|
44
|
+
doc << "(" << variable_defs << ")"
|
38
45
|
end
|
46
|
+
|
47
|
+
doc << op["selections"]
|
48
|
+
doc
|
39
49
|
end
|
40
50
|
end
|
41
51
|
|
@@ -62,7 +72,7 @@ module GraphQL
|
|
62
72
|
end
|
63
73
|
|
64
74
|
if origin_sets_by_operation.any?
|
65
|
-
query_document, variable_names =
|
75
|
+
query_document, variable_names = build_document(origin_sets_by_operation, @executor.request.operation_name)
|
66
76
|
variables = @executor.request.variables.slice(*variable_names)
|
67
77
|
raw_result = @executor.supergraph.execute_at_location(@location, query_document, variables, @executor.request.context)
|
68
78
|
@executor.query_count += 1
|
@@ -76,7 +86,14 @@ module GraphQL
|
|
76
86
|
ops.map { origin_sets_by_operation[_1] ? _1["key"] : nil }
|
77
87
|
end
|
78
88
|
|
79
|
-
|
89
|
+
# Builds batched boundary queries
|
90
|
+
# "query MyOperation_2_3($var:VarType) {
|
91
|
+
# _0_result: list(keys:["a","b","c"]) { boundarySelections... }
|
92
|
+
# _1_0_result: item(key:"x") { boundarySelections... }
|
93
|
+
# _1_1_result: item(key:"y") { boundarySelections... }
|
94
|
+
# _1_2_result: item(key:"z") { boundarySelections... }
|
95
|
+
# }"
|
96
|
+
def build_document(origin_sets_by_operation, operation_name = nil)
|
80
97
|
variable_defs = {}
|
81
98
|
query_fields = origin_sets_by_operation.map.with_index do |(op, origin_set), batch_index|
|
82
99
|
variable_defs.merge!(op["variables"])
|
@@ -94,14 +111,24 @@ module GraphQL
|
|
94
111
|
end
|
95
112
|
end
|
96
113
|
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
114
|
+
doc = String.new
|
115
|
+
doc << "query" # << boundary fulfillment always uses query
|
116
|
+
|
117
|
+
if operation_name
|
118
|
+
doc << " " << operation_name
|
119
|
+
origin_sets_by_operation.each_key do |op|
|
120
|
+
doc << "_" << op["key"].to_s
|
121
|
+
end
|
102
122
|
end
|
103
123
|
|
104
|
-
|
124
|
+
if variable_defs.any?
|
125
|
+
variable_str = variable_defs.map { |k, v| "$#{k}:#{v}" }.join(",")
|
126
|
+
doc << "(" << variable_str << ")"
|
127
|
+
end
|
128
|
+
|
129
|
+
doc << "{ " << query_fields.join(" ") << " }"
|
130
|
+
|
131
|
+
return doc, variable_defs.keys
|
105
132
|
end
|
106
133
|
|
107
134
|
def merge_results!(origin_sets_by_operation, raw_result)
|
@@ -31,48 +31,18 @@ module GraphQL
|
|
31
31
|
|
32
32
|
private
|
33
33
|
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
extract_locale_selections(location, parent_type, selections, insertion_path, parent_key)
|
38
|
-
end
|
39
|
-
|
40
|
-
grouping = String.new
|
41
|
-
grouping << after_key.to_s << "/" << location << "/" << parent_type.graphql_name
|
42
|
-
grouping = insertion_path.reduce(grouping) do |memo, segment|
|
43
|
-
memo << "/" << segment
|
44
|
-
end
|
45
|
-
|
46
|
-
if op = @operations_by_grouping[grouping]
|
47
|
-
op.selections += selection_set if selection_set
|
48
|
-
op.variables.merge!(variables) if variables
|
49
|
-
return op
|
50
|
-
end
|
51
|
-
|
52
|
-
type_conditional = !parent_type.kind.abstract? && parent_type != @supergraph.schema.query && parent_type != @supergraph.schema.mutation
|
53
|
-
|
54
|
-
@operations_by_grouping[grouping] = PlannerOperation.new(
|
55
|
-
key: parent_key,
|
56
|
-
after_key: after_key,
|
57
|
-
location: location,
|
58
|
-
parent_type: parent_type,
|
59
|
-
operation_type: operation_type,
|
60
|
-
insertion_path: insertion_path,
|
61
|
-
type_condition: type_conditional ? parent_type.graphql_name : nil,
|
62
|
-
selections: selection_set || [],
|
63
|
-
variables: variables || {},
|
64
|
-
boundary: boundary,
|
65
|
-
)
|
66
|
-
end
|
67
|
-
|
34
|
+
# groups root fields by operational strategy:
|
35
|
+
# - query immedaitely groups all root fields by location for async resolution
|
36
|
+
# - mutation groups sequential root fields by location for serial resolution
|
68
37
|
def build_root_operations
|
69
38
|
case @request.operation.operation_type
|
70
39
|
when "query"
|
71
|
-
# plan steps grouping all fields by location for async execution
|
72
40
|
parent_type = @supergraph.schema.query
|
73
41
|
|
74
42
|
selections_by_location = @request.operation.selections.each_with_object({}) do |node, memo|
|
75
43
|
locations = @supergraph.locations_by_type_and_field[parent_type.graphql_name][node.name] || SUPERGRAPH_LOCATIONS
|
44
|
+
|
45
|
+
# root fields currently just delegate to the last location that defined them; this should probably be smarter
|
76
46
|
memo[locations.last] ||= []
|
77
47
|
memo[locations.last] << node
|
78
48
|
end
|
@@ -82,20 +52,19 @@ module GraphQL
|
|
82
52
|
end
|
83
53
|
|
84
54
|
when "mutation"
|
85
|
-
# plan steps grouping sequential fields by location for serial execution
|
86
55
|
parent_type = @supergraph.schema.mutation
|
87
56
|
location_groups = []
|
88
57
|
|
89
58
|
@request.operation.selections.reduce(nil) do |last_location, node|
|
90
|
-
location
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
}
|
59
|
+
# root fields currently just delegate to the last location that defined them; this should probably be smarter
|
60
|
+
next_location = @supergraph.locations_by_type_and_field[parent_type.graphql_name][node.name].last
|
61
|
+
|
62
|
+
if next_location != last_location
|
63
|
+
location_groups << { location: next_location, selections: [] }
|
96
64
|
end
|
65
|
+
|
97
66
|
location_groups.last[:selections] << node
|
98
|
-
|
67
|
+
next_location
|
99
68
|
end
|
100
69
|
|
101
70
|
location_groups.reduce(0) do |after_key, group|
|
@@ -113,73 +82,105 @@ module GraphQL
|
|
113
82
|
end
|
114
83
|
end
|
115
84
|
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
85
|
+
# adds an operation (data access) to the plan which maps a data selection to an insertion point.
|
86
|
+
# note that planned operations are NOT always 1:1 with executed requests, as the executor can
|
87
|
+
# frequently batch different insertion points with the same location into a single request.
|
88
|
+
def add_operation(
|
89
|
+
location:,
|
90
|
+
parent_type:,
|
91
|
+
selections:,
|
92
|
+
insertion_path: [],
|
93
|
+
operation_type: "query",
|
94
|
+
after_key: 0,
|
95
|
+
boundary: nil
|
96
|
+
)
|
97
|
+
parent_key = @sequence_key += 1
|
98
|
+
locale_variables = {}
|
99
|
+
locale_selections = if selections.any?
|
100
|
+
extract_locale_selections(location, parent_type, selections, insertion_path, parent_key, locale_variables)
|
101
|
+
else
|
102
|
+
selections
|
103
|
+
end
|
121
104
|
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
extended_selections << node
|
131
|
-
true
|
132
|
-
end
|
133
|
-
end
|
105
|
+
# groupings coalesce similar operation parameters into a single operation
|
106
|
+
# multiple operations per service may still occur with different insertion points,
|
107
|
+
# but those will get query-batched together during execution.
|
108
|
+
grouping = String.new
|
109
|
+
grouping << after_key.to_s << "/" << location << "/" << parent_type.graphql_name
|
110
|
+
grouping = insertion_path.reduce(grouping) do |memo, segment|
|
111
|
+
memo << "/" << segment
|
112
|
+
end
|
134
113
|
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
114
|
+
if op = @operations_by_grouping[grouping]
|
115
|
+
op.selections.concat(locale_selections)
|
116
|
+
op.variables.merge!(locale_variables)
|
117
|
+
op
|
118
|
+
else
|
119
|
+
# concrete types that are not root Query/Mutation report themselves as a type condition
|
120
|
+
# executor must check the __typename of loaded objects to see if they match subsequent operations
|
121
|
+
# this prevents the executor from taking action on unused fragment selections
|
122
|
+
type_conditional = !parent_type.kind.abstract? && parent_type != @supergraph.schema.query && parent_type != @supergraph.schema.mutation
|
123
|
+
|
124
|
+
@operations_by_grouping[grouping] = PlannerOperation.new(
|
125
|
+
key: parent_key,
|
126
|
+
after_key: after_key,
|
127
|
+
location: location,
|
128
|
+
parent_type: parent_type,
|
129
|
+
operation_type: operation_type,
|
130
|
+
insertion_path: insertion_path,
|
131
|
+
type_condition: type_conditional ? parent_type.graphql_name : nil,
|
132
|
+
selections: locale_selections,
|
133
|
+
variables: locale_variables,
|
134
|
+
boundary: boundary,
|
135
|
+
)
|
136
|
+
end
|
137
|
+
end
|
140
138
|
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
139
|
+
# extracts a selection tree that can all be fulfilled through the current planning location.
|
140
|
+
# adjoining remote selections will fork new insertion points and extract selections at those locations.
|
141
|
+
def extract_locale_selections(current_location, parent_type, input_selections, insertion_path, after_key, locale_variables)
|
142
|
+
remote_selections = nil
|
143
|
+
locale_selections = []
|
144
|
+
implements_fragments = false
|
145
|
+
|
146
|
+
if parent_type.kind.interface?
|
147
|
+
expand_interface_selections(current_location, parent_type, input_selections)
|
145
148
|
end
|
146
149
|
|
147
150
|
input_selections.each do |node|
|
148
151
|
case node
|
149
152
|
when GraphQL::Language::Nodes::Field
|
150
153
|
if node.name == "__typename"
|
151
|
-
|
154
|
+
locale_selections << node
|
152
155
|
next
|
153
156
|
end
|
154
157
|
|
155
158
|
possible_locations = @supergraph.locations_by_type_and_field[parent_type.graphql_name][node.name] || SUPERGRAPH_LOCATIONS
|
156
159
|
unless possible_locations.include?(current_location)
|
160
|
+
remote_selections ||= []
|
157
161
|
remote_selections << node
|
158
162
|
next
|
159
163
|
end
|
160
164
|
|
161
|
-
field_type = Util.
|
162
|
-
|
163
|
-
extract_node_variables!(node, variables_result)
|
165
|
+
field_type = Util.named_type_for_field_node(@supergraph.schema, parent_type, node)
|
166
|
+
extract_node_variables(node, locale_variables)
|
164
167
|
|
165
168
|
if Util.is_leaf_type?(field_type)
|
166
|
-
|
169
|
+
locale_selections << node
|
167
170
|
else
|
168
171
|
insertion_path.push(node.alias || node.name)
|
169
|
-
selection_set
|
172
|
+
selection_set = extract_locale_selections(current_location, field_type, node.selections, insertion_path, after_key, locale_variables)
|
170
173
|
insertion_path.pop
|
171
174
|
|
172
|
-
|
173
|
-
variables_result.merge!(variables)
|
175
|
+
locale_selections << node.merge(selections: selection_set)
|
174
176
|
end
|
175
177
|
|
176
178
|
when GraphQL::Language::Nodes::InlineFragment
|
177
179
|
next unless @supergraph.locations_by_type[node.type.name].include?(current_location)
|
178
180
|
|
179
181
|
fragment_type = @supergraph.schema.types[node.type.name]
|
180
|
-
selection_set
|
181
|
-
|
182
|
-
variables_result.merge!(variables)
|
182
|
+
selection_set = extract_locale_selections(current_location, fragment_type, node.selections, insertion_path, after_key, locale_variables)
|
183
|
+
locale_selections << node.merge(selections: selection_set)
|
183
184
|
implements_fragments = true
|
184
185
|
|
185
186
|
when GraphQL::Language::Nodes::FragmentSpread
|
@@ -187,9 +188,8 @@ module GraphQL
|
|
187
188
|
next unless @supergraph.locations_by_type[fragment.type.name].include?(current_location)
|
188
189
|
|
189
190
|
fragment_type = @supergraph.schema.types[fragment.type.name]
|
190
|
-
selection_set
|
191
|
-
|
192
|
-
variables_result.merge!(variables)
|
191
|
+
selection_set = extract_locale_selections(current_location, fragment_type, fragment.selections, insertion_path, after_key, locale_variables)
|
192
|
+
locale_selections << GraphQL::Language::Nodes::InlineFragment.new(type: fragment.type, selections: selection_set)
|
193
193
|
implements_fragments = true
|
194
194
|
|
195
195
|
else
|
@@ -197,25 +197,35 @@ module GraphQL
|
|
197
197
|
end
|
198
198
|
end
|
199
199
|
|
200
|
-
if remote_selections
|
201
|
-
|
202
|
-
|
200
|
+
if remote_selections
|
201
|
+
delegate_remote_selections(
|
202
|
+
current_location,
|
203
|
+
parent_type,
|
204
|
+
locale_selections,
|
205
|
+
remote_selections,
|
206
|
+
insertion_path,
|
207
|
+
after_key
|
208
|
+
)
|
203
209
|
end
|
204
210
|
|
211
|
+
# always include a __typename on abstracts and scopes that implement fragments
|
212
|
+
# this provides type information to inspect while shaping the final result
|
205
213
|
if parent_type.kind.abstract? || implements_fragments
|
206
|
-
|
214
|
+
locale_selections << TYPENAME_NODE
|
207
215
|
end
|
208
216
|
|
209
|
-
|
217
|
+
locale_selections
|
210
218
|
end
|
211
219
|
|
212
|
-
|
213
|
-
|
220
|
+
# distributes remote selections across locations,
|
221
|
+
# while spawning new operations for each new fulfillment.
|
222
|
+
def delegate_remote_selections(current_location, parent_type, locale_selections, remote_selections, insertion_path, after_key)
|
223
|
+
possible_locations_by_field = @supergraph.locations_by_type_and_field[parent_type.graphql_name]
|
214
224
|
selections_by_location = {}
|
215
225
|
|
216
226
|
# distribute unique fields among required locations
|
217
|
-
|
218
|
-
possible_locations =
|
227
|
+
remote_selections.reject! do |node|
|
228
|
+
possible_locations = possible_locations_by_field[node.name]
|
219
229
|
if possible_locations.length == 1
|
220
230
|
selections_by_location[possible_locations.first] ||= []
|
221
231
|
selections_by_location[possible_locations.first] << node
|
@@ -223,31 +233,35 @@ module GraphQL
|
|
223
233
|
end
|
224
234
|
end
|
225
235
|
|
226
|
-
# distribute non-unique fields among available locations, preferring used
|
227
|
-
if
|
228
|
-
# weight locations by number of
|
229
|
-
location_weights =
|
230
|
-
|
231
|
-
|
232
|
-
|
233
|
-
|
236
|
+
# distribute non-unique fields among available locations, preferring locations already used
|
237
|
+
if remote_selections.any?
|
238
|
+
# weight locations by number of required fields available, preferring greater availability
|
239
|
+
location_weights = if remote_selections.length > 1
|
240
|
+
remote_selections.each_with_object({}) do |node, memo|
|
241
|
+
possible_locations = possible_locations_by_field[node.name]
|
242
|
+
possible_locations.each do |location|
|
243
|
+
memo[location] ||= 0
|
244
|
+
memo[location] += 1
|
245
|
+
end
|
234
246
|
end
|
247
|
+
else
|
248
|
+
GraphQL::Stitching::EMPTY_OBJECT
|
235
249
|
end
|
236
250
|
|
237
|
-
|
238
|
-
possible_locations =
|
239
|
-
|
240
|
-
perfect_location_score = input_selections.length
|
251
|
+
remote_selections.each do |node|
|
252
|
+
possible_locations = possible_locations_by_field[node.name]
|
241
253
|
preferred_location_score = 0
|
242
|
-
|
243
|
-
|
244
|
-
|
254
|
+
|
255
|
+
# hill climbing selects highest scoring locations to use
|
256
|
+
preferred_location = possible_locations.reduce(possible_locations.first) do |best_location, possible_location|
|
257
|
+
score = selections_by_location[location] ? remote_selections.length : 0
|
258
|
+
score += location_weights.fetch(possible_location, 0)
|
245
259
|
|
246
260
|
if score > preferred_location_score
|
247
261
|
preferred_location_score = score
|
248
|
-
|
262
|
+
possible_location
|
249
263
|
else
|
250
|
-
|
264
|
+
best_location
|
251
265
|
end
|
252
266
|
end
|
253
267
|
|
@@ -257,70 +271,103 @@ module GraphQL
|
|
257
271
|
end
|
258
272
|
|
259
273
|
routes = @supergraph.route_type_to_locations(parent_type.graphql_name, current_location, selections_by_location.keys)
|
260
|
-
routes.values.each_with_object({}) do |route,
|
274
|
+
routes.values.each_with_object({}) do |route, ops_by_location|
|
261
275
|
route.reduce(nil) do |parent_op, boundary|
|
262
276
|
location = boundary["location"]
|
263
|
-
|
277
|
+
new_operation = false
|
264
278
|
|
265
|
-
|
266
|
-
|
267
|
-
|
268
|
-
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
|
275
|
-
|
276
|
-
|
277
|
-
)
|
278
|
-
|
279
|
-
if parent_op
|
280
|
-
parent_op.selections << foreign_key_node << TYPENAME_NODE
|
281
|
-
else
|
282
|
-
parent_selections_result << foreign_key_node << TYPENAME_NODE
|
279
|
+
unless op = ops_by_location[location]
|
280
|
+
new_operation = true
|
281
|
+
op = ops_by_location[location] = add_operation(
|
282
|
+
location: location,
|
283
|
+
# routing locations added as intermediaries have no initial selections,
|
284
|
+
# but will be given foreign keys by subsequent operations
|
285
|
+
selections: selections_by_location[location] || [],
|
286
|
+
parent_type: parent_type,
|
287
|
+
insertion_path: insertion_path.dup,
|
288
|
+
boundary: boundary,
|
289
|
+
after_key: after_key,
|
290
|
+
)
|
283
291
|
end
|
284
292
|
|
285
|
-
|
293
|
+
foreign_key = "_STITCH_#{boundary["selection"]}"
|
294
|
+
parent_selections = parent_op ? parent_op.selections : locale_selections
|
295
|
+
|
296
|
+
if new_operation || parent_selections.none? { _1.is_a?(GraphQL::Language::Nodes::Field) && _1.alias == foreign_key }
|
297
|
+
foreign_key_node = GraphQL::Language::Nodes::Field.new(alias: foreign_key, name: boundary["selection"])
|
298
|
+
parent_selections << foreign_key_node << TYPENAME_NODE
|
299
|
+
end
|
300
|
+
|
301
|
+
op
|
286
302
|
end
|
287
303
|
end
|
288
|
-
|
289
|
-
parent_selections_result
|
290
304
|
end
|
291
305
|
|
292
|
-
|
293
|
-
|
306
|
+
# extracts variable definitions used by a node
|
307
|
+
# (each operation tracks the specific variables used in its tree)
|
308
|
+
def extract_node_variables(node_with_args, variable_definitions)
|
309
|
+
node_with_args.arguments.each do |argument|
|
294
310
|
case argument.value
|
295
311
|
when GraphQL::Language::Nodes::InputObject
|
296
|
-
extract_node_variables
|
312
|
+
extract_node_variables(argument.value, variable_definitions)
|
297
313
|
when GraphQL::Language::Nodes::VariableIdentifier
|
298
|
-
|
314
|
+
variable_definitions[argument.value.name] ||= @request.variable_definitions[argument.value.name]
|
315
|
+
end
|
316
|
+
end
|
317
|
+
|
318
|
+
if node_with_args.respond_to?(:directives)
|
319
|
+
node_with_args.directives.each do |directive|
|
320
|
+
extract_node_variables(directive, variable_definitions)
|
321
|
+
end
|
322
|
+
end
|
323
|
+
end
|
324
|
+
|
325
|
+
# fields of a merged interface may not belong to the interface at the local level,
|
326
|
+
# so any non-local interface fields get expanded into typed fragments before planning
|
327
|
+
def expand_interface_selections(current_location, parent_type, input_selections)
|
328
|
+
local_interface_fields = @supergraph.fields_by_type_and_location[parent_type.graphql_name][current_location]
|
329
|
+
|
330
|
+
expanded_selections = nil
|
331
|
+
input_selections.reject! do |node|
|
332
|
+
if node.is_a?(GraphQL::Language::Nodes::Field) && !local_interface_fields.include?(node.name)
|
333
|
+
expanded_selections ||= []
|
334
|
+
expanded_selections << node
|
335
|
+
true
|
336
|
+
end
|
337
|
+
end
|
338
|
+
|
339
|
+
if expanded_selections
|
340
|
+
@supergraph.schema.possible_types(parent_type).each do |possible_type|
|
341
|
+
next unless @supergraph.locations_by_type[possible_type.graphql_name].include?(current_location)
|
342
|
+
|
343
|
+
type_name = GraphQL::Language::Nodes::TypeName.new(name: possible_type.graphql_name)
|
344
|
+
input_selections << GraphQL::Language::Nodes::InlineFragment.new(type: type_name, selections: expanded_selections)
|
299
345
|
end
|
300
346
|
end
|
301
347
|
end
|
302
348
|
|
303
349
|
# expand concrete type selections into typed fragments when sending to abstract boundaries
|
350
|
+
# this shifts all loose selection fields into a wrapping concrete type fragment
|
304
351
|
def expand_abstract_boundaries
|
305
352
|
@operations_by_grouping.each do |_grouping, op|
|
306
353
|
next unless op.boundary
|
307
354
|
|
308
|
-
boundary_type = @supergraph.schema.
|
355
|
+
boundary_type = @supergraph.schema.types[op.boundary["type_name"]]
|
309
356
|
next unless boundary_type.kind.abstract?
|
357
|
+
next if boundary_type == op.parent_type
|
310
358
|
|
311
|
-
|
312
|
-
|
313
|
-
|
314
|
-
|
315
|
-
|
316
|
-
|
317
|
-
end
|
359
|
+
expanded_selections = nil
|
360
|
+
op.selections.reject! do |node|
|
361
|
+
if node.is_a?(GraphQL::Language::Nodes::Field)
|
362
|
+
expanded_selections ||= []
|
363
|
+
expanded_selections << node
|
364
|
+
true
|
318
365
|
end
|
366
|
+
end
|
319
367
|
|
320
|
-
|
321
|
-
|
322
|
-
|
323
|
-
end
|
368
|
+
if expanded_selections
|
369
|
+
type_name = GraphQL::Language::Nodes::TypeName.new(name: op.parent_type.graphql_name)
|
370
|
+
op.selections << GraphQL::Language::Nodes::InlineFragment.new(type: type_name, selections: expanded_selections)
|
324
371
|
end
|
325
372
|
end
|
326
373
|
end
|
@@ -4,7 +4,6 @@ module GraphQL
|
|
4
4
|
module Stitching
|
5
5
|
class Request
|
6
6
|
SUPPORTED_OPERATIONS = ["query", "mutation"].freeze
|
7
|
-
EMPTY_CONTEXT = {}.freeze
|
8
7
|
|
9
8
|
class ApplyRuntimeDirectives < GraphQL::Language::Visitor
|
10
9
|
def initialize(document, variables)
|
@@ -68,7 +67,7 @@ module GraphQL
|
|
68
67
|
|
69
68
|
@operation_name = operation_name
|
70
69
|
@variables = variables || {}
|
71
|
-
@context = context ||
|
70
|
+
@context = context || GraphQL::Stitching::EMPTY_OBJECT
|
72
71
|
end
|
73
72
|
|
74
73
|
def string
|
@@ -114,18 +113,19 @@ module GraphQL
|
|
114
113
|
@variables[v.name] ||= v.default_value
|
115
114
|
end
|
116
115
|
|
117
|
-
|
116
|
+
if @may_contain_runtime_directives
|
117
|
+
visitor = ApplyRuntimeDirectives.new(@document, @variables)
|
118
|
+
@document = visitor.visit
|
118
119
|
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
@variable_definitions = nil
|
127
|
-
@fragment_definitions = nil
|
120
|
+
if visitor.changed?
|
121
|
+
@string = nil
|
122
|
+
@digest = nil
|
123
|
+
@operation = nil
|
124
|
+
@variable_definitions = nil
|
125
|
+
@fragment_definitions = nil
|
126
|
+
end
|
128
127
|
end
|
128
|
+
|
129
129
|
self
|
130
130
|
end
|
131
131
|
end
|
@@ -29,7 +29,7 @@ module GraphQL
|
|
29
29
|
|
30
30
|
field_name = node.alias || node.name
|
31
31
|
node_type = parent_type.fields[node.name].type
|
32
|
-
named_type = Util.
|
32
|
+
named_type = Util.named_type_for_field_node(@schema, parent_type, node)
|
33
33
|
|
34
34
|
raw_object[field_name] = if node_type.list?
|
35
35
|
resolve_list_scope(raw_object[field_name], Util.unwrap_non_null(node_type), node.selections)
|
@@ -67,7 +67,7 @@ module GraphQL
|
|
67
67
|
return nil if raw_list.nil?
|
68
68
|
|
69
69
|
next_node_type = Util.unwrap_non_null(current_node_type).of_type
|
70
|
-
named_type =
|
70
|
+
named_type = next_node_type.unwrap
|
71
71
|
contains_null = false
|
72
72
|
|
73
73
|
resolved_list = raw_list.map! do |raw_list_element|
|
@@ -29,6 +29,7 @@ module GraphQL
|
|
29
29
|
end
|
30
30
|
end
|
31
31
|
|
32
|
+
@possible_keys_by_type = {}
|
32
33
|
@possible_keys_by_type_and_location = {}
|
33
34
|
@executables = { LOCATION => @schema }.merge!(executables)
|
34
35
|
end
|
@@ -72,7 +73,7 @@ module GraphQL
|
|
72
73
|
executable.execute(
|
73
74
|
query: query,
|
74
75
|
variables: variables,
|
75
|
-
context: context,
|
76
|
+
context: context.frozen? ? context.dup : context,
|
76
77
|
validate: false,
|
77
78
|
)
|
78
79
|
elsif executable.respond_to?(:call)
|
@@ -83,6 +84,7 @@ module GraphQL
|
|
83
84
|
end
|
84
85
|
|
85
86
|
# inverts fields map to provide fields for a type/location
|
87
|
+
# "Type" => "location" => ["field1", "field2", ...]
|
86
88
|
def fields_by_type_and_location
|
87
89
|
@fields_by_type_and_location ||= @locations_by_type_and_field.each_with_object({}) do |(type_name, fields), memo|
|
88
90
|
memo[type_name] = fields.each_with_object({}) do |(field_name, locations), memo|
|
@@ -94,24 +96,55 @@ module GraphQL
|
|
94
96
|
end
|
95
97
|
end
|
96
98
|
|
99
|
+
# { "Type" => ["location1", "location2", ...] }
|
97
100
|
def locations_by_type
|
98
101
|
@locations_by_type ||= @locations_by_type_and_field.each_with_object({}) do |(type_name, fields), memo|
|
99
102
|
memo[type_name] = fields.values.flatten.uniq
|
100
103
|
end
|
101
104
|
end
|
102
105
|
|
106
|
+
# collects all possible boundary keys for a given type
|
107
|
+
# { "Type" => ["id", ...] }
|
108
|
+
def possible_keys_for_type(type_name)
|
109
|
+
@possible_keys_by_type[type_name] ||= begin
|
110
|
+
keys = @boundaries[type_name].map { _1["selection"] }
|
111
|
+
keys.uniq!
|
112
|
+
keys
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
# collects possible boundary keys for a given type and location
|
117
|
+
# ("Type", "location") => ["id", ...]
|
103
118
|
def possible_keys_for_type_and_location(type_name, location)
|
104
119
|
possible_keys_by_type = @possible_keys_by_type_and_location[type_name] ||= {}
|
105
120
|
possible_keys_by_type[location] ||= begin
|
106
121
|
location_fields = fields_by_type_and_location[type_name][location] || []
|
107
|
-
location_fields &
|
122
|
+
location_fields & possible_keys_for_type(type_name)
|
108
123
|
end
|
109
124
|
end
|
110
125
|
|
111
|
-
# For a given type, route from one origin
|
112
|
-
#
|
113
|
-
# favor longer paths through target locations over shorter paths with additional locations.
|
126
|
+
# For a given type, route from one origin location to one or more remote locations
|
127
|
+
# used to connect a partial type across locations via boundary queries
|
114
128
|
def route_type_to_locations(type_name, start_location, goal_locations)
|
129
|
+
if possible_keys_for_type(type_name).length > 1
|
130
|
+
# multiple keys use an a-star search to traverse intermediary locations
|
131
|
+
return route_type_to_locations_via_search(type_name, start_location, goal_locations)
|
132
|
+
end
|
133
|
+
|
134
|
+
# types with a single key attribute must all be within a single hop of each other,
|
135
|
+
# so can use a simple match to collect boundaries for the goal locations.
|
136
|
+
@boundaries[type_name].each_with_object({}) do |boundary, memo|
|
137
|
+
if goal_locations.include?(boundary["location"])
|
138
|
+
memo[boundary["location"]] = [boundary]
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
private
|
144
|
+
|
145
|
+
# tunes a-star search to favor paths with fewest joining locations, ie:
|
146
|
+
# favor longer paths through target locations over shorter paths with additional locations.
|
147
|
+
def route_type_to_locations_via_search(type_name, start_location, goal_locations)
|
115
148
|
results = {}
|
116
149
|
costs = {}
|
117
150
|
|
@@ -3,13 +3,9 @@
|
|
3
3
|
module GraphQL
|
4
4
|
module Stitching
|
5
5
|
class Util
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
while type.respond_to?(:of_type)
|
10
|
-
type = type.of_type
|
11
|
-
end
|
12
|
-
type
|
6
|
+
# specifies if a type is a primitive leaf value
|
7
|
+
def self.is_leaf_type?(type)
|
8
|
+
type.kind.scalar? || type.kind.enum?
|
13
9
|
end
|
14
10
|
|
15
11
|
# strips non-null wrappers from a type
|
@@ -20,6 +16,31 @@ module GraphQL
|
|
20
16
|
type
|
21
17
|
end
|
22
18
|
|
19
|
+
# gets a named type for a field node, including hidden root introspections
|
20
|
+
def self.named_type_for_field_node(schema, parent_type, node)
|
21
|
+
if node.name == "__schema" && parent_type == schema.query
|
22
|
+
schema.types["__Schema"]
|
23
|
+
else
|
24
|
+
parent_type.fields[node.name].type.unwrap
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
# expands interfaces and unions to an array of their memberships
|
29
|
+
# like `schema.possible_types`, but includes child interfaces
|
30
|
+
def self.expand_abstract_type(schema, parent_type)
|
31
|
+
return [] unless parent_type.kind.abstract?
|
32
|
+
return parent_type.possible_types if parent_type.kind.union?
|
33
|
+
|
34
|
+
result = []
|
35
|
+
schema.types.values.each do |type|
|
36
|
+
next unless type <= GraphQL::Schema::Interface && type != parent_type
|
37
|
+
next unless type.interfaces.include?(parent_type)
|
38
|
+
result << type
|
39
|
+
result.push(*expand_abstract_type(schema, type)) if type.kind.interface?
|
40
|
+
end
|
41
|
+
result.uniq
|
42
|
+
end
|
43
|
+
|
23
44
|
# gets a deep structural description of a list value type
|
24
45
|
def self.get_list_structure(type)
|
25
46
|
structure = []
|
@@ -38,34 +59,6 @@ module GraphQL
|
|
38
59
|
end
|
39
60
|
structure
|
40
61
|
end
|
41
|
-
|
42
|
-
# Gets all objects and interfaces that implement a given interface
|
43
|
-
def self.get_possible_types(schema, parent_type)
|
44
|
-
return [parent_type] unless parent_type.kind.abstract?
|
45
|
-
return parent_type.possible_types if parent_type.kind.union?
|
46
|
-
|
47
|
-
result = []
|
48
|
-
schema.types.values.each do |type|
|
49
|
-
next unless type <= GraphQL::Schema::Interface && type != parent_type
|
50
|
-
next unless type.interfaces.include?(parent_type)
|
51
|
-
result << type
|
52
|
-
result.push(*get_possible_types(schema, type)) if type.kind.interface?
|
53
|
-
end
|
54
|
-
result.uniq
|
55
|
-
end
|
56
|
-
|
57
|
-
# Specifies if a type is a leaf node (no children)
|
58
|
-
def self.is_leaf_type?(type)
|
59
|
-
type.kind.scalar? || type.kind.enum?
|
60
|
-
end
|
61
|
-
|
62
|
-
def self.get_named_type_for_field_node(schema, parent_type, node)
|
63
|
-
if node.name == "__schema" && parent_type == schema.query
|
64
|
-
schema.types["__Schema"] # type mapped to phantom introspection field
|
65
|
-
else
|
66
|
-
Util.get_named_type(parent_type.fields[node.name].type)
|
67
|
-
end
|
68
|
-
end
|
69
62
|
end
|
70
63
|
end
|
71
64
|
end
|
data/lib/graphql/stitching.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: graphql-stitching
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.2.
|
4
|
+
version: 0.2.2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Greg MacWilliam
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2023-02-
|
11
|
+
date: 2023-02-21 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: graphql
|
@@ -16,14 +16,14 @@ dependencies:
|
|
16
16
|
requirements:
|
17
17
|
- - "~>"
|
18
18
|
- !ruby/object:Gem::Version
|
19
|
-
version: 2.0.
|
19
|
+
version: 2.0.3
|
20
20
|
type: :runtime
|
21
21
|
prerelease: false
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
23
23
|
requirements:
|
24
24
|
- - "~>"
|
25
25
|
- !ruby/object:Gem::Version
|
26
|
-
version: 2.0.
|
26
|
+
version: 2.0.3
|
27
27
|
- !ruby/object:Gem::Dependency
|
28
28
|
name: bundler
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|