graph_weaver 0.4.6 → 0.5.0

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