graph_weaver 0.1.0 → 0.2.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +6 -0
  3. data/CHANGELOG.md +213 -0
  4. data/Gemfile.lock +32 -2
  5. data/Makefile +7 -2
  6. data/NOTES.md +1 -1
  7. data/PLAN.md +34 -1
  8. data/README.md +56 -41
  9. data/docs/cassettes.md +76 -0
  10. data/docs/errors.md +37 -9
  11. data/docs/generated_modules.md +139 -29
  12. data/docs/getting_started.md +134 -0
  13. data/docs/logging.md +33 -0
  14. data/docs/real_world.md +27 -20
  15. data/docs/scalars.md +122 -8
  16. data/docs/testing.md +44 -34
  17. data/docs/transports.md +124 -0
  18. data/graph_weaver.gemspec +7 -1
  19. data/lib/graph_weaver/client.rb +200 -0
  20. data/lib/graph_weaver/codegen/emit.rb +88 -39
  21. data/lib/graph_weaver/codegen/enum_type.rb +149 -0
  22. data/lib/graph_weaver/codegen/nodes.rb +109 -9
  23. data/lib/graph_weaver/codegen/scalar_type.rb +53 -35
  24. data/lib/graph_weaver/codegen.rb +301 -67
  25. data/lib/graph_weaver/errors.rb +37 -25
  26. data/lib/graph_weaver/hints.rb +63 -0
  27. data/lib/graph_weaver/inflect.rb +2 -1
  28. data/lib/graph_weaver/logging.rb +41 -0
  29. data/lib/graph_weaver/railtie.rb +29 -0
  30. data/lib/graph_weaver/retry.rb +97 -0
  31. data/lib/graph_weaver/rspec.rb +19 -14
  32. data/lib/graph_weaver/schema_loader.rb +156 -20
  33. data/lib/graph_weaver/selection.rb +3 -3
  34. data/lib/graph_weaver/tasks.rb +71 -0
  35. data/lib/graph_weaver/testing/cassette.rb +42 -21
  36. data/lib/graph_weaver/testing/failure.rb +22 -22
  37. data/lib/graph_weaver/testing/{fake_executor.rb → fake_client.rb} +13 -13
  38. data/lib/graph_weaver/testing/values.rb +2 -2
  39. data/lib/graph_weaver/testing.rb +32 -16
  40. data/lib/graph_weaver/transport/faraday.rb +56 -0
  41. data/lib/graph_weaver/transport/http.rb +87 -0
  42. data/lib/graph_weaver/transport.rb +120 -0
  43. data/lib/graph_weaver/version.rb +1 -1
  44. data/lib/graph_weaver.rb +208 -38
  45. metadata +73 -5
  46. data/lib/graph_weaver/faraday_executor.rb +0 -61
  47. data/lib/graph_weaver/http_executor.rb +0 -44
  48. data/lib/graph_weaver/testing/rspec.rb +0 -5
@@ -1,7 +1,6 @@
1
1
  # typed: true
2
2
  # frozen_string_literal: true
3
3
 
4
- require "date"
5
4
  require "graphql"
6
5
  require "sorbet-runtime"
7
6
 
@@ -19,8 +18,10 @@ require "sorbet-runtime"
19
18
  # Split across: codegen/scalar_type.rb (the scalar registry),
20
19
  # codegen/nodes.rb (the typed IR), codegen/emit.rb (source emission);
21
20
  # this file holds the public API and the query walk.
21
+ require_relative "hints"
22
22
  require_relative "inflect"
23
23
  require_relative "selection"
24
+ require_relative "codegen/enum_type"
24
25
  require_relative "codegen/scalar_type"
25
26
  require_relative "codegen/nodes"
26
27
  require_relative "codegen/emit"
@@ -32,76 +33,106 @@ class GraphWeaver::Codegen
32
33
 
33
34
  attr_reader :module_name
34
35
 
35
- # An executor is anything responding to `execute(query, variables:)`
36
- # whose result `to_h`s into {"data" => ..., "errors" => ...}.
36
+ # A client is anything responding to `execute(query, variables:)`
37
+ # whose result `to_h`s into {"data" => ..., "errors" => ...} — a
38
+ # GraphWeaver::Client, a transport, a schema class, a fake.
37
39
  #
38
- # executor: (a constant, or its name as a string) becomes the generated
39
- # module's default transport; when omitted, generated code falls back
40
- # to GraphWeaver.executor. module_name: defaults to the operation's
40
+ # client: (a constant, or its name as a string) becomes the generated
41
+ # module's baked default; when omitted, generated code falls back to
42
+ # the app default (GraphWeaver.client=). module_name:
43
+ # defaults to the operation's
41
44
  # name; default_module_name: is parse's container-scoped fallback (file
42
45
  # generation stays strict — a checked-in file deserves a deliberate
43
- # name).
44
- def initialize(schema:, query:, module_name: nil, executor: nil, default_module_name: nil)
46
+ # name). scalars:/enums:/types: are client-scoped overlays consulted
47
+ # before the global registries (ScalarType, EnumType, and arrays of
48
+ # mixin modules, each keyed by GraphQL name).
49
+ def initialize(schema:, query:, module_name: nil, client: nil, default_module_name: nil,
50
+ scalars: nil, enums: nil, types: nil)
45
51
  @schema = schema
46
52
  @query = query.strip
47
53
  @module_name = module_name
48
54
  @default_module_name = default_module_name
49
- @executor_const = self.class.executor_const(executor)
55
+ @scalars = scalars || {}
56
+ @enums = enums || {}
57
+ @types = types || {}
58
+ @client_const = self.class.client_const(client)
50
59
 
51
- if executor && @executor_const.nil?
60
+ if client && @client_const.nil?
52
61
  # a live object can't be spelled in generated source — parse can
53
62
  # set one via the module's writer, but file generation cannot
54
- raise ArgumentError, "executor: must be a named constant or String (got #{executor.inspect}); pass live objects to parse"
63
+ raise ArgumentError, "client: must be a named constant or String (got #{client.inspect}); pass live objects to parse"
55
64
  end
56
65
  end
57
66
 
58
- # The constant name an executor can be referenced by in generated
67
+ # The constant name a client can be referenced by in generated
59
68
  # source — nil when it can't be (live objects, anonymous modules).
60
- def self.executor_const(executor)
61
- case executor
62
- when String then executor
63
- when Module then executor.name
69
+ def self.client_const(client)
70
+ case client
71
+ when String then client
72
+ when Module then client.name
64
73
  end
65
74
  end
66
75
 
67
76
  # one-step shorthand
68
- def self.generate(schema:, query:, module_name: nil, executor: nil)
69
- new(schema:, query:, module_name:, executor:).generate
77
+ def self.generate(schema:, query:, module_name: nil, client: nil, scalars: nil, enums: nil, types: nil)
78
+ new(schema:, query:, module_name:, client:, scalars:, enums:, types:).generate
70
79
  end
71
80
 
72
81
  # Development convenience: generate + eval in one step, no build
73
82
  # artifact or checked-in file. Same runtime semantics as the generated
74
83
  # file, but invisible to srb tc — use the build step for static typing.
75
84
  # Evaluates into an anonymous container, so no global constants leak;
76
- # executor: additionally accepts a live object (set via .executor=).
77
- def self.parse(schema:, query:, module_name: nil, executor: nil)
78
- executor_const = executor_const(executor)
85
+ # client: additionally accepts a live object (set via .client=).
86
+ def self.parse(schema:, query:, module_name: nil, client: nil, scalars: nil, enums: nil, types: nil)
87
+ client_const = client_const(client)
79
88
 
80
- codegen = new(schema:, query:, module_name:, executor: executor_const, default_module_name: "Query")
89
+ codegen = new(schema:, query:, module_name:, client: client_const, default_module_name: "Query",
90
+ scalars:, enums:, types:)
81
91
  source = codegen.generate
82
92
 
83
93
  container = Module.new
84
94
  container.module_eval(source, "(graph_weaver)", 1)
85
95
  mod = container.const_get(codegen.module_name)
96
+ GraphWeaver.log(:debug) { "parsed #{codegen.module_name} (dynamic module, #{source.bytesize} bytes)" }
86
97
  # live objects (or anonymous modules) can't be referenced from
87
98
  # generated source — set them via the module's writer instead
88
- mod.executor = executor if executor && executor_const.nil?
99
+ mod.client = client if client && client_const.nil?
89
100
  mod
90
101
  end
91
102
 
92
103
  VarDef = Struct.new(:kwarg, :wire, :node, :required)
93
104
 
105
+ # Names that cannot appear bare in generated Ruby: keywords aren't
106
+ # valid identifiers, and the struct's own generated methods would be
107
+ # silently replaced by a same-named prop reader.
108
+ RUBY_KEYWORDS = %w[
109
+ alias and begin break case class def defined? do else elsif end
110
+ ensure false for if in module next nil not or redo rescue retry
111
+ return self super then true undef unless until when while yield
112
+ BEGIN END __FILE__ __LINE__ __ENCODING__
113
+ ].to_set.freeze
114
+ GENERATED_METHODS = %w[serialize to_h].to_set.freeze
115
+
94
116
  def generate
95
- errors = @schema.validate(@query)
117
+ begin
118
+ errors = @schema.validate(@query)
119
+ rescue GraphQL::ParseError => e
120
+ # unparseable queries wrap like invalid ones — everything raised
121
+ # here descends from GraphWeaver::Error
122
+ raise GraphWeaver::ValidationError.new([{ message: e.message, line: nil, column: nil }])
123
+ end
96
124
  if errors.any?
97
125
  raise GraphWeaver::ValidationError.new(errors.map { |e| validation_detail(e) })
98
126
  end
99
127
 
128
+ validate_registrations!
129
+
100
130
  @variable_enums = {}
101
131
  @variable_inputs = {}
102
- @inputs_in_progress = []
103
- # requires contributed by the custom scalars this query actually uses
104
- @scalar_requires = []
132
+ @mapped_enums = {}
133
+ # requires the generated file needs (custom scalars, enum mappings,
134
+ # type helpers all contribute)
135
+ @requires = []
105
136
 
106
137
  operation = load_operation(@query)
107
138
  root_type = operation_root_type(operation)
@@ -121,7 +152,14 @@ class GraphWeaver::Codegen
121
152
  # a variable is optional when nullable or defaulted; optional kwargs
122
153
  # default to nil and are omitted from the wire
123
154
  required = node.non_null? && var.default_value.nil?
124
- VarDef.new(underscore(var.name), var.name, node, required)
155
+ kwarg = underscore(var.name)
156
+ # kwargs are declared and forwarded bare in generated source
157
+ if RUBY_KEYWORDS.include?(kwarg)
158
+ raise GraphWeaver::Error,
159
+ "variable $#{var.name} would become the kwarg '#{kwarg}:', which generated code can't declare " \
160
+ "(a Ruby keyword) — rename the variable"
161
+ end
162
+ VarDef.new(kwarg, var.name, node, required)
125
163
  end
126
164
 
127
165
  root = object_node(root_type, operation.selections, "Result")
@@ -132,7 +170,7 @@ class GraphWeaver::Codegen
132
170
  out << ""
133
171
  out << "# Generated by GraphWeaver — do not edit."
134
172
  out << ""
135
- requires = @scalar_requires.uniq.sort
173
+ requires = @requires.uniq.sort
136
174
  if requires.any?
137
175
  requires.each { |req| out << "require #{req.inspect}" }
138
176
  out << ""
@@ -149,17 +187,35 @@ class GraphWeaver::Codegen
149
187
  @query.each_line { |line| out << " #{line}".rstrip }
150
188
  out << " #{delimiter}"
151
189
  out << ""
190
+ @mapped_enums.each_value do |mapped|
191
+ emit_mapped_enum(mapped, out, 1)
192
+ out << ""
193
+ end
152
194
  @variable_enums.each_value do |enum|
153
195
  emit_enum(enum, out, 1)
154
196
  out << ""
155
197
  end
156
- @variable_inputs.each_value do |input|
198
+ inputs, cyclic = ordered_inputs
199
+ if cyclic
200
+ # Recursive input types (Hasura bool_exp et al) reference each other,
201
+ # so no definition order satisfies the runtime — forward-declare every
202
+ # class empty, then let the full definitions below reopen with props.
203
+ # eval'd so srb sees only the full bodies (reopening a T::Struct to
204
+ # add props is a static error; adding them at runtime is fine).
205
+ out << " # runtime-only forward declarations: these input types reference"
206
+ out << " # each other, so the full definitions below need the constants"
207
+ out << " eval(<<~RUBY, binding, __FILE__, __LINE__ + 1)"
208
+ inputs.each { |input| out << " class #{input.class_name} < T::Struct; end" }
209
+ out << " RUBY"
210
+ out << ""
211
+ end
212
+ inputs.each do |input|
157
213
  emit_input(input, out, 1)
158
214
  out << ""
159
215
  end
160
216
  emit_nested(root, out, 1)
161
217
  out << ""
162
- emit_execute(out, variables)
218
+ emit_execute(out, variables, flatten: flatten_input(variables))
163
219
  out << "end"
164
220
 
165
221
  out.join("\n") + "\n"
@@ -167,6 +223,49 @@ class GraphWeaver::Codegen
167
223
 
168
224
  private
169
225
 
226
+ # A client-scoped registration names a type in a specific schema — a
227
+ # typo'd name would otherwise be a silent no-op, the most confusing
228
+ # failure mode available. Called eagerly by Client#register_* when the
229
+ # schema is already loaded, and again at generation (covers clients
230
+ # whose schema introspects lazily). Global registrations skip this:
231
+ # they may target a different client's server.
232
+ def self.validate_registration!(schema, kind, name)
233
+ return if schema.get_type(name)
234
+
235
+ suggestion = defined?(DidYouMean::SpellChecker) &&
236
+ DidYouMean::SpellChecker.new(dictionary: schema.types.keys).correct(name).first
237
+ hint = suggestion ? " — did you mean '#{suggestion}'?" : ""
238
+ raise GraphWeaver::Error, "register_#{kind}(#{name.inspect}) matches no type in this schema#{hint}"
239
+ end
240
+
241
+ # Structured shape for a schema-validation error: message plus its first
242
+ # source location, so ValidationError#errors is inspectable.
243
+ def validation_detail(error)
244
+ loc = (error.to_h["locations"]&.first if error.respond_to?(:to_h))
245
+ { message: error.message, line: loc && loc["line"], column: loc && loc["column"] }
246
+ end
247
+
248
+ def validate_registrations!
249
+ { "enum" => @enums, "scalar" => @scalars, "type" => @types }.each do |kind, registry|
250
+ registry.each_key { |name| self.class.validate_registration!(@schema, kind, name) }
251
+ end
252
+ end
253
+
254
+ # The Relay convention — an operation whose only variable is a required
255
+ # input object — reads better flattened: the input's fields become
256
+ # execute's kwargs directly, and the wrapping level is rebuilt on the
257
+ # wire. Multi-variable (or nullable-input) operations keep the
258
+ # variable-per-kwarg surface.
259
+ def flatten_input(variables)
260
+ return unless variables.size == 1
261
+
262
+ var = variables.first
263
+ return unless var.required && var.node.is_a?(NonNull)
264
+
265
+ input = var.node.of
266
+ input if input.is_a?(InputNode)
267
+ end
268
+
170
269
  # Selection#each_field, collected by result key (codegen groups
171
270
  # repeated selections of one field so it can merge them)
172
271
  def gather(type, selections)
@@ -177,6 +276,8 @@ class GraphWeaver::Codegen
177
276
 
178
277
  def object_node(type, selections, class_name)
179
278
  node = ObjectNode.new(class_name)
279
+ node.graphql_type = type.graphql_name
280
+ node.mixins = type_mixins(type.graphql_name)
180
281
  taken = [class_name]
181
282
 
182
283
  gather(type, selections).each do |key, field_nodes|
@@ -184,7 +285,7 @@ class GraphWeaver::Codegen
184
285
  prop = underscore(key)
185
286
 
186
287
  child = if field_name == "__typename"
187
- NonNull.new(Scalar.new("String"))
288
+ NonNull.new(scalar_node("String"))
188
289
  else
189
290
  field_type = @schema.get_field(type.graphql_name, field_name).type
190
291
  sub_selections = field_nodes.flat_map(&:selections)
@@ -194,17 +295,47 @@ class GraphWeaver::Codegen
194
295
  name = pick_name(core.graphql_name, key, taken)
195
296
  type_ref(field_type) { object_node(core, sub_selections, name) }
196
297
  when "UNION", "INTERFACE"
197
- name = pick_name(core.graphql_name, key, taken)
198
- type_ref(field_type) { union_node(core, sub_selections, name) }
298
+ conditions = concrete_conditions(core, sub_selections)
299
+ bare = bare_fields(sub_selections) - ["__typename"]
300
+
301
+ if conditions.empty? && core.kind.name == "INTERFACE"
302
+ # interface-level fields only — every member shares them, so
303
+ # one struct suffices and no __typename dispatch is needed
304
+ name = pick_name(core.graphql_name, key, taken)
305
+ type_ref(field_type) { object_node(core, sub_selections, name) }
306
+ elsif conditions.size == 1 && bare.empty? &&
307
+ (member = @schema.get_type(conditions.first)).kind.name == "OBJECT"
308
+ # a single `... on X` condition: narrow to X's struct — nil
309
+ # when the runtime type doesn't match (narrowing filters).
310
+ # Narrowing reads "no fields came back" as "type didn't
311
+ # match", so a fragment whose every field hides behind
312
+ # @skip/@include would make a real match indistinguishable
313
+ # from a miss ({} either way) — refuse rather than guess.
314
+ unless unconditional_field?(member, sub_selections)
315
+ raise GraphWeaver::Error,
316
+ "narrowed `... on #{member.graphql_name}` needs at least one field not under " \
317
+ "@skip/@include — an all-conditional selection makes a match indistinguishable from nil"
318
+ end
319
+
320
+ name = pick_name(member.graphql_name, key, taken)
321
+ nilable_type_ref(field_type) { NarrowedNode.new(object_node(member, sub_selections, name)) }
322
+ else
323
+ name = pick_name(core.graphql_name, key, taken)
324
+ type_ref(field_type) { union_node(core, sub_selections, name) }
325
+ end
199
326
  when "ENUM"
200
- name = pick_name(core.graphql_name, key, taken)
201
- # sorted so output is deterministic across schema sources
202
- # (SDL round-trips reorder values alphabetically)
203
- type_ref(field_type) { EnumNode.new(name, core.values.keys.sort) }
327
+ if (mapped = mapped_enum_node(core))
328
+ type_ref(field_type) { mapped }
329
+ else
330
+ name = pick_name(core.graphql_name, key, taken)
331
+ # sorted so output is deterministic across schema sources
332
+ # (SDL round-trips reorder values alphabetically)
333
+ type_ref(field_type) { EnumNode.new(name, core.values.keys.sort) }
334
+ end
204
335
  when "SCALAR"
205
336
  type_ref(field_type) { scalar_node(core.graphql_name) }
206
337
  else
207
- raise NotImplementedError, "unsupported kind: #{core.kind.name}"
338
+ raise GraphWeaver::Error, "unsupported kind: #{core.kind.name}"
208
339
  end
209
340
  end
210
341
 
@@ -220,18 +351,60 @@ class GraphWeaver::Codegen
220
351
  node
221
352
  end
222
353
 
223
- # Abstract types (unions AND interfaces): one member struct per
224
- # possible type; wire dispatch reads __typename, so the query must
225
- # select it. For interfaces, the interface's own field selections
226
- # gather into every member.
354
+ # The concrete type conditions a selection mentions (inline fragments
355
+ # and named spreads), minus conditions naming the abstract type itself.
356
+ def concrete_conditions(core, selections)
357
+ selections.filter_map do |selection|
358
+ case selection
359
+ when GraphQL::Language::Nodes::InlineFragment
360
+ selection.type&.name
361
+ when GraphQL::Language::Nodes::FragmentSpread
362
+ @fragments.fetch(selection.name).type.name
363
+ end
364
+ end.uniq - [core.graphql_name]
365
+ end
366
+
367
+ # result keys selected as plain fields (outside any type condition)
368
+ def bare_fields(selections)
369
+ selections.grep(GraphQL::Language::Nodes::Field).map { |field| field.alias || field.name }
370
+ end
371
+
372
+ # does the flattened selection (as seen by member) include at least one
373
+ # field guaranteed to be present in a matching response?
374
+ def unconditional_field?(member, selections)
375
+ each_field(member, selections) do |_key, node|
376
+ return true if node.directives.none? { |d| %w[skip include].include?(d.name) }
377
+ end
378
+ false
379
+ end
380
+
381
+ # rebuild LIST wrappers but drop NON_NULLs — a narrowed member is nil
382
+ # whenever the runtime type doesn't match, whatever the schema promises
383
+ def nilable_type_ref(type, &core)
384
+ case type.kind.name
385
+ when "NON_NULL"
386
+ nilable_type_ref(type.of_type, &core)
387
+ when "LIST"
388
+ List.new(nilable_type_ref(type.of_type, &core))
389
+ else
390
+ core.call
391
+ end
392
+ end
393
+
394
+ # Abstract types (unions AND interfaces) whose selections vary by
395
+ # concrete type: one member struct per possible type; wire dispatch
396
+ # reads __typename, so the query must select it. For interfaces, the
397
+ # interface's own field selections gather into every member.
227
398
  def union_node(type, selections, class_name)
228
399
  unless gather(type, selections).key?("__typename")
229
- raise ArgumentError, "select __typename on #{type.graphql_name} so #{class_name} can dispatch"
400
+ raise ArgumentError,
401
+ "select __typename on #{type.graphql_name} so #{class_name} can dispatch — " \
402
+ "or narrow to a single `... on Type` condition (no dispatch needed)"
230
403
  end
231
404
 
232
405
  # sorted so output is deterministic across schema sources
233
406
  members = @schema.possible_types(type).sort_by(&:graphql_name).to_h do |possible|
234
- [possible.graphql_name, object_node(possible, selections, possible.graphql_name)]
407
+ [possible.graphql_name, object_node(possible, selections, camelize(possible.graphql_name))]
235
408
  end
236
409
 
237
410
  UnionNode.new(class_name, members)
@@ -248,7 +421,7 @@ class GraphWeaver::Codegen
248
421
  when GraphQL::Language::Nodes::TypeName
249
422
  variable_core(@schema.get_type(ast_type.name))
250
423
  else
251
- raise NotImplementedError, "unsupported type node: #{ast_type.class}"
424
+ raise GraphWeaver::Error, "unsupported type node: #{ast_type.class}"
252
425
  end
253
426
  end
254
427
 
@@ -258,45 +431,103 @@ class GraphWeaver::Codegen
258
431
  when "SCALAR"
259
432
  scalar_node(core.graphql_name)
260
433
  when "ENUM"
261
- @variable_enums[core.graphql_name] ||=
262
- EnumNode.new(core.graphql_name, core.values.keys.sort)
434
+ mapped_enum_node(core) || (@variable_enums[core.graphql_name] ||=
435
+ EnumNode.new(camelize(core.graphql_name), core.values.keys.sort))
263
436
  when "INPUT_OBJECT"
264
437
  input_node(core)
265
438
  else
266
- raise NotImplementedError, "unsupported variable kind: #{core.kind.name}"
439
+ raise GraphWeaver::Error, "unsupported variable kind: #{core.kind.name}"
267
440
  end
268
441
  end
269
442
 
270
443
  # A module-level T::Struct per input type, with a serialize method
271
- # producing the wire hash. Registered once per type; nested inputs
272
- # register their dependencies first (insertion order = emission order).
444
+ # producing the wire hash. Registered once per type and registered
445
+ # BEFORE its fields walk, so recursive references (Hasura's bool_exp
446
+ # _and/_or/_not) resolve to the same node instead of looping.
273
447
  def input_node(core)
274
448
  return @variable_inputs[core.graphql_name] if @variable_inputs.key?(core.graphql_name)
275
449
 
276
- if @inputs_in_progress.include?(core.graphql_name)
277
- raise NotImplementedError, "recursive input type: #{core.graphql_name}"
278
- end
279
- @inputs_in_progress << core.graphql_name
280
-
281
- node = InputNode.new(core.graphql_name)
450
+ node = @variable_inputs[core.graphql_name] = InputNode.new(camelize(core.graphql_name))
282
451
  # sorted so output is deterministic across schema sources
283
452
  core.arguments.values.sort_by(&:graphql_name).each do |argument|
453
+ prop = underscore(argument.graphql_name)
454
+ # prop readers are bare method calls in the generated struct
455
+ if RUBY_KEYWORDS.include?(prop) || GENERATED_METHODS.include?(prop)
456
+ raise GraphWeaver::Error,
457
+ "input field #{core.graphql_name}.#{argument.graphql_name} would become prop '#{prop}', " \
458
+ "which collides with #{RUBY_KEYWORDS.include?(prop) ? "a Ruby keyword" : "the struct's generated ##{prop}"}"
459
+ end
460
+
284
461
  child = type_ref(argument.type) { variable_core(unwrap(argument.type)) }
285
462
  required = child.non_null? && !argument.default_value?
286
- node.fields << InputNode::Field.new(
287
- underscore(argument.graphql_name), argument.graphql_name, child, required
288
- )
463
+ node.fields << InputNode::Field.new(prop, argument.graphql_name, child, required)
289
464
  end
465
+ node
466
+ end
467
+
468
+ # The InputNodes a struct's fields reference, through NON_NULL/LIST
469
+ # wrappers — the edges of the input dependency graph.
470
+ def input_references(node)
471
+ node.fields.filter_map do |field|
472
+ child = field.node
473
+ child = child.of while child.respond_to?(:of)
474
+ child if child.is_a?(InputNode)
475
+ end
476
+ end
477
+
478
+ # Input structs in dependency order (referenced types before their
479
+ # referrers) so each emitted const names an already-defined class.
480
+ # Cycles make that impossible — flagged so emission can forward-declare
481
+ # every input class first, then reopen each to add its props.
482
+ def ordered_inputs
483
+ ordered = []
484
+ seen = {} # node => :done | :visiting (bool_exp graphs get big)
485
+ cyclic = T.let(false, T::Boolean)
486
+
487
+ visit = lambda do |node|
488
+ next if seen[node] == :done
489
+
490
+ if seen[node] == :visiting
491
+ cyclic = true
492
+ next
493
+ end
494
+
495
+ seen[node] = :visiting
496
+ input_references(node).each(&visit)
497
+ seen[node] = :done
498
+ ordered << node
499
+ end
500
+ @variable_inputs.each_value(&visit)
501
+
502
+ [ordered, cyclic]
503
+ end
504
+
505
+ # Registered helper-module names for a GraphQL type (additive: global
506
+ # registrations plus this client's), collecting their requires.
507
+ def type_mixins(graphql_name)
508
+ entries = [GraphWeaver::Codegen.type_registry[graphql_name], @types[graphql_name]].compact
509
+ entries.each { |entry| @requires.concat(entry[:requires]) }
510
+ entries.flat_map { |entry| entry[:mixins].map(&:name) }
511
+ end
512
+
513
+ # The MappedEnum node for a schema enum with a registered app-enum
514
+ # mapping (client overlay first, then the global registry); nil when
515
+ # unregistered, falling back to a generated T::Enum.
516
+ def mapped_enum_node(core)
517
+ enum_type = @enums[core.graphql_name] || GraphWeaver::Codegen.enum_registry[core.graphql_name]
518
+ return unless enum_type
290
519
 
291
- @inputs_in_progress.delete(core.graphql_name)
292
- @variable_inputs[core.graphql_name] = node
520
+ @requires.concat(enum_type.requires)
521
+ @mapped_enums[core.graphql_name] ||= MappedEnum.new(enum_type, core.values.keys.sort)
293
522
  end
294
523
 
295
524
  # A Scalar node, recording any requires its registered type needs so the
296
525
  # generated file can require them (collected across the whole query).
526
+ # Resolution: the client-scoped overlay first, then the global registry.
297
527
  def scalar_node(name)
298
- @scalar_requires.concat(GraphWeaver::Codegen.scalar(name).requires)
299
- Scalar.new(name)
528
+ scalar = @scalars[name.to_s] || GraphWeaver::Codegen.scalar(name)
529
+ @requires.concat(scalar.requires)
530
+ Scalar.new(scalar)
300
531
  end
301
532
 
302
533
  # rebuild the NON_NULL/LIST wrappers around the core node
@@ -316,10 +547,13 @@ class GraphWeaver::Codegen
316
547
  type
317
548
  end
318
549
 
550
+ # GraphQL type names become struct names — camelized, because schemas
551
+ # in the wild use snake_case type names (Hasura, PostGraphile) and a
552
+ # verbatim lowercase name is not a Ruby constant
319
553
  def pick_name(type_name, key, taken)
320
- candidate = type_name
321
- candidate = "#{camelize(key)}#{type_name}" if taken.include?(candidate)
322
- raise NotImplementedError, "class name collision: #{candidate}" if taken.include?(candidate)
554
+ candidate = camelize(type_name)
555
+ candidate = "#{camelize(key)}#{candidate}" if taken.include?(candidate)
556
+ raise GraphWeaver::Error, "class name collision: #{candidate}" if taken.include?(candidate)
323
557
 
324
558
  taken << candidate
325
559
  candidate