graph_weaver 0.4.4 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1357 -0
  3. data/CLAUDE.md +100 -8
  4. data/DECISIONS.md +309 -0
  5. data/Gemfile.lock +23 -23
  6. data/NOTES.md +5 -5
  7. data/PLAN.md +106 -135
  8. data/README.md +115 -96
  9. data/REVIEW.md +946 -0
  10. data/docs/cassettes.md +75 -48
  11. data/docs/editors.md +82 -0
  12. data/docs/errors.md +32 -30
  13. data/docs/federation.md +520 -48
  14. data/docs/generated_modules.md +352 -137
  15. data/docs/getting_started.md +237 -67
  16. data/docs/logging.md +35 -6
  17. data/docs/real_world.md +21 -15
  18. data/docs/scalars.md +49 -136
  19. data/docs/testing.md +299 -52
  20. data/docs/transports.md +129 -30
  21. data/docs/upgrading.md +112 -0
  22. data/graph_weaver.gemspec +3 -1
  23. data/lib/generators/graph_weaver/install_generator.rb +259 -0
  24. data/lib/graph_weaver/client.rb +114 -111
  25. data/lib/graph_weaver/codegen/aliases.rb +217 -0
  26. data/lib/graph_weaver/codegen/emit.rb +272 -251
  27. data/lib/graph_weaver/codegen/enum_type.rb +27 -98
  28. data/lib/graph_weaver/codegen/nodes.rb +72 -13
  29. data/lib/graph_weaver/codegen/scalar_type.rb +72 -67
  30. data/lib/graph_weaver/codegen/type_helpers.rb +142 -0
  31. data/lib/graph_weaver/codegen.rb +617 -264
  32. data/lib/graph_weaver/errors.rb +127 -10
  33. data/lib/graph_weaver/federation.rb +272 -0
  34. data/lib/graph_weaver/hints.rb +12 -6
  35. data/lib/graph_weaver/in_process.rb +90 -0
  36. data/lib/graph_weaver/input_struct.rb +21 -2
  37. data/lib/graph_weaver/logging.rb +29 -0
  38. data/lib/graph_weaver/parsing.rb +67 -0
  39. data/lib/graph_weaver/query_module.rb +55 -0
  40. data/lib/graph_weaver/railtie.rb +23 -1
  41. data/lib/graph_weaver/representation.rb +74 -0
  42. data/lib/graph_weaver/response.rb +15 -1
  43. data/lib/graph_weaver/retry.rb +29 -8
  44. data/lib/graph_weaver/rspec.rb +214 -16
  45. data/lib/graph_weaver/schema_loader.rb +820 -57
  46. data/lib/graph_weaver/schemas.rb +46 -0
  47. data/lib/graph_weaver/selection.rb +59 -7
  48. data/lib/graph_weaver/tasks.rb +216 -21
  49. data/lib/graph_weaver/testing/cassette.rb +186 -62
  50. data/lib/graph_weaver/testing/coverage.rb +165 -0
  51. data/lib/graph_weaver/testing/failure.rb +10 -23
  52. data/lib/graph_weaver/testing/fake_client.rb +194 -28
  53. data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
  54. data/lib/graph_weaver/testing/router.rb +1431 -0
  55. data/lib/graph_weaver/testing/subgraphs.rb +130 -0
  56. data/lib/graph_weaver/testing.rb +204 -14
  57. data/lib/graph_weaver/transport/faraday.rb +31 -6
  58. data/lib/graph_weaver/transport/http.rb +99 -36
  59. data/lib/graph_weaver/transport.rb +74 -18
  60. data/lib/graph_weaver/version.rb +1 -1
  61. data/lib/graph_weaver.rb +398 -170
  62. metadata +20 -3
@@ -10,21 +10,6 @@ class GraphWeaver::Codegen
10
10
 
11
11
  private
12
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
13
  def input_references(node)
29
14
  node.fields.filter_map do |field|
30
15
  child = field.node
@@ -60,178 +45,166 @@ class GraphWeaver::Codegen
60
45
  [ordered, cyclic]
61
46
  end
62
47
 
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
48
+ # Mapped-enum tables, generated enums, and input structs
49
+ # (dependency-ordered, forward-declared when cyclic) — inline in the
50
+ # module that needs them, unless the shared module already holds them
51
+ # (then the module aliases them instead; see emit_shared_aliases).
52
+ def emit_variable_types(out)
53
+ return if @types_namespace
54
+
55
+ emit_enum_types(out)
56
+ inputs, cyclic = ordered_inputs
57
+ if cyclic
58
+ # Recursive input types (Hasura bool_exp et al) reference each other,
59
+ # so no definition order satisfies the runtime — forward-declare every
60
+ # class empty, then let the full definitions below reopen with props.
61
+ # eval'd so srb sees only the full bodies (reopening a T::Struct to
62
+ # add props is a static error; adding them at runtime is fine).
63
+ out << " # runtime-only forward declarations: these input types reference"
64
+ out << " # each other, so the full definitions below need the constants"
65
+ out << " eval(<<~RUBY, binding, __FILE__, __LINE__ + 1)"
66
+ inputs.each { |input| out << " class #{input.class_name} < T::Struct; end" }
67
+ out << " RUBY"
68
+ out << ""
116
69
  end
70
+ inputs.each do |input|
71
+ emit_input(input, out, 1)
72
+ out << ""
73
+ end
74
+ end
117
75
 
118
- def emit_shared_aliases(out, names, namespace = @inputs_namespace)
119
- return if names.empty?
120
-
121
- names.each { |name| out << " #{name} = #{namespace}::#{name}" }
76
+ # Every schema enum this walk touched, at module level: wire tables for
77
+ # the ones mapped onto an app enum, a T::Enum for the rest.
78
+ def emit_enum_types(out, indent = 1)
79
+ @mapped_enums.each_value do |mapped|
80
+ emit_mapped_enum(mapped, out, indent)
122
81
  out << ""
123
82
  end
83
+ @enums.each_value do |enum|
84
+ emit_enum(enum, out, indent)
85
+ out << ""
86
+ end
87
+ end
124
88
 
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 = []
89
+ # In the shared workflow the types live once in the shared module and each
90
+ # query module aliases what it uses, so AdoptMutation::AdoptionInput stays a
91
+ # real constant and a shared type keeps ONE identity across every module
92
+ # that touches it.
93
+ #
94
+ # Enums: every one this walk reached, since a result field and a variable
95
+ # both reference it by that name.
96
+ def shared_enum_names
97
+ @mapped_enums.each_value.flat_map { |m| ["#{m.const_prefix}_FROM_WIRE", "#{m.const_prefix}_TO_WIRE"] } +
98
+ @enums.each_value.map(&:class_name)
99
+ end
133
100
 
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
101
+ # Inputs: only the variable root types — the names this module's own
102
+ # source spells. Nested input types stay un-aliased; they live in the
103
+ # shared module.
104
+ def shared_input_names(variables)
105
+ variables.map(&:node).filter_map { |wrapped|
106
+ node = T.let(wrapped, T.untyped)
107
+ node = node.of while node.is_a?(NonNull) || node.is_a?(List)
108
+ node.class_name if node.is_a?(InputNode)
109
+ }.uniq
110
+ end
144
111
 
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
112
+ def emit_shared_aliases(out, names, namespace)
113
+ return if names.empty?
173
114
 
174
- files["inputs.rb"] = manifest.join("\n") + "\n"
175
- files
176
- end
115
+ names.each { |name| out << " #{name} = #{namespace}::#{name}" }
116
+ out << ""
117
+ end
177
118
 
178
- # The shared unions artifact as a single file: every hoisted union as a
179
- # <module_name>::<Name> module. Unions don't cross-reference (each is a
180
- # self-contained fragment), so there's no need for per-type files or the
181
- # forward declarations recursive input types require.
182
- def emit_unions_file(unions)
183
- out = []
184
- out << "# typed: strict"
185
- out << "# frozen_string_literal: true"
186
- out << ""
187
- out << "# Generated by GraphWeaver do not edit. Shared union types for this"
188
- out << "# schema (named fragments on union fields); query modules alias what they use."
119
+ # The shared types artifact as files: one file per type under types/, plus
120
+ # types.rb the manifest that requires them in the order the runtime needs
121
+ # (see below). One rule for all three kinds, so a schema migration diffs
122
+ # exactly the types it touched whether they're inputs, enums or unions.
123
+ def emit_types_files(unions)
124
+ files = {}
125
+ # a mapped enum's constants are its wire tables, but the file is still
126
+ # named for the GraphQL enum — one type, one file, whichever it is
127
+ enums = @mapped_enums.map { |name, mapped|
128
+ type_file(files, camelize(name)) { |out| emit_mapped_enum(mapped, out, 1) }
129
+ } + @enums.each_value.map { |enum|
130
+ type_file(files, enum.class_name) { |out| emit_enum(enum, out, 1) }
131
+ }
132
+ inputs, = ordered_inputs
133
+ structs = inputs.map { |input| type_file(files, input.class_name) { |out| emit_input(input, out, 1) } }
134
+ hoisted = unions.map { |union| type_file(files, union.class_name) { |out| emit_union(union, out, 1) } }
135
+
136
+ out = []
137
+ out << "# typed: strict"
138
+ out << "# frozen_string_literal: true"
139
+ out << ""
140
+ out << "# Generated by GraphWeaver — do not edit. Shared types for this schema —"
141
+ out << "# input types, enums, and unions hoisted from shared fragments — one file"
142
+ out << "# per type; query modules alias what they use."
143
+ out << ""
144
+ requires = @requires.uniq.sort
145
+ if requires.any?
146
+ requires.each { |req| out << "require #{req.inspect}" }
189
147
  out << ""
190
- requires = @requires.uniq.sort
191
- if requires.any?
192
- requires.each { |req| out << "require #{req.inspect}" }
193
- out << ""
194
- end
148
+ end
149
+ out << "module #{@module_name}; end"
150
+ out << ""
151
+ if inputs.any?
152
+ out << "# runtime-only forward declarations: input types reference each other"
153
+ out << "# across files, so every constant must exist before any definition loads"
154
+ out << "# (srb sees only the full bodies)"
195
155
  out << "module #{@module_name}"
196
- out << " extend T::Sig" << "" if GraphWeaver.extend_t_sig?
197
- unions.each do |union|
198
- emit_union(union, out, 1)
199
- out << ""
200
- end
201
- out.pop if out.last == ""
156
+ out << " eval(<<~RUBY, binding, __FILE__, __LINE__ + 1)"
157
+ inputs.each { |input| out << " class #{input.class_name} < T::Struct; end" }
158
+ out << " RUBY"
202
159
  out << "end"
203
-
204
- { "unions.rb" => out.join("\n") + "\n" }
160
+ out << ""
161
+ end
162
+ # enums first: an input struct's props and a union member's selections
163
+ # both spell them bare, and a T::Enum can't be forward-declared the way
164
+ # an input struct can
165
+ if enums.any? && (structs.any? || hoisted.any?)
166
+ out << "# enums first — input structs and union members spell them bare"
167
+ end
168
+ (enums.sort + structs.sort + hoisted.sort).each do |file|
169
+ out << "require_relative #{file.delete_suffix(".rb").inspect}"
205
170
  end
206
171
 
207
- # one type per file, wrapped in the namespace so bare sibling
208
- # references resolve lexically
209
- def inputs_file(files, name)
210
- out = []
211
- out << "# typed: strict"
212
- out << "# frozen_string_literal: true"
213
- out << ""
214
- out << "# Generated by GraphWeaver — do not edit."
215
- out << ""
216
- out << "module #{@module_name}"
217
- yield(out)
218
- out << "end"
172
+ files["types.rb"] = out.join("\n") + "\n"
173
+ files
174
+ end
219
175
 
220
- file = "inputs/#{GraphWeaver::Inflect.underscore(name)}.rb"
221
- files[file] = out.join("\n") + "\n"
222
- file
223
- end
176
+ # one type per file, wrapped in the shared module so bare sibling
177
+ # references resolve lexically
178
+ def type_file(files, name)
179
+ out = []
180
+ out << "# typed: strict"
181
+ out << "# frozen_string_literal: true"
182
+ out << ""
183
+ out << "# Generated by GraphWeaver — do not edit."
184
+ out << ""
185
+ out << "module #{@module_name}"
186
+ yield(out)
187
+ out << "end"
224
188
 
225
- # The whole generated file: header, requires, the QUERY heredoc,
226
- # enum tables, input structs (dependency-ordered, forward-declared
227
- # when cyclic), the Result tree, and execute — assembled from the
228
- # generator's walked state.
229
- def emit_module(root, variables)
230
- flatten = flatten_input(variables)
231
- aliases = @inputs_namespace ? shared_alias_names(variables, flatten) : []
232
- # hoisted unions the result tree references, aliased so <Name>::Type and
233
- # <Name>.from_h resolve to the shared module
234
- union_aliases = @used_unions.map { |name| camelize(name) }.uniq.sort
189
+ file = "types/#{GraphWeaver::Inflect.underscore(name)}.rb"
190
+ files[file] = out.join("\n") + "\n"
191
+ file
192
+ end
193
+
194
+ # The whole generated file: header, requires, the QUERY heredoc and
195
+ # its OPERATION_NAME, enum tables, input structs (dependency-ordered,
196
+ # forward-declared when cyclic), the Result tree, and execute
197
+ # assembled from the generator's walked state.
198
+ def emit_module(root, variables, representations = [], operation_name = nil)
199
+ # Every shared type this module names: its variable root inputs, the
200
+ # enums it reached, and the unions it hoisted (aliased so <Name>::Type
201
+ # and <Name>.from_h resolve to the shared module).
202
+ aliases = if @types_namespace
203
+ (shared_input_names(variables) + shared_enum_names +
204
+ @used_unions.map { |name| camelize(name) }).uniq.sort
205
+ else
206
+ []
207
+ end
235
208
 
236
209
  out = []
237
210
  out << "# typed: strict"
@@ -244,14 +217,10 @@ class GraphWeaver::Codegen
244
217
  requires.each { |req| out << "require #{req.inspect}" }
245
218
  out << ""
246
219
  end
220
+ # the aliases below need the shared module loaded (same directory by the
221
+ # generate! convention)
247
222
  if aliases.any?
248
- # the aliases below need the shared module loaded (same directory
249
- # by the generate! convention)
250
- out << "require_relative \"inputs\""
251
- out << ""
252
- end
253
- if union_aliases.any?
254
- out << "require_relative \"unions\""
223
+ out << "require_relative \"types\""
255
224
  out << ""
256
225
  end
257
226
  out << "module #{@module_name}"
@@ -265,20 +234,71 @@ class GraphWeaver::Codegen
265
234
  @query.each_line { |line| out << " #{line}".rstrip }
266
235
  out << " #{delimiter}"
267
236
  out << ""
268
- if @inputs_namespace
269
- emit_shared_aliases(out, aliases)
270
- else
271
- emit_variable_types(out)
272
- end
273
- emit_shared_aliases(out, union_aliases, @unions_namespace)
237
+ out << " # sent as the request's operationName — what an APM keys traces on"
238
+ out << " OPERATION_NAME = T.let(#{operation_name.inspect}, T.nilable(String))"
239
+ out << ""
240
+ emit_shared_aliases(out, aliases, @types_namespace)
241
+ emit_variable_types(out)
242
+ emit_representations(out, representations)
274
243
  emit_nested(root, out, 1)
275
244
  out << ""
276
- emit_execute(out, variables, flatten:)
245
+ emit_execute(out, variables)
277
246
  out << "end"
278
247
 
279
248
  out.join("\n") + "\n"
280
249
  end
281
250
 
251
+ # Typed constructors for the entity references an
252
+ # `_entities(representations:)` query takes — one per entity the query's
253
+ # selection reaches, its kwargs the type's @key fields. A single-key
254
+ # entity types them required, so an incomplete representation is a
255
+ # Sorbet error rather than a round trip; alternative keys and nested key
256
+ # sets are what GraphWeaver::Representation.build checks at runtime.
257
+ def emit_representations(out, nodes)
258
+ return if nodes.empty?
259
+
260
+ out << " # Entity references for _entities(representations:) — one builder"
261
+ out << " # per entity this query can resolve, typed from its @key fields."
262
+ out << " module Representations"
263
+ out << " extend T::Sig" if GraphWeaver.extend_t_sig?
264
+
265
+ nodes.each do |node|
266
+ out << ""
267
+ out << " # #{node.graphql_type} #{node.key_fields.map { |set| "@key(fields: #{set.inspect})" }.join(" ")}"
268
+ sig = node.params.map { |param| "#{param.kwarg}: #{param.type}" }.join(", ")
269
+ out << " sig { params(#{sig}).returns(T::Hash[String, T.untyped]) }"
270
+ kwargs = node.params.map { |param| param.required ? "#{param.kwarg}:" : "#{param.kwarg}: nil" }.join(", ")
271
+ out << " def self.#{node.method_name}(#{kwargs})"
272
+ out << " GraphWeaver::Representation.build(#{node.graphql_type.inspect}, {"
273
+ node.params.each { |param| out << " #{param.wire.inspect} => #{param.value}," }
274
+ out << " }, #{node.key_sets.inspect})"
275
+ out << " end"
276
+ end
277
+
278
+ # Builders are query-driven, so an entity the `_entities` selection
279
+ # doesn't name has none — a bare NoMethodError there points at nothing.
280
+ out << ""
281
+ out << " BUILDERS = T.let(#{nodes.map(&:method_name).sort.inspect}.freeze, T::Array[String])"
282
+ out << " private_constant :BUILDERS"
283
+ out << ""
284
+ out << " sig { params(name: Symbol, args: T.untyped, block: T.untyped).returns(T.noreturn) }"
285
+ out << " def self.method_missing(name, *args, &block)"
286
+ out << ' type = GraphWeaver::Inflect.camelize(name.to_s)'
287
+ out << ' raise NoMethodError, "no representation builder for #{type} (this query builds: ' \
288
+ '#{BUILDERS.join(", ")}) — if #{type} is an entity of this subgraph, name it in the ' \
289
+ '_entities selection (`... on #{type} { __typename }`) and regenerate"'
290
+ out << " end"
291
+
292
+ out << " end"
293
+ out << ""
294
+ end
295
+
296
+ # Is this node defined once at module level rather than inside the struct
297
+ # that references it? (Variable enums — see the ENUM branch of object_node.)
298
+ def module_level?(node)
299
+ @enums.value?(node)
300
+ end
301
+
282
302
  def emit_nested(node, out, indent)
283
303
  case node
284
304
  when UnionNode then emit_union(node, out, indent)
@@ -327,7 +347,11 @@ class GraphWeaver::Codegen
327
347
 
328
348
  # uniq by object identity: deduped sibling unions share one node, so the
329
349
  # shared type is emitted once (both fields' consts already reference it).
330
- node.fields.filter_map { |field| field.node.nested }.uniq.each do |child|
350
+ # A variable enum a result field reuses is already defined at module
351
+ # level (or aliased from the shared inputs module) — redefining it here
352
+ # would shadow the shared type back apart.
353
+ children = node.fields.filter_map { |field| field.node.nested }.uniq
354
+ children.reject { |child| module_level?(child) }.each do |child|
331
355
  emit_nested(child, out, indent + 1)
332
356
  out << ""
333
357
  end
@@ -344,9 +368,9 @@ class GraphWeaver::Codegen
344
368
  out << "#{pad} #{field.prop}: #{field_cast(field)},"
345
369
  end
346
370
  out << "#{pad} )"
347
- out << "#{pad} rescue GraphWeaver::TypeError"
348
- out << "#{pad} raise # already wrapped by a nested struct — keep the innermost context"
349
- out << "#{pad} rescue TypeError, ArgumentError, KeyError => e"
371
+ out << "#{pad} rescue GraphWeaver::Error"
372
+ out << "#{pad} raise # already branded by a nested struct — keep the innermost context"
373
+ out << "#{pad} rescue StandardError => e" # a scalar's cast may raise anything
350
374
  out << "#{pad} raise GraphWeaver::TypeError.new(struct: self, error: e)"
351
375
  out << "#{pad} end"
352
376
 
@@ -366,95 +390,93 @@ class GraphWeaver::Codegen
366
390
  out << "#{pad}module #{node.class_name}"
367
391
  out << "#{pad} extend T::Sig" << "" if GraphWeaver.extend_t_sig?
368
392
 
369
- node.members.each_value do |member|
393
+ structs = node.members.values + [node.catch_all].compact
394
+ structs.each do |member|
370
395
  emit_object(member, out, indent + 1)
371
396
  out << ""
372
397
  end
373
398
 
374
- member_names = node.members.values.map(&:class_name)
399
+ member_names = structs.map(&:class_name)
375
400
  type_alias = member_names.size == 1 ? member_names.first : "T.any(#{member_names.join(", ")})"
376
401
  out << "#{pad} Type = T.type_alias { #{type_alias} }"
377
402
  out << ""
378
403
  out << "#{pad} sig { params(data: T::Hash[String, T.untyped]).returns(Type) }"
379
404
  out << "#{pad} def self.from_h(data)"
380
- out << "#{pad} case (typename = data.fetch(\"__typename\"))"
405
+ if node.catch_all
406
+ out << "#{pad} case data.fetch(\"__typename\")"
407
+ else
408
+ out << "#{pad} case (typename = data.fetch(\"__typename\"))"
409
+ end
381
410
  node.members.each do |graphql_name, member|
382
411
  out << "#{pad} when #{graphql_name.inspect} then #{member.class_name}.from_h(data)"
383
412
  end
384
- out << "#{pad} else raise GraphWeaver::TypeError.new(struct: self, message: \"unexpected __typename: \#{typename}\")"
413
+ if node.catch_all
414
+ out << "#{pad} # a member this query names no fields on — including one the"
415
+ out << "#{pad} # schema grew since generation"
416
+ out << "#{pad} else #{node.catch_all.class_name}.from_h(data)"
417
+ else
418
+ out << "#{pad} else raise GraphWeaver::TypeError.new(struct: self, message: \"unexpected __typename: \#{typename}\")"
419
+ end
385
420
  out << "#{pad} end"
386
421
  out << "#{pad} end"
387
422
  out << "#{pad}end"
388
423
  end
389
424
 
390
- def emit_execute(out, variables, flatten: nil)
391
- out << " @client = T.let(nil, T.untyped)"
392
- out << ""
393
- out << " class << self"
394
- out << " extend T::Sig" << "" if GraphWeaver.extend_t_sig?
395
- out << " sig { params(client: T.untyped).void }"
396
- out << " attr_writer :client"
397
- out << ""
398
- out << " # default client (a GraphWeaver::Client or any transport) for"
399
- out << " # execute: per-module override, else the app default"
400
- out << " sig { returns(T.untyped) }"
401
- out << " def client"
402
- out << " @client || #{@client_const || "GraphWeaver.client!"}"
403
- out << " end"
404
- out << " end"
425
+ def emit_execute(out, variables)
426
+ # client/client= carry no per-query types, so they live in the gem
427
+ out << " # client / client= — see GraphWeaver::QueryModule"
428
+ out << " extend GraphWeaver::QueryModule"
429
+ if @client_const
430
+ out << ""
431
+ out << " # the baked default client, resolved on first use"
432
+ out << " DEFAULT_CLIENT = T.let(-> { #{@client_const} }, T.proc.returns(T.untyped))"
433
+ end
405
434
  out << ""
406
435
 
407
- # the kwarg surface: the input's fields when flattened, else one
408
- # kwarg per declared variable typed identically either way. The
409
- # per-call client override rides as an optional POSITIONAL arg, so
410
- # variables keep the entire kwarg namespace (nothing is reserved).
411
- params = flatten ? flatten.fields.partition(&:required).flatten : variables
412
-
413
- sig_params = ["client: T.untyped"]
414
- sig_params += params.map do |param|
415
- bare = param.node.coerce? ? param.node.coerce_input_type : param.node.bare_type
416
- kwarg_type = param.required || bare == "T.untyped" ? bare : "T.nilable(#{bare})"
417
- "#{kwarg_name(param)}: #{kwarg_type}"
436
+ # The kwarg surface: one kwarg per declared variable, always — so the
437
+ # call sites a query already has don't change shape when it grows one.
438
+ # The per-call client override is a kwarg like the rest; a GraphQL
439
+ # variable can't claim the name (RESERVED_KWARGS).
440
+ #
441
+ # Required kwargs first, then optional ones (client: last, since it
442
+ # always has a default): Method#parameters reports them in that order
443
+ # whatever the source says, and sorbet-runtime checks the sig against it.
444
+ required, optional = variables.partition(&:required)
445
+ ordered = required + optional
446
+
447
+ sig_params = ordered.map do |var|
448
+ bare = var.node.coerce? ? var.node.coerce_input_type : var.node.bare_type
449
+ kwarg_type = var.required || bare == "T.untyped" ? bare : "T.nilable(#{bare})"
450
+ "#{var.kwarg}: #{kwarg_type}"
418
451
  end
452
+ sig_params << "client: T.untyped"
419
453
 
420
- kwargs = ["client = nil"]
421
- kwargs += params.map { |param| param.required ? "#{kwarg_name(param)}:" : "#{kwarg_name(param)}: nil" }
454
+ kwargs = required.map { |var| "#{var.kwarg}:" } +
455
+ optional.map { |var| "#{var.kwarg}: nil" } + ["client: nil"]
422
456
 
423
457
  # execute returns the full envelope; execute! is the strict shortcut for
424
458
  # `execute(...).data!` — the typed result, or a raised QueryError.
425
459
  # kwargs forward via hash shorthand (key == value)
426
- forward = (["client"] + params.map { |param| "#{kwarg_name(param)}:" }).join(", ")
460
+ forward = (ordered.map { |var| "#{var.kwarg}:" } + ["client:"]).join(", ")
427
461
 
428
- if flatten
429
- out << " # $#{variables.first.wire}'s fields, flattened into kwargs (single input-object variable)"
430
- end
431
462
  out << " sig { params(#{sig_params.join(", ")}).returns(GraphWeaver::Response[Result]) }"
432
463
  out << " def self.execute(#{kwargs.join(", ")})"
433
464
 
434
- if flatten
435
- fields = flatten.fields.map { |field| "#{field.prop}:" }.join(", ")
436
- out << " variables = {"
437
- out << " #{variables.first.wire.inspect} => #{flatten.class_name}.coerce({ #{fields} }).serialize,"
438
- out << " }"
465
+ if required.empty?
466
+ out << " variables = {}"
439
467
  else
440
- required, optional = variables.partition(&:required)
441
- if required.empty?
442
- out << " variables = {}"
443
- else
444
- out << " variables = {"
445
- required.each do |var|
446
- out << " #{var.wire.inspect} => #{variable_serialize(var)},"
447
- end
448
- out << " }"
449
- end
450
- optional.each do |var|
451
- out << " variables[#{var.wire.inspect}] = #{variable_serialize(var)} unless #{var.kwarg}.nil?"
468
+ out << " variables = {"
469
+ required.each do |var|
470
+ out << " #{var.wire.inspect} => #{variable_serialize(var)},"
452
471
  end
472
+ out << " }"
473
+ end
474
+ optional.each do |var|
475
+ out << " variables[#{var.wire.inspect}] = #{variable_serialize(var)} unless #{var.kwarg}.nil?"
453
476
  end
454
477
 
455
478
  out << ""
456
- out << " transport = GraphWeaver.resolve_transport(client || self.client)"
457
- out << " from_response(transport.execute(QUERY, variables: variables))"
479
+ out << " from_response(client_for(client).execute(QUERY, variables:, operation_name: OPERATION_NAME))"
458
480
  out << " end"
459
481
  out << ""
460
482
  out << " sig { params(#{sig_params.join(", ")}).returns(Result) }"
@@ -477,7 +499,7 @@ class GraphWeaver::Codegen
477
499
  out << " # \"errors\" => ..., \"extensions\" => ...} with wire-cased string keys."
478
500
  out << " sig { params(response: T.untyped).returns(GraphWeaver::Response[Result]) }"
479
501
  out << " def self.from_response(response)"
480
- out << " raw = response.to_h"
502
+ out << " raw = GraphWeaver.check_envelope!(response.to_h, Result)"
481
503
  out << " GraphWeaver::Response[Result].new("
482
504
  out << " data: (Result.from_h(raw[\"data\"]) if raw[\"data\"]),"
483
505
  out << " errors: (raw[\"errors\"] || []).map { |e| GraphWeaver::GraphQLError.from_h(e) },"
@@ -492,12 +514,6 @@ class GraphWeaver::Codegen
492
514
  out << " end"
493
515
  end
494
516
 
495
- # a kwarg surface entry is a VarDef (.kwarg) or, when flattened, an
496
- # InputNode::Field (.prop)
497
- def kwarg_name(param)
498
- param.respond_to?(:kwarg) ? param.kwarg : param.prop
499
- end
500
-
501
517
  def variable_serialize(var)
502
518
  value = var.node.coerce? ? var.node.coerce(var.kwarg) : var.kwarg
503
519
  var.node.serialize_identity? ? value : var.node.serialize(value, 1)
@@ -526,6 +542,11 @@ class GraphWeaver::Codegen
526
542
  out << "#{pad} include GraphWeaver::InputStruct"
527
543
  out << "#{pad} extend GraphWeaver::InputStruct::ClassMethods"
528
544
  out << ""
545
+ if node.one_of
546
+ out << "#{pad} # @oneOf: every field is nullable, so exactly-one is checked at runtime"
547
+ out << "#{pad} ONE_OF = T.let(true, T::Boolean)"
548
+ out << ""
549
+ end
529
550
  node.fields.each do |field|
530
551
  default = field.required ? "" : ", default: nil"
531
552
  out << "#{pad} const :#{field.prop}, #{field.node.prop_type}#{default}"