graph_weaver 0.2.2 → 0.4.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.
@@ -90,19 +90,19 @@ class GraphWeaver::Codegen
90
90
  # Attach app-owned helper modules to every struct generated from a
91
91
  # GraphQL type — the logic stays in your code, generation wires it in:
92
92
  #
93
- # GraphWeaver.register_type("Pet", PetHelpers)
93
+ # GraphWeaver.extend_type("Pet", PetHelpers)
94
94
  #
95
95
  # Or build the mixin inline — the block is module_eval'd into a fresh
96
96
  # module auto-named GraphWeaver::TypeHelpers::<Type>. Handy for quick
97
97
  # decoration; srb tc can't see into block-defined methods, so prefer
98
98
  # a named module where static checking matters:
99
99
  #
100
- # GraphWeaver.register_type("Pet") do
100
+ # GraphWeaver.extend_type("Pet") do
101
101
  # def display_name = "#{name} the pet"
102
102
  # end
103
103
  #
104
104
  # Additive: repeated registrations (and client-scoped ones) stack.
105
- def register_type(graphql_name, *mixins, requires: nil, &block)
105
+ def extend_type(graphql_name, *mixins, requires: nil, &block)
106
106
  entry = type_registry[graphql_name.to_s] ||= { mixins: [], requires: [] }
107
107
  add_type_helpers(entry, graphql_name, mixins, requires, block)
108
108
  end
@@ -111,7 +111,7 @@ class GraphWeaver::Codegen
111
111
  @type_registry ||= {}
112
112
  end
113
113
 
114
- # shared with Client#register_type: build/validate the mixins and
114
+ # shared with Client#extend_type: build/validate the mixins and
115
115
  # append them to a registry entry
116
116
  def add_type_helpers(entry, graphql_name, mixins, requires, block)
117
117
  mixins = mixins.dup
@@ -143,7 +143,7 @@ class GraphWeaver::Codegen
143
143
  end
144
144
 
145
145
  module GraphWeaver
146
- # Home of block-built type helpers (register_type with a block), which
146
+ # Home of block-built type helpers (extend_type with a block), which
147
147
  # need constant names so generated files can reference them.
148
148
  module TypeHelpers; end
149
149
  end
@@ -147,7 +147,7 @@ class GraphWeaver::Codegen
147
147
 
148
148
  attr_reader :class_name, :fields
149
149
  # the GraphQL type this struct was generated from, and any registered
150
- # helper modules to include (see Codegen.register_type)
150
+ # helper modules to include (see Codegen.extend_type)
151
151
  attr_accessor :graphql_type, :mixins
152
152
 
153
153
  def initialize(class_name)
@@ -277,6 +277,26 @@ class GraphWeaver::Codegen
277
277
  def nested = self
278
278
  end
279
279
 
280
+ # A reference to a union hoisted into the shared unions module (a named
281
+ # shared fragment spread as a whole union field): the query references
282
+ # <Name>::Type and dispatches through <Name>.from_h, where <Name> is the
283
+ # alias the query module gives GraphQLUnions::<Name>. The type family lives
284
+ # once in the shared module, so the same union across queries is one Ruby
285
+ # type — nested is nil, nothing is emitted here.
286
+ class UnionRefNode < Node
287
+ attr_reader :class_name
288
+
289
+ def initialize(class_name)
290
+ @class_name = class_name
291
+ end
292
+
293
+ def bare_type = "#{class_name}::Type"
294
+
295
+ def cast(expr, _depth)
296
+ "#{class_name}.from_h(#{expr})"
297
+ end
298
+ end
299
+
280
300
  # An input-object variable: emitted as a module-level T::Struct whose
281
301
  # serialize produces the wire hash. Inputs never cast FROM the wire.
282
302
  # 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,13 +260,98 @@ 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) &&
220
274
  DidYouMean::SpellChecker.new(dictionary: schema.types.keys).correct(name).first
221
275
  hint = suggestion ? " — did you mean '#{suggestion}'?" : ""
222
- raise GraphWeaver::Error, "register_#{kind}(#{name.inspect}) matches no type in this schema#{hint}"
276
+ # the type registry is reached via extend_type; scalars/enums via register_*
277
+ method = kind == "type" ? "extend_type" : "register_#{kind}"
278
+ raise GraphWeaver::Error, "#{method}(#{name.inspect}) matches no type in this schema#{hint}"
279
+ end
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
223
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
224
355
 
225
356
  # Structured shape for a schema-validation error: message plus its first
226
357
  # source location, so ValidationError#errors is inspectable.
@@ -249,6 +380,10 @@ class GraphWeaver::Codegen
249
380
  node.graphql_type = type.graphql_name
250
381
  node.mixins = type_mixins(type.graphql_name)
251
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 = {}
252
387
 
253
388
  gather(type, selections).each do |key, field_nodes|
254
389
  field_name = field_nodes.first.name
@@ -289,9 +424,20 @@ class GraphWeaver::Codegen
289
424
 
290
425
  name = pick_name(member.graphql_name, key, taken)
291
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 }
292
435
  else
293
- name = pick_name(core.graphql_name, key, taken)
294
- 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 }
295
441
  end
296
442
  when "ENUM"
297
443
  if (mapped = mapped_enum_node(core))
@@ -303,7 +449,8 @@ class GraphWeaver::Codegen
303
449
  type_ref(field_type) { EnumNode.new(name, core.values.keys.sort) }
304
450
  end
305
451
  when "SCALAR"
306
- 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) }
307
454
  else
308
455
  raise GraphWeaver::Error, "unsupported kind: #{core.kind.name}"
309
456
  end
@@ -339,6 +486,17 @@ class GraphWeaver::Codegen
339
486
  selections.grep(GraphQL::Language::Nodes::Field).map { |field| field.alias || field.name }
340
487
  end
341
488
 
489
+ # The fragment name when a selection is exactly one bare fragment spread
490
+ # (`{ ...F }`) — the shape a union field must have to hoist into the shared
491
+ # unions module. A spread carrying directives (@skip/@include), or mixed with
492
+ # other fields, stays a locally-emitted union.
493
+ def lone_shared_spread(selections)
494
+ return unless selections.size == 1
495
+
496
+ spread = selections.first
497
+ spread.name if spread.is_a?(GraphQL::Language::Nodes::FragmentSpread) && spread.directives.empty?
498
+ end
499
+
342
500
  # does the flattened selection (as seen by member) include at least one
343
501
  # field guaranteed to be present in a matching response?
344
502
  def unconditional_field?(member, selections)
@@ -365,19 +523,45 @@ class GraphWeaver::Codegen
365
523
  # concrete type: one member struct per possible type; wire dispatch
366
524
  # reads __typename, so the query must select it. For interfaces, the
367
525
  # interface's own field selections gather into every member.
368
- def union_node(type, selections, class_name)
526
+ # The union's member structs (graphql type name => ObjectNode), sorted for
527
+ # deterministic output. Dispatch reads __typename, so the query must select
528
+ # it; for interfaces the interface-level fields gather into every member.
529
+ def union_members(type, selections)
369
530
  unless gather(type, selections).key?("__typename")
370
531
  raise ArgumentError,
371
- "select __typename on #{type.graphql_name} so #{class_name} can dispatch — " \
532
+ "select __typename on #{type.graphql_name} so the union can dispatch — " \
372
533
  "or narrow to a single `... on Type` condition (no dispatch needed)"
373
534
  end
374
535
 
375
- # sorted so output is deterministic across schema sources
376
- members = @schema.possible_types(type).sort_by(&:graphql_name).to_h do |possible|
536
+ @schema.possible_types(type).sort_by(&:graphql_name).to_h do |possible|
377
537
  [possible.graphql_name, object_node(possible, selections, camelize(possible.graphql_name))]
378
538
  end
539
+ end
379
540
 
380
- UnionNode.new(class_name, members)
541
+ # A name-independent structural fingerprint of a union's members, so two
542
+ # occurrences that generate identical structs collapse to one Ruby type.
543
+ def union_signature(members)
544
+ members.map { |gname, member| "#{gname}=#{signature(member)}" }.sort.join(",")
545
+ end
546
+
547
+ # Structural signature of a node — ignores the generated class name (which
548
+ # varies per occurrence), keying on GraphQL type, selection shape, and
549
+ # nullability so only genuinely-identical shapes collapse.
550
+ def signature(node)
551
+ case node
552
+ when NonNull then "!#{signature(node.of)}"
553
+ when List then "[#{signature(node.of)}]"
554
+ when NarrowedNode then "?#{signature(node.nested)}"
555
+ when Scalar then "s:#{node.bare_type}"
556
+ when EnumNode then "e:#{node.values.sort.join("|")}"
557
+ when MappedEnum then "m:#{node.graphql_name}"
558
+ when ObjectNode
559
+ inner = node.fields.map { |f| "#{f.prop}=#{signature(f.node)}" }.sort.join(",")
560
+ "o:#{node.graphql_type}(#{inner})"
561
+ when UnionNode then "u:(#{union_signature(node.members)})"
562
+ when UnionRefNode then "ur:#{node.class_name}" # hoisted — identity is its shared name
563
+ else "x:#{node.object_id}" # unknown node kind — never collapse
564
+ end
381
565
  end
382
566
 
383
567
  # Build a node from an AST type reference (variable definitions), where
@@ -460,9 +644,13 @@ class GraphWeaver::Codegen
460
644
 
461
645
  # A Scalar node, recording any requires its registered type needs so the
462
646
  # generated file can require them (collected across the whole query).
463
- # Resolution: the client-scoped overlay first, then the global registry.
464
- def scalar_node(name)
465
- scalar = @scalars[name.to_s] || GraphWeaver::Codegen.scalar(name)
647
+ # Resolution, most specific first: a per-field override (`Type.field`), then
648
+ # the scalar-name registration — each checked client-scoped, then global.
649
+ def scalar_node(name, coordinate = nil)
650
+ scalar =
651
+ (coordinate && (@scalars[coordinate] || GraphWeaver::Codegen.scalar_registry[coordinate])) ||
652
+ @scalars[name.to_s] ||
653
+ GraphWeaver::Codegen.scalar(name)
466
654
  @requires.concat(scalar.requires)
467
655
  Scalar.new(scalar)
468
656
  end