graph_weaver 0.1.0 → 0.2.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +6 -0
  3. data/CHANGELOG.md +213 -0
  4. data/Gemfile.lock +32 -2
  5. data/Makefile +7 -2
  6. data/NOTES.md +1 -1
  7. data/PLAN.md +34 -1
  8. data/README.md +56 -41
  9. data/docs/cassettes.md +76 -0
  10. data/docs/errors.md +37 -9
  11. data/docs/generated_modules.md +139 -29
  12. data/docs/getting_started.md +134 -0
  13. data/docs/logging.md +33 -0
  14. data/docs/real_world.md +27 -20
  15. data/docs/scalars.md +122 -8
  16. data/docs/testing.md +44 -34
  17. data/docs/transports.md +124 -0
  18. data/graph_weaver.gemspec +7 -1
  19. data/lib/graph_weaver/client.rb +200 -0
  20. data/lib/graph_weaver/codegen/emit.rb +88 -39
  21. data/lib/graph_weaver/codegen/enum_type.rb +149 -0
  22. data/lib/graph_weaver/codegen/nodes.rb +109 -9
  23. data/lib/graph_weaver/codegen/scalar_type.rb +53 -35
  24. data/lib/graph_weaver/codegen.rb +301 -67
  25. data/lib/graph_weaver/errors.rb +37 -25
  26. data/lib/graph_weaver/hints.rb +63 -0
  27. data/lib/graph_weaver/inflect.rb +2 -1
  28. data/lib/graph_weaver/logging.rb +41 -0
  29. data/lib/graph_weaver/railtie.rb +29 -0
  30. data/lib/graph_weaver/retry.rb +97 -0
  31. data/lib/graph_weaver/rspec.rb +19 -14
  32. data/lib/graph_weaver/schema_loader.rb +156 -20
  33. data/lib/graph_weaver/selection.rb +3 -3
  34. data/lib/graph_weaver/tasks.rb +71 -0
  35. data/lib/graph_weaver/testing/cassette.rb +42 -21
  36. data/lib/graph_weaver/testing/failure.rb +22 -22
  37. data/lib/graph_weaver/testing/{fake_executor.rb → fake_client.rb} +13 -13
  38. data/lib/graph_weaver/testing/values.rb +2 -2
  39. data/lib/graph_weaver/testing.rb +32 -16
  40. data/lib/graph_weaver/transport/faraday.rb +56 -0
  41. data/lib/graph_weaver/transport/http.rb +87 -0
  42. data/lib/graph_weaver/transport.rb +120 -0
  43. data/lib/graph_weaver/version.rb +1 -1
  44. data/lib/graph_weaver.rb +208 -38
  45. metadata +73 -5
  46. data/lib/graph_weaver/faraday_executor.rb +0 -61
  47. data/lib/graph_weaver/http_executor.rb +0 -44
  48. data/lib/graph_weaver/testing/rspec.rb +0 -5
@@ -29,11 +29,30 @@ class GraphWeaver::Codegen
29
29
  out << "#{pad}end"
30
30
  end
31
31
 
32
+ # module-level wire translation tables for an app-mapped enum
33
+ def emit_mapped_enum(node, out, indent)
34
+ pad = " " * indent
35
+ type = node.bare_type
36
+ prefix = node.const_prefix
37
+
38
+ out << "#{pad}# GraphQL enum #{node.graphql_name} <-> #{type} (registered mapping)"
39
+ out << "#{pad}#{prefix}_FROM_WIRE = T.let({"
40
+ node.mapping.each do |wire, member|
41
+ out << "#{pad} #{wire.inspect} => #{type}.deserialize(#{member.serialize.to_s.inspect}),"
42
+ end
43
+ out << "#{pad}}.freeze, T::Hash[String, #{type}])"
44
+ out << "#{pad}#{prefix}_TO_WIRE = T.let(#{prefix}_FROM_WIRE.invert.freeze, T::Hash[#{type}, String])"
45
+ end
46
+
32
47
  def emit_object(node, out, indent)
33
48
  pad = " " * indent
34
49
 
35
50
  out << "#{pad}class #{node.class_name} < T::Struct"
36
51
  out << "#{pad} extend T::Sig"
52
+ out << "#{pad} include GraphWeaver::Hints"
53
+ node.mixins.each do |mixin|
54
+ out << "#{pad} include #{mixin} # registered for #{node.graphql_type}"
55
+ end
37
56
  out << ""
38
57
 
39
58
  node.fields.filter_map { |field| field.node.nested }.each do |child|
@@ -89,56 +108,74 @@ class GraphWeaver::Codegen
89
108
  out << "#{pad}end"
90
109
  end
91
110
 
92
- def emit_execute(out, variables)
93
- out << " @executor = T.let(nil, T.untyped)"
111
+ def emit_execute(out, variables, flatten: nil)
112
+ out << " @client = T.let(nil, T.untyped)"
94
113
  out << ""
95
114
  out << " class << self"
96
115
  out << " extend T::Sig"
97
116
  out << ""
98
- out << " sig { params(executor: T.untyped).void }"
99
- out << " attr_writer :executor"
117
+ out << " sig { params(client: T.untyped).void }"
118
+ out << " attr_writer :client"
100
119
  out << ""
101
- out << " # default transport for execute"
120
+ out << " # default client (a GraphWeaver::Client or any transport) for"
121
+ out << " # execute: per-module override, else the app default"
102
122
  out << " sig { returns(T.untyped) }"
103
- out << " def executor"
104
- out << " @executor || #{@executor_const || "GraphWeaver.executor"}"
123
+ out << " def client"
124
+ out << " @client || #{@client_const || "GraphWeaver.client!"}"
105
125
  out << " end"
106
126
  out << " end"
107
127
  out << ""
108
128
 
109
- sig_params = variables.map do |var|
110
- bare = var.node.coerce? ? var.node.coerce_input_type : var.node.bare_type
111
- kwarg_type = var.required ? bare : "T.nilable(#{bare})"
112
- "#{var.kwarg}: #{kwarg_type}"
129
+ # the kwarg surface: the input's fields when flattened, else one
130
+ # kwarg per declared variable typed identically either way. The
131
+ # per-call client override rides as an optional POSITIONAL arg, so
132
+ # variables keep the entire kwarg namespace (nothing is reserved).
133
+ params = flatten ? flatten.fields.partition(&:required).flatten : variables
134
+
135
+ sig_params = ["client: T.untyped"]
136
+ sig_params += params.map do |param|
137
+ bare = param.node.coerce? ? param.node.coerce_input_type : param.node.bare_type
138
+ kwarg_type = param.required || bare == "T.untyped" ? bare : "T.nilable(#{bare})"
139
+ "#{kwarg_name(param)}: #{kwarg_type}"
113
140
  end
114
- sig_params << "executor: T.untyped"
115
141
 
116
- kwargs = variables.map { |var| var.required ? "#{var.kwarg}:" : "#{var.kwarg}: nil" }
117
- kwargs << "executor: self.executor"
142
+ kwargs = ["client = nil"]
143
+ kwargs += params.map { |param| param.required ? "#{kwarg_name(param)}:" : "#{kwarg_name(param)}: nil" }
118
144
 
119
145
  # execute returns the full envelope; execute! is the strict shortcut for
120
146
  # `execute(...).data!` — the typed result, or a raised QueryError.
121
- forward = (variables.map { |var| "#{var.kwarg}: #{var.kwarg}" } + ["executor: executor"]).join(", ")
147
+ forward = (["client"] + params.map { |param| "#{kwarg_name(param)}: #{kwarg_name(param)}" }).join(", ")
122
148
 
149
+ if flatten
150
+ out << " # $#{variables.first.wire}'s fields, flattened into kwargs (single input-object variable)"
151
+ end
123
152
  out << " sig { params(#{sig_params.join(", ")}).returns(GraphWeaver::Response[Result]) }"
124
153
  out << " def self.execute(#{kwargs.join(", ")})"
125
154
 
126
- required, optional = variables.partition(&:required)
127
- if required.empty?
128
- out << " variables = {}"
129
- else
155
+ if flatten
156
+ fields = flatten.fields.map { |field| "#{field.prop}:" }.join(", ")
130
157
  out << " variables = {"
131
- required.each do |var|
132
- out << " #{var.wire.inspect} => #{variable_serialize(var)},"
133
- end
158
+ out << " #{variables.first.wire.inspect} => #{flatten.class_name}.coerce({ #{fields} }).serialize,"
134
159
  out << " }"
135
- end
136
- optional.each do |var|
137
- out << " variables[#{var.wire.inspect}] = #{variable_serialize(var)} unless #{var.kwarg}.nil?"
160
+ else
161
+ required, optional = variables.partition(&:required)
162
+ if required.empty?
163
+ out << " variables = {}"
164
+ else
165
+ out << " variables = {"
166
+ required.each do |var|
167
+ out << " #{var.wire.inspect} => #{variable_serialize(var)},"
168
+ end
169
+ out << " }"
170
+ end
171
+ optional.each do |var|
172
+ out << " variables[#{var.wire.inspect}] = #{variable_serialize(var)} unless #{var.kwarg}.nil?"
173
+ end
138
174
  end
139
175
 
140
176
  out << ""
141
- out << " raw = executor.execute(QUERY, variables: variables).to_h"
177
+ out << " transport = GraphWeaver.resolve_transport(client || self.client)"
178
+ out << " raw = transport.execute(QUERY, variables: variables).to_h"
142
179
  out << " GraphWeaver::Response[Result].new("
143
180
  out << " data: (Result.from_h(raw[\"data\"]) if raw[\"data\"]),"
144
181
  out << " errors: (raw[\"errors\"] || []).map { |e| GraphWeaver::GraphQLError.from_h(e) },"
@@ -152,18 +189,17 @@ class GraphWeaver::Codegen
152
189
  out << " end"
153
190
  end
154
191
 
192
+ # a kwarg surface entry is a VarDef (.kwarg) or, when flattened, an
193
+ # InputNode::Field (.prop)
194
+ def kwarg_name(param)
195
+ param.respond_to?(:kwarg) ? param.kwarg : param.prop
196
+ end
197
+
155
198
  def variable_serialize(var)
156
199
  value = var.node.coerce? ? var.node.coerce(var.kwarg) : var.kwarg
157
200
  var.node.serialize_identity? ? value : var.node.serialize(value, 1)
158
201
  end
159
202
 
160
- # Structured shape for a schema-validation error: message plus its first
161
- # source location, so ValidationError#errors is inspectable.
162
- def validation_detail(error)
163
- loc = (error.to_h["locations"]&.first if error.respond_to?(:to_h))
164
- { message: error.message, line: loc && loc["line"], column: loc && loc["column"] }
165
- end
166
-
167
203
  def field_cast(field)
168
204
  node = field.node
169
205
 
@@ -189,16 +225,26 @@ class GraphWeaver::Codegen
189
225
  out << "#{pad} const :#{field.prop}, #{field.node.prop_type}#{default}"
190
226
  end
191
227
  out << ""
228
+ # locals wear the reserved __gw prefix: prop readers are bare method
229
+ # calls here, and a prop named "result" or "value" would otherwise
230
+ # be shadowed — GraphQL reserves __-names, so no field can collide
192
231
  out << "#{pad} sig { returns(T::Hash[String, T.untyped]) }"
193
232
  out << "#{pad} def serialize"
194
- out << "#{pad} result = T.let({}, T::Hash[String, T.untyped])"
233
+ out << "#{pad} __gw_result = T.let({}, T::Hash[String, T.untyped])"
195
234
  node.fields.each do |field|
196
- value = field.node.serialize_identity? ? field.prop.to_s : field.node.serialize(field.prop.to_s, 1)
197
- line = "result[#{field.wire.inspect}] = #{value}"
198
- line += " unless #{field.prop}.nil?" unless field.required
199
- out << "#{pad} #{line}"
235
+ if field.required || field.node.serialize_identity?
236
+ value = field.node.serialize_identity? ? field.prop.to_s : field.node.serialize(field.prop.to_s, 1)
237
+ line = "__gw_result[#{field.wire.inspect}] = #{value}"
238
+ line += " unless #{field.prop}.nil?" unless field.required
239
+ out << "#{pad} #{line}"
240
+ else
241
+ # bind a local so sorbet's flow-sensitivity narrows the nilable
242
+ out << "#{pad} unless (__gw_value = #{field.prop}).nil?"
243
+ out << "#{pad} __gw_result[#{field.wire.inspect}] = #{field.node.serialize("__gw_value", 1)}"
244
+ out << "#{pad} end"
245
+ end
200
246
  end
201
- out << "#{pad} result"
247
+ out << "#{pad} __gw_result"
202
248
  out << "#{pad} end"
203
249
  out << ""
204
250
  out << "#{pad} # serialize, under the conventional name"
@@ -212,6 +258,9 @@ class GraphWeaver::Codegen
212
258
  out << "#{pad} def self.coerce(value)"
213
259
  out << "#{pad} return value if value.is_a?(#{node.class_name})"
214
260
  out << ""
261
+ out << "#{pad} # a typo'd key must not silently drop off the wire"
262
+ out << "#{pad} GraphWeaver::Hints.validate_keys!(self, value)"
263
+ out << ""
215
264
  out << "#{pad} new("
216
265
  node.fields.each do |field|
217
266
  raw = "value_at(value, :#{field.prop})"
@@ -0,0 +1,149 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ class GraphWeaver::Codegen
5
+ # How one GraphQL enum maps onto an app-owned T::Enum, so generated
6
+ # code speaks YOUR enum instead of generating one per module:
7
+ #
8
+ # class PetKind < T::Enum
9
+ # enums { Cat = new("cat"); Dog = new("dog") }
10
+ # end
11
+ #
12
+ # GraphWeaver.register_enum("Species", PetKind)
13
+ #
14
+ # The wire mapping is inferred by name ("CAT" <-> PetKind::Cat,
15
+ # case/underscore-insensitive against each member's serialized value);
16
+ # map: pins renames explicitly and merges over inference. Every wire
17
+ # value the schema declares must resolve — generation fails naming the
18
+ # gaps — unless fallback: names a member to absorb unknown values
19
+ # (forward-compat for servers that add members; inputs stay strict).
20
+ class EnumType
21
+ attr_reader :graphql_name, :type, :fallback, :requires
22
+
23
+ def initialize(graphql_name, type, map: nil, fallback: nil, requires: nil)
24
+ @graphql_name = graphql_name.to_s
25
+ unless type.is_a?(Class) && type < T::Enum
26
+ raise ArgumentError, "type: must be a T::Enum subclass, got #{type.inspect}"
27
+ end
28
+ unless type.name
29
+ raise ArgumentError, "type: must be a named constant (anonymous classes can't appear in generated source)"
30
+ end
31
+
32
+ @type = type
33
+ @map = map || {}
34
+ @fallback = fallback
35
+ @requires = Array(requires)
36
+
37
+ if fallback && !type.values.include?(fallback)
38
+ raise ArgumentError, "fallback: must be a #{type} member, got #{fallback.inspect}"
39
+ end
40
+ end
41
+
42
+ # wire value => member for every value the schema declares; raises
43
+ # naming the unmappable ones (unless fallback: absorbs them)
44
+ def mapping_for(wire_values)
45
+ mapping = {}
46
+ missing = []
47
+
48
+ wire_values.each do |wire|
49
+ member = @map[wire] || infer(wire)
50
+ member ? mapping[wire] = member : missing << wire
51
+ end
52
+
53
+ if missing.any? && !fallback
54
+ raise GraphWeaver::Error,
55
+ "#{type} has no member for #{graphql_name} value(s) #{missing.join(", ")} — " \
56
+ "add them, pin with map:, or absorb with fallback:"
57
+ end
58
+
59
+ mapping
60
+ end
61
+
62
+ private
63
+
64
+ # "CAT" matches serialize "cat"; "NOT_FOUND" matches "not_found"
65
+ def infer(wire)
66
+ @type.values.find { |member| normalize(member.serialize.to_s) == normalize(wire) }
67
+ end
68
+
69
+ def normalize(value)
70
+ value.downcase.delete("_")
71
+ end
72
+ end
73
+
74
+ class << self
75
+ # Map a GraphQL enum onto an app-owned T::Enum (see EnumType); the
76
+ # global default — client.register_enum scopes to one client.
77
+ def register_enum(graphql_name, type, map: nil, fallback: nil, requires: nil)
78
+ enum_registry[graphql_name.to_s] = EnumType.new(graphql_name, type, map:, fallback:, requires:)
79
+ end
80
+
81
+ # Bulk, inference-only form: register_enums("Species" => PetKind, ...)
82
+ def register_enums(mappings)
83
+ mappings.each { |graphql_name, type| register_enum(graphql_name, type) }
84
+ end
85
+
86
+ def enum_registry
87
+ @enum_registry ||= {}
88
+ end
89
+
90
+ # Attach app-owned helper modules to every struct generated from a
91
+ # GraphQL type — the logic stays in your code, generation wires it in:
92
+ #
93
+ # GraphWeaver.register_type("Pet", PetHelpers)
94
+ #
95
+ # Or build the mixin inline — the block is module_eval'd into a fresh
96
+ # module auto-named GraphWeaver::TypeHelpers::<Type>. Handy for quick
97
+ # decoration; srb tc can't see into block-defined methods, so prefer
98
+ # a named module where static checking matters:
99
+ #
100
+ # GraphWeaver.register_type("Pet") do
101
+ # def display_name = "#{name} the pet"
102
+ # end
103
+ #
104
+ # Additive: repeated registrations (and client-scoped ones) stack.
105
+ def register_type(graphql_name, *mixins, requires: nil, &block)
106
+ entry = type_registry[graphql_name.to_s] ||= { mixins: [], requires: [] }
107
+ add_type_helpers(entry, graphql_name, mixins, requires, block)
108
+ end
109
+
110
+ def type_registry
111
+ @type_registry ||= {}
112
+ end
113
+
114
+ # shared with Client#register_type: build/validate the mixins and
115
+ # append them to a registry entry
116
+ def add_type_helpers(entry, graphql_name, mixins, requires, block)
117
+ mixins = mixins.dup
118
+ mixins << helper_module(graphql_name, block) if block
119
+
120
+ raise ArgumentError, "pass one or more helper modules, or a block" if mixins.empty?
121
+ mixins.each do |mixin|
122
+ unless mixin.is_a?(Module) && mixin.name
123
+ raise ArgumentError, "type helpers must be named modules, got #{mixin.inspect}"
124
+ end
125
+ end
126
+
127
+ entry[:mixins].concat(mixins)
128
+ entry[:requires].concat(Array(requires))
129
+ entry
130
+ end
131
+
132
+ # a block-built mixin needs a name generated files can reference:
133
+ # GraphWeaver::TypeHelpers::Pet (suffixed on re-registration)
134
+ def helper_module(graphql_name, block)
135
+ base = GraphWeaver::Inflect.camelize(graphql_name.to_s)
136
+ name = base
137
+ count = 1
138
+ name = "#{base}V#{count += 1}" while GraphWeaver::TypeHelpers.const_defined?(name, false)
139
+ GraphWeaver::TypeHelpers.const_set(name, Module.new(&block))
140
+ end
141
+ private :helper_module
142
+ end
143
+ end
144
+
145
+ module GraphWeaver
146
+ # Home of block-built type helpers (register_type with a block), which
147
+ # need constant names so generated files can reference them.
148
+ module TypeHelpers; end
149
+ end
@@ -6,8 +6,10 @@
6
6
  # cast/serialize code to emit.
7
7
  class GraphWeaver::Codegen
8
8
  class Scalar
9
- def initialize(name)
10
- @scalar = GraphWeaver::Codegen.scalar(name)
9
+ # takes a resolved ScalarType — the generator picks it from the
10
+ # client-scoped overlay or the global registry
11
+ def initialize(scalar_type)
12
+ @scalar = scalar_type
11
13
  end
12
14
 
13
15
  def bare_type
@@ -15,7 +17,9 @@ class GraphWeaver::Codegen
15
17
  end
16
18
 
17
19
  def prop_type
18
- "T.nilable(#{bare_type})"
20
+ # unregistered scalars are already T.untyped — wrapping in
21
+ # T.nilable is redundant and an srb tc error under typed: strict
22
+ bare_type == "T.untyped" ? bare_type : "T.nilable(#{bare_type})"
19
23
  end
20
24
 
21
25
  def cast(expr, _depth)
@@ -41,8 +45,13 @@ class GraphWeaver::Codegen
41
45
  def coerce(expr) = @scalar.coerce_input(expr)
42
46
  def coerce_input_type = @scalar.coerce_type
43
47
 
44
- def hash_coerce(expr, _depth) = expr
45
- def hash_coerce_identity? = true
48
+ # inside input-struct hashes, scalars coerce exactly like variable
49
+ # kwargs do — the registry (incl. GraphWeaver.auto_coerce) decides
50
+ def hash_coerce(expr, _depth)
51
+ @scalar.coerce_input(expr) || expr
52
+ end
53
+
54
+ def hash_coerce_identity? = !@scalar.coerce?
46
55
 
47
56
  def non_null? = false
48
57
  def nested = nil
@@ -71,6 +80,8 @@ class GraphWeaver::Codegen
71
80
  end
72
81
 
73
82
  class List
83
+ attr_reader :of
84
+
74
85
  def initialize(of)
75
86
  @of = of
76
87
  end
@@ -115,7 +126,7 @@ class GraphWeaver::Codegen
115
126
  inner = if @of.non_null? || @of.hash_coerce_identity?
116
127
  @of.hash_coerce_identity? ? var : @of.hash_coerce(var, depth + 1)
117
128
  else
118
- "#{var}.nil? ? nil : #{@of.hash_coerce(var, depth + 1)}"
129
+ "#{var}&.then { |v#{depth + 1}| #{@of.hash_coerce("v#{depth + 1}", depth + 2)} }"
119
130
  end
120
131
 
121
132
  "#{expr}.map { |#{var}| #{inner} }"
@@ -130,10 +141,14 @@ class GraphWeaver::Codegen
130
141
  Field = Struct.new(:prop, :key, :node)
131
142
 
132
143
  attr_reader :class_name, :fields
144
+ # the GraphQL type this struct was generated from, and any registered
145
+ # helper modules to include (see Codegen.register_type)
146
+ attr_accessor :graphql_type, :mixins
133
147
 
134
148
  def initialize(class_name)
135
149
  @class_name = class_name
136
150
  @fields = []
151
+ @mixins = []
137
152
  end
138
153
 
139
154
  def bare_type = class_name
@@ -176,18 +191,103 @@ class GraphWeaver::Codegen
176
191
  end
177
192
 
178
193
  def serialize_identity? = false
179
- def coerce? = false
180
194
 
181
- # enum fields in a plain hash accept the T::Enum or its wire value
182
- def hash_coerce(expr, _depth)
195
+ # enums always coerce: a kwarg or hash field accepts the T::Enum or
196
+ # its wire value (deserialize raises on anything else)
197
+ def coerce? = true
198
+
199
+ def coerce(expr)
183
200
  "(#{expr}.is_a?(#{class_name}) ? #{expr} : #{class_name}.deserialize(#{expr}))"
184
201
  end
185
202
 
203
+ def coerce_input_type = "T.any(#{class_name}, String)"
204
+ def hash_coerce(expr, _depth) = coerce(expr)
186
205
  def hash_coerce_identity? = false
187
206
  def non_null? = false
188
207
  def nested = self
189
208
  end
190
209
 
210
+ # A GraphQL enum mapped onto an app-owned T::Enum (see EnumType): no
211
+ # generated enum class — instead module-level <NAME>_FROM_WIRE /
212
+ # <NAME>_TO_WIRE constants translate at the boundary. fallback: makes
213
+ # casting absorb unknown wire values (inputs stay strict).
214
+ class MappedEnum
215
+ attr_reader :graphql_name, :mapping
216
+
217
+ def initialize(enum_type, wire_values)
218
+ @graphql_name = enum_type.graphql_name
219
+ @type_name = enum_type.type.name
220
+ @fallback = enum_type.fallback
221
+ @mapping = enum_type.mapping_for(wire_values)
222
+ end
223
+
224
+ def const_prefix = GraphWeaver::Inflect.underscore(@graphql_name).upcase
225
+ def fallback_const = @fallback && "#{@type_name}.deserialize(#{@fallback.serialize.to_s.inspect})"
226
+
227
+ def bare_type = @type_name
228
+
229
+ def prop_type
230
+ "T.nilable(#{bare_type})"
231
+ end
232
+
233
+ def cast(expr, _depth)
234
+ if @fallback
235
+ "#{const_prefix}_FROM_WIRE.fetch(#{expr}) { #{fallback_const} }"
236
+ else
237
+ "#{const_prefix}_FROM_WIRE.fetch(#{expr})"
238
+ end
239
+ end
240
+
241
+ def identity? = false
242
+
243
+ def serialize(expr, _depth)
244
+ "#{const_prefix}_TO_WIRE.fetch(#{expr})"
245
+ end
246
+
247
+ def serialize_identity? = false
248
+
249
+ # kwargs and hash fields accept the member or its wire value; unlike
250
+ # casting, bad input raises even with a fallback (a typo'd input is
251
+ # our bug, not server drift)
252
+ def coerce? = true
253
+
254
+ def coerce(expr)
255
+ "(#{expr}.is_a?(#{@type_name}) ? #{expr} : #{const_prefix}_FROM_WIRE.fetch(#{expr}))"
256
+ end
257
+
258
+ def coerce_input_type = "T.any(#{@type_name}, String)"
259
+ def hash_coerce(expr, _depth) = coerce(expr)
260
+ def hash_coerce_identity? = false
261
+ def non_null? = false
262
+ def nested = nil
263
+ end
264
+
265
+ # A single-condition narrowing of an abstract field (`... on Pet { ... }`
266
+ # and nothing else): the member struct when the runtime type matches,
267
+ # nil when it doesn't — a non-match's response object carries no
268
+ # matching fields, so the hash arrives empty. Always nilable, whatever
269
+ # the schema's nullability, because narrowing filters.
270
+ class NarrowedNode
271
+ def initialize(of)
272
+ @of = of
273
+ end
274
+
275
+ def class_name = @of.class_name
276
+ def bare_type = @of.bare_type
277
+
278
+ def prop_type
279
+ "T.nilable(#{bare_type})"
280
+ end
281
+
282
+ def cast(expr, depth)
283
+ "(#{expr}.empty? ? nil : #{@of.cast(expr, depth)})"
284
+ end
285
+
286
+ def identity? = false
287
+ def non_null? = false
288
+ def nested = @of
289
+ end
290
+
191
291
  class UnionNode
192
292
  attr_reader :class_name, :members # graphql type name => ObjectNode
193
293