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.
@@ -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