graph_weaver 0.0.1 → 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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +6 -0
  3. data/CHANGELOG.md +279 -0
  4. data/Gemfile.lock +75 -17
  5. data/Makefile +11 -1
  6. data/NOTES.md +1 -1
  7. data/PLAN.md +85 -22
  8. data/README.md +86 -26
  9. data/docs/cassettes.md +76 -0
  10. data/docs/errors.md +134 -0
  11. data/docs/generated_modules.md +229 -0
  12. data/docs/getting_started.md +134 -0
  13. data/docs/logging.md +33 -0
  14. data/docs/real_world.md +53 -0
  15. data/docs/scalars.md +183 -0
  16. data/docs/testing.md +103 -0
  17. data/docs/transports.md +124 -0
  18. data/graph_weaver.gemspec +10 -1
  19. data/lib/graph_weaver/client.rb +200 -0
  20. data/lib/graph_weaver/codegen/emit.rb +286 -0
  21. data/lib/graph_weaver/codegen/enum_type.rb +149 -0
  22. data/lib/graph_weaver/codegen/nodes.rb +356 -0
  23. data/lib/graph_weaver/codegen/scalar_type.rb +256 -0
  24. data/lib/graph_weaver/codegen.rb +396 -401
  25. data/lib/graph_weaver/errors.rb +322 -0
  26. data/lib/graph_weaver/hints.rb +63 -0
  27. data/lib/graph_weaver/inflect.rb +19 -0
  28. data/lib/graph_weaver/logging.rb +41 -0
  29. data/lib/graph_weaver/railtie.rb +29 -0
  30. data/lib/graph_weaver/response.rb +55 -0
  31. data/lib/graph_weaver/retry.rb +97 -0
  32. data/lib/graph_weaver/rspec.rb +59 -0
  33. data/lib/graph_weaver/schema_loader.rb +208 -8
  34. data/lib/graph_weaver/selection.rb +68 -0
  35. data/lib/graph_weaver/tasks.rb +71 -0
  36. data/lib/graph_weaver/testing/cassette.rb +224 -0
  37. data/lib/graph_weaver/testing/failure.rb +109 -0
  38. data/lib/graph_weaver/testing/fake_client.rb +228 -0
  39. data/lib/graph_weaver/testing/values.rb +98 -0
  40. data/lib/graph_weaver/testing.rb +106 -0
  41. data/lib/graph_weaver/transport/faraday.rb +56 -0
  42. data/lib/graph_weaver/transport/http.rb +87 -0
  43. data/lib/graph_weaver/transport.rb +120 -0
  44. data/lib/graph_weaver/version.rb +1 -1
  45. data/lib/graph_weaver.rb +268 -4
  46. metadata +132 -2
  47. data/lib/graph_weaver/http_executor.rb +0 -31
@@ -0,0 +1,356 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ # The typed intermediate representation of a query selection: one node
5
+ # per GraphQL type shape, each knowing its Sorbet prop type and the
6
+ # cast/serialize code to emit.
7
+ class GraphWeaver::Codegen
8
+ class Scalar
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
13
+ end
14
+
15
+ def bare_type
16
+ @scalar.type
17
+ end
18
+
19
+ def prop_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})"
23
+ end
24
+
25
+ def cast(expr, _depth)
26
+ @scalar.cast(expr)
27
+ end
28
+
29
+ def identity?
30
+ !@scalar.cast?
31
+ end
32
+
33
+ def serialize(expr, _depth)
34
+ @scalar.serialize(expr)
35
+ end
36
+
37
+ def serialize_identity?
38
+ !@scalar.serialize?
39
+ end
40
+
41
+ # coercion (opt-in per scalar): accept the value or its raw input and
42
+ # normalize before serializing — parse for a rich type (coerce: true),
43
+ # or a plain conversion for built-ins (coerce: :to_f). See ScalarType.
44
+ def coerce? = @scalar.coerce?
45
+ def coerce(expr) = @scalar.coerce_input(expr)
46
+ def coerce_input_type = @scalar.coerce_type
47
+
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?
55
+
56
+ def non_null? = false
57
+ def nested = nil
58
+ end
59
+
60
+ class NonNull
61
+ attr_reader :of
62
+
63
+ def initialize(of)
64
+ @of = of
65
+ end
66
+
67
+ def bare_type = @of.bare_type
68
+ def prop_type = bare_type
69
+ def cast(expr, depth) = @of.cast(expr, depth)
70
+ def identity? = @of.identity?
71
+ def serialize(expr, depth) = @of.serialize(expr, depth)
72
+ def serialize_identity? = @of.serialize_identity?
73
+ def coerce? = @of.coerce?
74
+ def coerce(expr) = @of.coerce(expr)
75
+ def coerce_input_type = @of.coerce_input_type
76
+ def hash_coerce(expr, depth) = @of.hash_coerce(expr, depth)
77
+ def hash_coerce_identity? = @of.hash_coerce_identity?
78
+ def non_null? = true
79
+ def nested = @of.nested
80
+ end
81
+
82
+ class List
83
+ attr_reader :of
84
+
85
+ def initialize(of)
86
+ @of = of
87
+ end
88
+
89
+ def bare_type
90
+ "T::Array[#{@of.prop_type}]"
91
+ end
92
+
93
+ def prop_type
94
+ "T.nilable(#{bare_type})"
95
+ end
96
+
97
+ def cast(expr, depth)
98
+ var = "v#{depth}"
99
+ element = if @of.non_null? || @of.identity?
100
+ @of.identity? ? var : @of.cast(var, depth + 1)
101
+ else
102
+ "#{var}&.then { |v#{depth + 1}| #{@of.cast("v#{depth + 1}", depth + 2)} }"
103
+ end
104
+
105
+ "#{expr}.map { |#{var}| #{element} }"
106
+ end
107
+
108
+ def identity? = @of.identity?
109
+
110
+ def serialize(expr, depth)
111
+ var = "v#{depth}"
112
+ element = if @of.non_null? || @of.serialize_identity?
113
+ @of.serialize_identity? ? var : @of.serialize(var, depth + 1)
114
+ else
115
+ "#{var}&.then { |v#{depth + 1}| #{@of.serialize("v#{depth + 1}", depth + 2)} }"
116
+ end
117
+
118
+ "#{expr}.map { |#{var}| #{element} }"
119
+ end
120
+
121
+ def serialize_identity? = @of.serialize_identity?
122
+ def coerce? = false
123
+
124
+ def hash_coerce(expr, depth)
125
+ var = "v#{depth}"
126
+ inner = if @of.non_null? || @of.hash_coerce_identity?
127
+ @of.hash_coerce_identity? ? var : @of.hash_coerce(var, depth + 1)
128
+ else
129
+ "#{var}&.then { |v#{depth + 1}| #{@of.hash_coerce("v#{depth + 1}", depth + 2)} }"
130
+ end
131
+
132
+ "#{expr}.map { |#{var}| #{inner} }"
133
+ end
134
+
135
+ def hash_coerce_identity? = @of.hash_coerce_identity?
136
+ def non_null? = false
137
+ def nested = @of.nested
138
+ end
139
+
140
+ class ObjectNode
141
+ Field = Struct.new(:prop, :key, :node)
142
+
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
147
+
148
+ def initialize(class_name)
149
+ @class_name = class_name
150
+ @fields = []
151
+ @mixins = []
152
+ end
153
+
154
+ def bare_type = class_name
155
+
156
+ def prop_type
157
+ "T.nilable(#{bare_type})"
158
+ end
159
+
160
+ def cast(expr, _depth)
161
+ "#{class_name}.from_h(#{expr})"
162
+ end
163
+
164
+ def identity? = false
165
+ def non_null? = false
166
+ def nested = self
167
+ end
168
+
169
+ class EnumNode
170
+ attr_reader :class_name, :values
171
+
172
+ def initialize(class_name, values)
173
+ @class_name = class_name
174
+ @values = values
175
+ end
176
+
177
+ def bare_type = class_name
178
+
179
+ def prop_type
180
+ "T.nilable(#{bare_type})"
181
+ end
182
+
183
+ def cast(expr, _depth)
184
+ "#{class_name}.deserialize(#{expr})"
185
+ end
186
+
187
+ def identity? = false
188
+
189
+ def serialize(expr, _depth)
190
+ "#{expr}.serialize"
191
+ end
192
+
193
+ def serialize_identity? = false
194
+
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)
200
+ "(#{expr}.is_a?(#{class_name}) ? #{expr} : #{class_name}.deserialize(#{expr}))"
201
+ end
202
+
203
+ def coerce_input_type = "T.any(#{class_name}, String)"
204
+ def hash_coerce(expr, _depth) = coerce(expr)
205
+ def hash_coerce_identity? = false
206
+ def non_null? = false
207
+ def nested = self
208
+ end
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
+
291
+ class UnionNode
292
+ attr_reader :class_name, :members # graphql type name => ObjectNode
293
+
294
+ def initialize(class_name, members)
295
+ @class_name = class_name
296
+ @members = members
297
+ end
298
+
299
+ def bare_type = "#{class_name}::Type"
300
+
301
+ def prop_type
302
+ "T.nilable(#{bare_type})"
303
+ end
304
+
305
+ def cast(expr, _depth)
306
+ "#{class_name}.from_h(#{expr})"
307
+ end
308
+
309
+ def identity? = false
310
+ def non_null? = false
311
+ def nested = self
312
+ end
313
+
314
+ # An input-object variable: emitted as a module-level T::Struct whose
315
+ # serialize produces the wire hash. Inputs never cast FROM the wire.
316
+ # Joins the coerce protocol so execute kwargs accept plain hashes,
317
+ # normalized (and type-checked) through the generated .coerce.
318
+ class InputNode
319
+ Field = Struct.new(:prop, :wire, :node, :required)
320
+
321
+ attr_reader :class_name, :fields
322
+
323
+ def initialize(class_name)
324
+ @class_name = class_name
325
+ @fields = []
326
+ end
327
+
328
+ def bare_type = class_name
329
+
330
+ def prop_type
331
+ "T.nilable(#{bare_type})"
332
+ end
333
+
334
+ def serialize(expr, _depth)
335
+ "#{expr}.serialize"
336
+ end
337
+
338
+ def serialize_identity? = false
339
+
340
+ def cast(_expr, _depth)
341
+ raise NotImplementedError, "input objects are never cast from responses"
342
+ end
343
+
344
+ def coerce? = true
345
+ def coerce(expr) = "#{class_name}.coerce(#{expr})"
346
+ def coerce_input_type = "T.any(#{class_name}, T::Hash[T.untyped, T.untyped])"
347
+
348
+ # building a struct field from a caller-supplied plain hash value
349
+ def hash_coerce(expr, _depth) = "#{class_name}.coerce(#{expr})"
350
+ def hash_coerce_identity? = false
351
+
352
+ def identity? = false
353
+ def non_null? = false
354
+ def nested = nil
355
+ end
356
+ end
@@ -0,0 +1,256 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "date"
5
+
6
+ class GraphWeaver::Codegen
7
+
8
+ # How one GraphQL scalar maps to Ruby: the Sorbet prop type, the
9
+ # (optional) code emitted to deserialize a wire value into a rich Ruby
10
+ # object and serialize it back, and any requires the generated file
11
+ # needs. A single registry (below) holds one of these per scalar name;
12
+ # the built-in scalars are just pre-registered entries, so custom
13
+ # scalars and overrides go through the same path.
14
+ #
15
+ # cast/serialize normalize to procs that, given a Ruby expression string,
16
+ # return the code to inline. Left nil (the default) they are inferred
17
+ # from the Ruby type when it is a real class, by probing for a known
18
+ # deserializer and pairing its serializer (see CODECS) — so the common
19
+ # case needs no more than a class:
20
+ # type: Money (defines .parse) => Money.parse(expr) / expr.to_s
21
+ # type: Blob (defines .load) => Blob.load(expr) / Blob.dump(expr)
22
+ # Probing the *deserialize* side is deliberate: every object has #to_s,
23
+ # so inferring a serializer off it would wrongly wrap plain types (String,
24
+ # Integer) — pairing off a deserializer the type actually defines avoids
25
+ # that. Override with an explicit value:
26
+ # - a Symbol names a method, so there is no string to misspell:
27
+ # cast: :load => "Blob.load(expr)" (class method on type)
28
+ # serialize: :to_json => "expr.to_json" (instance method)
29
+ # - a Proc handles anything a Symbol can't express:
30
+ # cast: ->(e) { "Money.new(#{e})" }
31
+ # - :itself opts out — force identity pass-through even when a codec
32
+ # would otherwise match (rare)
33
+ # requires: a String or Array of paths emitted as `require`s atop the
34
+ # generated file (e.g. "bigdecimal") so the cast/type resolve.
35
+ class ScalarType
36
+ # Inferred (deserialize, serialize) codecs, tried in order; the first
37
+ # whose probe the Ruby type defines as a class method wins, and its
38
+ # serialize is paired with it. Builders take (type_name, expr) => code.
39
+ Codec = Struct.new(:probe, :cast, :serialize)
40
+ CODECS = [
41
+ Codec.new(:parse, # Type.parse(wire) <-> value.to_s
42
+ ->(type, expr) { "#{type}.parse(#{expr})" },
43
+ ->(_type, expr) { "#{expr}.to_s" }),
44
+ Codec.new(:load, # Type.load(wire) <-> Type.dump(value)
45
+ ->(type, expr) { "#{type}.load(#{expr})" },
46
+ ->(type, expr) { "#{type}.dump(#{expr})" }),
47
+ ].freeze
48
+
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",
57
+ }.freeze
58
+
59
+ attr_reader :graphql_name, :type, :requires
60
+
61
+ def initialize(graphql_name, type, cast: nil, serialize: nil, requires: nil, coerce: nil)
62
+ @graphql_name = graphql_name.to_s
63
+ @klass = type.is_a?(Module) ? type : nil
64
+ @type = type_name(type)
65
+ # requires: load BEFORE codec probing — the probe method may come
66
+ # from the required file (core Time has no .parse until the "time"
67
+ # stdlib loads)
68
+ @requires = normalize_requires(requires)
69
+ codec = @klass && CODECS.find { |c| @klass.respond_to?(c.probe) }
70
+ @cast = normalize_cast(cast, codec&.cast)
71
+ @serialize = normalize_serialize(serialize, codec&.serialize)
72
+ @coerce = coerce
73
+ validate_coerce!
74
+ end
75
+
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
+ def cast(expr) = @cast&.call(expr)
83
+ def cast? = !@cast.nil?
84
+ def serialize(expr) = @serialize&.call(expr)
85
+ def serialize? = !@serialize.nil?
86
+ def coerce? = !!effective_coerce
87
+
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
96
+
97
+ AUTO_CONVERSIONS.fetch(@graphql_name) { (cast? && serialize?) || nil }
98
+ end
99
+
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").
107
+ 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}"
111
+ end
112
+ end
113
+
114
+ # the accepted Sorbet type for a coercible variable kwarg
115
+ 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")
119
+ end
120
+ end
121
+
122
+ private
123
+
124
+ def type_name(type)
125
+ case type
126
+ when Module then type.name
127
+ when String then type
128
+ else raise ArgumentError, "type: must be a class/module or String, got #{type.inspect}"
129
+ end
130
+ end
131
+
132
+ # nil infers via the matched codec; :itself opts out (identity); a
133
+ # Symbol is a class method on the type — Money.parse(expr)
134
+ def normalize_cast(cast, inferred)
135
+ case cast
136
+ when :itself then nil
137
+ when nil then inferred && ->(expr) { inferred.call(@type, expr) }
138
+ when Proc then cast
139
+ when Symbol then ->(expr) { "#{@type}.#{cast}(#{expr})" }
140
+ else raise ArgumentError, "cast: must be a Symbol, Proc, :itself, or nil, got #{cast.inspect}"
141
+ end
142
+ end
143
+
144
+ # nil infers via the matched codec; :itself opts out (identity); a
145
+ # Symbol is an instance method on the value — expr.to_s
146
+ def normalize_serialize(serialize, inferred)
147
+ case serialize
148
+ when :itself then nil
149
+ when nil then inferred && ->(expr) { inferred.call(@type, expr) }
150
+ when Proc then serialize
151
+ when Symbol then ->(expr) { "#{expr}.#{serialize}" }
152
+ else raise ArgumentError, "serialize: must be a Symbol, Proc, :itself, or nil, got #{serialize.inspect}"
153
+ end
154
+ end
155
+
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.
163
+ def normalize_requires(requires)
164
+ Array(requires).each do |req|
165
+ unless req.is_a?(String) && !req.empty?
166
+ raise ArgumentError, "requires: must be a String or Array of Strings, got #{req.inspect}"
167
+ end
168
+
169
+ next unless @klass
170
+
171
+ begin
172
+ require req
173
+ rescue LoadError => e
174
+ raise ArgumentError, "requires: #{req.inspect} is not loadable (#{e.message})"
175
+ end
176
+ end
177
+ end
178
+
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
+ # Register (or override) how a GraphQL custom scalar deserializes into
197
+ # a Ruby object and serializes back onto the wire. See ScalarType for
198
+ # the accepted cast:/serialize:/requires: forms. Later registrations
199
+ # win, so an app can override a built-in (e.g. map Date onto its own
200
+ # type).
201
+ def register_scalar(graphql_name, type, cast: nil, serialize: nil, requires: nil, coerce: nil)
202
+ scalar_registry[graphql_name.to_s] =
203
+ ScalarType.new(graphql_name, type, cast:, serialize:, requires:, coerce:)
204
+ end
205
+
206
+ # The ScalarType for a scalar name; unknown scalars fall back to an
207
+ # untyped pass-through (T.untyped, no cast) — the prior behavior for
208
+ # scalars outside the table.
209
+ def scalar(graphql_name)
210
+ scalar_registry.fetch(graphql_name.to_s) do
211
+ ScalarType.new(graphql_name, "T.untyped")
212
+ end
213
+ end
214
+
215
+ def scalar_registry
216
+ @scalar_registry ||= {}
217
+ end
218
+
219
+ # Empty the registry entirely, built-ins included. Mostly useful for
220
+ # tests; see reset_scalars! to restore the built-in defaults.
221
+ def clear_scalars!
222
+ scalar_registry.clear
223
+ self
224
+ end
225
+
226
+ # Drop every custom registration and restore the built-in scalars — the
227
+ # clean slate to reach for between tests, or to undo overrides. (Want
228
+ # the built-ins to coerce loose input? That's GraphWeaver.auto_coerce,
229
+ # resolved at generation time — no re-registering.)
230
+ def reset_scalars!
231
+ clear_scalars!
232
+ register_builtin_scalars!
233
+ self
234
+ end
235
+
236
+ # Built-in scalars — pre-registered entries in the one registry. The
237
+ # standard scalars stay pass-through: their Ruby classes (String,
238
+ # Integer, Float) define neither .parse nor .load, so codec inference
239
+ # matches nothing and leaves them identity — which is exactly why we
240
+ # can name them with the real class constants. Date deserializes via
241
+ # 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).
245
+ def register_builtin_scalars!
246
+ register_scalar "ID", String
247
+ register_scalar "String", String
248
+ register_scalar "Int", Integer
249
+ register_scalar "Float", Float
250
+ register_scalar "Boolean", "T::Boolean"
251
+ register_scalar "Date", Date, cast: :iso8601, serialize: :iso8601, requires: "date"
252
+ end
253
+ end
254
+
255
+ register_builtin_scalars!
256
+ end