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