graph_weaver 0.7.4 → 0.7.5

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.
data/docs/scalars.md CHANGED
@@ -428,7 +428,7 @@ It raises naming every scalar that disagreed and which way:
428
428
  ```
429
429
  2 scalar(s) disagree with Catalog::Schema:
430
430
  Money: the server refused "12.5", the wire form serialize: writes (expected "12.50 USD")
431
- Decimal: round-trips lossily — sent 0.123456789123456789e9, got back 0.1234567891234567e9
431
+ Decimal: round-trips lossily — sent 123456789.123456789, got back 123456789.1234567
432
432
  ```
433
433
 
434
434
  The fabricated value is all it has to work with, so pin the one that matters:
@@ -505,13 +505,48 @@ Two safety properties do the real work:
505
505
  - **`fallback:` for forward-compat**: `fallback: PetKind::Unknown` makes *casting*
506
506
  absorb wire values the server added after you generated, so responses keep
507
507
  flowing instead of raising. Inputs stay strict either way: a typo'd input is your
508
- bug, not drift. A union or interface absorbs the same drift with no registration
509
- a member added upstream lands in the catch-all `Other` its dispatch always
510
- carries ([generated modules](generated_modules.md#abstract-types)).
508
+ bug, not drift. You don't need an enum of your own for that:
509
+ `GraphWeaver.register_enum("Species", fallback: true)` gives the *generated* enum
510
+ the same forward-compat, [below](#values-the-server-hasnt-told-you-about-yet).
511
511
 
512
512
  The translation tables are emitted into the generated source (`SPECIES_FROM_WIRE` /
513
513
  `SPECIES_TO_WIRE`) — reviewable in the diff, no runtime registry.
514
514
 
515
+ ### Values the server hasn't told you about yet
516
+
517
+ A server adding an enum value is a deploy you weren't part of, and by default the
518
+ next response carrying it raises. `fallback: true` says take it anyway:
519
+
520
+ ```ruby
521
+ GraphWeaver.register_enum("Species", fallback: true)
522
+ ```
523
+
524
+ The generated `Species` gains one member, `Other`, and every wire value the schema
525
+ doesn't declare casts to it — the leniency a union or interface already has for
526
+ free, where a member added upstream lands in the catch-all `Other` its dispatch
527
+ always carries ([generated modules](generated_modules.md#abstract-types)).
528
+
529
+ ```ruby
530
+ pet.species # => GraphQLTypes::Species::Other, for "AXOLOTL"
531
+ ```
532
+
533
+ Three things follow from that, and they are the whole rule:
534
+
535
+ - **`Other` doesn't carry the value it absorbed.** A `T::Enum` member is a
536
+ singleton, so there is nowhere to put it; the cast writes one debug line
537
+ (`GraphQLTypes::Species absorbed "AXOLOTL" into Other`) and that is the record
538
+ — so a presenter humanising the server's spelling can't, and falls back to a
539
+ label of its own.
540
+ - **Inputs stay strict.** No wire value means `Other`, so a variable carrying it
541
+ is refused rather than sent. Everything else is unchanged: a typo'd input is
542
+ your bug, not drift.
543
+ - **A schema that already declares `OTHER` keeps it.** The declared value stays
544
+ `Other`, and the fallback member is `Other2` — the same rule a union's
545
+ catch-all struct follows when a member is already named `Other`.
546
+
547
+ It rides along with `alias:` on the one registration, and like every registration
548
+ it belongs to one graph.
549
+
515
550
  ### Two spellings, one value
516
551
 
517
552
  A schema mid-rename declares both `LEGACY_MODE` and `legacy_mode` so old clients
data/docs/testing.md CHANGED
@@ -423,7 +423,9 @@ time** — answering one means setting the client's context for the length of th
423
423
  dispatch, so the identity a request asked for is the identity it gets, whatever
424
424
  else is in flight. The lock is the client's own, so it holds however the endpoint
425
425
  is mounted. A `context:` hash is served **concurrently**: nothing writes it, so
426
- there is nothing to serialize.
426
+ there is nothing to serialize. Either way a query reads its context once, so
427
+ every subgraph it hops through runs as the identity it started with, even if
428
+ the router's context is reassigned while it is in flight.
427
429
 
428
430
  ### Making the served endpoint fail
429
431
 
data/docs/upgrading.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # Upgrading
2
2
 
3
3
  [Regenerate](#regenerate-on-every-upgrade) whichever version you're on, then read
4
- the one section that is yours: from [0.7.3](#upgrading-from-073), from
4
+ the one section that is yours: from [0.7.4](#upgrading-from-074), from
5
+ [0.7.3](#upgrading-from-073), from
5
6
  [0.7.1](#upgrading-from-071), from [0.7.0](#upgrading-from-070) or from
6
7
  [0.6.1](#upgrading-from-061). Coming from 0.6.0 or older, the path is that
7
8
  version's own upgrade notes — read them at the tag they shipped under
@@ -23,6 +24,25 @@ That's the reminder working, not a false alarm. Generation is deterministic, so
23
24
  the diff is exactly what the new version emits differently and nothing else —
24
25
  worth reading rather than rubber-stamping.
25
26
 
27
+ ## Upgrading from 0.7.4
28
+
29
+ Read the left column and skip what isn't yours; the
30
+ [changelog](../CHANGELOG.md) says why each one moved.
31
+
32
+ | applies if you… | what changed |
33
+ |---|---|
34
+ | spread a shared fragment as a whole field — `grep -rln '{ *\.\.\.[A-Za-z]* *}' app/graphql/queries` | on an object type it now [hoists](generated_modules.md#a-shared-fragment-is-one-type) into `GraphQLTypes` under the fragment's name, as it already did on a union. **App code naming the struct it used to produce (`PetQuery::Result::Pet`) moves to `GraphQLTypes::PetFields`** — one type instead of one per query, so a `T.type_alias { T.any(…) }` written to paper over that goes away. Regenerating and running `srb tc` finds every site |
35
+ | register an `abstract!` mixin — `grep -rn 'extend_type' app config lib`, then check which of those modules call `abstract!` | **generation refuses** where a query doesn't select everything the mixin declares, instead of leaving it for your `srb tc`. The message names the struct, the members and the fixes: select them in that query, or select the type through one shared fragment so a single hoisted struct answers for every query |
36
+ | keep a hand-maintained schema dump on a graph that also names a `client` — `rake graph_weaver:graphs` lists both per graph | `rake graph_weaver:schema:refresh` **rewrites that dump from the client** where it used to refuse and exit 1. If the file is the source of truth, don't run `refresh` for that graph, or drop the client from it |
37
+ | call `Codegen#used_union_names` | it is `used_fragment_names` — the set it reports now includes hoisted object fragments |
38
+
39
+ Then regenerate, and the gate:
40
+
41
+ ```sh
42
+ rake graph_weaver:generate
43
+ rake graph_weaver:verify
44
+ ```
45
+
26
46
  ## Upgrading from 0.7.3
27
47
 
28
48
  A patch release. One change can reach an app that never touched it — how a
@@ -119,6 +119,28 @@ module GraphWeaver
119
119
  end
120
120
  end
121
121
 
122
+ # The same thing the header and the Exclude above say, told to GitHub:
123
+ # linguist-generated collapses these files in a diff ("Load diff") and
124
+ # drops them from the language breakdown. Display only — they stay
125
+ # versioned, expandable, and `git diff` here is untouched.
126
+ #
127
+ # Written even when there is no .gitattributes, unlike .rubocop.yml: a
128
+ # .gitattributes turns no tooling on, so there is no app this surprises.
129
+ def mark_generated_for_github
130
+ body = File.read(gitattributes) if File.exist?(gitattributes)
131
+ # already marked — a re-run, or done by hand
132
+ globs = linguist_globs.reject { |glob| body&.include?(glob) }
133
+ return if globs.empty?
134
+
135
+ lines = globs.map { |glob| "#{glob} linguist-generated" }.join("\n")
136
+ marks = <<~TEXT + lines + "\n"
137
+ # Machine-written by `rake graph_weaver:generate` — GitHub collapses it in
138
+ # diffs and leaves it out of the language breakdown.
139
+ TEXT
140
+
141
+ body ? append_to_file(GITATTRIBUTES, "\n#{marks}") : create_file(GITATTRIBUTES, marks)
142
+ end
143
+
122
144
  # The `graphql:` tags need this require, and it has to be somewhere
123
145
  # rspec actually loads. A spec/support file is not: rspec-rails ships
124
146
  # the spec/support glob commented out, so the require sat there doing
@@ -191,6 +213,7 @@ module GraphWeaver
191
213
  private
192
214
 
193
215
  RUBOCOP_CONFIG = ".rubocop.yml"
216
+ GITATTRIBUTES = ".gitattributes"
194
217
 
195
218
  # rails_helper first: rspec-rails writes both, and only rails_helper
196
219
  # has Rails booted by the time the require runs.
@@ -203,6 +226,8 @@ module GraphWeaver
203
226
 
204
227
  def rubocop_config = File.join(GraphWeaver.root, RUBOCOP_CONFIG)
205
228
 
229
+ def gitattributes = File.join(GraphWeaver.root, GITATTRIBUTES)
230
+
206
231
  # Parsed, not counted: a `---` can also be a line inside a block scalar.
207
232
  # A file rubocop itself can't read is left to rubocop to complain about.
208
233
  def yaml_documents(body)
@@ -213,9 +238,13 @@ module GraphWeaver
213
238
 
214
239
  # Every graph's output directory, so a multi-schema app is covered by
215
240
  # the same run — read off the graphs rather than restated here.
216
- def generated_globs
217
- GraphWeaver.graphs.map { |graph| File.join(graph.output, "**/*") }.uniq
218
- end
241
+ def generated_outputs = GraphWeaver.graphs.map(&:output).uniq
242
+
243
+ def generated_globs = generated_outputs.map { |dir| File.join(dir, "**/*") }
244
+
245
+ # gitattributes patterns are gitignore-style, where `dir/**` is
246
+ # everything beneath dir — spelled as git reads it, not as rubocop does.
247
+ def linguist_globs = generated_outputs.map { |dir| File.join(dir, "**") }
219
248
 
220
249
  # This install run is the one moment the user is guaranteed to be
221
250
  # reading, and a composed supergraph changes what the next steps are:
@@ -119,6 +119,7 @@ class GraphWeaver::Codegen
119
119
  nilable = true
120
120
  else
121
121
  obj = object_of(cur)
122
+ refuse_hoisted!(node, name, hoisted_of(cur), seg)
122
123
  unless obj
123
124
  hint = if list_of(cur)
124
125
  " — use .first or .last to pick an element"
@@ -155,6 +156,19 @@ class GraphWeaver::Codegen
155
156
  ObjectNode::Alias.new(name, expr, type)
156
157
  end
157
158
 
159
+ # A hop landing on a hoisted shared fragment: the struct is another
160
+ # module's, so the path stops here. Which query hoists is a property of how
161
+ # that query spreads the fragment, so optional: still skips it.
162
+ def refuse_hoisted!(node, name, ref, seg)
163
+ return unless ref
164
+
165
+ raise GraphWeaver::Error,
166
+ "alias #{name.inspect} on #{node.graphql_type}: '#{seg}' is inside the shared fragment " \
167
+ "#{ref.class_name}, which hoists to #{@types_namespace}::#{ref.class_name} — a path can't " \
168
+ "read into it. Register the alias on #{ref.graphql_type}, or select a field beside the " \
169
+ "spread to keep the struct local"
170
+ end
171
+
158
172
  # Separate "this query didn't select it" from "no query could": a segment
159
173
  # the schema doesn't declare on the type is a mistake in the registration,
160
174
  # so it raises even for an optional alias — which otherwise turns a typo
@@ -185,7 +199,7 @@ class GraphWeaver::Codegen
185
199
  # The leaf's Sorbet type as referenced from the aliased struct. Generated
186
200
  # nested constants (structs, enums, unions) must carry the container path,
187
201
  # since the delegator's `sig` is emitted in an outer struct where a bare
188
- # `Sub` wouldn't resolve; scalars, mapped enums, and hoisted union refs are
202
+ # `Sub` wouldn't resolve; scalars, mapped enums, and hoisted refs are
189
203
  # already top-level. `containers` is the class-name chain to the leaf.
190
204
  def qualified_alias_type(node, containers)
191
205
  node = node.of if node.is_a?(NonNull)
@@ -203,10 +217,17 @@ class GraphWeaver::Codegen
203
217
  # there from the shared enums module — either way, no container prefix
204
218
  when EnumNode then node.class_name
205
219
  when UnionNode then "#{prefix}#{node.bare_type}"
206
- else node.bare_type # Scalar, MappedEnum, UnionRefNode — already top-level
220
+ else node.bare_type # Scalar, MappedEnum, a hoisted ref — already top-level
207
221
  end
208
222
  end
209
223
 
224
+ # the hoisted-fragment reference a node resolves to (through NON_NULL), or nil
225
+ def hoisted_of(node)
226
+ node = T.let(node, T.untyped)
227
+ node = node.of while node.is_a?(NonNull)
228
+ node if node.is_a?(HoistedRefNode)
229
+ end
230
+
210
231
  # the List a node wraps (through NON_NULL), or nil
211
232
  def list_of(node)
212
233
  node = T.let(node, T.untyped)
@@ -130,8 +130,8 @@ class GraphWeaver::Codegen
130
130
  # The shared types artifact as files: one file per type under types/, plus
131
131
  # types.rb — the manifest that requires them in the order the runtime needs
132
132
  # (see below). One rule for all three kinds, so a schema migration diffs
133
- # exactly the types it touched whether they're inputs, enums or unions.
134
- def emit_types_files(unions)
133
+ # exactly the types it touched whether they're inputs, enums or fragments.
134
+ def emit_types_files(fragments)
135
135
  files = {}
136
136
  # a mapped enum's constants are its wire tables, but the file is still
137
137
  # named for the GraphQL enum — one type, one file, whichever it is
@@ -142,15 +142,15 @@ class GraphWeaver::Codegen
142
142
  }
143
143
  inputs, = ordered_inputs
144
144
  structs = inputs.map { |input| type_file(files, input.class_name) { |out| emit_input(input, out, 1) } }
145
- hoisted = unions.map { |union| type_file(files, union.class_name) { |out| emit_union(union, out, 1) } }
145
+ hoisted = fragments.map { |node| type_file(files, node.class_name) { |out| emit_nested(node, out, 1) } }
146
146
 
147
147
  out = []
148
148
  out << "# typed: strict"
149
149
  out << "# frozen_string_literal: true"
150
150
  out << ""
151
151
  out << "# Generated by GraphWeaver #{GraphWeaver::VERSION} — do not edit. Shared types for this schema —"
152
- out << "# input types, enums, and unions hoisted from shared fragments — one file"
153
- out << "# per type; query modules alias what they use."
152
+ out << "# input types, enums, and the types hoisted out of shared fragments — one"
153
+ out << "# file per type; query modules alias what they use."
154
154
  out << ""
155
155
  requires = @requires.uniq.sort
156
156
  if requires.any?
@@ -175,7 +175,7 @@ class GraphWeaver::Codegen
175
175
  # both spell them bare, and a T::Enum can't be forward-declared the way
176
176
  # an input struct can
177
177
  if enums.any? && (structs.any? || hoisted.any?)
178
- out << "# enums first — input structs and union members spell them bare"
178
+ out << "# enums first — input structs and hoisted fragments spell them bare"
179
179
  end
180
180
  (enums.sort + structs.sort + hoisted.sort).each do |file|
181
181
  out << "require_relative #{file.delete_suffix(".rb").inspect}"
@@ -210,11 +210,11 @@ class GraphWeaver::Codegen
210
210
  # assembled from the generator's walked state.
211
211
  def emit_module(root, variables, representations = [], operation_name = nil)
212
212
  # Every shared type this module names: its variable root inputs, the
213
- # enums it reached, and the unions it hoisted (aliased so <Name>::Type
214
- # and <Name>.from_h resolve to the shared module).
213
+ # enums it reached, and the fragments it hoisted (aliased so <Name> and
214
+ # <Name>.from_h resolve to the shared module).
215
215
  aliases = if @types_namespace
216
216
  (shared_input_names(variables) + shared_enum_names +
217
- @used_unions.map { |name| camelize(name) }).uniq.sort
217
+ @used_fragments.map { |name| camelize(name) }).uniq.sort
218
218
  else
219
219
  []
220
220
  end
@@ -338,6 +338,10 @@ class GraphWeaver::Codegen
338
338
  node.values.each do |value|
339
339
  out << "#{pad} #{camelize(value.downcase)} = new(#{value.inspect})"
340
340
  end
341
+ if node.fallback?
342
+ out << "#{pad} # every other wire value casts here (register_enum fallback: true)"
343
+ out << "#{pad} #{node.fallback} = new(#{GraphWeaver::Internal::ENUM_FALLBACK_WIRE.inspect})"
344
+ end
341
345
  out << "#{pad} end"
342
346
  out << "#{pad}end"
343
347
  return unless node.aliased?
@@ -22,6 +22,10 @@ class GraphWeaver::Codegen
22
22
  # value — both spellings cast, the target is what serializes. It is the
23
23
  # whole registration when there is no T::Enum to map onto, and then the
24
24
  # generated enum gets one constant for the target and none for the alias.
25
+ #
26
+ # fallback: true is the same type-less form asking for leniency instead:
27
+ # the generated enum gains an Other member and absorbs undeclared wire
28
+ # values into it (see Codegen#enum_values).
25
29
  class EnumType
26
30
  attr_reader :graphql_name, :type, :fallback, :requires
27
31
 
@@ -55,6 +59,10 @@ class GraphWeaver::Codegen
55
59
  end
56
60
  end
57
61
 
62
+ # register_enum("Species", fallback: true): the generated enum gains an
63
+ # Other member and casts every undeclared wire value to it.
64
+ def generated_fallback? = type.nil? && fallback == true
65
+
58
66
  # The wire tables for a mapped enum: [wire value => member, member => the
59
67
  # wire value that goes out]. Every spelling casts; an alias's target is the
60
68
  # one that serializes.
@@ -115,19 +123,27 @@ class GraphWeaver::Codegen
115
123
 
116
124
  private
117
125
 
118
- # Without a T::Enum there is nothing for map:/fallback:/requires: to
119
- # describe, so alias: is the whole registration.
126
+ # Without a T::Enum there is nothing for map:/requires: to describe, so
127
+ # alias: and fallback: true are the whole registration.
120
128
  def alias_only!(map, fallback, requires)
121
- if @aliases.empty?
129
+ if fallback && fallback != true
130
+ raise ArgumentError, "register_enum(#{graphql_name.inspect}, fallback: #{fallback.inspect}): the " \
131
+ "generated enum generates its fallback member too, so say fallback: true. To fall back onto a " \
132
+ "member of your own, pass the T::Enum: " \
133
+ "register_enum(#{graphql_name.inspect}, YourEnum, fallback: YourEnum::Unknown)"
134
+ end
135
+
136
+ if @aliases.empty? && !fallback
122
137
  raise ArgumentError, "register_enum(#{graphql_name.inspect}) says nothing about #{graphql_name} — " \
123
- "pass the T::Enum to map it onto, or alias: { \"old\" => \"NEW\" } to read two wire values as one"
138
+ "pass the T::Enum to map it onto, alias: { \"old\" => \"NEW\" } to read two wire values as one, " \
139
+ "or fallback: true to absorb values the server adds"
124
140
  end
125
141
 
126
- extra = { map:, fallback:, requires: }.compact.keys.first
142
+ extra = { map:, requires: }.compact.keys.first
127
143
  if extra
128
144
  raise ArgumentError,
129
- "register_enum(#{graphql_name.inspect}, alias: {...}) takes no #{extra}: — that describes a T::Enum " \
130
- "of your own, so pass one: register_enum(#{graphql_name.inspect}, YourEnum, alias: {...})"
145
+ "register_enum(#{graphql_name.inspect}) takes no #{extra}: — that describes a T::Enum of your own, " \
146
+ "so pass one: register_enum(#{graphql_name.inspect}, YourEnum, #{extra}: {...})"
131
147
  end
132
148
 
133
149
  @requires = []
@@ -189,8 +205,9 @@ class GraphWeaver::Codegen
189
205
 
190
206
  # The enum half of one graph's registrations — see Codegen::Registry.
191
207
  class Registry
192
- # Map a GraphQL enum onto an app-owned T::Enum, or fold two of its wire
193
- # spellings into one value (see EnumType). The one implementation
208
+ # Map a GraphQL enum onto an app-owned T::Enum, fold two of its wire
209
+ # spellings into one value, or absorb the ones the server hasn't told you
210
+ # about yet (see EnumType). The one implementation —
194
211
  # GraphWeaver.register_enum is a delegate, so the same call reaches it
195
212
  # whichever door you came in by.
196
213
  #
@@ -113,11 +113,11 @@ class GraphWeaver::Codegen
113
113
 
114
114
  def cast(expr, depth)
115
115
  var = "v#{depth}"
116
- element = if @of.non_null? || @of.identity?
117
- @of.identity? ? var : @of.cast(var, depth + 1)
118
- else
119
- "#{var}&.then { |v#{depth + 1}| #{@of.cast("v#{depth + 1}", depth + 2)} }"
120
- end
116
+ element =
117
+ if @of.identity? then var
118
+ elsif @of.non_null? then @of.cast(var, depth + 1)
119
+ else "#{var}&.then { |v#{depth + 1}| #{@of.cast("v#{depth + 1}", depth + 2)} }"
120
+ end
121
121
 
122
122
  "#{expr}.map { |#{var}| #{element} }"
123
123
  end
@@ -127,11 +127,11 @@ class GraphWeaver::Codegen
127
127
 
128
128
  def serialize(expr, depth)
129
129
  var = "v#{depth}"
130
- element = if @of.non_null? || @of.serialize_identity?
131
- @of.serialize_identity? ? var : @of.serialize(var, depth + 1)
132
- else
133
- "#{var}&.then { |v#{depth + 1}| #{@of.serialize("v#{depth + 1}", depth + 2)} }"
134
- end
130
+ element =
131
+ if @of.serialize_identity? then var
132
+ elsif @of.non_null? then @of.serialize(var, depth + 1)
133
+ else "#{var}&.then { |v#{depth + 1}| #{@of.serialize("v#{depth + 1}", depth + 2)} }"
134
+ end
135
135
 
136
136
  "#{expr}.map { |#{var}| #{element} }"
137
137
  end
@@ -153,11 +153,11 @@ class GraphWeaver::Codegen
153
153
 
154
154
  def hash_coerce(expr, depth)
155
155
  var = "v#{depth}"
156
- inner = if @of.non_null? || @of.hash_coerce_identity?
157
- @of.hash_coerce_identity? ? var : @of.hash_coerce(var, depth + 1)
158
- else
159
- "#{var}&.then { |v#{depth + 1}| #{@of.hash_coerce("v#{depth + 1}", depth + 2)} }"
160
- end
156
+ inner =
157
+ if @of.hash_coerce_identity? then var
158
+ elsif @of.non_null? then @of.hash_coerce(var, depth + 1)
159
+ else "#{var}&.then { |v#{depth + 1}| #{@of.hash_coerce("v#{depth + 1}", depth + 2)} }"
160
+ end
161
161
  return "#{expr}.map { |#{var}| #{inner} }" if hash_coerce_identity?
162
162
 
163
163
  # the index is a path segment — `where._and.0._not.species` needs the 0
@@ -207,10 +207,13 @@ class GraphWeaver::Codegen
207
207
  class EnumNode < Node
208
208
  attr_reader :class_name, :values, :aliases
209
209
 
210
- def initialize(class_name, values, aliases = {})
210
+ # fallback: the name of the member unknown wire values cast to (register_enum
211
+ # fallback: true), or nil for a strict enum
212
+ def initialize(class_name, values, aliases = {}, fallback: nil)
211
213
  @class_name = class_name
212
214
  @values = values
213
215
  @aliases = aliases
216
+ @fallback = fallback
214
217
  end
215
218
 
216
219
  def bare_type = class_name
@@ -220,10 +223,19 @@ class GraphWeaver::Codegen
220
223
  def aliased? = !@aliases.empty?
221
224
  def alias_const = "#{GraphWeaver::Inflect.underscore(class_name).upcase}_ALIASES"
222
225
 
226
+ # register_enum fallback: true — the extra member every value the schema
227
+ # doesn't declare casts to, and the one member a variable can't send
228
+ attr_reader :fallback
229
+ def fallback? = !@fallback.nil?
230
+ def fallback_const = "#{class_name}::#{@fallback}"
231
+
223
232
  def cast(expr, _depth)
224
- "GraphWeaver::Hints.enum(#{class_name}, #{expr}#{", #{alias_const}" if aliased?})"
233
+ "GraphWeaver::Hints.enum(#{class_name}, #{expr}#{runtime_args})"
225
234
  end
226
235
 
236
+ # Other serializes to ENUM_FALLBACK_WIRE, which casts back to Other — so a
237
+ # result carrying it still round-trips through #as_json, even though no
238
+ # server would accept that spelling.
227
239
  def serialize(expr, _depth)
228
240
  "#{expr}.serialize"
229
241
  end
@@ -235,7 +247,11 @@ class GraphWeaver::Codegen
235
247
  def coerce? = true
236
248
 
237
249
  def coerce(expr)
238
- "GraphWeaver::InputStruct.enum(#{class_name}, #{expr}#{", #{alias_const}" if aliased?})"
250
+ "GraphWeaver::InputStruct.enum(#{class_name}, #{expr}#{runtime_args})"
251
+ end
252
+
253
+ def runtime_args
254
+ "#{", #{alias_const}" if aliased?}#{", fallback: #{fallback_const}" if fallback?}"
239
255
  end
240
256
 
241
257
  def input_type = "T.any(#{class_name}, String)"
@@ -358,20 +374,22 @@ class GraphWeaver::Codegen
358
374
  def nested = self
359
375
  end
360
376
 
361
- # A reference to a union hoisted into the shared types module (a named
362
- # shared fragment spread as a whole union field): the query references
363
- # <Name>::Type and dispatches through <Name>.from_h, where <Name> is the
364
- # alias the query module gives GraphQLTypes::<Name>. The type family lives
365
- # once in the shared module, so the same union across queries is one Ruby
366
- # type nested is nil, nothing is emitted here.
367
- class UnionRefNode < Node
368
- attr_reader :class_name
369
-
370
- def initialize(class_name)
377
+ # A reference to a type hoisted into the shared types module (a named shared
378
+ # fragment spread as a whole field): the query casts through <Name>.from_h,
379
+ # where <Name> is the alias the query module gives GraphQLTypes::<Name>. The
380
+ # type lives once in the shared module, so the same fragment across queries
381
+ # is one Ruby type nested is nil, nothing is emitted here.
382
+ class HoistedRefNode < Node
383
+ # graphql_type is carried for refusals alone — an alias path that tries to
384
+ # read into the hoisted struct says which type to register itself on
385
+ attr_reader :class_name, :graphql_type
386
+
387
+ def initialize(class_name, graphql_type = nil)
371
388
  @class_name = class_name
389
+ @graphql_type = graphql_type
372
390
  end
373
391
 
374
- def bare_type = "#{class_name}::Type"
392
+ def bare_type = class_name
375
393
 
376
394
  def cast(expr, _depth)
377
395
  "#{class_name}.from_h(#{expr})"
@@ -382,6 +400,12 @@ class GraphWeaver::Codegen
382
400
  end
383
401
  end
384
402
 
403
+ # The same, on an abstract type: the hoisted name is a dispatch module, so
404
+ # the Ruby type is its member union rather than the module itself.
405
+ class UnionRefNode < HoistedRefNode
406
+ def bare_type = "#{class_name}::Type"
407
+ end
408
+
385
409
  # An input-object variable: emitted as a module-level T::Struct whose
386
410
  # serialize produces the wire hash. Inputs never cast FROM the wire.
387
411
  # Joins the coerce protocol so execute kwargs accept plain hashes,
@@ -454,6 +478,6 @@ class GraphWeaver::Codegen
454
478
  # The IR is codegen's own vocabulary — every node type is reachable only
455
479
  # from inside the walk.
456
480
  private_constant :Node, :Scalar, :NonNull, :List, :ObjectNode, :EnumNode,
457
- :MappedEnum, :NarrowedNode, :UnionNode, :UnionRefNode, :InputNode,
458
- :RepresentationNode
481
+ :MappedEnum, :NarrowedNode, :UnionNode, :HoistedRefNode, :UnionRefNode,
482
+ :InputNode, :RepresentationNode
459
483
  end