graph_weaver 0.6.1 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +8 -0
  3. data/Gemfile.lock +153 -4
  4. data/README.md +45 -79
  5. data/docs/alternatives.md +195 -0
  6. data/docs/cassettes.md +61 -50
  7. data/docs/editors.md +32 -47
  8. data/docs/errors.md +360 -103
  9. data/docs/federation.md +692 -473
  10. data/docs/generated_modules.md +441 -314
  11. data/docs/getting_started.md +370 -194
  12. data/docs/i18n.md +171 -0
  13. data/docs/logging.md +197 -50
  14. data/docs/real_world.md +42 -27
  15. data/docs/scalars.md +307 -176
  16. data/docs/testing.md +473 -220
  17. data/docs/transports.md +224 -151
  18. data/docs/upgrading.md +258 -305
  19. data/examples/README.md +38 -0
  20. data/examples/countries.rb +39 -0
  21. data/examples/federation.rb +62 -0
  22. data/examples/github/generate.rb +20 -0
  23. data/examples/github/generated/star_mutation.rb +126 -0
  24. data/examples/github/generated/stargazers_query.rb +232 -0
  25. data/examples/github/generated/starred_query.rb +151 -0
  26. data/examples/github/queries/star.graphql +8 -0
  27. data/examples/github/queries/stargazers.graphql +22 -0
  28. data/examples/github/queries/starred.graphql +11 -0
  29. data/examples/github/run.rb +43 -0
  30. data/examples/github/setup.rb +18 -0
  31. data/examples/rick_and_morty.rb +57 -0
  32. data/graph_weaver.gemspec +19 -3
  33. data/lib/generators/graph_weaver/install_generator.rb +138 -4
  34. data/lib/graph_weaver/client.rb +69 -11
  35. data/lib/graph_weaver/codegen/aliases.rb +7 -5
  36. data/lib/graph_weaver/codegen/emit.rb +98 -29
  37. data/lib/graph_weaver/codegen/enum_type.rb +2 -1
  38. data/lib/graph_weaver/codegen/nodes.rb +39 -6
  39. data/lib/graph_weaver/codegen/registry.rb +175 -0
  40. data/lib/graph_weaver/codegen/scalar_type.rb +123 -30
  41. data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
  42. data/lib/graph_weaver/codegen.rb +406 -195
  43. data/lib/graph_weaver/coerce.rb +155 -26
  44. data/lib/graph_weaver/context_seam.rb +54 -0
  45. data/lib/graph_weaver/errors.rb +284 -46
  46. data/lib/graph_weaver/federation.rb +129 -27
  47. data/lib/graph_weaver/graph.rb +315 -0
  48. data/lib/graph_weaver/hints.rb +100 -24
  49. data/lib/graph_weaver/in_process.rb +27 -15
  50. data/lib/graph_weaver/input_struct.rb +119 -32
  51. data/lib/graph_weaver/internal/endpoint.rb +80 -0
  52. data/lib/graph_weaver/internal/headers.rb +70 -0
  53. data/lib/graph_weaver/internal/overrides.rb +67 -5
  54. data/lib/graph_weaver/internal/planner.rb +45 -15
  55. data/lib/graph_weaver/internal/refusal.rb +49 -0
  56. data/lib/graph_weaver/internal/schemas.rb +23 -9
  57. data/lib/graph_weaver/internal/selection.rb +34 -0
  58. data/lib/graph_weaver/internal/server_input.rb +251 -0
  59. data/lib/graph_weaver/internal/test_clients.rb +276 -0
  60. data/lib/graph_weaver/internal/unused.rb +287 -0
  61. data/lib/graph_weaver/internal/values.rb +40 -4
  62. data/lib/graph_weaver/internal.rb +249 -14
  63. data/lib/graph_weaver/log_subscriber.rb +74 -0
  64. data/lib/graph_weaver/logging.rb +163 -19
  65. data/lib/graph_weaver/query_module.rb +44 -3
  66. data/lib/graph_weaver/railtie.rb +237 -17
  67. data/lib/graph_weaver/representation.rb +55 -17
  68. data/lib/graph_weaver/result_struct.rb +90 -0
  69. data/lib/graph_weaver/retry.rb +45 -13
  70. data/lib/graph_weaver/rspec.rb +404 -93
  71. data/lib/graph_weaver/schema_loader.rb +266 -56
  72. data/lib/graph_weaver/tasks.rb +380 -89
  73. data/lib/graph_weaver/testing/cassette.rb +34 -10
  74. data/lib/graph_weaver/testing/endpoint.rb +107 -0
  75. data/lib/graph_weaver/testing/failure.rb +69 -12
  76. data/lib/graph_weaver/testing/fake_client.rb +164 -45
  77. data/lib/graph_weaver/testing/router.rb +64 -13
  78. data/lib/graph_weaver/testing.rb +200 -58
  79. data/lib/graph_weaver/transport/faraday.rb +41 -8
  80. data/lib/graph_weaver/transport/http.rb +48 -6
  81. data/lib/graph_weaver/transport.rb +134 -27
  82. data/lib/graph_weaver/version.rb +1 -1
  83. data/lib/graph_weaver.rb +495 -106
  84. metadata +71 -3
  85. data/CHANGELOG.md +0 -2355
@@ -4,6 +4,8 @@
4
4
  require "graphql"
5
5
  require "sorbet-runtime"
6
6
 
7
+ require_relative "result_struct"
8
+
7
9
  # Generates plain, statically-typecheckable Ruby from a GraphQL query +
8
10
  # schema: nested T::Structs, from_h casting code, and a sig'd execute
9
11
  # method. The output is source on disk, so srb tc sees the exact result
@@ -32,6 +34,8 @@ require_relative "codegen/scalar_type"
32
34
  require_relative "codegen/nodes"
33
35
  require_relative "codegen/aliases"
34
36
  require_relative "codegen/emit"
37
+ # after the three files that fill Registrations in — it gives them a home
38
+ require_relative "codegen/registry"
35
39
 
36
40
  class GraphWeaver::Codegen
37
41
  include GraphWeaver::Inflect
@@ -61,7 +65,9 @@ class GraphWeaver::Codegen
61
65
  #
62
66
  # client: (a constant, or its name as a string) becomes the generated
63
67
  # module's baked default; when omitted, generated code falls back to
64
- # the app default (GraphWeaver.client=). name: is the module the file
68
+ # the app default (GraphWeaver.client=). graph_name: is the graph the
69
+ # module belongs to, baked in so a test mode can build its stand-in from
70
+ # the right schema. name: is the module the file
65
71
  # defines, defaulting to the operation's own name; default_name: is
66
72
  # parse's container-scoped fallback (file generation stays strict — a
67
73
  # checked-in file deserves a deliberate name). types_namespace: is the shared-types workflow (see
@@ -73,9 +79,13 @@ class GraphWeaver::Codegen
73
79
  # shared module (see used_union_names). path: is the file the query was read
74
80
  # from, named alongside line and column in validation errors.
75
81
  def initialize(schema:, query:, name: nil, client: nil, default_name: nil,
76
- types_namespace: nil, hoistable_unions: nil, path: nil, module_name: nil)
82
+ types_namespace: nil, hoistable_unions: nil, path: nil, module_name: nil,
83
+ graph_name: nil, registry: GraphWeaver::Codegen.registry)
77
84
  renamed!(module_name)
78
85
  @schema = schema
86
+ # the registrations this generation reads — one graph's, or the default
87
+ # graph's, which is what every top-level register_scalar writes to
88
+ @registry = registry
79
89
  @query = query.strip
80
90
  # only ever quoted in a message, so it is stored the way it is reported
81
91
  @path = path && GraphWeaver::Internal::Util.relative(path)
@@ -87,11 +97,26 @@ class GraphWeaver::Codegen
87
97
  # scalars this generation had no registration for (see report_untyped_scalars)
88
98
  @untyped_scalars = []
89
99
  @client_const = CLIENT_CONST.call(client)
100
+ # the graph this module belongs to, baked in beside the client: a test
101
+ # mode builds its stand-in from the module's own schema, and only the
102
+ # module can say whose that is (GraphWeaver::Internal::TestClients).
103
+ # A Symbol, as GraphWeaver.graph makes it — the name is the identity.
104
+ @graph_name = graph_name&.to_sym
90
105
 
91
106
  if client && @client_const.nil?
92
107
  # a live object can't be spelled in generated source — parse can
93
108
  # set one via the module's writer, but file generation cannot
94
- raise ArgumentError, "client: must be a named constant or String (got #{client.inspect}); pass live objects to parse"
109
+ raise ArgumentError, "client: must be a named constant or String (got #{client.inspect}) " \
110
+ "put the object in a constant and name it, client: \"MyApi::CLIENT\"; pass live objects to parse"
111
+ end
112
+ # The String is written into the module verbatim, so anything that isn't a
113
+ # constant path emits source that doesn't parse. A url is the way to get
114
+ # here — it is where the endpoint is spelled everywhere else — so the fix
115
+ # names the value that was passed.
116
+ if @client_const && !@client_const.match?(CONSTANT_NAME)
117
+ raise ArgumentError, "client: #{@client_const.inspect} isn't a constant — generated source " \
118
+ "spells this name, so it has to be one: CLIENT = GraphWeaver.new(#{@client_const.inspect}), " \
119
+ "then client \"CLIENT\""
95
120
  end
96
121
  end
97
122
 
@@ -126,10 +151,10 @@ class GraphWeaver::Codegen
126
151
  # file, but invisible to srb tc — use the build step for static typing.
127
152
  # Evaluates into an anonymous container, so no global constants leak;
128
153
  # client: additionally accepts a live object (set via .client=).
129
- def self.parse(schema:, query:, name: nil, client: nil, path: nil, module_name: nil)
154
+ def self.parse(schema:, query:, name: nil, client: nil, path: nil, module_name: nil, graph_name: nil)
130
155
  client_const = CLIENT_CONST.call(client)
131
156
 
132
- codegen = new(schema:, query:, name:, client: client_const, path:, module_name:,
157
+ codegen = new(schema:, query:, name:, client: client_const, path:, module_name:, graph_name:,
133
158
  default_name: "Query")
134
159
  source = codegen.generate
135
160
 
@@ -148,16 +173,6 @@ class GraphWeaver::Codegen
148
173
  mod
149
174
  end
150
175
 
151
- # Every registry back to its starting state — scalars (built-ins restored),
152
- # enum mappings, and type helpers. The clean slate between tests, and the
153
- # one call that stays right when a fourth kind of registration shows up.
154
- def self.reset_registrations!
155
- reset_scalars!
156
- reset_enums!
157
- reset_type_helpers!
158
- self
159
- end
160
-
161
176
  # The schema-level types this walk touched, by GraphQL name — the generate!
162
177
  # workflow unions these across queries to decide what the shared types module
163
178
  # must contain.
@@ -170,6 +185,11 @@ class GraphWeaver::Codegen
170
185
  # must contain.
171
186
  def used_union_names = @used_unions.dup
172
187
 
188
+ # The custom scalars this walk found no registration for (see
189
+ # report_untyped_scalars) — the generate! workflow unions these across
190
+ # queries so the build says them once, for GraphWeaver.untyped_scalars.
191
+ def untyped_scalars = @untyped_scalars.uniq.sort
192
+
173
193
  # The shared types artifact: every type a schema shares across query modules,
174
194
  # emitted once as a manifest (types.rb) plus one file per type under types/,
175
195
  # so a schema migration diffs only the types it touched. Returns
@@ -253,6 +273,12 @@ class GraphWeaver::Codegen
253
273
  def reset_walk_state!
254
274
  @enums = {}
255
275
  @variable_inputs = {}
276
+ # the variable declaration the input walk descends from, and the field
277
+ # hops it has taken since — an input collision is about a type the user
278
+ # usually never named, so the refusal has to point back at what they wrote
279
+ @input_from = nil
280
+ @input_list = false
281
+ @input_hops = []
256
282
  @mapped_enums = {}
257
283
  @used_unions = []
258
284
  # requires the generated file needs (custom scalars, enum mappings,
@@ -265,6 +291,15 @@ class GraphWeaver::Codegen
265
291
  CONSTANT_NAME = /\A[A-Z]\w*(::[A-Z]\w*)*\z/
266
292
 
267
293
  def validate_module_name!(subject)
294
+ # Generated code spells Sorbet's T inside the module's own body (T.let,
295
+ # T::Struct), and constant lookup finds a top-level T first — so a module
296
+ # whose path starts with one shadows the thing it depends on. A generated
297
+ # file can't get here (a file name always gains a Query/Mutation word);
298
+ # an explicit name:, a namespace, and parse can.
299
+ if @name&.split("::")&.first == "T"
300
+ raise ArgumentError, "#{subject} #{@name.inspect} — a top-level module named T shadows " \
301
+ "Sorbet's T, which generated code uses in its own body (T.let, T::Struct)"
302
+ end
268
303
  return if @name&.match?(CONSTANT_NAME)
269
304
 
270
305
  problem = "#{subject} must be a constant name, got #{@name.inspect}"
@@ -299,23 +334,90 @@ class GraphWeaver::Codegen
299
334
  # Ruby local is unreachable by a GraphQL variable name, so this is a guard
300
335
  # rather than a rename.
301
336
  RESERVED_KWARGS = %w[client variables].to_set.freeze
302
- # Every method a struct instance already answers: T::Props refuses to redefine
303
- # those (`class`, `hash`, `send`, `to_s`), so the generated file would raise
304
- # ArgumentError at require time. Derived rather than listed, so it tracks
305
- # whatever the Ruby and sorbet-runtime in play actually define.
306
- STRUCT_METHODS = (GENERATED_METHODS + T::Struct.instance_methods.map(&:to_s)).freeze
307
- private_constant :RUBY_KEYWORDS, :GENERATED_METHODS, :RESERVED_KWARGS, :STRUCT_METHODS
337
+ # One rule: a prop may not shadow a method its struct answers. Three kinds of
338
+ # name land in it the ones T::Struct and Object answer PUBLICLY, the hooks
339
+ # Ruby and Rails call on any object without it defining one, and the ones the
340
+ # gem's own mixins define.
341
+ #
342
+ # Kernel's PRIVATE methods are deliberately NOT here. A struct doesn't answer
343
+ # them, so a prop shadows one only for a bare call from inside the struct —
344
+ # which is the gem's code to keep qualified (`Kernel.raise` in hints.rb and
345
+ # input_struct.rb), not the schema's to avoid. Reserving them refused real
346
+ # columns: a Hasura bool_exp has one input field per column, and `format`,
347
+ # `select`, `test`, `open`, `load` and `pp` are all columns somebody has.
348
+ #
349
+ # Listed, not derived from the live T::Struct: derivation made generation a
350
+ # function of require order — with ActiveSupport loaded first `as_json` was
351
+ # refused, loaded second it became a prop that shadowed the real #as_json —
352
+ # and generated output must depend on nothing but the schema, the query and
353
+ # the gem. sorbet-runtime's own BANNED_METHOD_NAMES is that same snapshot of
354
+ # `Object.instance_methods`, so it can't be leaned on either. A name a later
355
+ # Ruby adds and this misses is loud anyway: the generated file raises
356
+ # ArgumentError when it is required.
357
+ #
358
+ # The gem's own mixins stay derived — they are the gem's to track, so adding
359
+ # a method to one reserves its name without a second edit here.
360
+ RESERVED_PROPS = (GENERATED_METHODS +
361
+ # what T::Struct and Object answer publicly. `to_yaml` and `pretty_print*`
362
+ # ride in on psych and pp, which land on Object whenever they are loaded —
363
+ # listed unconditionally, since what else is loaded is not generation's
364
+ # business.
365
+ %w[
366
+ ! != !~ <=> == === __id__ __send__
367
+ class clone define_singleton_method deserialize display dup enum_for eql?
368
+ equal? extend freeze frozen? hash inspect instance_eval instance_exec
369
+ instance_of? instance_variable_defined? instance_variable_get instance_variable_set
370
+ instance_variables is_a? itself kind_of? method methods nil? object_id
371
+ pretty_inspect pretty_print pretty_print_cycle pretty_print_inspect
372
+ pretty_print_instance_variables private_methods protected_methods
373
+ public_method public_methods public_send remove_instance_variable respond_to?
374
+ send serialize singleton_class singleton_method singleton_methods tap then
375
+ to_enum to_s to_yaml with yield_self
376
+ ] +
377
+ # hooks called on an object that doesn't define them: `initialize` from
378
+ # .new, `initialize_copy` from dup, `to_ary` from `puts`, `deconstruct`
379
+ # from an array pattern, `as_json` from `render json:`
380
+ %w[
381
+ as_json deconstruct deconstruct_keys each initialize initialize_clone
382
+ initialize_copy initialize_dup presence to_a to_ary to_hash to_int to_json
383
+ to_param to_proc to_query to_str try
384
+ ] +
385
+ [GraphWeaver::ResultStruct, GraphWeaver::Hints, GraphWeaver::InputStruct].flat_map { |mod|
386
+ (mod.instance_methods(false) + mod.private_instance_methods(false)).map(&:to_s)
387
+ }).freeze
388
+ private_constant :RUBY_KEYWORDS, :GENERATED_METHODS, :RESERVED_KWARGS, :RESERVED_PROPS
389
+
390
+ # The Ruby name for a GraphQL field or result key: snake_case, plus a
391
+ # trailing underscore when that would shadow a method the struct answers
392
+ # (`class` -> `class_`, `hash` -> `hash_`). A schema's field name is not the
393
+ # user's to rename, and `class`, `hash`, `display` and `supplied` are all
394
+ # columns somebody has — so renaming beats refusing. Only the Ruby side
395
+ # moves: the wire name, and everything read from or written to the wire,
396
+ # is untouched.
397
+ #
398
+ # Public because the rule has readers outside generation: Hints maps a wire
399
+ # key onto the prop that holds it, and the round-trip harness checks what
400
+ # came back against them. A second spelling of the rule is how the two sides
401
+ # drift apart.
402
+ def self.prop_name(graphql_name)
403
+ prop = GraphWeaver::Inflect.underscore(graphql_name)
404
+ RESERVED_PROPS.include?(prop) ? "#{prop}_" : prop
405
+ end
308
406
 
309
407
  def generate
310
- begin
311
- errors = @schema.validate(@query)
408
+ document = begin
409
+ GraphQL.parse(@query)
312
410
  rescue GraphQL::ParseError => e
313
411
  # unparseable queries wrap like invalid ones — everything raised
314
412
  # here descends from GraphWeaver::Error
315
- raise GraphWeaver::ValidationError.new([detail(e.message, e.line, e.col)])
413
+ raise GraphWeaver::QueryValidationError.new([detail(e.message, e.line, e.col)])
316
414
  end
415
+
416
+ refuse_incremental!(document)
417
+
418
+ errors = @schema.validate(document)
317
419
  if errors.any?
318
- raise GraphWeaver::ValidationError.new(errors.map { |e| validation_detail(e) })
420
+ raise GraphWeaver::QueryValidationError.new(errors.map { |e| validation_detail(e) })
319
421
  end
320
422
 
321
423
  validate_registrations!
@@ -340,7 +442,7 @@ class GraphWeaver::Codegen
340
442
  # operationName the document doesn't declare). The conventional .graphql
341
443
  # file names nothing, so without this every trace arrives anonymous.
342
444
  operation_name = operation.name || @name.split("::").last
343
- @query = declare_operation_name(operation, operation_name) unless operation.name
445
+ @query = declare_operation_name(operation_name) unless operation.name
344
446
 
345
447
  emit_module(root, variables, representation_nodes(operation, root_type), operation_name)
346
448
  .tap { report_untyped_scalars }
@@ -352,12 +454,51 @@ class GraphWeaver::Codegen
352
454
  # document exactly as written — re-printing the AST would reformat the query
353
455
  # the reader reviews. The module name is already constrained to
354
456
  # /[A-Z]\w*(::[A-Z]\w*)*/, so its last segment is always a legal GraphQL name.
355
- def declare_operation_name(operation, name)
356
- at = @query.lines.first(operation.line - 1).sum(&:length) + operation.col - 1
457
+ def declare_operation_name(name)
458
+ at = operation_offset
357
459
  keyword = @query[at..].to_s[/\A(?:query|mutation|subscription)\b/]
358
- return "#{@query[0, at]}query #{name} #{@query[at..]}" unless keyword # `{ ... }` shorthand
460
+ named = if keyword
461
+ "#{@query[0, at + keyword.length]} #{name}#{@query[(at + keyword.length)..]}"
462
+ else # `{ ... }` shorthand
463
+ "#{@query[0, at]}query #{name} #{@query[at..]}"
464
+ end
359
465
 
360
- "#{@query[0, at + keyword.length]} #{name}#{@query[(at + keyword.length)..]}"
466
+ declares!(named, name)
467
+ end
468
+
469
+ # Where the operation starts in @query, in characters. graphql-ruby subtracts
470
+ # a CHARACTER line start from the lexer's BYTE position
471
+ # (Language::Parser#column_at over Lexer's @scanner.pos), so line start plus
472
+ # col is a byte offset — and col falls back to a whole-document offset for a
473
+ # token with no newline after it, which .strip guarantees for the last line,
474
+ # hence the copy that ends in one.
475
+ def operation_offset
476
+ operation = sole_operation("#{@query}\n")
477
+ bytes = @query.lines.first(operation.line - 1).sum(&:length) + operation.col - 1
478
+ @query.byteslice(0, bytes).length
479
+ end
480
+
481
+ # The splice above is arithmetic over a position graphql-ruby reports, so it
482
+ # can land wrong and still emit a module that looks fine — and OPERATION_NAME
483
+ # would then be an operationName the document doesn't declare, which every
484
+ # server rejects. Nothing else re-reads the query, so this is the only place
485
+ # that can refuse.
486
+ def declares!(query, name)
487
+ declared = begin
488
+ sole_operation(query)&.name
489
+ rescue GraphQL::ParseError
490
+ nil
491
+ end
492
+ return query if declared == name
493
+
494
+ raise GraphWeaver::Error, "#{@path ? "#{@path}: " : ""}could not name the anonymous operation — " \
495
+ "the document GraphWeaver would send does not declare #{name.inspect}. Name the operation in " \
496
+ "the query itself (`query #{name} { ... }`) and please report this as a bug."
497
+ end
498
+
499
+ # load_operation has already refused a document with a second one.
500
+ def sole_operation(query)
501
+ GraphQL.parse(query).definitions.grep(GraphQL::Language::Nodes::OperationDefinition).first
361
502
  end
362
503
 
363
504
  # The operation's variables as execute's kwarg surface: one VarDef each,
@@ -365,6 +506,9 @@ class GraphWeaver::Codegen
365
506
  # optional kwargs default to nil and are omitted from the wire.
366
507
  def build_variables(operation)
367
508
  variables = operation.variables.map do |var|
509
+ @input_from = "$#{var.name}"
510
+ @input_list = ast_list?(var.type)
511
+ @input_hops = []
368
512
  node = ast_type_ref(var.type)
369
513
  required = node.non_null? && var.default_value.nil?
370
514
  kwarg = underscore(var.name)
@@ -475,46 +619,95 @@ class GraphWeaver::Codegen
475
619
  # "id organization { id }" — flattened to the dotted leaf paths the wire
476
620
  # hash needs. The same reading the routing table does of the same syntax,
477
621
  # so a supergraph and a subgraph SDL can't disagree about one key.
622
+ #
623
+ # Each hop the schema declares as a list is marked: "id lineItems { sku }"
624
+ # over a `[LineItem!]!` becomes "lineItems[].sku". The runtime builds the
625
+ # wire representation from these paths and nothing else, so list-ness has
626
+ # to travel in them — otherwise a list arrives as one object and the
627
+ # subgraph is asked about an entity that doesn't exist.
478
628
  def key_paths(entity, fields)
479
- GraphWeaver::SchemaLoader::RoutingTable.parse_field_set(fields)
629
+ GraphWeaver::SchemaLoader::RoutingTable.parse_field_set(fields).map do |path|
630
+ mark_lists(entity.graphql_name, path)
631
+ end
480
632
  rescue GraphQL::ParseError => e
481
633
  raise GraphWeaver::Error, "#{entity.graphql_name} @key(fields: #{fields.inspect}) isn't a selection set: #{e.message}"
482
634
  end
483
635
 
636
+ # A hop the schema doesn't declare is left as written — key_params names
637
+ # the first of those, and a deeper one is the runtime's to complain about.
638
+ def mark_lists(type_name, path)
639
+ path.split(".").map do |segment|
640
+ field = @schema.get_field(type_name, segment) or next segment
641
+
642
+ type_name = field.type.unwrap.graphql_name
643
+ field.type.list? ? "#{segment}#{GraphWeaver::Representation::LIST_HOP}" : segment
644
+ end.join(".")
645
+ end
646
+
647
+ # The leaf a nested @key passes through as: an open hash the runtime
648
+ # narrows to the declared sub-paths, rather than the sig. Identity
649
+ # everywhere, so `key_node` can wrap it in the same List a scalar leaf gets.
650
+ class OpaqueHash < Node
651
+ def bare_type = "T::Hash[T.untyped, T.untyped]"
652
+ def identity? = true
653
+ def serialize_identity? = true
654
+ def hash_coerce_identity? = true
655
+ end
656
+ private_constant :OpaqueHash
657
+
484
658
  # The kwargs a builder takes: every key set's top-level field, once. Typed
485
659
  # from the schema — a leaf key field gets its registered scalar's Ruby
486
- # type, a nested one an open Hash whose shape the runtime checks.
660
+ # type, a nested one an open Hash whose shape the runtime checks, and a key
661
+ # field the schema declares as a list takes a list of whichever it is.
487
662
  def key_params(entity, key_sets, required:)
488
- key_sets.flatten.map { |path| path.split(".").first }.uniq.map do |name|
663
+ key_sets.flatten.map { |path|
664
+ path.split(".").first.delete_suffix(GraphWeaver::Representation::LIST_HOP)
665
+ }.uniq.map do |name|
489
666
  field = @schema.get_field(entity.graphql_name, name)
490
667
  unless field
491
668
  raise GraphWeaver::Error, "#{entity.graphql_name} @key names #{name.inspect}, which the type doesn't declare"
492
669
  end
493
670
 
494
- kwarg = underscore(name)
495
- if RUBY_KEYWORDS.include?(kwarg)
496
- raise GraphWeaver::Error,
497
- "#{entity.graphql_name} @key field #{name.inspect} would become the kwarg '#{kwarg}:', " \
498
- "which generated code can't declare (a Ruby keyword)"
499
- end
500
-
671
+ kwarg = key_kwarg(name)
501
672
  core = field.type.unwrap
502
- if core.kind.name == "SCALAR"
503
- node = scalar_node(core.graphql_name, "#{entity.graphql_name}.#{name}")
504
- type = required ? node.bare_type : node.prop_type
505
- value = representation_value(entity, name, kwarg, node)
673
+ leaf = if core.kind.name == "SCALAR"
674
+ scalar_node(core.graphql_name, "#{entity.graphql_name}.#{name}")
506
675
  else
507
- # a nested key set or an enum/composite one — passes through as an
508
- # open hash, narrowed to the declared sub-paths by the runtime
509
- type = "T::Hash[T.untyped, T.untyped]"
510
- type = "T.nilable(#{type})" unless required
511
- value = kwarg
676
+ # a nested key set, or an enum one
677
+ OpaqueHash.new
512
678
  end
513
-
514
- RepresentationNode::Param.new(kwarg, name, type, value, required)
679
+ # the outermost non-null is `required`'s call rather than the SDL's: an
680
+ # entity with two @keys makes every kwarg optional however it declares
681
+ # the fields
682
+ node = key_node(field.type.non_null? ? field.type.of_type : field.type, leaf)
683
+ type = required ? node.bare_type : node.prop_type
684
+
685
+ RepresentationNode::Param.new(kwarg, name, type,
686
+ representation_value(entity, name, kwarg, node), required)
515
687
  end
516
688
  end
517
689
 
690
+ # A @key field's list wrappers, rebuilt around its leaf node — `@key(fields:
691
+ # "id lineItems { sku }")` over a `[LineItem!]!` takes a list of hashes, and
692
+ # `Representation` walks it to reach the leaf of every element.
693
+ def key_node(type, leaf)
694
+ return NonNull.new(key_node(type.of_type, leaf)) if type.non_null?
695
+ return List.new(key_node(type.of_type, leaf)) if type.list?
696
+
697
+ leaf
698
+ end
699
+
700
+ # A @key field's kwarg. The prop rule, plus the one thing a kwarg can't be
701
+ # that a prop can: a Ruby keyword, since a kwarg is declared and passed
702
+ # bare. Renaming rather than refusing, because a subgraph's @key field is
703
+ # even less the user's to rename than a schema's field name — there is no
704
+ # escape to point them at. The SAME underscore the entity struct's prop
705
+ # took, or reading `slot.hash_` and writing it back is an ArgumentError.
706
+ def key_kwarg(name)
707
+ kwarg = GraphWeaver::Codegen.prop_name(name)
708
+ RUBY_KEYWORDS.include?(kwarg) ? "#{kwarg}_" : kwarg
709
+ end
710
+
518
711
  # The kwarg's trip onto the wire — the same normalize-then-serialize an
519
712
  # execute kwarg gets (see Emit#variable_serialize), since the builder's sig
520
713
  # is `.checked(:never)` too and a representation is built from params just
@@ -528,96 +721,6 @@ class GraphWeaver::Codegen
528
721
  "{ |v1| #{inner} }"
529
722
  end
530
723
 
531
- # What a registry's names must be in the schema. extend_type decorates
532
- # whatever composite a query reaches, so it demands no particular kind.
533
- REGISTERED_KIND = { "scalar" => "SCALAR", "enum" => "ENUM" }.freeze
534
- # the type registry is reached via extend_type; scalars/enums via register_*
535
- REGISTRATION_METHOD = { "type" => "extend_type", "scalar" => "register_scalar", "enum" => "register_enum" }.freeze
536
- private_constant :REGISTERED_KIND, :REGISTRATION_METHOD
537
-
538
- # Every registration this schema can't match, one sentence each. The answer
539
- # depends on the schema and the registry alone, not on any one document, so
540
- # a whole generate! run gets the same list — which is what lets the build
541
- # report it once (see GraphWeaver.unmatched_registrations).
542
- #
543
- # The built-in scalars are pre-registered entries in the same table rather
544
- # than user intent, so they're exempt — a schema with no Date scalar is not
545
- # a mistake.
546
- def self.unmatched_registrations(schema)
547
- {
548
- "enum" => enum_registry,
549
- "scalar" => scalar_registry.except(*BUILTIN_SCALARS),
550
- "type" => type_registry,
551
- }.flat_map do |kind, registry|
552
- registry.keys.filter_map { |name| validate_registration!(schema, kind, name) }
553
- end
554
- end
555
-
556
- # One registry serves the whole graph, but a generation sees one schema — so
557
- # a registration fails generation only where THIS schema can disprove it: a
558
- # name it declares as something else, or a coordinate whose field it declares
559
- # as a composite. A name it can't match at all proves nothing, because an
560
- # entity type is declared by every subgraph that references it while its
561
- # fields are split among them; that returns the sentence to say instead.
562
- def self.validate_registration!(schema, kind, name)
563
- method = REGISTRATION_METHOD.fetch(kind)
564
- # register_scalar("Type.field", ...) overrides one field's scalar — validate
565
- # the field, not that a type named "Type.field" exists.
566
- return validate_scalar_field!(schema, name, method) if kind == "scalar" && name.include?(".")
567
-
568
- type = schema.get_type(name)
569
- return unmatched(schema, method, name, kind, GraphWeaver::Internal::Util.did_you_mean(schema.types.keys, name)) unless type
570
-
571
- expected = REGISTERED_KIND[kind]
572
- return if expected.nil? || type.kind.name == expected
573
-
574
- found = type.kind.name.downcase.tr("_", " ")
575
- # a leaf registered as the other kind has a method that would have worked
576
- other = REGISTERED_KIND.key(type.kind.name)
577
- raise GraphWeaver::Error,
578
- "#{method}(#{name.inspect}) names #{article(found)} #{found}, not #{article(kind)} " \
579
- "#{kind}#{other ? " — use #{REGISTRATION_METHOD.fetch(other)}" : ""}"
580
- end
581
- private_class_method :validate_registration!
582
-
583
- # A per-field override, register_scalar("Type.field", ...). Neither an absent
584
- # type nor an absent field is disprovable here; what is, is a field this
585
- # schema declares as something a scalar codec could never read.
586
- def self.validate_scalar_field!(schema, name, method)
587
- type_name, field_name = name.split(".", 2)
588
- type = schema.get_type(type_name)
589
- unless type
590
- near = GraphWeaver::Internal::Util.did_you_mean(schema.types.keys, type_name)
591
- return unmatched(schema, method, name, "scalar field", near && "#{near}.#{field_name}")
592
- end
593
-
594
- fields = type.respond_to?(:fields) ? type.fields : {}
595
- field = fields[field_name]
596
- unless field
597
- near = GraphWeaver::Internal::Util.did_you_mean(fields.keys, field_name)
598
- return unmatched(schema, method, name, "scalar field", near && "#{type_name}.#{near}")
599
- end
600
- return if field.type.unwrap.kind.name == "SCALAR"
601
-
602
- raise GraphWeaver::Error,
603
- "#{method}(#{name.inspect}): #{name} isn't a scalar field (it's #{field.type.unwrap.kind.name.downcase})"
604
- end
605
- private_class_method :validate_scalar_field!
606
-
607
- # What to say about a name this schema has nothing for. Registrations are
608
- # graph-scoped — federation composes by name, so one `Money` codec serves
609
- # every subgraph that declares it — which is exactly why this schema can't
610
- # tell a typo from a registration for the subgraph next door. Say both.
611
- def self.unmatched(schema, method, name, what, suggestion)
612
- hint = suggestion ? " (did you mean '#{suggestion}'?)" : ""
613
- "#{method}(#{name.inspect}) matches no #{what} in #{schema.name || "this schema"} " \
614
- "— a typo#{hint}, or a registration for another schema"
615
- end
616
- private_class_method :unmatched
617
-
618
- def self.article(word) = word.downcase.start_with?(/[aeiou]/) ? "an" : "a"
619
- private_class_method :article
620
-
621
724
  # Parse every fragment file under `paths` into one { name => FragmentDefinition }
622
725
  # map — reusable fragments a query can spread. Fragment files hold only
623
726
  # fragments (no operations); names are unique across them.
@@ -664,7 +767,7 @@ class GraphWeaver::Codegen
664
767
  GraphQL.parse(query)
665
768
  rescue GraphQL::ParseError => e
666
769
  prefix = [path && GraphWeaver::Internal::Util.relative(path), e.line, e.col].compact.join(":")
667
- raise GraphWeaver::ValidationError.new(
770
+ raise GraphWeaver::QueryValidationError.new(
668
771
  [{ message: prefix.empty? ? e.message : "#{prefix} #{e.message}", line: e.line, column: e.col }],
669
772
  )
670
773
  end
@@ -705,14 +808,32 @@ class GraphWeaver::Codegen
705
808
  end
706
809
  private_class_method :fragment_spreads
707
810
 
811
+ # @defer/@stream deliver the answer in instalments over a multipart body,
812
+ # and generated code reads one JSON response — so there is nothing to
813
+ # generate, whether or not the schema in hand declares the directives.
814
+ # Above validation because most schemas don't: "Directive @defer is not
815
+ # defined" reads like a typo, and it stops being raised at all the day a
816
+ # supergraph @links the defer spec.
817
+ def refuse_incremental!(document)
818
+ node = GraphWeaver::Internal::Selection.incremental_directive(document) or return
819
+
820
+ raise GraphWeaver::QueryValidationError.new([detail(
821
+ "this query carries @#{node.name}, and the answer would arrive in more than one payload " \
822
+ "(incremental delivery) — generated code reads a single JSON response, so drop the " \
823
+ "directive or move those selections into their own query",
824
+ node.line, node.col,
825
+ )])
826
+ end
827
+ private :refuse_incremental!
828
+
708
829
  # Structured shape for a schema-validation error: message plus its first
709
- # source location, so ValidationError#errors is inspectable.
830
+ # source location, so QueryValidationError#errors is inspectable.
710
831
  def validation_detail(error)
711
832
  loc = (error.to_h["locations"]&.first if error.respond_to?(:to_h))
712
833
  detail(error.message, loc && loc["line"], loc && loc["column"])
713
834
  end
714
835
 
715
- # One ValidationError entry, its message prefixed "file:line:col" like a
836
+ # One QueryValidationError entry, its message prefixed "file:line:col" like a
716
837
  # compiler — the position is captured either way, and without it a project
717
838
  # with thirty query files leaves the reader hunting for the typo.
718
839
  def detail(message, line, column)
@@ -725,7 +846,7 @@ class GraphWeaver::Codegen
725
846
  # The build channel prints the same list once per run; see
726
847
  # GraphWeaver.unmatched_registrations.
727
848
  def validate_registrations!
728
- self.class.unmatched_registrations(@schema).each { |message| GraphWeaver::Internal::Log.log(:warn) { message } }
849
+ @registry.unmatched_registrations(@schema).each { |message| GraphWeaver::Internal::Log.log(:warn) { message } }
729
850
  end
730
851
 
731
852
  # The @include/@skip a fragment carries applies to what it guards, so it has
@@ -755,7 +876,9 @@ class GraphWeaver::Codegen
755
876
  node = ObjectNode.new(class_name)
756
877
  node.graphql_type = type.graphql_name
757
878
  node.mixins = type_mixins(type.graphql_name)
758
- taken = [class_name]
879
+ # class name => the result key that claimed it; the struct itself first,
880
+ # claimed by nothing (see pick_name)
881
+ taken = { class_name => nil }
759
882
  # Dedup structurally-identical dispatch-union fields on this struct: the
760
883
  # same union selected two ways (unblockOptions vs selectedOption) shares
761
884
  # one Ruby type, so consumers get one exhaustive `case ... T.absurd`.
@@ -765,7 +888,7 @@ class GraphWeaver::Codegen
765
888
  gather_conditional(type, selections).each do |key, occurrences|
766
889
  field_nodes = occurrences.map(&:first)
767
890
  field_name = field_nodes.first.name
768
- prop = underscore(key)
891
+ prop = GraphWeaver::Codegen.prop_name(key)
769
892
  check_output_prop!(type, key, prop, props)
770
893
 
771
894
  child = if field_name == "__typename"
@@ -867,7 +990,7 @@ class GraphWeaver::Codegen
867
990
  def check_shadowing!(node, scope = {})
868
991
  case node
869
992
  when UnionNode
870
- members = node.members.each_value.to_a + [node.catch_all].compact
993
+ members = node.members.each_value.to_a + [node.catch_all]
871
994
  inner = scope.merge(members.to_h { |m| [m.class_name, "the member struct #{m.class_name}"] })
872
995
  members.each { |member| check_shadowing!(member, inner) }
873
996
  when ObjectNode
@@ -920,20 +1043,12 @@ class GraphWeaver::Codegen
920
1043
  node
921
1044
  end
922
1045
 
923
- # Both ways a result key can fail to become a prop a name the struct
924
- # already answers, or a second key that underscores onto an earlier one.
925
- # Either emits a file that raises ArgumentError at require time, so refuse
926
- # here; an alias in the query fixes both. `props` accumulates prop => key.
1046
+ # The one way a result key can still fail to become a prop: a second key that
1047
+ # underscores onto an earlier one. That emits a struct that misbehaves (an
1048
+ # ArgumentError at require time), so refuse here; an alias in the query fixes
1049
+ # it. A reserved name doesn't get here — prop_name renames it. `props`
1050
+ # accumulates prop => key.
927
1051
  def check_output_prop!(type, key, prop, props)
928
- # Keywords are fine: `const :next` and `next: data["next"]` are legal, and
929
- # the one place a prop is read bare (an alias delegator) qualifies it.
930
- # `pageInfo { next }` and `filter { in }` are ordinary API shapes.
931
- if STRUCT_METHODS.include?(prop)
932
- raise GraphWeaver::Error,
933
- "#{type.graphql_name}.#{key} would become prop '#{prop}', which every generated struct " \
934
- "already defines — alias it in the query (`#{prop}Value: #{key}`)"
935
- end
936
-
937
1052
  if (earlier = props[prop])
938
1053
  raise GraphWeaver::Error,
939
1054
  "result keys #{earlier.inspect} and #{key.inspect} on #{type.graphql_name} both map to the " \
@@ -1033,10 +1148,9 @@ class GraphWeaver::Codegen
1033
1148
  # interfaces the interface-level fields gather into every member.
1034
1149
  def union_members(type, selections)
1035
1150
  unless dispatchable_typename?(type, selections)
1036
- raise ArgumentError,
1037
- "select __typename on #{type.graphql_name} so the union can dispatch unaliased and " \
1038
- "not under @skip/@include, since from_h reads it on every response — or narrow to a " \
1039
- "single `... on Type` condition (no dispatch needed)"
1151
+ # a refusal about the query, like its siblings: branded and path-named,
1152
+ # so generate! can collect it and rake names the file
1153
+ raise GraphWeaver::Error, "#{@path ? "#{@path}: " : ""}#{typename_refusal(type, selections)}"
1040
1154
  end
1041
1155
 
1042
1156
  selected_members(type, selections).sort_by(&:graphql_name).to_h do |possible|
@@ -1044,6 +1158,28 @@ class GraphWeaver::Codegen
1044
1158
  end
1045
1159
  end
1046
1160
 
1161
+ # Why a selection can't dispatch. The near miss is worth its own sentence:
1162
+ # putting __typename in every `... on Type` and nowhere else looks like
1163
+ # compliance — it is what Linear's published SDK documents do — and the tag
1164
+ # really is on the wire, but from_h reads it before it knows which member is
1165
+ # live, and a member the query never named would carry none at all.
1166
+ def typename_refusal(type, selections)
1167
+ base = "select __typename on #{type.graphql_name} so the union can dispatch — unaliased and " \
1168
+ "not under @skip/@include, since from_h reads it on every response — or narrow to a " \
1169
+ "single `... on Type` condition (no dispatch needed)"
1170
+ return base unless member_typename?(type, selections)
1171
+
1172
+ "#{base}. The __typename in each `... on Type` here is read only after the dispatch it " \
1173
+ "would decide, so it can't stand in for one on #{type.graphql_name} itself"
1174
+ end
1175
+
1176
+ # Does every member the selection names carry its own unconditional
1177
+ # __typename? Asked only to explain a refusal, never to permit one.
1178
+ def member_typename?(type, selections)
1179
+ members = selected_members(type, selections).to_a
1180
+ members.any? && members.all? { |member| dispatchable_typename?(member, selections) }
1181
+ end
1182
+
1047
1183
  # The concrete types a selection names through its type conditions, kept to
1048
1184
  # the abstract type's own members. A condition naming another abstract type
1049
1185
  # (`... on Named` inside a union) stands for the members it covers, since its
@@ -1127,9 +1263,9 @@ class GraphWeaver::Codegen
1127
1263
 
1128
1264
  # A name-independent structural fingerprint of a union's members, so two
1129
1265
  # occurrences that generate identical structs collapse to one Ruby type.
1130
- def union_signature(members, catch_all = nil)
1266
+ def union_signature(members, catch_all)
1131
1267
  parts = members.map { |gname, member| "#{gname}=#{signature(member)}" }
1132
- parts << "*=#{signature(catch_all)}" if catch_all
1268
+ parts << "*=#{signature(catch_all)}"
1133
1269
  parts.sort.join(",")
1134
1270
  end
1135
1271
 
@@ -1191,28 +1327,101 @@ class GraphWeaver::Codegen
1191
1327
  def input_node(core)
1192
1328
  return @variable_inputs[core.graphql_name] if @variable_inputs.key?(core.graphql_name)
1193
1329
 
1194
- node = @variable_inputs[core.graphql_name] = InputNode.new(camelize(core.graphql_name))
1330
+ class_name = camelize(core.graphql_name)
1331
+ # enum_node's twin. Without it `class Result` was emitted twice, the second
1332
+ # reopening the first into one struct answering for both the variable and
1333
+ # the response — and `class QUERY` raised a bare TypeError about the heredoc.
1334
+ if MODULE_RESERVED.include?(class_name)
1335
+ raise GraphWeaver::Error,
1336
+ "input type #{core.graphql_name} generates #{class_name}, which collides with a generated " \
1337
+ "constant — every query module defines #{MODULE_RESERVED.to_a.join(", ")}, so an input type " \
1338
+ "by one of those names has no room here"
1339
+ end
1340
+
1341
+ node = @variable_inputs[core.graphql_name] = InputNode.new(class_name, core.graphql_name)
1195
1342
  node.one_of = core.respond_to?(:one_of?) && core.one_of?
1196
1343
  # sorted so output is deterministic across schema sources
1197
1344
  core.arguments.values.sort_by(&:graphql_name).each do |argument|
1198
- prop = underscore(argument.graphql_name)
1199
1345
  # Keywords are fine here: nothing reads an input prop bare (serialize goes
1200
1346
  # through public_send), and `const :in` is legal — which matters, since a
1201
1347
  # schema's field name is not the user's to rename. `Tricky.in` filters are
1202
1348
  # standard Hasura/Gatsby shape.
1203
- if STRUCT_METHODS.include?(prop)
1204
- raise GraphWeaver::Error,
1205
- "input field #{core.graphql_name}.#{argument.graphql_name} would become prop '#{prop}', " \
1206
- "which collides with a method every struct defines"
1207
- end
1208
-
1349
+ prop = GraphWeaver::Codegen.prop_name(argument.graphql_name)
1350
+ @input_hops.push(InputHop.new(argument.graphql_name, list_type?(argument.type)))
1209
1351
  child = type_ref(argument.type) { variable_core(argument.type.unwrap) }
1352
+ @input_hops.pop
1210
1353
  required = child.non_null? && !argument.default_value?
1211
1354
  node.fields << InputNode::Field.new(prop, argument.graphql_name, child, required)
1212
1355
  end
1356
+ check_input_props!(core, node)
1213
1357
  node
1214
1358
  end
1215
1359
 
1360
+ # check_output_prop!'s twin: two input fields that underscore onto one prop
1361
+ # emit `const :x` twice, which raises ArgumentError when the generated file
1362
+ # is REQUIRED — a stack trace with no schema in it. A schema's field name
1363
+ # isn't the user's to rename and an input struct has no alias, so the fix is
1364
+ # to keep the type out of the variables.
1365
+ def check_input_props!(core, node)
1366
+ collision = node.fields.group_by(&:prop).find { |_, group| group.size > 1 }
1367
+ return unless collision
1368
+
1369
+ fields = collision.last.map { |field| field.wire.inspect }.join(" and ")
1370
+ raise GraphWeaver::Error,
1371
+ "input fields #{fields} on #{core.graphql_name} both map to the prop " \
1372
+ "'#{collision.first}' — #{input_collision_advice(core)}"
1373
+ end
1374
+
1375
+ # One hop of the input walk: the field taken, and whether taking it crosses
1376
+ # a list.
1377
+ InputHop = Struct.new(:name, :list)
1378
+ private_constant :InputHop
1379
+
1380
+ def list_type?(type)
1381
+ type = type.of_type if type.kind.name == "NON_NULL"
1382
+ type.kind.name == "LIST"
1383
+ end
1384
+
1385
+ # What to do about an input collision, said in terms of the declaration the
1386
+ # user wrote: the colliding type is usually several hops below it, and
1387
+ # naming the type alone sends them looking for a variable they never
1388
+ # declared. The escape — inline the path as a literal, keeping a variable
1389
+ # per leaf — exists only when no hop crosses a list, since a literal list
1390
+ # can't stand in for a length only the runtime knows.
1391
+ def input_collision_advice(core)
1392
+ from = @input_from || "a variable that reaches it"
1393
+ path = @input_hops.map(&:name).join(".")
1394
+ crossed = @input_hops.find(&:list)
1395
+
1396
+ if crossed || @input_list
1397
+ blocked =
1398
+ if crossed
1399
+ "#{from} reaches #{core.graphql_name} through #{path}, and #{crossed.name} is a list"
1400
+ else
1401
+ "#{from} is declared as a list of #{core.graphql_name}"
1402
+ end
1403
+ return "#{blocked} — a literal list can't stand in for a length only the runtime knows, so " \
1404
+ "no form of this query generates. Keep #{core.graphql_name} out of the variables."
1405
+ end
1406
+
1407
+ if @input_hops.any?
1408
+ return "#{from} reaches #{core.graphql_name} through #{path} — drop #{from} and write that " \
1409
+ "path as a literal in the query, with a variable per field of #{core.graphql_name}"
1410
+ end
1411
+
1412
+ "pass #{core.graphql_name} as a literal in the query, with a variable per field, instead of " \
1413
+ "declaring #{from}"
1414
+ end
1415
+
1416
+ # Does this AST type reference cross a list on its way to the core type?
1417
+ def ast_list?(ast_type)
1418
+ case ast_type
1419
+ when GraphQL::Language::Nodes::ListType then true
1420
+ when GraphQL::Language::Nodes::NonNullType then ast_list?(ast_type.of_type)
1421
+ else false
1422
+ end
1423
+ end
1424
+
1216
1425
  # The module-level T::Enum for a schema enum, named for the enum itself —
1217
1426
  # it is shared by every field and variable of that type.
1218
1427
  def enum_node(core)
@@ -1255,7 +1464,7 @@ class GraphWeaver::Codegen
1255
1464
 
1256
1465
  # Registered helper-module names for a GraphQL type, collecting their requires.
1257
1466
  def type_mixins(graphql_name)
1258
- entry = GraphWeaver::Codegen.type_registry[graphql_name]
1467
+ entry = @registry.type_registry[graphql_name]
1259
1468
  return [] unless entry
1260
1469
 
1261
1470
  @requires.concat(entry[:requires])
@@ -1265,7 +1474,7 @@ class GraphWeaver::Codegen
1265
1474
  # The MappedEnum node for a schema enum with a registered app-enum
1266
1475
  # mapping; nil when unregistered, falling back to a generated T::Enum.
1267
1476
  def mapped_enum_node(core)
1268
- enum_type = GraphWeaver::Codegen.enum_registry[core.graphql_name]
1477
+ enum_type = @registry.enum_registry[core.graphql_name]
1269
1478
  return unless enum_type
1270
1479
 
1271
1480
  @requires.concat(enum_type.requires)
@@ -1277,9 +1486,9 @@ class GraphWeaver::Codegen
1277
1486
  # Resolution, most specific first: a per-field override (`Type.field`), then
1278
1487
  # the scalar-name registration.
1279
1488
  def scalar_node(name, coordinate = nil, result: false)
1280
- registry = GraphWeaver::Codegen.scalar_registry
1281
- @untyped_scalars << name.to_s unless (coordinate && registry[coordinate]) || registry[name.to_s]
1282
- scalar = GraphWeaver::Codegen.scalar(name, coordinate)
1489
+ scalars = @registry.scalar_registry
1490
+ @untyped_scalars << name.to_s unless (coordinate && scalars[coordinate]) || scalars[name.to_s]
1491
+ scalar = @registry.scalar(name, coordinate)
1283
1492
  refuse_uncastable!(scalar, coordinate || name) if result
1284
1493
  @requires.concat(scalar.requires)
1285
1494
  Scalar.new(scalar)
@@ -1296,11 +1505,9 @@ class GraphWeaver::Codegen
1296
1505
  klass = Object.const_get(scalar.type)
1297
1506
  return unless klass.is_a?(Class) && ScalarType::WIRE_CLASSES.none? { |native| native <= klass }
1298
1507
 
1299
- raise GraphWeaver::Error,
1300
- "register_scalar(#{scalar.graphql_name.inspect}, #{scalar.type}) has no cast, so nothing " \
1301
- "builds a #{scalar.type} out of the JSON at #{where} — give it one (cast: :parse names a " \
1302
- "class method, cast: ->(v) { \"#{scalar.type}(\#{v})\" } emits any expression), or register " \
1303
- "a type the wire already parses into"
1508
+ # why there is no cast — and so what to do — is the registration's own
1509
+ # knowledge, not the walk's
1510
+ raise GraphWeaver::Error, scalar.uncastable_message(where)
1304
1511
  rescue ::NameError
1305
1512
  nil # a type: given as a String names a class this process may not have
1306
1513
  end
@@ -1308,14 +1515,15 @@ class GraphWeaver::Codegen
1308
1515
  # An unregistered custom scalar passes through as T.untyped — legitimate
1309
1516
  # (nobody needs a codec for every scalar), but it's the one hole in an
1310
1517
  # otherwise exact result type, so name the holes rather than leave them
1311
- # silent. Informational: not a warning, never an error.
1518
+ # silent. Informational: not a warning, never an error. The logger is the
1519
+ # runtime channel, so a console `parse` says it too; the build channel prints
1520
+ # the same list once per run (see GraphWeaver.untyped_scalars).
1312
1521
  def report_untyped_scalars
1313
- names = @untyped_scalars.uniq.sort
1522
+ names = untyped_scalars
1314
1523
  return if names.empty?
1315
1524
 
1316
1525
  GraphWeaver::Internal::Log.log(:info) do
1317
- "#{names.size} unregistered custom scalar#{"s" unless names.one?} → T.untyped: " \
1318
- "#{names.join(", ")} (register with GraphWeaver.register_scalar)"
1526
+ GraphWeaver::Internal::Util.untyped_scalars_report(@graph_name => names)
1319
1527
  end
1320
1528
  end
1321
1529
 
@@ -1336,12 +1544,14 @@ class GraphWeaver::Codegen
1336
1544
  # nothing else, so adding, removing, or reordering an unrelated selection can
1337
1545
  # never rename it. Generated code is app-code API; a name that shifts under
1338
1546
  # an unrelated edit is a silent break. `taken` is the names claimed in this
1339
- # struct's scope, its first entry the struct itself.
1547
+ # struct's scope => the key that claimed each, its first entry the struct
1548
+ # itself (claimed by nothing) — the key is what a collision has to name.
1340
1549
  #
1341
1550
  # (Union members are the exception: they are named for the type condition
1342
1551
  # that produces them, which is equally position-determined.)
1343
1552
  def pick_name(key, taken)
1344
1553
  name = camelize(key)
1554
+ struct = taken.keys.first
1345
1555
 
1346
1556
  # a key that camelizes to no constant at all ("_", "_1") would emit
1347
1557
  # `class < T::Struct`
@@ -1350,18 +1560,19 @@ class GraphWeaver::Codegen
1350
1560
  "result key #{key.inspect} makes no class name (#{name.inspect}) — alias it to one starting with a letter"
1351
1561
  end
1352
1562
 
1353
- if name == taken.first
1563
+ if name == struct
1354
1564
  # would shadow the struct it nests in — the parent's own `returns(Name)`
1355
1565
  # resolves lexically and would find the child
1356
1566
  suffix = 2
1357
- suffix += 1 while taken.include?("#{name}#{suffix}")
1567
+ suffix += 1 while taken.key?("#{name}#{suffix}")
1358
1568
  name = "#{name}#{suffix}"
1359
- elsif taken.include?(name)
1569
+ elsif (earlier = taken[name])
1360
1570
  raise GraphWeaver::Error,
1361
- "result keys on #{taken.first} both generate the class #{name} alias one to a distinct name"
1571
+ "result keys #{earlier.inspect} and #{key.inspect} on #{struct} both generate the class " \
1572
+ "#{name} — alias one to a distinct name"
1362
1573
  end
1363
1574
 
1364
- taken << name
1575
+ taken[name] = key
1365
1576
  name
1366
1577
  end
1367
1578