graph_weaver 0.7.5 → 0.7.6

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/Gemfile.lock +2 -2
  3. data/docs/errors.md +7 -3
  4. data/docs/generated_modules.md +10 -6
  5. data/docs/getting_started.md +34 -17
  6. data/docs/logging.md +79 -35
  7. data/docs/migrating.md +11 -4
  8. data/docs/scalars.md +12 -3
  9. data/docs/testing.md +75 -13
  10. data/docs/upgrading.md +25 -3
  11. data/examples/README.md +4 -2
  12. data/examples/github/generate.rb +22 -8
  13. data/examples/github/generated/star_mutation.rb +2 -2
  14. data/examples/github/generated/stargazers_query.rb +2 -2
  15. data/examples/github/generated/starred_query.rb +2 -2
  16. data/examples/github/run.rb +1 -0
  17. data/examples/github/setup.rb +16 -8
  18. data/graph_weaver.gemspec +15 -6
  19. data/lib/generators/graph_weaver/install_generator.rb +20 -2
  20. data/lib/graph_weaver/client.rb +0 -23
  21. data/lib/graph_weaver/codegen/aliases.rb +15 -3
  22. data/lib/graph_weaver/codegen/emit.rb +7 -6
  23. data/lib/graph_weaver/codegen/enum_type.rb +26 -2
  24. data/lib/graph_weaver/codegen/nodes.rb +21 -2
  25. data/lib/graph_weaver/codegen.rb +106 -54
  26. data/lib/graph_weaver/graph.rb +55 -5
  27. data/lib/graph_weaver/in_process.rb +1 -1
  28. data/lib/graph_weaver/input_struct.rb +29 -4
  29. data/lib/graph_weaver/internal/overrides.rb +126 -14
  30. data/lib/graph_weaver/internal/test_clients.rb +29 -7
  31. data/lib/graph_weaver/internal/unused.rb +30 -11
  32. data/lib/graph_weaver/internal/values.rb +17 -5
  33. data/lib/graph_weaver/internal.rb +6 -6
  34. data/lib/graph_weaver/log_subscriber.rb +27 -17
  35. data/lib/graph_weaver/logging.rb +89 -53
  36. data/lib/graph_weaver/parsing.rb +32 -3
  37. data/lib/graph_weaver/query_module.rb +48 -8
  38. data/lib/graph_weaver/rspec.rb +41 -17
  39. data/lib/graph_weaver/schema_diff.rb +24 -5
  40. data/lib/graph_weaver/schema_loader.rb +22 -9
  41. data/lib/graph_weaver/tasks.rb +71 -44
  42. data/lib/graph_weaver/testing/fake_client.rb +24 -21
  43. data/lib/graph_weaver/testing.rb +27 -7
  44. data/lib/graph_weaver/transport.rb +1 -1
  45. data/lib/graph_weaver/version.rb +1 -1
  46. data/lib/graph_weaver.rb +59 -35
  47. metadata +2 -2
data/docs/upgrading.md CHANGED
@@ -1,8 +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.4](#upgrading-from-074), from
5
- [0.7.3](#upgrading-from-073), from
4
+ the one section that is yours: from [0.7.5](#upgrading-from-075), from
5
+ [0.7.4](#upgrading-from-074), from [0.7.3](#upgrading-from-073), from
6
6
  [0.7.1](#upgrading-from-071), from [0.7.0](#upgrading-from-070) or from
7
7
  [0.6.1](#upgrading-from-061). Coming from 0.6.0 or older, the path is that
8
8
  version's own upgrade notes — read them at the tag they shipped under
@@ -24,6 +24,28 @@ That's the reminder working, not a false alarm. Generation is deterministic, so
24
24
  the diff is exactly what the new version emits differently and nothing else —
25
25
  worth reading rather than rubber-stamping.
26
26
 
27
+ ## Upgrading from 0.7.5
28
+
29
+ A patch release. The regenerate is what changes behaviour — every module gains
30
+ the per-call event — and the rest reaches an app only where it already relied
31
+ on the old wording. Read the left column and skip what isn't yours; the
32
+ [changelog](../CHANGELOG.md) says why each one moved.
33
+
34
+ | applies if you… | what changed |
35
+ |---|---|
36
+ | subscribe to `execute.graph_weaver`, or read `GraphWeaver::LogSubscriber`'s line | a second event, [`operation.graph_weaver`](logging.md#instrumentation), covers one `execute` start to typed result — retries, backoff and the cast — and the request events nest inside it. **The info log line is the operation's now**: `failed GraphWeaver::CastError` where it read `ok`, present under a `graphql:` tag, one line per retried call; the per-attempt line writes at debug. A module generated by 0.7.5 emits neither until regenerated |
37
+ | spread a shared fragment on an interface or union that selects no `... on`, or exactly one — `grep -rn 'fragment .* on' app/graphql/fragments` | it [hoists](generated_modules.md#abstract-types) into `GraphQLTypes::<Fragment>` like every other shared fragment; app code naming `YourQuery::Result::Field` moves, and `srb tc` finds each site |
38
+ | run `rake graph_weaver:queries:check` in CI for a graph that names its own `schema` — `rake graph_weaver:graphs` lists them | it re-introspects that graph's server (the dump's url, or the client's url or schema class) where it used to check the committed file, so CI needs the same reach a single-schema app already had. The verdict names what it checked against either way |
39
+ | keep a composed supergraph on a graph that also names a `client` | `schema:refresh` steps over it with the recompose line where 0.7.5 could have overwritten it with the API schema |
40
+ | parse `rake graph_weaver:unused` output, or run it `STRICT=1` | a `before_action`-loaded `@result` is credited again, and the coordinate printed comes from the query, not from any word in the file |
41
+
42
+ Then regenerate, and the gate:
43
+
44
+ ```sh
45
+ rake graph_weaver:generate
46
+ rake graph_weaver:verify
47
+ ```
48
+
27
49
  ## Upgrading from 0.7.4
28
50
 
29
51
  Read the left column and skip what isn't yours; the
@@ -227,7 +249,7 @@ only one here that shows up in production rather than in your code.
227
249
  Nothing raises: a subscriber comparing it to an Integer just stops matching. **A
228
250
  subscriber that branched on a 4xx/5xx reads `:http_status` now**, which is nil
229
251
  in-process. The whole payload is a documented contract —
230
- [logging](logging.md#the-payload).
252
+ [logging](logging.md#the-request-payload).
231
253
  - **`respond_to?` on a result struct no longer answers true for a name that doesn't
232
254
  exist.** It used to say true for any near miss, which broke the standard
233
255
  duck-typing guard. **A branch that read the old answer now takes the other path**,
data/examples/README.md CHANGED
@@ -34,5 +34,7 @@ looks like an app:
34
34
  fellow stargazers.
35
35
 
36
36
  Regeneration introspects GitHub's schema (a few seconds, cached to a gitignored
37
- `github/schema.json`); `run.rb` alone never introspects, because the generated
38
- modules already carry their types.
37
+ `github/schema.json`); once that dump is there the repo's own `bin/generate`
38
+ refreshes these modules along with every other generated file, offline. `run.rb`
39
+ alone never introspects, because the generated modules already carry their
40
+ types.
@@ -6,15 +6,29 @@
6
6
  # the same workflow `rake graph_weaver:generate` runs in an app:
7
7
  #
8
8
  # examples/github/generate.rb
9
+ #
10
+ # bin/generate calls generate! too, off the cached dump, so these files are
11
+ # regenerated by the build like every other checked-in generated file.
9
12
  require_relative "setup"
10
13
 
11
- GraphWeaver.graph :github do
12
- schema GraphWeaver.client.schema
13
- queries File.join(__dir__, "queries")
14
- output File.join(__dir__, "generated")
14
+ module GithubExample
15
+ # The graph, declared once for both doors: this script, which introspects
16
+ # GitHub, and bin/generate, which passes the dump that introspection cached.
17
+ def self.generate!(schema_source)
18
+ GraphWeaver.graph :github do
19
+ schema schema_source
20
+ queries File.join(__dir__, "queries")
21
+ output File.join(__dir__, "generated")
22
+ end
23
+
24
+ GraphWeaver.generate!
25
+ GraphWeaver.changed_files
26
+ end
15
27
  end
16
28
 
17
- GraphWeaver.generate!
18
- changed = GraphWeaver.changed_files
19
- changed.each { |path| puts "wrote #{path}" }
20
- puts "already up to date" if changed.empty?
29
+ if $PROGRAM_NAME == __FILE__
30
+ GithubExample.connect!
31
+ changed = GithubExample.generate!(GraphWeaver.client.schema)
32
+ changed.each { |path| puts "wrote #{path}" }
33
+ puts "already up to date" if changed.empty?
34
+ end
@@ -1,7 +1,7 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
- # Generated by GraphWeaver 0.7.4 — do not edit.
4
+ # Generated by GraphWeaver 0.7.6 — do not edit.
5
5
 
6
6
  module StarMutation
7
7
  extend T::Sig
@@ -117,7 +117,7 @@ module StarMutation
117
117
  "id" => GraphWeaver::Coerce.variable("id", OPERATION_NAME, id) { |v| GraphWeaver::Coerce.id(v, "ID") },
118
118
  }
119
119
 
120
- from_response(dispatch(variables, client:))
120
+ dispatch(variables, client:) { |raw| from_response(raw) }
121
121
  end
122
122
 
123
123
  sig { params(id: String, client: T.untyped).returns(Result).checked(:never) }
@@ -1,7 +1,7 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
- # Generated by GraphWeaver 0.7.4 — do not edit.
4
+ # Generated by GraphWeaver 0.7.6 — do not edit.
5
5
 
6
6
  require "time"
7
7
 
@@ -257,7 +257,7 @@ module StargazersQuery
257
257
  "first" => GraphWeaver::Coerce.variable("first", OPERATION_NAME, first) { |v| GraphWeaver::Coerce.integer(v, "Int") },
258
258
  }
259
259
 
260
- from_response(dispatch(variables, client:))
260
+ dispatch(variables, client:) { |raw| from_response(raw) }
261
261
  end
262
262
 
263
263
  sig { params(owner: String, name: String, first: Integer, client: T.untyped).returns(Result).checked(:never) }
@@ -1,7 +1,7 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
- # Generated by GraphWeaver 0.7.4 — do not edit.
4
+ # Generated by GraphWeaver 0.7.6 — do not edit.
5
5
 
6
6
  module StarredQuery
7
7
  extend T::Sig
@@ -150,7 +150,7 @@ module StarredQuery
150
150
  "first" => GraphWeaver::Coerce.variable("first", OPERATION_NAME, first) { |v| GraphWeaver::Coerce.integer(v, "Int") },
151
151
  }
152
152
 
153
- from_response(dispatch(variables, client:))
153
+ dispatch(variables, client:) { |raw| from_response(raw) }
154
154
  end
155
155
 
156
156
  sig { params(login: String, first: Integer, client: T.untyped).returns(Result).checked(:never) }
@@ -7,6 +7,7 @@
7
7
  #
8
8
  # examples/github/run.rb
9
9
  require_relative "setup"
10
+ GithubExample.connect!
10
11
 
11
12
  # the checked-in typed modules (regenerate: examples/github/generate.rb)
12
13
  Dir[File.join(__dir__, "generated", "*.rb")].sort.each { |file| require file }
@@ -4,15 +4,23 @@
4
4
  # Shared wiring for the GitHub example: auth and the client.
5
5
  require_relative "../../lib/graph_weaver"
6
6
 
7
- token = ENV["GITHUB_TOKEN"] || `gh auth token 2>/dev/null`.strip
8
- abort "need a token: `gh auth login`, or GITHUB_TOKEN=..." if token.empty?
9
-
10
- GraphWeaver.client = GraphWeaver.new(
11
- "https://api.github.com/graphql",
12
- auth: token,
7
+ module GithubExample
13
8
  # used by generate.rb (and any dynamic parse): the first introspection
14
9
  # of GitHub's large schema dumps here — gitignored, a few seconds once,
15
10
  # instant after. run.rb's checked-in generated modules never introspect,
16
11
  # so running it alone won't create this file.
17
- cache: File.join(__dir__, "schema.json"),
18
- )
12
+ SCHEMA_CACHE = File.join(__dir__, "schema.json")
13
+
14
+ # Requiring this file reaches nothing, so bin/generate can regenerate from
15
+ # SCHEMA_CACHE with no token; needing GitHub is this call.
16
+ def self.connect!
17
+ token = ENV["GITHUB_TOKEN"] || `gh auth token 2>/dev/null`.strip
18
+ abort "need a token: `gh auth login`, or GITHUB_TOKEN=..." if token.empty?
19
+
20
+ GraphWeaver.client = GraphWeaver.new(
21
+ "https://api.github.com/graphql",
22
+ auth: token,
23
+ cache: SCHEMA_CACHE,
24
+ )
25
+ end
26
+ end
data/graph_weaver.gemspec CHANGED
@@ -5,8 +5,6 @@ Gem::Specification.new do |s|
5
5
  # the README tagline, verbatim — the two pitches drifted apart once already,
6
6
  # so spec/gemspec_spec.rb pins them together
7
7
  s.description = "Your .graphql files, compiled into Sorbet types — and the fakes to test them."
8
- # ".yardopts" explicitly: `git ls-files *` skips dotfiles, and
9
- # rubydoc.info needs it shipped to render docstrings as markdown
10
8
  # CLAUDE.md/PLAN.md/REVIEW.md/NOTES.md/DECISIONS.md are written for whoever
11
9
  # works on the gem, not whoever installs it — and REVIEW.md carries examples
12
10
  # from before the API it describes was rewritten
@@ -14,10 +12,21 @@ Gem::Specification.new do |s|
14
12
  # someone who only has the installed gem, not a checkout
15
13
  # CHANGELOG.md doesn't (259 KB, ~16% of the package) — changelog_uri below
16
14
  # points at the GitHub copy instead
17
- s.files = `git ls-files * ':!:spec' ':!:sorbet' ':!:bin' \
18
- ':!:CLAUDE.md' ':!:PLAN.md' ':!:REVIEW.md' ':!:NOTES.md' \
19
- ':!:DECISIONS.md' ':!:CHANGELOG.md' ':!:Makefile' ':!:design' \
20
- ':!:research'`.split("\n") + [".yardopts"]
15
+ unpackaged = %w[spec sorbet bin design research CLAUDE.md PLAN.md REVIEW.md
16
+ NOTES.md DECISIONS.md CHANGELOG.md Makefile]
17
+ # git knows what is tracked; a checkout that isn't a repo — an unpacked gem,
18
+ # a vendored copy, a shallow CI export — has no such answer, and git's
19
+ # "fatal:" on stderr lands in the output of every subprocess a spec captures.
20
+ # So ask quietly, and walk the tree rather than ship nothing.
21
+ files = `git ls-files * #{unpackaged.map { |path| "':!:#{path}'" }.join(" ")} 2>/dev/null`.split("\n")
22
+ if files.empty?
23
+ files = Dir.glob("**/*", base: __dir__).reject do |path|
24
+ unpackaged.include?(path.split("/").first) || File.directory?(File.join(__dir__, path))
25
+ end
26
+ end
27
+ # ".yardopts" explicitly: neither list carries a dotfile, and rubydoc.info
28
+ # needs it shipped to render docstrings as markdown
29
+ s.files = files + [".yardopts"]
21
30
  s.homepage = "https://github.com/dpep/graph_weaver"
22
31
  s.license = "MIT"
23
32
  s.name = "graph_weaver"
@@ -128,8 +128,7 @@ module GraphWeaver
128
128
  # .gitattributes turns no tooling on, so there is no app this surprises.
129
129
  def mark_generated_for_github
130
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) }
131
+ globs = linguist_globs.reject { |glob| marked?(body, glob) }
133
132
  return if globs.empty?
134
133
 
135
134
  lines = globs.map { |glob| "#{glob} linguist-generated" }.join("\n")
@@ -246,6 +245,25 @@ module GraphWeaver
246
245
  # everything beneath dir — spelled as git reads it, not as rubocop does.
247
246
  def linguist_globs = generated_outputs.map { |dir| File.join(dir, "**") }
248
247
 
248
+ # git's own spellings of the one attribute: set, unset (`-`), unspecified
249
+ # (`!`), or valued.
250
+ LINGUIST = /\A[-!]?linguist-generated(?:=|\z)/
251
+
252
+ # Already marked — a re-run, or done by hand. A .gitattributes line is
253
+ # `<pattern> <attribute>…`, with `#` opening a comment, and the same
254
+ # directory is `dir/**`, `dir/*`, `dir/` or `dir`. Searching the body as
255
+ # one string instead let a comment that mentions the path suppress the
256
+ # mark, and saw only the spelling this generator writes.
257
+ def marked?(body, glob)
258
+ dir = glob.delete_suffix("/**")
259
+ body.to_s.lines.any? do |line|
260
+ pattern, *attributes = line.split
261
+ next false if pattern.nil? || pattern.start_with?("#")
262
+
263
+ pattern.chomp("**").chomp("*").chomp("/") == dir && attributes.any? { |a| LINGUIST.match?(a) }
264
+ end
265
+ end
266
+
249
267
  # This install run is the one moment the user is guaranteed to be
250
268
  # reading, and a composed supergraph changes what the next steps are:
251
269
  # the test client is the interesting one, and there's a CI gate to add.
@@ -190,29 +190,6 @@ class GraphWeaver::Client
190
190
  run(query, **variables).data!
191
191
  end
192
192
 
193
- # Does this query validate? The string form of GraphWeaver.check_queries,
194
- # answering with the same JSON-ready hashes — `message`, `line`, `column`,
195
- # plus `subgraphs` where a supergraph brands them — so an empty array means
196
- # it validates:
197
- #
198
- # client.check_query("query { viewer { login } }") # => []
199
- # client.check_query("query { viewer { lgoin } }")
200
- # # => [{ "message" => "Field 'lgoin' doesn't exist on type 'User'",
201
- # # "line" => 1, "column" => 17 }]
202
- #
203
- # Checked against this client's own schema — what `execute` would run
204
- # against — so a url client introspects on first use as it always does, and
205
- # nothing re-introspects the way check_queries defaults to. An unparseable
206
- # source is an entry like any other; nothing here raises for a bad query.
207
- # Shared fragments are inlined from fragments: the same way every other door
208
- # inlines them.
209
- def check_query(source, fragments: GraphWeaver.fragments_paths)
210
- GraphWeaver::Internal::QueryCheck.errors(
211
- schema, source, GraphWeaver::Codegen.load_fragments(fragments),
212
- GraphWeaver::Internal::QueryCheck.routing_table_for(schema_source),
213
- )
214
- end
215
-
216
193
  private
217
194
 
218
195
  # Anything already speaking the client contract — another Client,
@@ -34,6 +34,13 @@ class GraphWeaver::Codegen
34
34
  rescue UnknownSegment => e
35
35
  # names nothing in the schema, so no selection would fit — offering
36
36
  # optional: as the way out would just hide the typo
37
+ raise e.class, qualify(node, e.message)
38
+ rescue HoistedSegment => e
39
+ # optional: still skips it, but the message doesn't offer it: the two
40
+ # fixes it names are the only ones that end with an accessor, and no
41
+ # change to the fragment would make the path fit
42
+ next nil if spec[:optional]
43
+
37
44
  raise e.class, qualify(node, e.message)
38
45
  rescue GraphWeaver::Error => e
39
46
  # a path that doesn't fit THIS query's selection: optional simply
@@ -86,7 +93,10 @@ class GraphWeaver::Codegen
86
93
  # ever satisfy it, so it's a typo (or a wire-cased name), not a path that
87
94
  # doesn't fit this query. optional: skips the latter, never this.
88
95
  UnknownSegment = Class.new(GraphWeaver::Error)
89
- private_constant :ALIAS_RESERVED, :LIST_SELECTORS, :UnknownSegment
96
+
97
+ # A segment inside a struct another module owns — see refuse_hoisted!.
98
+ HoistedSegment = Class.new(GraphWeaver::Error)
99
+ private_constant :ALIAS_RESERVED, :LIST_SELECTORS, :UnknownSegment, :HoistedSegment
90
100
 
91
101
  # Walk a dotted path through this struct's selected shape, building the
92
102
  # delegator expression (`meta&.tag`, `_entities.first&.name`) and its return
@@ -158,11 +168,13 @@ class GraphWeaver::Codegen
158
168
 
159
169
  # A hop landing on a hoisted shared fragment: the struct is another
160
170
  # 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.
171
+ # that query spreads the fragment, so optional: still skips it — but
172
+ # selecting differently inside the fragment never makes the path fit, so
173
+ # the message names only the two fixes that do.
162
174
  def refuse_hoisted!(node, name, ref, seg)
163
175
  return unless ref
164
176
 
165
- raise GraphWeaver::Error,
177
+ raise HoistedSegment,
166
178
  "alias #{name.inspect} on #{node.graphql_type}: '#{seg}' is inside the shared fragment " \
167
179
  "#{ref.class_name}, which hoists to #{@types_namespace}::#{ref.class_name} — a path can't " \
168
180
  "read into it. Register the alias on #{ref.graphql_type}, or select a field beside the " \
@@ -521,10 +521,11 @@ class GraphWeaver::Codegen
521
521
  flag ? "#{var.kwarg}: (#{flag} = true; nil)" : "#{var.kwarg}: nil"
522
522
  } + ["client: nil"]
523
523
 
524
- # QueryModule#dispatch reads QUERY/OPERATION_NAME/GRAPH off the module,
525
- # so the gem gets to bracket every request without a line of it landing
526
- # in every generated file
527
- call = "dispatch(variables, client:)"
524
+ # QueryModule#dispatch reads QUERY/OPERATION_NAME/GRAPH off the module
525
+ # and closes the operation event over the cast the block does, so the
526
+ # gem brackets the whole call — request and cast — without a line of it
527
+ # landing in every generated file
528
+ call = "dispatch(variables, client:) { |raw| from_response(raw) }"
528
529
 
529
530
  # execute returns the full envelope; execute! is the strict shortcut for
530
531
  # the typed result, or a raised QueryError.
@@ -534,7 +535,7 @@ class GraphWeaver::Codegen
534
535
  out << " def self.execute(#{kwargs.join(", ")})"
535
536
  emit_variables(out, required, optional, omitted)
536
537
  out << ""
537
- out << " from_response(#{call})"
538
+ out << " #{call}"
538
539
  out << " end"
539
540
  out << ""
540
541
  out << " sig { params(#{sig_params.join(", ")}).returns(Result).checked(:never) }"
@@ -547,7 +548,7 @@ class GraphWeaver::Codegen
547
548
  # left out is exactly what a Ruby call can't forward
548
549
  emit_variables(out, required, optional, omitted)
549
550
  out << ""
550
- out << " from_response(#{call}).data!"
551
+ out << " #{call}.data!"
551
552
  end
552
553
  out << " end"
553
554
  out << ""
@@ -83,7 +83,31 @@ class GraphWeaver::Codegen
83
83
  "add them, pin with map:, or absorb with fallback:"
84
84
  end
85
85
 
86
- [from_wire, to_wire(from_wire, aliases)]
86
+ wire = to_wire(from_wire, aliases)
87
+ warn_unsendable(wire)
88
+ [from_wire, wire]
89
+ end
90
+
91
+ # The other direction, which nothing said: a member no wire value maps back
92
+ # from. A warning, not a refusal — one app-wide T::Enum serving two APIs
93
+ # that each expose a subset is a real shape, and the call that carries such
94
+ # a member is refused by name at runtime (InputStruct.enum_wire), so no
95
+ # wrong answer gets through either way. Warned on the channel
96
+ # validate_registrations! already narrates a mismatched registration on.
97
+ # fallback: is exempt: standing for what the schema doesn't declare is
98
+ # exactly what it is for.
99
+ def warn_unsendable(wire)
100
+ unsendable = type.values - wire.keys - [fallback].compact
101
+ return if unsendable.empty?
102
+
103
+ # a T::Enum member inspects as #<Type::Name>
104
+ names = unsendable.map { |member| member.inspect[2..-2] }.join(", ")
105
+ them = unsendable.one? ? "it" : "them"
106
+ GraphWeaver::Internal::Log.log(:warn) do
107
+ "#{graphql_name} has no value for #{type} member(s) #{names} — a kwarg carrying " \
108
+ "#{them} is refused at the call; map onto a matching T::Enum, or name the drift " \
109
+ "member with fallback:"
110
+ end
87
111
  end
88
112
 
89
113
  # alias spelling => the value it is read as, checked against what the
@@ -185,7 +209,7 @@ class GraphWeaver::Codegen
185
209
  groups = ambiguous.values.map { |pairs| pairs.map(&:first) }
186
210
  more = ambiguous.size - 1
187
211
  raise GraphWeaver::Error,
188
- "enum #{graphql_name}: #{groups.first.join(" and ")} both map onto the #{type} member " \
212
+ "enum #{graphql_name}: #{groups.first.join(" and ")} all map onto the #{type} member " \
189
213
  "#{member.serialize.to_s.inspect}#{" (and #{more} more)" unless more.zero?} — say which spelling " \
190
214
  "goes on the wire:\n #{EnumType.alias_suggestion(graphql_name, groups, type)}"
191
215
  end
@@ -293,7 +293,9 @@ class GraphWeaver::Codegen
293
293
  # #as_json. Without a fallback the table is total and a miss is a real
294
294
  # mistake, so it still raises.
295
295
  def serialize(expr, _depth)
296
- return "#{const_prefix}_TO_WIRE.fetch(#{expr})" unless @fallback
296
+ unless @fallback
297
+ return "GraphWeaver::InputStruct.enum_wire(#{@graphql_name.inspect}, #{const_prefix}_TO_WIRE, #{expr})"
298
+ end
297
299
 
298
300
  "#{const_prefix}_TO_WIRE.fetch(#{expr}) { |member| member.serialize }"
299
301
  end
@@ -406,6 +408,23 @@ class GraphWeaver::Codegen
406
408
  def bare_type = "#{class_name}::Type"
407
409
  end
408
410
 
411
+ # An abstract fragment narrowing to one member hoists to that member's
412
+ # struct, and narrowing filters — so the match is tested at the reference,
413
+ # the way NarrowedNode does it, rather than inside a T::Struct.from_h that
414
+ # has no way to answer nil. See NarrowedNode for what `typename` being
415
+ # absent means.
416
+ class NarrowedRefNode < HoistedRefNode
417
+ def initialize(class_name, graphql_type = nil, typename: nil)
418
+ super(class_name, graphql_type)
419
+ @typename = typename
420
+ end
421
+
422
+ def cast(expr, depth)
423
+ match = @typename ? "#{expr}[\"__typename\"] == #{@typename.inspect}" : "!#{expr}.empty?"
424
+ "(#{match} ? #{super} : nil)"
425
+ end
426
+ end
427
+
409
428
  # An input-object variable: emitted as a module-level T::Struct whose
410
429
  # serialize produces the wire hash. Inputs never cast FROM the wire.
411
430
  # Joins the coerce protocol so execute kwargs accept plain hashes,
@@ -479,5 +498,5 @@ class GraphWeaver::Codegen
479
498
  # from inside the walk.
480
499
  private_constant :Node, :Scalar, :NonNull, :List, :ObjectNode, :EnumNode,
481
500
  :MappedEnum, :NarrowedNode, :UnionNode, :HoistedRefNode, :UnionRefNode,
482
- :InputNode, :RepresentationNode
501
+ :NarrowedRefNode, :InputNode, :RepresentationNode
483
502
  end