graph_weaver 0.4.6 → 0.5.1
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 +1442 -0
- data/Gemfile.lock +23 -23
- data/README.md +115 -96
- data/docs/cassettes.md +93 -46
- data/docs/editors.md +82 -0
- data/docs/errors.md +34 -30
- data/docs/federation.md +521 -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 -154
- data/docs/testing.md +300 -52
- data/docs/transports.md +129 -30
- data/docs/upgrading.md +134 -0
- data/graph_weaver.gemspec +19 -3
- data/lib/generators/graph_weaver/install_generator.rb +259 -0
- data/lib/graph_weaver/client.rb +118 -111
- data/lib/graph_weaver/codegen/aliases.rb +223 -0
- data/lib/graph_weaver/codegen/emit.rb +283 -261
- data/lib/graph_weaver/codegen/enum_type.rb +25 -124
- data/lib/graph_weaver/codegen/nodes.rb +72 -13
- data/lib/graph_weaver/codegen/scalar_type.rb +69 -66
- data/lib/graph_weaver/codegen/type_helpers.rb +140 -0
- data/lib/graph_weaver/codegen.rb +672 -336
- data/lib/graph_weaver/errors.rb +154 -16
- data/lib/graph_weaver/federation.rb +259 -0
- data/lib/graph_weaver/hints.rb +9 -1
- data/lib/graph_weaver/in_process.rb +90 -0
- data/lib/graph_weaver/input_struct.rb +14 -2
- data/lib/graph_weaver/logging.rb +29 -0
- data/lib/graph_weaver/parsing.rb +59 -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 +7 -0
- data/lib/graph_weaver/retry.rb +29 -8
- data/lib/graph_weaver/rspec.rb +220 -16
- data/lib/graph_weaver/schema_loader.rb +819 -60
- data/lib/graph_weaver/schemas.rb +48 -0
- data/lib/graph_weaver/selection.rb +43 -8
- data/lib/graph_weaver/tasks.rb +220 -22
- data/lib/graph_weaver/testing/cassette.rb +249 -81
- data/lib/graph_weaver/testing/coverage.rb +160 -0
- data/lib/graph_weaver/testing/failure.rb +14 -25
- data/lib/graph_weaver/testing/fake_client.rb +182 -22
- data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
- data/lib/graph_weaver/testing/router.rb +1452 -0
- data/lib/graph_weaver/testing/subgraphs.rb +134 -0
- data/lib/graph_weaver/testing.rb +209 -13
- data/lib/graph_weaver/transport/faraday.rb +28 -10
- data/lib/graph_weaver/transport/http.rb +99 -36
- data/lib/graph_weaver/transport.rb +67 -14
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +416 -172
- metadata +25 -9
- data/CLAUDE.md +0 -69
- data/Makefile +0 -23
- data/NOTES.md +0 -182
- data/PLAN.md +0 -144
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,49 @@ 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
|
-
|
|
39
|
+
# refused from two branches — a url source, and a schema source with
|
|
40
|
+
# nothing to hand a context to — so the two can't word it differently
|
|
41
|
+
CONTEXT_IN_PROCESS = "context: applies to a schema class executing in-process"
|
|
42
|
+
|
|
43
|
+
def initialize(source, auth: nil, headers: {}, retries: false, transport: nil, cache: nil, ttl: nil,
|
|
44
|
+
open_timeout: nil, read_timeout: nil, context: nil, &middleware)
|
|
45
|
+
check_source!(source)
|
|
46
|
+
|
|
33
47
|
if source.is_a?(String) && source.match?(URL)
|
|
34
|
-
raise ArgumentError,
|
|
48
|
+
raise ArgumentError, CONTEXT_IN_PROCESS if context
|
|
35
49
|
|
|
36
|
-
|
|
50
|
+
built = build_transport(source, auth:, headers:, kind: transport, open_timeout:, read_timeout:, &middleware)
|
|
51
|
+
@transport = wrap_retries(built, retries)
|
|
37
52
|
else
|
|
38
|
-
if auth || middleware || retries
|
|
39
|
-
raise ArgumentError, "auth:/retries:/middleware apply to a url — got a schema source"
|
|
53
|
+
if auth || middleware || retries || open_timeout || read_timeout
|
|
54
|
+
raise ArgumentError, "auth:/retries:/timeouts/middleware apply to a url — got a schema source"
|
|
55
|
+
end
|
|
56
|
+
if transport.is_a?(Symbol)
|
|
57
|
+
# naming a bundled transport only builds one from a url
|
|
58
|
+
raise ArgumentError, "transport: #{transport.inspect} needs a url — got a schema source; pass a built transport"
|
|
40
59
|
end
|
|
41
60
|
if cache || ttl
|
|
42
61
|
# a schema source never introspects, so a cache would silently no-op
|
|
@@ -46,14 +65,21 @@ class GraphWeaver::Client
|
|
|
46
65
|
# a live schema class doubles as an in-process transport; a loaded
|
|
47
66
|
# dump has no resolvers, so it is type information only
|
|
48
67
|
@schema = source.is_a?(Module) ? source : GraphWeaver::SchemaLoader.load(source)
|
|
49
|
-
|
|
68
|
+
|
|
69
|
+
if context && !(source.is_a?(Module) && transport.nil?)
|
|
70
|
+
# nothing would ever read it — a dump has no resolvers, and an
|
|
71
|
+
# explicit transport carries its own
|
|
72
|
+
raise ArgumentError, CONTEXT_IN_PROCESS
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# InProcess adds context:, logging and branded errors to the bare
|
|
76
|
+
# schema class, which stays usable on its own everywhere else
|
|
77
|
+
@transport = transport ||
|
|
78
|
+
(GraphWeaver::InProcess.new(source, context: context || {}) if source.is_a?(Module))
|
|
50
79
|
end
|
|
51
80
|
|
|
52
81
|
@cache = cache
|
|
53
82
|
@ttl = ttl
|
|
54
|
-
@scalars = {}
|
|
55
|
-
@enums = {}
|
|
56
|
-
@types = {}
|
|
57
83
|
end
|
|
58
84
|
|
|
59
85
|
# The transport queries run through: a url-built transport, an
|
|
@@ -74,123 +100,104 @@ class GraphWeaver::Client
|
|
|
74
100
|
@schema ||= GraphWeaver::SchemaLoader.introspect(transport!, cache: @cache, ttl: @ttl)
|
|
75
101
|
end
|
|
76
102
|
|
|
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:)
|
|
103
|
+
# The client contract, same as every transport: a query and its
|
|
104
|
+
# variables in, the raw response hash out. (#run is the one-shot that
|
|
105
|
+
# parses and returns the typed envelope.)
|
|
106
|
+
def execute(query, variables: {}, operation_name: nil)
|
|
107
|
+
transport!.execute(query, variables:, operation_name:)
|
|
95
108
|
end
|
|
96
109
|
|
|
97
|
-
#
|
|
98
|
-
|
|
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
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
# One-shot dynamic execution — parse + execute, returning the typed
|
|
143
|
-
# Response envelope (execute! returns the result or raises). Variables
|
|
110
|
+
# One-shot dynamic execution — parse + run, returning the typed
|
|
111
|
+
# Response envelope (run! returns the result or raises). Variables
|
|
144
112
|
# are plain kwargs, exactly as on a generated module; graphql-cased
|
|
145
113
|
# string keys work too.
|
|
146
|
-
def
|
|
114
|
+
def run(query, **variables)
|
|
147
115
|
mod = parse(query)
|
|
148
116
|
kwargs = variables.to_h { |key, value| [GraphWeaver::Inflect.underscore(key.to_s).to_sym, value] }
|
|
149
|
-
mod.execute(
|
|
117
|
+
mod.execute(**kwargs)
|
|
150
118
|
end
|
|
151
119
|
|
|
152
|
-
def
|
|
153
|
-
|
|
120
|
+
def run!(query, **variables)
|
|
121
|
+
run(query, **variables).data!
|
|
154
122
|
end
|
|
155
123
|
|
|
156
124
|
private
|
|
157
125
|
|
|
158
|
-
#
|
|
159
|
-
#
|
|
160
|
-
#
|
|
161
|
-
#
|
|
162
|
-
#
|
|
163
|
-
def
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
126
|
+
# Anything already speaking the client contract — another Client,
|
|
127
|
+
# InProcess, Retry, a transport, a fake, the test router — carries no
|
|
128
|
+
# schema to generate from, so it can't stand in as the schema source.
|
|
129
|
+
# Without this it is handed to SchemaLoader and fails as `undefined
|
|
130
|
+
# method 'lstrip'`.
|
|
131
|
+
def check_source!(source)
|
|
132
|
+
# a graphql-ruby schema class executes too, and *is* a schema source
|
|
133
|
+
return if source.is_a?(Module) || !source.respond_to?(:execute)
|
|
134
|
+
|
|
135
|
+
raise GraphWeaver::Error, "#{source.class} is a client, not a schema source — pass the schema, and this " \
|
|
136
|
+
"as its transport: GraphWeaver.new(schema, transport: client). For a live schema class " \
|
|
137
|
+
"with a context: GraphWeaver.new(schema, context: { ... })."
|
|
167
138
|
end
|
|
168
139
|
|
|
169
140
|
# 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
|
-
|
|
141
|
+
# own scheme ("Basic dXNlcjpwYXNz...").
|
|
142
|
+
#
|
|
143
|
+
# Transport pick: always Transport::HTTP unless you ask for Faraday
|
|
144
|
+
# (transport: :faraday, or a middleware block, which is Faraday's
|
|
145
|
+
# anyway). Deliberately NOT `defined?(Faraday)`: faraday rides along
|
|
146
|
+
# transitively in most bundles (stripe, octokit, ...), so sniffing for
|
|
147
|
+
# it lets an unrelated gem swap your transport — along with its
|
|
148
|
+
# timeouts and, since Faraday's default net_http adapter reconnects
|
|
149
|
+
# per request, your connection reuse. Same code, same transport.
|
|
150
|
+
def build_transport(url, auth:, headers:, kind:, open_timeout: nil, read_timeout: nil, &middleware)
|
|
179
151
|
headers = headers.dup
|
|
180
152
|
if auth
|
|
181
153
|
headers["Authorization"] ||= auth.include?(" ") ? auth : "Bearer #{auth}"
|
|
182
154
|
end
|
|
183
155
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
156
|
+
# nil means "the transport's default" — both bundled ones agree on it
|
|
157
|
+
timeouts = { open_timeout:, read_timeout: }.compact
|
|
158
|
+
|
|
159
|
+
transport =
|
|
160
|
+
if transport_kind(kind, middleware) == :faraday
|
|
161
|
+
build_faraday(url, headers:, timeouts:, &middleware)
|
|
162
|
+
else
|
|
163
|
+
GraphWeaver::Transport::HTTP.new(url, headers:, **timeouts)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
GraphWeaver.log(:info) { "transport: #{transport.class} -> #{url}" }
|
|
167
|
+
transport
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Which bundled transport a url client builds: the explicit
|
|
171
|
+
# transport:, else Faraday when a middleware block asks for it.
|
|
172
|
+
def transport_kind(kind, middleware)
|
|
173
|
+
case kind
|
|
174
|
+
when nil then middleware ? :faraday : :http
|
|
175
|
+
when :faraday then :faraday
|
|
176
|
+
when :http
|
|
177
|
+
raise ArgumentError, "middleware blocks are Faraday's — pass transport: :faraday" if middleware
|
|
178
|
+
|
|
179
|
+
:http
|
|
189
180
|
else
|
|
190
|
-
|
|
181
|
+
raise ArgumentError, "transport: takes :http or :faraday alongside a url, got #{kind.inspect}"
|
|
191
182
|
end
|
|
192
183
|
end
|
|
193
184
|
|
|
185
|
+
# The faraday gem is optional, so a missing one reads as a Gemfile
|
|
186
|
+
# problem rather than a stack trace out of require.
|
|
187
|
+
def build_faraday(url, headers:, timeouts:, &middleware)
|
|
188
|
+
begin
|
|
189
|
+
require_relative "transport/faraday"
|
|
190
|
+
rescue LoadError
|
|
191
|
+
nil # reported below, alongside a gem that loaded but wasn't there
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
unless defined?(::Faraday)
|
|
195
|
+
raise ArgumentError, "the faraday transport needs the faraday gem — add it to your Gemfile"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
GraphWeaver::Transport::Faraday.new(url, headers:, **timeouts, &middleware)
|
|
199
|
+
end
|
|
200
|
+
|
|
194
201
|
# retries: is off by default — true for Retry defaults, or a
|
|
195
202
|
# Hash of its options
|
|
196
203
|
def wrap_retries(transport, retries)
|
|
@@ -0,0 +1,223 @@
|
|
|
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
|
+
# An alias emits a plain instance method, so it is held to the same bar
|
|
60
|
+
# as a wire field's prop: a name the struct already answers to would be
|
|
61
|
+
# silently overridden, and `hash` or `inspect` breaks the object rather
|
|
62
|
+
# than the file.
|
|
63
|
+
def check_alias_name!(node, name)
|
|
64
|
+
taken = node.fields.any? { |f| f.prop == name } ||
|
|
65
|
+
STRUCT_METHODS.include?(name) || ALIAS_RESERVED.include?(name) ||
|
|
66
|
+
RUBY_KEYWORDS.include?(name)
|
|
67
|
+
return unless taken
|
|
68
|
+
|
|
69
|
+
raise GraphWeaver::Error,
|
|
70
|
+
"alias #{name.inspect} on #{node.graphql_type} collides with an existing field or method"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Registered aliases for a GraphQL type (see extend_type alias:).
|
|
74
|
+
def type_aliases(graphql_name)
|
|
75
|
+
GraphWeaver::Codegen.type_registry[graphql_name]&.dig(:aliases) || {}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The CLASS methods a generated struct defines; STRUCT_METHODS covers the
|
|
79
|
+
# instance side, and both are checked with RUBY_KEYWORDS alongside (all
|
|
80
|
+
# three are defined by the class this mixes into).
|
|
81
|
+
ALIAS_RESERVED = %w[from_h].to_set.freeze
|
|
82
|
+
# list selectors — pick one element out of a list-typed hop, always nilable
|
|
83
|
+
# (the list may be empty). Everything else is a field prop.
|
|
84
|
+
LIST_SELECTORS = %w[first last].freeze
|
|
85
|
+
# A segment naming no field of the GraphQL type at all — no selection could
|
|
86
|
+
# ever satisfy it, so it's a typo (or a wire-cased name), not a path that
|
|
87
|
+
# doesn't fit this query. optional: skips the latter, never this.
|
|
88
|
+
UnknownSegment = Class.new(GraphWeaver::Error)
|
|
89
|
+
|
|
90
|
+
# Walk a dotted path through this struct's selected shape, building the
|
|
91
|
+
# delegator expression (`meta&.tag`, `_entities.first&.name`) and its return
|
|
92
|
+
# type. A segment is a field prop, or `first`/`last` to pick a list element.
|
|
93
|
+
# Everything is checked against the node tree: a field on a non-object, a
|
|
94
|
+
# selector on a non-list, or an unselected segment raises. Any nilable hop
|
|
95
|
+
# (a nullable field, or a list element) makes the accessor nilable.
|
|
96
|
+
def resolve_alias(node, name, segments)
|
|
97
|
+
cur = T.let(node, T.untyped) # the node the path has reached
|
|
98
|
+
cur_nilable = T.let(false, T::Boolean) # is the expression so far nilable
|
|
99
|
+
nilable = T.let(false, T::Boolean) # is the accessor overall nilable
|
|
100
|
+
containers = T.let([], T::Array[String]) # nested-struct class names on the way to the leaf
|
|
101
|
+
expr = +""
|
|
102
|
+
|
|
103
|
+
segments.each do |seg|
|
|
104
|
+
# the first hop reads off the struct itself — spelled `self.` when the
|
|
105
|
+
# prop is a Ruby keyword (`self.next`), which bare would be the keyword
|
|
106
|
+
connector = if !expr.empty?
|
|
107
|
+
cur_nilable ? "&." : "."
|
|
108
|
+
else
|
|
109
|
+
RUBY_KEYWORDS.include?(seg) ? "self." : ""
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# `first`/`last` select an element only when the current hop is actually a
|
|
113
|
+
# list; otherwise they're an ordinary field (a schema field named `first`)
|
|
114
|
+
if LIST_SELECTORS.include?(seg) && list_of(cur)
|
|
115
|
+
expr << connector << seg
|
|
116
|
+
cur = list_of(cur).of
|
|
117
|
+
cur_nilable = true # first/last is nil on an empty list
|
|
118
|
+
nilable = true
|
|
119
|
+
else
|
|
120
|
+
obj = object_of(cur)
|
|
121
|
+
unless obj
|
|
122
|
+
hint = if list_of(cur)
|
|
123
|
+
" — use .first or .last to pick an element"
|
|
124
|
+
elsif LIST_SELECTORS.include?(seg)
|
|
125
|
+
" — .#{seg} needs a list"
|
|
126
|
+
else
|
|
127
|
+
""
|
|
128
|
+
end
|
|
129
|
+
raise GraphWeaver::Error,
|
|
130
|
+
"alias #{name.inspect} on #{node.graphql_type}: '#{seg}' can't be read here (not an object)#{hint}"
|
|
131
|
+
end
|
|
132
|
+
# the object a field is read from is the lexical container of its result
|
|
133
|
+
# (nested structs emit inside their parent); the aliased struct itself is
|
|
134
|
+
# the delegator's own scope, so it contributes no prefix
|
|
135
|
+
containers << obj.class_name unless obj.equal?(node)
|
|
136
|
+
field = obj.fields.find { |f| f.prop == seg }
|
|
137
|
+
unless field
|
|
138
|
+
check_segment_exists!(node, name, obj, seg)
|
|
139
|
+
props = obj.fields.map(&:prop)
|
|
140
|
+
suggestion = GraphWeaver.did_you_mean(props, seg)
|
|
141
|
+
hint = suggestion ? " — did you mean '#{suggestion}'?" : " (have: #{props.join(", ")})"
|
|
142
|
+
raise GraphWeaver::Error,
|
|
143
|
+
"alias #{name.inspect} on #{node.graphql_type}: '#{seg}' is not a selected field#{hint}"
|
|
144
|
+
end
|
|
145
|
+
expr << connector << seg
|
|
146
|
+
cur = field.node
|
|
147
|
+
cur_nilable = !field.node.non_null?
|
|
148
|
+
nilable ||= cur_nilable
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
leaf = qualified_alias_type(cur, containers)
|
|
153
|
+
type = nilable && leaf != "T.untyped" ? "T.nilable(#{leaf})" : leaf
|
|
154
|
+
ObjectNode::Alias.new(name, expr, type)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Separate "this query didn't select it" from "no query could": a segment
|
|
158
|
+
# the schema doesn't declare on the type is a mistake in the registration,
|
|
159
|
+
# so it raises even for an optional alias — which otherwise turns a typo
|
|
160
|
+
# (or a wire-cased 'findPets') into an accessor that silently vanishes.
|
|
161
|
+
def check_segment_exists!(node, name, obj, seg)
|
|
162
|
+
type = obj.graphql_type && @schema.get_type(obj.graphql_type)
|
|
163
|
+
return unless type.respond_to?(:fields)
|
|
164
|
+
|
|
165
|
+
known = type.fields.keys.map { |field| GraphWeaver::Inflect.underscore(field) }
|
|
166
|
+
return if seg == "__typename" || known.include?(seg)
|
|
167
|
+
|
|
168
|
+
prop = GraphWeaver::Inflect.underscore(seg)
|
|
169
|
+
hint = if prop != seg && known.include?(prop)
|
|
170
|
+
# paths are the Ruby prop chain, not the GraphQL one — the classic miss
|
|
171
|
+
" — GraphQL fields generate snake_case props; use '#{prop}'"
|
|
172
|
+
elsif (suggestion = GraphWeaver.did_you_mean(known, prop))
|
|
173
|
+
" — did you mean '#{suggestion}'?"
|
|
174
|
+
else
|
|
175
|
+
" (has: #{known.sort.join(", ")})"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
raise UnknownSegment,
|
|
179
|
+
"alias #{name.inspect} on #{node.graphql_type}: '#{seg}' is not a field of #{obj.graphql_type}#{hint}"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# The leaf's Sorbet type as referenced from the aliased struct. Generated
|
|
183
|
+
# nested constants (structs, enums, unions) must carry the container path,
|
|
184
|
+
# since the delegator's `sig` is emitted in an outer struct where a bare
|
|
185
|
+
# `Sub` wouldn't resolve; scalars, mapped enums, and hoisted union refs are
|
|
186
|
+
# already top-level. `containers` is the class-name chain to the leaf.
|
|
187
|
+
def qualified_alias_type(node, containers)
|
|
188
|
+
node = node.of if node.is_a?(NonNull)
|
|
189
|
+
prefix = containers.empty? ? "" : "#{containers.join("::")}::"
|
|
190
|
+
|
|
191
|
+
case node
|
|
192
|
+
when List
|
|
193
|
+
element = node.of.is_a?(NonNull) ? qualified_alias_type(node.of, containers) : begin
|
|
194
|
+
inner = qualified_alias_type(node.of, containers)
|
|
195
|
+
inner == "T.untyped" ? inner : "T.nilable(#{inner})"
|
|
196
|
+
end
|
|
197
|
+
"T::Array[#{element}]"
|
|
198
|
+
when ObjectNode, NarrowedNode then "#{prefix}#{node.class_name}"
|
|
199
|
+
# enums are emitted at module level (see Emit#module_level?), or aliased
|
|
200
|
+
# there from the shared enums module — either way, no container prefix
|
|
201
|
+
when EnumNode then node.class_name
|
|
202
|
+
when UnionNode then "#{prefix}#{node.bare_type}"
|
|
203
|
+
else node.bare_type # Scalar, MappedEnum, UnionRefNode — already top-level
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# the List a node wraps (through NON_NULL), or nil
|
|
208
|
+
def list_of(node)
|
|
209
|
+
node = T.let(node, T.untyped)
|
|
210
|
+
node = node.of while node.is_a?(NonNull)
|
|
211
|
+
node if node.is_a?(List)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# the ObjectNode a node resolves to for field access (through NON_NULL and a
|
|
215
|
+
# narrowed abstract member), or nil — unions/scalars/lists can't be read into
|
|
216
|
+
def object_of(node)
|
|
217
|
+
node = T.let(node, T.untyped)
|
|
218
|
+
node = node.of while node.is_a?(NonNull)
|
|
219
|
+
node = node.nested if node.is_a?(NarrowedNode)
|
|
220
|
+
node if node.is_a?(ObjectNode)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|