graph_weaver 0.4.4 → 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 +1357 -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 -136
  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 -251
  27. data/lib/graph_weaver/codegen/enum_type.rb +27 -98
  28. data/lib/graph_weaver/codegen/nodes.rb +72 -13
  29. data/lib/graph_weaver/codegen/scalar_type.rb +72 -67
  30. data/lib/graph_weaver/codegen/type_helpers.rb +142 -0
  31. data/lib/graph_weaver/codegen.rb +617 -264
  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 +12 -6
  35. data/lib/graph_weaver/in_process.rb +90 -0
  36. data/lib/graph_weaver/input_struct.rb +21 -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 +15 -1
  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 +820 -57
  46. data/lib/graph_weaver/schemas.rb +46 -0
  47. data/lib/graph_weaver/selection.rb +59 -7
  48. data/lib/graph_weaver/tasks.rb +216 -21
  49. data/lib/graph_weaver/testing/cassette.rb +186 -62
  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 +194 -28
  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 +31 -6
  58. data/lib/graph_weaver/transport/http.rb +99 -36
  59. data/lib/graph_weaver/transport.rb +74 -18
  60. data/lib/graph_weaver/version.rb +1 -1
  61. data/lib/graph_weaver.rb +398 -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,80 +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
- fragment = fragments.fetch(name)
179
- type = @schema.get_type(fragment.type.name)
180
- UnionNode.new(camelize(name), union_members(type, fragment.selections))
181
- 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
182
245
 
183
- 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)"
184
248
  end
249
+ private :validate_module_name!
185
250
 
186
251
  VarDef = Struct.new(:kwarg, :wire, :node, :required)
187
252
 
188
- # Names that cannot appear bare in generated Ruby: keywords aren't
189
- # valid identifiers, and the struct's own generated methods would be
190
- # 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.
191
256
  RUBY_KEYWORDS = %w[
192
257
  alias and begin break case class def defined? do else elsif end
193
258
  ensure false for if in module next nil not or redo rescue retry
@@ -195,6 +260,17 @@ class GraphWeaver::Codegen
195
260
  BEGIN END __FILE__ __LINE__ __ENCODING__
196
261
  ].to_set.freeze
197
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
198
274
 
199
275
  def generate
200
276
  begin
@@ -202,21 +278,14 @@ class GraphWeaver::Codegen
202
278
  rescue GraphQL::ParseError => e
203
279
  # unparseable queries wrap like invalid ones — everything raised
204
280
  # here descends from GraphWeaver::Error
205
- raise GraphWeaver::ValidationError.new([{ message: e.message, line: nil, column: nil }])
281
+ raise GraphWeaver::ValidationError.new([detail(e.message, e.line, e.col)])
206
282
  end
207
283
  if errors.any?
208
284
  raise GraphWeaver::ValidationError.new(errors.map { |e| validation_detail(e) })
209
285
  end
210
286
 
211
287
  validate_registrations!
212
-
213
- @variable_enums = {}
214
- @variable_inputs = {}
215
- @mapped_enums = {}
216
- @used_unions = []
217
- # requires the generated file needs (custom scalars, enum mappings,
218
- # type helpers all contribute)
219
- @requires = []
288
+ reset_walk_state!
220
289
 
221
290
  operation = load_operation(@query)
222
291
  root_type = operation_root_type(operation)
@@ -226,15 +295,42 @@ class GraphWeaver::Codegen
226
295
  raise ArgumentError, "module_name: required for anonymous operations"
227
296
  end
228
297
 
229
- # generated source is eval'd by parse — never let a name inject code
230
- unless @module_name.match?(/\A[A-Z]\w*(::[A-Z]\w*)*\z/)
231
- raise ArgumentError, "module_name: must be a constant name, got #{@module_name.inspect}"
232
- 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
233
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)
234
332
  variables = operation.variables.map do |var|
235
333
  node = ast_type_ref(var.type)
236
- # a variable is optional when nullable or defaulted; optional kwargs
237
- # default to nil and are omitted from the wire
238
334
  required = node.non_null? && var.default_value.nil?
239
335
  kwarg = underscore(var.name)
240
336
  # kwargs are declared and forwarded bare in generated source
@@ -243,22 +339,156 @@ class GraphWeaver::Codegen
243
339
  "variable $#{var.name} would become the kwarg '#{kwarg}:', which generated code can't declare " \
244
340
  "(a Ruby keyword) — rename the variable"
245
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
246
347
  VarDef.new(kwarg, var.name, node, required)
247
348
  end
248
349
 
249
- root = object_node(root_type, operation.selections, "Result")
350
+ # two variables that underscore to the same kwarg ($userId + $user_id) would
351
+ # silently drop one on the wire — flag it like a prop collision
352
+ collision = variables.group_by(&:kwarg).find { |_, vars| vars.size > 1 }
353
+ if collision
354
+ wire = collision.last.map { |var| "$#{var.wire}" }.join(", ")
355
+ raise GraphWeaver::Error,
356
+ "variables #{wire} both map to the kwarg '#{collision.first}:' — rename one"
357
+ end
250
358
 
251
- emit_module(root, variables)
359
+ variables
252
360
  end
253
361
 
254
- private
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
377
+
378
+ nodes
379
+ end
380
+
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
255
437
 
256
- # A client-scoped registration names a type in a specific schema a
257
- # typo'd name would otherwise be a silent no-op, the most confusing
258
- # failure mode available. Called eagerly by Client#register_* when the
259
- # schema is already loaded, and again at generation (covers clients
260
- # whose schema introspects lazily). Global registrations skip this:
261
- # 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.
262
492
  def self.validate_registration!(schema, kind, name)
263
493
  # register_scalar("Type.field", ...) overrides one field's scalar — validate
264
494
  # the field exists and is a scalar, not that a type named "Type.field" exists.
@@ -270,8 +500,7 @@ class GraphWeaver::Codegen
270
500
 
271
501
  return if schema.get_type(name)
272
502
 
273
- suggestion = defined?(DidYouMean::SpellChecker) &&
274
- DidYouMean::SpellChecker.new(dictionary: schema.types.keys).correct(name).first
503
+ suggestion = GraphWeaver.did_you_mean(schema.types.keys, name)
275
504
  hint = suggestion ? " — did you mean '#{suggestion}'?" : ""
276
505
  # the type registry is reached via extend_type; scalars/enums via register_*
277
506
  method = kind == "type" ? "extend_type" : "register_#{kind}"
@@ -294,13 +523,19 @@ class GraphWeaver::Codegen
294
523
  # map — reusable fragments a query can spread. Fragment files hold only
295
524
  # fragments (no operations); names are unique across them.
296
525
  def self.load_fragments(paths)
297
- Array(paths).flat_map { |dir| Dir[File.join(dir, "*.graphql")].sort }.each_with_object({}) do |file, out|
298
- 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)
299
530
  if doc.definitions.grep(GraphQL::Language::Nodes::OperationDefinition).any?
300
531
  raise GraphWeaver::Error, "#{file}: fragment files define only fragments, no operations"
301
532
  end
302
533
  doc.definitions.grep(GraphQL::Language::Nodes::FragmentDefinition).each do |frag|
303
- 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
304
539
  out[frag.name] = frag
305
540
  end
306
541
  end
@@ -310,18 +545,34 @@ class GraphWeaver::Codegen
310
545
  # shadows with a local definition of the same name — the names
311
546
  # inline_fragments appends, and the set the generate! workflow may hoist
312
547
  # when they sit on a whole-union field.
313
- 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)
314
553
  return [] if shared.empty?
315
554
 
316
- doc = GraphQL.parse(query)
317
555
  local = doc.definitions.grep(GraphQL::Language::Nodes::FragmentDefinition).map(&:name)
318
556
  reachable_fragments(fragment_spreads(doc.definitions), shared, local)
319
557
  end
320
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
+
321
572
  # Append the shared fragments a query spreads (transitively) to its source, so
322
573
  # the sent query is self-contained. Unused shared fragments are left out.
323
- def self.inline_fragments(query, shared)
324
- used = shared_fragment_spreads(query, shared)
574
+ def self.inline_fragments(query, shared, path = nil)
575
+ used = shared_fragment_spreads(query, shared, path)
325
576
  return query if used.empty?
326
577
 
327
578
  "#{query.rstrip}\n\n#{used.sort.map { |name| shared.fetch(name).to_query_string }.join("\n\n")}\n"
@@ -357,24 +608,30 @@ class GraphWeaver::Codegen
357
608
  # source location, so ValidationError#errors is inspectable.
358
609
  def validation_detail(error)
359
610
  loc = (error.to_h["locations"]&.first if error.respond_to?(:to_h))
360
- { message: error.message, line: loc && loc["line"], column: loc && loc["column"] }
611
+ detail(error.message, loc && loc["line"], loc && loc["column"])
612
+ end
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: }
361
620
  end
362
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.
363
625
  def validate_registrations!
364
- { "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|
365
631
  registry.each_key { |name| self.class.validate_registration!(@schema, kind, name) }
366
632
  end
367
633
  end
368
634
 
369
-
370
- # Selection#each_field, collected by result key (codegen groups
371
- # repeated selections of one field so it can merge them)
372
- def gather(type, selections)
373
- out = {}
374
- each_field(type, selections) { |key, node| (out[key] ||= []) << node }
375
- out
376
- end
377
-
378
635
  def object_node(type, selections, class_name)
379
636
  node = ObjectNode.new(class_name)
380
637
  node.graphql_type = type.graphql_name
@@ -384,10 +641,13 @@ class GraphWeaver::Codegen
384
641
  # same union selected two ways (unblockOptions vs selectedOption) shares
385
642
  # one Ruby type, so consumers get one exhaustive `case ... T.absurd`.
386
643
  union_cache = {}
644
+ props = {}
387
645
 
388
- gather(type, selections).each do |key, field_nodes|
646
+ gather_conditional(type, selections).each do |key, occurrences|
647
+ field_nodes = occurrences.map(&:first)
389
648
  field_name = field_nodes.first.name
390
649
  prop = underscore(key)
650
+ check_output_prop!(type, key, prop, props)
391
651
 
392
652
  child = if field_name == "__typename"
393
653
  NonNull.new(scalar_node("String"))
@@ -395,59 +655,66 @@ class GraphWeaver::Codegen
395
655
  field_type = @schema.get_field(type.graphql_name, field_name).type
396
656
  sub_selections = field_nodes.flat_map(&:selections)
397
657
 
398
- case (core = unwrap(field_type)).kind.name
658
+ case (core = field_type.unwrap).kind.name
399
659
  when "OBJECT"
400
- name = pick_name(core.graphql_name, key, taken)
660
+ name = pick_name(key, taken)
401
661
  type_ref(field_type) { object_node(core, sub_selections, name) }
402
662
  when "UNION", "INTERFACE"
403
663
  conditions = concrete_conditions(core, sub_selections)
404
664
  bare = bare_fields(sub_selections) - ["__typename"]
405
665
 
406
- if conditions.empty? && core.kind.name == "INTERFACE"
407
- # interface-level fields only — every member shares them, so
408
- # one struct suffices and no __typename dispatch is needed
409
- 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)
410
671
  type_ref(field_type) { object_node(core, sub_selections, name) }
411
672
  elsif conditions.size == 1 && bare.empty? &&
412
673
  (member = @schema.get_type(conditions.first)).kind.name == "OBJECT"
413
674
  # a single `... on X` condition: narrow to X's struct — nil
414
675
  # when the runtime type doesn't match (narrowing filters).
415
- # Narrowing reads "no fields came back" as "type didn't
416
- # match", so a fragment whose every field hides behind
417
- # @skip/@include would make a real match indistinguishable
418
- # from a miss ({} either way) — refuse rather than guess.
419
- 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)
420
683
  raise GraphWeaver::Error,
421
684
  "narrowed `... on #{member.graphql_name}` needs at least one field not under " \
422
- "@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"
423
687
  end
424
688
 
425
- name = pick_name(member.graphql_name, key, taken)
426
- nilable_type_ref(field_type) { NarrowedNode.new(object_node(member, sub_selections, name)) }
427
- 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)) &&
428
692
  @hoistable_unions.include?(frag)
429
693
  # a whole-union field spread as a named shared fragment: hoist to
430
- # 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
431
695
  # Ruby type family (one exhaustive `case ... T.absurd`).
432
696
  @used_unions << frag unless @used_unions.include?(frag)
433
697
  ref = UnionRefNode.new(camelize(frag))
434
698
  type_ref(field_type) { ref }
435
699
  else
436
700
  members = union_members(core, sub_selections)
437
- # reuse an identical sibling union (pick_name/name only on a miss)
438
- union = (union_cache[union_signature(members)] ||=
439
- 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
440
711
  type_ref(field_type) { union }
441
712
  end
442
713
  when "ENUM"
443
- if (mapped = mapped_enum_node(core))
444
- type_ref(field_type) { mapped }
445
- else
446
- name = pick_name(core.graphql_name, key, taken)
447
- # sorted so output is deterministic across schema sources
448
- # (SDL round-trips reorder values alphabetically)
449
- type_ref(field_type) { EnumNode.new(name, core.values.keys.sort) }
450
- 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) }
451
718
  when "SCALAR"
452
719
  coordinate = "#{type.graphql_name}.#{field_name}"
453
720
  type_ref(field_type) { scalar_node(core.graphql_name, coordinate) }
@@ -456,9 +723,11 @@ class GraphWeaver::Codegen
456
723
  end
457
724
  end
458
725
 
459
- # a field under @skip/@include may be absent from the response no
460
- # matter what the schema says its type must admit nil
461
- 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) }
462
731
  child = child.of if child.is_a?(NonNull)
463
732
  end
464
733
 
@@ -469,79 +738,47 @@ class GraphWeaver::Codegen
469
738
  node
470
739
  end
471
740
 
472
- # Resolve each registered alias (extend_type alias:) for this struct's type
473
- # against its actual selection path -> a typed delegator emitted into the
474
- # struct body. Validated here, per query, so an unselected or untraversable
475
- # path fails at generation with a pointed message.
476
- def resolve_aliases(node)
477
- type_aliases(node.graphql_type).map do |name, segments|
478
- resolve_alias(node, name, segments)
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)
750
+ raise GraphWeaver::Error,
751
+ "#{type.graphql_name}.#{key} would become prop '#{prop}', which every generated struct " \
752
+ "already defines — alias it in the query (`#{prop}Value: #{key}`)"
479
753
  end
480
- end
481
-
482
- # Registered aliases for a GraphQL type: global registry plus this client's
483
- # overlay (client-scoped wins on a name clash).
484
- def type_aliases(graphql_name)
485
- global = GraphWeaver::Codegen.type_registry[graphql_name]&.dig(:aliases) || {}
486
- (global.merge(@types[graphql_name]&.dig(:aliases) || {}))
487
- end
488
754
 
489
- ALIAS_RESERVED = (%w[from_h serialize to_h].to_set + RUBY_KEYWORDS).freeze
490
-
491
- # Walk a dotted path (Ruby prop names) through this struct's selected fields,
492
- # building the delegator expression (`meta&.tag`) and its return type. Any hop
493
- # is nilable -> the accessor is nilable; a list hop or an unselected segment
494
- # raises. The leaf may be any node (scalar, enum, nested struct).
495
- def resolve_alias(node, name, segments)
496
- if node.fields.any? { |f| f.prop == name } || ALIAS_RESERVED.include?(name)
755
+ if (earlier = props[prop])
497
756
  raise GraphWeaver::Error,
498
- "alias #{name.inspect} on #{node.graphql_type} collides with an existing field or method"
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"
499
759
  end
500
760
 
501
- current = T.let(node, T.untyped)
502
- parts = []
503
- nilable = T.let(false, T::Boolean)
504
- segments.each_with_index do |seg, i|
505
- field = current.fields.find { |f| f.prop == seg }
506
- unless field
507
- props = current.fields.map(&:prop)
508
- suggestion = defined?(DidYouMean::SpellChecker) &&
509
- DidYouMean::SpellChecker.new(dictionary: props).correct(seg).first
510
- hint = suggestion ? " — did you mean '#{suggestion}'?" : " (have: #{props.join(", ")})"
511
- raise GraphWeaver::Error,
512
- "alias #{name.inspect} on #{node.graphql_type}: '#{seg}' is not a selected field#{hint}"
513
- end
514
- nilable ||= !field.node.non_null?
515
-
516
- if i == segments.size - 1
517
- leaf = field.node.bare_type
518
- type = nilable && leaf != "T.untyped" ? "T.nilable(#{leaf})" : leaf
519
- return ObjectNode::Alias.new(name, (parts << seg).join, type)
520
- end
521
-
522
- inner = T.let(field.node, T.untyped)
523
- inner = inner.of while inner.is_a?(NonNull)
524
- raise GraphWeaver::Error, "alias #{name.inspect}: cannot traverse list-typed '#{seg}'" if inner.is_a?(List)
525
- unless inner.is_a?(ObjectNode)
526
- raise GraphWeaver::Error, "alias #{name.inspect}: '#{seg}' is not an object to traverse into"
527
- end
528
-
529
- parts << seg << (field.node.non_null? ? "." : "&.")
530
- current = inner
531
- end
761
+ props[prop] = key
532
762
  end
533
763
 
534
- # The concrete type conditions a selection mentions (inline fragments
535
- # and named spreads), minus conditions naming the abstract type itself.
536
- def concrete_conditions(core, selections)
537
- selections.filter_map do |selection|
764
+ # The concrete type conditions a selection mentions, minus conditions naming
765
+ # the abstract type itself recursing into named-fragment and inline bodies,
766
+ # so a `... on X` nested inside a spread (`{ ...NodeFields }` where NodeFields
767
+ # holds `... on X`) still drives dispatch instead of being silently dropped.
768
+ def concrete_conditions(core, selections, visiting = Set.new)
769
+ selections.flat_map do |selection|
538
770
  case selection
539
771
  when GraphQL::Language::Nodes::InlineFragment
540
- selection.type&.name
772
+ [selection.type&.name, *concrete_conditions(core, selection.selections, visiting)]
541
773
  when GraphQL::Language::Nodes::FragmentSpread
542
- @fragments.fetch(selection.name).type.name
774
+ next [] if visiting.include?(selection.name)
775
+
776
+ fragment = @fragments.fetch(selection.name)
777
+ [fragment.type.name, *concrete_conditions(core, fragment.selections, visiting | [selection.name])]
778
+ else
779
+ []
543
780
  end
544
- end.uniq - [core.graphql_name]
781
+ end.compact.uniq - [core.graphql_name]
545
782
  end
546
783
 
547
784
  # result keys selected as plain fields (outside any type condition)
@@ -563,12 +800,23 @@ class GraphWeaver::Codegen
563
800
  # does the flattened selection (as seen by member) include at least one
564
801
  # field guaranteed to be present in a matching response?
565
802
  def unconditional_field?(member, selections)
566
- each_field(member, selections) do |_key, node|
567
- 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)
568
805
  end
569
806
  false
570
807
  end
571
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
+
572
820
  # rebuild LIST wrappers but drop NON_NULLs — a narrowed member is nil
573
821
  # whenever the runtime type doesn't match, whatever the schema promises
574
822
  def nilable_type_ref(type, &core)
@@ -582,29 +830,65 @@ class GraphWeaver::Codegen
582
830
  end
583
831
  end
584
832
 
585
- # Abstract types (unions AND interfaces) whose selections vary by
586
- # concrete type: one member struct per possible type; wire dispatch
587
- # reads __typename, so the query must select it. For interfaces, the
588
- # interface's own field selections gather into every member.
589
- # The union's member structs (graphql type name => ObjectNode), sorted for
590
- # deterministic output. Dispatch reads __typename, so the query must select
591
- # 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.
592
839
  def union_members(type, selections)
593
- unless gather(type, selections).key?("__typename")
840
+ unless dispatchable_typename?(type, selections)
594
841
  raise ArgumentError,
595
- "select __typename on #{type.graphql_name} so the union can dispatch — " \
596
- "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)"
597
845
  end
598
846
 
599
- @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|
600
848
  [possible.graphql_name, object_node(possible, selections, camelize(possible.graphql_name))]
601
849
  end
602
850
  end
603
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
+
604
886
  # A name-independent structural fingerprint of a union's members, so two
605
887
  # occurrences that generate identical structs collapse to one Ruby type.
606
- def union_signature(members)
607
- 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(",")
608
892
  end
609
893
 
610
894
  # Structural signature of a node — ignores the generated class name (which
@@ -621,7 +905,7 @@ class GraphWeaver::Codegen
621
905
  when ObjectNode
622
906
  inner = node.fields.map { |f| "#{f.prop}=#{signature(f.node)}" }.sort.join(",")
623
907
  "o:#{node.graphql_type}(#{inner})"
624
- when UnionNode then "u:(#{union_signature(node.members)})"
908
+ when UnionNode then "u:(#{union_signature(node.members, node.catch_all)})"
625
909
  when UnionRefNode then "ur:#{node.class_name}" # hoisted — identity is its shared name
626
910
  else "x:#{node.object_id}" # unknown node kind — never collapse
627
911
  end
@@ -648,8 +932,7 @@ class GraphWeaver::Codegen
648
932
  when "SCALAR"
649
933
  scalar_node(core.graphql_name)
650
934
  when "ENUM"
651
- mapped_enum_node(core) || (@variable_enums[core.graphql_name] ||=
652
- EnumNode.new(camelize(core.graphql_name), core.values.keys.sort))
935
+ mapped_enum_node(core) || (@enums[core.graphql_name] ||= enum_node(core))
653
936
  when "INPUT_OBJECT"
654
937
  input_node(core)
655
938
  else
@@ -665,40 +948,70 @@ class GraphWeaver::Codegen
665
948
  return @variable_inputs[core.graphql_name] if @variable_inputs.key?(core.graphql_name)
666
949
 
667
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?
668
952
  # sorted so output is deterministic across schema sources
669
953
  core.arguments.values.sort_by(&:graphql_name).each do |argument|
670
954
  prop = underscore(argument.graphql_name)
671
- # prop readers are bare method calls in the generated struct
672
- 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)
673
960
  raise GraphWeaver::Error,
674
961
  "input field #{core.graphql_name}.#{argument.graphql_name} would become prop '#{prop}', " \
675
- "which collides with #{RUBY_KEYWORDS.include?(prop) ? "a Ruby keyword" : "the struct's generated ##{prop}"}"
962
+ "which collides with a method every struct defines"
676
963
  end
677
964
 
678
- child = type_ref(argument.type) { variable_core(unwrap(argument.type)) }
965
+ child = type_ref(argument.type) { variable_core(argument.type.unwrap) }
679
966
  required = child.non_null? && !argument.default_value?
680
967
  node.fields << InputNode::Field.new(prop, argument.graphql_name, child, required)
681
968
  end
682
969
  node
683
970
  end
684
971
 
685
- # The InputNodes a struct's fields reference, through NON_NULL/LIST
686
- # 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
687
998
 
999
+ values
1000
+ end
688
1001
 
689
- # Registered helper-module names for a GraphQL type (additive: global
690
- # registrations plus this client's), collecting their requires.
1002
+ # Registered helper-module names for a GraphQL type, collecting their requires.
691
1003
  def type_mixins(graphql_name)
692
- entries = [GraphWeaver::Codegen.type_registry[graphql_name], @types[graphql_name]].compact
693
- entries.each { |entry| @requires.concat(entry[:requires]) }
694
- 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)
695
1009
  end
696
1010
 
697
1011
  # The MappedEnum node for a schema enum with a registered app-enum
698
- # mapping (client overlay first, then the global registry); nil when
699
- # unregistered, falling back to a generated T::Enum.
1012
+ # mapping; nil when unregistered, falling back to a generated T::Enum.
700
1013
  def mapped_enum_node(core)
701
- enum_type = @enums[core.graphql_name] || GraphWeaver::Codegen.enum_registry[core.graphql_name]
1014
+ enum_type = GraphWeaver::Codegen.enum_registry[core.graphql_name]
702
1015
  return unless enum_type
703
1016
 
704
1017
  @requires.concat(enum_type.requires)
@@ -708,16 +1021,32 @@ class GraphWeaver::Codegen
708
1021
  # A Scalar node, recording any requires its registered type needs so the
709
1022
  # generated file can require them (collected across the whole query).
710
1023
  # Resolution, most specific first: a per-field override (`Type.field`), then
711
- # the scalar-name registration — each checked client-scoped, then global.
1024
+ # the scalar-name registration.
712
1025
  def scalar_node(name, coordinate = nil)
713
- scalar =
714
- (coordinate && (@scalars[coordinate] || GraphWeaver::Codegen.scalar_registry[coordinate])) ||
715
- @scalars[name.to_s] ||
716
- 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
717
1032
  @requires.concat(scalar.requires)
718
1033
  Scalar.new(scalar)
719
1034
  end
720
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
+
721
1050
  # rebuild the NON_NULL/LIST wrappers around the core node
722
1051
  def type_ref(type, &core)
723
1052
  case type.kind.name
@@ -730,21 +1059,45 @@ class GraphWeaver::Codegen
730
1059
  end
731
1060
  end
732
1061
 
733
- def unwrap(type)
734
- type = type.of_type while type.kind.name == "NON_NULL" || type.kind.name == "LIST"
735
- type
736
- 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
737
1080
 
738
- # GraphQL type names become struct names — camelized, because schemas
739
- # in the wild use snake_case type names (Hasura, PostGraphile) and a
740
- # verbatim lowercase name is not a Ruby constant
741
- def pick_name(type_name, key, taken)
742
- candidate = camelize(type_name)
743
- candidate = "#{camelize(key)}#{candidate}" if taken.include?(candidate)
744
- 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
745
1091
 
746
- taken << candidate
747
- candidate
1092
+ taken << name
1093
+ name
748
1094
  end
749
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
750
1103
  end