graph_weaver 0.0.1 → 0.1.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +66 -0
- data/Gemfile.lock +45 -17
- data/Makefile +6 -1
- data/PLAN.md +52 -22
- data/README.md +62 -17
- data/docs/errors.md +106 -0
- data/docs/generated_modules.md +119 -0
- data/docs/real_world.md +46 -0
- data/docs/scalars.md +69 -0
- data/docs/testing.md +93 -0
- data/graph_weaver.gemspec +3 -0
- data/lib/graph_weaver/codegen/emit.rb +237 -0
- data/lib/graph_weaver/codegen/nodes.rb +256 -0
- data/lib/graph_weaver/codegen/scalar_type.rb +238 -0
- data/lib/graph_weaver/codegen.rb +156 -395
- data/lib/graph_weaver/errors.rb +310 -0
- data/lib/graph_weaver/faraday_executor.rb +61 -0
- data/lib/graph_weaver/http_executor.rb +16 -3
- data/lib/graph_weaver/inflect.rb +18 -0
- data/lib/graph_weaver/response.rb +55 -0
- data/lib/graph_weaver/rspec.rb +54 -0
- data/lib/graph_weaver/schema_loader.rb +73 -9
- data/lib/graph_weaver/selection.rb +68 -0
- data/lib/graph_weaver/testing/cassette.rb +203 -0
- data/lib/graph_weaver/testing/failure.rb +109 -0
- data/lib/graph_weaver/testing/fake_executor.rb +228 -0
- data/lib/graph_weaver/testing/rspec.rb +5 -0
- data/lib/graph_weaver/testing/values.rb +98 -0
- data/lib/graph_weaver/testing.rb +90 -0
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +97 -3
- metadata +63 -1
|
@@ -0,0 +1,256 @@
|
|
|
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
|
+
def initialize(name)
|
|
10
|
+
@scalar = GraphWeaver::Codegen.scalar(name)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def bare_type
|
|
14
|
+
@scalar.type
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def prop_type
|
|
18
|
+
"T.nilable(#{bare_type})"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def cast(expr, _depth)
|
|
22
|
+
@scalar.cast(expr)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def identity?
|
|
26
|
+
!@scalar.cast?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def serialize(expr, _depth)
|
|
30
|
+
@scalar.serialize(expr)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def serialize_identity?
|
|
34
|
+
!@scalar.serialize?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# coercion (opt-in per scalar): accept the value or its raw input and
|
|
38
|
+
# normalize before serializing — parse for a rich type (coerce: true),
|
|
39
|
+
# or a plain conversion for built-ins (coerce: :to_f). See ScalarType.
|
|
40
|
+
def coerce? = @scalar.coerce?
|
|
41
|
+
def coerce(expr) = @scalar.coerce_input(expr)
|
|
42
|
+
def coerce_input_type = @scalar.coerce_type
|
|
43
|
+
|
|
44
|
+
def hash_coerce(expr, _depth) = expr
|
|
45
|
+
def hash_coerce_identity? = true
|
|
46
|
+
|
|
47
|
+
def non_null? = false
|
|
48
|
+
def nested = nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
class NonNull
|
|
52
|
+
attr_reader :of
|
|
53
|
+
|
|
54
|
+
def initialize(of)
|
|
55
|
+
@of = of
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def bare_type = @of.bare_type
|
|
59
|
+
def prop_type = bare_type
|
|
60
|
+
def cast(expr, depth) = @of.cast(expr, depth)
|
|
61
|
+
def identity? = @of.identity?
|
|
62
|
+
def serialize(expr, depth) = @of.serialize(expr, depth)
|
|
63
|
+
def serialize_identity? = @of.serialize_identity?
|
|
64
|
+
def coerce? = @of.coerce?
|
|
65
|
+
def coerce(expr) = @of.coerce(expr)
|
|
66
|
+
def coerce_input_type = @of.coerce_input_type
|
|
67
|
+
def hash_coerce(expr, depth) = @of.hash_coerce(expr, depth)
|
|
68
|
+
def hash_coerce_identity? = @of.hash_coerce_identity?
|
|
69
|
+
def non_null? = true
|
|
70
|
+
def nested = @of.nested
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
class List
|
|
74
|
+
def initialize(of)
|
|
75
|
+
@of = of
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def bare_type
|
|
79
|
+
"T::Array[#{@of.prop_type}]"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def prop_type
|
|
83
|
+
"T.nilable(#{bare_type})"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def cast(expr, depth)
|
|
87
|
+
var = "v#{depth}"
|
|
88
|
+
element = if @of.non_null? || @of.identity?
|
|
89
|
+
@of.identity? ? var : @of.cast(var, depth + 1)
|
|
90
|
+
else
|
|
91
|
+
"#{var}&.then { |v#{depth + 1}| #{@of.cast("v#{depth + 1}", depth + 2)} }"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
"#{expr}.map { |#{var}| #{element} }"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def identity? = @of.identity?
|
|
98
|
+
|
|
99
|
+
def serialize(expr, depth)
|
|
100
|
+
var = "v#{depth}"
|
|
101
|
+
element = if @of.non_null? || @of.serialize_identity?
|
|
102
|
+
@of.serialize_identity? ? var : @of.serialize(var, depth + 1)
|
|
103
|
+
else
|
|
104
|
+
"#{var}&.then { |v#{depth + 1}| #{@of.serialize("v#{depth + 1}", depth + 2)} }"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
"#{expr}.map { |#{var}| #{element} }"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def serialize_identity? = @of.serialize_identity?
|
|
111
|
+
def coerce? = false
|
|
112
|
+
|
|
113
|
+
def hash_coerce(expr, depth)
|
|
114
|
+
var = "v#{depth}"
|
|
115
|
+
inner = if @of.non_null? || @of.hash_coerce_identity?
|
|
116
|
+
@of.hash_coerce_identity? ? var : @of.hash_coerce(var, depth + 1)
|
|
117
|
+
else
|
|
118
|
+
"#{var}.nil? ? nil : #{@of.hash_coerce(var, depth + 1)}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
"#{expr}.map { |#{var}| #{inner} }"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def hash_coerce_identity? = @of.hash_coerce_identity?
|
|
125
|
+
def non_null? = false
|
|
126
|
+
def nested = @of.nested
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
class ObjectNode
|
|
130
|
+
Field = Struct.new(:prop, :key, :node)
|
|
131
|
+
|
|
132
|
+
attr_reader :class_name, :fields
|
|
133
|
+
|
|
134
|
+
def initialize(class_name)
|
|
135
|
+
@class_name = class_name
|
|
136
|
+
@fields = []
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def bare_type = class_name
|
|
140
|
+
|
|
141
|
+
def prop_type
|
|
142
|
+
"T.nilable(#{bare_type})"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def cast(expr, _depth)
|
|
146
|
+
"#{class_name}.from_h(#{expr})"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def identity? = false
|
|
150
|
+
def non_null? = false
|
|
151
|
+
def nested = self
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
class EnumNode
|
|
155
|
+
attr_reader :class_name, :values
|
|
156
|
+
|
|
157
|
+
def initialize(class_name, values)
|
|
158
|
+
@class_name = class_name
|
|
159
|
+
@values = values
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def bare_type = class_name
|
|
163
|
+
|
|
164
|
+
def prop_type
|
|
165
|
+
"T.nilable(#{bare_type})"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def cast(expr, _depth)
|
|
169
|
+
"#{class_name}.deserialize(#{expr})"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def identity? = false
|
|
173
|
+
|
|
174
|
+
def serialize(expr, _depth)
|
|
175
|
+
"#{expr}.serialize"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def serialize_identity? = false
|
|
179
|
+
def coerce? = false
|
|
180
|
+
|
|
181
|
+
# enum fields in a plain hash accept the T::Enum or its wire value
|
|
182
|
+
def hash_coerce(expr, _depth)
|
|
183
|
+
"(#{expr}.is_a?(#{class_name}) ? #{expr} : #{class_name}.deserialize(#{expr}))"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def hash_coerce_identity? = false
|
|
187
|
+
def non_null? = false
|
|
188
|
+
def nested = self
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
class UnionNode
|
|
192
|
+
attr_reader :class_name, :members # graphql type name => ObjectNode
|
|
193
|
+
|
|
194
|
+
def initialize(class_name, members)
|
|
195
|
+
@class_name = class_name
|
|
196
|
+
@members = members
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def bare_type = "#{class_name}::Type"
|
|
200
|
+
|
|
201
|
+
def prop_type
|
|
202
|
+
"T.nilable(#{bare_type})"
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def cast(expr, _depth)
|
|
206
|
+
"#{class_name}.from_h(#{expr})"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def identity? = false
|
|
210
|
+
def non_null? = false
|
|
211
|
+
def nested = self
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# An input-object variable: emitted as a module-level T::Struct whose
|
|
215
|
+
# serialize produces the wire hash. Inputs never cast FROM the wire.
|
|
216
|
+
# Joins the coerce protocol so execute kwargs accept plain hashes,
|
|
217
|
+
# normalized (and type-checked) through the generated .coerce.
|
|
218
|
+
class InputNode
|
|
219
|
+
Field = Struct.new(:prop, :wire, :node, :required)
|
|
220
|
+
|
|
221
|
+
attr_reader :class_name, :fields
|
|
222
|
+
|
|
223
|
+
def initialize(class_name)
|
|
224
|
+
@class_name = class_name
|
|
225
|
+
@fields = []
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def bare_type = class_name
|
|
229
|
+
|
|
230
|
+
def prop_type
|
|
231
|
+
"T.nilable(#{bare_type})"
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def serialize(expr, _depth)
|
|
235
|
+
"#{expr}.serialize"
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def serialize_identity? = false
|
|
239
|
+
|
|
240
|
+
def cast(_expr, _depth)
|
|
241
|
+
raise NotImplementedError, "input objects are never cast from responses"
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def coerce? = true
|
|
245
|
+
def coerce(expr) = "#{class_name}.coerce(#{expr})"
|
|
246
|
+
def coerce_input_type = "T.any(#{class_name}, T::Hash[T.untyped, T.untyped])"
|
|
247
|
+
|
|
248
|
+
# building a struct field from a caller-supplied plain hash value
|
|
249
|
+
def hash_coerce(expr, _depth) = "#{class_name}.coerce(#{expr})"
|
|
250
|
+
def hash_coerce_identity? = false
|
|
251
|
+
|
|
252
|
+
def identity? = false
|
|
253
|
+
def non_null? = false
|
|
254
|
+
def nested = nil
|
|
255
|
+
end
|
|
256
|
+
end
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
class GraphWeaver::Codegen
|
|
5
|
+
|
|
6
|
+
# How one GraphQL scalar maps to Ruby: the Sorbet prop type, the
|
|
7
|
+
# (optional) code emitted to deserialize a wire value into a rich Ruby
|
|
8
|
+
# object and serialize it back, and any requires the generated file
|
|
9
|
+
# needs. A single registry (below) holds one of these per scalar name;
|
|
10
|
+
# the built-in scalars are just pre-registered entries, so custom
|
|
11
|
+
# scalars and overrides go through the same path.
|
|
12
|
+
#
|
|
13
|
+
# cast/serialize normalize to procs that, given a Ruby expression string,
|
|
14
|
+
# return the code to inline. Left nil (the default) they are inferred
|
|
15
|
+
# from the Ruby type when it is a real class, by probing for a known
|
|
16
|
+
# deserializer and pairing its serializer (see CODECS) — so the common
|
|
17
|
+
# case needs no more than a class:
|
|
18
|
+
# type: Money (defines .parse) => Money.parse(expr) / expr.to_s
|
|
19
|
+
# type: Blob (defines .load) => Blob.load(expr) / Blob.dump(expr)
|
|
20
|
+
# Probing the *deserialize* side is deliberate: every object has #to_s,
|
|
21
|
+
# so inferring a serializer off it would wrongly wrap plain types (String,
|
|
22
|
+
# Integer) — pairing off a deserializer the type actually defines avoids
|
|
23
|
+
# that. Override with an explicit value:
|
|
24
|
+
# - a Symbol names a method, so there is no string to misspell:
|
|
25
|
+
# cast: :load => "Blob.load(expr)" (class method on type)
|
|
26
|
+
# serialize: :to_json => "expr.to_json" (instance method)
|
|
27
|
+
# - a Proc handles anything a Symbol can't express:
|
|
28
|
+
# cast: ->(e) { "Money.new(#{e})" }
|
|
29
|
+
# - :itself opts out — force identity pass-through even when a codec
|
|
30
|
+
# would otherwise match (rare)
|
|
31
|
+
# requires: a String or Array of paths emitted as `require`s atop the
|
|
32
|
+
# generated file (e.g. "bigdecimal") so the cast/type resolve.
|
|
33
|
+
class ScalarType
|
|
34
|
+
# Inferred (deserialize, serialize) codecs, tried in order; the first
|
|
35
|
+
# whose probe the Ruby type defines as a class method wins, and its
|
|
36
|
+
# serialize is paired with it. Builders take (type_name, expr) => code.
|
|
37
|
+
Codec = Struct.new(:probe, :cast, :serialize)
|
|
38
|
+
CODECS = [
|
|
39
|
+
Codec.new(:parse, # Type.parse(wire) <-> value.to_s
|
|
40
|
+
->(type, expr) { "#{type}.parse(#{expr})" },
|
|
41
|
+
->(_type, expr) { "#{expr}.to_s" }),
|
|
42
|
+
Codec.new(:load, # Type.load(wire) <-> Type.dump(value)
|
|
43
|
+
->(type, expr) { "#{type}.load(#{expr})" },
|
|
44
|
+
->(type, expr) { "#{type}.dump(#{expr})" }),
|
|
45
|
+
].freeze
|
|
46
|
+
|
|
47
|
+
# Accepted kwarg types for Symbol (instance-method) coercion — the
|
|
48
|
+
# looser inputs the conversion sensibly handles. #to_s is defined on
|
|
49
|
+
# every object, so it accepts anything; #to_f/#to_i only make sense for
|
|
50
|
+
# numerics and strings.
|
|
51
|
+
CONVERT_INPUTS = {
|
|
52
|
+
to_f: "T.any(Float, Integer, String)",
|
|
53
|
+
to_i: "T.any(Integer, Float, String)",
|
|
54
|
+
to_s: "T.anything",
|
|
55
|
+
}.freeze
|
|
56
|
+
|
|
57
|
+
attr_reader :graphql_name, :type, :requires
|
|
58
|
+
|
|
59
|
+
def initialize(graphql_name, type:, cast: nil, serialize: nil, requires: nil, coerce: false)
|
|
60
|
+
@graphql_name = graphql_name.to_s
|
|
61
|
+
@klass = type.is_a?(Module) ? type : nil
|
|
62
|
+
@type = type_name(type)
|
|
63
|
+
codec = @klass && CODECS.find { |c| @klass.respond_to?(c.probe) }
|
|
64
|
+
@cast = normalize_cast(cast, codec&.cast)
|
|
65
|
+
@serialize = normalize_serialize(serialize, codec&.serialize)
|
|
66
|
+
@requires = normalize_requires(requires)
|
|
67
|
+
@coerce = coerce
|
|
68
|
+
validate_coerce!
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def cast(expr) = @cast&.call(expr)
|
|
72
|
+
def cast? = !@cast.nil?
|
|
73
|
+
def serialize(expr) = @serialize&.call(expr)
|
|
74
|
+
def serialize? = !@serialize.nil?
|
|
75
|
+
def coerce? = !!@coerce
|
|
76
|
+
|
|
77
|
+
# The code that normalizes a variable input before it's serialized. Two
|
|
78
|
+
# shapes: coerce: true parses a raw value into the rich type via the cast
|
|
79
|
+
# (guarded so an already-typed value passes through); coerce: :to_f (a
|
|
80
|
+
# Symbol) calls that instance method, for built-ins where a plain
|
|
81
|
+
# conversion is the whole story (5, "5" -> 5.0). serialize still runs
|
|
82
|
+
# afterward, but is identity for the conversion built-ins, so the
|
|
83
|
+
# converted value goes on the wire natively (a Float, not "5.0").
|
|
84
|
+
def coerce_input(expr)
|
|
85
|
+
case @coerce
|
|
86
|
+
when true then "(#{expr}.is_a?(#{@type}) ? #{expr} : #{cast(expr)})"
|
|
87
|
+
when Symbol then "#{expr}.#{@coerce}"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# the accepted Sorbet type for a coercible variable kwarg
|
|
92
|
+
def coerce_type
|
|
93
|
+
case @coerce
|
|
94
|
+
when true then "T.any(#{@type}, String)"
|
|
95
|
+
when Symbol then CONVERT_INPUTS.fetch(@coerce, "T.untyped")
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def type_name(type)
|
|
102
|
+
case type
|
|
103
|
+
when Module then type.name
|
|
104
|
+
when String then type
|
|
105
|
+
else raise ArgumentError, "type: must be a class/module or String, got #{type.inspect}"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# nil infers via the matched codec; :itself opts out (identity); a
|
|
110
|
+
# Symbol is a class method on the type — Money.parse(expr)
|
|
111
|
+
def normalize_cast(cast, inferred)
|
|
112
|
+
case cast
|
|
113
|
+
when :itself then nil
|
|
114
|
+
when nil then inferred && ->(expr) { inferred.call(@type, expr) }
|
|
115
|
+
when Proc then cast
|
|
116
|
+
when Symbol then ->(expr) { "#{@type}.#{cast}(#{expr})" }
|
|
117
|
+
else raise ArgumentError, "cast: must be a Symbol, Proc, :itself, or nil, got #{cast.inspect}"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# nil infers via the matched codec; :itself opts out (identity); a
|
|
122
|
+
# Symbol is an instance method on the value — expr.to_s
|
|
123
|
+
def normalize_serialize(serialize, inferred)
|
|
124
|
+
case serialize
|
|
125
|
+
when :itself then nil
|
|
126
|
+
when nil then inferred && ->(expr) { inferred.call(@type, expr) }
|
|
127
|
+
when Proc then serialize
|
|
128
|
+
when Symbol then ->(expr) { "#{expr}.#{serialize}" }
|
|
129
|
+
else raise ArgumentError, "serialize: must be a Symbol, Proc, :itself, or nil, got #{serialize.inspect}"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# requires: is a require path or list of them; each must be a non-empty
|
|
134
|
+
# String (it is emitted verbatim as `require "..."`), caught here rather
|
|
135
|
+
# than as a syntax error in the generated file. When a real class was
|
|
136
|
+
# given as type:, we're in a runtime with its deps loaded, so we also
|
|
137
|
+
# `require` each path to prove it resolves (a no-op for already-loaded
|
|
138
|
+
# libs, and it surfaces a typo now). With only a type-name string we
|
|
139
|
+
# can't assume the lib is installed at codegen time, so we don't try.
|
|
140
|
+
def normalize_requires(requires)
|
|
141
|
+
Array(requires).each do |req|
|
|
142
|
+
unless req.is_a?(String) && !req.empty?
|
|
143
|
+
raise ArgumentError, "requires: must be a String or Array of Strings, got #{req.inspect}"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
next unless @klass
|
|
147
|
+
|
|
148
|
+
begin
|
|
149
|
+
require req
|
|
150
|
+
rescue LoadError => e
|
|
151
|
+
raise ArgumentError, "requires: #{req.inspect} is not loadable (#{e.message})"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# coerce: true round-trips through cast+serialize, so it needs both; a
|
|
157
|
+
# Symbol is a self-contained conversion and needs neither.
|
|
158
|
+
def validate_coerce!
|
|
159
|
+
case @coerce
|
|
160
|
+
when false, nil, Symbol then nil
|
|
161
|
+
when true
|
|
162
|
+
return if cast? && serialize?
|
|
163
|
+
|
|
164
|
+
raise ArgumentError,
|
|
165
|
+
"coerce: true needs both a cast and a serialize (#{@graphql_name} is missing one)"
|
|
166
|
+
else
|
|
167
|
+
raise ArgumentError, "coerce: must be true, false, or a Symbol method name, got #{@coerce.inspect}"
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
class << self
|
|
173
|
+
# Register (or override) how a GraphQL custom scalar deserializes into
|
|
174
|
+
# a Ruby object and serializes back onto the wire. See ScalarType for
|
|
175
|
+
# the accepted cast:/serialize:/requires: forms. Later registrations
|
|
176
|
+
# win, so an app can override a built-in (e.g. map Date onto its own
|
|
177
|
+
# type).
|
|
178
|
+
def register_scalar(graphql_name, type:, cast: nil, serialize: nil, requires: nil, coerce: false)
|
|
179
|
+
scalar_registry[graphql_name.to_s] =
|
|
180
|
+
ScalarType.new(graphql_name, type:, cast:, serialize:, requires:, coerce:)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# The ScalarType for a scalar name; unknown scalars fall back to an
|
|
184
|
+
# untyped pass-through (T.untyped, no cast) — the prior behavior for
|
|
185
|
+
# scalars outside the table.
|
|
186
|
+
def scalar(graphql_name)
|
|
187
|
+
scalar_registry.fetch(graphql_name.to_s) do
|
|
188
|
+
ScalarType.new(graphql_name, type: "T.untyped")
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def scalar_registry
|
|
193
|
+
@scalar_registry ||= {}
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Empty the registry entirely, built-ins included. Mostly useful for
|
|
197
|
+
# tests; see reset_scalars! to restore the built-in defaults.
|
|
198
|
+
def clear_scalars!
|
|
199
|
+
scalar_registry.clear
|
|
200
|
+
self
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Drop every custom registration and restore the built-in scalars — the
|
|
204
|
+
# clean slate to reach for between tests, or to undo overrides. Pass
|
|
205
|
+
# coerce: true to reload the built-ins with input coercion enabled
|
|
206
|
+
# (Float accepts 5/"5" and .to_f's it, etc.), then register your own
|
|
207
|
+
# scalars on top — a one-liner alternative to re-registering each
|
|
208
|
+
# built-in by hand.
|
|
209
|
+
def reset_scalars!(coerce: false)
|
|
210
|
+
clear_scalars!
|
|
211
|
+
register_builtin_scalars!(coerce:)
|
|
212
|
+
self
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Built-in scalars — pre-registered entries in the one registry. The
|
|
216
|
+
# standard scalars stay pass-through: their Ruby classes (String,
|
|
217
|
+
# Integer, Float) define neither .parse nor .load, so codec inference
|
|
218
|
+
# matches nothing and leaves them identity — which is exactly why we
|
|
219
|
+
# can name them with the real class constants. Date deserializes via
|
|
220
|
+
# ISO-8601 (it *does* define .parse, but we want iso8601 specifically,
|
|
221
|
+
# so it's explicit).
|
|
222
|
+
#
|
|
223
|
+
# coerce: true opts the convertible scalars into input coercion via a
|
|
224
|
+
# plain conversion method (to_f/to_i/to_s); the wire value stays native
|
|
225
|
+
# (a Float, not "5.0"). Boolean and Date have no lossless one-method
|
|
226
|
+
# conversion, so they stay strict either way.
|
|
227
|
+
def register_builtin_scalars!(coerce: false)
|
|
228
|
+
register_scalar "ID", type: String, coerce: (:to_s if coerce)
|
|
229
|
+
register_scalar "String", type: String, coerce: (:to_s if coerce)
|
|
230
|
+
register_scalar "Int", type: Integer, coerce: (:to_i if coerce)
|
|
231
|
+
register_scalar "Float", type: Float, coerce: (:to_f if coerce)
|
|
232
|
+
register_scalar "Boolean", type: "T::Boolean"
|
|
233
|
+
register_scalar "Date", type: Date, cast: :iso8601, serialize: :iso8601, requires: "date"
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
register_builtin_scalars!
|
|
238
|
+
end
|