graphql-stitching 0.3.4 → 0.3.6
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +2 -2
- data/lib/graphql/stitching/composer/validate_boundaries.rb +4 -4
- data/lib/graphql/stitching/composer.rb +5 -3
- data/lib/graphql/stitching/executor.rb +17 -18
- data/lib/graphql/stitching/planner.rb +264 -189
- data/lib/graphql/stitching/planner_operation.rb +18 -16
- data/lib/graphql/stitching/shaper.rb +1 -1
- data/lib/graphql/stitching/supergraph.rb +21 -28
- data/lib/graphql/stitching/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: d3cb133db35cdb705f296a2fe67909bfe2f07baefc873cf502ba48dbd53e8c6a
|
4
|
+
data.tar.gz: c81040a57f364a1a8a045bf079428cfae3c8a5886d2f7d0aaae45844c055b92e
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: dc2a8d99942c2a5e1558ded0fbb467dd96d4bdb30c5a2ff9aa2348161ae28073cf36b2b56e51b0d92b757f68a87366ab1750e716bf0939d1645bf1ddee2049f5
|
7
|
+
data.tar.gz: 599760134f3f59f6ec5285e0d0976e1e9c07ba4103a74341bb35d1dacb89d0026383da2f4c2ca47d33ce3971510d9dddcc61d90a5248342406a4787f650d6126
|
data/README.md
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
## GraphQL Stitching for Ruby
|
2
2
|
|
3
|
-
GraphQL stitching composes a single schema from multiple underlying GraphQL resources, then smartly
|
3
|
+
GraphQL stitching composes a single schema from multiple underlying GraphQL resources, then smartly proxies portions of incoming requests to their respective locations in dependency order and returns the merged results. This allows an entire location graph to be queried through one combined GraphQL surface area.
|
4
4
|
|
5
5
|
![Stitched graph](./docs/images/stitching.png)
|
6
6
|
|
@@ -14,7 +14,7 @@ GraphQL stitching composes a single schema from multiple underlying GraphQL reso
|
|
14
14
|
- Computed fields (ie: federation-style `@requires`).
|
15
15
|
- Subscriptions, defer/stream.
|
16
16
|
|
17
|
-
This Ruby implementation is a sibling to [GraphQL Tools](https://the-guild.dev/graphql/stitching) (JS) and [Bramble](https://movio.github.io/bramble/) (Go), and its capabilities fall somewhere in between them. GraphQL stitching is similar in concept to [Apollo Federation](https://www.apollographql.com/docs/federation/), though more generic. While Ruby is not the fastest language for a high-throughput API gateway, the opportunity here is for a Ruby application to stitch its local
|
17
|
+
This Ruby implementation is a sibling to [GraphQL Tools](https://the-guild.dev/graphql/stitching) (JS) and [Bramble](https://movio.github.io/bramble/) (Go), and its capabilities fall somewhere in between them. GraphQL stitching is similar in concept to [Apollo Federation](https://www.apollographql.com/docs/federation/), though more generic. While Ruby is not the fastest language for a purely high-throughput API gateway, the opportunity here is for a Ruby application to stitch its local schemas together or onto remote sources without requiring an additional proxy service running in another language.
|
18
18
|
|
19
19
|
## Getting started
|
20
20
|
|
@@ -32,16 +32,16 @@ module GraphQL
|
|
32
32
|
|
33
33
|
# only one boundary allowed per type/location/key
|
34
34
|
boundaries_by_location_and_key = boundaries.each_with_object({}) do |boundary, memo|
|
35
|
-
if memo.dig(boundary["location"], boundary["
|
36
|
-
raise Composer::ValidationError, "Multiple boundary queries for `#{type.graphql_name}.#{boundary["
|
35
|
+
if memo.dig(boundary["location"], boundary["key"])
|
36
|
+
raise Composer::ValidationError, "Multiple boundary queries for `#{type.graphql_name}.#{boundary["key"]}` "\
|
37
37
|
"found in #{boundary["location"]}. Limit one boundary query per type and key in each location. "\
|
38
38
|
"Abstract boundaries provide all possible types."
|
39
39
|
end
|
40
40
|
memo[boundary["location"]] ||= {}
|
41
|
-
memo[boundary["location"]][boundary["
|
41
|
+
memo[boundary["location"]][boundary["key"]] = boundary
|
42
42
|
end
|
43
43
|
|
44
|
-
boundary_keys = boundaries.map { _1["
|
44
|
+
boundary_keys = boundaries.map { _1["key"] }.uniq
|
45
45
|
key_only_types_by_location = candidate_types_by_location.select do |location, subschema_type|
|
46
46
|
subschema_type.fields.keys.length == 1 && boundary_keys.include?(subschema_type.fields.keys.first)
|
47
47
|
end
|
@@ -56,8 +56,9 @@ module GraphQL
|
|
56
56
|
raise ComposerError, "Location keys must be strings" unless location.is_a?(String)
|
57
57
|
raise ComposerError, "The subscription operation is not supported." if schema.subscription
|
58
58
|
|
59
|
+
introspection_types = schema.introspection_system.types.keys
|
59
60
|
schema.types.each do |type_name, type_candidate|
|
60
|
-
next if
|
61
|
+
next if introspection_types.include?(type_name)
|
61
62
|
|
62
63
|
if type_name == @query_name && type_candidate != schema.query
|
63
64
|
raise ComposerError, "Query name \"#{@query_name}\" is used by non-query type in #{location} schema."
|
@@ -491,7 +492,7 @@ module GraphQL
|
|
491
492
|
@boundary_map[boundary_type_name] ||= []
|
492
493
|
@boundary_map[boundary_type_name] << {
|
493
494
|
"location" => location,
|
494
|
-
"
|
495
|
+
"key" => key_selections[0].name,
|
495
496
|
"field" => field_candidate.name,
|
496
497
|
"arg" => argument_name,
|
497
498
|
"list" => boundary_structure.first[:list],
|
@@ -538,8 +539,9 @@ module GraphQL
|
|
538
539
|
writes = []
|
539
540
|
|
540
541
|
schemas.each do |schema|
|
542
|
+
introspection_types = schema.introspection_system.types.keys
|
541
543
|
schema.types.values.each do |type|
|
542
|
-
next if
|
544
|
+
next if introspection_types.include?(type.graphql_name)
|
543
545
|
|
544
546
|
if type.kind.object? || type.kind.interface?
|
545
547
|
type.fields.values.each do |field|
|
@@ -26,7 +26,7 @@ module GraphQL
|
|
26
26
|
@executor.errors.concat(result["errors"])
|
27
27
|
end
|
28
28
|
|
29
|
-
ops.map { op["
|
29
|
+
ops.map { op["order"] }
|
30
30
|
end
|
31
31
|
|
32
32
|
# Builds root source documents
|
@@ -36,12 +36,12 @@ module GraphQL
|
|
36
36
|
doc << op["operation_type"]
|
37
37
|
|
38
38
|
if operation_name
|
39
|
-
doc << "
|
39
|
+
doc << " #{operation_name}_#{op["order"]}"
|
40
40
|
end
|
41
41
|
|
42
42
|
if op["variables"].any?
|
43
43
|
variable_defs = op["variables"].map { |k, v| "$#{k}:#{v}" }.join(",")
|
44
|
-
doc << "(
|
44
|
+
doc << "(#{variable_defs})"
|
45
45
|
end
|
46
46
|
|
47
47
|
doc << op["selections"]
|
@@ -57,13 +57,13 @@ module GraphQL
|
|
57
57
|
|
58
58
|
def fetch(ops)
|
59
59
|
origin_sets_by_operation = ops.each_with_object({}) do |op, memo|
|
60
|
-
origin_set = op["
|
60
|
+
origin_set = op["path"].reduce([@executor.data]) do |set, path_segment|
|
61
61
|
set.flat_map { |obj| obj && obj[path_segment] }.tap(&:compact!)
|
62
62
|
end
|
63
63
|
|
64
|
-
if op["
|
64
|
+
if op["if_type"]
|
65
65
|
# operations planned around unused fragment conditions should not trigger requests
|
66
|
-
origin_set.select! { _1["_STITCH_typename"] == op["
|
66
|
+
origin_set.select! { _1["_STITCH_typename"] == op["if_type"] }
|
67
67
|
end
|
68
68
|
|
69
69
|
memo[op] = origin_set if origin_set.any?
|
@@ -81,7 +81,7 @@ module GraphQL
|
|
81
81
|
@executor.errors.concat(extract_errors!(origin_sets_by_operation, errors)) if errors&.any?
|
82
82
|
end
|
83
83
|
|
84
|
-
ops.map { origin_sets_by_operation[_1] ? _1["
|
84
|
+
ops.map { origin_sets_by_operation[_1] ? _1["order"] : nil }
|
85
85
|
end
|
86
86
|
|
87
87
|
# Builds batched boundary queries
|
@@ -96,7 +96,7 @@ module GraphQL
|
|
96
96
|
query_fields = origin_sets_by_operation.map.with_index do |(op, origin_set), batch_index|
|
97
97
|
variable_defs.merge!(op["variables"])
|
98
98
|
boundary = op["boundary"]
|
99
|
-
key_selection = "_STITCH_#{boundary["
|
99
|
+
key_selection = "_STITCH_#{boundary["key"]}"
|
100
100
|
|
101
101
|
if boundary["list"]
|
102
102
|
input = JSON.generate(origin_set.map { _1[key_selection] })
|
@@ -113,18 +113,18 @@ module GraphQL
|
|
113
113
|
doc << "query" # << boundary fulfillment always uses query
|
114
114
|
|
115
115
|
if operation_name
|
116
|
-
doc << " "
|
116
|
+
doc << " #{operation_name}"
|
117
117
|
origin_sets_by_operation.each_key do |op|
|
118
|
-
doc << "_
|
118
|
+
doc << "_#{op["order"]}"
|
119
119
|
end
|
120
120
|
end
|
121
121
|
|
122
122
|
if variable_defs.any?
|
123
123
|
variable_str = variable_defs.map { |k, v| "$#{k}:#{v}" }.join(",")
|
124
|
-
doc << "(
|
124
|
+
doc << "(#{variable_str})"
|
125
125
|
end
|
126
126
|
|
127
|
-
doc << "{
|
127
|
+
doc << "{ #{query_fields.join(" ")} }"
|
128
128
|
|
129
129
|
return doc, variable_defs.keys
|
130
130
|
end
|
@@ -183,7 +183,7 @@ module GraphQL
|
|
183
183
|
|
184
184
|
if pathed_errors_by_op_index_and_object_id.any?
|
185
185
|
pathed_errors_by_op_index_and_object_id.each do |op_index, pathed_errors_by_object_id|
|
186
|
-
repath_errors!(pathed_errors_by_object_id, ops.dig(op_index, "
|
186
|
+
repath_errors!(pathed_errors_by_object_id, ops.dig(op_index, "path"))
|
187
187
|
errors_result.concat(pathed_errors_by_object_id.values)
|
188
188
|
end
|
189
189
|
end
|
@@ -265,7 +265,7 @@ module GraphQL
|
|
265
265
|
|
266
266
|
private
|
267
267
|
|
268
|
-
def exec!(
|
268
|
+
def exec!(next_ordinals = [0])
|
269
269
|
if @exec_cycles > @queue.length
|
270
270
|
# sanity check... if we've exceeded queue size, then something went wrong.
|
271
271
|
raise StitchingError, "Too many execution requests attempted."
|
@@ -273,7 +273,7 @@ module GraphQL
|
|
273
273
|
|
274
274
|
@dataloader.append_job do
|
275
275
|
tasks = @queue
|
276
|
-
.select {
|
276
|
+
.select { next_ordinals.include?(_1["after"]) }
|
277
277
|
.group_by { [_1["location"], _1["boundary"].nil?] }
|
278
278
|
.map do |(location, root_source), ops|
|
279
279
|
if root_source
|
@@ -291,9 +291,8 @@ module GraphQL
|
|
291
291
|
end
|
292
292
|
|
293
293
|
def exec_task(task)
|
294
|
-
|
295
|
-
|
296
|
-
exec!(next_keys) if next_keys.any?
|
294
|
+
next_ordinals = task.load.tap(&:compact!)
|
295
|
+
exec!(next_ordinals) if next_ordinals.any?
|
297
296
|
end
|
298
297
|
end
|
299
298
|
end
|
@@ -5,22 +5,23 @@ module GraphQL
|
|
5
5
|
class Planner
|
6
6
|
SUPERGRAPH_LOCATIONS = [Supergraph::LOCATION].freeze
|
7
7
|
TYPENAME_NODE = GraphQL::Language::Nodes::Field.new(alias: "_STITCH_typename", name: "__typename")
|
8
|
+
ROOT_ORDER = 0
|
8
9
|
|
9
10
|
def initialize(supergraph:, request:)
|
10
11
|
@supergraph = supergraph
|
11
12
|
@request = request
|
12
|
-
@
|
13
|
-
@
|
13
|
+
@planning_order = ROOT_ORDER
|
14
|
+
@operations_by_entrypoint = {}
|
14
15
|
end
|
15
16
|
|
16
17
|
def perform
|
17
|
-
|
18
|
+
build_root_entrypoints
|
18
19
|
expand_abstract_boundaries
|
19
20
|
self
|
20
21
|
end
|
21
22
|
|
22
23
|
def operations
|
23
|
-
@
|
24
|
+
@operations_by_entrypoint.values.sort_by!(&:order)
|
24
25
|
end
|
25
26
|
|
26
27
|
def to_h
|
@@ -29,12 +30,96 @@ module GraphQL
|
|
29
30
|
|
30
31
|
private
|
31
32
|
|
32
|
-
#
|
33
|
-
#
|
34
|
-
#
|
35
|
-
|
33
|
+
# **
|
34
|
+
# Algorithm:
|
35
|
+
#
|
36
|
+
# A) Group all root selections by their preferred entrypoint locations.
|
37
|
+
# A.1) Group query fields by location for parallel execution.
|
38
|
+
# A.2) Partition mutation fields by consecutive location for serial execution.
|
39
|
+
#
|
40
|
+
# B) Extract contiguous selections for each entrypoint location.
|
41
|
+
#
|
42
|
+
# B.1) Selections on interface types that do not belong to the interface at the
|
43
|
+
# entrypoint location are expanded into concrete type fragments prior to extraction.
|
44
|
+
#
|
45
|
+
# B.2) Filter the selection tree down to just fields of the entrypoint location.
|
46
|
+
# Adjoining selections not available here get split off into new entrypoints (C).
|
47
|
+
#
|
48
|
+
# B.3) Collect all variable definitions used within the filtered selection.
|
49
|
+
# These specify which request variables to pass along with the selection.
|
50
|
+
#
|
51
|
+
# B.4) Add a `__typename` selection to concrete types and abstracts that implement
|
52
|
+
# fragments. This provides resolved type information used during execution.
|
53
|
+
#
|
54
|
+
# C) Delegate adjoining selections to new entrypoint locations.
|
55
|
+
# C.1) Distribute unique fields among their required locations.
|
56
|
+
# C.2) Distribute non-unique fields among locations that were added during C.1.
|
57
|
+
# C.3) Distribute remaining fields among locations weighted by greatest availability.
|
58
|
+
#
|
59
|
+
# D) Create paths routing to new entrypoint locations via boundary queries.
|
60
|
+
# D.1) Types joining through multiple keys route using a-star search.
|
61
|
+
# D.2) Types joining through a single key route via quick location match.
|
62
|
+
# (D.2 is an optional optimization of D.1)
|
63
|
+
#
|
64
|
+
# E) Translate boundary pathways into new entrypoints.
|
65
|
+
# E.1) Add the key of each boundary query into the prior location's selection set.
|
66
|
+
# E.2) Add a planner operation for each new entrypoint location, then extract it (B).
|
67
|
+
#
|
68
|
+
# F) Wrap concrete selections targeting abstract boundaries in typed fragments.
|
69
|
+
# **
|
70
|
+
|
71
|
+
# adds an entrypoint for fetching and inserting data into the aggregate result.
|
72
|
+
def add_entrypoint(
|
73
|
+
location:,
|
74
|
+
parent_order:,
|
75
|
+
parent_type:,
|
76
|
+
selections:,
|
77
|
+
variables: {},
|
78
|
+
path: [],
|
79
|
+
operation_type: "query",
|
80
|
+
boundary: nil
|
81
|
+
)
|
82
|
+
# coalesce repeat parameters into a single entrypoint
|
83
|
+
boundary_key = boundary ? boundary["key"] : "_"
|
84
|
+
entrypoint = String.new("#{parent_order}/#{location}/#{parent_type.graphql_name}/#{boundary_key}")
|
85
|
+
path.each { entrypoint << "/#{_1}" }
|
86
|
+
|
87
|
+
op = @operations_by_entrypoint[entrypoint]
|
88
|
+
next_order = op ? parent_order : @planning_order += 1
|
89
|
+
|
90
|
+
if selections.any?
|
91
|
+
selections = extract_locale_selections(location, parent_type, next_order, selections, path, variables)
|
92
|
+
end
|
93
|
+
|
94
|
+
if op.nil?
|
95
|
+
# concrete types that are not root Query/Mutation report themselves as a type condition
|
96
|
+
# executor must check the __typename of loaded objects to see if they match subsequent operations
|
97
|
+
# this prevents the executor from taking action on unused fragment selections
|
98
|
+
conditional = !parent_type.kind.abstract? && parent_type != @supergraph.schema.root_type_for_operation(operation_type)
|
99
|
+
|
100
|
+
@operations_by_entrypoint[entrypoint] = PlannerOperation.new(
|
101
|
+
order: next_order,
|
102
|
+
after: parent_order,
|
103
|
+
location: location,
|
104
|
+
parent_type: parent_type,
|
105
|
+
operation_type: operation_type,
|
106
|
+
selections: selections,
|
107
|
+
variables: variables,
|
108
|
+
path: path,
|
109
|
+
if_type: conditional ? parent_type.graphql_name : nil,
|
110
|
+
boundary: boundary,
|
111
|
+
)
|
112
|
+
else
|
113
|
+
op.selections.concat(selections)
|
114
|
+
op
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
# A) Group all root selections by their preferred entrypoint locations.
|
119
|
+
def build_root_entrypoints
|
36
120
|
case @request.operation.operation_type
|
37
121
|
when "query"
|
122
|
+
# A.1) Group query fields by location for parallel execution.
|
38
123
|
parent_type = @supergraph.schema.query
|
39
124
|
|
40
125
|
selections_by_location = {}
|
@@ -45,31 +130,37 @@ module GraphQL
|
|
45
130
|
end
|
46
131
|
|
47
132
|
selections_by_location.each do |location, selections|
|
48
|
-
|
133
|
+
add_entrypoint(
|
134
|
+
location: location,
|
135
|
+
parent_order: ROOT_ORDER,
|
136
|
+
parent_type: parent_type,
|
137
|
+
selections: selections,
|
138
|
+
)
|
49
139
|
end
|
50
140
|
|
51
141
|
when "mutation"
|
142
|
+
# A.2) Partition mutation fields by consecutive location for serial execution.
|
52
143
|
parent_type = @supergraph.schema.mutation
|
53
144
|
|
54
|
-
|
145
|
+
partitions = []
|
55
146
|
each_selection_in_type(parent_type, @request.operation.selections) do |node|
|
56
147
|
next_location = @supergraph.locations_by_type_and_field[parent_type.graphql_name][node.name].first
|
57
148
|
|
58
|
-
if
|
59
|
-
|
149
|
+
if partitions.none? || partitions.last[:location] != next_location
|
150
|
+
partitions << { location: next_location, selections: [] }
|
60
151
|
end
|
61
152
|
|
62
|
-
|
153
|
+
partitions.last[:selections] << node
|
63
154
|
end
|
64
155
|
|
65
|
-
|
66
|
-
|
67
|
-
location:
|
68
|
-
|
69
|
-
operation_type: "mutation",
|
156
|
+
partitions.reduce(ROOT_ORDER) do |parent_order, partition|
|
157
|
+
add_entrypoint(
|
158
|
+
location: partition[:location],
|
159
|
+
parent_order: parent_order,
|
70
160
|
parent_type: parent_type,
|
71
|
-
|
72
|
-
|
161
|
+
selections: partition[:selections],
|
162
|
+
operation_type: "mutation",
|
163
|
+
).order
|
73
164
|
end
|
74
165
|
|
75
166
|
else
|
@@ -84,7 +175,7 @@ module GraphQL
|
|
84
175
|
yield(node)
|
85
176
|
|
86
177
|
when GraphQL::Language::Nodes::InlineFragment
|
87
|
-
next unless parent_type.graphql_name == node.type.name
|
178
|
+
next unless node.type.nil? || parent_type.graphql_name == node.type.name
|
88
179
|
each_selection_in_type(parent_type, node.selections, &block)
|
89
180
|
|
90
181
|
when GraphQL::Language::Nodes::FragmentSpread
|
@@ -98,67 +189,23 @@ module GraphQL
|
|
98
189
|
end
|
99
190
|
end
|
100
191
|
|
101
|
-
#
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
after_key: 0,
|
111
|
-
boundary: nil
|
192
|
+
# B) Contiguous selections are extracted for each entrypoint location.
|
193
|
+
def extract_locale_selections(
|
194
|
+
current_location,
|
195
|
+
parent_type,
|
196
|
+
parent_order,
|
197
|
+
input_selections,
|
198
|
+
path,
|
199
|
+
locale_variables,
|
200
|
+
locale_selections = []
|
112
201
|
)
|
113
|
-
|
114
|
-
|
115
|
-
locale_selections = if selections.any?
|
116
|
-
extract_locale_selections(location, parent_type, selections, insertion_path, parent_key, locale_variables)
|
117
|
-
else
|
118
|
-
selections
|
119
|
-
end
|
120
|
-
|
121
|
-
# groupings coalesce similar operation parameters into a single operation
|
122
|
-
# multiple operations per service may still occur with different insertion points,
|
123
|
-
# but those will get query-batched together during execution.
|
124
|
-
grouping = String.new("#{after_key}/#{location}/#{parent_type.graphql_name}")
|
125
|
-
insertion_path.each { grouping << "/#{_1}" }
|
126
|
-
|
127
|
-
if op = @operations_by_grouping[grouping]
|
128
|
-
op.selections.concat(locale_selections)
|
129
|
-
op.variables.merge!(locale_variables)
|
130
|
-
op
|
131
|
-
else
|
132
|
-
# concrete types that are not root Query/Mutation report themselves as a type condition
|
133
|
-
# executor must check the __typename of loaded objects to see if they match subsequent operations
|
134
|
-
# this prevents the executor from taking action on unused fragment selections
|
135
|
-
type_conditional = !parent_type.kind.abstract? && parent_type != @supergraph.schema.query && parent_type != @supergraph.schema.mutation
|
202
|
+
# B.1) Expand selections on interface types that do not belong to this location.
|
203
|
+
input_selections = expand_interface_selections(current_location, parent_type, input_selections)
|
136
204
|
|
137
|
-
|
138
|
-
|
139
|
-
after_key: after_key,
|
140
|
-
location: location,
|
141
|
-
parent_type: parent_type,
|
142
|
-
operation_type: operation_type,
|
143
|
-
insertion_path: insertion_path,
|
144
|
-
type_condition: type_conditional ? parent_type.graphql_name : nil,
|
145
|
-
selections: locale_selections,
|
146
|
-
variables: locale_variables,
|
147
|
-
boundary: boundary,
|
148
|
-
)
|
149
|
-
end
|
150
|
-
end
|
151
|
-
|
152
|
-
# extracts a selection tree that can all be fulfilled through the current planning location.
|
153
|
-
# adjoining remote selections will fork new insertion points and extract selections at those locations.
|
154
|
-
def extract_locale_selections(current_location, parent_type, input_selections, insertion_path, after_key, locale_variables)
|
205
|
+
# B.2) Filter the selection tree down to just fields of the entrypoint location.
|
206
|
+
# Adjoining selections not available here get split off into new entrypoints (C).
|
155
207
|
remote_selections = nil
|
156
|
-
|
157
|
-
implements_fragments = false
|
158
|
-
|
159
|
-
if parent_type.kind.interface?
|
160
|
-
input_selections = expand_interface_selections(current_location, parent_type, input_selections)
|
161
|
-
end
|
208
|
+
requires_typename = parent_type.kind.abstract?
|
162
209
|
|
163
210
|
input_selections.each do |node|
|
164
211
|
case node
|
@@ -175,68 +222,172 @@ module GraphQL
|
|
175
222
|
next
|
176
223
|
end
|
177
224
|
|
178
|
-
|
225
|
+
# B.3) Collect all variable definitions used within the filtered selection.
|
179
226
|
extract_node_variables(node, locale_variables)
|
227
|
+
field_type = @supergraph.memoized_schema_fields(parent_type.graphql_name)[node.name].type.unwrap
|
180
228
|
|
181
229
|
if Util.is_leaf_type?(field_type)
|
182
230
|
locale_selections << node
|
183
231
|
else
|
184
|
-
|
185
|
-
selection_set = extract_locale_selections(current_location, field_type, node.selections,
|
186
|
-
|
232
|
+
path.push(node.alias || node.name)
|
233
|
+
selection_set = extract_locale_selections(current_location, field_type, parent_order, node.selections, path, locale_variables)
|
234
|
+
path.pop
|
187
235
|
|
188
236
|
locale_selections << node.merge(selections: selection_set)
|
189
237
|
end
|
190
238
|
|
191
239
|
when GraphQL::Language::Nodes::InlineFragment
|
192
|
-
|
240
|
+
fragment_type = node.type ? @supergraph.memoized_schema_types[node.type.name] : parent_type
|
241
|
+
next unless @supergraph.locations_by_type[fragment_type.graphql_name].include?(current_location)
|
193
242
|
|
194
|
-
|
195
|
-
selection_set =
|
196
|
-
|
197
|
-
|
243
|
+
is_same_scope = fragment_type == parent_type
|
244
|
+
selection_set = is_same_scope ? locale_selections : []
|
245
|
+
extract_locale_selections(current_location, fragment_type, parent_order, node.selections, path, locale_variables, selection_set)
|
246
|
+
|
247
|
+
unless is_same_scope
|
248
|
+
locale_selections << node.merge(selections: selection_set)
|
249
|
+
requires_typename = true
|
250
|
+
end
|
198
251
|
|
199
252
|
when GraphQL::Language::Nodes::FragmentSpread
|
200
253
|
fragment = @request.fragment_definitions[node.name]
|
201
254
|
next unless @supergraph.locations_by_type[fragment.type.name].include?(current_location)
|
202
255
|
|
203
256
|
fragment_type = @supergraph.memoized_schema_types[fragment.type.name]
|
204
|
-
|
205
|
-
|
206
|
-
|
257
|
+
is_same_scope = fragment_type == parent_type
|
258
|
+
selection_set = is_same_scope ? locale_selections : []
|
259
|
+
extract_locale_selections(current_location, fragment_type, parent_order, fragment.selections, path, locale_variables, selection_set)
|
260
|
+
|
261
|
+
unless is_same_scope
|
262
|
+
locale_selections << GraphQL::Language::Nodes::InlineFragment.new(type: fragment.type, selections: selection_set)
|
263
|
+
requires_typename = true
|
264
|
+
end
|
207
265
|
|
208
266
|
else
|
209
267
|
raise "Unexpected node of type #{node.class.name} in selection set."
|
210
268
|
end
|
211
269
|
end
|
212
270
|
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
locale_selections,
|
218
|
-
remote_selections,
|
219
|
-
insertion_path,
|
220
|
-
after_key
|
221
|
-
)
|
271
|
+
# B.4) Add a `__typename` selection to concrete types and abstracts that implement
|
272
|
+
# fragments so that resolved type information is available during execution.
|
273
|
+
if requires_typename
|
274
|
+
locale_selections << TYPENAME_NODE
|
222
275
|
end
|
223
276
|
|
224
|
-
|
225
|
-
|
226
|
-
|
227
|
-
|
277
|
+
if remote_selections
|
278
|
+
# C) Delegate adjoining selections to new entrypoint locations.
|
279
|
+
remote_selections_by_location = delegate_remote_selections(parent_type, remote_selections)
|
280
|
+
|
281
|
+
# D) Create paths routing to new entrypoint locations via boundary queries.
|
282
|
+
routes = @supergraph.route_type_to_locations(parent_type.graphql_name, current_location, remote_selections_by_location.keys)
|
283
|
+
|
284
|
+
# E) Translate boundary pathways into new entrypoints.
|
285
|
+
routes.each_value do |route|
|
286
|
+
route.reduce(locale_selections) do |parent_selections, boundary|
|
287
|
+
# E.1) Add the key of each boundary query into the prior location's selection set.
|
288
|
+
foreign_key = "_STITCH_#{boundary["key"]}"
|
289
|
+
has_key = false
|
290
|
+
has_typename = false
|
291
|
+
|
292
|
+
parent_selections.each do |selection|
|
293
|
+
next unless selection.is_a?(GraphQL::Language::Nodes::Field)
|
294
|
+
case selection.alias
|
295
|
+
when foreign_key
|
296
|
+
has_key = true
|
297
|
+
when TYPENAME_NODE.alias
|
298
|
+
has_typename = true
|
299
|
+
end
|
300
|
+
end
|
301
|
+
|
302
|
+
parent_selections << GraphQL::Language::Nodes::Field.new(alias: foreign_key, name: boundary["key"]) unless has_key
|
303
|
+
parent_selections << TYPENAME_NODE unless has_typename
|
304
|
+
|
305
|
+
# E.2) Add a planner operation for each new entrypoint location.
|
306
|
+
location = boundary["location"]
|
307
|
+
add_entrypoint(
|
308
|
+
location: location,
|
309
|
+
parent_order: parent_order,
|
310
|
+
parent_type: parent_type,
|
311
|
+
selections: remote_selections_by_location[location] || [],
|
312
|
+
path: path.dup,
|
313
|
+
boundary: boundary,
|
314
|
+
).selections
|
315
|
+
end
|
316
|
+
end
|
228
317
|
end
|
229
318
|
|
230
319
|
locale_selections
|
231
320
|
end
|
232
321
|
|
233
|
-
#
|
234
|
-
#
|
235
|
-
def
|
322
|
+
# B.1) Selections on interface types that do not belong to the interface at the
|
323
|
+
# entrypoint location are expanded into concrete type fragments prior to extraction.
|
324
|
+
def expand_interface_selections(current_location, parent_type, input_selections)
|
325
|
+
return input_selections unless parent_type.kind.interface?
|
326
|
+
|
327
|
+
local_interface_fields = @supergraph.fields_by_type_and_location[parent_type.graphql_name][current_location]
|
328
|
+
|
329
|
+
expanded_selections = nil
|
330
|
+
input_selections = input_selections.filter_map do |node|
|
331
|
+
case node
|
332
|
+
when GraphQL::Language::Nodes::Field
|
333
|
+
if node.name != "__typename" && !local_interface_fields.include?(node.name)
|
334
|
+
expanded_selections ||= []
|
335
|
+
expanded_selections << node
|
336
|
+
next nil
|
337
|
+
end
|
338
|
+
|
339
|
+
when GraphQL::Language::Nodes::InlineFragment
|
340
|
+
fragment_type = node.type ? @supergraph.memoized_schema_types[node.type.name] : parent_type
|
341
|
+
selection_set = expand_interface_selections(current_location, fragment_type, node.selections)
|
342
|
+
node = node.merge(selections: selection_set)
|
343
|
+
|
344
|
+
when GraphQL::Language::Nodes::FragmentSpread
|
345
|
+
fragment = @request.fragment_definitions[node.name]
|
346
|
+
fragment_type = @supergraph.memoized_schema_types[fragment.type.name]
|
347
|
+
selection_set = expand_interface_selections(current_location, fragment_type, fragment.selections)
|
348
|
+
node = GraphQL::Language::Nodes::InlineFragment.new(type: fragment.type, selections: selection_set)
|
349
|
+
|
350
|
+
end
|
351
|
+
node
|
352
|
+
end
|
353
|
+
|
354
|
+
if expanded_selections
|
355
|
+
@supergraph.memoized_schema_possible_types(parent_type.graphql_name).each do |possible_type|
|
356
|
+
next unless @supergraph.locations_by_type[possible_type.graphql_name].include?(current_location)
|
357
|
+
|
358
|
+
type_name = GraphQL::Language::Nodes::TypeName.new(name: possible_type.graphql_name)
|
359
|
+
input_selections << GraphQL::Language::Nodes::InlineFragment.new(type: type_name, selections: expanded_selections)
|
360
|
+
end
|
361
|
+
end
|
362
|
+
|
363
|
+
input_selections
|
364
|
+
end
|
365
|
+
|
366
|
+
# B.3) Collect all variable definitions used within the filtered selection.
|
367
|
+
# These specify which request variables to pass along with the selection.
|
368
|
+
def extract_node_variables(node_with_args, variable_definitions)
|
369
|
+
node_with_args.arguments.each do |argument|
|
370
|
+
case argument.value
|
371
|
+
when GraphQL::Language::Nodes::InputObject
|
372
|
+
extract_node_variables(argument.value, variable_definitions)
|
373
|
+
when GraphQL::Language::Nodes::VariableIdentifier
|
374
|
+
variable_definitions[argument.value.name] ||= @request.variable_definitions[argument.value.name]
|
375
|
+
end
|
376
|
+
end
|
377
|
+
|
378
|
+
if node_with_args.respond_to?(:directives)
|
379
|
+
node_with_args.directives.each do |directive|
|
380
|
+
extract_node_variables(directive, variable_definitions)
|
381
|
+
end
|
382
|
+
end
|
383
|
+
end
|
384
|
+
|
385
|
+
# C) Delegate adjoining selections to new entrypoint locations.
|
386
|
+
def delegate_remote_selections(parent_type, remote_selections)
|
236
387
|
possible_locations_by_field = @supergraph.locations_by_type_and_field[parent_type.graphql_name]
|
237
388
|
selections_by_location = {}
|
238
389
|
|
239
|
-
# 1
|
390
|
+
# C.1) Distribute unique fields among their required locations.
|
240
391
|
remote_selections.reject! do |node|
|
241
392
|
possible_locations = possible_locations_by_field[node.name]
|
242
393
|
if possible_locations.length == 1
|
@@ -246,7 +397,7 @@ module GraphQL
|
|
246
397
|
end
|
247
398
|
end
|
248
399
|
|
249
|
-
# 2
|
400
|
+
# C.2) Distribute non-unique fields among locations that were added during C.1.
|
250
401
|
if selections_by_location.any? && remote_selections.any?
|
251
402
|
remote_selections.reject! do |node|
|
252
403
|
used_location = possible_locations_by_field[node.name].find { selections_by_location[_1] }
|
@@ -257,7 +408,7 @@ module GraphQL
|
|
257
408
|
end
|
258
409
|
end
|
259
410
|
|
260
|
-
# 3
|
411
|
+
# C.3) Distribute remaining fields among locations weighted by greatest availability.
|
261
412
|
if remote_selections.any?
|
262
413
|
field_count_by_location = if remote_selections.length > 1
|
263
414
|
remote_selections.each_with_object({}) do |node, memo|
|
@@ -290,88 +441,12 @@ module GraphQL
|
|
290
441
|
end
|
291
442
|
end
|
292
443
|
|
293
|
-
|
294
|
-
# then translate those routes into planner operations
|
295
|
-
routes = @supergraph.route_type_to_locations(parent_type.graphql_name, current_location, selections_by_location.keys)
|
296
|
-
routes.values.each_with_object({}) do |route, ops_by_location|
|
297
|
-
route.reduce(nil) do |parent_op, boundary|
|
298
|
-
location = boundary["location"]
|
299
|
-
|
300
|
-
unless op = ops_by_location[location]
|
301
|
-
op = ops_by_location[location] = add_operation(
|
302
|
-
location: location,
|
303
|
-
# routing locations added as intermediaries have no initial selections,
|
304
|
-
# but will be given foreign keys by subsequent operations
|
305
|
-
selections: selections_by_location[location] || [],
|
306
|
-
parent_type: parent_type,
|
307
|
-
insertion_path: insertion_path.dup,
|
308
|
-
boundary: boundary,
|
309
|
-
after_key: after_key,
|
310
|
-
)
|
311
|
-
end
|
312
|
-
|
313
|
-
foreign_key = "_STITCH_#{boundary["selection"]}"
|
314
|
-
parent_selections = parent_op ? parent_op.selections : locale_selections
|
315
|
-
|
316
|
-
if parent_selections.none? { _1.is_a?(GraphQL::Language::Nodes::Field) && _1.alias == foreign_key }
|
317
|
-
foreign_key_node = GraphQL::Language::Nodes::Field.new(alias: foreign_key, name: boundary["selection"])
|
318
|
-
parent_selections << foreign_key_node << TYPENAME_NODE
|
319
|
-
end
|
320
|
-
|
321
|
-
op
|
322
|
-
end
|
323
|
-
end
|
324
|
-
end
|
325
|
-
|
326
|
-
# extracts variable definitions used by a node
|
327
|
-
# (each operation tracks the specific variables used in its tree)
|
328
|
-
def extract_node_variables(node_with_args, variable_definitions)
|
329
|
-
node_with_args.arguments.each do |argument|
|
330
|
-
case argument.value
|
331
|
-
when GraphQL::Language::Nodes::InputObject
|
332
|
-
extract_node_variables(argument.value, variable_definitions)
|
333
|
-
when GraphQL::Language::Nodes::VariableIdentifier
|
334
|
-
variable_definitions[argument.value.name] ||= @request.variable_definitions[argument.value.name]
|
335
|
-
end
|
336
|
-
end
|
337
|
-
|
338
|
-
if node_with_args.respond_to?(:directives)
|
339
|
-
node_with_args.directives.each do |directive|
|
340
|
-
extract_node_variables(directive, variable_definitions)
|
341
|
-
end
|
342
|
-
end
|
343
|
-
end
|
344
|
-
|
345
|
-
# fields of a merged interface may not belong to the interface at the local level,
|
346
|
-
# so any non-local interface fields get expanded into typed fragments before planning
|
347
|
-
def expand_interface_selections(current_location, parent_type, input_selections)
|
348
|
-
local_interface_fields = @supergraph.fields_by_type_and_location[parent_type.graphql_name][current_location]
|
349
|
-
|
350
|
-
expanded_selections = nil
|
351
|
-
input_selections = input_selections.reject do |node|
|
352
|
-
if node.is_a?(GraphQL::Language::Nodes::Field) && node.name != "__typename" && !local_interface_fields.include?(node.name)
|
353
|
-
expanded_selections ||= []
|
354
|
-
expanded_selections << node
|
355
|
-
true
|
356
|
-
end
|
357
|
-
end
|
358
|
-
|
359
|
-
if expanded_selections
|
360
|
-
@supergraph.memoized_schema_possible_types(parent_type.graphql_name).each do |possible_type|
|
361
|
-
next unless @supergraph.locations_by_type[possible_type.graphql_name].include?(current_location)
|
362
|
-
|
363
|
-
type_name = GraphQL::Language::Nodes::TypeName.new(name: possible_type.graphql_name)
|
364
|
-
input_selections << GraphQL::Language::Nodes::InlineFragment.new(type: type_name, selections: expanded_selections)
|
365
|
-
end
|
366
|
-
end
|
367
|
-
|
368
|
-
input_selections
|
444
|
+
selections_by_location
|
369
445
|
end
|
370
446
|
|
371
|
-
#
|
372
|
-
# this shifts all loose selection fields into a wrapping concrete type fragment
|
447
|
+
# F) Wrap concrete selections targeting abstract boundaries in typed fragments.
|
373
448
|
def expand_abstract_boundaries
|
374
|
-
@
|
449
|
+
@operations_by_entrypoint.each_value do |op|
|
375
450
|
next unless op.boundary
|
376
451
|
|
377
452
|
boundary_type = @supergraph.memoized_schema_types[op.boundary["type_name"]]
|
@@ -5,30 +5,30 @@ module GraphQL
|
|
5
5
|
class PlannerOperation
|
6
6
|
LANGUAGE_PRINTER = GraphQL::Language::Printer.new
|
7
7
|
|
8
|
-
attr_reader :
|
9
|
-
attr_accessor :
|
8
|
+
attr_reader :order, :location, :parent_type, :if_type, :operation_type, :path
|
9
|
+
attr_accessor :after, :selections, :variables, :boundary
|
10
10
|
|
11
11
|
def initialize(
|
12
|
-
key:,
|
13
12
|
location:,
|
14
13
|
parent_type:,
|
14
|
+
order:,
|
15
|
+
after: nil,
|
15
16
|
operation_type: "query",
|
16
|
-
insertion_path: [],
|
17
|
-
type_condition: nil,
|
18
|
-
after_key: nil,
|
19
17
|
selections: [],
|
20
18
|
variables: [],
|
19
|
+
path: [],
|
20
|
+
if_type: nil,
|
21
21
|
boundary: nil
|
22
22
|
)
|
23
|
-
@key = key
|
24
|
-
@after_key = after_key
|
25
23
|
@location = location
|
26
24
|
@parent_type = parent_type
|
25
|
+
@order = order
|
26
|
+
@after = after
|
27
27
|
@operation_type = operation_type
|
28
|
-
@insertion_path = insertion_path
|
29
|
-
@type_condition = type_condition
|
30
28
|
@selections = selections
|
31
29
|
@variables = variables
|
30
|
+
@path = path
|
31
|
+
@if_type = if_type
|
32
32
|
@boundary = boundary
|
33
33
|
end
|
34
34
|
|
@@ -44,17 +44,19 @@ module GraphQL
|
|
44
44
|
end
|
45
45
|
|
46
46
|
def to_h
|
47
|
-
{
|
48
|
-
"
|
49
|
-
"
|
47
|
+
data = {
|
48
|
+
"order" => @order,
|
49
|
+
"after" => @after,
|
50
50
|
"location" => @location,
|
51
51
|
"operation_type" => @operation_type,
|
52
|
-
"insertion_path" => @insertion_path,
|
53
|
-
"type_condition" => @type_condition,
|
54
52
|
"selections" => selection_set,
|
55
53
|
"variables" => variable_set,
|
56
|
-
"
|
54
|
+
"path" => @path,
|
57
55
|
}
|
56
|
+
|
57
|
+
data["if_type"] = @if_type if @if_type
|
58
|
+
data["boundary"] = @boundary if @boundary
|
59
|
+
data
|
58
60
|
end
|
59
61
|
end
|
60
62
|
end
|
@@ -45,7 +45,7 @@ module GraphQL
|
|
45
45
|
return nil if raw_object[field_name].nil? && node_type.non_null?
|
46
46
|
|
47
47
|
when GraphQL::Language::Nodes::InlineFragment
|
48
|
-
fragment_type = @supergraph.memoized_schema_types[node.type.name]
|
48
|
+
fragment_type = node.type ? @supergraph.memoized_schema_types[node.type.name] : parent_type
|
49
49
|
next unless typename_in_type?(typename, fragment_type)
|
50
50
|
|
51
51
|
result = resolve_object_scope(raw_object, fragment_type, node.selections, typename)
|
@@ -4,16 +4,6 @@ module GraphQL
|
|
4
4
|
module Stitching
|
5
5
|
class Supergraph
|
6
6
|
LOCATION = "__super"
|
7
|
-
INTROSPECTION_TYPES = [
|
8
|
-
"__Schema",
|
9
|
-
"__Type",
|
10
|
-
"__Field",
|
11
|
-
"__Directive",
|
12
|
-
"__EnumValue",
|
13
|
-
"__InputValue",
|
14
|
-
"__TypeKind",
|
15
|
-
"__DirectiveLocation",
|
16
|
-
].freeze
|
17
7
|
|
18
8
|
def self.validate_executable!(location, executable)
|
19
9
|
return true if executable.is_a?(Class) && executable <= GraphQL::Schema
|
@@ -50,11 +40,10 @@ module GraphQL
|
|
50
40
|
@memoized_schema_fields = {}
|
51
41
|
|
52
42
|
# add introspection types into the fields mapping
|
53
|
-
@locations_by_type_and_field =
|
54
|
-
|
55
|
-
next unless introspection_type.kind.fields?
|
43
|
+
@locations_by_type_and_field = memoized_introspection_types.each_with_object(fields) do |(type_name, type), memo|
|
44
|
+
next unless type.kind.fields?
|
56
45
|
|
57
|
-
memo[type_name] =
|
46
|
+
memo[type_name] = type.fields.keys.each_with_object({}) do |field_name, m|
|
58
47
|
m[field_name] = [LOCATION]
|
59
48
|
end
|
60
49
|
end.freeze
|
@@ -68,7 +57,7 @@ module GraphQL
|
|
68
57
|
end
|
69
58
|
|
70
59
|
def fields
|
71
|
-
@locations_by_type_and_field.reject { |k, _v|
|
60
|
+
@locations_by_type_and_field.reject { |k, _v| memoized_introspection_types[k] }
|
72
61
|
end
|
73
62
|
|
74
63
|
def locations
|
@@ -83,6 +72,10 @@ module GraphQL
|
|
83
72
|
}
|
84
73
|
end
|
85
74
|
|
75
|
+
def memoized_introspection_types
|
76
|
+
@memoized_introspection_types ||= schema.introspection_system.types
|
77
|
+
end
|
78
|
+
|
86
79
|
def memoized_schema_types
|
87
80
|
@memoized_schema_types ||= @schema.types
|
88
81
|
end
|
@@ -94,11 +87,14 @@ module GraphQL
|
|
94
87
|
def memoized_schema_fields(type_name)
|
95
88
|
@memoized_schema_fields[type_name] ||= begin
|
96
89
|
fields = memoized_schema_types[type_name].fields
|
97
|
-
|
90
|
+
@schema.introspection_system.dynamic_fields.each do |field|
|
91
|
+
fields[field.name] ||= field # adds __typename
|
92
|
+
end
|
98
93
|
|
99
94
|
if type_name == @schema.query.graphql_name
|
100
|
-
|
101
|
-
|
95
|
+
@schema.introspection_system.entry_points.each do |field|
|
96
|
+
fields[field.name] ||= field # adds __schema, __type
|
97
|
+
end
|
102
98
|
end
|
103
99
|
|
104
100
|
fields
|
@@ -148,9 +144,7 @@ module GraphQL
|
|
148
144
|
# ("Type") => ["id", ...]
|
149
145
|
def possible_keys_for_type(type_name)
|
150
146
|
@possible_keys_by_type[type_name] ||= begin
|
151
|
-
|
152
|
-
keys.uniq!
|
153
|
-
keys
|
147
|
+
@boundaries[type_name].map { _1["key"] }.tap(&:uniq!)
|
154
148
|
end
|
155
149
|
end
|
156
150
|
|
@@ -190,18 +184,18 @@ module GraphQL
|
|
190
184
|
costs = {}
|
191
185
|
|
192
186
|
paths = possible_keys_for_type_and_location(type_name, start_location).map do |possible_key|
|
193
|
-
[{ location: start_location,
|
187
|
+
[{ location: start_location, key: possible_key, cost: 0 }]
|
194
188
|
end
|
195
189
|
|
196
190
|
while paths.any?
|
197
191
|
path = paths.pop
|
198
192
|
current_location = path.last[:location]
|
199
|
-
|
193
|
+
current_key = path.last[:key]
|
200
194
|
current_cost = path.last[:cost]
|
201
195
|
|
202
196
|
@boundaries[type_name].each do |boundary|
|
203
197
|
forward_location = boundary["location"]
|
204
|
-
next if
|
198
|
+
next if current_key != boundary["key"]
|
205
199
|
next if path.any? { _1[:location] == forward_location }
|
206
200
|
|
207
201
|
best_cost = costs[forward_location] || Float::INFINITY
|
@@ -210,7 +204,7 @@ module GraphQL
|
|
210
204
|
path.pop
|
211
205
|
path << {
|
212
206
|
location: current_location,
|
213
|
-
|
207
|
+
key: current_key,
|
214
208
|
cost: current_cost,
|
215
209
|
boundary: boundary,
|
216
210
|
}
|
@@ -228,14 +222,13 @@ module GraphQL
|
|
228
222
|
costs[forward_location] = forward_cost if forward_cost < best_cost
|
229
223
|
|
230
224
|
possible_keys_for_type_and_location(type_name, forward_location).each do |possible_key|
|
231
|
-
paths << [*path, { location: forward_location,
|
225
|
+
paths << [*path, { location: forward_location, key: possible_key, cost: forward_cost }]
|
232
226
|
end
|
233
227
|
end
|
234
228
|
|
235
229
|
paths.sort! do |a, b|
|
236
230
|
cost_diff = a.last[:cost] - b.last[:cost]
|
237
|
-
|
238
|
-
a.length - b.length
|
231
|
+
cost_diff.zero? ? a.length - b.length : cost_diff
|
239
232
|
end.reverse!
|
240
233
|
end
|
241
234
|
|
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.3.
|
4
|
+
version: 0.3.6
|
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-
|
11
|
+
date: 2023-04-11 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: graphql
|