graph_weaver 0.4.4 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1357 -0
- data/CLAUDE.md +100 -8
- data/DECISIONS.md +309 -0
- data/Gemfile.lock +23 -23
- data/NOTES.md +5 -5
- data/PLAN.md +106 -135
- data/README.md +115 -96
- data/REVIEW.md +946 -0
- data/docs/cassettes.md +75 -48
- data/docs/editors.md +82 -0
- data/docs/errors.md +32 -30
- data/docs/federation.md +520 -48
- data/docs/generated_modules.md +352 -137
- data/docs/getting_started.md +237 -67
- data/docs/logging.md +35 -6
- data/docs/real_world.md +21 -15
- data/docs/scalars.md +49 -136
- data/docs/testing.md +299 -52
- data/docs/transports.md +129 -30
- data/docs/upgrading.md +112 -0
- data/graph_weaver.gemspec +3 -1
- data/lib/generators/graph_weaver/install_generator.rb +259 -0
- data/lib/graph_weaver/client.rb +114 -111
- data/lib/graph_weaver/codegen/aliases.rb +217 -0
- data/lib/graph_weaver/codegen/emit.rb +272 -251
- data/lib/graph_weaver/codegen/enum_type.rb +27 -98
- data/lib/graph_weaver/codegen/nodes.rb +72 -13
- data/lib/graph_weaver/codegen/scalar_type.rb +72 -67
- data/lib/graph_weaver/codegen/type_helpers.rb +142 -0
- data/lib/graph_weaver/codegen.rb +617 -264
- data/lib/graph_weaver/errors.rb +127 -10
- data/lib/graph_weaver/federation.rb +272 -0
- data/lib/graph_weaver/hints.rb +12 -6
- data/lib/graph_weaver/in_process.rb +90 -0
- data/lib/graph_weaver/input_struct.rb +21 -2
- data/lib/graph_weaver/logging.rb +29 -0
- data/lib/graph_weaver/parsing.rb +67 -0
- data/lib/graph_weaver/query_module.rb +55 -0
- data/lib/graph_weaver/railtie.rb +23 -1
- data/lib/graph_weaver/representation.rb +74 -0
- data/lib/graph_weaver/response.rb +15 -1
- data/lib/graph_weaver/retry.rb +29 -8
- data/lib/graph_weaver/rspec.rb +214 -16
- data/lib/graph_weaver/schema_loader.rb +820 -57
- data/lib/graph_weaver/schemas.rb +46 -0
- data/lib/graph_weaver/selection.rb +59 -7
- data/lib/graph_weaver/tasks.rb +216 -21
- data/lib/graph_weaver/testing/cassette.rb +186 -62
- data/lib/graph_weaver/testing/coverage.rb +165 -0
- data/lib/graph_weaver/testing/failure.rb +10 -23
- data/lib/graph_weaver/testing/fake_client.rb +194 -28
- data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
- data/lib/graph_weaver/testing/router.rb +1431 -0
- data/lib/graph_weaver/testing/subgraphs.rb +130 -0
- data/lib/graph_weaver/testing.rb +204 -14
- data/lib/graph_weaver/transport/faraday.rb +31 -6
- data/lib/graph_weaver/transport/http.rb +99 -36
- data/lib/graph_weaver/transport.rb +74 -18
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +398 -170
- metadata +20 -3
data/lib/graph_weaver/client.rb
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
require_relative "codegen"
|
|
5
5
|
require_relative "errors"
|
|
6
6
|
require_relative "inflect"
|
|
7
|
+
require_relative "parsing"
|
|
7
8
|
require_relative "retry"
|
|
8
9
|
require_relative "schema_loader"
|
|
9
10
|
require_relative "transport/http"
|
|
@@ -12,31 +13,45 @@ require_relative "transport/http"
|
|
|
12
13
|
# generation:
|
|
13
14
|
#
|
|
14
15
|
# github = GraphWeaver.new("https://api.github.com/graphql", auth: token, cache: true)
|
|
15
|
-
# github.register_scalar("DateTime", Time, serialize: :iso8601, requires: "time")
|
|
16
16
|
#
|
|
17
|
-
# RepoQuery = github.parse("queries/repo.graphql") # implicit schema +
|
|
18
|
-
# github.
|
|
17
|
+
# RepoQuery = github.parse("queries/repo.graphql") # implicit schema + client
|
|
18
|
+
# github.run!("query { viewer { login } }") # one-shot
|
|
19
19
|
#
|
|
20
20
|
# The first argument is a url (a transport is built; the schema comes
|
|
21
21
|
# from introspection on first use, cached per cache:/ttl:) or a schema
|
|
22
|
-
# source — a live schema class (which also executes in-process
|
|
23
|
-
#
|
|
24
|
-
#
|
|
22
|
+
# source — a live schema class (which also executes in-process, through
|
|
23
|
+
# an InProcess wrapper that takes context:), or a path/SDL/introspection
|
|
24
|
+
# dump via SchemaLoader.
|
|
25
25
|
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
26
|
+
# transport: means "which transport" alongside a url — :http (the
|
|
27
|
+
# default, always, whatever else the Gemfile loads) or :faraday — and
|
|
28
|
+
# "this transport" alongside a schema source.
|
|
29
|
+
#
|
|
30
|
+
# Clients are independent: each has its own transport and schema, so one
|
|
31
|
+
# app can talk to several GraphQL servers. Scalar/enum/type registrations
|
|
32
|
+
# are a codegen concern and live in one global registry (see
|
|
33
|
+
# GraphWeaver.register_scalar) — the same registry the rake tasks bake.
|
|
29
34
|
class GraphWeaver::Client
|
|
35
|
+
include GraphWeaver::Parsing
|
|
36
|
+
|
|
30
37
|
URL = %r{\Ahttps?://}i
|
|
31
38
|
|
|
32
|
-
def initialize(source, auth: nil, headers: {}, retries: false, transport: nil, cache: nil, ttl: nil,
|
|
39
|
+
def initialize(source, auth: nil, headers: {}, retries: false, transport: nil, cache: nil, ttl: nil,
|
|
40
|
+
open_timeout: nil, read_timeout: nil, context: nil, &middleware)
|
|
41
|
+
check_source!(source)
|
|
42
|
+
|
|
33
43
|
if source.is_a?(String) && source.match?(URL)
|
|
34
|
-
raise ArgumentError, "
|
|
44
|
+
raise ArgumentError, "context: applies to a schema class executing in-process" if context
|
|
35
45
|
|
|
36
|
-
|
|
46
|
+
built = build_transport(source, auth:, headers:, kind: transport, open_timeout:, read_timeout:, &middleware)
|
|
47
|
+
@transport = wrap_retries(built, retries)
|
|
37
48
|
else
|
|
38
|
-
if auth || middleware || retries
|
|
39
|
-
raise ArgumentError, "auth:/retries:/middleware apply to a url — got a schema source"
|
|
49
|
+
if auth || middleware || retries || open_timeout || read_timeout
|
|
50
|
+
raise ArgumentError, "auth:/retries:/timeouts/middleware apply to a url — got a schema source"
|
|
51
|
+
end
|
|
52
|
+
if transport.is_a?(Symbol)
|
|
53
|
+
# naming a bundled transport only builds one from a url
|
|
54
|
+
raise ArgumentError, "transport: #{transport.inspect} needs a url — got a schema source; pass a built transport"
|
|
40
55
|
end
|
|
41
56
|
if cache || ttl
|
|
42
57
|
# a schema source never introspects, so a cache would silently no-op
|
|
@@ -46,14 +61,21 @@ class GraphWeaver::Client
|
|
|
46
61
|
# a live schema class doubles as an in-process transport; a loaded
|
|
47
62
|
# dump has no resolvers, so it is type information only
|
|
48
63
|
@schema = source.is_a?(Module) ? source : GraphWeaver::SchemaLoader.load(source)
|
|
49
|
-
|
|
64
|
+
|
|
65
|
+
if context && !(source.is_a?(Module) && transport.nil?)
|
|
66
|
+
# nothing would ever read it — a dump has no resolvers, and an
|
|
67
|
+
# explicit transport carries its own
|
|
68
|
+
raise ArgumentError, "context: applies to a schema class executing in-process"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# InProcess adds context:, logging and branded errors to the bare
|
|
72
|
+
# schema class, which stays usable on its own everywhere else
|
|
73
|
+
@transport = transport ||
|
|
74
|
+
(GraphWeaver::InProcess.new(source, context: context || {}) if source.is_a?(Module))
|
|
50
75
|
end
|
|
51
76
|
|
|
52
77
|
@cache = cache
|
|
53
78
|
@ttl = ttl
|
|
54
|
-
@scalars = {}
|
|
55
|
-
@enums = {}
|
|
56
|
-
@types = {}
|
|
57
79
|
end
|
|
58
80
|
|
|
59
81
|
# The transport queries run through: a url-built transport, an
|
|
@@ -74,123 +96,104 @@ class GraphWeaver::Client
|
|
|
74
96
|
@schema ||= GraphWeaver::SchemaLoader.introspect(transport!, cache: @cache, ttl: @ttl)
|
|
75
97
|
end
|
|
76
98
|
|
|
77
|
-
#
|
|
78
|
-
#
|
|
79
|
-
#
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def register_scalar(graphql_name, type, cast: nil, serialize: nil, requires: nil, coerce: nil)
|
|
83
|
-
validate_registration!("scalar", graphql_name.to_s)
|
|
84
|
-
@scalars[graphql_name.to_s] =
|
|
85
|
-
GraphWeaver::Codegen::ScalarType.new(graphql_name, type, cast:, serialize:, requires:, coerce:)
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
# Client-scoped enum mapping: this client's generated code speaks your
|
|
89
|
-
# T::Enum for the named GraphQL enum (see Codegen::EnumType — inference
|
|
90
|
-
# by name, map: for renames, fallback: to absorb unknown wire values).
|
|
91
|
-
def register_enum(graphql_name, type, map: nil, fallback: nil, requires: nil)
|
|
92
|
-
validate_registration!("enum", graphql_name.to_s)
|
|
93
|
-
@enums[graphql_name.to_s] =
|
|
94
|
-
GraphWeaver::Codegen::EnumType.new(graphql_name, type, map:, fallback:, requires:)
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
# Bulk, inference-only form: register_enums("Species" => PetKind, ...)
|
|
98
|
-
def register_enums(mappings)
|
|
99
|
-
mappings.each { |graphql_name, type| register_enum(graphql_name, type) }
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
# Client-scoped type helpers: include app-owned modules into every
|
|
103
|
-
# struct this client generates from the named GraphQL type — pass
|
|
104
|
-
# modules, or a block to build one inline. Additive with global
|
|
105
|
-
# registrations (see GraphWeaver.extend_type).
|
|
106
|
-
def extend_type(graphql_name, *mixins, requires: nil, **kw, &block)
|
|
107
|
-
validate_registration!("type", graphql_name.to_s)
|
|
108
|
-
aliases = GraphWeaver::Codegen.take_aliases(kw)
|
|
109
|
-
entry = @types[graphql_name.to_s] ||= { mixins: [], requires: [], aliases: {} }
|
|
110
|
-
GraphWeaver::Codegen.add_type_helpers(entry, graphql_name, mixins, requires, block, aliases)
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
# Parse a query (a .graphql path or raw string) into a typed module
|
|
114
|
-
# bound to this client's schema, scalars, enums, helpers, and transport
|
|
115
|
-
# (including a live schema class executing in-process — the module came
|
|
116
|
-
# from this client, so it runs against it; pass a client per call to
|
|
117
|
-
# override, e.g. with a fake).
|
|
118
|
-
def parse(query, name: nil)
|
|
119
|
-
GraphWeaver.parse(schema:, query:, name:, client: transport,
|
|
120
|
-
scalars: @scalars, enums: @enums, types: @types)
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
# Parse every .graphql query in a directory into typed modules, named
|
|
124
|
-
# like generation would name them — the no-build-step analog of
|
|
125
|
-
# generate! + load_generated!:
|
|
126
|
-
#
|
|
127
|
-
# github.load_queries! # queries/person.graphql => ::PersonQuery
|
|
128
|
-
# github.load_queries!(namespace: Github) # => Github::PersonQuery
|
|
129
|
-
#
|
|
130
|
-
# Reloadable (constants are replaced), so it suits consoles and dev.
|
|
131
|
-
# Returns the modules.
|
|
132
|
-
def load_queries!(dir = nil, namespace: Object)
|
|
133
|
-
dirs = dir ? [dir] : GraphWeaver.queries_paths
|
|
134
|
-
dirs.flat_map { |d| Dir[File.join(d, "*.graphql")].sort }.map do |path|
|
|
135
|
-
name = "#{GraphWeaver::Inflect.camelize(File.basename(path, ".graphql"))}Query"
|
|
136
|
-
namespace.send(:remove_const, name) if namespace.const_defined?(name, false)
|
|
137
|
-
GraphWeaver.log(:info) { "loaded #{name} from #{path}" }
|
|
138
|
-
namespace.const_set(name, parse(path))
|
|
139
|
-
end
|
|
99
|
+
# The client contract, same as every transport: a query and its
|
|
100
|
+
# variables in, the raw response hash out. (#run is the one-shot that
|
|
101
|
+
# parses and returns the typed envelope.)
|
|
102
|
+
def execute(query, variables: {}, operation_name: nil)
|
|
103
|
+
transport!.execute(query, variables:, operation_name:)
|
|
140
104
|
end
|
|
141
105
|
|
|
142
|
-
# One-shot dynamic execution — parse +
|
|
143
|
-
# Response envelope (
|
|
106
|
+
# One-shot dynamic execution — parse + run, returning the typed
|
|
107
|
+
# Response envelope (run! returns the result or raises). Variables
|
|
144
108
|
# are plain kwargs, exactly as on a generated module; graphql-cased
|
|
145
109
|
# string keys work too.
|
|
146
|
-
def
|
|
110
|
+
def run(query, **variables)
|
|
147
111
|
mod = parse(query)
|
|
148
112
|
kwargs = variables.to_h { |key, value| [GraphWeaver::Inflect.underscore(key.to_s).to_sym, value] }
|
|
149
|
-
mod.execute(
|
|
113
|
+
mod.execute(**kwargs)
|
|
150
114
|
end
|
|
151
115
|
|
|
152
|
-
def
|
|
153
|
-
|
|
116
|
+
def run!(query, **variables)
|
|
117
|
+
run(query, **variables).data!
|
|
154
118
|
end
|
|
155
119
|
|
|
156
120
|
private
|
|
157
121
|
|
|
158
|
-
#
|
|
159
|
-
#
|
|
160
|
-
#
|
|
161
|
-
#
|
|
162
|
-
#
|
|
163
|
-
def
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
122
|
+
# Anything already speaking the client contract — another Client,
|
|
123
|
+
# InProcess, Retry, a transport, a fake, the test router — carries no
|
|
124
|
+
# schema to generate from, so it can't stand in as the schema source.
|
|
125
|
+
# Without this it is handed to SchemaLoader and fails as `undefined
|
|
126
|
+
# method 'lstrip'`.
|
|
127
|
+
def check_source!(source)
|
|
128
|
+
# a graphql-ruby schema class executes too, and *is* a schema source
|
|
129
|
+
return if source.is_a?(Module) || !source.respond_to?(:execute)
|
|
130
|
+
|
|
131
|
+
raise GraphWeaver::Error, "#{source.class} is a client, not a schema source — pass the schema, and this " \
|
|
132
|
+
"as its transport: GraphWeaver.new(schema, transport: client). For a live schema class " \
|
|
133
|
+
"with a context: GraphWeaver.new(schema, context: { ... })."
|
|
167
134
|
end
|
|
168
135
|
|
|
169
136
|
# auth: is a token — "Bearer" is assumed unless the string carries its
|
|
170
|
-
# own scheme ("Basic dXNlcjpwYXNz...").
|
|
171
|
-
#
|
|
172
|
-
#
|
|
173
|
-
#
|
|
174
|
-
#
|
|
175
|
-
#
|
|
176
|
-
#
|
|
177
|
-
#
|
|
178
|
-
|
|
137
|
+
# own scheme ("Basic dXNlcjpwYXNz...").
|
|
138
|
+
#
|
|
139
|
+
# Transport pick: always Transport::HTTP unless you ask for Faraday
|
|
140
|
+
# (transport: :faraday, or a middleware block, which is Faraday's
|
|
141
|
+
# anyway). Deliberately NOT `defined?(Faraday)`: faraday rides along
|
|
142
|
+
# transitively in most bundles (stripe, octokit, ...), so sniffing for
|
|
143
|
+
# it lets an unrelated gem swap your transport — along with its
|
|
144
|
+
# timeouts and, since Faraday's default net_http adapter reconnects
|
|
145
|
+
# per request, your connection reuse. Same code, same transport.
|
|
146
|
+
def build_transport(url, auth:, headers:, kind:, open_timeout: nil, read_timeout: nil, &middleware)
|
|
179
147
|
headers = headers.dup
|
|
180
148
|
if auth
|
|
181
149
|
headers["Authorization"] ||= auth.include?(" ") ? auth : "Bearer #{auth}"
|
|
182
150
|
end
|
|
183
151
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
152
|
+
# nil means "the transport's default" — both bundled ones agree on it
|
|
153
|
+
timeouts = { open_timeout:, read_timeout: }.compact
|
|
154
|
+
|
|
155
|
+
transport =
|
|
156
|
+
if transport_kind(kind, middleware) == :faraday
|
|
157
|
+
build_faraday(url, headers:, timeouts:, &middleware)
|
|
158
|
+
else
|
|
159
|
+
GraphWeaver::Transport::HTTP.new(url, headers:, **timeouts)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
GraphWeaver.log(:info) { "transport: #{transport.class} -> #{url}" }
|
|
163
|
+
transport
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Which bundled transport a url client builds: the explicit
|
|
167
|
+
# transport:, else Faraday when a middleware block asks for it.
|
|
168
|
+
def transport_kind(kind, middleware)
|
|
169
|
+
case kind
|
|
170
|
+
when nil then middleware ? :faraday : :http
|
|
171
|
+
when :faraday then :faraday
|
|
172
|
+
when :http
|
|
173
|
+
raise ArgumentError, "middleware blocks are Faraday's — pass transport: :faraday" if middleware
|
|
174
|
+
|
|
175
|
+
:http
|
|
189
176
|
else
|
|
190
|
-
|
|
177
|
+
raise ArgumentError, "transport: takes :http or :faraday alongside a url, got #{kind.inspect}"
|
|
191
178
|
end
|
|
192
179
|
end
|
|
193
180
|
|
|
181
|
+
# The faraday gem is optional, so a missing one reads as a Gemfile
|
|
182
|
+
# problem rather than a stack trace out of require.
|
|
183
|
+
def build_faraday(url, headers:, timeouts:, &middleware)
|
|
184
|
+
begin
|
|
185
|
+
require_relative "transport/faraday"
|
|
186
|
+
rescue LoadError
|
|
187
|
+
nil # reported below, alongside a gem that loaded but wasn't there
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
unless defined?(::Faraday)
|
|
191
|
+
raise ArgumentError, "the faraday transport needs the faraday gem — add it to your Gemfile"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
GraphWeaver::Transport::Faraday.new(url, headers:, **timeouts, &middleware)
|
|
195
|
+
end
|
|
196
|
+
|
|
194
197
|
# retries: is off by default — true for Retry defaults, or a
|
|
195
198
|
# Hash of its options
|
|
196
199
|
def wrap_retries(transport, retries)
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Registered aliases (extend_type alias:): dotted paths that project a nested
|
|
5
|
+
# selection onto the struct that owns it — `entity: "_entities.first"`,
|
|
6
|
+
# `tag: "meta.tag"` — as typed delegators. Resolved against the walked node
|
|
7
|
+
# tree, so a path the query doesn't select fails at generation.
|
|
8
|
+
#
|
|
9
|
+
# Mixed into Codegen — methods run with the generator instance state. The
|
|
10
|
+
# subsystem hangs off one seam: object_node's
|
|
11
|
+
# `node.aliases = resolve_aliases(node)`.
|
|
12
|
+
|
|
13
|
+
# the other half: extend_type, which populates the registry read here
|
|
14
|
+
require_relative "type_helpers"
|
|
15
|
+
|
|
16
|
+
class GraphWeaver::Codegen
|
|
17
|
+
module Aliases
|
|
18
|
+
include Kernel # for sorbet: hosts are Objects
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
# Resolve each registered alias (extend_type alias:) for this struct's type
|
|
23
|
+
# against its actual selection — path -> a typed delegator emitted into the
|
|
24
|
+
# struct body. Validated here, per query, so an unselected or untraversable
|
|
25
|
+
# path fails at generation with a pointed message.
|
|
26
|
+
def resolve_aliases(node)
|
|
27
|
+
type_aliases(node.graphql_type).filter_map do |name, spec|
|
|
28
|
+
# a bad accessor name (reserved, or colliding with a real field) is a
|
|
29
|
+
# registration mistake — it fails for every query, so it always raises,
|
|
30
|
+
# even for optional aliases (which otherwise mask it as "doesn't fit").
|
|
31
|
+
check_alias_name!(node, name)
|
|
32
|
+
begin
|
|
33
|
+
resolve_alias(node, name, spec[:segments])
|
|
34
|
+
rescue UnknownSegment => e
|
|
35
|
+
# names nothing in the schema, so no selection would fit — offering
|
|
36
|
+
# optional: as the way out would just hide the typo
|
|
37
|
+
raise e.class, qualify(node, e.message)
|
|
38
|
+
rescue GraphWeaver::Error => e
|
|
39
|
+
# a path that doesn't fit THIS query's selection: optional simply
|
|
40
|
+
# omits the accessor; strict breaks generation for every query on the
|
|
41
|
+
# type, so name the one that failed and the way out
|
|
42
|
+
next nil if spec[:optional]
|
|
43
|
+
|
|
44
|
+
raise e.class, "#{qualify(node, e.message)} " \
|
|
45
|
+
"— pass optional: true to skip selections that don't fit"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Name the failing query module — unless the message already names it,
|
|
51
|
+
# since a module and the type it queries can share a name (module Query
|
|
52
|
+
# on type Query would otherwise stutter).
|
|
53
|
+
def qualify(node, message)
|
|
54
|
+
return message if @module_name.nil? || @module_name == node.graphql_type
|
|
55
|
+
|
|
56
|
+
"#{@module_name}: #{message}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def check_alias_name!(node, name)
|
|
60
|
+
taken = node.fields.any? { |f| f.prop == name } ||
|
|
61
|
+
ALIAS_RESERVED.include?(name) || RUBY_KEYWORDS.include?(name)
|
|
62
|
+
return unless taken
|
|
63
|
+
|
|
64
|
+
raise GraphWeaver::Error,
|
|
65
|
+
"alias #{name.inspect} on #{node.graphql_type} collides with an existing field or method"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Registered aliases for a GraphQL type (see extend_type alias:).
|
|
69
|
+
def type_aliases(graphql_name)
|
|
70
|
+
GraphWeaver::Codegen.type_registry[graphql_name]&.dig(:aliases) || {}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# methods every generated struct already answers to; Ruby keywords are
|
|
74
|
+
# checked alongside (RUBY_KEYWORDS is defined by the class this mixes into)
|
|
75
|
+
ALIAS_RESERVED = %w[from_h serialize to_h].to_set.freeze
|
|
76
|
+
# list selectors — pick one element out of a list-typed hop, always nilable
|
|
77
|
+
# (the list may be empty). Everything else is a field prop.
|
|
78
|
+
LIST_SELECTORS = %w[first last].freeze
|
|
79
|
+
# A segment naming no field of the GraphQL type at all — no selection could
|
|
80
|
+
# ever satisfy it, so it's a typo (or a wire-cased name), not a path that
|
|
81
|
+
# doesn't fit this query. optional: skips the latter, never this.
|
|
82
|
+
UnknownSegment = Class.new(GraphWeaver::Error)
|
|
83
|
+
|
|
84
|
+
# Walk a dotted path through this struct's selected shape, building the
|
|
85
|
+
# delegator expression (`meta&.tag`, `_entities.first&.name`) and its return
|
|
86
|
+
# type. A segment is a field prop, or `first`/`last` to pick a list element.
|
|
87
|
+
# Everything is checked against the node tree: a field on a non-object, a
|
|
88
|
+
# selector on a non-list, or an unselected segment raises. Any nilable hop
|
|
89
|
+
# (a nullable field, or a list element) makes the accessor nilable.
|
|
90
|
+
def resolve_alias(node, name, segments)
|
|
91
|
+
cur = T.let(node, T.untyped) # the node the path has reached
|
|
92
|
+
cur_nilable = T.let(false, T::Boolean) # is the expression so far nilable
|
|
93
|
+
nilable = T.let(false, T::Boolean) # is the accessor overall nilable
|
|
94
|
+
containers = T.let([], T::Array[String]) # nested-struct class names on the way to the leaf
|
|
95
|
+
expr = +""
|
|
96
|
+
|
|
97
|
+
segments.each do |seg|
|
|
98
|
+
# the first hop reads off the struct itself — spelled `self.` when the
|
|
99
|
+
# prop is a Ruby keyword (`self.next`), which bare would be the keyword
|
|
100
|
+
connector = if !expr.empty?
|
|
101
|
+
cur_nilable ? "&." : "."
|
|
102
|
+
else
|
|
103
|
+
RUBY_KEYWORDS.include?(seg) ? "self." : ""
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# `first`/`last` select an element only when the current hop is actually a
|
|
107
|
+
# list; otherwise they're an ordinary field (a schema field named `first`)
|
|
108
|
+
if LIST_SELECTORS.include?(seg) && list_of(cur)
|
|
109
|
+
expr << connector << seg
|
|
110
|
+
cur = list_of(cur).of
|
|
111
|
+
cur_nilable = true # first/last is nil on an empty list
|
|
112
|
+
nilable = true
|
|
113
|
+
else
|
|
114
|
+
obj = object_of(cur)
|
|
115
|
+
unless obj
|
|
116
|
+
hint = if list_of(cur)
|
|
117
|
+
" — use .first or .last to pick an element"
|
|
118
|
+
elsif LIST_SELECTORS.include?(seg)
|
|
119
|
+
" — .#{seg} needs a list"
|
|
120
|
+
else
|
|
121
|
+
""
|
|
122
|
+
end
|
|
123
|
+
raise GraphWeaver::Error,
|
|
124
|
+
"alias #{name.inspect} on #{node.graphql_type}: '#{seg}' can't be read here (not an object)#{hint}"
|
|
125
|
+
end
|
|
126
|
+
# the object a field is read from is the lexical container of its result
|
|
127
|
+
# (nested structs emit inside their parent); the aliased struct itself is
|
|
128
|
+
# the delegator's own scope, so it contributes no prefix
|
|
129
|
+
containers << obj.class_name unless obj.equal?(node)
|
|
130
|
+
field = obj.fields.find { |f| f.prop == seg }
|
|
131
|
+
unless field
|
|
132
|
+
check_segment_exists!(node, name, obj, seg)
|
|
133
|
+
props = obj.fields.map(&:prop)
|
|
134
|
+
suggestion = GraphWeaver.did_you_mean(props, seg)
|
|
135
|
+
hint = suggestion ? " — did you mean '#{suggestion}'?" : " (have: #{props.join(", ")})"
|
|
136
|
+
raise GraphWeaver::Error,
|
|
137
|
+
"alias #{name.inspect} on #{node.graphql_type}: '#{seg}' is not a selected field#{hint}"
|
|
138
|
+
end
|
|
139
|
+
expr << connector << seg
|
|
140
|
+
cur = field.node
|
|
141
|
+
cur_nilable = !field.node.non_null?
|
|
142
|
+
nilable ||= cur_nilable
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
leaf = qualified_alias_type(cur, containers)
|
|
147
|
+
type = nilable && leaf != "T.untyped" ? "T.nilable(#{leaf})" : leaf
|
|
148
|
+
ObjectNode::Alias.new(name, expr, type)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Separate "this query didn't select it" from "no query could": a segment
|
|
152
|
+
# the schema doesn't declare on the type is a mistake in the registration,
|
|
153
|
+
# so it raises even for an optional alias — which otherwise turns a typo
|
|
154
|
+
# (or a wire-cased 'findPets') into an accessor that silently vanishes.
|
|
155
|
+
def check_segment_exists!(node, name, obj, seg)
|
|
156
|
+
type = obj.graphql_type && @schema.get_type(obj.graphql_type)
|
|
157
|
+
return unless type.respond_to?(:fields)
|
|
158
|
+
|
|
159
|
+
known = type.fields.keys.map { |field| GraphWeaver::Inflect.underscore(field) }
|
|
160
|
+
return if seg == "__typename" || known.include?(seg)
|
|
161
|
+
|
|
162
|
+
prop = GraphWeaver::Inflect.underscore(seg)
|
|
163
|
+
hint = if prop != seg && known.include?(prop)
|
|
164
|
+
# paths are the Ruby prop chain, not the GraphQL one — the classic miss
|
|
165
|
+
" — GraphQL fields generate snake_case props; use '#{prop}'"
|
|
166
|
+
elsif (suggestion = GraphWeaver.did_you_mean(known, prop))
|
|
167
|
+
" — did you mean '#{suggestion}'?"
|
|
168
|
+
else
|
|
169
|
+
" (has: #{known.sort.join(", ")})"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
raise UnknownSegment,
|
|
173
|
+
"alias #{name.inspect} on #{node.graphql_type}: '#{seg}' is not a field of #{obj.graphql_type}#{hint}"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# The leaf's Sorbet type as referenced from the aliased struct. Generated
|
|
177
|
+
# nested constants (structs, enums, unions) must carry the container path,
|
|
178
|
+
# since the delegator's `sig` is emitted in an outer struct where a bare
|
|
179
|
+
# `Sub` wouldn't resolve; scalars, mapped enums, and hoisted union refs are
|
|
180
|
+
# already top-level. `containers` is the class-name chain to the leaf.
|
|
181
|
+
def qualified_alias_type(node, containers)
|
|
182
|
+
node = node.of if node.is_a?(NonNull)
|
|
183
|
+
prefix = containers.empty? ? "" : "#{containers.join("::")}::"
|
|
184
|
+
|
|
185
|
+
case node
|
|
186
|
+
when List
|
|
187
|
+
element = node.of.is_a?(NonNull) ? qualified_alias_type(node.of, containers) : begin
|
|
188
|
+
inner = qualified_alias_type(node.of, containers)
|
|
189
|
+
inner == "T.untyped" ? inner : "T.nilable(#{inner})"
|
|
190
|
+
end
|
|
191
|
+
"T::Array[#{element}]"
|
|
192
|
+
when ObjectNode, NarrowedNode then "#{prefix}#{node.class_name}"
|
|
193
|
+
# enums are emitted at module level (see Emit#module_level?), or aliased
|
|
194
|
+
# there from the shared enums module — either way, no container prefix
|
|
195
|
+
when EnumNode then node.class_name
|
|
196
|
+
when UnionNode then "#{prefix}#{node.bare_type}"
|
|
197
|
+
else node.bare_type # Scalar, MappedEnum, UnionRefNode — already top-level
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# the List a node wraps (through NON_NULL), or nil
|
|
202
|
+
def list_of(node)
|
|
203
|
+
node = T.let(node, T.untyped)
|
|
204
|
+
node = node.of while node.is_a?(NonNull)
|
|
205
|
+
node if node.is_a?(List)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# the ObjectNode a node resolves to for field access (through NON_NULL and a
|
|
209
|
+
# narrowed abstract member), or nil — unions/scalars/lists can't be read into
|
|
210
|
+
def object_of(node)
|
|
211
|
+
node = T.let(node, T.untyped)
|
|
212
|
+
node = node.of while node.is_a?(NonNull)
|
|
213
|
+
node = node.nested if node.is_a?(NarrowedNode)
|
|
214
|
+
node if node.is_a?(ObjectNode)
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|