graphql-stitching 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +27 -0
  3. data/.gitignore +59 -0
  4. data/.ruby-version +1 -0
  5. data/Gemfile +11 -0
  6. data/Gemfile.lock +49 -0
  7. data/LICENSE +21 -0
  8. data/Procfile +3 -0
  9. data/README.md +329 -0
  10. data/Rakefile +12 -0
  11. data/docs/README.md +14 -0
  12. data/docs/composer.md +69 -0
  13. data/docs/document.md +15 -0
  14. data/docs/executor.md +29 -0
  15. data/docs/gateway.md +106 -0
  16. data/docs/images/library.png +0 -0
  17. data/docs/images/merging.png +0 -0
  18. data/docs/images/stitching.png +0 -0
  19. data/docs/planner.md +43 -0
  20. data/docs/shaper.md +20 -0
  21. data/docs/supergraph.md +65 -0
  22. data/example/gateway.rb +50 -0
  23. data/example/graphiql.html +153 -0
  24. data/example/remote1.rb +26 -0
  25. data/example/remote2.rb +26 -0
  26. data/graphql-stitching.gemspec +34 -0
  27. data/lib/graphql/stitching/composer/base_validator.rb +11 -0
  28. data/lib/graphql/stitching/composer/validate_boundaries.rb +80 -0
  29. data/lib/graphql/stitching/composer/validate_interfaces.rb +24 -0
  30. data/lib/graphql/stitching/composer.rb +442 -0
  31. data/lib/graphql/stitching/document.rb +59 -0
  32. data/lib/graphql/stitching/executor.rb +254 -0
  33. data/lib/graphql/stitching/gateway.rb +120 -0
  34. data/lib/graphql/stitching/planner.rb +323 -0
  35. data/lib/graphql/stitching/planner_operation.rb +59 -0
  36. data/lib/graphql/stitching/remote_client.rb +25 -0
  37. data/lib/graphql/stitching/shaper.rb +92 -0
  38. data/lib/graphql/stitching/supergraph.rb +171 -0
  39. data/lib/graphql/stitching/util.rb +63 -0
  40. data/lib/graphql/stitching/version.rb +7 -0
  41. data/lib/graphql/stitching.rb +30 -0
  42. metadata +142 -0
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module GraphQL
6
+ module Stitching
7
+ class Executor
8
+
9
+ class RootSource < GraphQL::Dataloader::Source
10
+ def initialize(executor)
11
+ @executor = executor
12
+ end
13
+
14
+ def fetch(ops)
15
+ op = ops.first # There should only ever be one per location at a time
16
+
17
+ query_document = build_query(op)
18
+ query_variables = @executor.variables.slice(*op["variables"].keys)
19
+ result = @executor.supergraph.execute_at_location(op["location"], query_document, query_variables)
20
+ @executor.query_count += 1
21
+
22
+ @executor.data.merge!(result["data"]) if result["data"]
23
+ if result["errors"]&.any?
24
+ result["errors"].each { _1.delete("locations") }
25
+ @executor.errors.concat(result["errors"])
26
+ end
27
+ op["key"]
28
+ end
29
+
30
+ def build_query(op)
31
+ if op["variables"].any?
32
+ variable_defs = op["variables"].map { |k, v| "$#{k}:#{v}" }.join(",")
33
+ "#{op["operation_type"]}(#{variable_defs})#{op["selections"]}"
34
+ else
35
+ "#{op["operation_type"]}#{op["selections"]}"
36
+ end
37
+ end
38
+ end
39
+
40
+ class BoundarySource < GraphQL::Dataloader::Source
41
+ def initialize(executor, location)
42
+ @executor = executor
43
+ @location = location
44
+ end
45
+
46
+ def fetch(ops)
47
+ origin_sets_by_operation = ops.each_with_object({}) do |op, memo|
48
+ origin_set = op["insertion_path"].reduce([@executor.data]) do |set, path_segment|
49
+ mapped = set.flat_map { |obj| obj && obj[path_segment] }
50
+ mapped.compact!
51
+ mapped
52
+ end
53
+
54
+ if op["type_condition"]
55
+ # operations planned around unused fragment conditions should not trigger requests
56
+ origin_set.select! { _1["_STITCH_typename"] == op["type_condition"] }
57
+ end
58
+
59
+ memo[op] = origin_set if origin_set.any?
60
+ end
61
+
62
+ if origin_sets_by_operation.any?
63
+ query_document, variable_names = build_query(origin_sets_by_operation)
64
+ variables = @executor.variables.slice(*variable_names)
65
+ raw_result = @executor.supergraph.execute_at_location(@location, query_document, variables)
66
+ @executor.query_count += 1
67
+
68
+ merge_results!(origin_sets_by_operation, raw_result.dig("data"))
69
+
70
+ errors = raw_result.dig("errors")
71
+ @executor.errors.concat(extract_errors!(origin_sets_by_operation, errors)) if errors&.any?
72
+ end
73
+
74
+ ops.map { origin_sets_by_operation[_1] ? _1["key"] : nil }
75
+ end
76
+
77
+ def build_query(origin_sets_by_operation)
78
+ variable_defs = {}
79
+ query_fields = origin_sets_by_operation.map.with_index do |(op, origin_set), batch_index|
80
+ variable_defs.merge!(op["variables"])
81
+ boundary = op["boundary"]
82
+ key_selection = "_STITCH_#{boundary["selection"]}"
83
+
84
+ if boundary["list"]
85
+ input = JSON.generate(origin_set.map { _1[key_selection] })
86
+ "_#{batch_index}_result: #{boundary["field"]}(#{boundary["arg"]}:#{input}) #{op["selections"]}"
87
+ else
88
+ origin_set.map.with_index do |origin_obj, index|
89
+ input = JSON.generate(origin_obj[key_selection])
90
+ "_#{batch_index}_#{index}_result: #{boundary["field"]}(#{boundary["arg"]}:#{input}) #{op["selections"]}"
91
+ end
92
+ end
93
+ end
94
+
95
+ query_document = if variable_defs.any?
96
+ query_variables = variable_defs.map { |k, v| "$#{k}:#{v}" }.join(",")
97
+ "query(#{query_variables}){ #{query_fields.join(" ")} }"
98
+ else
99
+ "query{ #{query_fields.join(" ")} }"
100
+ end
101
+
102
+ return query_document, variable_defs.keys
103
+ end
104
+
105
+ def merge_results!(origin_sets_by_operation, raw_result)
106
+ return unless raw_result
107
+
108
+ origin_sets_by_operation.each_with_index do |(op, origin_set), batch_index|
109
+ results = if op.dig("boundary", "list")
110
+ raw_result["_#{batch_index}_result"]
111
+ else
112
+ origin_set.map.with_index { |_, index| raw_result["_#{batch_index}_#{index}_result"] }
113
+ end
114
+
115
+ next unless results&.any?
116
+
117
+ origin_set.each_with_index do |origin_obj, index|
118
+ origin_obj.merge!(results[index]) if results[index]
119
+ end
120
+ end
121
+ end
122
+
123
+ # https://spec.graphql.org/June2018/#sec-Errors
124
+ def extract_errors!(origin_sets_by_operation, errors)
125
+ ops = origin_sets_by_operation.keys
126
+ origin_sets = origin_sets_by_operation.values
127
+ pathed_errors_by_op_index_and_object_id = {}
128
+
129
+ errors_result = errors.each_with_object([]) do |err, memo|
130
+ err.delete("locations")
131
+ path = err["path"]
132
+
133
+ if path && path.length > 0
134
+ result_alias = /^_(\d+)(?:_(\d+))?_result$/.match(path.first.to_s)
135
+
136
+ if result_alias
137
+ path = err["path"] = path[1..-1]
138
+
139
+ origin_obj = if result_alias[2]
140
+ origin_sets.dig(result_alias[1].to_i, result_alias[2].to_i)
141
+ elsif path[0].is_a?(Integer) || /\d+/.match?(path[0].to_s)
142
+ origin_sets.dig(result_alias[1].to_i, path.shift.to_i)
143
+ end
144
+
145
+ if origin_obj
146
+ by_op_index = pathed_errors_by_op_index_and_object_id[result_alias[1].to_i] ||= {}
147
+ by_object_id = by_op_index[origin_obj.object_id] ||= []
148
+ by_object_id << err
149
+ next
150
+ end
151
+ end
152
+ end
153
+
154
+ memo << err
155
+ end
156
+
157
+ if pathed_errors_by_op_index_and_object_id.any?
158
+ pathed_errors_by_op_index_and_object_id.each do |op_index, pathed_errors_by_object_id|
159
+ repath_errors!(pathed_errors_by_object_id, ops.dig(op_index, "insertion_path"))
160
+ errors_result.concat(pathed_errors_by_object_id.values)
161
+ end
162
+ end
163
+ errors_result.flatten!
164
+ end
165
+
166
+ private
167
+
168
+ # traverses forward through origin data, expanding arrays to follow all paths
169
+ # any errors found for an origin object_id have their path prefixed by the object path
170
+ def repath_errors!(pathed_errors_by_object_id, forward_path, current_path=[], root=@executor.data)
171
+ current_path << forward_path.first
172
+ forward_path = forward_path[1..-1]
173
+ scope = root[current_path.last]
174
+
175
+ if forward_path.any? && scope.is_a?(Array)
176
+ scope.each_with_index do |element, index|
177
+ inner_elements = element.is_a?(Array) ? element.flatten : [element]
178
+ inner_elements.each do |inner_element|
179
+ repath_errors!(pathed_errors_by_object_id, forward_path, [*current_path, index], inner_element)
180
+ end
181
+ end
182
+
183
+ elsif forward_path.any?
184
+ repath_errors!(pathed_errors_by_object_id, forward_path, [*current_path, index], scope)
185
+
186
+ elsif scope.is_a?(Array)
187
+ scope.each_with_index do |element, index|
188
+ inner_elements = element.is_a?(Array) ? element.flatten : [element]
189
+ inner_elements.each do |inner_element|
190
+ errors = pathed_errors_by_object_id[inner_element.object_id]
191
+ errors.each { _1["path"] = [*current_path, index, *_1["path"]] } if errors
192
+ end
193
+ end
194
+
195
+ else
196
+ errors = pathed_errors_by_object_id[scope.object_id]
197
+ errors.each { _1["path"] = [*current_path, *_1["path"]] } if errors
198
+ end
199
+ end
200
+ end
201
+
202
+ attr_reader :supergraph, :data, :errors, :variables
203
+ attr_accessor :query_count
204
+
205
+ def initialize(supergraph:, plan:, variables: {}, nonblocking: false)
206
+ @supergraph = supergraph
207
+ @variables = variables
208
+ @queue = plan["ops"]
209
+ @data = {}
210
+ @errors = []
211
+ @query_count = 0
212
+ @dataloader = GraphQL::Dataloader.new(nonblocking: nonblocking)
213
+ end
214
+
215
+ def perform(document=nil)
216
+ exec!
217
+
218
+ result = {}
219
+ result["data"] = @data if @data && @data.length > 0
220
+ result["errors"] = @errors if @errors.length > 0
221
+
222
+ result if document.nil?
223
+
224
+ GraphQL::Stitching::Shaper.new(supergraph: @supergraph, document: document, raw: result).perform!
225
+ end
226
+
227
+ private
228
+
229
+ def exec!(after_keys = [0])
230
+ @dataloader.append_job do
231
+ requests = @queue
232
+ .select { after_keys.include?(_1["after_key"]) }
233
+ .group_by { _1["location"] }
234
+ .map do |location, ops|
235
+ if ops.first["after_key"].zero?
236
+ @dataloader.with(RootSource, self).request_all(ops)
237
+ else
238
+ @dataloader.with(BoundarySource, self, location).request_all(ops)
239
+ end
240
+ end
241
+
242
+ requests.each(&method(:exec_request))
243
+ end
244
+ @dataloader.run
245
+ end
246
+
247
+ def exec_request(request)
248
+ next_keys = request.load
249
+ next_keys.compact!
250
+ exec!(next_keys) if next_keys.any?
251
+ end
252
+ end
253
+ end
254
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module GraphQL
6
+ module Stitching
7
+ class Gateway
8
+ class GatewayError < StitchingError; end
9
+
10
+ EMPTY_CONTEXT = {}.freeze
11
+
12
+ attr_reader :supergraph
13
+
14
+ def initialize(locations: nil, supergraph: nil)
15
+ @supergraph = if locations && supergraph
16
+ raise GatewayError, "Cannot provide both locations and a supergraph."
17
+ elsif supergraph && !supergraph.is_a?(Supergraph)
18
+ raise GatewayError, "Provided supergraph must be a GraphQL::Stitching::Supergraph instance."
19
+ elsif supergraph
20
+ supergraph
21
+ elsif locations
22
+ build_supergraph_from_locations_config(locations)
23
+ else
24
+ raise GatewayError, "No locations or supergraph provided."
25
+ end
26
+ end
27
+
28
+ def execute(query:, variables: nil, operation_name: nil, context: EMPTY_CONTEXT, validate: true)
29
+ document = GraphQL::Stitching::Document.new(query, operation_name: operation_name)
30
+
31
+ if validate
32
+ validation_errors = @supergraph.schema.validate(document.ast)
33
+ return error_result(validation_errors) if validation_errors.any?
34
+ end
35
+
36
+ begin
37
+ plan = fetch_plan(document, context) do
38
+ GraphQL::Stitching::Planner.new(
39
+ supergraph: @supergraph,
40
+ document: document,
41
+ ).perform.to_h
42
+ end
43
+
44
+ GraphQL::Stitching::Executor.new(
45
+ supergraph: @supergraph,
46
+ plan: plan,
47
+ variables: variables || {},
48
+ ).perform(document)
49
+ rescue StandardError => e
50
+ custom_message = @on_error.call(e, context) if @on_error
51
+ error_result([{ "message" => custom_message || "An unexpected error occured." }])
52
+ end
53
+ end
54
+
55
+ def on_cache_read(&block)
56
+ raise GatewayError, "A cache read block is required." unless block_given?
57
+ @on_cache_read = block
58
+ end
59
+
60
+ def on_cache_write(&block)
61
+ raise GatewayError, "A cache write block is required." unless block_given?
62
+ @on_cache_write = block
63
+ end
64
+
65
+ def on_error(&block)
66
+ raise GatewayError, "An error handler block is required." unless block_given?
67
+ @on_error = block
68
+ end
69
+
70
+ private
71
+
72
+ def build_supergraph_from_locations_config(locations)
73
+ schemas = locations.each_with_object({}) do |(location, config), memo|
74
+ schema = config[:schema]
75
+ if schema.nil?
76
+ raise GatewayError, "A schema is required for `#{location}` location."
77
+ elsif !(schema.is_a?(Class) && schema <= GraphQL::Schema)
78
+ raise GatewayError, "The schema for `#{location}` location must be a GraphQL::Schema class."
79
+ else
80
+ memo[location.to_s] = schema
81
+ end
82
+ end
83
+
84
+ supergraph = GraphQL::Stitching::Composer.new(schemas: schemas).perform
85
+
86
+ locations.each do |location, config|
87
+ executable = config[:executable]
88
+ supergraph.assign_executable(location.to_s, executable) if executable
89
+ end
90
+
91
+ supergraph
92
+ end
93
+
94
+ def fetch_plan(document, context)
95
+ if @on_cache_read
96
+ cached_plan = @on_cache_read.call(document.digest, context)
97
+ return JSON.parse(cached_plan) if cached_plan
98
+ end
99
+
100
+ plan_json = yield
101
+
102
+ if @on_cache_write
103
+ @on_cache_write.call(document.digest, JSON.generate(plan_json), context)
104
+ end
105
+
106
+ plan_json
107
+ end
108
+
109
+ def error_result(errors)
110
+ public_errors = errors.map do |e|
111
+ public_error = e.is_a?(Hash) ? e : e.to_h
112
+ public_error["path"] ||= []
113
+ public_error
114
+ end
115
+
116
+ { "errors" => public_errors }
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,323 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Stitching
5
+ class Planner
6
+ SUPERGRAPH_LOCATIONS = [Supergraph::LOCATION].freeze
7
+ TYPENAME_NODE = GraphQL::Language::Nodes::Field.new(alias: "_STITCH_typename", name: "__typename")
8
+
9
+ def initialize(supergraph:, document:)
10
+ @supergraph = supergraph
11
+ @document = document
12
+ @sequence_key = 0
13
+ @operations_by_grouping = {}
14
+ end
15
+
16
+ def perform
17
+ build_root_operations
18
+ expand_abstract_boundaries
19
+ self
20
+ end
21
+
22
+ def operations
23
+ ops = @operations_by_grouping.values
24
+ ops.sort_by!(&:key)
25
+ ops
26
+ end
27
+
28
+ def to_h
29
+ { "ops" => operations.map(&:to_h) }
30
+ end
31
+
32
+ private
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 = [after_key, location, parent_type.graphql_name, *insertion_path].join("/")
41
+
42
+ if op = @operations_by_grouping[grouping]
43
+ op.selections += selection_set if selection_set
44
+ op.variables.merge!(variables) if variables
45
+ return op
46
+ end
47
+
48
+ type_conditional = !parent_type.kind.abstract? && parent_type != @supergraph.schema.query && parent_type != @supergraph.schema.mutation
49
+
50
+ @operations_by_grouping[grouping] = PlannerOperation.new(
51
+ key: parent_key,
52
+ after_key: after_key,
53
+ location: location,
54
+ parent_type: parent_type,
55
+ operation_type: operation_type,
56
+ insertion_path: insertion_path,
57
+ type_condition: type_conditional ? parent_type.graphql_name : nil,
58
+ selections: selection_set || [],
59
+ variables: variables || {},
60
+ boundary: boundary,
61
+ )
62
+ end
63
+
64
+ def build_root_operations
65
+ case @document.operation.operation_type
66
+ when "query"
67
+ # plan steps grouping all fields by location for async execution
68
+ parent_type = @supergraph.schema.query
69
+
70
+ selections_by_location = @document.operation.selections.each_with_object({}) do |node, memo|
71
+ locations = @supergraph.locations_by_type_and_field[parent_type.graphql_name][node.name] || SUPERGRAPH_LOCATIONS
72
+ memo[locations.last] ||= []
73
+ memo[locations.last] << node
74
+ end
75
+
76
+ selections_by_location.each do |location, selections|
77
+ add_operation(location: location, parent_type: parent_type, selections: selections)
78
+ end
79
+
80
+ when "mutation"
81
+ # plan steps grouping sequential fields by location for serial execution
82
+ parent_type = @supergraph.schema.mutation
83
+ location_groups = []
84
+
85
+ @document.operation.selections.reduce(nil) do |last_location, node|
86
+ location = @supergraph.locations_by_type_and_field[parent_type.graphql_name][node.name].last
87
+ if location != last_location
88
+ location_groups << {
89
+ location: location,
90
+ selections: [],
91
+ }
92
+ end
93
+ location_groups.last[:selections] << node
94
+ location
95
+ end
96
+
97
+ location_groups.reduce(0) do |after_key, group|
98
+ add_operation(
99
+ location: group[:location],
100
+ selections: group[:selections],
101
+ operation_type: "mutation",
102
+ parent_type: parent_type,
103
+ after_key: after_key
104
+ ).key
105
+ end
106
+
107
+ else
108
+ raise "Invalid operation type."
109
+ end
110
+ end
111
+
112
+ def extract_locale_selections(current_location, parent_type, input_selections, insertion_path, after_key)
113
+ remote_selections = []
114
+ selections_result = []
115
+ variables_result = {}
116
+ implements_fragments = false
117
+
118
+ if parent_type.kind.interface?
119
+ # fields of a merged interface may not belong to the interface at the local level,
120
+ # so these non-local interface fields get expanded into typed fragments for planning
121
+ local_interface_fields = @supergraph.fields_by_type_and_location[parent_type.graphql_name][current_location]
122
+ extended_selections = []
123
+
124
+ input_selections.reject! do |node|
125
+ if node.is_a?(GraphQL::Language::Nodes::Field) && !local_interface_fields.include?(node.name)
126
+ extended_selections << node
127
+ true
128
+ end
129
+ end
130
+
131
+ if extended_selections.any?
132
+ possible_types = Util.get_possible_types(@supergraph.schema, parent_type)
133
+ possible_types.each do |possible_type|
134
+ next if possible_type.kind.abstract? # ignore child interfaces
135
+ next unless @supergraph.locations_by_type[possible_type.graphql_name].include?(current_location)
136
+
137
+ type_name = GraphQL::Language::Nodes::TypeName.new(name: possible_type.graphql_name)
138
+ input_selections << GraphQL::Language::Nodes::InlineFragment.new(type: type_name, selections: extended_selections)
139
+ end
140
+ end
141
+ end
142
+
143
+ input_selections.each do |node|
144
+ case node
145
+ when GraphQL::Language::Nodes::Field
146
+ if node.name == "__typename"
147
+ selections_result << node
148
+ next
149
+ end
150
+
151
+ possible_locations = @supergraph.locations_by_type_and_field[parent_type.graphql_name][node.name] || SUPERGRAPH_LOCATIONS
152
+ unless possible_locations.include?(current_location)
153
+ remote_selections << node
154
+ next
155
+ end
156
+
157
+ field_type = Util.get_named_type_for_field_node(@supergraph.schema, parent_type, node)
158
+
159
+ extract_node_variables!(node, variables_result)
160
+
161
+ if Util.is_leaf_type?(field_type)
162
+ selections_result << node
163
+ else
164
+ expanded_path = [*insertion_path, node.alias || node.name]
165
+ selection_set, variables = extract_locale_selections(current_location, field_type, node.selections, expanded_path, after_key)
166
+ selections_result << node.merge(selections: selection_set)
167
+ variables_result.merge!(variables)
168
+ end
169
+
170
+ when GraphQL::Language::Nodes::InlineFragment
171
+ next unless @supergraph.locations_by_type[node.type.name].include?(current_location)
172
+
173
+ fragment_type = @supergraph.schema.types[node.type.name]
174
+ selection_set, variables = extract_locale_selections(current_location, fragment_type, node.selections, insertion_path, after_key)
175
+ selections_result << node.merge(selections: selection_set)
176
+ variables_result.merge!(variables)
177
+ implements_fragments = true
178
+
179
+ when GraphQL::Language::Nodes::FragmentSpread
180
+ fragment = @document.fragment_definitions[node.name]
181
+ next unless @supergraph.locations_by_type[fragment.type.name].include?(current_location)
182
+
183
+ fragment_type = @supergraph.schema.types[fragment.type.name]
184
+ selection_set, variables = extract_locale_selections(current_location, fragment_type, fragment.selections, insertion_path, after_key)
185
+ selections_result << GraphQL::Language::Nodes::InlineFragment.new(type: fragment.type, selections: selection_set)
186
+ variables_result.merge!(variables)
187
+ implements_fragments = true
188
+
189
+ else
190
+ raise "Unexpected node of type #{node.class.name} in selection set."
191
+ end
192
+ end
193
+
194
+ if remote_selections.any?
195
+ selection_set = build_child_operations(current_location, parent_type, remote_selections, insertion_path, after_key)
196
+ selections_result.concat(selection_set)
197
+ end
198
+
199
+ if parent_type.kind.abstract? || implements_fragments
200
+ selections_result << TYPENAME_NODE
201
+ end
202
+
203
+ return selections_result, variables_result
204
+ end
205
+
206
+ def build_child_operations(current_location, parent_type, input_selections, insertion_path, after_key)
207
+ parent_selections_result = []
208
+ selections_by_location = {}
209
+
210
+ # distribute unique fields among required locations
211
+ input_selections.reject! do |node|
212
+ possible_locations = @supergraph.locations_by_type_and_field[parent_type.graphql_name][node.name]
213
+ if possible_locations.length == 1
214
+ selections_by_location[possible_locations.first] ||= []
215
+ selections_by_location[possible_locations.first] << node
216
+ true
217
+ end
218
+ end
219
+
220
+ # distribute non-unique fields among available locations, preferring used locations
221
+ if input_selections.any?
222
+ # weight locations by number of needed fields available, prefer greater availability
223
+ location_weights = input_selections.each_with_object({}) do |node, memo|
224
+ possible_locations = @supergraph.locations_by_type_and_field[parent_type.graphql_name][node.name]
225
+ possible_locations.each do |location|
226
+ memo[location] ||= 0
227
+ memo[location] += 1
228
+ end
229
+ end
230
+
231
+ input_selections.each do |node|
232
+ possible_locations = @supergraph.locations_by_type_and_field[parent_type.graphql_name][node.name]
233
+
234
+ perfect_location_score = input_selections.length
235
+ preferred_location_score = 0
236
+ preferred_location = possible_locations.reduce(possible_locations.first) do |current_loc, candidate_loc|
237
+ score = selections_by_location[location] ? perfect_location_score : 0
238
+ score += location_weights.fetch(candidate_loc, 0)
239
+
240
+ if score > preferred_location_score
241
+ preferred_location_score = score
242
+ candidate_loc
243
+ else
244
+ current_loc
245
+ end
246
+ end
247
+
248
+ selections_by_location[preferred_location] ||= []
249
+ selections_by_location[preferred_location] << node
250
+ end
251
+ end
252
+
253
+ routes = @supergraph.route_type_to_locations(parent_type.graphql_name, current_location, selections_by_location.keys)
254
+ routes.values.each_with_object({}) do |route, memo|
255
+ route.reduce(nil) do |parent_op, boundary|
256
+ location = boundary["location"]
257
+ next memo[location] if memo[location]
258
+
259
+ child_op = memo[location] = add_operation(
260
+ location: location,
261
+ selections: selections_by_location[location],
262
+ parent_type: parent_type,
263
+ insertion_path: insertion_path,
264
+ boundary: boundary,
265
+ after_key: after_key,
266
+ )
267
+
268
+ foreign_key_node = GraphQL::Language::Nodes::Field.new(
269
+ alias: "_STITCH_#{boundary["selection"]}",
270
+ name: boundary["selection"]
271
+ )
272
+
273
+ if parent_op
274
+ parent_op.selections << foreign_key_node << TYPENAME_NODE
275
+ else
276
+ parent_selections_result << foreign_key_node << TYPENAME_NODE
277
+ end
278
+
279
+ child_op
280
+ end
281
+ end
282
+
283
+ parent_selections_result
284
+ end
285
+
286
+ def extract_node_variables!(node_with_args, variables={})
287
+ node_with_args.arguments.each_with_object(variables) do |argument, memo|
288
+ case argument.value
289
+ when GraphQL::Language::Nodes::InputObject
290
+ extract_node_variables!(argument.value, memo)
291
+ when GraphQL::Language::Nodes::VariableIdentifier
292
+ memo[argument.value.name] ||= @document.variable_definitions[argument.value.name]
293
+ end
294
+ end
295
+ end
296
+
297
+ # expand concrete type selections into typed fragments when sending to abstract boundaries
298
+ def expand_abstract_boundaries
299
+ @operations_by_grouping.each do |_grouping, op|
300
+ next unless op.boundary
301
+
302
+ boundary_type = @supergraph.schema.get_type(op.boundary["type_name"])
303
+ next unless boundary_type.kind.abstract?
304
+
305
+ unless op.parent_type == boundary_type
306
+ to_typed_selections = []
307
+ op.selections.reject! do |node|
308
+ if node.is_a?(GraphQL::Language::Nodes::Field)
309
+ to_typed_selections << node
310
+ true
311
+ end
312
+ end
313
+
314
+ if to_typed_selections.any?
315
+ type_name = GraphQL::Language::Nodes::TypeName.new(name: op.parent_type.graphql_name)
316
+ op.selections << GraphQL::Language::Nodes::InlineFragment.new(type: type_name, selections: to_typed_selections)
317
+ end
318
+ end
319
+ end
320
+ end
321
+ end
322
+ end
323
+ end