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
@@ -5,10 +5,244 @@
5
5
  # Mixed into Codegen — methods run with the generator instance state.
6
6
  class GraphWeaver::Codegen
7
7
  module Emit
8
+ include Kernel # for sorbet: hosts are Objects
8
9
  include GraphWeaver::Inflect
9
10
 
10
11
  private
11
12
 
13
+ # The Relay convention — an operation whose only variable is a required
14
+ # input object — reads better flattened: the input's fields become
15
+ # execute's kwargs directly, and the wrapping level is rebuilt on the
16
+ # wire. Multi-variable (or nullable-input) operations keep the
17
+ # variable-per-kwarg surface.
18
+ def flatten_input(variables)
19
+ return unless variables.size == 1
20
+
21
+ var = variables.first
22
+ return unless var.required && var.node.is_a?(NonNull)
23
+
24
+ input = var.node.of
25
+ input if input.is_a?(InputNode)
26
+ end
27
+
28
+ def input_references(node)
29
+ node.fields.filter_map do |field|
30
+ child = field.node
31
+ child = child.of while child.respond_to?(:of)
32
+ child if child.is_a?(InputNode)
33
+ end
34
+ end
35
+
36
+ # Input structs in dependency order (referenced types before their
37
+ # referrers) so each emitted const names an already-defined class.
38
+ # Cycles make that impossible — flagged so emission can forward-declare
39
+ # every input class first, then reopen each to add its props.
40
+ def ordered_inputs
41
+ ordered = []
42
+ seen = {} # node => :done | :visiting (bool_exp graphs get big)
43
+ cyclic = T.let(false, T::Boolean)
44
+
45
+ visit = lambda do |node|
46
+ next if seen[node] == :done
47
+
48
+ if seen[node] == :visiting
49
+ cyclic = true
50
+ next
51
+ end
52
+
53
+ seen[node] = :visiting
54
+ input_references(node).each(&visit)
55
+ seen[node] = :done
56
+ ordered << node
57
+ end
58
+ @variable_inputs.each_value(&visit)
59
+
60
+ [ordered, cyclic]
61
+ end
62
+
63
+ # Mapped-enum tables, generated enums, and input structs
64
+ # (dependency-ordered, forward-declared when cyclic) — inline in the
65
+ # module that needs them, or once in the shared inputs module.
66
+ def emit_variable_types(out)
67
+ @mapped_enums.each_value do |mapped|
68
+ emit_mapped_enum(mapped, out, 1)
69
+ out << ""
70
+ end
71
+ @variable_enums.each_value do |enum|
72
+ emit_enum(enum, out, 1)
73
+ out << ""
74
+ end
75
+ inputs, cyclic = ordered_inputs
76
+ if cyclic
77
+ # Recursive input types (Hasura bool_exp et al) reference each other,
78
+ # so no definition order satisfies the runtime — forward-declare every
79
+ # class empty, then let the full definitions below reopen with props.
80
+ # eval'd so srb sees only the full bodies (reopening a T::Struct to
81
+ # add props is a static error; adding them at runtime is fine).
82
+ out << " # runtime-only forward declarations: these input types reference"
83
+ out << " # each other, so the full definitions below need the constants"
84
+ out << " eval(<<~RUBY, binding, __FILE__, __LINE__ + 1)"
85
+ inputs.each { |input| out << " class #{input.class_name} < T::Struct; end" }
86
+ out << " RUBY"
87
+ out << ""
88
+ end
89
+ inputs.each do |input|
90
+ emit_input(input, out, 1)
91
+ out << ""
92
+ end
93
+ end
94
+
95
+ # In the shared-inputs workflow the variable types live once in the
96
+ # inputs module; the query module aliases what it uses, so
97
+ # AdoptQuery::AdoptionInput stays a real constant — and shared types
98
+ # keep ONE identity across every module that touches them.
99
+ # Only the shared names THIS module's emission references: variable
100
+ # root types (and, when flattened, the root input's field types)
101
+ # plus every mapped-enum table (result casting reads them too).
102
+ # Nested types stay un-aliased — they live in the inputs module.
103
+ def shared_alias_names(variables, flatten)
104
+ nodes = variables.map(&:node)
105
+ nodes += flatten.fields.map(&:node) if flatten
106
+
107
+ names = @mapped_enums.each_value.flat_map { |m| ["#{m.const_prefix}_FROM_WIRE", "#{m.const_prefix}_TO_WIRE"] }
108
+ nodes.each do |wrapped|
109
+ node = T.let(wrapped, T.untyped)
110
+ node = node.of while node.is_a?(NonNull) || node.is_a?(List)
111
+ case node
112
+ when EnumNode, InputNode then names << node.class_name
113
+ end
114
+ end
115
+ names.uniq
116
+ end
117
+
118
+ def emit_shared_aliases(out, names)
119
+ return if names.empty?
120
+
121
+ names.each { |name| out << " #{name} = #{@inputs_namespace}::#{name}" }
122
+ out << ""
123
+ end
124
+
125
+ # The shared inputs artifact as files: inputs.rb (the manifest —
126
+ # requires, forward declarations for every struct so definition
127
+ # order never matters, then one require per type file) plus
128
+ # inputs/<type>.rb per enum/mapped-table/input struct.
129
+ def emit_inputs_files
130
+ files = {}
131
+ enum_files = []
132
+ struct_files = []
133
+
134
+ @mapped_enums.each do |graphql_name, mapped|
135
+ enum_files << inputs_file(files, graphql_name) { |out| emit_mapped_enum(mapped, out, 1) }
136
+ end
137
+ @variable_enums.each do |graphql_name, enum|
138
+ enum_files << inputs_file(files, graphql_name) { |out| emit_enum(enum, out, 1) }
139
+ end
140
+ inputs, = ordered_inputs
141
+ inputs.each do |input|
142
+ struct_files << inputs_file(files, input.class_name) { |out| emit_input(input, out, 1) }
143
+ end
144
+
145
+ manifest = []
146
+ manifest << "# typed: strict"
147
+ manifest << "# frozen_string_literal: true"
148
+ manifest << ""
149
+ manifest << "# Generated by GraphWeaver — do not edit. Shared variable types for"
150
+ manifest << "# this schema, one file per type; query modules alias what they use."
151
+ manifest << ""
152
+ requires = @requires.uniq.sort
153
+ if requires.any?
154
+ requires.each { |req| manifest << "require #{req.inspect}" }
155
+ manifest << ""
156
+ end
157
+ manifest << "module #{@module_name}; end"
158
+ manifest << ""
159
+ enum_files.sort.each { |file| manifest << "require_relative #{file.delete_suffix(".rb").inspect}" }
160
+ if inputs.any?
161
+ manifest << ""
162
+ manifest << "# runtime-only forward declarations: input types reference each"
163
+ manifest << "# other across files, so every constant must exist before any"
164
+ manifest << "# definition loads (srb sees only the full bodies)"
165
+ manifest << "module #{@module_name}"
166
+ manifest << " eval(<<~RUBY, binding, __FILE__, __LINE__ + 1)"
167
+ inputs.each { |input| manifest << " class #{input.class_name} < T::Struct; end" }
168
+ manifest << " RUBY"
169
+ manifest << "end"
170
+ manifest << ""
171
+ struct_files.sort.each { |file| manifest << "require_relative #{file.delete_suffix(".rb").inspect}" }
172
+ end
173
+
174
+ files["inputs.rb"] = manifest.join("\n") + "\n"
175
+ files
176
+ end
177
+
178
+ # one type per file, wrapped in the namespace so bare sibling
179
+ # references resolve lexically
180
+ def inputs_file(files, name)
181
+ out = []
182
+ out << "# typed: strict"
183
+ out << "# frozen_string_literal: true"
184
+ out << ""
185
+ out << "# Generated by GraphWeaver — do not edit."
186
+ out << ""
187
+ out << "module #{@module_name}"
188
+ yield(out)
189
+ out << "end"
190
+
191
+ file = "inputs/#{GraphWeaver::Inflect.underscore(name)}.rb"
192
+ files[file] = out.join("\n") + "\n"
193
+ file
194
+ end
195
+
196
+ # The whole generated file: header, requires, the QUERY heredoc,
197
+ # enum tables, input structs (dependency-ordered, forward-declared
198
+ # when cyclic), the Result tree, and execute — assembled from the
199
+ # generator's walked state.
200
+ def emit_module(root, variables)
201
+ flatten = flatten_input(variables)
202
+ aliases = @inputs_namespace ? shared_alias_names(variables, flatten) : []
203
+
204
+ out = []
205
+ out << "# typed: strict"
206
+ out << "# frozen_string_literal: true"
207
+ out << ""
208
+ out << "# Generated by GraphWeaver — do not edit."
209
+ out << ""
210
+ requires = @requires.uniq.sort
211
+ if requires.any?
212
+ requires.each { |req| out << "require #{req.inspect}" }
213
+ out << ""
214
+ end
215
+ if aliases.any?
216
+ # the aliases below need the shared module loaded (same directory
217
+ # by the generate! convention)
218
+ out << "require_relative \"inputs\""
219
+ out << ""
220
+ end
221
+ out << "module #{@module_name}"
222
+ out << " extend T::Sig"
223
+ out << ""
224
+ # a GraphQL block string could contain a bare GRAPHQL line, which
225
+ # would terminate the heredoc early — pick a delimiter the query
226
+ # can't collide with
227
+ delimiter = "GRAPHQL"
228
+ delimiter += "_" while @query.match?(/^\s*#{delimiter}\s*$/)
229
+ out << " QUERY = T.let(<<~'#{delimiter}', String)"
230
+ @query.each_line { |line| out << " #{line}".rstrip }
231
+ out << " #{delimiter}"
232
+ out << ""
233
+ if @inputs_namespace
234
+ emit_shared_aliases(out, aliases)
235
+ else
236
+ emit_variable_types(out)
237
+ end
238
+ emit_nested(root, out, 1)
239
+ out << ""
240
+ emit_execute(out, variables, flatten:)
241
+ out << "end"
242
+
243
+ out.join("\n") + "\n"
244
+ end
245
+
12
246
  def emit_nested(node, out, indent)
13
247
  case node
14
248
  when UnionNode then emit_union(node, out, indent)
@@ -29,11 +263,30 @@ class GraphWeaver::Codegen
29
263
  out << "#{pad}end"
30
264
  end
31
265
 
266
+ # module-level wire translation tables for an app-mapped enum
267
+ def emit_mapped_enum(node, out, indent)
268
+ pad = " " * indent
269
+ type = node.bare_type
270
+ prefix = node.const_prefix
271
+
272
+ out << "#{pad}# GraphQL enum #{node.graphql_name} <-> #{type} (registered mapping)"
273
+ out << "#{pad}#{prefix}_FROM_WIRE = T.let({"
274
+ node.mapping.each do |wire, member|
275
+ out << "#{pad} #{wire.inspect} => #{type}.deserialize(#{member.serialize.to_s.inspect}),"
276
+ end
277
+ out << "#{pad}}.freeze, T::Hash[String, #{type}])"
278
+ out << "#{pad}#{prefix}_TO_WIRE = T.let(#{prefix}_FROM_WIRE.invert.freeze, T::Hash[#{type}, String])"
279
+ end
280
+
32
281
  def emit_object(node, out, indent)
33
282
  pad = " " * indent
34
283
 
35
284
  out << "#{pad}class #{node.class_name} < T::Struct"
36
285
  out << "#{pad} extend T::Sig"
286
+ out << "#{pad} include GraphWeaver::Hints"
287
+ node.mixins.each do |mixin|
288
+ out << "#{pad} include #{mixin} # registered for #{node.graphql_type}"
289
+ end
37
290
  out << ""
38
291
 
39
292
  node.fields.filter_map { |field| field.node.nested }.each do |child|
@@ -89,56 +342,74 @@ class GraphWeaver::Codegen
89
342
  out << "#{pad}end"
90
343
  end
91
344
 
92
- def emit_execute(out, variables)
93
- out << " @executor = T.let(nil, T.untyped)"
345
+ def emit_execute(out, variables, flatten: nil)
346
+ out << " @client = T.let(nil, T.untyped)"
94
347
  out << ""
95
348
  out << " class << self"
96
349
  out << " extend T::Sig"
97
350
  out << ""
98
- out << " sig { params(executor: T.untyped).void }"
99
- out << " attr_writer :executor"
351
+ out << " sig { params(client: T.untyped).void }"
352
+ out << " attr_writer :client"
100
353
  out << ""
101
- out << " # default transport for execute"
354
+ out << " # default client (a GraphWeaver::Client or any transport) for"
355
+ out << " # execute: per-module override, else the app default"
102
356
  out << " sig { returns(T.untyped) }"
103
- out << " def executor"
104
- out << " @executor || #{@executor_const || "GraphWeaver.executor"}"
357
+ out << " def client"
358
+ out << " @client || #{@client_const || "GraphWeaver.client!"}"
105
359
  out << " end"
106
360
  out << " end"
107
361
  out << ""
108
362
 
109
- sig_params = variables.map do |var|
110
- bare = var.node.coerce? ? var.node.coerce_input_type : var.node.bare_type
111
- kwarg_type = var.required ? bare : "T.nilable(#{bare})"
112
- "#{var.kwarg}: #{kwarg_type}"
363
+ # the kwarg surface: the input's fields when flattened, else one
364
+ # kwarg per declared variable typed identically either way. The
365
+ # per-call client override rides as an optional POSITIONAL arg, so
366
+ # variables keep the entire kwarg namespace (nothing is reserved).
367
+ params = flatten ? flatten.fields.partition(&:required).flatten : variables
368
+
369
+ sig_params = ["client: T.untyped"]
370
+ sig_params += params.map do |param|
371
+ bare = param.node.coerce? ? param.node.coerce_input_type : param.node.bare_type
372
+ kwarg_type = param.required || bare == "T.untyped" ? bare : "T.nilable(#{bare})"
373
+ "#{kwarg_name(param)}: #{kwarg_type}"
113
374
  end
114
- sig_params << "executor: T.untyped"
115
375
 
116
- kwargs = variables.map { |var| var.required ? "#{var.kwarg}:" : "#{var.kwarg}: nil" }
117
- kwargs << "executor: self.executor"
376
+ kwargs = ["client = nil"]
377
+ kwargs += params.map { |param| param.required ? "#{kwarg_name(param)}:" : "#{kwarg_name(param)}: nil" }
118
378
 
119
379
  # execute returns the full envelope; execute! is the strict shortcut for
120
380
  # `execute(...).data!` — the typed result, or a raised QueryError.
121
- forward = (variables.map { |var| "#{var.kwarg}: #{var.kwarg}" } + ["executor: executor"]).join(", ")
381
+ forward = (["client"] + params.map { |param| "#{kwarg_name(param)}: #{kwarg_name(param)}" }).join(", ")
122
382
 
383
+ if flatten
384
+ out << " # $#{variables.first.wire}'s fields, flattened into kwargs (single input-object variable)"
385
+ end
123
386
  out << " sig { params(#{sig_params.join(", ")}).returns(GraphWeaver::Response[Result]) }"
124
387
  out << " def self.execute(#{kwargs.join(", ")})"
125
388
 
126
- required, optional = variables.partition(&:required)
127
- if required.empty?
128
- out << " variables = {}"
129
- else
389
+ if flatten
390
+ fields = flatten.fields.map { |field| "#{field.prop}:" }.join(", ")
130
391
  out << " variables = {"
131
- required.each do |var|
132
- out << " #{var.wire.inspect} => #{variable_serialize(var)},"
133
- end
392
+ out << " #{variables.first.wire.inspect} => #{flatten.class_name}.coerce({ #{fields} }).serialize,"
134
393
  out << " }"
135
- end
136
- optional.each do |var|
137
- out << " variables[#{var.wire.inspect}] = #{variable_serialize(var)} unless #{var.kwarg}.nil?"
394
+ else
395
+ required, optional = variables.partition(&:required)
396
+ if required.empty?
397
+ out << " variables = {}"
398
+ else
399
+ out << " variables = {"
400
+ required.each do |var|
401
+ out << " #{var.wire.inspect} => #{variable_serialize(var)},"
402
+ end
403
+ out << " }"
404
+ end
405
+ optional.each do |var|
406
+ out << " variables[#{var.wire.inspect}] = #{variable_serialize(var)} unless #{var.kwarg}.nil?"
407
+ end
138
408
  end
139
409
 
140
410
  out << ""
141
- out << " raw = executor.execute(QUERY, variables: variables).to_h"
411
+ out << " transport = GraphWeaver.resolve_transport(client || self.client)"
412
+ out << " raw = transport.execute(QUERY, variables: variables).to_h"
142
413
  out << " GraphWeaver::Response[Result].new("
143
414
  out << " data: (Result.from_h(raw[\"data\"]) if raw[\"data\"]),"
144
415
  out << " errors: (raw[\"errors\"] || []).map { |e| GraphWeaver::GraphQLError.from_h(e) },"
@@ -152,18 +423,17 @@ class GraphWeaver::Codegen
152
423
  out << " end"
153
424
  end
154
425
 
426
+ # a kwarg surface entry is a VarDef (.kwarg) or, when flattened, an
427
+ # InputNode::Field (.prop)
428
+ def kwarg_name(param)
429
+ param.respond_to?(:kwarg) ? param.kwarg : param.prop
430
+ end
431
+
155
432
  def variable_serialize(var)
156
433
  value = var.node.coerce? ? var.node.coerce(var.kwarg) : var.kwarg
157
434
  var.node.serialize_identity? ? value : var.node.serialize(value, 1)
158
435
  end
159
436
 
160
- # Structured shape for a schema-validation error: message plus its first
161
- # source location, so ValidationError#errors is inspectable.
162
- def validation_detail(error)
163
- loc = (error.to_h["locations"]&.first if error.respond_to?(:to_h))
164
- { message: error.message, line: loc && loc["line"], column: loc && loc["column"] }
165
- end
166
-
167
437
  def field_cast(field)
168
438
  node = field.node
169
439
 
@@ -176,61 +446,30 @@ class GraphWeaver::Codegen
176
446
  end
177
447
  end
178
448
 
179
- # a module-level T::Struct per input type; serialize builds the wire
180
- # hash, omitting optional fields left nil
449
+ # A module-level T::Struct per input type: typed consts plus a FIELDS
450
+ # table the GraphWeaver::InputStruct runtime drives — serialize/to_h/
451
+ # coerce live once in the gem, not unrolled per struct (bool_exp
452
+ # schemas pull hundreds of inputs into one module).
181
453
  def emit_input(node, out, indent)
182
454
  pad = " " * indent
183
455
 
184
456
  out << "#{pad}class #{node.class_name} < T::Struct"
185
- out << "#{pad} extend T::Sig"
457
+ out << "#{pad} include GraphWeaver::InputStruct"
458
+ out << "#{pad} extend GraphWeaver::InputStruct::ClassMethods"
186
459
  out << ""
187
460
  node.fields.each do |field|
188
461
  default = field.required ? "" : ", default: nil"
189
462
  out << "#{pad} const :#{field.prop}, #{field.node.prop_type}#{default}"
190
463
  end
191
464
  out << ""
192
- out << "#{pad} sig { returns(T::Hash[String, T.untyped]) }"
193
- out << "#{pad} def serialize"
194
- out << "#{pad} result = T.let({}, T::Hash[String, T.untyped])"
195
- node.fields.each do |field|
196
- value = field.node.serialize_identity? ? field.prop.to_s : field.node.serialize(field.prop.to_s, 1)
197
- line = "result[#{field.wire.inspect}] = #{value}"
198
- line += " unless #{field.prop}.nil?" unless field.required
199
- out << "#{pad} #{line}"
200
- end
201
- out << "#{pad} result"
202
- out << "#{pad} end"
203
- out << ""
204
- out << "#{pad} # serialize, under the conventional name"
205
- out << "#{pad} sig { returns(T::Hash[String, T.untyped]) }"
206
- out << "#{pad} def to_h = serialize"
207
- out << ""
208
- out << "#{pad} # Build from a plain hash (underscored keys, Symbol or String):"
209
- out << "#{pad} # enums accept their wire values, nested inputs accept hashes;"
210
- out << "#{pad} # the struct's types are enforced on construction."
211
- out << "#{pad} sig { params(value: T.any(#{node.class_name}, T::Hash[T.untyped, T.untyped])).returns(#{node.class_name}) }"
212
- out << "#{pad} def self.coerce(value)"
213
- out << "#{pad} return value if value.is_a?(#{node.class_name})"
214
- out << ""
215
- out << "#{pad} new("
465
+ out << "#{pad} # (prop, wire, required, serializer, coercer) per field"
466
+ out << "#{pad} FIELDS = T.let(["
216
467
  node.fields.each do |field|
217
- raw = "value_at(value, :#{field.prop})"
218
- expr = if field.node.hash_coerce_identity?
219
- raw
220
- elsif field.required
221
- "#{raw}.then { |v1| #{field.node.hash_coerce("v1", 2)} }"
222
- else
223
- "#{raw}&.then { |v1| #{field.node.hash_coerce("v1", 2)} }"
224
- end
225
- out << "#{pad} #{field.prop}: #{expr},"
468
+ serializer = field.node.serialize_identity? ? "nil" : "->(v) { #{field.node.serialize("v", 1)} }"
469
+ coercer = field.node.hash_coerce_identity? ? "nil" : "->(v) { #{field.node.hash_coerce("v", 1)} }"
470
+ out << "#{pad} GraphWeaver::InputStruct::Field.new(:#{field.prop}, #{field.wire.inspect}, #{field.required}, #{serializer}, #{coercer}),"
226
471
  end
227
- out << "#{pad} )"
228
- out << "#{pad} end"
229
- out << ""
230
- out << "#{pad} sig { params(hash: T::Hash[T.untyped, T.untyped], key: Symbol).returns(T.untyped) }"
231
- out << "#{pad} private_class_method def self.value_at(hash, key)"
232
- out << "#{pad} hash.key?(key) ? hash[key] : hash[key.to_s]"
233
- out << "#{pad} end"
472
+ out << "#{pad} ].freeze, T::Array[GraphWeaver::InputStruct::Field])"
234
473
  out << "#{pad}end"
235
474
  end
236
475
  end
@@ -0,0 +1,149 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ class GraphWeaver::Codegen
5
+ # How one GraphQL enum maps onto an app-owned T::Enum, so generated
6
+ # code speaks YOUR enum instead of generating one per module:
7
+ #
8
+ # class PetKind < T::Enum
9
+ # enums { Cat = new("cat"); Dog = new("dog") }
10
+ # end
11
+ #
12
+ # GraphWeaver.register_enum("Species", PetKind)
13
+ #
14
+ # The wire mapping is inferred by name ("CAT" <-> PetKind::Cat,
15
+ # case/underscore-insensitive against each member's serialized value);
16
+ # map: pins renames explicitly and merges over inference. Every wire
17
+ # value the schema declares must resolve — generation fails naming the
18
+ # gaps — unless fallback: names a member to absorb unknown values
19
+ # (forward-compat for servers that add members; inputs stay strict).
20
+ class EnumType
21
+ attr_reader :graphql_name, :type, :fallback, :requires
22
+
23
+ def initialize(graphql_name, type, map: nil, fallback: nil, requires: nil)
24
+ @graphql_name = graphql_name.to_s
25
+ unless type.is_a?(Class) && type < T::Enum
26
+ raise ArgumentError, "type: must be a T::Enum subclass, got #{type.inspect}"
27
+ end
28
+ unless type.name
29
+ raise ArgumentError, "type: must be a named constant (anonymous classes can't appear in generated source)"
30
+ end
31
+
32
+ @type = type
33
+ @map = map || {}
34
+ @fallback = fallback
35
+ @requires = Array(requires)
36
+
37
+ if fallback && !type.values.include?(fallback)
38
+ raise ArgumentError, "fallback: must be a #{type} member, got #{fallback.inspect}"
39
+ end
40
+ end
41
+
42
+ # wire value => member for every value the schema declares; raises
43
+ # naming the unmappable ones (unless fallback: absorbs them)
44
+ def mapping_for(wire_values)
45
+ mapping = {}
46
+ missing = []
47
+
48
+ wire_values.each do |wire|
49
+ member = @map[wire] || infer(wire)
50
+ member ? mapping[wire] = member : missing << wire
51
+ end
52
+
53
+ if missing.any? && !fallback
54
+ raise GraphWeaver::Error,
55
+ "#{type} has no member for #{graphql_name} value(s) #{missing.join(", ")} — " \
56
+ "add them, pin with map:, or absorb with fallback:"
57
+ end
58
+
59
+ mapping
60
+ end
61
+
62
+ private
63
+
64
+ # "CAT" matches serialize "cat"; "NOT_FOUND" matches "not_found"
65
+ def infer(wire)
66
+ @type.values.find { |member| normalize(member.serialize.to_s) == normalize(wire) }
67
+ end
68
+
69
+ def normalize(value)
70
+ value.downcase.delete("_")
71
+ end
72
+ end
73
+
74
+ class << self
75
+ # Map a GraphQL enum onto an app-owned T::Enum (see EnumType); the
76
+ # global default — client.register_enum scopes to one client.
77
+ def register_enum(graphql_name, type, map: nil, fallback: nil, requires: nil)
78
+ enum_registry[graphql_name.to_s] = EnumType.new(graphql_name, type, map:, fallback:, requires:)
79
+ end
80
+
81
+ # Bulk, inference-only form: register_enums("Species" => PetKind, ...)
82
+ def register_enums(mappings)
83
+ mappings.each { |graphql_name, type| register_enum(graphql_name, type) }
84
+ end
85
+
86
+ def enum_registry
87
+ @enum_registry ||= {}
88
+ end
89
+
90
+ # Attach app-owned helper modules to every struct generated from a
91
+ # GraphQL type — the logic stays in your code, generation wires it in:
92
+ #
93
+ # GraphWeaver.register_type("Pet", PetHelpers)
94
+ #
95
+ # Or build the mixin inline — the block is module_eval'd into a fresh
96
+ # module auto-named GraphWeaver::TypeHelpers::<Type>. Handy for quick
97
+ # decoration; srb tc can't see into block-defined methods, so prefer
98
+ # a named module where static checking matters:
99
+ #
100
+ # GraphWeaver.register_type("Pet") do
101
+ # def display_name = "#{name} the pet"
102
+ # end
103
+ #
104
+ # Additive: repeated registrations (and client-scoped ones) stack.
105
+ def register_type(graphql_name, *mixins, requires: nil, &block)
106
+ entry = type_registry[graphql_name.to_s] ||= { mixins: [], requires: [] }
107
+ add_type_helpers(entry, graphql_name, mixins, requires, block)
108
+ end
109
+
110
+ def type_registry
111
+ @type_registry ||= {}
112
+ end
113
+
114
+ # shared with Client#register_type: build/validate the mixins and
115
+ # append them to a registry entry
116
+ def add_type_helpers(entry, graphql_name, mixins, requires, block)
117
+ mixins = mixins.dup
118
+ mixins << helper_module(graphql_name, block) if block
119
+
120
+ raise ArgumentError, "pass one or more helper modules, or a block" if mixins.empty?
121
+ mixins.each do |mixin|
122
+ unless mixin.is_a?(Module) && mixin.name
123
+ raise ArgumentError, "type helpers must be named modules, got #{mixin.inspect}"
124
+ end
125
+ end
126
+
127
+ entry[:mixins].concat(mixins)
128
+ entry[:requires].concat(Array(requires))
129
+ entry
130
+ end
131
+
132
+ # a block-built mixin needs a name generated files can reference:
133
+ # GraphWeaver::TypeHelpers::Pet (suffixed on re-registration)
134
+ def helper_module(graphql_name, block)
135
+ base = GraphWeaver::Inflect.camelize(graphql_name.to_s)
136
+ name = base
137
+ count = 1
138
+ name = "#{base}V#{count += 1}" while GraphWeaver::TypeHelpers.const_defined?(name, false)
139
+ GraphWeaver::TypeHelpers.const_set(name, Module.new(&block))
140
+ end
141
+ private :helper_module
142
+ end
143
+ end
144
+
145
+ module GraphWeaver
146
+ # Home of block-built type helpers (register_type with a block), which
147
+ # need constant names so generated files can reference them.
148
+ module TypeHelpers; end
149
+ end