graph_weaver 0.3.0 → 0.4.4

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.
@@ -115,10 +115,10 @@ class GraphWeaver::Codegen
115
115
  names.uniq
116
116
  end
117
117
 
118
- def emit_shared_aliases(out, names)
118
+ def emit_shared_aliases(out, names, namespace = @inputs_namespace)
119
119
  return if names.empty?
120
120
 
121
- names.each { |name| out << " #{name} = #{@inputs_namespace}::#{name}" }
121
+ names.each { |name| out << " #{name} = #{namespace}::#{name}" }
122
122
  out << ""
123
123
  end
124
124
 
@@ -175,6 +175,35 @@ class GraphWeaver::Codegen
175
175
  files
176
176
  end
177
177
 
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."
189
+ out << ""
190
+ requires = @requires.uniq.sort
191
+ if requires.any?
192
+ requires.each { |req| out << "require #{req.inspect}" }
193
+ out << ""
194
+ end
195
+ 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 == ""
202
+ out << "end"
203
+
204
+ { "unions.rb" => out.join("\n") + "\n" }
205
+ end
206
+
178
207
  # one type per file, wrapped in the namespace so bare sibling
179
208
  # references resolve lexically
180
209
  def inputs_file(files, name)
@@ -200,6 +229,9 @@ class GraphWeaver::Codegen
200
229
  def emit_module(root, variables)
201
230
  flatten = flatten_input(variables)
202
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
203
235
 
204
236
  out = []
205
237
  out << "# typed: strict"
@@ -218,9 +250,12 @@ class GraphWeaver::Codegen
218
250
  out << "require_relative \"inputs\""
219
251
  out << ""
220
252
  end
253
+ if union_aliases.any?
254
+ out << "require_relative \"unions\""
255
+ out << ""
256
+ end
221
257
  out << "module #{@module_name}"
222
- out << " extend T::Sig"
223
- out << ""
258
+ out << " extend T::Sig" << "" if GraphWeaver.extend_t_sig?
224
259
  # a GraphQL block string could contain a bare GRAPHQL line, which
225
260
  # would terminate the heredoc early — pick a delimiter the query
226
261
  # can't collide with
@@ -235,6 +270,7 @@ class GraphWeaver::Codegen
235
270
  else
236
271
  emit_variable_types(out)
237
272
  end
273
+ emit_shared_aliases(out, union_aliases, @unions_namespace)
238
274
  emit_nested(root, out, 1)
239
275
  out << ""
240
276
  emit_execute(out, variables, flatten:)
@@ -282,14 +318,16 @@ class GraphWeaver::Codegen
282
318
  pad = " " * indent
283
319
 
284
320
  out << "#{pad}class #{node.class_name} < T::Struct"
285
- out << "#{pad} extend T::Sig"
321
+ out << "#{pad} extend T::Sig" if GraphWeaver.extend_t_sig?
286
322
  out << "#{pad} include GraphWeaver::Hints"
287
323
  node.mixins.each do |mixin|
288
324
  out << "#{pad} include #{mixin} # registered for #{node.graphql_type}"
289
325
  end
290
326
  out << ""
291
327
 
292
- node.fields.filter_map { |field| field.node.nested }.each do |child|
328
+ # uniq by object identity: deduped sibling unions share one node, so the
329
+ # shared type is emitted once (both fields' consts already reference it).
330
+ node.fields.filter_map { |field| field.node.nested }.uniq.each do |child|
293
331
  emit_nested(child, out, indent + 1)
294
332
  out << ""
295
333
  end
@@ -311,6 +349,14 @@ class GraphWeaver::Codegen
311
349
  out << "#{pad} rescue TypeError, ArgumentError, KeyError => e"
312
350
  out << "#{pad} raise GraphWeaver::TypeError.new(struct: self, error: e)"
313
351
  out << "#{pad} end"
352
+
353
+ # alias delegators (extend_type alias:) — typed accessors that project a
354
+ # selected field onto the struct, next to the honest wire data
355
+ node.aliases.each do |a|
356
+ out << ""
357
+ out << "#{pad} sig { returns(#{a.type}) }"
358
+ out << "#{pad} def #{a.name} = #{a.expr}"
359
+ end
314
360
  out << "#{pad}end"
315
361
  end
316
362
 
@@ -318,8 +364,7 @@ class GraphWeaver::Codegen
318
364
  pad = " " * indent
319
365
 
320
366
  out << "#{pad}module #{node.class_name}"
321
- out << "#{pad} extend T::Sig"
322
- out << ""
367
+ out << "#{pad} extend T::Sig" << "" if GraphWeaver.extend_t_sig?
323
368
 
324
369
  node.members.each_value do |member|
325
370
  emit_object(member, out, indent + 1)
@@ -346,8 +391,7 @@ class GraphWeaver::Codegen
346
391
  out << " @client = T.let(nil, T.untyped)"
347
392
  out << ""
348
393
  out << " class << self"
349
- out << " extend T::Sig"
350
- out << ""
394
+ out << " extend T::Sig" << "" if GraphWeaver.extend_t_sig?
351
395
  out << " sig { params(client: T.untyped).void }"
352
396
  out << " attr_writer :client"
353
397
  out << ""
@@ -378,7 +422,8 @@ class GraphWeaver::Codegen
378
422
 
379
423
  # execute returns the full envelope; execute! is the strict shortcut for
380
424
  # `execute(...).data!` — the typed result, or a raised QueryError.
381
- forward = (["client"] + params.map { |param| "#{kwarg_name(param)}: #{kwarg_name(param)}" }).join(", ")
425
+ # kwargs forward via hash shorthand (key == value)
426
+ forward = (["client"] + params.map { |param| "#{kwarg_name(param)}:" }).join(", ")
382
427
 
383
428
  if flatten
384
429
  out << " # $#{variables.first.wire}'s fields, flattened into kwargs (single input-object variable)"
@@ -102,9 +102,38 @@ class GraphWeaver::Codegen
102
102
  # end
103
103
  #
104
104
  # Additive: repeated registrations (and client-scoped ones) stack.
105
- def extend_type(graphql_name, *mixins, requires: nil, &block)
106
- entry = type_registry[graphql_name.to_s] ||= { mixins: [], requires: [] }
107
- add_type_helpers(entry, graphql_name, mixins, requires, block)
105
+ #
106
+ # alias: projects a (possibly nested) selected field onto a flat, typed
107
+ # accessor on the struct — the one derivation codegen can type itself, so
108
+ # it's emitted into the struct body where the field is in scope:
109
+ #
110
+ # GraphWeaver.extend_type("Widget", alias: { tag: "meta.tag" })
111
+ # GraphWeaver.extend_type("Widget", alias: "meta.tag") # accessor named `tag`
112
+ # GraphWeaver.extend_type("Widget", alias: ["meta.tag", "meta.color"])
113
+ def extend_type(graphql_name, *mixins, requires: nil, **kw, &block)
114
+ aliases = take_aliases(kw)
115
+ entry = type_registry[graphql_name.to_s] ||= { mixins: [], requires: [], aliases: {} }
116
+ add_type_helpers(entry, graphql_name, mixins, requires, block, aliases)
117
+ end
118
+
119
+ # Pull alias: out of the keyword rest and normalize it; any other keyword
120
+ # is a typo worth flagging rather than silently dropping.
121
+ def take_aliases(kw)
122
+ aliases = normalize_aliases(kw.delete(:alias))
123
+ raise ArgumentError, "unknown keyword: #{kw.keys.first}" unless kw.empty?
124
+ aliases
125
+ end
126
+
127
+ # { accessor => [path, segments] } from a path string (accessor named
128
+ # after the last segment), an array of such, or an { accessor => path } hash.
129
+ def normalize_aliases(input)
130
+ case input
131
+ when nil then {}
132
+ when String then { input.split(".").last => input.split(".") }
133
+ when Array then input.to_h { |path| [path.split(".").last, path.split(".")] }
134
+ when Hash then input.to_h { |name, path| [name.to_s, path.to_s.split(".")] }
135
+ else raise ArgumentError, "alias: expects a String, Array, or Hash, got #{input.class}"
136
+ end
108
137
  end
109
138
 
110
139
  def type_registry
@@ -113,11 +142,13 @@ class GraphWeaver::Codegen
113
142
 
114
143
  # shared with Client#extend_type: build/validate the mixins and
115
144
  # append them to a registry entry
116
- def add_type_helpers(entry, graphql_name, mixins, requires, block)
145
+ def add_type_helpers(entry, graphql_name, mixins, requires, block, aliases = {})
117
146
  mixins = mixins.dup
118
147
  mixins << helper_module(graphql_name, block) if block
119
148
 
120
- raise ArgumentError, "pass one or more helper modules, or a block" if mixins.empty?
149
+ if mixins.empty? && aliases.empty?
150
+ raise ArgumentError, "pass one or more helper modules, a block, or alias:"
151
+ end
121
152
  mixins.each do |mixin|
122
153
  unless mixin.is_a?(Module) && mixin.name
123
154
  raise ArgumentError, "type helpers must be named modules, got #{mixin.inspect}"
@@ -126,6 +157,7 @@ class GraphWeaver::Codegen
126
157
 
127
158
  entry[:mixins].concat(mixins)
128
159
  entry[:requires].concat(Array(requires))
160
+ (entry[:aliases] ||= {}).merge!(aliases)
129
161
  entry
130
162
  end
131
163
 
@@ -144,16 +144,20 @@ class GraphWeaver::Codegen
144
144
 
145
145
  class ObjectNode < Node
146
146
  Field = Struct.new(:prop, :key, :node)
147
+ # a resolved alias delegator (extend_type alias:): the accessor name, the
148
+ # Ruby path expression it reads (meta&.tag), and its Sorbet return type
149
+ Alias = Struct.new(:name, :expr, :type)
147
150
 
148
151
  attr_reader :class_name, :fields
149
- # the GraphQL type this struct was generated from, and any registered
150
- # helper modules to include (see Codegen.extend_type)
151
- attr_accessor :graphql_type, :mixins
152
+ # the GraphQL type this struct was generated from, any registered helper
153
+ # modules to include, and any resolved alias delegators (see extend_type)
154
+ attr_accessor :graphql_type, :mixins, :aliases
152
155
 
153
156
  def initialize(class_name)
154
157
  @class_name = class_name
155
158
  @fields = []
156
159
  @mixins = []
160
+ @aliases = []
157
161
  end
158
162
 
159
163
  def bare_type = class_name
@@ -277,6 +281,26 @@ class GraphWeaver::Codegen
277
281
  def nested = self
278
282
  end
279
283
 
284
+ # A reference to a union hoisted into the shared unions module (a named
285
+ # shared fragment spread as a whole union field): the query references
286
+ # <Name>::Type and dispatches through <Name>.from_h, where <Name> is the
287
+ # alias the query module gives GraphQLUnions::<Name>. The type family lives
288
+ # once in the shared module, so the same union across queries is one Ruby
289
+ # type — nested is nil, nothing is emitted here.
290
+ class UnionRefNode < Node
291
+ attr_reader :class_name
292
+
293
+ def initialize(class_name)
294
+ @class_name = class_name
295
+ end
296
+
297
+ def bare_type = "#{class_name}::Type"
298
+
299
+ def cast(expr, _depth)
300
+ "#{class_name}.from_h(#{expr})"
301
+ end
302
+ end
303
+
280
304
  # An input-object variable: emitted as a module-level T::Struct whose
281
305
  # serialize produces the wire hash. Inputs never cast FROM the wire.
282
306
  # Joins the coerce protocol so execute kwargs accept plain hashes,
@@ -49,8 +49,12 @@ class GraphWeaver::Codegen
49
49
  # mixin modules, each keyed by GraphQL name). inputs_namespace: is the
50
50
  # shared-inputs workflow (see GraphWeaver.generate!): variable types
51
51
  # live once in that module and the query module aliases what it uses.
52
+ # unions_namespace:/hoistable_unions: are the parallel shared-unions
53
+ # workflow — a whole-union field spread as a named shared fragment resolves
54
+ # to one canonical type in that module (see used_union_names).
52
55
  def initialize(schema:, query:, module_name: nil, client: nil, default_module_name: nil,
53
- scalars: nil, enums: nil, types: nil, inputs_namespace: nil)
56
+ scalars: nil, enums: nil, types: nil, inputs_namespace: nil, unions_namespace: nil,
57
+ hoistable_unions: nil)
54
58
  @schema = schema
55
59
  @query = query.strip
56
60
  @module_name = module_name
@@ -59,6 +63,12 @@ class GraphWeaver::Codegen
59
63
  @enums = enums || {}
60
64
  @types = types || {}
61
65
  @inputs_namespace = inputs_namespace
66
+ # the shared-unions workflow: unions_namespace names the module hoisted
67
+ # unions live in; hoistable_unions is the set of shared fragment names this
68
+ # query may hoist (spreads it inlined, minus any it shadows locally)
69
+ @unions_namespace = unions_namespace
70
+ @hoistable_unions = hoistable_unions || []
71
+ @used_unions = []
62
72
  @client_const = self.class.client_const(client)
63
73
 
64
74
  if client && @client_const.nil?
@@ -111,6 +121,11 @@ class GraphWeaver::Codegen
111
121
  { inputs: @variable_inputs.keys, enums: @variable_enums.keys, mapped: @mapped_enums.keys }
112
122
  end
113
123
 
124
+ # The shared union fragments this query hoisted, by name — the generate!
125
+ # workflow unions these across queries to decide what the shared unions
126
+ # module must contain.
127
+ def used_union_names = @used_unions.dup
128
+
114
129
  # The shared inputs artifact: the named input/enum types — plus
115
130
  # everything they transitively reference — emitted once per schema as
116
131
  # a manifest (inputs.rb) plus one file per type under inputs/, so a
@@ -138,6 +153,36 @@ class GraphWeaver::Codegen
138
153
  emit_inputs_files
139
154
  end
140
155
 
156
+ # The shared unions artifact: each named shared fragment a query hoisted,
157
+ # built once against the schema as <module_name>::<Name>, so the same union
158
+ # across queries resolves to one Ruby type family. `fragments` is the loaded
159
+ # shared-fragment table (nested spreads resolve through it); `names` the
160
+ # fragments to build. Returns { "unions.rb" => source }.
161
+ def self.generate_unions(schema:, module_name:, fragments:, names:,
162
+ scalars: nil, enums: nil, types: nil)
163
+ codegen = new(schema:, query: "", module_name:, scalars:, enums:, types:)
164
+ codegen.generate_unions(fragments, names)
165
+ end
166
+
167
+ def generate_unions(fragments, names)
168
+ unless @module_name&.match?(/\A[A-Z]\w*(::[A-Z]\w*)*\z/)
169
+ raise ArgumentError, "unions module name must be a constant name, got #{@module_name.inspect}"
170
+ end
171
+
172
+ @requires = []
173
+ @mapped_enums = {}
174
+ # nested spreads inside a shared fragment resolve through the whole table
175
+ @fragments = fragments
176
+
177
+ unions = names.uniq.sort.map do |name|
178
+ fragment = fragments.fetch(name)
179
+ type = @schema.get_type(fragment.type.name)
180
+ UnionNode.new(camelize(name), union_members(type, fragment.selections))
181
+ end
182
+
183
+ emit_unions_file(unions)
184
+ end
185
+
141
186
  VarDef = Struct.new(:kwarg, :wire, :node, :required)
142
187
 
143
188
  # Names that cannot appear bare in generated Ruby: keywords aren't
@@ -168,6 +213,7 @@ class GraphWeaver::Codegen
168
213
  @variable_enums = {}
169
214
  @variable_inputs = {}
170
215
  @mapped_enums = {}
216
+ @used_unions = []
171
217
  # requires the generated file needs (custom scalars, enum mappings,
172
218
  # type helpers all contribute)
173
219
  @requires = []
@@ -214,6 +260,14 @@ class GraphWeaver::Codegen
214
260
  # whose schema introspects lazily). Global registrations skip this:
215
261
  # they may target a different client's server.
216
262
  def self.validate_registration!(schema, kind, name)
263
+ # register_scalar("Type.field", ...) overrides one field's scalar — validate
264
+ # the field exists and is a scalar, not that a type named "Type.field" exists.
265
+ if kind == "scalar" && name.include?(".")
266
+ return if scalar_field?(schema, name)
267
+
268
+ raise GraphWeaver::Error, "register_scalar(#{name.inspect}) matches no scalar field in this schema"
269
+ end
270
+
217
271
  return if schema.get_type(name)
218
272
 
219
273
  suggestion = defined?(DidYouMean::SpellChecker) &&
@@ -224,6 +278,81 @@ class GraphWeaver::Codegen
224
278
  raise GraphWeaver::Error, "#{method}(#{name.inspect}) matches no type in this schema#{hint}"
225
279
  end
226
280
 
281
+ # Whether `coordinate` ("Type.field") names an existing scalar field — the
282
+ # validation for a per-field register_scalar override.
283
+ def self.scalar_field?(schema, coordinate)
284
+ type_name, field_name = coordinate.split(".", 2)
285
+ return false unless field_name
286
+
287
+ field = schema.get_field(type_name, field_name)
288
+ !!field && field.type.unwrap.kind.name == "SCALAR"
289
+ rescue StandardError
290
+ false
291
+ end
292
+
293
+ # Parse every fragment file under `paths` into one { name => FragmentDefinition }
294
+ # map — reusable fragments a query can spread. Fragment files hold only
295
+ # fragments (no operations); names are unique across them.
296
+ def self.load_fragments(paths)
297
+ Array(paths).flat_map { |dir| Dir[File.join(dir, "*.graphql")].sort }.each_with_object({}) do |file, out|
298
+ doc = GraphQL.parse(File.read(file))
299
+ if doc.definitions.grep(GraphQL::Language::Nodes::OperationDefinition).any?
300
+ raise GraphWeaver::Error, "#{file}: fragment files define only fragments, no operations"
301
+ end
302
+ doc.definitions.grep(GraphQL::Language::Nodes::FragmentDefinition).each do |frag|
303
+ raise GraphWeaver::Error, "duplicate shared fragment '#{frag.name}' (#{file})" if out.key?(frag.name)
304
+ out[frag.name] = frag
305
+ end
306
+ end
307
+ end
308
+
309
+ # The shared fragments a query spreads (transitively), excluding any it
310
+ # shadows with a local definition of the same name — the names
311
+ # inline_fragments appends, and the set the generate! workflow may hoist
312
+ # when they sit on a whole-union field.
313
+ def self.shared_fragment_spreads(query, shared)
314
+ return [] if shared.empty?
315
+
316
+ doc = GraphQL.parse(query)
317
+ local = doc.definitions.grep(GraphQL::Language::Nodes::FragmentDefinition).map(&:name)
318
+ reachable_fragments(fragment_spreads(doc.definitions), shared, local)
319
+ end
320
+
321
+ # Append the shared fragments a query spreads (transitively) to its source, so
322
+ # the sent query is self-contained. Unused shared fragments are left out.
323
+ def self.inline_fragments(query, shared)
324
+ used = shared_fragment_spreads(query, shared)
325
+ return query if used.empty?
326
+
327
+ "#{query.rstrip}\n\n#{used.sort.map { |name| shared.fetch(name).to_query_string }.join("\n\n")}\n"
328
+ end
329
+
330
+ # BFS over spreads, following shared fragments into their own spreads; a
331
+ # locally-defined or unknown spread is left for schema validation to judge.
332
+ def self.reachable_fragments(spreads, shared, local)
333
+ needed = []
334
+ queue = spreads.dup
335
+ until queue.empty?
336
+ name = queue.shift
337
+ next if needed.include?(name) || local.include?(name) || !shared.key?(name)
338
+
339
+ needed << name
340
+ queue.concat(fragment_spreads([shared.fetch(name)]))
341
+ end
342
+ needed
343
+ end
344
+ private_class_method :reachable_fragments
345
+
346
+ # Names of every fragment spread reachable in these AST nodes.
347
+ def self.fragment_spreads(nodes, acc = [])
348
+ nodes.each do |node|
349
+ acc << node.name if node.is_a?(GraphQL::Language::Nodes::FragmentSpread)
350
+ fragment_spreads(node.selections, acc) if node.respond_to?(:selections)
351
+ end
352
+ acc
353
+ end
354
+ private_class_method :fragment_spreads
355
+
227
356
  # Structured shape for a schema-validation error: message plus its first
228
357
  # source location, so ValidationError#errors is inspectable.
229
358
  def validation_detail(error)
@@ -251,6 +380,10 @@ class GraphWeaver::Codegen
251
380
  node.graphql_type = type.graphql_name
252
381
  node.mixins = type_mixins(type.graphql_name)
253
382
  taken = [class_name]
383
+ # Dedup structurally-identical dispatch-union fields on this struct: the
384
+ # same union selected two ways (unblockOptions vs selectedOption) shares
385
+ # one Ruby type, so consumers get one exhaustive `case ... T.absurd`.
386
+ union_cache = {}
254
387
 
255
388
  gather(type, selections).each do |key, field_nodes|
256
389
  field_name = field_nodes.first.name
@@ -291,9 +424,20 @@ class GraphWeaver::Codegen
291
424
 
292
425
  name = pick_name(member.graphql_name, key, taken)
293
426
  nilable_type_ref(field_type) { NarrowedNode.new(object_node(member, sub_selections, name)) }
427
+ elsif @unions_namespace && (frag = lone_shared_spread(sub_selections)) &&
428
+ @hoistable_unions.include?(frag)
429
+ # a whole-union field spread as a named shared fragment: hoist to
430
+ # the shared unions module so the same union across queries is one
431
+ # Ruby type family (one exhaustive `case ... T.absurd`).
432
+ @used_unions << frag unless @used_unions.include?(frag)
433
+ ref = UnionRefNode.new(camelize(frag))
434
+ type_ref(field_type) { ref }
294
435
  else
295
- name = pick_name(core.graphql_name, key, taken)
296
- type_ref(field_type) { union_node(core, sub_selections, name) }
436
+ members = union_members(core, sub_selections)
437
+ # reuse an identical sibling union (pick_name/name only on a miss)
438
+ union = (union_cache[union_signature(members)] ||=
439
+ UnionNode.new(pick_name(core.graphql_name, key, taken), members))
440
+ type_ref(field_type) { union }
297
441
  end
298
442
  when "ENUM"
299
443
  if (mapped = mapped_enum_node(core))
@@ -305,7 +449,8 @@ class GraphWeaver::Codegen
305
449
  type_ref(field_type) { EnumNode.new(name, core.values.keys.sort) }
306
450
  end
307
451
  when "SCALAR"
308
- type_ref(field_type) { scalar_node(core.graphql_name) }
452
+ coordinate = "#{type.graphql_name}.#{field_name}"
453
+ type_ref(field_type) { scalar_node(core.graphql_name, coordinate) }
309
454
  else
310
455
  raise GraphWeaver::Error, "unsupported kind: #{core.kind.name}"
311
456
  end
@@ -320,9 +465,72 @@ class GraphWeaver::Codegen
320
465
  node.fields << ObjectNode::Field.new(prop, key, child)
321
466
  end
322
467
 
468
+ node.aliases = resolve_aliases(node)
323
469
  node
324
470
  end
325
471
 
472
+ # Resolve each registered alias (extend_type alias:) for this struct's type
473
+ # against its actual selection — path -> a typed delegator emitted into the
474
+ # struct body. Validated here, per query, so an unselected or untraversable
475
+ # path fails at generation with a pointed message.
476
+ def resolve_aliases(node)
477
+ type_aliases(node.graphql_type).map do |name, segments|
478
+ resolve_alias(node, name, segments)
479
+ end
480
+ end
481
+
482
+ # Registered aliases for a GraphQL type: global registry plus this client's
483
+ # overlay (client-scoped wins on a name clash).
484
+ def type_aliases(graphql_name)
485
+ global = GraphWeaver::Codegen.type_registry[graphql_name]&.dig(:aliases) || {}
486
+ (global.merge(@types[graphql_name]&.dig(:aliases) || {}))
487
+ end
488
+
489
+ ALIAS_RESERVED = (%w[from_h serialize to_h].to_set + RUBY_KEYWORDS).freeze
490
+
491
+ # Walk a dotted path (Ruby prop names) through this struct's selected fields,
492
+ # building the delegator expression (`meta&.tag`) and its return type. Any hop
493
+ # is nilable -> the accessor is nilable; a list hop or an unselected segment
494
+ # raises. The leaf may be any node (scalar, enum, nested struct).
495
+ def resolve_alias(node, name, segments)
496
+ if node.fields.any? { |f| f.prop == name } || ALIAS_RESERVED.include?(name)
497
+ raise GraphWeaver::Error,
498
+ "alias #{name.inspect} on #{node.graphql_type} collides with an existing field or method"
499
+ end
500
+
501
+ current = T.let(node, T.untyped)
502
+ parts = []
503
+ nilable = T.let(false, T::Boolean)
504
+ segments.each_with_index do |seg, i|
505
+ field = current.fields.find { |f| f.prop == seg }
506
+ unless field
507
+ props = current.fields.map(&:prop)
508
+ suggestion = defined?(DidYouMean::SpellChecker) &&
509
+ DidYouMean::SpellChecker.new(dictionary: props).correct(seg).first
510
+ hint = suggestion ? " — did you mean '#{suggestion}'?" : " (have: #{props.join(", ")})"
511
+ raise GraphWeaver::Error,
512
+ "alias #{name.inspect} on #{node.graphql_type}: '#{seg}' is not a selected field#{hint}"
513
+ end
514
+ nilable ||= !field.node.non_null?
515
+
516
+ if i == segments.size - 1
517
+ leaf = field.node.bare_type
518
+ type = nilable && leaf != "T.untyped" ? "T.nilable(#{leaf})" : leaf
519
+ return ObjectNode::Alias.new(name, (parts << seg).join, type)
520
+ end
521
+
522
+ inner = T.let(field.node, T.untyped)
523
+ inner = inner.of while inner.is_a?(NonNull)
524
+ raise GraphWeaver::Error, "alias #{name.inspect}: cannot traverse list-typed '#{seg}'" if inner.is_a?(List)
525
+ unless inner.is_a?(ObjectNode)
526
+ raise GraphWeaver::Error, "alias #{name.inspect}: '#{seg}' is not an object to traverse into"
527
+ end
528
+
529
+ parts << seg << (field.node.non_null? ? "." : "&.")
530
+ current = inner
531
+ end
532
+ end
533
+
326
534
  # The concrete type conditions a selection mentions (inline fragments
327
535
  # and named spreads), minus conditions naming the abstract type itself.
328
536
  def concrete_conditions(core, selections)
@@ -341,6 +549,17 @@ class GraphWeaver::Codegen
341
549
  selections.grep(GraphQL::Language::Nodes::Field).map { |field| field.alias || field.name }
342
550
  end
343
551
 
552
+ # The fragment name when a selection is exactly one bare fragment spread
553
+ # (`{ ...F }`) — the shape a union field must have to hoist into the shared
554
+ # unions module. A spread carrying directives (@skip/@include), or mixed with
555
+ # other fields, stays a locally-emitted union.
556
+ def lone_shared_spread(selections)
557
+ return unless selections.size == 1
558
+
559
+ spread = selections.first
560
+ spread.name if spread.is_a?(GraphQL::Language::Nodes::FragmentSpread) && spread.directives.empty?
561
+ end
562
+
344
563
  # does the flattened selection (as seen by member) include at least one
345
564
  # field guaranteed to be present in a matching response?
346
565
  def unconditional_field?(member, selections)
@@ -367,19 +586,45 @@ class GraphWeaver::Codegen
367
586
  # concrete type: one member struct per possible type; wire dispatch
368
587
  # reads __typename, so the query must select it. For interfaces, the
369
588
  # interface's own field selections gather into every member.
370
- def union_node(type, selections, class_name)
589
+ # The union's member structs (graphql type name => ObjectNode), sorted for
590
+ # deterministic output. Dispatch reads __typename, so the query must select
591
+ # it; for interfaces the interface-level fields gather into every member.
592
+ def union_members(type, selections)
371
593
  unless gather(type, selections).key?("__typename")
372
594
  raise ArgumentError,
373
- "select __typename on #{type.graphql_name} so #{class_name} can dispatch — " \
595
+ "select __typename on #{type.graphql_name} so the union can dispatch — " \
374
596
  "or narrow to a single `... on Type` condition (no dispatch needed)"
375
597
  end
376
598
 
377
- # sorted so output is deterministic across schema sources
378
- members = @schema.possible_types(type).sort_by(&:graphql_name).to_h do |possible|
599
+ @schema.possible_types(type).sort_by(&:graphql_name).to_h do |possible|
379
600
  [possible.graphql_name, object_node(possible, selections, camelize(possible.graphql_name))]
380
601
  end
602
+ end
603
+
604
+ # A name-independent structural fingerprint of a union's members, so two
605
+ # occurrences that generate identical structs collapse to one Ruby type.
606
+ def union_signature(members)
607
+ members.map { |gname, member| "#{gname}=#{signature(member)}" }.sort.join(",")
608
+ end
381
609
 
382
- UnionNode.new(class_name, members)
610
+ # Structural signature of a node — ignores the generated class name (which
611
+ # varies per occurrence), keying on GraphQL type, selection shape, and
612
+ # nullability so only genuinely-identical shapes collapse.
613
+ def signature(node)
614
+ case node
615
+ when NonNull then "!#{signature(node.of)}"
616
+ when List then "[#{signature(node.of)}]"
617
+ when NarrowedNode then "?#{signature(node.nested)}"
618
+ when Scalar then "s:#{node.bare_type}"
619
+ when EnumNode then "e:#{node.values.sort.join("|")}"
620
+ when MappedEnum then "m:#{node.graphql_name}"
621
+ when ObjectNode
622
+ inner = node.fields.map { |f| "#{f.prop}=#{signature(f.node)}" }.sort.join(",")
623
+ "o:#{node.graphql_type}(#{inner})"
624
+ when UnionNode then "u:(#{union_signature(node.members)})"
625
+ when UnionRefNode then "ur:#{node.class_name}" # hoisted — identity is its shared name
626
+ else "x:#{node.object_id}" # unknown node kind — never collapse
627
+ end
383
628
  end
384
629
 
385
630
  # Build a node from an AST type reference (variable definitions), where
@@ -462,9 +707,13 @@ class GraphWeaver::Codegen
462
707
 
463
708
  # A Scalar node, recording any requires its registered type needs so the
464
709
  # generated file can require them (collected across the whole query).
465
- # Resolution: the client-scoped overlay first, then the global registry.
466
- def scalar_node(name)
467
- scalar = @scalars[name.to_s] || GraphWeaver::Codegen.scalar(name)
710
+ # Resolution, most specific first: a per-field override (`Type.field`), then
711
+ # the scalar-name registration — each checked client-scoped, then global.
712
+ def scalar_node(name, coordinate = nil)
713
+ scalar =
714
+ (coordinate && (@scalars[coordinate] || GraphWeaver::Codegen.scalar_registry[coordinate])) ||
715
+ @scalars[name.to_s] ||
716
+ GraphWeaver::Codegen.scalar(name)
468
717
  @requires.concat(scalar.requires)
469
718
  Scalar.new(scalar)
470
719
  end