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
@@ -22,6 +22,15 @@ 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}). An autoloaded constant isn't " \
31
+ "resolvable while config/initializers run; register from a " \
32
+ "Rails.application.config.to_prepare block, which generation also runs first."
33
+ end
25
34
  unless type.is_a?(Class) && type < T::Enum
26
35
  raise ArgumentError, "type: must be a T::Enum subclass, got #{type.inspect}"
27
36
  end
@@ -32,7 +41,7 @@ class GraphWeaver::Codegen
32
41
  @type = type
33
42
  @map = map || {}
34
43
  @fallback = fallback
35
- @requires = Array(requires)
44
+ @requires = GraphWeaver::Codegen.normalize_requires!(requires, load: true)
36
45
 
37
46
  if fallback && !type.values.include?(fallback)
38
47
  raise ArgumentError, "fallback: must be a #{type} member, got #{fallback.inspect}"
@@ -72,110 +81,30 @@ class GraphWeaver::Codegen
72
81
  end
73
82
 
74
83
  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.
105
- #
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:
84
+ # Map a GraphQL enum onto an app-owned T::Enum (see EnumType). The one
85
+ # implementationGraphWeaver.register_enum is a delegate, so the same
86
+ # call reaches it whichever door you came in by.
109
87
  #
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
- def extend_type(graphql_name, *mixins, requires: nil, **kw, &block)
114
- aliases = take_aliases(kw)
115
- entry = type_registry[graphql_name.to_s] ||= { mixins: [], requires: [], aliases: {} }
116
- add_type_helpers(entry, graphql_name, mixins, requires, block, aliases)
117
- end
118
-
119
- # Pull alias: out of the keyword rest and normalize it; any other keyword
120
- # is a typo worth flagging rather than silently dropping.
121
- def take_aliases(kw)
122
- aliases = normalize_aliases(kw.delete(:alias))
123
- raise ArgumentError, "unknown keyword: #{kw.keys.first}" unless kw.empty?
124
- aliases
125
- end
126
-
127
- # { accessor => [path, segments] } from a path string (accessor named
128
- # after the last segment), an array of such, or an { accessor => path } hash.
129
- def normalize_aliases(input)
130
- case input
131
- when nil then {}
132
- when String then { input.split(".").last => input.split(".") }
133
- when Array then input.to_h { |path| [path.split(".").last, path.split(".")] }
134
- when Hash then input.to_h { |name, path| [name.to_s, path.to_s.split(".")] }
135
- else raise ArgumentError, "alias: expects a String, Array, or Hash, got #{input.class}"
88
+ # A value map is a natural third *positional* guess, and Ruby's arity
89
+ # complaint ("given 3, expected 2") never mentions the keyword.
90
+ def register_enum(graphql_name, type, positional_map = nil, map: nil, fallback: nil, requires: nil)
91
+ if positional_map
92
+ raise GraphWeaver::Error, "register_enum: the value map is a keyword — " \
93
+ "register_enum(#{graphql_name.inspect}, #{type}, map: {...})"
136
94
  end
137
- end
138
95
 
139
- def type_registry
140
- @type_registry ||= {}
96
+ enum_registry[graphql_name.to_s] = EnumType.new(graphql_name, type, map:, fallback:, requires:)
141
97
  end
142
98
 
143
- # shared with Client#extend_type: build/validate the mixins and
144
- # append them to a registry entry
145
- def add_type_helpers(entry, graphql_name, mixins, requires, block, aliases = {})
146
- mixins = mixins.dup
147
- mixins << helper_module(graphql_name, block) if block
148
-
149
- if mixins.empty? && aliases.empty?
150
- raise ArgumentError, "pass one or more helper modules, a block, or alias:"
151
- end
152
- mixins.each do |mixin|
153
- unless mixin.is_a?(Module) && mixin.name
154
- raise ArgumentError, "type helpers must be named modules, got #{mixin.inspect}"
155
- end
156
- end
157
-
158
- entry[:mixins].concat(mixins)
159
- entry[:requires].concat(Array(requires))
160
- (entry[:aliases] ||= {}).merge!(aliases)
161
- entry
99
+ def enum_registry
100
+ @enum_registry ||= {}
162
101
  end
163
102
 
164
- # a block-built mixin needs a name generated files can reference:
165
- # GraphWeaver::TypeHelpers::Pet (suffixed on re-registration)
166
- def helper_module(graphql_name, block)
167
- base = GraphWeaver::Inflect.camelize(graphql_name.to_s)
168
- name = base
169
- count = 1
170
- name = "#{base}V#{count += 1}" while GraphWeaver::TypeHelpers.const_defined?(name, false)
171
- GraphWeaver::TypeHelpers.const_set(name, Module.new(&block))
103
+ # Drop every register_enum mapping. No pair with a clear_ twin the way
104
+ # scalars have one: there are no built-in enums to restore.
105
+ def reset_enums!
106
+ enum_registry.clear
107
+ self
172
108
  end
173
- private :helper_module
174
109
  end
175
110
  end
176
-
177
- module GraphWeaver
178
- # Home of block-built type helpers (extend_type with a block), which
179
- # need constant names so generated files can reference them.
180
- module TypeHelpers; end
181
- 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
 
@@ -123,7 +115,10 @@ class GraphWeaver::Codegen
123
115
 
124
116
  def type_name(type)
125
117
  case type
126
- when Module then type.name
118
+ when Module
119
+ # an anonymous class has no name to emit — it would land as a literal
120
+ # `nil` in generated source
121
+ type.name || raise(ArgumentError, "type: must be a named class/module, got an anonymous one")
127
122
  when String then type
128
123
  else raise ArgumentError, "type: must be a class/module or String, got #{type.inspect}"
129
124
  end
@@ -153,20 +148,48 @@ class GraphWeaver::Codegen
153
148
  end
154
149
  end
155
150
 
156
- # requires: is a require path or list of them; each must be a non-empty
157
- # String (it is emitted verbatim as `require "..."`), caught here rather
158
- # than as a syntax error in the generated file. When a real class was
159
- # given as type:, we're in a runtime with its deps loaded, so we also
160
- # `require` each path to prove it resolves (a no-op for already-loaded
161
- # libs, and it surfaces a typo now). With only a type-name string we
162
- # 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.
163
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
+ # Pre-registered rather than user intent (see register_builtin_scalars!), so
175
+ # generation doesn't hold a schema to them.
176
+ BUILTIN_SCALARS = %w[ID String Int Float Boolean Date].freeze
177
+
178
+ class << self
179
+ # requires: is a require path or list of them; each must be a non-empty
180
+ # String (it is emitted verbatim as `require "..."` atop the generated
181
+ # file), caught here rather than as a syntax error in the generated file.
182
+ # load: when the registration handed us live constants — a class, a T::Enum,
183
+ # a helper module — we're in a runtime with its deps loaded, so each path is
184
+ # required to prove it resolves: a typo fails now, not in the generated file
185
+ # (a no-op for already-loaded libs).
186
+ def normalize_requires!(requires, load:)
164
187
  Array(requires).each do |req|
165
188
  unless req.is_a?(String) && !req.empty?
166
189
  raise ArgumentError, "requires: must be a String or Array of Strings, got #{req.inspect}"
167
190
  end
168
191
 
169
- next unless @klass
192
+ next unless load
170
193
 
171
194
  begin
172
195
  require req
@@ -176,23 +199,6 @@ class GraphWeaver::Codegen
176
199
  end
177
200
  end
178
201
 
179
- # coerce: true round-trips through cast+serialize, so it needs both; a
180
- # Symbol is a self-contained conversion and needs neither.
181
- def validate_coerce!
182
- case @coerce
183
- when false, nil, Symbol then nil
184
- when true
185
- return if cast? && serialize?
186
-
187
- raise ArgumentError,
188
- "coerce: true needs both a cast and a serialize (#{@graphql_name} is missing one)"
189
- else
190
- raise ArgumentError, "coerce: must be true, false, or a Symbol method name, got #{@coerce.inspect}"
191
- end
192
- end
193
- end
194
-
195
- class << self
196
202
  # Register (or override) how a GraphQL custom scalar deserializes into
197
203
  # a Ruby object and serializes back onto the wire. See ScalarType for
198
204
  # the accepted cast:/serialize:/requires: forms. Later registrations
@@ -239,9 +245,8 @@ class GraphWeaver::Codegen
239
245
  # matches nothing and leaves them identity — which is exactly why we
240
246
  # can name them with the real class constants. Date deserializes via
241
247
  # ISO-8601 (it *does* define .parse, but we want iso8601 specifically,
242
- # so it's explicit). Input coercion is a generation-time concern:
243
- # GraphWeaver.auto_coerce gives the convertible built-ins their
244
- # conversion (see ScalarType::AUTO_CONVERSIONS).
248
+ # so it's explicit). Whether a variable of one accepts loose input is a
249
+ # separate, generation-time question see coercion.
245
250
  def register_builtin_scalars!
246
251
  register_scalar "ID", String
247
252
  register_scalar "String", String
@@ -0,0 +1,142 @@
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}). An autoloaded constant isn't " \
58
+ "resolvable while config/initializers run; register from a " \
59
+ "Rails.application.config.to_prepare block, which generation also runs first."
60
+ end
61
+ unless mixin.is_a?(Module) && mixin.name
62
+ raise ArgumentError, "type helpers must be named modules, got #{mixin.inspect}"
63
+ end
64
+ end
65
+
66
+ entry = type_registry[graphql_name.to_s] ||= { mixins: [], requires: [], aliases: {} }
67
+ entry[:mixins].concat(mixins)
68
+ entry[:requires].concat(GraphWeaver::Codegen.normalize_requires!(requires, load: true))
69
+ entry[:aliases].merge!(aliases)
70
+ entry
71
+ end
72
+
73
+ # Pull alias:/optional: out of the keyword rest and normalize; any other
74
+ # keyword is a typo worth flagging rather than silently dropping.
75
+ def take_aliases(kw)
76
+ aliases = normalize_aliases(kw.delete(:alias), optional: !!kw.delete(:optional))
77
+ raise ArgumentError, "unknown keyword: #{kw.keys.first}" unless kw.empty?
78
+ aliases
79
+ end
80
+ private :take_aliases
81
+
82
+ # accessor names and path segments are interpolated verbatim into generated
83
+ # source, so — like module_name — they must be plain identifiers, never
84
+ # arbitrary text that could inject code
85
+ ALIAS_NAME = /\A[a-zA-Z_]\w*[?!]?\z/
86
+ ALIAS_SEGMENT = /\A[a-zA-Z_]\w*\z/
87
+
88
+ # { accessor => { segments:, optional: } } from a path string (accessor
89
+ # named after the last segment), an array of such, or an { accessor => path }
90
+ # hash. `optional:` marks every alias in this registration as lenient.
91
+ def normalize_aliases(input, optional:)
92
+ pairs = case input
93
+ when nil then []
94
+ when String then [[input.split(".").last, input.split(".")]]
95
+ when Array then input.map { |path| [path.split(".").last, path.split(".")] }
96
+ when Hash then input.map { |name, path| [name.to_s, path.to_s.split(".")] }
97
+ else raise ArgumentError, "alias: expects a String, Array, or Hash, got #{input.class}"
98
+ end
99
+ pairs.to_h do |name, segments|
100
+ unless name.to_s.match?(ALIAS_NAME)
101
+ raise ArgumentError, "alias name #{name.inspect} is not a valid method name"
102
+ end
103
+ raise ArgumentError, "alias #{name.inspect} has an empty path" if segments.empty?
104
+
105
+ bad = segments.reject { |seg| seg.match?(ALIAS_SEGMENT) }
106
+ raise ArgumentError, "alias #{name.inspect} has an invalid path segment: #{bad.first.inspect}" if bad.any?
107
+
108
+ [name, { segments:, optional: }]
109
+ end
110
+ end
111
+ private :normalize_aliases
112
+
113
+ def type_registry
114
+ @type_registry ||= {}
115
+ end
116
+
117
+ # Drop every extend_type registration (mixins, requires, alias: paths).
118
+ # The block-built mixin constants under GraphWeaver::TypeHelpers stay —
119
+ # generated files may still name them.
120
+ def reset_type_helpers!
121
+ type_registry.clear
122
+ self
123
+ end
124
+
125
+ # a block-built mixin needs a name generated files can reference:
126
+ # GraphWeaver::TypeHelpers::Pet (suffixed on re-registration)
127
+ def helper_module(graphql_name, block)
128
+ base = GraphWeaver::Inflect.camelize(graphql_name.to_s)
129
+ name = base
130
+ count = 1
131
+ name = "#{base}V#{count += 1}" while GraphWeaver::TypeHelpers.const_defined?(name, false)
132
+ GraphWeaver::TypeHelpers.const_set(name, Module.new(&block))
133
+ end
134
+ private :helper_module
135
+ end
136
+ end
137
+
138
+ module GraphWeaver
139
+ # Home of block-built type helpers (extend_type with a block), which
140
+ # need constant names so generated files can reference them.
141
+ module TypeHelpers; end
142
+ end