graph_weaver 0.4.6 → 0.5.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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1442 -0
  3. data/Gemfile.lock +23 -23
  4. data/README.md +115 -96
  5. data/docs/cassettes.md +93 -46
  6. data/docs/editors.md +82 -0
  7. data/docs/errors.md +34 -30
  8. data/docs/federation.md +521 -48
  9. data/docs/generated_modules.md +352 -137
  10. data/docs/getting_started.md +237 -67
  11. data/docs/logging.md +35 -6
  12. data/docs/real_world.md +21 -15
  13. data/docs/scalars.md +49 -154
  14. data/docs/testing.md +300 -52
  15. data/docs/transports.md +129 -30
  16. data/docs/upgrading.md +134 -0
  17. data/graph_weaver.gemspec +19 -3
  18. data/lib/generators/graph_weaver/install_generator.rb +259 -0
  19. data/lib/graph_weaver/client.rb +118 -111
  20. data/lib/graph_weaver/codegen/aliases.rb +223 -0
  21. data/lib/graph_weaver/codegen/emit.rb +283 -261
  22. data/lib/graph_weaver/codegen/enum_type.rb +25 -124
  23. data/lib/graph_weaver/codegen/nodes.rb +72 -13
  24. data/lib/graph_weaver/codegen/scalar_type.rb +69 -66
  25. data/lib/graph_weaver/codegen/type_helpers.rb +140 -0
  26. data/lib/graph_weaver/codegen.rb +672 -336
  27. data/lib/graph_weaver/errors.rb +154 -16
  28. data/lib/graph_weaver/federation.rb +259 -0
  29. data/lib/graph_weaver/hints.rb +9 -1
  30. data/lib/graph_weaver/in_process.rb +90 -0
  31. data/lib/graph_weaver/input_struct.rb +14 -2
  32. data/lib/graph_weaver/logging.rb +29 -0
  33. data/lib/graph_weaver/parsing.rb +59 -0
  34. data/lib/graph_weaver/query_module.rb +55 -0
  35. data/lib/graph_weaver/railtie.rb +23 -1
  36. data/lib/graph_weaver/representation.rb +74 -0
  37. data/lib/graph_weaver/response.rb +7 -0
  38. data/lib/graph_weaver/retry.rb +29 -8
  39. data/lib/graph_weaver/rspec.rb +220 -16
  40. data/lib/graph_weaver/schema_loader.rb +819 -60
  41. data/lib/graph_weaver/schemas.rb +48 -0
  42. data/lib/graph_weaver/selection.rb +43 -8
  43. data/lib/graph_weaver/tasks.rb +220 -22
  44. data/lib/graph_weaver/testing/cassette.rb +249 -81
  45. data/lib/graph_weaver/testing/coverage.rb +160 -0
  46. data/lib/graph_weaver/testing/failure.rb +14 -25
  47. data/lib/graph_weaver/testing/fake_client.rb +182 -22
  48. data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
  49. data/lib/graph_weaver/testing/router.rb +1452 -0
  50. data/lib/graph_weaver/testing/subgraphs.rb +134 -0
  51. data/lib/graph_weaver/testing.rb +209 -13
  52. data/lib/graph_weaver/transport/faraday.rb +28 -10
  53. data/lib/graph_weaver/transport/http.rb +99 -36
  54. data/lib/graph_weaver/transport.rb +67 -14
  55. data/lib/graph_weaver/version.rb +1 -1
  56. data/lib/graph_weaver.rb +416 -172
  57. metadata +25 -9
  58. data/CLAUDE.md +0 -69
  59. data/Makefile +0 -23
  60. data/NOTES.md +0 -182
  61. data/PLAN.md +0 -144
@@ -15,23 +15,41 @@ require "sorbet-runtime"
15
15
  # T::Enum), and typed variables (kwargs on execute). Subscriptions are
16
16
  # still open.
17
17
  #
18
- # Split across: codegen/scalar_type.rb (the scalar registry),
19
- # codegen/nodes.rb (the typed IR), codegen/emit.rb (source emission);
18
+ # Split across: codegen/scalar_type.rb and codegen/enum_type.rb (the leaf
19
+ # registries), codegen/type_helpers.rb (extend_type and the alias/mixin
20
+ # registry), codegen/nodes.rb (the typed IR), codegen/aliases.rb (resolving
21
+ # registered alias paths against a node), codegen/emit.rb (source emission);
20
22
  # this file holds the public API and the query walk.
21
23
  require_relative "hints"
22
24
  require_relative "input_struct"
25
+ require_relative "schema_loader"
26
+ require_relative "representation"
23
27
  require_relative "inflect"
24
28
  require_relative "selection"
25
29
  require_relative "codegen/enum_type"
26
30
  require_relative "codegen/scalar_type"
27
31
  require_relative "codegen/nodes"
32
+ require_relative "codegen/aliases"
28
33
  require_relative "codegen/emit"
29
34
 
30
35
  class GraphWeaver::Codegen
31
36
  include GraphWeaver::Inflect
32
37
  include GraphWeaver::Selection
38
+ include Aliases
33
39
  include Emit
34
40
 
41
+ # How a directory of GraphQL documents is scanned: both extensions the rest of
42
+ # the library already accepts, and nested — `queries/admin/pets.graphql` is
43
+ # how anyone with sixty queries organizes them.
44
+ DOCUMENT_GLOB = "**/*.{graphql,gql}"
45
+
46
+ # Why every registration takes the constant and never its name. register_enum
47
+ # and extend_type refuse a String for the same reason, so they say it in the
48
+ # same words — a reword has to reach both or one starts giving worse advice.
49
+ AUTOLOAD_HINT = "An autoloaded constant isn't resolvable while config/initializers " \
50
+ "run; register from a Rails.application.config.to_prepare block, which generation " \
51
+ "also runs first."
52
+
35
53
  attr_reader :module_name
36
54
 
37
55
  # A client is anything responding to `execute(query, variables:)`
@@ -44,31 +62,26 @@ class GraphWeaver::Codegen
44
62
  # defaults to the operation's
45
63
  # name; default_module_name: is parse's container-scoped fallback (file
46
64
  # generation stays strict — a checked-in file deserves a deliberate
47
- # name). scalars:/enums:/types: are client-scoped overlays consulted
48
- # before the global registries (ScalarType, EnumType, and arrays of
49
- # mixin modules, each keyed by GraphQL name). inputs_namespace: is the
50
- # shared-inputs workflow (see GraphWeaver.generate!): variable types
51
- # live once in that module and the query module aliases what it uses.
52
- # unions_namespace:/hoistable_unions: are the parallel shared-unions
53
- # workflow a whole-union field spread as a named shared fragment resolves
54
- # to one canonical type in that module (see used_union_names).
65
+ # name). types_namespace: is the shared-types workflow (see
66
+ # GraphWeaver.generate!): input types, schema enums, and unions hoisted from
67
+ # shared fragments live once in that module and the query module aliases what
68
+ # it uses. hoistable_unions: is the set of shared fragment names this query
69
+ # may hoist (spreads it inlined, minus any it shadows locally) a
70
+ # whole-union field spread as one of them resolves to a canonical type in the
71
+ # shared module (see used_union_names). path: is the file the query was read
72
+ # from, named alongside line and column in validation errors.
55
73
  def initialize(schema:, query:, module_name: nil, client: nil, default_module_name: nil,
56
- scalars: nil, enums: nil, types: nil, inputs_namespace: nil, unions_namespace: nil,
57
- hoistable_unions: nil)
74
+ types_namespace: nil, hoistable_unions: nil, path: nil)
58
75
  @schema = schema
59
76
  @query = query.strip
77
+ @path = path
60
78
  @module_name = module_name
61
79
  @default_module_name = default_module_name
62
- @scalars = scalars || {}
63
- @enums = enums || {}
64
- @types = types || {}
65
- @inputs_namespace = inputs_namespace
66
- # the shared-unions workflow: unions_namespace names the module hoisted
67
- # unions live in; hoistable_unions is the set of shared fragment names this
68
- # query may hoist (spreads it inlined, minus any it shadows locally)
69
- @unions_namespace = unions_namespace
80
+ @types_namespace = types_namespace
70
81
  @hoistable_unions = hoistable_unions || []
71
82
  @used_unions = []
83
+ # scalars this generation had no registration for (see report_untyped_scalars)
84
+ @untyped_scalars = []
72
85
  @client_const = self.class.client_const(client)
73
86
 
74
87
  if client && @client_const.nil?
@@ -88,8 +101,8 @@ class GraphWeaver::Codegen
88
101
  end
89
102
 
90
103
  # one-step shorthand
91
- def self.generate(schema:, query:, module_name: nil, client: nil, scalars: nil, enums: nil, types: nil)
92
- new(schema:, query:, module_name:, client:, scalars:, enums:, types:).generate
104
+ def self.generate(schema:, query:, module_name: nil, client: nil, path: nil)
105
+ new(schema:, query:, module_name:, client:, path:).generate
93
106
  end
94
107
 
95
108
  # Development convenience: generate + eval in one step, no build
@@ -97,11 +110,11 @@ class GraphWeaver::Codegen
97
110
  # file, but invisible to srb tc — use the build step for static typing.
98
111
  # Evaluates into an anonymous container, so no global constants leak;
99
112
  # client: additionally accepts a live object (set via .client=).
100
- def self.parse(schema:, query:, module_name: nil, client: nil, scalars: nil, enums: nil, types: nil)
113
+ def self.parse(schema:, query:, module_name: nil, client: nil, path: nil)
101
114
  client_const = client_const(client)
102
115
 
103
- codegen = new(schema:, query:, module_name:, client: client_const, default_module_name: "Query",
104
- scalars:, enums:, types:)
116
+ codegen = new(schema:, query:, module_name:, client: client_const, path:,
117
+ default_module_name: "Query")
105
118
  source = codegen.generate
106
119
 
107
120
  container = Module.new
@@ -114,92 +127,141 @@ class GraphWeaver::Codegen
114
127
  mod
115
128
  end
116
129
 
117
- # The schema-level variable types this query touched, by GraphQL
118
- # name the generate! workflow unions these across queries to decide
119
- # what the shared inputs module must contain.
130
+ # Every registry back to its starting state scalars (built-ins restored),
131
+ # enum mappings, and type helpers. The clean slate between tests, and the
132
+ # one call that stays right when a fourth kind of registration shows up.
133
+ def self.reset_registrations!
134
+ reset_scalars!
135
+ reset_enums!
136
+ reset_type_helpers!
137
+ self
138
+ end
139
+
140
+ # The schema-level types this walk touched, by GraphQL name — the generate!
141
+ # workflow unions these across queries to decide what the shared types module
142
+ # must contain.
120
143
  def variable_type_names
121
- { inputs: @variable_inputs.keys, enums: @variable_enums.keys, mapped: @mapped_enums.keys }
144
+ { inputs: @variable_inputs.keys, enums: @enums.keys, mapped: @mapped_enums.keys }
122
145
  end
123
146
 
124
147
  # The shared union fragments this query hoisted, by name — the generate!
125
- # workflow unions these across queries to decide what the shared unions
126
- # module must contain.
148
+ # workflow unions these across queries to decide what the shared types module
149
+ # must contain.
127
150
  def used_union_names = @used_unions.dup
128
151
 
129
- # The shared inputs artifact: the named input/enum types plus
130
- # everything they transitively reference emitted once per schema as
131
- # a manifest (inputs.rb) plus one file per type under inputs/, so a
132
- # schema migration diffs only the types it touched. Returns
133
- # { relative_filename => source }.
134
- def self.generate_inputs(schema:, module_name:, input_types: [], enum_types: [],
135
- scalars: nil, enums: nil, types: nil)
136
- codegen = new(schema:, query: "", module_name:, scalars:, enums:, types:)
137
- codegen.generate_inputs(input_types, enum_types)
152
+ # The shared types artifact: every type a schema shares across query modules,
153
+ # emitted once as a manifest (types.rb) plus one file per type under types/,
154
+ # so a schema migration diffs only the types it touched. Returns
155
+ # { relative_filename => source }. Three kinds live here:
156
+ #
157
+ # - inputs: the named input types, plus everything they transitively
158
+ # reference (nested types stay unaliased — the query module names only the
159
+ # variable roots);
160
+ # - enums: one Ruby type per schema enum a query touched — a generated
161
+ # T::Enum, or the wire tables for one mapped onto an app enum
162
+ # (register_enum) — so a value read out of one query's result hands
163
+ # straight back into another's variable;
164
+ # - unions: each named shared fragment a query spread as a whole union field,
165
+ # so the same union across queries is one Ruby type family. `fragments` is
166
+ # the loaded shared-fragment table (nested spreads resolve through it).
167
+ #
168
+ # Unions are built first: a hoisted fragment's own selections are the one
169
+ # place a query walk never reaches, so the enums they touch are only known
170
+ # once the fragments are built.
171
+ def generate_types(inputs:, enums:, unions:, fragments:)
172
+ validate_module_name!("types module name")
173
+ reset_walk_state!
174
+ # nested spreads inside a shared fragment resolve through the whole table
175
+ @fragments = fragments
176
+
177
+ union_nodes = unions.uniq.sort.map { |name| hoisted_union(fragments, name) }
178
+ inputs.sort.each { |name| input_node(@schema.get_type(name)) }
179
+ enums.uniq.sort.each { |name| variable_core(@schema.get_type(name)) }
180
+ check_shared_collisions!(unions)
181
+
182
+ emit_types_files(union_nodes).tap { report_untyped_scalars }
138
183
  end
139
184
 
140
- def generate_inputs(input_types, enum_types)
141
- unless @module_name&.match?(/\A[A-Z]\w*(::[A-Z]\w*)*\z/)
142
- raise ArgumentError, "inputs module name must be a constant name, got #{@module_name.inspect}"
185
+ # module-level constants every generated query module defines — a shared
186
+ # type aliased to one of these would clash at load
187
+ MODULE_RESERVED = %w[Result QUERY Representations].to_set.freeze
188
+
189
+ # One hoisted shared fragment, built against the schema and named for the
190
+ # fragment rather than the field that spread it.
191
+ def hoisted_union(fragments, name)
192
+ class_name = camelize(name)
193
+ # the query module aliases <class_name> = <shared module>::<class_name>; a
194
+ # name that camelizes to a generated module-level constant (the Result
195
+ # struct, the QUERY heredoc) would collide with that alias at load
196
+ if MODULE_RESERVED.include?(class_name)
197
+ raise GraphWeaver::Error,
198
+ "shared fragment #{name.inspect} hoists to #{class_name}, which collides with a generated constant — rename the fragment"
143
199
  end
144
200
 
145
- @variable_enums = {}
146
- @variable_inputs = {}
147
- @mapped_enums = {}
148
- @requires = []
149
-
150
- enum_types.sort.each { |name| variable_core(@schema.get_type(name)) }
151
- input_types.sort.each { |name| input_node(@schema.get_type(name)) }
201
+ fragment = fragments.fetch(name)
202
+ type = @schema.get_type(fragment.type.name)
203
+ members = union_members(type, fragment.selections)
204
+ UnionNode.new(class_name, members, catch_all_member(type, fragment.selections, members))
205
+ end
206
+ private :hoisted_union
207
+
208
+ # Schema type names are unique, so an input and an enum can never land on the
209
+ # same name — but a hoisted union is named for its FRAGMENT, which the schema
210
+ # knows nothing about. One shared module means one namespace, so a fragment
211
+ # named after a type it doesn't describe has to refuse rather than overwrite.
212
+ def check_shared_collisions!(names)
213
+ taken = {}
214
+ @enums.each { |graphql_name, node| taken[node.class_name] = "the schema enum #{graphql_name}" }
215
+ @mapped_enums.each_key { |graphql_name| taken[camelize(graphql_name)] = "the schema enum #{graphql_name}" }
216
+ @variable_inputs.each { |graphql_name, node| taken[node.class_name] = "the input type #{graphql_name}" }
217
+
218
+ names.each do |name|
219
+ class_name = camelize(name)
220
+ claim = taken[class_name] or next
152
221
 
153
- emit_inputs_files
222
+ raise GraphWeaver::Error,
223
+ "shared fragment #{name.inspect} hoists to #{@module_name}::#{class_name}, " \
224
+ "where #{claim} already generates — rename the fragment"
225
+ end
154
226
  end
227
+ private :check_shared_collisions!
155
228
 
156
- # The shared unions artifact: each named shared fragment a query hoisted,
157
- # built once against the schema as <module_name>::<Name>, so the same union
158
- # across queries resolves to one Ruby type family. `fragments` is the loaded
159
- # shared-fragment table (nested spreads resolve through it); `names` the
160
- # fragments to build. Returns { "unions.rb" => source }.
161
- def self.generate_unions(schema:, module_name:, fragments:, names:,
162
- scalars: nil, enums: nil, types: nil)
163
- codegen = new(schema:, query: "", module_name:, scalars:, enums:, types:)
164
- codegen.generate_unions(fragments, names)
229
+ # per-run walk state, cleared so one Codegen can generate more than once
230
+ def reset_walk_state!
231
+ @enums = {}
232
+ @variable_inputs = {}
233
+ @mapped_enums = {}
234
+ @used_unions = []
235
+ # requires the generated file needs (custom scalars, enum mappings,
236
+ # type helpers all contribute)
237
+ @requires = []
165
238
  end
239
+ private :reset_walk_state!
166
240
 
167
- def generate_unions(fragments, names)
168
- unless @module_name&.match?(/\A[A-Z]\w*(::[A-Z]\w*)*\z/)
169
- raise ArgumentError, "unions module name must be a constant name, got #{@module_name.inspect}"
170
- end
241
+ # generated source is eval'd by parse — never let a name inject code
242
+ CONSTANT_NAME = /\A[A-Z]\w*(::[A-Z]\w*)*\z/
171
243
 
172
- @requires = []
173
- @mapped_enums = {}
174
- # nested spreads inside a shared fragment resolve through the whole table
175
- @fragments = fragments
244
+ def validate_module_name!(subject)
245
+ return if @module_name&.match?(CONSTANT_NAME)
176
246
 
177
- unions = names.uniq.sort.map do |name|
178
- class_name = camelize(name)
179
- # the query module aliases <class_name> = <unions module>::<class_name>;
180
- # a name that camelizes to a generated module-level constant (the Result
181
- # struct, the QUERY heredoc) would collide with that alias at load
182
- if HOISTED_UNION_RESERVED.include?(class_name)
183
- raise GraphWeaver::Error,
184
- "shared fragment #{name.inspect} hoists to #{class_name}, which collides with a generated constant — rename the fragment"
185
- end
186
- fragment = fragments.fetch(name)
187
- type = @schema.get_type(fragment.type.name)
188
- UnionNode.new(class_name, union_members(type, fragment.selections))
189
- end
247
+ problem = "#{subject} must be a constant name, got #{@module_name.inspect}"
248
+ # An explicit module_name: is an argument wrong on its face. A derived one
249
+ # is a verdict on a FILE a numeric prefix (01_home.graphql) is the usual
250
+ # way in so it names the file, says the fix is a rename, and brands so
251
+ # `rake graph_weaver:generate` aborts on it instead of burying it under a
252
+ # backtrace through codegen.
253
+ raise ArgumentError, problem unless @path
190
254
 
191
- emit_unions_file(unions)
255
+ raise GraphWeaver::Error, "#{@path}: #{problem} — it comes from the file name, so rename the " \
256
+ "file to one a constant can spell (a letter first, then letters, digits or underscores)"
192
257
  end
193
-
194
- # module-level constants every generated query module defines — a hoisted
195
- # union aliased to one of these would clash at load
196
- HOISTED_UNION_RESERVED = %w[Result QUERY].to_set.freeze
258
+ private :validate_module_name!
197
259
 
198
260
  VarDef = Struct.new(:kwarg, :wire, :node, :required)
199
261
 
200
- # Names that cannot appear bare in generated Ruby: keywords aren't
201
- # valid identifiers, and the struct's own generated methods would be
202
- # silently replaced by a same-named prop reader.
262
+ # Names generated Ruby can't spell bare as a kwarg, a local, or a method
263
+ # name. As a prop they're fine (`const :next`), since a prop is only ever
264
+ # read off a receiver.
203
265
  RUBY_KEYWORDS = %w[
204
266
  alias and begin break case class def defined? do else elsif end
205
267
  ensure false for if in module next nil not or redo rescue retry
@@ -207,6 +269,17 @@ class GraphWeaver::Codegen
207
269
  BEGIN END __FILE__ __LINE__ __ENCODING__
208
270
  ].to_set.freeze
209
271
  GENERATED_METHODS = %w[serialize to_h].to_set.freeze
272
+ # Names the generated `execute` body owns: the per-call client kwarg and the
273
+ # variables hash it builds. A GraphQL variable by either name redeclares one
274
+ # — `def self.execute(client:, client: nil)` doesn't even parse. No legal
275
+ # Ruby local is unreachable by a GraphQL variable name, so this is a guard
276
+ # rather than a rename.
277
+ RESERVED_KWARGS = %w[client variables].to_set.freeze
278
+ # Every method a struct instance already answers: T::Props refuses to redefine
279
+ # those (`class`, `hash`, `send`, `to_s`), so the generated file would raise
280
+ # ArgumentError at require time. Derived rather than listed, so it tracks
281
+ # whatever the Ruby and sorbet-runtime in play actually define.
282
+ STRUCT_METHODS = (GENERATED_METHODS + T::Struct.instance_methods.map(&:to_s)).freeze
210
283
 
211
284
  def generate
212
285
  begin
@@ -214,21 +287,14 @@ class GraphWeaver::Codegen
214
287
  rescue GraphQL::ParseError => e
215
288
  # unparseable queries wrap like invalid ones — everything raised
216
289
  # here descends from GraphWeaver::Error
217
- raise GraphWeaver::ValidationError.new([{ message: e.message, line: nil, column: nil }])
290
+ raise GraphWeaver::ValidationError.new([detail(e.message, e.line, e.col)])
218
291
  end
219
292
  if errors.any?
220
293
  raise GraphWeaver::ValidationError.new(errors.map { |e| validation_detail(e) })
221
294
  end
222
295
 
223
296
  validate_registrations!
224
-
225
- @variable_enums = {}
226
- @variable_inputs = {}
227
- @mapped_enums = {}
228
- @used_unions = []
229
- # requires the generated file needs (custom scalars, enum mappings,
230
- # type helpers all contribute)
231
- @requires = []
297
+ reset_walk_state!
232
298
 
233
299
  operation = load_operation(@query)
234
300
  root_type = operation_root_type(operation)
@@ -238,15 +304,42 @@ class GraphWeaver::Codegen
238
304
  raise ArgumentError, "module_name: required for anonymous operations"
239
305
  end
240
306
 
241
- # generated source is eval'd by parse — never let a name inject code
242
- unless @module_name.match?(/\A[A-Z]\w*(::[A-Z]\w*)*\z/)
243
- raise ArgumentError, "module_name: must be a constant name, got #{@module_name.inspect}"
244
- end
307
+ validate_module_name!("module_name:")
308
+
309
+ variables = build_variables(operation)
310
+ root = object_node(root_type, operation.selections, "Result")
311
+
312
+ # An anonymous operation takes the module's name — declared in the document
313
+ # AND sent as operationName, which have to agree (a server rejects an
314
+ # operationName the document doesn't declare). The conventional .graphql
315
+ # file names nothing, so without this every trace arrives anonymous.
316
+ operation_name = operation.name || @module_name.split("::").last
317
+ @query = declare_operation_name(operation, operation_name) unless operation.name
318
+
319
+ emit_module(root, variables, representation_nodes(operation, root_type), operation_name)
320
+ .tap { report_untyped_scalars }
321
+ end
322
+
323
+ private
245
324
 
325
+ # Insert `name` into the operation's own declaration, leaving the rest of the
326
+ # document exactly as written — re-printing the AST would reformat the query
327
+ # the reader reviews. The module name is already constrained to
328
+ # /[A-Z]\w*(::[A-Z]\w*)*/, so its last segment is always a legal GraphQL name.
329
+ def declare_operation_name(operation, name)
330
+ at = @query.lines.first(operation.line - 1).sum(&:length) + operation.col - 1
331
+ keyword = @query[at..].to_s[/\A(?:query|mutation|subscription)\b/]
332
+ return "#{@query[0, at]}query #{name} #{@query[at..]}" unless keyword # `{ ... }` shorthand
333
+
334
+ "#{@query[0, at + keyword.length]} #{name}#{@query[(at + keyword.length)..]}"
335
+ end
336
+
337
+ # The operation's variables as execute's kwarg surface: one VarDef each,
338
+ # typed from the AST. A variable is optional when nullable or defaulted —
339
+ # optional kwargs default to nil and are omitted from the wire.
340
+ def build_variables(operation)
246
341
  variables = operation.variables.map do |var|
247
342
  node = ast_type_ref(var.type)
248
- # a variable is optional when nullable or defaulted; optional kwargs
249
- # default to nil and are omitted from the wire
250
343
  required = node.non_null? && var.default_value.nil?
251
344
  kwarg = underscore(var.name)
252
345
  # kwargs are declared and forwarded bare in generated source
@@ -255,6 +348,11 @@ class GraphWeaver::Codegen
255
348
  "variable $#{var.name} would become the kwarg '#{kwarg}:', which generated code can't declare " \
256
349
  "(a Ruby keyword) — rename the variable"
257
350
  end
351
+ if RESERVED_KWARGS.include?(kwarg)
352
+ raise GraphWeaver::Error,
353
+ "variable $#{var.name} would become the kwarg '#{kwarg}:', which generated execute already " \
354
+ "uses — rename the variable (query($#{var.name}Id: ...))"
355
+ end
258
356
  VarDef.new(kwarg, var.name, node, required)
259
357
  end
260
358
 
@@ -267,19 +365,132 @@ class GraphWeaver::Codegen
267
365
  "variables #{wire} both map to the kwarg '#{collision.first}:' — rename one"
268
366
  end
269
367
 
270
- root = object_node(root_type, operation.selections, "Result")
368
+ variables
369
+ end
370
+
371
+ # Builders for the entity types this query's representation-taking fields
372
+ # can return. The hook is the schema, not the field name: the subgraph spec
373
+ # types a representation as `_Any`, so a field taking one is asking for
374
+ # entity references, and the entity types are the @key'd members its
375
+ # selection names. Query-driven like everything else — a subgraph with
376
+ # fifty entities emits builders only for the ones the query reaches.
377
+ def representation_nodes(operation, root_type)
378
+ nodes = entity_types(operation, root_type).filter_map { |entity| representation_node(entity) }
271
379
 
272
- emit_module(root, variables)
380
+ collision = nodes.group_by(&:method_name).find { |_, group| group.size > 1 }
381
+ if collision
382
+ types = collision.last.map(&:graphql_type).join(" and ")
383
+ raise GraphWeaver::Error,
384
+ "entities #{types} both build Representations.#{collision.first} — rename one, or drop it from the selection"
385
+ end
386
+
387
+ nodes
273
388
  end
274
389
 
275
- private
390
+ # The types a representation-taking field's selection names. `_entities` is
391
+ # a root field and the spec defines it nowhere else, so this looks no deeper.
392
+ def entity_types(operation, root_type)
393
+ gather_conditional(root_type, operation.selections).each_value.flat_map { |occurrences|
394
+ fields = occurrences.map(&:first)
395
+ definition = @schema.get_field(root_type.graphql_name, fields.first.name)
396
+ next [] unless definition && representation_field?(definition)
397
+
398
+ core = definition.type.unwrap
399
+ next [] unless %w[UNION INTERFACE].include?(core.kind.name)
400
+
401
+ selected_members(core, fields.flat_map(&:selections))
402
+ }.uniq(&:graphql_name)
403
+ end
404
+
405
+ # The subgraph spec's representation scalar. A field taking one is the
406
+ # entity resolver, whatever it's called.
407
+ REPRESENTATION_SCALAR = "_Any"
408
+
409
+ def representation_field?(definition)
410
+ definition.arguments.each_value.any? { |argument| argument.type.unwrap.graphql_name == REPRESENTATION_SCALAR }
411
+ end
412
+
413
+ # A `@key` this subgraph resolves. Matched by local name, since a fed-2
414
+ # subgraph linking the spec under a namespace applies @federation__key;
415
+ # `resolvable: false` declares a key the subgraph explicitly does NOT
416
+ # answer for, so it can't stand behind a representation.
417
+ def resolvable_keys(type)
418
+ return [] unless type.respond_to?(:directives)
419
+
420
+ type.directives.filter_map do |directive|
421
+ name = directive.graphql_name
422
+ next unless name == "key" || name.end_with?("__key")
423
+
424
+ arguments = directive.arguments.keyword_arguments
425
+ next if arguments[:resolvable] == false
426
+
427
+ arguments[:fields]&.to_s
428
+ end
429
+ end
430
+
431
+ # An entity's builder, or nil when the type isn't one (no resolvable @key).
432
+ def representation_node(entity)
433
+ key_fields = resolvable_keys(entity)
434
+ key_sets = key_fields.map { |fields| key_paths(entity, fields) }
435
+ return if key_sets.empty?
436
+
437
+ method_name = underscore(entity.graphql_name)
438
+ if RUBY_KEYWORDS.include?(method_name)
439
+ raise GraphWeaver::Error,
440
+ "entity #{entity.graphql_name} would build Representations.#{method_name}, which generated code can't declare (a Ruby keyword)"
441
+ end
442
+
443
+ RepresentationNode.new(method_name, entity.graphql_name, key_fields, key_sets,
444
+ key_params(entity, key_sets, required: key_sets.one?))
445
+ end
446
+
447
+ # A @key field set is a GraphQL selection set — "upc sku", or a nested
448
+ # "id organization { id }" — flattened to the dotted leaf paths the wire
449
+ # hash needs. The same reading the routing table does of the same syntax,
450
+ # so a supergraph and a subgraph SDL can't disagree about one key.
451
+ def key_paths(entity, fields)
452
+ GraphWeaver::SchemaLoader::RoutingTable.parse_field_set(fields)
453
+ rescue GraphQL::ParseError => e
454
+ raise GraphWeaver::Error, "#{entity.graphql_name} @key(fields: #{fields.inspect}) isn't a selection set: #{e.message}"
455
+ end
456
+
457
+ # The kwargs a builder takes: every key set's top-level field, once. Typed
458
+ # from the schema — a leaf key field gets its registered scalar's Ruby
459
+ # type, a nested one an open Hash whose shape the runtime checks.
460
+ def key_params(entity, key_sets, required:)
461
+ key_sets.flatten.map { |path| path.split(".").first }.uniq.map do |name|
462
+ field = @schema.get_field(entity.graphql_name, name)
463
+ unless field
464
+ raise GraphWeaver::Error, "#{entity.graphql_name} @key names #{name.inspect}, which the type doesn't declare"
465
+ end
466
+
467
+ kwarg = underscore(name)
468
+ if RUBY_KEYWORDS.include?(kwarg)
469
+ raise GraphWeaver::Error,
470
+ "#{entity.graphql_name} @key field #{name.inspect} would become the kwarg '#{kwarg}:', " \
471
+ "which generated code can't declare (a Ruby keyword)"
472
+ end
473
+
474
+ core = field.type.unwrap
475
+ if core.kind.name == "SCALAR"
476
+ node = scalar_node(core.graphql_name, "#{entity.graphql_name}.#{name}")
477
+ type = required ? node.bare_type : node.prop_type
478
+ value = node.serialize_identity? ? kwarg : "#{kwarg}&.then { |v1| #{node.serialize("v1", 2)} }"
479
+ else
480
+ # a nested key set — or an enum/composite one — passes through as an
481
+ # open hash, narrowed to the declared sub-paths by the runtime
482
+ type = "T::Hash[T.untyped, T.untyped]"
483
+ type = "T.nilable(#{type})" unless required
484
+ value = kwarg
485
+ end
276
486
 
277
- # A client-scoped registration names a type in a specific schema — a
278
- # typo'd name would otherwise be a silent no-op, the most confusing
279
- # failure mode available. Called eagerly by Client#register_* when the
280
- # schema is already loaded, and again at generation (covers clients
281
- # whose schema introspects lazily). Global registrations skip this:
282
- # they may target a different client's server.
487
+ RepresentationNode::Param.new(kwarg, name, type, value, required)
488
+ end
489
+ end
490
+
491
+ # A registration names a type in a specific schema a typo'd name would
492
+ # otherwise be a silent no-op, the most confusing failure mode available.
493
+ # Called at generation for every registration in play.
283
494
  def self.validate_registration!(schema, kind, name)
284
495
  # register_scalar("Type.field", ...) overrides one field's scalar — validate
285
496
  # the field exists and is a scalar, not that a type named "Type.field" exists.
@@ -314,13 +525,19 @@ class GraphWeaver::Codegen
314
525
  # map — reusable fragments a query can spread. Fragment files hold only
315
526
  # fragments (no operations); names are unique across them.
316
527
  def self.load_fragments(paths)
317
- Array(paths).flat_map { |dir| Dir[File.join(dir, "*.graphql")].sort }.each_with_object({}) do |file, out|
318
- doc = GraphQL.parse(File.read(file))
528
+ source = {} # fragment name => the file that defined it, for the collision message
529
+
530
+ Array(paths).flat_map { |dir| Dir[File.join(dir, DOCUMENT_GLOB)].sort }.each_with_object({}) do |file, out|
531
+ doc = parse_document(File.read(file), file)
319
532
  if doc.definitions.grep(GraphQL::Language::Nodes::OperationDefinition).any?
320
533
  raise GraphWeaver::Error, "#{file}: fragment files define only fragments, no operations"
321
534
  end
322
535
  doc.definitions.grep(GraphQL::Language::Nodes::FragmentDefinition).each do |frag|
323
- raise GraphWeaver::Error, "duplicate shared fragment '#{frag.name}' (#{file})" if out.key?(frag.name)
536
+ if (earlier = source[frag.name])
537
+ raise GraphWeaver::Error,
538
+ "duplicate shared fragment '#{frag.name}' — defined in #{earlier} and #{file}; rename one"
539
+ end
540
+ source[frag.name] = file
324
541
  out[frag.name] = frag
325
542
  end
326
543
  end
@@ -330,18 +547,34 @@ class GraphWeaver::Codegen
330
547
  # shadows with a local definition of the same name — the names
331
548
  # inline_fragments appends, and the set the generate! workflow may hoist
332
549
  # when they sit on a whole-union field.
333
- def self.shared_fragment_spreads(query, shared)
550
+ def self.shared_fragment_spreads(query, shared, path = nil)
551
+ # parsed even with nothing to spread: this is the first look at the document
552
+ # on the generate! path, so it's where a syntax error gets branded and
553
+ # pinned to the file it came from
554
+ doc = parse_document(query, path)
334
555
  return [] if shared.empty?
335
556
 
336
- doc = GraphQL.parse(query)
337
557
  local = doc.definitions.grep(GraphQL::Language::Nodes::FragmentDefinition).map(&:name)
338
558
  reachable_fragments(fragment_spreads(doc.definitions), shared, local)
339
559
  end
340
560
 
561
+ # Parse a GraphQL document, branding graphql-ruby's ParseError under the
562
+ # umbrella and naming the file it came from — its own location is a line and
563
+ # column in a document the caller never sees. This runs on the generate! path
564
+ # BEFORE Codegen#generate's rescue, so it needs its own guard.
565
+ def self.parse_document(query, path = nil)
566
+ GraphQL.parse(query)
567
+ rescue GraphQL::ParseError => e
568
+ prefix = [path, e.line, e.col].compact.join(":")
569
+ raise GraphWeaver::ValidationError.new(
570
+ [{ message: prefix.empty? ? e.message : "#{prefix} #{e.message}", line: e.line, column: e.col }],
571
+ )
572
+ end
573
+
341
574
  # Append the shared fragments a query spreads (transitively) to its source, so
342
575
  # the sent query is self-contained. Unused shared fragments are left out.
343
- def self.inline_fragments(query, shared)
344
- used = shared_fragment_spreads(query, shared)
576
+ def self.inline_fragments(query, shared, path = nil)
577
+ used = shared_fragment_spreads(query, shared, path)
345
578
  return query if used.empty?
346
579
 
347
580
  "#{query.rstrip}\n\n#{used.sort.map { |name| shared.fetch(name).to_query_string }.join("\n\n")}\n"
@@ -377,15 +610,52 @@ class GraphWeaver::Codegen
377
610
  # source location, so ValidationError#errors is inspectable.
378
611
  def validation_detail(error)
379
612
  loc = (error.to_h["locations"]&.first if error.respond_to?(:to_h))
380
- { message: error.message, line: loc && loc["line"], column: loc && loc["column"] }
613
+ detail(error.message, loc && loc["line"], loc && loc["column"])
614
+ end
615
+
616
+ # One ValidationError entry, its message prefixed "file:line:col" like a
617
+ # compiler — the position is captured either way, and without it a project
618
+ # with thirty query files leaves the reader hunting for the typo.
619
+ def detail(message, line, column)
620
+ prefix = [@path, line, column].compact.join(":")
621
+ { message: prefix.empty? ? message : "#{prefix} #{message}", line:, column: }
381
622
  end
382
623
 
624
+ # Every registration this generation could consult. The built-in scalars are
625
+ # pre-registered entries in the same table rather than user intent, so
626
+ # they're exempt — a schema with no Date scalar is not a mistake.
383
627
  def validate_registrations!
384
- { "enum" => @enums, "scalar" => @scalars, "type" => @types }.each do |kind, registry|
628
+ {
629
+ "enum" => GraphWeaver::Codegen.enum_registry,
630
+ "scalar" => GraphWeaver::Codegen.scalar_registry.except(*BUILTIN_SCALARS),
631
+ "type" => GraphWeaver::Codegen.type_registry,
632
+ }.each do |kind, registry|
385
633
  registry.each_key { |name| self.class.validate_registration!(@schema, kind, name) }
386
634
  end
387
635
  end
388
636
 
637
+ # The @include/@skip a fragment carries applies to what it guards, so it has
638
+ # to travel with the selections into the child rather than being spent on the
639
+ # key. Re-wrapping in a guarded inline fragment says that in the vocabulary
640
+ # the walk already speaks, which is what keeps dispatchable_typename? and the
641
+ # __typename refusal honest for free.
642
+ GUARDED = [GraphQL::Language::Nodes::Directive.new(name: "include")].freeze
643
+ private_constant :GUARDED
644
+
645
+ # A key's merged sub-selections, keeping the conditionality of the occurrence
646
+ # each child came from: `pets @include(if:) { name } pets { species }` answers
647
+ # with `name` only when that occurrence ran, so those children have to admit
648
+ # nil. One occurrence needs none of this — the key is there exactly when it
649
+ # ran, and its own prop already says so.
650
+ def merged_selections(occurrences)
651
+ return occurrences.first.first.selections if occurrences.one?
652
+
653
+ occurrences.flat_map do |node, conditional|
654
+ next node.selections unless conditional || conditional?(node)
655
+
656
+ [GraphQL::Language::Nodes::InlineFragment.new(type: nil, directives: GUARDED, selections: node.selections)]
657
+ end
658
+ end
389
659
 
390
660
  def object_node(type, selections, class_name)
391
661
  node = ObjectNode.new(class_name)
@@ -396,70 +666,80 @@ class GraphWeaver::Codegen
396
666
  # same union selected two ways (unblockOptions vs selectedOption) shares
397
667
  # one Ruby type, so consumers get one exhaustive `case ... T.absurd`.
398
668
  union_cache = {}
669
+ props = {}
399
670
 
400
- gather(type, selections).each do |key, field_nodes|
671
+ gather_conditional(type, selections).each do |key, occurrences|
672
+ field_nodes = occurrences.map(&:first)
401
673
  field_name = field_nodes.first.name
402
674
  prop = underscore(key)
675
+ check_output_prop!(type, key, prop, props)
403
676
 
404
677
  child = if field_name == "__typename"
405
678
  NonNull.new(scalar_node("String"))
406
679
  else
407
680
  field_type = @schema.get_field(type.graphql_name, field_name).type
408
- sub_selections = field_nodes.flat_map(&:selections)
681
+ sub_selections = merged_selections(occurrences)
409
682
 
410
- case (core = unwrap(field_type)).kind.name
683
+ case (core = field_type.unwrap).kind.name
411
684
  when "OBJECT"
412
- name = pick_name(core.graphql_name, key, taken)
685
+ name = pick_name(key, taken)
413
686
  type_ref(field_type) { object_node(core, sub_selections, name) }
414
687
  when "UNION", "INTERFACE"
415
688
  conditions = concrete_conditions(core, sub_selections)
416
689
  bare = bare_fields(sub_selections) - ["__typename"]
417
690
 
418
- if conditions.empty? && core.kind.name == "INTERFACE"
419
- # interface-level fields only — every member shares them, so
420
- # one struct suffices and no __typename dispatch is needed
421
- name = pick_name(core.graphql_name, key, taken)
691
+ if conditions.empty?
692
+ # abstract-level fields only — every member shares them, so one
693
+ # struct suffices and no __typename dispatch is needed (for a
694
+ # union that selection can only be __typename)
695
+ name = pick_name(key, taken)
422
696
  type_ref(field_type) { object_node(core, sub_selections, name) }
423
697
  elsif conditions.size == 1 && bare.empty? &&
424
698
  (member = @schema.get_type(conditions.first)).kind.name == "OBJECT"
425
699
  # a single `... on X` condition: narrow to X's struct — nil
426
700
  # when the runtime type doesn't match (narrowing filters).
427
- # Narrowing reads "no fields came back" as "type didn't
428
- # match", so a fragment whose every field hides behind
429
- # @skip/@include would make a real match indistinguishable
430
- # from a miss ({} either way) — refuse rather than guess.
431
- unless unconditional_field?(member, sub_selections)
701
+ # With `__typename` selected the match is read off the tag;
702
+ # without one there is nothing to read but emptiness, and a
703
+ # fragment whose every field hides behind @skip/@include would
704
+ # make a real match indistinguishable from a miss ({} either
705
+ # way) — refuse rather than guess.
706
+ tag = member.graphql_name if dispatchable_typename?(core, sub_selections)
707
+ unless tag || unconditional_field?(member, sub_selections)
432
708
  raise GraphWeaver::Error,
433
709
  "narrowed `... on #{member.graphql_name}` needs at least one field not under " \
434
- "@skip/@include — an all-conditional selection makes a match indistinguishable from nil"
710
+ "@skip/@include (or a `__typename` to match on) — an all-conditional selection " \
711
+ "makes a match indistinguishable from nil"
435
712
  end
436
713
 
437
- name = pick_name(member.graphql_name, key, taken)
438
- nilable_type_ref(field_type) { NarrowedNode.new(object_node(member, sub_selections, name)) }
439
- elsif @unions_namespace && (frag = lone_shared_spread(sub_selections)) &&
714
+ name = pick_name(key, taken)
715
+ nilable_type_ref(field_type) { NarrowedNode.new(object_node(member, sub_selections, name), typename: tag) }
716
+ elsif @types_namespace && (frag = lone_shared_spread(sub_selections)) &&
440
717
  @hoistable_unions.include?(frag)
441
718
  # a whole-union field spread as a named shared fragment: hoist to
442
- # the shared unions module so the same union across queries is one
719
+ # the shared types module so the same union across queries is one
443
720
  # Ruby type family (one exhaustive `case ... T.absurd`).
444
721
  @used_unions << frag unless @used_unions.include?(frag)
445
722
  ref = UnionRefNode.new(camelize(frag))
446
723
  type_ref(field_type) { ref }
447
724
  else
448
725
  members = union_members(core, sub_selections)
449
- # reuse an identical sibling union (pick_name/name only on a miss)
450
- union = (union_cache[union_signature(members)] ||=
451
- UnionNode.new(pick_name(core.graphql_name, key, taken), members))
726
+ catch_all = catch_all_member(core, sub_selections, members)
727
+ # reuse an identical sibling union the shared type takes the
728
+ # first of the sharing keys alphabetically, not in walk order
729
+ signature = union_signature(members, catch_all)
730
+ union = union_cache[signature]
731
+ if union
732
+ rename_union(union, key, taken) if camelize(key) < union.class_name
733
+ else
734
+ union = union_cache[signature] = UnionNode.new(pick_name(key, taken), members, catch_all)
735
+ end
452
736
  type_ref(field_type) { union }
453
737
  end
454
738
  when "ENUM"
455
- if (mapped = mapped_enum_node(core))
456
- type_ref(field_type) { mapped }
457
- else
458
- name = pick_name(core.graphql_name, key, taken)
459
- # sorted so output is deterministic across schema sources
460
- # (SDL round-trips reorder values alphabetically)
461
- type_ref(field_type) { EnumNode.new(name, core.values.keys.sort) }
462
- end
739
+ # one schema enum is one Ruby type: module-level, named for the enum,
740
+ # shared by every result field and variable that reaches it (and, on
741
+ # the generate! path, by every query module — see types_namespace)
742
+ type_ref(field_type) { variable_core(core) }
463
743
  when "SCALAR"
464
744
  coordinate = "#{type.graphql_name}.#{field_name}"
465
745
  type_ref(field_type) { scalar_node(core.graphql_name, coordinate) }
@@ -468,9 +748,11 @@ class GraphWeaver::Codegen
468
748
  end
469
749
  end
470
750
 
471
- # a field under @skip/@include may be absent from the response no
472
- # matter what the schema says its type must admit nil
473
- if field_nodes.any? { |n| n.directives.any? { |d| %w[skip include].include?(d.name) } }
751
+ # A field under @skip/@include on the field itself, or on any fragment
752
+ # it was reached throughmay be absent from the response no matter what
753
+ # the schema says, so its type must admit nil. One unconditional
754
+ # selection of the same key still guarantees it, though.
755
+ if occurrences.all? { |node, conditional| conditional || conditional?(node) }
474
756
  child = child.of if child.is_a?(NonNull)
475
757
  end
476
758
 
@@ -481,141 +763,27 @@ class GraphWeaver::Codegen
481
763
  node
482
764
  end
483
765
 
484
- # Resolve each registered alias (extend_type alias:) for this struct's type
485
- # against its actual selection path -> a typed delegator emitted into the
486
- # struct body. Validated here, per query, so an unselected or untraversable
487
- # path fails at generation with a pointed message.
488
- def resolve_aliases(node)
489
- type_aliases(node.graphql_type).filter_map do |name, spec|
490
- # a bad accessor name (reserved, or colliding with a real field) is a
491
- # registration mistake it fails for every query, so it always raises,
492
- # even for optional aliases (which otherwise mask it as "doesn't fit").
493
- check_alias_name!(node, name)
494
- begin
495
- resolve_alias(node, name, spec[:segments])
496
- rescue GraphWeaver::Error
497
- # a path that doesn't fit THIS query's selection: strict raises,
498
- # optional simply omits the accessor
499
- raise unless spec[:optional]
500
- end
501
- end
502
- end
503
-
504
- def check_alias_name!(node, name)
505
- if node.fields.any? { |f| f.prop == name } || ALIAS_RESERVED.include?(name)
766
+ # Both ways a result key can fail to become a prop — a name the struct
767
+ # already answers, or a second key that underscores onto an earlier one.
768
+ # Either emits a file that raises ArgumentError at require time, so refuse
769
+ # here; an alias in the query fixes both. `props` accumulates prop => key.
770
+ def check_output_prop!(type, key, prop, props)
771
+ # Keywords are fine: `const :next` and `next: data["next"]` are legal, and
772
+ # the one place a prop is read bare (an alias delegator) qualifies it.
773
+ # `pageInfo { next }` and `filter { in }` are ordinary API shapes.
774
+ if STRUCT_METHODS.include?(prop)
506
775
  raise GraphWeaver::Error,
507
- "alias #{name.inspect} on #{node.graphql_type} collides with an existing field or method"
508
- end
509
- end
510
-
511
- # Registered aliases for a GraphQL type: global registry plus this client's
512
- # overlay (client-scoped wins on a name clash).
513
- def type_aliases(graphql_name)
514
- global = GraphWeaver::Codegen.type_registry[graphql_name]&.dig(:aliases) || {}
515
- (global.merge(@types[graphql_name]&.dig(:aliases) || {}))
516
- end
517
-
518
- ALIAS_RESERVED = (%w[from_h serialize to_h].to_set + RUBY_KEYWORDS).freeze
519
- # list selectors — pick one element out of a list-typed hop, always nilable
520
- # (the list may be empty). Everything else is a field prop.
521
- LIST_SELECTORS = %w[first last].freeze
522
-
523
- # Walk a dotted path through this struct's selected shape, building the
524
- # delegator expression (`meta&.tag`, `_entities.first&.name`) and its return
525
- # type. A segment is a field prop, or `first`/`last` to pick a list element.
526
- # Everything is checked against the node tree: a field on a non-object, a
527
- # selector on a non-list, or an unselected segment raises. Any nilable hop
528
- # (a nullable field, or a list element) makes the accessor nilable.
529
- def resolve_alias(node, name, segments)
530
- cur = T.let(node, T.untyped) # the node the path has reached
531
- cur_nilable = T.let(false, T::Boolean) # is the expression so far nilable
532
- nilable = T.let(false, T::Boolean) # is the accessor overall nilable
533
- containers = T.let([], T::Array[String]) # nested-struct class names on the way to the leaf
534
- expr = +""
535
-
536
- segments.each do |seg|
537
- connector = expr.empty? ? "" : (cur_nilable ? "&." : ".")
538
-
539
- # `first`/`last` select an element only when the current hop is actually a
540
- # list; otherwise they're an ordinary field (a schema field named `first`)
541
- if LIST_SELECTORS.include?(seg) && list_of(cur)
542
- expr << connector << seg
543
- cur = list_of(cur).of
544
- cur_nilable = true # first/last is nil on an empty list
545
- nilable = true
546
- else
547
- obj = object_of(cur)
548
- unless obj
549
- hint = if list_of(cur)
550
- " — use .first or .last to pick an element"
551
- elsif LIST_SELECTORS.include?(seg)
552
- " — .#{seg} needs a list"
553
- else
554
- ""
555
- end
556
- raise GraphWeaver::Error,
557
- "alias #{name.inspect} on #{node.graphql_type}: '#{seg}' can't be read here (not an object)#{hint}"
558
- end
559
- # the object a field is read from is the lexical container of its result
560
- # (nested structs emit inside their parent); the aliased struct itself is
561
- # the delegator's own scope, so it contributes no prefix
562
- containers << obj.class_name unless obj.equal?(node)
563
- field = obj.fields.find { |f| f.prop == seg }
564
- unless field
565
- props = obj.fields.map(&:prop)
566
- suggestion = GraphWeaver.did_you_mean(props, seg)
567
- hint = suggestion ? " — did you mean '#{suggestion}'?" : " (have: #{props.join(", ")})"
568
- raise GraphWeaver::Error,
569
- "alias #{name.inspect} on #{node.graphql_type}: '#{seg}' is not a selected field#{hint}"
570
- end
571
- expr << connector << seg
572
- cur = field.node
573
- cur_nilable = !field.node.non_null?
574
- nilable ||= cur_nilable
575
- end
776
+ "#{type.graphql_name}.#{key} would become prop '#{prop}', which every generated struct " \
777
+ "already defines — alias it in the query (`#{prop}Value: #{key}`)"
576
778
  end
577
779
 
578
- leaf = qualified_alias_type(cur, containers)
579
- type = nilable && leaf != "T.untyped" ? "T.nilable(#{leaf})" : leaf
580
- ObjectNode::Alias.new(name, expr, type)
581
- end
582
-
583
- # The leaf's Sorbet type as referenced from the aliased struct. Generated
584
- # nested constants (structs, enums, unions) must carry the container path,
585
- # since the delegator's `sig` is emitted in an outer struct where a bare
586
- # `Sub` wouldn't resolve; scalars, mapped enums, and hoisted union refs are
587
- # already top-level. `containers` is the class-name chain to the leaf.
588
- def qualified_alias_type(node, containers)
589
- node = node.of if node.is_a?(NonNull)
590
- prefix = containers.empty? ? "" : "#{containers.join("::")}::"
591
-
592
- case node
593
- when List
594
- element = node.of.is_a?(NonNull) ? qualified_alias_type(node.of, containers) : begin
595
- inner = qualified_alias_type(node.of, containers)
596
- inner == "T.untyped" ? inner : "T.nilable(#{inner})"
597
- end
598
- "T::Array[#{element}]"
599
- when ObjectNode, EnumNode, NarrowedNode then "#{prefix}#{node.class_name}"
600
- when UnionNode then "#{prefix}#{node.bare_type}"
601
- else node.bare_type # Scalar, MappedEnum, UnionRefNode — already top-level
780
+ if (earlier = props[prop])
781
+ raise GraphWeaver::Error,
782
+ "result keys #{earlier.inspect} and #{key.inspect} on #{type.graphql_name} both map to the " \
783
+ "prop '#{prop}' — alias one to a distinct name"
602
784
  end
603
- end
604
785
 
605
- # the List a node wraps (through NON_NULL), or nil
606
- def list_of(node)
607
- node = T.let(node, T.untyped)
608
- node = node.of while node.is_a?(NonNull)
609
- node if node.is_a?(List)
610
- end
611
-
612
- # the ObjectNode a node resolves to for field access (through NON_NULL and a
613
- # narrowed abstract member), or nil — unions/scalars/lists can't be read into
614
- def object_of(node)
615
- node = T.let(node, T.untyped)
616
- node = node.of while node.is_a?(NonNull)
617
- node = node.nested if node.is_a?(NarrowedNode)
618
- node if node.is_a?(ObjectNode)
786
+ props[prop] = key
619
787
  end
620
788
 
621
789
  # The concrete type conditions a selection mentions, minus conditions naming
@@ -657,18 +825,32 @@ class GraphWeaver::Codegen
657
825
  # does the flattened selection (as seen by member) include at least one
658
826
  # field guaranteed to be present in a matching response?
659
827
  def unconditional_field?(member, selections)
660
- each_field(member, selections) do |_key, node|
661
- return true if node.directives.none? { |d| %w[skip include].include?(d.name) }
828
+ each_field(member, selections) do |_key, node, conditional|
829
+ return true if !conditional && !conditional?(node)
662
830
  end
663
831
  false
664
832
  end
665
833
 
834
+ # Is the response guaranteed to carry a plain "__typename" key for this
835
+ # abstract selection? Every dispatch reads the tag unguarded, so an alias
836
+ # (which files it under another key) or an @skip/@include (which may drop
837
+ # it) means there is no tag to dispatch on.
838
+ def dispatchable_typename?(type, selections)
839
+ occurrences = gather_conditional(type, selections)["__typename"]
840
+ !!occurrences&.any? do |node, conditional|
841
+ node.name == "__typename" && !conditional && !conditional?(node)
842
+ end
843
+ end
844
+
666
845
  # rebuild LIST wrappers but drop NON_NULLs — a narrowed member is nil
667
846
  # whenever the runtime type doesn't match, whatever the schema promises
668
847
  def nilable_type_ref(type, &core)
669
848
  case type.kind.name
670
849
  when "NON_NULL"
671
- nilable_type_ref(type.of_type, &core)
850
+ # only the NON_NULL around the narrowed member itself drops — `[Thing!]!`
851
+ # narrowed is a guaranteed array of nilable members, not a nilable array
852
+ inner = nilable_type_ref(type.of_type, &core)
853
+ inner.is_a?(List) ? NonNull.new(inner) : inner
672
854
  when "LIST"
673
855
  List.new(nilable_type_ref(type.of_type, &core))
674
856
  else
@@ -676,29 +858,112 @@ class GraphWeaver::Codegen
676
858
  end
677
859
  end
678
860
 
679
- # Abstract types (unions AND interfaces) whose selections vary by
680
- # concrete type: one member struct per possible type; wire dispatch
681
- # reads __typename, so the query must select it. For interfaces, the
682
- # interface's own field selections gather into every member.
683
- # The union's member structs (graphql type name => ObjectNode), sorted for
684
- # deterministic output. Dispatch reads __typename, so the query must select
685
- # it; for interfaces the interface-level fields gather into every member.
861
+ # Abstract types (unions AND interfaces) whose selections vary by concrete
862
+ # type: one member struct per type the selection NAMES (graphql type name =>
863
+ # ObjectNode, sorted for deterministic output), never one per schema member —
864
+ # a query against an interface with 278 implementations types the two it asked
865
+ # about. Dispatch reads __typename, so the query must select it; for
866
+ # interfaces the interface-level fields gather into every member.
686
867
  def union_members(type, selections)
687
- unless gather(type, selections).key?("__typename")
868
+ unless dispatchable_typename?(type, selections)
688
869
  raise ArgumentError,
689
- "select __typename on #{type.graphql_name} so the union can dispatch — " \
690
- "or narrow to a single `... on Type` condition (no dispatch needed)"
870
+ "select __typename on #{type.graphql_name} so the union can dispatch — unaliased and " \
871
+ "not under @skip/@include, since from_h reads it on every response or narrow to a " \
872
+ "single `... on Type` condition (no dispatch needed)"
691
873
  end
692
874
 
693
- @schema.possible_types(type).sort_by(&:graphql_name).to_h do |possible|
875
+ selected_members(type, selections).sort_by(&:graphql_name).to_h do |possible|
694
876
  [possible.graphql_name, object_node(possible, selections, camelize(possible.graphql_name))]
695
877
  end
696
878
  end
697
879
 
880
+ # The concrete types a selection names through its type conditions, kept to
881
+ # the abstract type's own members. A condition naming another abstract type
882
+ # (`... on Named` inside a union) stands for the members it covers, since its
883
+ # fields are typed per member.
884
+ def selected_members(type, selections)
885
+ possible = @schema.possible_types(type).to_h { |member| [member.graphql_name, member] }
886
+
887
+ concrete_conditions(type, selections).flat_map { |name|
888
+ condition = @schema.get_type(name)
889
+ condition.kind.name == "OBJECT" ? [condition] : @schema.possible_types(condition)
890
+ }.map(&:graphql_name).uniq.filter_map { |name| possible[name] }
891
+ end
892
+
893
+ # The one struct everything else deserializes into: a member the query didn't
894
+ # name, and — the point — a member the schema grows AFTER this file was
895
+ # generated, so a new upstream member bends the result rather than breaking
896
+ # it. It carries what the abstract type itself guarantees, plus anything a
897
+ # `... on SomeInterface` asked for, since an unnamed member may implement it.
898
+ def catch_all_member(type, selections, members)
899
+ node = object_node(type, selections, catch_all_name(members))
900
+ taken = node.fields.map(&:key)
901
+
902
+ # These are nilable whatever the schema promises: the member that arrives
903
+ # need not implement the interface, and then the server sends nothing.
904
+ sibling_conditions(type, selections).each do |condition, sub_selections|
905
+ object_node(condition, sub_selections, node.class_name).fields.each do |field|
906
+ next if taken.include?(field.key)
907
+
908
+ taken << field.key
909
+ child = field.node
910
+ node.fields << ObjectNode::Field.new(field.prop, field.key, child.is_a?(NonNull) ? child.of : child)
911
+ end
912
+ end
913
+
914
+ node.aliases = resolve_aliases(node)
915
+ node
916
+ end
917
+
918
+ # The abstract type conditions inside an abstract selection that a member the
919
+ # query never NAMED could still satisfy — `... on Named` under a union, or
920
+ # under a different interface. Returns condition => merged selections, so the
921
+ # same interface spread twice types once; concrete conditions are excluded,
922
+ # since a member they'd match already has a struct of its own.
923
+ def sibling_conditions(type, selections, visiting = Set.new, out = {})
924
+ selections.each do |selection|
925
+ case selection
926
+ when GraphQL::Language::Nodes::InlineFragment
927
+ sibling_condition(type, selection.type&.name, selection.selections, visiting, out)
928
+ when GraphQL::Language::Nodes::FragmentSpread
929
+ next if visiting.include?(selection.name)
930
+
931
+ fragment = @fragments.fetch(selection.name)
932
+ sibling_condition(type, fragment.type.name, fragment.selections, visiting | [selection.name], out)
933
+ end
934
+ end
935
+ out
936
+ end
937
+
938
+ def sibling_condition(type, name, selections, visiting, out)
939
+ condition = name ? @schema.get_type(name) : type
940
+ return unless condition
941
+ # same type condition restated — keep descending at this level
942
+ return sibling_conditions(type, selections, visiting, out) if condition.graphql_name == type.graphql_name
943
+ return unless condition.kind.abstract?
944
+
945
+ (out[condition] ||= []).concat(selections)
946
+ sibling_conditions(condition, selections, visiting, out)
947
+ end
948
+
949
+ # "Other", unless a real member already claims that name.
950
+ def catch_all_name(members)
951
+ taken = members.each_value.map(&:class_name)
952
+ name = "Other"
953
+ suffix = 2
954
+ while taken.include?(name)
955
+ name = "Other#{suffix}"
956
+ suffix += 1
957
+ end
958
+ name
959
+ end
960
+
698
961
  # A name-independent structural fingerprint of a union's members, so two
699
962
  # occurrences that generate identical structs collapse to one Ruby type.
700
- def union_signature(members)
701
- members.map { |gname, member| "#{gname}=#{signature(member)}" }.sort.join(",")
963
+ def union_signature(members, catch_all = nil)
964
+ parts = members.map { |gname, member| "#{gname}=#{signature(member)}" }
965
+ parts << "*=#{signature(catch_all)}" if catch_all
966
+ parts.sort.join(",")
702
967
  end
703
968
 
704
969
  # Structural signature of a node — ignores the generated class name (which
@@ -715,7 +980,7 @@ class GraphWeaver::Codegen
715
980
  when ObjectNode
716
981
  inner = node.fields.map { |f| "#{f.prop}=#{signature(f.node)}" }.sort.join(",")
717
982
  "o:#{node.graphql_type}(#{inner})"
718
- when UnionNode then "u:(#{union_signature(node.members)})"
983
+ when UnionNode then "u:(#{union_signature(node.members, node.catch_all)})"
719
984
  when UnionRefNode then "ur:#{node.class_name}" # hoisted — identity is its shared name
720
985
  else "x:#{node.object_id}" # unknown node kind — never collapse
721
986
  end
@@ -736,14 +1001,15 @@ class GraphWeaver::Codegen
736
1001
  end
737
1002
  end
738
1003
 
739
- # the input-side core kinds a variable (or input-object field) can have
1004
+ # The node for a core type, reached from a variable, an input-object field
1005
+ # or a result-side enum. All three share it so that one schema enum is one
1006
+ # Ruby type wherever it appears — see object_node's ENUM branch.
740
1007
  def variable_core(core)
741
1008
  case core.kind.name
742
1009
  when "SCALAR"
743
1010
  scalar_node(core.graphql_name)
744
1011
  when "ENUM"
745
- mapped_enum_node(core) || (@variable_enums[core.graphql_name] ||=
746
- EnumNode.new(camelize(core.graphql_name), core.values.keys.sort))
1012
+ mapped_enum_node(core) || (@enums[core.graphql_name] ||= enum_node(core))
747
1013
  when "INPUT_OBJECT"
748
1014
  input_node(core)
749
1015
  else
@@ -759,40 +1025,70 @@ class GraphWeaver::Codegen
759
1025
  return @variable_inputs[core.graphql_name] if @variable_inputs.key?(core.graphql_name)
760
1026
 
761
1027
  node = @variable_inputs[core.graphql_name] = InputNode.new(camelize(core.graphql_name))
1028
+ node.one_of = core.respond_to?(:one_of?) && core.one_of?
762
1029
  # sorted so output is deterministic across schema sources
763
1030
  core.arguments.values.sort_by(&:graphql_name).each do |argument|
764
1031
  prop = underscore(argument.graphql_name)
765
- # prop readers are bare method calls in the generated struct
766
- if RUBY_KEYWORDS.include?(prop) || GENERATED_METHODS.include?(prop)
1032
+ # Keywords are fine here: nothing reads an input prop bare (serialize goes
1033
+ # through public_send), and `const :in` is legal — which matters, since a
1034
+ # schema's field name is not the user's to rename. `Tricky.in` filters are
1035
+ # standard Hasura/Gatsby shape.
1036
+ if STRUCT_METHODS.include?(prop)
767
1037
  raise GraphWeaver::Error,
768
1038
  "input field #{core.graphql_name}.#{argument.graphql_name} would become prop '#{prop}', " \
769
- "which collides with #{RUBY_KEYWORDS.include?(prop) ? "a Ruby keyword" : "the struct's generated ##{prop}"}"
1039
+ "which collides with a method every struct defines"
770
1040
  end
771
1041
 
772
- child = type_ref(argument.type) { variable_core(unwrap(argument.type)) }
1042
+ child = type_ref(argument.type) { variable_core(argument.type.unwrap) }
773
1043
  required = child.non_null? && !argument.default_value?
774
1044
  node.fields << InputNode::Field.new(prop, argument.graphql_name, child, required)
775
1045
  end
776
1046
  node
777
1047
  end
778
1048
 
779
- # The InputNodes a struct's fields reference, through NON_NULL/LIST
780
- # wrappers the edges of the input dependency graph.
1049
+ # The module-level T::Enum for a schema enum, named for the enum itself —
1050
+ # it is shared by every field and variable of that type.
1051
+ def enum_node(core)
1052
+ class_name = camelize(core.graphql_name)
1053
+ if MODULE_RESERVED.include?(class_name)
1054
+ raise GraphWeaver::Error,
1055
+ "enum #{core.graphql_name} generates #{class_name}, which collides with a generated " \
1056
+ "constant — map it onto one of yours: register_enum(#{core.graphql_name.inspect}, YourEnum)"
1057
+ end
781
1058
 
1059
+ EnumNode.new(class_name, enum_values(core))
1060
+ end
782
1061
 
783
- # Registered helper-module names for a GraphQL type (additive: global
784
- # registrations plus this client's), collecting their requires.
1062
+ # A schema enum's wire values, sorted so output is deterministic across schema
1063
+ # sources (SDL round-trips reorder values alphabetically). Values that differ
1064
+ # only in case name the same T::Enum constant, which raises at LOAD time
1065
+ # ("Enum values must be assigned to constants") — catch it here instead.
1066
+ def enum_values(core)
1067
+ values = core.values.keys.sort
1068
+ collision = values.group_by { |value| camelize(value.downcase) }.find { |_, group| group.size > 1 }
1069
+ if collision
1070
+ raise GraphWeaver::Error,
1071
+ "enum #{core.graphql_name} values #{collision.last.join(" and ")} both become the constant " \
1072
+ "#{collision.first} — map the enum onto one of yours: " \
1073
+ "register_enum(#{core.graphql_name.inspect}, YourEnum)"
1074
+ end
1075
+
1076
+ values
1077
+ end
1078
+
1079
+ # Registered helper-module names for a GraphQL type, collecting their requires.
785
1080
  def type_mixins(graphql_name)
786
- entries = [GraphWeaver::Codegen.type_registry[graphql_name], @types[graphql_name]].compact
787
- entries.each { |entry| @requires.concat(entry[:requires]) }
788
- entries.flat_map { |entry| entry[:mixins].map(&:name) }
1081
+ entry = GraphWeaver::Codegen.type_registry[graphql_name]
1082
+ return [] unless entry
1083
+
1084
+ @requires.concat(entry[:requires])
1085
+ entry[:mixins].map(&:name)
789
1086
  end
790
1087
 
791
1088
  # The MappedEnum node for a schema enum with a registered app-enum
792
- # mapping (client overlay first, then the global registry); nil when
793
- # unregistered, falling back to a generated T::Enum.
1089
+ # mapping; nil when unregistered, falling back to a generated T::Enum.
794
1090
  def mapped_enum_node(core)
795
- enum_type = @enums[core.graphql_name] || GraphWeaver::Codegen.enum_registry[core.graphql_name]
1091
+ enum_type = GraphWeaver::Codegen.enum_registry[core.graphql_name]
796
1092
  return unless enum_type
797
1093
 
798
1094
  @requires.concat(enum_type.requires)
@@ -802,16 +1098,32 @@ class GraphWeaver::Codegen
802
1098
  # A Scalar node, recording any requires its registered type needs so the
803
1099
  # generated file can require them (collected across the whole query).
804
1100
  # Resolution, most specific first: a per-field override (`Type.field`), then
805
- # the scalar-name registration — each checked client-scoped, then global.
1101
+ # the scalar-name registration.
806
1102
  def scalar_node(name, coordinate = nil)
807
- scalar =
808
- (coordinate && (@scalars[coordinate] || GraphWeaver::Codegen.scalar_registry[coordinate])) ||
809
- @scalars[name.to_s] ||
810
- GraphWeaver::Codegen.scalar(name)
1103
+ registry = GraphWeaver::Codegen.scalar_registry
1104
+ scalar = (coordinate && registry[coordinate]) || registry[name.to_s]
1105
+ if scalar.nil?
1106
+ @untyped_scalars << name.to_s
1107
+ scalar = GraphWeaver::Codegen.scalar(name)
1108
+ end
811
1109
  @requires.concat(scalar.requires)
812
1110
  Scalar.new(scalar)
813
1111
  end
814
1112
 
1113
+ # An unregistered custom scalar passes through as T.untyped — legitimate
1114
+ # (nobody needs a codec for every scalar), but it's the one hole in an
1115
+ # otherwise exact result type, so name the holes rather than leave them
1116
+ # silent. Informational: not a warning, never an error.
1117
+ def report_untyped_scalars
1118
+ names = @untyped_scalars.uniq.sort
1119
+ return if names.empty?
1120
+
1121
+ GraphWeaver.log(:info) do
1122
+ "#{names.size} unregistered custom scalar#{"s" unless names.one?} → T.untyped: " \
1123
+ "#{names.join(", ")} (register with GraphWeaver.register_scalar)"
1124
+ end
1125
+ end
1126
+
815
1127
  # rebuild the NON_NULL/LIST wrappers around the core node
816
1128
  def type_ref(type, &core)
817
1129
  case type.kind.name
@@ -824,21 +1136,45 @@ class GraphWeaver::Codegen
824
1136
  end
825
1137
  end
826
1138
 
827
- def unwrap(type)
828
- type = type.of_type while type.kind.name == "NON_NULL" || type.kind.name == "LIST"
829
- type
830
- end
1139
+ # A generated type is named for the response key that selects it, camelized
1140
+ # (`stargazers` => Stargazers) a function of the field's own position and
1141
+ # nothing else, so adding, removing, or reordering an unrelated selection can
1142
+ # never rename it. Generated code is app-code API; a name that shifts under
1143
+ # an unrelated edit is a silent break. `taken` is the names claimed in this
1144
+ # struct's scope, its first entry the struct itself.
1145
+ #
1146
+ # (Union members are the exception: they are named for the type condition
1147
+ # that produces them, which is equally position-determined.)
1148
+ def pick_name(key, taken)
1149
+ name = camelize(key)
1150
+
1151
+ # a key that camelizes to no constant at all ("_", "_1") would emit
1152
+ # `class < T::Struct`
1153
+ unless name.match?(/\A[A-Z]/)
1154
+ raise GraphWeaver::Error,
1155
+ "result key #{key.inspect} makes no class name (#{name.inspect}) — alias it to one starting with a letter"
1156
+ end
831
1157
 
832
- # GraphQL type names become struct names — camelized, because schemas
833
- # in the wild use snake_case type names (Hasura, PostGraphile) and a
834
- # verbatim lowercase name is not a Ruby constant
835
- def pick_name(type_name, key, taken)
836
- candidate = camelize(type_name)
837
- candidate = "#{camelize(key)}#{candidate}" if taken.include?(candidate)
838
- raise GraphWeaver::Error, "class name collision: #{candidate}" if taken.include?(candidate)
1158
+ if name == taken.first
1159
+ # would shadow the struct it nests in the parent's own `returns(Name)`
1160
+ # resolves lexically and would find the child
1161
+ suffix = 2
1162
+ suffix += 1 while taken.include?("#{name}#{suffix}")
1163
+ name = "#{name}#{suffix}"
1164
+ elsif taken.include?(name)
1165
+ raise GraphWeaver::Error,
1166
+ "result keys on #{taken.first} both generate the class #{name} — alias one to a distinct name"
1167
+ end
839
1168
 
840
- taken << candidate
841
- candidate
1169
+ taken << name
1170
+ name
842
1171
  end
843
1172
 
1173
+ # Fields whose union selections are structurally identical share one Ruby
1174
+ # type; name it for the alphabetically first of their keys, so which field
1175
+ # the walk happened to reach first doesn't decide.
1176
+ def rename_union(union, key, taken)
1177
+ taken.delete(union.class_name)
1178
+ union.class_name = pick_name(key, taken)
1179
+ end
844
1180
  end