graph_weaver 0.4.6 → 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 +1314 -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 -154
  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 -258
  27. data/lib/graph_weaver/codegen/enum_type.rb +27 -124
  28. data/lib/graph_weaver/codegen/nodes.rb +72 -13
  29. data/lib/graph_weaver/codegen/scalar_type.rb +68 -66
  30. data/lib/graph_weaver/codegen/type_helpers.rb +142 -0
  31. data/lib/graph_weaver/codegen.rb +593 -334
  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 +9 -1
  35. data/lib/graph_weaver/in_process.rb +90 -0
  36. data/lib/graph_weaver/input_struct.rb +14 -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 +7 -0
  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 +794 -59
  46. data/lib/graph_weaver/schemas.rb +46 -0
  47. data/lib/graph_weaver/selection.rb +43 -8
  48. data/lib/graph_weaver/tasks.rb +216 -21
  49. data/lib/graph_weaver/testing/cassette.rb +160 -61
  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 +181 -21
  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 +28 -10
  58. data/lib/graph_weaver/transport/http.rb +99 -36
  59. data/lib/graph_weaver/transport.rb +67 -14
  60. data/lib/graph_weaver/version.rb +1 -1
  61. data/lib/graph_weaver.rb +389 -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,185 +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
- # a member selecting a mapped enum (register_enum) reads its module-level
198
- # <NAME>_FROM_WIRE table — emit those here so from_h resolves them, the
199
- # same way emit_variable_types does for the query module
200
- @mapped_enums.each_value do |mapped|
201
- emit_mapped_enum(mapped, out, 1)
202
- out << ""
203
- end
204
- unions.each do |union|
205
- emit_union(union, out, 1)
206
- out << ""
207
- end
208
- 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"
209
159
  out << "end"
210
-
211
- { "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}"
212
170
  end
213
171
 
214
- # one type per file, wrapped in the namespace so bare sibling
215
- # references resolve lexically
216
- def inputs_file(files, name)
217
- out = []
218
- out << "# typed: strict"
219
- out << "# frozen_string_literal: true"
220
- out << ""
221
- out << "# Generated by GraphWeaver — do not edit."
222
- out << ""
223
- out << "module #{@module_name}"
224
- yield(out)
225
- out << "end"
172
+ files["types.rb"] = out.join("\n") + "\n"
173
+ files
174
+ end
226
175
 
227
- file = "inputs/#{GraphWeaver::Inflect.underscore(name)}.rb"
228
- files[file] = out.join("\n") + "\n"
229
- file
230
- 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"
231
188
 
232
- # The whole generated file: header, requires, the QUERY heredoc,
233
- # enum tables, input structs (dependency-ordered, forward-declared
234
- # when cyclic), the Result tree, and execute — assembled from the
235
- # generator's walked state.
236
- def emit_module(root, variables)
237
- flatten = flatten_input(variables)
238
- aliases = @inputs_namespace ? shared_alias_names(variables, flatten) : []
239
- # hoisted unions the result tree references, aliased so <Name>::Type and
240
- # <Name>.from_h resolve to the shared module
241
- 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
242
208
 
243
209
  out = []
244
210
  out << "# typed: strict"
@@ -251,14 +217,10 @@ class GraphWeaver::Codegen
251
217
  requires.each { |req| out << "require #{req.inspect}" }
252
218
  out << ""
253
219
  end
220
+ # the aliases below need the shared module loaded (same directory by the
221
+ # generate! convention)
254
222
  if aliases.any?
255
- # the aliases below need the shared module loaded (same directory
256
- # by the generate! convention)
257
- out << "require_relative \"inputs\""
258
- out << ""
259
- end
260
- if union_aliases.any?
261
- out << "require_relative \"unions\""
223
+ out << "require_relative \"types\""
262
224
  out << ""
263
225
  end
264
226
  out << "module #{@module_name}"
@@ -272,20 +234,71 @@ class GraphWeaver::Codegen
272
234
  @query.each_line { |line| out << " #{line}".rstrip }
273
235
  out << " #{delimiter}"
274
236
  out << ""
275
- if @inputs_namespace
276
- emit_shared_aliases(out, aliases)
277
- else
278
- emit_variable_types(out)
279
- end
280
- 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)
281
243
  emit_nested(root, out, 1)
282
244
  out << ""
283
- emit_execute(out, variables, flatten:)
245
+ emit_execute(out, variables)
284
246
  out << "end"
285
247
 
286
248
  out.join("\n") + "\n"
287
249
  end
288
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
+
289
302
  def emit_nested(node, out, indent)
290
303
  case node
291
304
  when UnionNode then emit_union(node, out, indent)
@@ -334,7 +347,11 @@ class GraphWeaver::Codegen
334
347
 
335
348
  # uniq by object identity: deduped sibling unions share one node, so the
336
349
  # shared type is emitted once (both fields' consts already reference it).
337
- 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|
338
355
  emit_nested(child, out, indent + 1)
339
356
  out << ""
340
357
  end
@@ -351,9 +368,9 @@ class GraphWeaver::Codegen
351
368
  out << "#{pad} #{field.prop}: #{field_cast(field)},"
352
369
  end
353
370
  out << "#{pad} )"
354
- out << "#{pad} rescue GraphWeaver::TypeError"
355
- out << "#{pad} raise # already wrapped by a nested struct — keep the innermost context"
356
- 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
357
374
  out << "#{pad} raise GraphWeaver::TypeError.new(struct: self, error: e)"
358
375
  out << "#{pad} end"
359
376
 
@@ -373,95 +390,93 @@ class GraphWeaver::Codegen
373
390
  out << "#{pad}module #{node.class_name}"
374
391
  out << "#{pad} extend T::Sig" << "" if GraphWeaver.extend_t_sig?
375
392
 
376
- node.members.each_value do |member|
393
+ structs = node.members.values + [node.catch_all].compact
394
+ structs.each do |member|
377
395
  emit_object(member, out, indent + 1)
378
396
  out << ""
379
397
  end
380
398
 
381
- member_names = node.members.values.map(&:class_name)
399
+ member_names = structs.map(&:class_name)
382
400
  type_alias = member_names.size == 1 ? member_names.first : "T.any(#{member_names.join(", ")})"
383
401
  out << "#{pad} Type = T.type_alias { #{type_alias} }"
384
402
  out << ""
385
403
  out << "#{pad} sig { params(data: T::Hash[String, T.untyped]).returns(Type) }"
386
404
  out << "#{pad} def self.from_h(data)"
387
- 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
388
410
  node.members.each do |graphql_name, member|
389
411
  out << "#{pad} when #{graphql_name.inspect} then #{member.class_name}.from_h(data)"
390
412
  end
391
- 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
392
420
  out << "#{pad} end"
393
421
  out << "#{pad} end"
394
422
  out << "#{pad}end"
395
423
  end
396
424
 
397
- def emit_execute(out, variables, flatten: nil)
398
- out << " @client = T.let(nil, T.untyped)"
399
- out << ""
400
- out << " class << self"
401
- out << " extend T::Sig" << "" if GraphWeaver.extend_t_sig?
402
- out << " sig { params(client: T.untyped).void }"
403
- out << " attr_writer :client"
404
- out << ""
405
- out << " # default client (a GraphWeaver::Client or any transport) for"
406
- out << " # execute: per-module override, else the app default"
407
- out << " sig { returns(T.untyped) }"
408
- out << " def client"
409
- out << " @client || #{@client_const || "GraphWeaver.client!"}"
410
- out << " end"
411
- 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
412
434
  out << ""
413
435
 
414
- # the kwarg surface: the input's fields when flattened, else one
415
- # kwarg per declared variable typed identically either way. The
416
- # per-call client override rides as an optional POSITIONAL arg, so
417
- # variables keep the entire kwarg namespace (nothing is reserved).
418
- params = flatten ? flatten.fields.partition(&:required).flatten : variables
419
-
420
- sig_params = ["client: T.untyped"]
421
- sig_params += params.map do |param|
422
- bare = param.node.coerce? ? param.node.coerce_input_type : param.node.bare_type
423
- kwarg_type = param.required || bare == "T.untyped" ? bare : "T.nilable(#{bare})"
424
- "#{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}"
425
451
  end
452
+ sig_params << "client: T.untyped"
426
453
 
427
- kwargs = ["client = nil"]
428
- 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"]
429
456
 
430
457
  # execute returns the full envelope; execute! is the strict shortcut for
431
458
  # `execute(...).data!` — the typed result, or a raised QueryError.
432
459
  # kwargs forward via hash shorthand (key == value)
433
- forward = (["client"] + params.map { |param| "#{kwarg_name(param)}:" }).join(", ")
460
+ forward = (ordered.map { |var| "#{var.kwarg}:" } + ["client:"]).join(", ")
434
461
 
435
- if flatten
436
- out << " # $#{variables.first.wire}'s fields, flattened into kwargs (single input-object variable)"
437
- end
438
462
  out << " sig { params(#{sig_params.join(", ")}).returns(GraphWeaver::Response[Result]) }"
439
463
  out << " def self.execute(#{kwargs.join(", ")})"
440
464
 
441
- if flatten
442
- fields = flatten.fields.map { |field| "#{field.prop}:" }.join(", ")
443
- out << " variables = {"
444
- out << " #{variables.first.wire.inspect} => #{flatten.class_name}.coerce({ #{fields} }).serialize,"
445
- out << " }"
465
+ if required.empty?
466
+ out << " variables = {}"
446
467
  else
447
- required, optional = variables.partition(&:required)
448
- if required.empty?
449
- out << " variables = {}"
450
- else
451
- out << " variables = {"
452
- required.each do |var|
453
- out << " #{var.wire.inspect} => #{variable_serialize(var)},"
454
- end
455
- out << " }"
456
- end
457
- optional.each do |var|
458
- 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)},"
459
471
  end
472
+ out << " }"
473
+ end
474
+ optional.each do |var|
475
+ out << " variables[#{var.wire.inspect}] = #{variable_serialize(var)} unless #{var.kwarg}.nil?"
460
476
  end
461
477
 
462
478
  out << ""
463
- out << " transport = GraphWeaver.resolve_transport(client || self.client)"
464
- out << " from_response(transport.execute(QUERY, variables: variables))"
479
+ out << " from_response(client_for(client).execute(QUERY, variables:, operation_name: OPERATION_NAME))"
465
480
  out << " end"
466
481
  out << ""
467
482
  out << " sig { params(#{sig_params.join(", ")}).returns(Result) }"
@@ -484,7 +499,7 @@ class GraphWeaver::Codegen
484
499
  out << " # \"errors\" => ..., \"extensions\" => ...} with wire-cased string keys."
485
500
  out << " sig { params(response: T.untyped).returns(GraphWeaver::Response[Result]) }"
486
501
  out << " def self.from_response(response)"
487
- out << " raw = response.to_h"
502
+ out << " raw = GraphWeaver.check_envelope!(response.to_h, Result)"
488
503
  out << " GraphWeaver::Response[Result].new("
489
504
  out << " data: (Result.from_h(raw[\"data\"]) if raw[\"data\"]),"
490
505
  out << " errors: (raw[\"errors\"] || []).map { |e| GraphWeaver::GraphQLError.from_h(e) },"
@@ -499,12 +514,6 @@ class GraphWeaver::Codegen
499
514
  out << " end"
500
515
  end
501
516
 
502
- # a kwarg surface entry is a VarDef (.kwarg) or, when flattened, an
503
- # InputNode::Field (.prop)
504
- def kwarg_name(param)
505
- param.respond_to?(:kwarg) ? param.kwarg : param.prop
506
- end
507
-
508
517
  def variable_serialize(var)
509
518
  value = var.node.coerce? ? var.node.coerce(var.kwarg) : var.kwarg
510
519
  var.node.serialize_identity? ? value : var.node.serialize(value, 1)
@@ -533,6 +542,11 @@ class GraphWeaver::Codegen
533
542
  out << "#{pad} include GraphWeaver::InputStruct"
534
543
  out << "#{pad} extend GraphWeaver::InputStruct::ClassMethods"
535
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
536
550
  node.fields.each do |field|
537
551
  default = field.required ? "" : ", default: nil"
538
552
  out << "#{pad} const :#{field.prop}, #{field.node.prop_type}#{default}"