graph_weaver 0.0.1 → 0.2.0

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