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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 05c82abfcbec2db513d097c21424916bea46ebb0e4b434db0dc10a92ddfc2f9b
4
- data.tar.gz: 79e4184f4ed237e2132f67389bb838fbbf18a8141b1d2ab30f8c5b710f47cb8f
3
+ metadata.gz: f67a4b892fba612e2e38552ae0a0f681ed0fdc136c04ecef4d2c01692eed9eb8
4
+ data.tar.gz: 3db164c53dc67d64b7364ba5a17186e3eb612074993b21df5f9a8b059fca9f89
5
5
  SHA512:
6
- metadata.gz: e6c6ccb67c95df19b01bbeea1c0ed731a7153c3e4a184dab7dad6c2c52a22e22183075d123bf2dc78f431e73ff1fb4d31f0a21100072adb4f05bff2813ff9294
7
- data.tar.gz: 2b9257ed86bdac5cb2403e527fc90893c961637d14bda2c4e5a16be7c8dc23206f860bdb596cc26f35ea704156b7443c5ef013efcc4c8cde251c9a865777e3ad
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(query, variables: { "id" => "123" }, operation_name: "MyQuery")
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(document, operation_name: "MyQuery").prepare!
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 before using it:
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
 
@@ -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.16'
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 { Util.get_named_type(_1).graphql_name }.uniq
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 = Util.get_named_type(field_candidate.type).graphql_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
- possible_types = Util.get_possible_types(schema, boundary_type)
474
- possible_types.select { @subschema_types_by_name_and_location[_1.graphql_name].length > 1 }.each do |possible_type|
475
- @boundary_map[possible_type.graphql_name] ||= []
476
- @boundary_map[possible_type.graphql_name].push(*@boundary_map[type_name])
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 = Util.get_named_type(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 = Util.get_named_type(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 = Util.get_named_type(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 = build_query(op)
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
- def build_query(op)
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
- "#{op["operation_type"]}(#{variable_defs})#{op["selections"]}"
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 = build_query(origin_sets_by_operation)
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
- def build_query(origin_sets_by_operation)
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
- query_document = if variable_defs.any?
98
- query_variables = variable_defs.map { |k, v| "$#{k}:#{v}" }.join(",")
99
- "query(#{query_variables}){ #{query_fields.join(" ")} }"
100
- else
101
- "query{ #{query_fields.join(" ")} }"
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
- return query_document, variable_defs.keys
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
- def add_operation(location:, parent_type:, selections: nil, insertion_path: [], operation_type: "query", after_key: 0, boundary: nil)
35
- parent_key = @sequence_key += 1
36
- selection_set, variables = if selections&.any?
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 = @supergraph.locations_by_type_and_field[parent_type.graphql_name][node.name].last
91
- if location != last_location
92
- location_groups << {
93
- location: location,
94
- selections: [],
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
- location
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
- def extract_locale_selections(current_location, parent_type, input_selections, insertion_path, after_key)
117
- remote_selections = []
118
- selections_result = []
119
- variables_result = {}
120
- implements_fragments = false
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
- if parent_type.kind.interface?
123
- # fields of a merged interface may not belong to the interface at the local level,
124
- # so these non-local interface fields get expanded into typed fragments for planning
125
- local_interface_fields = @supergraph.fields_by_type_and_location[parent_type.graphql_name][current_location]
126
- extended_selections = []
127
-
128
- input_selections.reject! do |node|
129
- if node.is_a?(GraphQL::Language::Nodes::Field) && !local_interface_fields.include?(node.name)
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
- if extended_selections.any?
136
- possible_types = Util.get_possible_types(@supergraph.schema, parent_type)
137
- possible_types.each do |possible_type|
138
- next if possible_type.kind.abstract? # ignore child interfaces
139
- next unless @supergraph.locations_by_type[possible_type.graphql_name].include?(current_location)
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
- type_name = GraphQL::Language::Nodes::TypeName.new(name: possible_type.graphql_name)
142
- input_selections << GraphQL::Language::Nodes::InlineFragment.new(type: type_name, selections: extended_selections)
143
- end
144
- end
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
- selections_result << node
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.get_named_type_for_field_node(@supergraph.schema, parent_type, node)
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
- selections_result << node
169
+ locale_selections << node
167
170
  else
168
171
  insertion_path.push(node.alias || node.name)
169
- selection_set, variables = extract_locale_selections(current_location, field_type, node.selections, insertion_path, after_key)
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
- selections_result << node.merge(selections: selection_set)
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, variables = extract_locale_selections(current_location, fragment_type, node.selections, insertion_path, after_key)
181
- selections_result << node.merge(selections: selection_set)
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, variables = extract_locale_selections(current_location, fragment_type, fragment.selections, insertion_path, after_key)
191
- selections_result << GraphQL::Language::Nodes::InlineFragment.new(type: fragment.type, selections: selection_set)
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.any?
201
- selection_set = build_child_operations(current_location, parent_type, remote_selections, insertion_path, after_key)
202
- selections_result.concat(selection_set)
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
- selections_result << TYPENAME_NODE
214
+ locale_selections << TYPENAME_NODE
207
215
  end
208
216
 
209
- return selections_result, variables_result
217
+ locale_selections
210
218
  end
211
219
 
212
- def build_child_operations(current_location, parent_type, input_selections, insertion_path, after_key)
213
- parent_selections_result = []
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
- input_selections.reject! do |node|
218
- possible_locations = @supergraph.locations_by_type_and_field[parent_type.graphql_name][node.name]
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 locations
227
- if input_selections.any?
228
- # weight locations by number of needed fields available, prefer greater availability
229
- location_weights = input_selections.each_with_object({}) do |node, memo|
230
- possible_locations = @supergraph.locations_by_type_and_field[parent_type.graphql_name][node.name]
231
- possible_locations.each do |location|
232
- memo[location] ||= 0
233
- memo[location] += 1
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
- input_selections.each do |node|
238
- possible_locations = @supergraph.locations_by_type_and_field[parent_type.graphql_name][node.name]
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
- preferred_location = possible_locations.reduce(possible_locations.first) do |current_loc, candidate_loc|
243
- score = selections_by_location[location] ? perfect_location_score : 0
244
- score += location_weights.fetch(candidate_loc, 0)
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
- candidate_loc
262
+ possible_location
249
263
  else
250
- current_loc
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, memo|
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
- next memo[location] if memo[location]
277
+ new_operation = false
264
278
 
265
- child_op = memo[location] = add_operation(
266
- location: location,
267
- selections: selections_by_location[location],
268
- parent_type: parent_type,
269
- insertion_path: insertion_path.dup,
270
- boundary: boundary,
271
- after_key: after_key,
272
- )
273
-
274
- foreign_key_node = GraphQL::Language::Nodes::Field.new(
275
- alias: "_STITCH_#{boundary["selection"]}",
276
- name: boundary["selection"]
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
- child_op
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
- def extract_node_variables!(node_with_args, variables={})
293
- node_with_args.arguments.each_with_object(variables) do |argument, memo|
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!(argument.value, memo)
312
+ extract_node_variables(argument.value, variable_definitions)
297
313
  when GraphQL::Language::Nodes::VariableIdentifier
298
- memo[argument.value.name] ||= @request.variable_definitions[argument.value.name]
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.get_type(op.boundary["type_name"])
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
- unless op.parent_type == boundary_type
312
- to_typed_selections = []
313
- op.selections.reject! do |node|
314
- if node.is_a?(GraphQL::Language::Nodes::Field)
315
- to_typed_selections << node
316
- true
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
- if to_typed_selections.any?
321
- type_name = GraphQL::Language::Nodes::TypeName.new(name: op.parent_type.graphql_name)
322
- op.selections << GraphQL::Language::Nodes::InlineFragment.new(type: type_name, selections: to_typed_selections)
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 || EMPTY_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
- return self unless @may_contain_runtime_directives
116
+ if @may_contain_runtime_directives
117
+ visitor = ApplyRuntimeDirectives.new(@document, @variables)
118
+ @document = visitor.visit
118
119
 
119
- visitor = ApplyRuntimeDirectives.new(@document, @variables)
120
- @document = visitor.visit
121
-
122
- if visitor.changed?
123
- @string = nil
124
- @digest = nil
125
- @operation = nil
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.get_named_type_for_field_node(@schema, parent_type, node)
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 = Util.get_named_type(next_node_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 & @boundaries[type_name].map { _1["selection"] }
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 service to one or more remote locations.
112
- # Tunes a-star search to favor paths with fewest joining locations, ie:
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
- # gets the named type at the bottom of a non-null/list wrapper tree
8
- def self.get_named_type(type)
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module GraphQL
4
4
  module Stitching
5
- VERSION = "0.2.1"
5
+ VERSION = "0.2.2"
6
6
  end
7
7
  end
@@ -4,6 +4,8 @@ require "graphql"
4
4
 
5
5
  module GraphQL
6
6
  module Stitching
7
+ EMPTY_OBJECT = {}.freeze
8
+
7
9
  class StitchingError < StandardError; end
8
10
 
9
11
  class << self
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.1
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-15 00:00:00.000000000 Z
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.16
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.16
26
+ version: 2.0.3
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: bundler
29
29
  requirement: !ruby/object:Gem::Requirement