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.
- checksums.yaml +4 -4
- data/Gemfile.lock +2 -2
- data/README.md +1 -0
- data/docs/errors.md +12 -5
- data/docs/generated_modules.md +134 -17
- data/docs/getting_started.md +182 -20
- data/docs/logging.md +79 -35
- data/docs/migrating.md +126 -0
- data/docs/scalars.md +50 -6
- data/docs/testing.md +78 -14
- data/docs/upgrading.md +44 -2
- data/examples/README.md +4 -2
- data/examples/github/generate.rb +22 -8
- data/examples/github/generated/star_mutation.rb +2 -2
- data/examples/github/generated/stargazers_query.rb +2 -2
- data/examples/github/generated/starred_query.rb +2 -2
- data/examples/github/run.rb +1 -0
- data/examples/github/setup.rb +16 -8
- data/graph_weaver.gemspec +15 -6
- data/lib/generators/graph_weaver/install_generator.rb +49 -2
- data/lib/graph_weaver/client.rb +0 -23
- data/lib/graph_weaver/codegen/aliases.rb +36 -3
- data/lib/graph_weaver/codegen/emit.rb +20 -15
- data/lib/graph_weaver/codegen/enum_type.rb +52 -11
- data/lib/graph_weaver/codegen/nodes.rb +75 -32
- data/lib/graph_weaver/codegen.rb +260 -100
- data/lib/graph_weaver/coerce.rb +1 -1
- data/lib/graph_weaver/federation.rb +1 -6
- data/lib/graph_weaver/graph.rb +55 -5
- data/lib/graph_weaver/hints.rb +20 -5
- data/lib/graph_weaver/in_process.rb +2 -4
- data/lib/graph_weaver/input_struct.rb +50 -10
- data/lib/graph_weaver/internal/overrides.rb +126 -14
- data/lib/graph_weaver/internal/subgraphs.rb +1 -10
- data/lib/graph_weaver/internal/test_clients.rb +29 -7
- data/lib/graph_weaver/internal/unused.rb +62 -18
- data/lib/graph_weaver/internal/values.rb +24 -7
- data/lib/graph_weaver/internal.rb +23 -6
- data/lib/graph_weaver/log_subscriber.rb +27 -17
- data/lib/graph_weaver/logging.rb +115 -82
- data/lib/graph_weaver/parsing.rb +32 -3
- data/lib/graph_weaver/query_module.rb +67 -12
- data/lib/graph_weaver/railtie.rb +7 -2
- data/lib/graph_weaver/rspec.rb +41 -18
- data/lib/graph_weaver/schema_diff.rb +24 -5
- data/lib/graph_weaver/schema_loader.rb +29 -17
- data/lib/graph_weaver/tasks.rb +98 -16
- data/lib/graph_weaver/testing/fake_client.rb +28 -31
- data/lib/graph_weaver/testing/router.rb +26 -25
- data/lib/graph_weaver/testing.rb +27 -8
- data/lib/graph_weaver/transport.rb +1 -1
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +72 -42
- 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.
|
|
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`);
|
|
38
|
-
modules
|
|
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.
|
data/examples/github/generate.rb
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
changed
|
|
20
|
-
|
|
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
|
+
# 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
|
-
|
|
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
|
+
# 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
|
-
|
|
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
|
+
# 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
|
-
|
|
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) }
|
data/examples/github/run.rb
CHANGED
data/examples/github/setup.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
217
|
-
|
|
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
|
data/lib/graph_weaver/client.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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,
|
|
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
|
|
134
|
-
def emit_types_files(
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
214
|
-
#
|
|
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
|
-
@
|
|
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
|
-
#
|
|
522
|
-
#
|
|
523
|
-
|
|
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 << "
|
|
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 << "
|
|
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
|
-
|
|
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:/
|
|
119
|
-
#
|
|
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
|
|
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,
|
|
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:,
|
|
166
|
+
extra = { map:, requires: }.compact.keys.first
|
|
127
167
|
if extra
|
|
128
168
|
raise ArgumentError,
|
|
129
|
-
"register_enum(#{graphql_name.inspect}
|
|
130
|
-
"
|
|
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 ")}
|
|
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,
|
|
193
|
-
# spellings into one value
|
|
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
|
#
|