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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0b8fbbdd8c300982092ee9297953f87e014420801e1a6058a49e36d5de8fb85b
4
- data.tar.gz: 1fbd9b21161e8452a2fa55c4617ffabe8834c49e1b0635a58e17ed9b0cb43315
3
+ metadata.gz: d3cb133db35cdb705f296a2fe67909bfe2f07baefc873cf502ba48dbd53e8c6a
4
+ data.tar.gz: c81040a57f364a1a8a045bf079428cfae3c8a5886d2f7d0aaae45844c055b92e
5
5
  SHA512:
6
- metadata.gz: 9eb747176cb0b39ced4ce35a10bab8f4a8e0b7448a5d6a9a74e8e74f484aba7f1bea46c1eff3ca3b118ee3be5fd2f8e7f5ebffb23dd5e6480a92abe620af9523
7
- data.tar.gz: 64d935449b052d52b1068f22cc7df4b33608bb2b05bb5f8670da0e045f1c0faa06e44aa137acedab469312db5cda5144755a8d99b116a1c7ac09f809759ef198
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 delegates portions of incoming requests to their respective service locations in dependency order and returns the merged results. This allows an entire location graph to be queried through one combined GraphQL surface area.
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 schema onto a remote schema (making itself a superset of the remote) without requiring an additional gateway service.
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["selection"])
36
- raise Composer::ValidationError, "Multiple boundary queries for `#{type.graphql_name}.#{boundary["selection"]}` "\
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["selection"]] = boundary
41
+ memo[boundary["location"]][boundary["key"]] = boundary
42
42
  end
43
43
 
44
- boundary_keys = boundaries.map { _1["selection"] }.uniq
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 Supergraph::INTROSPECTION_TYPES.include?(type_name)
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
- "selection" => key_selections[0].name,
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 Supergraph::INTROSPECTION_TYPES.include?(type.graphql_name)
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["key"] }
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 << " " << operation_name << "_" << op["key"].to_s
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 << "(" << variable_defs << ")"
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["insertion_path"].reduce([@executor.data]) do |set, path_segment|
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["type_condition"]
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["type_condition"] }
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["key"] : nil }
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["selection"]}"
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 << " " << operation_name
116
+ doc << " #{operation_name}"
117
117
  origin_sets_by_operation.each_key do |op|
118
- doc << "_" << op["key"].to_s
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 << "(" << variable_str << ")"
124
+ doc << "(#{variable_str})"
125
125
  end
126
126
 
127
- doc << "{ " << query_fields.join(" ") << " }"
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, "insertion_path"))
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!(after_keys = [0])
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 { after_keys.include?(_1["after_key"]) }
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
- next_keys = task.load
295
- next_keys.compact!
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
- @sequence_key = 0
13
- @operations_by_grouping = {}
13
+ @planning_order = ROOT_ORDER
14
+ @operations_by_entrypoint = {}
14
15
  end
15
16
 
16
17
  def perform
17
- build_root_operations
18
+ build_root_entrypoints
18
19
  expand_abstract_boundaries
19
20
  self
20
21
  end
21
22
 
22
23
  def operations
23
- @operations_by_grouping.values.sort_by!(&:key)
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
- # groups root fields by operational strategy:
33
- # - query immedaitely groups all root fields by location for async resolution
34
- # - mutation groups sequential root fields by location for serial resolution
35
- def build_root_operations
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
- add_operation(location: location, parent_type: parent_type, selections: selections)
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
- location_groups = []
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 location_groups.none? || location_groups.last[:location] != next_location
59
- location_groups << { location: next_location, selections: [] }
149
+ if partitions.none? || partitions.last[:location] != next_location
150
+ partitions << { location: next_location, selections: [] }
60
151
  end
61
152
 
62
- location_groups.last[:selections] << node
153
+ partitions.last[:selections] << node
63
154
  end
64
155
 
65
- location_groups.reduce(0) do |after_key, group|
66
- add_operation(
67
- location: group[:location],
68
- selections: group[:selections],
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
- after_key: after_key
72
- ).key
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
- # adds an operation (data access) to the plan which maps a data selection to an insertion point.
102
- # note that planned operations are NOT always 1:1 with executed requests, as the executor can
103
- # frequently batch different insertion points with the same location into a single request.
104
- def add_operation(
105
- location:,
106
- parent_type:,
107
- selections:,
108
- insertion_path: [],
109
- operation_type: "query",
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
- parent_key = @sequence_key += 1
114
- locale_variables = {}
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
- @operations_by_grouping[grouping] = PlannerOperation.new(
138
- key: parent_key,
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
- locale_selections = []
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
- field_type = @supergraph.memoized_schema_fields(parent_type.graphql_name)[node.name].type.unwrap
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
- insertion_path.push(node.alias || node.name)
185
- selection_set = extract_locale_selections(current_location, field_type, node.selections, insertion_path, after_key, locale_variables)
186
- insertion_path.pop
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
- next unless @supergraph.locations_by_type[node.type.name].include?(current_location)
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
- fragment_type = @supergraph.memoized_schema_types[node.type.name]
195
- selection_set = extract_locale_selections(current_location, fragment_type, node.selections, insertion_path, after_key, locale_variables)
196
- locale_selections << node.merge(selections: selection_set)
197
- implements_fragments = true
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
- selection_set = extract_locale_selections(current_location, fragment_type, fragment.selections, insertion_path, after_key, locale_variables)
205
- locale_selections << GraphQL::Language::Nodes::InlineFragment.new(type: fragment.type, selections: selection_set)
206
- implements_fragments = true
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
- if remote_selections
214
- delegate_remote_selections(
215
- current_location,
216
- parent_type,
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
- # always include a __typename on abstracts and scopes that implement fragments
225
- # this provides type information to inspect while shaping the final result
226
- if parent_type.kind.abstract? || implements_fragments
227
- locale_selections << TYPENAME_NODE
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
- # distributes remote selections across locations,
234
- # while spawning new operations for each new fulfillment.
235
- def delegate_remote_selections(current_location, parent_type, locale_selections, remote_selections, insertion_path, after_key)
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. distribute unique fields among required locations
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. distribute non-unique fields among locations that are already used
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. distribute remaining fields among locations weighted by greatest availability
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
- # route from current location to target locations via boundary queries,
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
- # expand concrete type selections into typed fragments when sending to abstract boundaries
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
- @operations_by_grouping.each do |_grouping, op|
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 :key, :location, :parent_type, :type_condition, :operation_type, :insertion_path
9
- attr_accessor :after_key, :selections, :variables, :boundary
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
- "key" => @key,
49
- "after_key" => @after_key,
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
- "boundary" => @boundary,
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 = INTROSPECTION_TYPES.each_with_object(fields) do |type_name, memo|
54
- introspection_type = schema.get_type(type_name)
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] = introspection_type.fields.keys.each_with_object({}) do |field_name, m|
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| INTROSPECTION_TYPES.include?(k) }
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
- fields["__typename"] = @schema.introspection_system.dynamic_field(name: "__typename")
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
- fields["__schema"] = @schema.introspection_system.entry_point(name: "__schema")
101
- fields["__type"] = @schema.introspection_system.entry_point(name: "__type")
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
- keys = @boundaries[type_name].map { _1["selection"] }
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, selection: possible_key, cost: 0 }]
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
- current_selection = path.last[:selection]
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 current_selection != boundary["selection"]
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
- selection: current_selection,
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, selection: possible_key, cost: forward_cost }]
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
- next cost_diff unless cost_diff.zero?
238
- a.length - b.length
231
+ cost_diff.zero? ? a.length - b.length : cost_diff
239
232
  end.reverse!
240
233
  end
241
234
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module GraphQL
4
4
  module Stitching
5
- VERSION = "0.3.4"
5
+ VERSION = "0.3.6"
6
6
  end
7
7
  end
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
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-03-27 00:00:00.000000000 Z
11
+ date: 2023-04-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: graphql