graph_weaver 0.7.4 → 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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +2 -2
  3. data/README.md +1 -0
  4. data/docs/errors.md +12 -5
  5. data/docs/generated_modules.md +134 -17
  6. data/docs/getting_started.md +182 -20
  7. data/docs/logging.md +79 -35
  8. data/docs/migrating.md +126 -0
  9. data/docs/scalars.md +50 -6
  10. data/docs/testing.md +78 -14
  11. data/docs/upgrading.md +44 -2
  12. data/examples/README.md +4 -2
  13. data/examples/github/generate.rb +22 -8
  14. data/examples/github/generated/star_mutation.rb +2 -2
  15. data/examples/github/generated/stargazers_query.rb +2 -2
  16. data/examples/github/generated/starred_query.rb +2 -2
  17. data/examples/github/run.rb +1 -0
  18. data/examples/github/setup.rb +16 -8
  19. data/graph_weaver.gemspec +15 -6
  20. data/lib/generators/graph_weaver/install_generator.rb +49 -2
  21. data/lib/graph_weaver/client.rb +0 -23
  22. data/lib/graph_weaver/codegen/aliases.rb +36 -3
  23. data/lib/graph_weaver/codegen/emit.rb +20 -15
  24. data/lib/graph_weaver/codegen/enum_type.rb +52 -11
  25. data/lib/graph_weaver/codegen/nodes.rb +75 -32
  26. data/lib/graph_weaver/codegen.rb +260 -100
  27. data/lib/graph_weaver/coerce.rb +1 -1
  28. data/lib/graph_weaver/federation.rb +1 -6
  29. data/lib/graph_weaver/graph.rb +55 -5
  30. data/lib/graph_weaver/hints.rb +20 -5
  31. data/lib/graph_weaver/in_process.rb +2 -4
  32. data/lib/graph_weaver/input_struct.rb +50 -10
  33. data/lib/graph_weaver/internal/overrides.rb +126 -14
  34. data/lib/graph_weaver/internal/subgraphs.rb +1 -10
  35. data/lib/graph_weaver/internal/test_clients.rb +29 -7
  36. data/lib/graph_weaver/internal/unused.rb +62 -18
  37. data/lib/graph_weaver/internal/values.rb +24 -7
  38. data/lib/graph_weaver/internal.rb +23 -6
  39. data/lib/graph_weaver/log_subscriber.rb +27 -17
  40. data/lib/graph_weaver/logging.rb +115 -82
  41. data/lib/graph_weaver/parsing.rb +32 -3
  42. data/lib/graph_weaver/query_module.rb +67 -12
  43. data/lib/graph_weaver/railtie.rb +7 -2
  44. data/lib/graph_weaver/rspec.rb +41 -18
  45. data/lib/graph_weaver/schema_diff.rb +24 -5
  46. data/lib/graph_weaver/schema_loader.rb +29 -17
  47. data/lib/graph_weaver/tasks.rb +98 -16
  48. data/lib/graph_weaver/testing/fake_client.rb +28 -31
  49. data/lib/graph_weaver/testing/router.rb +26 -25
  50. data/lib/graph_weaver/testing.rb +27 -8
  51. data/lib/graph_weaver/transport.rb +1 -1
  52. data/lib/graph_weaver/version.rb +1 -1
  53. data/lib/graph_weaver.rb +72 -42
  54. metadata +3 -2
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.5](#upgrading-from-075), from
5
+ [0.7.4](#upgrading-from-074), from [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,47 @@ 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.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
+
49
+ ## Upgrading from 0.7.4
50
+
51
+ Read the left column and skip what isn't yours; the
52
+ [changelog](../CHANGELOG.md) says why each one moved.
53
+
54
+ | applies if you… | what changed |
55
+ |---|---|
56
+ | 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 |
57
+ | 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 |
58
+ | 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 |
59
+ | call `Codegen#used_union_names` | it is `used_fragment_names` — the set it reports now includes hoisted object fragments |
60
+
61
+ Then regenerate, and the gate:
62
+
63
+ ```sh
64
+ rake graph_weaver:generate
65
+ rake graph_weaver:verify
66
+ ```
67
+
26
68
  ## Upgrading from 0.7.3
27
69
 
28
70
  A patch release. One change can reach an app that never touched it — how a
@@ -207,7 +249,7 @@ only one here that shows up in production rather than in your code.
207
249
  Nothing raises: a subscriber comparing it to an Integer just stops matching. **A
208
250
  subscriber that branched on a 4xx/5xx reads `:http_status` now**, which is nil
209
251
  in-process. The whole payload is a documented contract —
210
- [logging](logging.md#the-payload).
252
+ [logging](logging.md#the-request-payload).
211
253
  - **`respond_to?` on a result struct no longer answers true for a name that doesn't
212
254
  exist.** It used to say true for any near miss, which broke the standard
213
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"
@@ -119,6 +119,27 @@ 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
+ globs = linguist_globs.reject { |glob| marked?(body, glob) }
132
+ return if globs.empty?
133
+
134
+ lines = globs.map { |glob| "#{glob} linguist-generated" }.join("\n")
135
+ marks = <<~TEXT + lines + "\n"
136
+ # Machine-written by `rake graph_weaver:generate` — GitHub collapses it in
137
+ # diffs and leaves it out of the language breakdown.
138
+ TEXT
139
+
140
+ body ? append_to_file(GITATTRIBUTES, "\n#{marks}") : create_file(GITATTRIBUTES, marks)
141
+ end
142
+
122
143
  # The `graphql:` tags need this require, and it has to be somewhere
123
144
  # rspec actually loads. A spec/support file is not: rspec-rails ships
124
145
  # the spec/support glob commented out, so the require sat there doing
@@ -191,6 +212,7 @@ module GraphWeaver
191
212
  private
192
213
 
193
214
  RUBOCOP_CONFIG = ".rubocop.yml"
215
+ GITATTRIBUTES = ".gitattributes"
194
216
 
195
217
  # rails_helper first: rspec-rails writes both, and only rails_helper
196
218
  # has Rails booted by the time the require runs.
@@ -203,6 +225,8 @@ module GraphWeaver
203
225
 
204
226
  def rubocop_config = File.join(GraphWeaver.root, RUBOCOP_CONFIG)
205
227
 
228
+ def gitattributes = File.join(GraphWeaver.root, GITATTRIBUTES)
229
+
206
230
  # Parsed, not counted: a `---` can also be a line inside a block scalar.
207
231
  # A file rubocop itself can't read is left to rubocop to complain about.
208
232
  def yaml_documents(body)
@@ -213,8 +237,31 @@ module GraphWeaver
213
237
 
214
238
  # Every graph's output directory, so a multi-schema app is covered by
215
239
  # 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
240
+ def generated_outputs = GraphWeaver.graphs.map(&:output).uniq
241
+
242
+ def generated_globs = generated_outputs.map { |dir| File.join(dir, "**/*") }
243
+
244
+ # gitattributes patterns are gitignore-style, where `dir/**` is
245
+ # everything beneath dir — spelled as git reads it, not as rubocop does.
246
+ def linguist_globs = generated_outputs.map { |dir| File.join(dir, "**") }
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
218
265
  end
219
266
 
220
267
  # This install run is the one moment the user is guaranteed to be
@@ -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
@@ -119,6 +129,7 @@ class GraphWeaver::Codegen
119
129
  nilable = true
120
130
  else
121
131
  obj = object_of(cur)
132
+ refuse_hoisted!(node, name, hoisted_of(cur), seg)
122
133
  unless obj
123
134
  hint = if list_of(cur)
124
135
  " — use .first or .last to pick an element"
@@ -155,6 +166,21 @@ class GraphWeaver::Codegen
155
166
  ObjectNode::Alias.new(name, expr, type)
156
167
  end
157
168
 
169
+ # A hop landing on a hoisted shared fragment: the struct is another
170
+ # module's, so the path stops here. Which query hoists is a property of how
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.
174
+ def refuse_hoisted!(node, name, ref, seg)
175
+ return unless ref
176
+
177
+ raise HoistedSegment,
178
+ "alias #{name.inspect} on #{node.graphql_type}: '#{seg}' is inside the shared fragment " \
179
+ "#{ref.class_name}, which hoists to #{@types_namespace}::#{ref.class_name} — a path can't " \
180
+ "read into it. Register the alias on #{ref.graphql_type}, or select a field beside the " \
181
+ "spread to keep the struct local"
182
+ end
183
+
158
184
  # Separate "this query didn't select it" from "no query could": a segment
159
185
  # the schema doesn't declare on the type is a mistake in the registration,
160
186
  # so it raises even for an optional alias — which otherwise turns a typo
@@ -185,7 +211,7 @@ class GraphWeaver::Codegen
185
211
  # The leaf's Sorbet type as referenced from the aliased struct. Generated
186
212
  # nested constants (structs, enums, unions) must carry the container path,
187
213
  # 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
214
+ # `Sub` wouldn't resolve; scalars, mapped enums, and hoisted refs are
189
215
  # already top-level. `containers` is the class-name chain to the leaf.
190
216
  def qualified_alias_type(node, containers)
191
217
  node = node.of if node.is_a?(NonNull)
@@ -203,10 +229,17 @@ class GraphWeaver::Codegen
203
229
  # there from the shared enums module — either way, no container prefix
204
230
  when EnumNode then node.class_name
205
231
  when UnionNode then "#{prefix}#{node.bare_type}"
206
- else node.bare_type # Scalar, MappedEnum, UnionRefNode — already top-level
232
+ else node.bare_type # Scalar, MappedEnum, a hoisted ref — already top-level
207
233
  end
208
234
  end
209
235
 
236
+ # the hoisted-fragment reference a node resolves to (through NON_NULL), or nil
237
+ def hoisted_of(node)
238
+ node = T.let(node, T.untyped)
239
+ node = node.of while node.is_a?(NonNull)
240
+ node if node.is_a?(HoistedRefNode)
241
+ end
242
+
210
243
  # the List a node wraps (through NON_NULL), or nil
211
244
  def list_of(node)
212
245
  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?
@@ -517,10 +521,11 @@ class GraphWeaver::Codegen
517
521
  flag ? "#{var.kwarg}: (#{flag} = true; nil)" : "#{var.kwarg}: nil"
518
522
  } + ["client: nil"]
519
523
 
520
- # QueryModule#dispatch reads QUERY/OPERATION_NAME/GRAPH off the module,
521
- # so the gem gets to bracket every request without a line of it landing
522
- # in every generated file
523
- 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) }"
524
529
 
525
530
  # execute returns the full envelope; execute! is the strict shortcut for
526
531
  # the typed result, or a raised QueryError.
@@ -530,7 +535,7 @@ class GraphWeaver::Codegen
530
535
  out << " def self.execute(#{kwargs.join(", ")})"
531
536
  emit_variables(out, required, optional, omitted)
532
537
  out << ""
533
- out << " from_response(#{call})"
538
+ out << " #{call}"
534
539
  out << " end"
535
540
  out << ""
536
541
  out << " sig { params(#{sig_params.join(", ")}).returns(Result).checked(:never) }"
@@ -543,7 +548,7 @@ class GraphWeaver::Codegen
543
548
  # left out is exactly what a Ruby call can't forward
544
549
  emit_variables(out, required, optional, omitted)
545
550
  out << ""
546
- out << " from_response(#{call}).data!"
551
+ out << " #{call}.data!"
547
552
  end
548
553
  out << " end"
549
554
  out << ""
@@ -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.
@@ -75,7 +83,31 @@ class GraphWeaver::Codegen
75
83
  "add them, pin with map:, or absorb with fallback:"
76
84
  end
77
85
 
78
- [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
79
111
  end
80
112
 
81
113
  # alias spelling => the value it is read as, checked against what the
@@ -115,19 +147,27 @@ class GraphWeaver::Codegen
115
147
 
116
148
  private
117
149
 
118
- # Without a T::Enum there is nothing for map:/fallback:/requires: to
119
- # describe, so alias: is the whole registration.
150
+ # Without a T::Enum there is nothing for map:/requires: to describe, so
151
+ # alias: and fallback: true are the whole registration.
120
152
  def alias_only!(map, fallback, requires)
121
- if @aliases.empty?
153
+ if fallback && fallback != true
154
+ raise ArgumentError, "register_enum(#{graphql_name.inspect}, fallback: #{fallback.inspect}): the " \
155
+ "generated enum generates its fallback member too, so say fallback: true. To fall back onto a " \
156
+ "member of your own, pass the T::Enum: " \
157
+ "register_enum(#{graphql_name.inspect}, YourEnum, fallback: YourEnum::Unknown)"
158
+ end
159
+
160
+ if @aliases.empty? && !fallback
122
161
  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"
162
+ "pass the T::Enum to map it onto, alias: { \"old\" => \"NEW\" } to read two wire values as one, " \
163
+ "or fallback: true to absorb values the server adds"
124
164
  end
125
165
 
126
- extra = { map:, fallback:, requires: }.compact.keys.first
166
+ extra = { map:, requires: }.compact.keys.first
127
167
  if extra
128
168
  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: {...})"
169
+ "register_enum(#{graphql_name.inspect}) takes no #{extra}: — that describes a T::Enum of your own, " \
170
+ "so pass one: register_enum(#{graphql_name.inspect}, YourEnum, #{extra}: {...})"
131
171
  end
132
172
 
133
173
  @requires = []
@@ -169,7 +209,7 @@ class GraphWeaver::Codegen
169
209
  groups = ambiguous.values.map { |pairs| pairs.map(&:first) }
170
210
  more = ambiguous.size - 1
171
211
  raise GraphWeaver::Error,
172
- "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 " \
173
213
  "#{member.serialize.to_s.inspect}#{" (and #{more} more)" unless more.zero?} — say which spelling " \
174
214
  "goes on the wire:\n #{EnumType.alias_suggestion(graphql_name, groups, type)}"
175
215
  end
@@ -189,8 +229,9 @@ class GraphWeaver::Codegen
189
229
 
190
230
  # The enum half of one graph's registrations — see Codegen::Registry.
191
231
  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
232
+ # Map a GraphQL enum onto an app-owned T::Enum, fold two of its wire
233
+ # spellings into one value, or absorb the ones the server hasn't told you
234
+ # about yet (see EnumType). The one implementation —
194
235
  # GraphWeaver.register_enum is a delegate, so the same call reaches it
195
236
  # whichever door you came in by.
196
237
  #