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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1442 -0
  3. data/Gemfile.lock +23 -23
  4. data/README.md +115 -96
  5. data/docs/cassettes.md +93 -46
  6. data/docs/editors.md +82 -0
  7. data/docs/errors.md +34 -30
  8. data/docs/federation.md +521 -48
  9. data/docs/generated_modules.md +352 -137
  10. data/docs/getting_started.md +237 -67
  11. data/docs/logging.md +35 -6
  12. data/docs/real_world.md +21 -15
  13. data/docs/scalars.md +49 -154
  14. data/docs/testing.md +300 -52
  15. data/docs/transports.md +129 -30
  16. data/docs/upgrading.md +134 -0
  17. data/graph_weaver.gemspec +19 -3
  18. data/lib/generators/graph_weaver/install_generator.rb +259 -0
  19. data/lib/graph_weaver/client.rb +118 -111
  20. data/lib/graph_weaver/codegen/aliases.rb +223 -0
  21. data/lib/graph_weaver/codegen/emit.rb +283 -261
  22. data/lib/graph_weaver/codegen/enum_type.rb +25 -124
  23. data/lib/graph_weaver/codegen/nodes.rb +72 -13
  24. data/lib/graph_weaver/codegen/scalar_type.rb +69 -66
  25. data/lib/graph_weaver/codegen/type_helpers.rb +140 -0
  26. data/lib/graph_weaver/codegen.rb +672 -336
  27. data/lib/graph_weaver/errors.rb +154 -16
  28. data/lib/graph_weaver/federation.rb +259 -0
  29. data/lib/graph_weaver/hints.rb +9 -1
  30. data/lib/graph_weaver/in_process.rb +90 -0
  31. data/lib/graph_weaver/input_struct.rb +14 -2
  32. data/lib/graph_weaver/logging.rb +29 -0
  33. data/lib/graph_weaver/parsing.rb +59 -0
  34. data/lib/graph_weaver/query_module.rb +55 -0
  35. data/lib/graph_weaver/railtie.rb +23 -1
  36. data/lib/graph_weaver/representation.rb +74 -0
  37. data/lib/graph_weaver/response.rb +7 -0
  38. data/lib/graph_weaver/retry.rb +29 -8
  39. data/lib/graph_weaver/rspec.rb +220 -16
  40. data/lib/graph_weaver/schema_loader.rb +819 -60
  41. data/lib/graph_weaver/schemas.rb +48 -0
  42. data/lib/graph_weaver/selection.rb +43 -8
  43. data/lib/graph_weaver/tasks.rb +220 -22
  44. data/lib/graph_weaver/testing/cassette.rb +249 -81
  45. data/lib/graph_weaver/testing/coverage.rb +160 -0
  46. data/lib/graph_weaver/testing/failure.rb +14 -25
  47. data/lib/graph_weaver/testing/fake_client.rb +182 -22
  48. data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
  49. data/lib/graph_weaver/testing/router.rb +1452 -0
  50. data/lib/graph_weaver/testing/subgraphs.rb +134 -0
  51. data/lib/graph_weaver/testing.rb +209 -13
  52. data/lib/graph_weaver/transport/faraday.rb +28 -10
  53. data/lib/graph_weaver/transport/http.rb +99 -36
  54. data/lib/graph_weaver/transport.rb +67 -14
  55. data/lib/graph_weaver/version.rb +1 -1
  56. data/lib/graph_weaver.rb +416 -172
  57. metadata +25 -9
  58. data/CLAUDE.md +0 -69
  59. data/Makefile +0 -23
  60. data/NOTES.md +0 -182
  61. data/PLAN.md +0 -144
@@ -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 + transport
18
- # github.execute!("query { viewer { login } }") # one-shot
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), or a
23
- # path/SDL/introspection dump via SchemaLoader. Pass transport: to
24
- # bring your own transport for a schema source.
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
- # Clients are independent: each has its own transport, schema, and
27
- # scalar registrations, so one app can talk to several GraphQL servers —
28
- # even ones that disagree about what a "DateTime" is.
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, &middleware)
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, "pass a url or transport:, not both" if transport
48
+ raise ArgumentError, CONTEXT_IN_PROCESS if context
35
49
 
36
- @transport = wrap_retries(build_transport(source, auth:, headers:, &middleware), retries)
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
- @transport = transport || (source if source.is_a?(Module))
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
- # Client-scoped scalar registration: consulted before the global
78
- # registry when this client generates code, so two clients can map the
79
- # same scalar name onto different Ruby types. A `Type.field` coordinate
80
- # (e.g. "User.birthday") overrides just that field. Same signature as
81
- # GraphWeaver.register_scalar.
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
- # 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
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 execute(query, **variables)
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(transport!, **kwargs)
117
+ mod.execute(**kwargs)
150
118
  end
151
119
 
152
- def execute!(query, **variables)
153
- execute(query, **variables).data!
120
+ def run!(query, **variables)
121
+ run(query, **variables).data!
154
122
  end
155
123
 
156
124
  private
157
125
 
158
- # Fail a typo'd registration at the call site when the schema is
159
- # already in hand (a schema-source client, or a url client after first
160
- # use) immediate feedback in consoles. Lazily-introspecting clients
161
- # get the same check at generation time instead; never trigger an
162
- # introspection just to validate a name.
163
- def validate_registration!(kind, name)
164
- return unless @schema
165
-
166
- GraphWeaver::Codegen.validate_registration!(@schema, kind, name)
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..."). Transport pick: Faraday when
171
- # the app already loads it (its middleware/proxy/timeout ecosystem
172
- # comes along), the zero-dependency Transport::HTTP otherwise.
173
- # Detection is `defined?(Faraday)` deliberately NOT a require:
174
- # faraday rides along transitively in most bundles (stripe, octokit,
175
- # ...), and try-requiring would switch transports on apps that never
176
- # chose it. With faraday under `require: false`, load it before
177
- # building the client.
178
- def build_transport(url, auth:, headers:, &middleware)
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
- if defined?(::Faraday)
185
- require_relative "transport/faraday"
186
- GraphWeaver::Transport::Faraday.new(url, headers:, &middleware)
187
- elsif middleware
188
- raise ArgumentError, "middleware blocks require the faraday gem"
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
- GraphWeaver::Transport::HTTP.new(url, headers:)
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