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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1357 -0
  3. data/CLAUDE.md +100 -8
  4. data/DECISIONS.md +309 -0
  5. data/Gemfile.lock +23 -23
  6. data/NOTES.md +5 -5
  7. data/PLAN.md +106 -135
  8. data/README.md +115 -96
  9. data/REVIEW.md +946 -0
  10. data/docs/cassettes.md +75 -48
  11. data/docs/editors.md +82 -0
  12. data/docs/errors.md +32 -30
  13. data/docs/federation.md +520 -48
  14. data/docs/generated_modules.md +352 -137
  15. data/docs/getting_started.md +237 -67
  16. data/docs/logging.md +35 -6
  17. data/docs/real_world.md +21 -15
  18. data/docs/scalars.md +49 -136
  19. data/docs/testing.md +299 -52
  20. data/docs/transports.md +129 -30
  21. data/docs/upgrading.md +112 -0
  22. data/graph_weaver.gemspec +3 -1
  23. data/lib/generators/graph_weaver/install_generator.rb +259 -0
  24. data/lib/graph_weaver/client.rb +114 -111
  25. data/lib/graph_weaver/codegen/aliases.rb +217 -0
  26. data/lib/graph_weaver/codegen/emit.rb +272 -251
  27. data/lib/graph_weaver/codegen/enum_type.rb +27 -98
  28. data/lib/graph_weaver/codegen/nodes.rb +72 -13
  29. data/lib/graph_weaver/codegen/scalar_type.rb +72 -67
  30. data/lib/graph_weaver/codegen/type_helpers.rb +142 -0
  31. data/lib/graph_weaver/codegen.rb +617 -264
  32. data/lib/graph_weaver/errors.rb +127 -10
  33. data/lib/graph_weaver/federation.rb +272 -0
  34. data/lib/graph_weaver/hints.rb +12 -6
  35. data/lib/graph_weaver/in_process.rb +90 -0
  36. data/lib/graph_weaver/input_struct.rb +21 -2
  37. data/lib/graph_weaver/logging.rb +29 -0
  38. data/lib/graph_weaver/parsing.rb +67 -0
  39. data/lib/graph_weaver/query_module.rb +55 -0
  40. data/lib/graph_weaver/railtie.rb +23 -1
  41. data/lib/graph_weaver/representation.rb +74 -0
  42. data/lib/graph_weaver/response.rb +15 -1
  43. data/lib/graph_weaver/retry.rb +29 -8
  44. data/lib/graph_weaver/rspec.rb +214 -16
  45. data/lib/graph_weaver/schema_loader.rb +820 -57
  46. data/lib/graph_weaver/schemas.rb +46 -0
  47. data/lib/graph_weaver/selection.rb +59 -7
  48. data/lib/graph_weaver/tasks.rb +216 -21
  49. data/lib/graph_weaver/testing/cassette.rb +186 -62
  50. data/lib/graph_weaver/testing/coverage.rb +165 -0
  51. data/lib/graph_weaver/testing/failure.rb +10 -23
  52. data/lib/graph_weaver/testing/fake_client.rb +194 -28
  53. data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
  54. data/lib/graph_weaver/testing/router.rb +1431 -0
  55. data/lib/graph_weaver/testing/subgraphs.rb +130 -0
  56. data/lib/graph_weaver/testing.rb +204 -14
  57. data/lib/graph_weaver/transport/faraday.rb +31 -6
  58. data/lib/graph_weaver/transport/http.rb +99 -36
  59. data/lib/graph_weaver/transport.rb +74 -18
  60. data/lib/graph_weaver/version.rb +1 -1
  61. data/lib/graph_weaver.rb +398 -170
  62. metadata +20 -3
@@ -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 + 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
+ 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, "pass a url or transport:, not both" if transport
44
+ raise ArgumentError, "context: applies to a schema class executing in-process" if context
35
45
 
36
- @transport = wrap_retries(build_transport(source, auth:, headers:, &middleware), retries)
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 != false
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
- @transport = transport || (source if source.is_a?(Module))
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
- # 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:)
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 + execute, returning the typed
143
- # Response envelope (execute! returns the result or raises). Variables
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 execute(query, **variables)
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(transport!, **kwargs)
113
+ mod.execute(**kwargs)
150
114
  end
151
115
 
152
- def execute!(query, **variables)
153
- execute(query, **variables).data!
116
+ def run!(query, **variables)
117
+ run(query, **variables).data!
154
118
  end
155
119
 
156
120
  private
157
121
 
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)
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..."). 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)
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
- 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"
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
- GraphWeaver::Transport::HTTP.new(url, headers:)
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