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
@@ -22,6 +22,13 @@ class GraphWeaver::Codegen
22
22
 
23
23
  def initialize(graphql_name, type, map: nil, fallback: nil, requires: nil)
24
24
  @graphql_name = graphql_name.to_s
25
+ # A name, not the class, is what you write when the constant won't
26
+ # resolve yet — which in Rails means a config/initializers file, since
27
+ # autoloading is set up after those run. Say where it does resolve.
28
+ if type.is_a?(String)
29
+ raise ArgumentError, "type: is the T::Enum itself, not its name — " \
30
+ "register_enum(#{@graphql_name.inspect}, #{type}). #{GraphWeaver::Codegen::AUTOLOAD_HINT}"
31
+ end
25
32
  unless type.is_a?(Class) && type < T::Enum
26
33
  raise ArgumentError, "type: must be a T::Enum subclass, got #{type.inspect}"
27
34
  end
@@ -32,7 +39,7 @@ class GraphWeaver::Codegen
32
39
  @type = type
33
40
  @map = map || {}
34
41
  @fallback = fallback
35
- @requires = Array(requires)
42
+ @requires = GraphWeaver::Codegen.normalize_requires!(requires, load: true)
36
43
 
37
44
  if fallback && !type.values.include?(fallback)
38
45
  raise ArgumentError, "fallback: must be a #{type} member, got #{fallback.inspect}"
@@ -72,136 +79,30 @@ class GraphWeaver::Codegen
72
79
  end
73
80
 
74
81
  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.extend_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.extend_type("Pet") do
101
- # def display_name = "#{name} the pet"
102
- # end
103
- #
104
- # Additive: repeated registrations (and client-scoped ones) stack.
82
+ # Map a GraphQL enum onto an app-owned T::Enum (see EnumType). The one
83
+ # implementationGraphWeaver.register_enum is a delegate, so the same
84
+ # call reaches it whichever door you came in by.
105
85
  #
106
- # alias: projects a (possibly nested) selected field onto a flat, typed
107
- # accessor on the struct the one derivation codegen can type itself, so
108
- # it's emitted into the struct body where the field is in scope:
109
- #
110
- # GraphWeaver.extend_type("Widget", alias: { tag: "meta.tag" })
111
- # GraphWeaver.extend_type("Widget", alias: "meta.tag") # accessor named `tag`
112
- # GraphWeaver.extend_type("Widget", alias: ["meta.tag", "meta.color"])
113
- #
114
- # A path segment is a field, or `first`/`last` to pick one element out of a
115
- # list hop (always nilable): `alias: { entity: "_entities.first" }`.
116
- #
117
- # optional: true makes the aliases lenient — a query whose selection doesn't
118
- # fit the path just omits the accessor instead of failing generation. Use it
119
- # for a root-type accessor (a Query alias every query would otherwise have to
120
- # satisfy) or one that only fits some selections.
121
- def extend_type(graphql_name, *mixins, requires: nil, **kw, &block)
122
- aliases = take_aliases(kw)
123
- entry = type_registry[graphql_name.to_s] ||= { mixins: [], requires: [], aliases: {} }
124
- add_type_helpers(entry, graphql_name, mixins, requires, block, aliases)
125
- end
126
-
127
- # Pull alias:/optional: out of the keyword rest and normalize; any other
128
- # keyword is a typo worth flagging rather than silently dropping.
129
- def take_aliases(kw)
130
- aliases = normalize_aliases(kw.delete(:alias), optional: !!kw.delete(:optional))
131
- raise ArgumentError, "unknown keyword: #{kw.keys.first}" unless kw.empty?
132
- aliases
133
- end
134
-
135
- # accessor names and path segments are interpolated verbatim into generated
136
- # source, so — like module_name — they must be plain identifiers, never
137
- # arbitrary text that could inject code
138
- ALIAS_NAME = /\A[a-zA-Z_]\w*[?!]?\z/
139
- ALIAS_SEGMENT = /\A[a-zA-Z_]\w*\z/
140
-
141
- # { accessor => { segments:, optional: } } from a path string (accessor
142
- # named after the last segment), an array of such, or an { accessor => path }
143
- # hash. `optional:` marks every alias in this registration as lenient.
144
- def normalize_aliases(input, optional:)
145
- pairs = case input
146
- when nil then []
147
- when String then [[input.split(".").last, input.split(".")]]
148
- when Array then input.map { |path| [path.split(".").last, path.split(".")] }
149
- when Hash then input.map { |name, path| [name.to_s, path.to_s.split(".")] }
150
- else raise ArgumentError, "alias: expects a String, Array, or Hash, got #{input.class}"
86
+ # A value map is a natural third *positional* guess, and Ruby's arity
87
+ # complaint ("given 3, expected 2") never mentions the keyword.
88
+ def register_enum(graphql_name, type, positional_map = nil, map: nil, fallback: nil, requires: nil)
89
+ if positional_map
90
+ raise GraphWeaver::Error, "register_enum: the value map is a keyword — " \
91
+ "register_enum(#{graphql_name.inspect}, #{type}, map: {...})"
151
92
  end
152
- pairs.to_h do |name, segments|
153
- unless name.to_s.match?(ALIAS_NAME)
154
- raise ArgumentError, "alias name #{name.inspect} is not a valid method name"
155
- end
156
- raise ArgumentError, "alias #{name.inspect} has an empty path" if segments.empty?
157
-
158
- bad = segments.reject { |seg| seg.match?(ALIAS_SEGMENT) }
159
- raise ArgumentError, "alias #{name.inspect} has an invalid path segment: #{bad.first.inspect}" if bad.any?
160
93
 
161
- [name, { segments:, optional: }]
162
- end
163
- end
164
-
165
- def type_registry
166
- @type_registry ||= {}
94
+ enum_registry[graphql_name.to_s] = EnumType.new(graphql_name, type, map:, fallback:, requires:)
167
95
  end
168
96
 
169
- # shared with Client#extend_type: build/validate the mixins and
170
- # append them to a registry entry
171
- def add_type_helpers(entry, graphql_name, mixins, requires, block, aliases = {})
172
- mixins = mixins.dup
173
- mixins << helper_module(graphql_name, block) if block
174
-
175
- if mixins.empty? && aliases.empty?
176
- raise ArgumentError, "pass one or more helper modules, a block, or alias:"
177
- end
178
- mixins.each do |mixin|
179
- unless mixin.is_a?(Module) && mixin.name
180
- raise ArgumentError, "type helpers must be named modules, got #{mixin.inspect}"
181
- end
182
- end
183
-
184
- entry[:mixins].concat(mixins)
185
- entry[:requires].concat(Array(requires))
186
- (entry[:aliases] ||= {}).merge!(aliases)
187
- entry
97
+ def enum_registry
98
+ @enum_registry ||= {}
188
99
  end
189
100
 
190
- # a block-built mixin needs a name generated files can reference:
191
- # GraphWeaver::TypeHelpers::Pet (suffixed on re-registration)
192
- def helper_module(graphql_name, block)
193
- base = GraphWeaver::Inflect.camelize(graphql_name.to_s)
194
- name = base
195
- count = 1
196
- name = "#{base}V#{count += 1}" while GraphWeaver::TypeHelpers.const_defined?(name, false)
197
- GraphWeaver::TypeHelpers.const_set(name, Module.new(&block))
101
+ # Drop every register_enum mapping. No pair with a clear_ twin the way
102
+ # scalars have one: there are no built-in enums to restore.
103
+ def reset_enums!
104
+ enum_registry.clear
105
+ self
198
106
  end
199
- private :helper_module
200
107
  end
201
108
  end
202
-
203
- module GraphWeaver
204
- # Home of block-built type helpers (extend_type with a block), which
205
- # need constant names so generated files can reference them.
206
- module TypeHelpers; end
207
- end
@@ -56,9 +56,9 @@ class GraphWeaver::Codegen
56
56
  !@scalar.serialize?
57
57
  end
58
58
 
59
- # coercion (opt-in per scalar): accept the value or its raw input and
60
- # normalize before serializing parse for a rich type (coerce: true),
61
- # or a plain conversion for built-ins (coerce: :to_f). See ScalarType.
59
+ # coercion (coerce: per scalar, or GraphWeaver.auto_coerce for all):
60
+ # accept the value or its raw input and normalize before serializing.
61
+ # See ScalarType#coercion.
62
62
  def coerce? = @scalar.coerce?
63
63
  def coerce(expr) = @scalar.coerce_input(expr)
64
64
  def coerce_input_type = @scalar.coerce_type
@@ -127,6 +127,19 @@ class GraphWeaver::Codegen
127
127
 
128
128
  def serialize_identity? = @of.serialize_identity?
129
129
 
130
+ # A list coerces exactly as its elements do, per element: `sort: ["POPULARITY_DESC"]`
131
+ # has to accept a wire string the way `type: "ANIME"` does. Sorbet's runtime
132
+ # doesn't check element types, so without this a String reached .serialize
133
+ # and raised a NoMethodError naming neither the variable nor the enum.
134
+ def coerce? = !hash_coerce_identity?
135
+ def coerce(expr) = hash_coerce(expr, 1)
136
+
137
+ def coerce_input_type
138
+ element = @of.coerce? ? @of.coerce_input_type : @of.prop_type
139
+ element = "T.nilable(#{element})" if @of.coerce? && !@of.non_null? && element != "T.untyped"
140
+ "T::Array[#{element}]"
141
+ end
142
+
130
143
  def hash_coerce(expr, depth)
131
144
  var = "v#{depth}"
132
145
  inner = if @of.non_null? || @of.hash_coerce_identity?
@@ -246,30 +259,45 @@ class GraphWeaver::Codegen
246
259
 
247
260
  # A single-condition narrowing of an abstract field (`... on Pet { ... }`
248
261
  # and nothing else): the member struct when the runtime type matches,
249
- # nil when it doesn't a non-match's response object carries no
250
- # matching fields, so the hash arrives empty. Always nilable, whatever
251
- # the schema's nullability, because narrowing filters.
262
+ # nil when it doesn't. Always nilable, whatever the schema's nullability,
263
+ # because narrowing filters.
264
+ #
265
+ # typename: is the member's GraphQL name when the selection also carries an
266
+ # unconditional `__typename` — then the match is read off the tag. Without
267
+ # it there is nothing to read but the object's emptiness: a non-match
268
+ # carries none of the selected fields, so the hash arrives empty.
252
269
  class NarrowedNode < Node
253
- def initialize(of)
270
+ def initialize(of, typename: nil)
254
271
  @of = of
272
+ @typename = typename
255
273
  end
256
274
 
257
275
  def class_name = @of.class_name
258
276
  def bare_type = @of.bare_type
259
277
 
260
278
  def cast(expr, depth)
261
- "(#{expr}.empty? ? nil : #{@of.cast(expr, depth)})"
279
+ if @typename
280
+ "(#{expr}[\"__typename\"] == #{@typename.inspect} ? #{@of.cast(expr, depth)} : nil)"
281
+ else
282
+ "(#{expr}.empty? ? nil : #{@of.cast(expr, depth)})"
283
+ end
262
284
  end
263
285
 
264
286
  def nested = @of
265
287
  end
266
288
 
267
289
  class UnionNode < Node
268
- attr_reader :class_name, :members # graphql type name => ObjectNode
269
-
270
- def initialize(class_name, members)
290
+ # class_name is writable: fields sharing one collapsed union settle on the
291
+ # alphabetically first of their keys, which the walk may reach second
292
+ attr_accessor :class_name
293
+ attr_reader :members # graphql type name => ObjectNode
294
+ # the struct an unnamed (or newly-added) __typename deserializes into
295
+ attr_reader :catch_all
296
+
297
+ def initialize(class_name, members, catch_all = nil)
271
298
  @class_name = class_name
272
299
  @members = members
300
+ @catch_all = catch_all
273
301
  end
274
302
 
275
303
  def bare_type = "#{class_name}::Type"
@@ -281,10 +309,10 @@ class GraphWeaver::Codegen
281
309
  def nested = self
282
310
  end
283
311
 
284
- # A reference to a union hoisted into the shared unions module (a named
312
+ # A reference to a union hoisted into the shared types module (a named
285
313
  # shared fragment spread as a whole union field): the query references
286
314
  # <Name>::Type and dispatches through <Name>.from_h, where <Name> is the
287
- # alias the query module gives GraphQLUnions::<Name>. The type family lives
315
+ # alias the query module gives GraphQLTypes::<Name>. The type family lives
288
316
  # once in the shared module, so the same union across queries is one Ruby
289
317
  # type — nested is nil, nothing is emitted here.
290
318
  class UnionRefNode < Node
@@ -309,10 +337,15 @@ class GraphWeaver::Codegen
309
337
  Field = Struct.new(:prop, :wire, :node, :required)
310
338
 
311
339
  attr_reader :class_name, :fields
340
+ # @oneOf: exactly one field may be supplied. The schema can't say so — every
341
+ # @oneOf field is nullable — so the generated struct carries the flag and
342
+ # InputStruct#serialize enforces it.
343
+ attr_accessor :one_of
312
344
 
313
345
  def initialize(class_name)
314
346
  @class_name = class_name
315
347
  @fields = []
348
+ @one_of = false
316
349
  end
317
350
 
318
351
  def bare_type = class_name
@@ -332,4 +365,30 @@ class GraphWeaver::Codegen
332
365
  # building a struct field from a caller-supplied plain hash value
333
366
  def hash_coerce(expr, _depth) = "#{class_name}.coerce(#{expr})"
334
367
  end
368
+
369
+ # One entity's `Representations` builder: the typed constructor for the
370
+ # references an `_entities(representations:)` query takes. `key_sets` are
371
+ # the type's @key field sets as dotted paths ("organization.id"), in
372
+ # declaration order; `params` the union of their top-level fields, which
373
+ # is what the generated method takes as kwargs. Not part of the node
374
+ # protocol — nothing casts or serializes through it — it's a shape emit
375
+ # walks, sitting beside the result tree rather than inside it.
376
+ class RepresentationNode
377
+ # `wire` is the GraphQL field name, `value` the emitted expression that
378
+ # puts the kwarg on the wire (a registered scalar serializes here)
379
+ Param = Struct.new(:kwarg, :wire, :type, :value, :required)
380
+
381
+ attr_reader :method_name, :graphql_type, :key_fields, :key_sets, :params
382
+
383
+ # key_fields are the @key(fields:) strings as written, kept for the
384
+ # comment above the builder — "organization { id }" reads better there
385
+ # than the flattened path it becomes
386
+ def initialize(method_name, graphql_type, key_fields, key_sets, params)
387
+ @method_name = method_name
388
+ @graphql_type = graphql_type
389
+ @key_fields = key_fields
390
+ @key_sets = key_sets
391
+ @params = params
392
+ end
393
+ end
335
394
  end
@@ -4,7 +4,6 @@
4
4
  require "date"
5
5
 
6
6
  class GraphWeaver::Codegen
7
-
8
7
  # How one GraphQL scalar maps to Ruby: the Sorbet prop type, the
9
8
  # (optional) code emitted to deserialize a wire value into a rich Ruby
10
9
  # object and serialize it back, and any requires the generated file
@@ -46,14 +45,13 @@ class GraphWeaver::Codegen
46
45
  ->(type, expr) { "#{type}.dump(#{expr})" }),
47
46
  ].freeze
48
47
 
49
- # Accepted kwarg types for Symbol (instance-method) coercion the
50
- # looser inputs the conversion sensibly handles. #to_s is defined on
51
- # every object, so it accepts anything; #to_f/#to_i only make sense for
52
- # numerics and strings.
53
- CONVERT_INPUTS = {
54
- to_f: "T.any(Float, Integer, String)",
55
- to_i: "T.any(Integer, Float, String)",
56
- to_s: "T.anything",
48
+ # How a built-in converts a loose variable input, and the widened kwarg
49
+ # it then accepts. Only the numerics can: a String/ID/Boolean input is
50
+ # already its own Ruby type, so there is nothing to convert.
51
+ Conversion = Struct.new(:via, :input_type)
52
+ CONVERSIONS = {
53
+ "Int" => Conversion.new(:to_i, "T.any(Integer, Float, String)"),
54
+ "Float" => Conversion.new(:to_f, "T.any(Float, Integer, String)"),
57
55
  }.freeze
58
56
 
59
57
  attr_reader :graphql_name, :type, :requires
@@ -73,49 +71,43 @@ class GraphWeaver::Codegen
73
71
  validate_coerce!
74
72
  end
75
73
 
76
- # conversions applied to the four convertible built-ins when the
77
- # global GraphWeaver.auto_coerce is on and no explicit coerce: given
78
- AUTO_CONVERSIONS = {
79
- "ID" => :to_s, "String" => :to_s, "Int" => :to_i, "Float" => :to_f,
80
- }.freeze
81
-
82
74
  def cast(expr) = @cast&.call(expr)
83
75
  def cast? = !@cast.nil?
84
76
  def serialize(expr) = @serialize&.call(expr)
85
77
  def serialize? = !@serialize.nil?
86
- def coerce? = !!effective_coerce
78
+ def coerce? = !!coercion
87
79
 
88
- # Explicit coerce: always wins (false means never). Left unset, the
89
- # global GraphWeaver.auto_coerce decides resolved HERE, at
90
- # generation time, so registration order doesn't matter: convertible
91
- # built-ins get their conversion, anything with a full cast/serialize
92
- # pair gets parse-style coercion.
93
- def effective_coerce
94
- return @coerce unless @coerce.nil?
95
- return false unless GraphWeaver.auto_coerce
80
+ # How this scalar coerces a variable input, or nil for not at all.
81
+ # coerce: says WHETHER (explicit always wins); left unset the global
82
+ # GraphWeaver.auto_coerce decides resolved HERE, at generation time,
83
+ # so registration order doesn't matter. The scalar itself says HOW: a
84
+ # convertible built-in converts, anything with a full cast/serialize
85
+ # pair parses. Nothing left to try means it can't coerce.
86
+ def coercion
87
+ return if @coerce == false
88
+ return if @coerce.nil? && !GraphWeaver.auto_coerce
96
89
 
97
- AUTO_CONVERSIONS.fetch(@graphql_name) { (cast? && serialize?) || nil }
90
+ CONVERSIONS[@graphql_name] || (:parse if cast? && serialize?)
98
91
  end
99
92
 
100
- # The code that normalizes a variable input before it's serialized. Two
101
- # shapes: coerce: true parses a raw value into the rich type via the cast
102
- # (guarded so an already-typed value passes through); coerce: :to_f (a
103
- # Symbol) calls that instance method, for built-ins where a plain
104
- # conversion is the whole story (5, "5" -> 5.0). serialize still runs
105
- # afterward, but is identity for the conversion built-ins, so the
106
- # converted value goes on the wire natively (a Float, not "5.0").
93
+ # The code that normalizes a variable input before it's serialized.
94
+ # Parsing runs a raw value through the cast, guarded so an already-typed
95
+ # value passes through; a conversion just calls the method (5, "5" ->
96
+ # 5.0). serialize still runs afterward, but is identity for the
97
+ # convertible built-ins, so the converted value goes on the wire
98
+ # natively (a Float, not "5.0").
107
99
  def coerce_input(expr)
108
- case effective_coerce
109
- when true then "(#{expr}.is_a?(#{@type}) ? #{expr} : #{cast(expr)})"
110
- when Symbol then "#{expr}.#{effective_coerce}"
100
+ case (how = coercion)
101
+ when :parse then "(#{expr}.is_a?(#{@type}) ? #{expr} : #{cast(expr)})"
102
+ when Conversion then "#{expr}.#{how.via}"
111
103
  end
112
104
  end
113
105
 
114
106
  # the accepted Sorbet type for a coercible variable kwarg
115
107
  def coerce_type
116
- case effective_coerce
117
- when true then "T.any(#{@type}, String)"
118
- when Symbol then CONVERT_INPUTS.fetch(effective_coerce, "T.untyped")
108
+ case (how = coercion)
109
+ when :parse then "T.any(#{@type}, String)"
110
+ when Conversion then how.input_type
119
111
  end
120
112
  end
121
113
 
@@ -156,20 +148,44 @@ class GraphWeaver::Codegen
156
148
  end
157
149
  end
158
150
 
159
- # requires: is a require path or list of them; each must be a non-empty
160
- # String (it is emitted verbatim as `require "..."`), caught here rather
161
- # than as a syntax error in the generated file. When a real class was
162
- # given as type:, we're in a runtime with its deps loaded, so we also
163
- # `require` each path to prove it resolves (a no-op for already-loaded
164
- # libs, and it surfaces a typo now). With only a type-name string we
165
- # can't assume the lib is installed at codegen time, so we don't try.
151
+ # With only a type-name string we can't assume the lib is installed at
152
+ # codegen time, so the paths aren't loaded only shape-checked.
166
153
  def normalize_requires(requires)
154
+ GraphWeaver::Codegen.normalize_requires!(requires, load: !@klass.nil?)
155
+ end
156
+
157
+ # coerce: true asks for something the scalar has to know how to do, so
158
+ # refuse a pass-through one now rather than emit a silent no-op.
159
+ def validate_coerce!
160
+ case @coerce
161
+ when false, nil then nil
162
+ when true
163
+ return if coercion
164
+
165
+ raise ArgumentError,
166
+ "coerce: true needs a cast and a serialize (#{@graphql_name} has neither, " \
167
+ "so there is nothing to coerce)"
168
+ else
169
+ raise ArgumentError, "coerce: must be true or false, got #{@coerce.inspect}"
170
+ end
171
+ end
172
+ end
173
+
174
+ class << self
175
+ # requires: is a require path or list of them; each must be a non-empty
176
+ # String (it is emitted verbatim as `require "..."` atop the generated
177
+ # file), caught here rather than as a syntax error in the generated file.
178
+ # load: when the registration handed us live constants — a class, a T::Enum,
179
+ # a helper module — we're in a runtime with its deps loaded, so each path is
180
+ # required to prove it resolves: a typo fails now, not in the generated file
181
+ # (a no-op for already-loaded libs).
182
+ def normalize_requires!(requires, load:)
167
183
  Array(requires).each do |req|
168
184
  unless req.is_a?(String) && !req.empty?
169
185
  raise ArgumentError, "requires: must be a String or Array of Strings, got #{req.inspect}"
170
186
  end
171
187
 
172
- next unless @klass
188
+ next unless load
173
189
 
174
190
  begin
175
191
  require req
@@ -179,23 +195,6 @@ class GraphWeaver::Codegen
179
195
  end
180
196
  end
181
197
 
182
- # coerce: true round-trips through cast+serialize, so it needs both; a
183
- # Symbol is a self-contained conversion and needs neither.
184
- def validate_coerce!
185
- case @coerce
186
- when false, nil, Symbol then nil
187
- when true
188
- return if cast? && serialize?
189
-
190
- raise ArgumentError,
191
- "coerce: true needs both a cast and a serialize (#{@graphql_name} is missing one)"
192
- else
193
- raise ArgumentError, "coerce: must be true, false, or a Symbol method name, got #{@coerce.inspect}"
194
- end
195
- end
196
- end
197
-
198
- class << self
199
198
  # Register (or override) how a GraphQL custom scalar deserializes into
200
199
  # a Ruby object and serializes back onto the wire. See ScalarType for
201
200
  # the accepted cast:/serialize:/requires: forms. Later registrations
@@ -242,9 +241,8 @@ class GraphWeaver::Codegen
242
241
  # matches nothing and leaves them identity — which is exactly why we
243
242
  # can name them with the real class constants. Date deserializes via
244
243
  # ISO-8601 (it *does* define .parse, but we want iso8601 specifically,
245
- # so it's explicit). Input coercion is a generation-time concern:
246
- # GraphWeaver.auto_coerce gives the convertible built-ins their
247
- # conversion (see ScalarType::AUTO_CONVERSIONS).
244
+ # so it's explicit). Whether a variable of one accepts loose input is a
245
+ # separate, generation-time question see coercion.
248
246
  def register_builtin_scalars!
249
247
  register_scalar "ID", String
250
248
  register_scalar "String", String
@@ -256,4 +254,9 @@ class GraphWeaver::Codegen
256
254
  end
257
255
 
258
256
  register_builtin_scalars!
257
+
258
+ # Pre-registered rather than user intent, so generation doesn't hold a schema
259
+ # to them (validate_registration! skips these). Read off the registry the line
260
+ # above just filled: a seventh built-in shouldn't have to be named twice.
261
+ BUILTIN_SCALARS = scalar_registry.keys.freeze
259
262
  end
@@ -0,0 +1,140 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ # What `extend_type` registers, and where its block-built mixins land.
5
+ #
6
+ # Three things ride on one call — helper modules mixed into every struct
7
+ # generated from a GraphQL type, the requires those modules need, and
8
+ # alias: paths that flatten a nested selection onto a typed accessor. They
9
+ # share a registry entry because they share a registration, and codegen
10
+ # reads all three off the same type name (see Codegen::Aliases for how a
11
+ # path is resolved against an actual selection).
12
+
13
+ class GraphWeaver::Codegen
14
+ class << self
15
+ # Attach app-owned helper modules to every struct generated from a
16
+ # GraphQL type — the logic stays in your code, generation wires it in:
17
+ #
18
+ # GraphWeaver.extend_type("Pet", PetHelpers)
19
+ #
20
+ # Or build the mixin inline — the block is module_eval'd into a fresh
21
+ # module auto-named GraphWeaver::TypeHelpers::<Type>. Handy for quick
22
+ # decoration; srb tc can't see into block-defined methods, so prefer
23
+ # a named module where static checking matters:
24
+ #
25
+ # GraphWeaver.extend_type("Pet") do
26
+ # def display_name = "#{name} the pet"
27
+ # end
28
+ #
29
+ # Additive: repeated registrations (and client-scoped ones) stack.
30
+ #
31
+ # alias: projects a (possibly nested) selected field onto a flat, typed
32
+ # accessor on the struct — the one derivation codegen can type itself, so
33
+ # it's emitted into the struct body where the field is in scope:
34
+ #
35
+ # GraphWeaver.extend_type("Widget", alias: { tag: "meta.tag" })
36
+ # GraphWeaver.extend_type("Widget", alias: "meta.tag") # accessor named `tag`
37
+ # GraphWeaver.extend_type("Widget", alias: ["meta.tag", "meta.color"])
38
+ #
39
+ # A path segment is a field, or `first`/`last` to pick one element out of a
40
+ # list hop (always nilable): `alias: { entity: "_entities.first" }`.
41
+ #
42
+ # optional: true makes the aliases lenient — a query whose selection doesn't
43
+ # fit the path just omits the accessor instead of failing generation. Use it
44
+ # for a root-type accessor (a Query alias every query would otherwise have to
45
+ # satisfy) or one that only fits some selections.
46
+ def extend_type(graphql_name, *mixins, requires: nil, **kw, &block)
47
+ aliases = take_aliases(kw)
48
+ mixins = mixins.dup
49
+ mixins << helper_module(graphql_name, block) if block
50
+
51
+ raise ArgumentError, "pass one or more helper modules, a block, or alias:" if mixins.empty? && aliases.empty?
52
+ mixins.each do |mixin|
53
+ # a name rather than the module is what you write when the constant
54
+ # won't resolve yet — see EnumType for why that's a Rails initializer
55
+ if mixin.is_a?(String)
56
+ raise ArgumentError, "type helpers are the modules themselves, not their names — " \
57
+ "extend_type(#{graphql_name.to_s.inspect}, #{mixin}). #{GraphWeaver::Codegen::AUTOLOAD_HINT}"
58
+ end
59
+ unless mixin.is_a?(Module) && mixin.name
60
+ raise ArgumentError, "type helpers must be named modules, got #{mixin.inspect}"
61
+ end
62
+ end
63
+
64
+ entry = type_registry[graphql_name.to_s] ||= { mixins: [], requires: [], aliases: {} }
65
+ entry[:mixins].concat(mixins)
66
+ entry[:requires].concat(GraphWeaver::Codegen.normalize_requires!(requires, load: true))
67
+ entry[:aliases].merge!(aliases)
68
+ entry
69
+ end
70
+
71
+ # Pull alias:/optional: out of the keyword rest and normalize; any other
72
+ # keyword is a typo worth flagging rather than silently dropping.
73
+ def take_aliases(kw)
74
+ aliases = normalize_aliases(kw.delete(:alias), optional: !!kw.delete(:optional))
75
+ raise ArgumentError, "unknown keyword: #{kw.keys.first}" unless kw.empty?
76
+ aliases
77
+ end
78
+ private :take_aliases
79
+
80
+ # accessor names and path segments are interpolated verbatim into generated
81
+ # source, so — like module_name — they must be plain identifiers, never
82
+ # arbitrary text that could inject code
83
+ ALIAS_NAME = /\A[a-zA-Z_]\w*[?!]?\z/
84
+ ALIAS_SEGMENT = /\A[a-zA-Z_]\w*\z/
85
+
86
+ # { accessor => { segments:, optional: } } from a path string (accessor
87
+ # named after the last segment), an array of such, or an { accessor => path }
88
+ # hash. `optional:` marks every alias in this registration as lenient.
89
+ def normalize_aliases(input, optional:)
90
+ pairs = case input
91
+ when nil then []
92
+ when String then [[input.split(".").last, input.split(".")]]
93
+ when Array then input.map { |path| [path.split(".").last, path.split(".")] }
94
+ when Hash then input.map { |name, path| [name.to_s, path.to_s.split(".")] }
95
+ else raise ArgumentError, "alias: expects a String, Array, or Hash, got #{input.class}"
96
+ end
97
+ pairs.to_h do |name, segments|
98
+ unless name.to_s.match?(ALIAS_NAME)
99
+ raise ArgumentError, "alias name #{name.inspect} is not a valid method name"
100
+ end
101
+ raise ArgumentError, "alias #{name.inspect} has an empty path" if segments.empty?
102
+
103
+ bad = segments.reject { |seg| seg.match?(ALIAS_SEGMENT) }
104
+ raise ArgumentError, "alias #{name.inspect} has an invalid path segment: #{bad.first.inspect}" if bad.any?
105
+
106
+ [name, { segments:, optional: }]
107
+ end
108
+ end
109
+ private :normalize_aliases
110
+
111
+ def type_registry
112
+ @type_registry ||= {}
113
+ end
114
+
115
+ # Drop every extend_type registration (mixins, requires, alias: paths).
116
+ # The block-built mixin constants under GraphWeaver::TypeHelpers stay —
117
+ # generated files may still name them.
118
+ def reset_type_helpers!
119
+ type_registry.clear
120
+ self
121
+ end
122
+
123
+ # a block-built mixin needs a name generated files can reference:
124
+ # GraphWeaver::TypeHelpers::Pet (suffixed on re-registration)
125
+ def helper_module(graphql_name, block)
126
+ base = GraphWeaver::Inflect.camelize(graphql_name.to_s)
127
+ name = base
128
+ count = 1
129
+ name = "#{base}V#{count += 1}" while GraphWeaver::TypeHelpers.const_defined?(name, false)
130
+ GraphWeaver::TypeHelpers.const_set(name, Module.new(&block))
131
+ end
132
+ private :helper_module
133
+ end
134
+ end
135
+
136
+ module GraphWeaver
137
+ # Home of block-built type helpers (extend_type with a block), which
138
+ # need constant names so generated files can reference them.
139
+ module TypeHelpers; end
140
+ end