graph_weaver 0.7.3 → 0.7.4
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/Gemfile.lock +2 -2
- data/docs/federation.md +3 -2
- data/docs/generated_modules.md +49 -8
- data/docs/getting_started.md +15 -0
- data/docs/i18n.md +4 -4
- data/docs/scalars.md +123 -32
- data/docs/testing.md +21 -2
- data/docs/upgrading.md +31 -5
- data/examples/github/generated/star_mutation.rb +24 -2
- data/examples/github/generated/stargazers_query.rb +61 -5
- data/examples/github/generated/starred_query.rb +33 -3
- data/lib/graph_weaver/client.rb +23 -0
- data/lib/graph_weaver/codegen/emit.rb +22 -7
- data/lib/graph_weaver/codegen/enum_type.rb +132 -19
- data/lib/graph_weaver/codegen/nodes.rb +20 -9
- data/lib/graph_weaver/codegen/scalar_type.rb +72 -18
- data/lib/graph_weaver/codegen/type_helpers.rb +71 -13
- data/lib/graph_weaver/codegen.rb +61 -16
- data/lib/graph_weaver/coerce.rb +24 -5
- data/lib/graph_weaver/graph.rb +4 -1
- data/lib/graph_weaver/hints.rb +4 -1
- data/lib/graph_weaver/input_struct.rb +13 -7
- data/lib/graph_weaver/internal/values.rb +5 -2
- data/lib/graph_weaver/internal.rb +67 -0
- data/lib/graph_weaver/testing.rb +101 -0
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +67 -67
- metadata +2 -2
|
@@ -56,6 +56,8 @@ class GraphWeaver::Codegen
|
|
|
56
56
|
# is the whole rule, so key on that — a custom scalar registered as a
|
|
57
57
|
# plain String gets the same check. ID is the exception GraphQL itself
|
|
58
58
|
# names (see Coerce.id), matched by GraphQL name in #coercer.
|
|
59
|
+
# Read back off the wire too, where the type is one JSON already holds
|
|
60
|
+
# (see #wire_cast).
|
|
59
61
|
COERCERS = {
|
|
60
62
|
"Integer" => "integer",
|
|
61
63
|
"Float" => "float",
|
|
@@ -83,15 +85,15 @@ class GraphWeaver::Codegen
|
|
|
83
85
|
|
|
84
86
|
STDLIB = {
|
|
85
87
|
"BigDecimal" => { serialize: [:to_s, "F"], requires: "bigdecimal" },
|
|
86
|
-
# JSON has one number type, so a whole Float arrives as `1` from every
|
|
87
|
-
# encoder that drops the trailing zero (graphql-js and Go both do)
|
|
88
|
-
"Float" => { cast: ->(expr) { "GraphWeaver::Coerce.float(#{expr})" } },
|
|
89
88
|
# strftime, not #iso8601: DateTime < Date passes the is_a? guard, and its
|
|
90
89
|
# #iso8601 writes a timestamp where the schema said a date goes
|
|
91
90
|
"Date" => { cast: :iso8601, serialize: [:strftime, "%F"], requires: "date" },
|
|
92
|
-
#
|
|
93
|
-
#
|
|
94
|
-
|
|
91
|
+
# Time.iso8601, not Time.parse: the latter also reads "Jan 15 2024
|
|
92
|
+
# 10:20", which no spec-compliant server writes, at 3× the cost.
|
|
93
|
+
# Writing back goes through Coerce.timestamp rather than #iso8601,
|
|
94
|
+
# which takes no precision — a sub-second timestamp would go out
|
|
95
|
+
# poorer than it came in.
|
|
96
|
+
"Time" => { cast: :iso8601, serialize: TIMESTAMP, call: TIMESTAMP_CALL, requires: "time" },
|
|
95
97
|
"DateTime" => { cast: :iso8601, serialize: TIMESTAMP, call: TIMESTAMP_CALL, requires: "date" },
|
|
96
98
|
}.freeze
|
|
97
99
|
|
|
@@ -122,11 +124,12 @@ class GraphWeaver::Codegen
|
|
|
122
124
|
end
|
|
123
125
|
@cast_given = cast unless cast == :itself
|
|
124
126
|
codec = @klass && CODECS.find { |c| @klass.respond_to?(c.probe) }
|
|
125
|
-
@cast = normalize_cast(cast || known[:cast], codec&.cast || kernel_cast)
|
|
127
|
+
@cast = normalize_cast(cast || known[:cast], wire_cast || codec&.cast || kernel_cast)
|
|
126
128
|
@serialize = normalize_serialize(serialize || known[:serialize], codec&.serialize)
|
|
127
129
|
@serialize_value = (known[:call] if serialize.nil?) ||
|
|
128
130
|
runtime_serialize(serialize || known[:serialize], codec)
|
|
129
131
|
warn_half_a_value_object
|
|
132
|
+
warn_half_a_codec(serialize)
|
|
130
133
|
end
|
|
131
134
|
|
|
132
135
|
def cast(expr) = @cast&.call(expr)
|
|
@@ -145,6 +148,10 @@ class GraphWeaver::Codegen
|
|
|
145
148
|
@serialize_value.call(value)
|
|
146
149
|
end
|
|
147
150
|
|
|
151
|
+
# Whether #serialize_value actually runs this registration's serializer,
|
|
152
|
+
# rather than passing the value through for want of one to run.
|
|
153
|
+
def serialize_value? = !@serialize_value.nil?
|
|
154
|
+
|
|
148
155
|
# The code that normalizes a loose input — a Rails param — into this
|
|
149
156
|
# scalar's Ruby type before it is serialized, or nil for nothing to do.
|
|
150
157
|
# The Ruby type's own rule in Coerce is the check, which is what
|
|
@@ -207,6 +214,32 @@ class GraphWeaver::Codegen
|
|
|
207
214
|
end
|
|
208
215
|
end
|
|
209
216
|
|
|
217
|
+
# The other half of the same rule: a registration says both directions or
|
|
218
|
+
# is told what it can't do. A cast with nothing to write back reads the
|
|
219
|
+
# wire and can't put a variable of that scalar onto it, and a result's
|
|
220
|
+
# #as_json can't reproduce what the server sent — both silent, since
|
|
221
|
+
# `#to_json` answers for any object.
|
|
222
|
+
def warn_half_a_codec(serialize)
|
|
223
|
+
# nothing said and nothing inferred: `serialize: :itself` said it, and a
|
|
224
|
+
# Ruby type JSON already holds has no other half to name
|
|
225
|
+
return if !cast? || serialize? || !serialize.nil? || json_shaped?
|
|
226
|
+
|
|
227
|
+
GraphWeaver::Internal::Log.log(:warn) do
|
|
228
|
+
"register_scalar(#{@graphql_name.inspect}, #{@klass ? @type : @type.inspect}): cast: reads " \
|
|
229
|
+
"#{GraphWeaver::Internal::Util.article(@type)} #{@type} off the wire and nothing writes one " \
|
|
230
|
+
"back — a #{@graphql_name} variable goes out as whatever #to_json makes of it, and a result's " \
|
|
231
|
+
"#as_json can't reproduce what the server sent. Name the other half (serialize: :to_s names an " \
|
|
232
|
+
"instance method, serialize: [:to_s, \"F\"] passes it arguments, " \
|
|
233
|
+
"serialize: ->(v) { \"\#{v}.to_s\" } emits any expression), or serialize: :itself if the value " \
|
|
234
|
+
"really does go out as it is"
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# A Ruby type JSON already holds writes itself, subclasses included
|
|
239
|
+
def json_shaped?
|
|
240
|
+
wire_class?(@type) || (!@klass.nil? && WIRE_CLASSES.any? { |native| @klass <= native })
|
|
241
|
+
end
|
|
242
|
+
|
|
210
243
|
def defines?(method)
|
|
211
244
|
![BasicObject, Kernel, Object].include?(@klass.instance_method(method).owner)
|
|
212
245
|
end
|
|
@@ -231,13 +264,31 @@ class GraphWeaver::Codegen
|
|
|
231
264
|
end
|
|
232
265
|
end
|
|
233
266
|
|
|
267
|
+
# `register_scalar("Count", Integer)` says the scalar IS an Integer, so the
|
|
268
|
+
# library's own rule for that class runs in both directions — the entry
|
|
269
|
+
# #coercer brands a variable with, reading the wire as well. It is the
|
|
270
|
+
# lenient reading: a custom scalar is the server's own, and may write the
|
|
271
|
+
# number as a string where the spec's `Int` may not (which is why the
|
|
272
|
+
# pre-registered Int/String/Boolean/ID opt out with `cast: :itself`).
|
|
273
|
+
# Only the wire class itself — Coerce hands back the plain type, so a
|
|
274
|
+
# subclass would land in a prop it doesn't satisfy — and only where Coerce
|
|
275
|
+
# has a rule, leaving Hash and Array passing through.
|
|
276
|
+
def wire_cast
|
|
277
|
+
return unless wire_class?(@type) && (fn = COERCERS[@type])
|
|
278
|
+
|
|
279
|
+
->(_type, expr) { "GraphWeaver::Coerce.#{fn}(#{expr}, #{@graphql_name.inspect})" }
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# T::Boolean is the pair TrueClass/FalseClass, spelled as Sorbet types it
|
|
283
|
+
def wire_class?(name) = name == "T::Boolean" || WIRE_CLASSES.any? { |native| native.name == name }
|
|
284
|
+
|
|
234
285
|
# Kernel's conversion functions are how a wire value becomes one of these
|
|
235
286
|
# — BigDecimal defines neither .parse nor .load, but Kernel#BigDecimal has
|
|
236
287
|
# read a decimal string all along. Only for a type the wire can't already
|
|
237
288
|
# be: Kernel#String and Kernel#Array wrap a value rather than convert it.
|
|
238
289
|
def kernel_cast
|
|
239
290
|
return unless Kernel.private_method_defined?(@type.to_sym)
|
|
240
|
-
return if
|
|
291
|
+
return if wire_class?(@type)
|
|
241
292
|
|
|
242
293
|
->(type, expr) { "#{type}(#{expr})" }
|
|
243
294
|
end
|
|
@@ -373,12 +424,15 @@ class GraphWeaver::Codegen
|
|
|
373
424
|
# Pre-registered scalars — ordinary entries in the one registry, so a
|
|
374
425
|
# later register_scalar overrides any of them.
|
|
375
426
|
#
|
|
376
|
-
#
|
|
377
|
-
#
|
|
378
|
-
#
|
|
379
|
-
#
|
|
380
|
-
#
|
|
381
|
-
#
|
|
427
|
+
# Four of the five the spec names stay pass-through coming back —
|
|
428
|
+
# `cast: :itself` — because the spec guarantees their JSON type, so
|
|
429
|
+
# checking it would cost a call on the commonest leaves in every response
|
|
430
|
+
# and refuse nothing a compliant server sends. (Going out is another
|
|
431
|
+
# matter: a Rails param is a String whatever the sig says, so the same
|
|
432
|
+
# scalars still coerce there.) A scalar an app registers as one of those
|
|
433
|
+
# classes is the server's own, and gets the rule in both directions.
|
|
434
|
+
# Float is the exception even coming back: JSON has one number type, so
|
|
435
|
+
# `1.0` arrives as `1` from graphql-js and from Go.
|
|
382
436
|
#
|
|
383
437
|
# The rest are names, not guesses: graphql-ruby ships all but DateTime as
|
|
384
438
|
# its own scalars, and this library runs a graphql-ruby schema in-process.
|
|
@@ -388,11 +442,11 @@ class GraphWeaver::Codegen
|
|
|
388
442
|
# away. Date and datetime are told apart by their Ruby type — a Date cast
|
|
389
443
|
# to Time would invent a midnight the server never sent.
|
|
390
444
|
def register_builtin_scalars!
|
|
391
|
-
register_scalar "ID", String
|
|
392
|
-
register_scalar "String", String
|
|
393
|
-
register_scalar "Int", Integer
|
|
445
|
+
register_scalar "ID", String, cast: :itself
|
|
446
|
+
register_scalar "String", String, cast: :itself
|
|
447
|
+
register_scalar "Int", Integer, cast: :itself
|
|
394
448
|
register_scalar "Float", Float
|
|
395
|
-
register_scalar "Boolean", "T::Boolean"
|
|
449
|
+
register_scalar "Boolean", "T::Boolean", cast: :itself
|
|
396
450
|
register_scalar "Date", Date
|
|
397
451
|
register_scalar "ISO8601Date", Date
|
|
398
452
|
register_scalar "ISO8601DateTime", Time
|
|
@@ -45,10 +45,21 @@ class GraphWeaver::Codegen
|
|
|
45
45
|
# fit the path just omits the accessor instead of failing generation. Use it
|
|
46
46
|
# for a root-type accessor (a Query alias every query would otherwise have to
|
|
47
47
|
# satisfy) or one that only fits some selections.
|
|
48
|
+
#
|
|
49
|
+
# Inside the block, `alias_field` is the same keyword said next to the
|
|
50
|
+
# methods that use it — one alias per line, always strict:
|
|
51
|
+
#
|
|
52
|
+
# GraphWeaver.extend_type("Widget") do
|
|
53
|
+
# alias_field :tag, "meta.tag"
|
|
54
|
+
# def shout = tag&.upcase
|
|
55
|
+
# end
|
|
48
56
|
def extend_type(graphql_name, *mixins, requires: nil, **kw, &block)
|
|
49
|
-
|
|
57
|
+
optional = !!kw.delete(:optional)
|
|
58
|
+
aliases = normalize_aliases(kw.delete(:alias), optional:)
|
|
59
|
+
raise ArgumentError, "unknown keyword: #{kw.keys.first}" unless kw.empty?
|
|
60
|
+
|
|
50
61
|
mixins = mixins.dup
|
|
51
|
-
mixins << helper_module(graphql_name, block) if block
|
|
62
|
+
mixins << helper_module(graphql_name, block, aliases) if block
|
|
52
63
|
|
|
53
64
|
raise ArgumentError, "pass one or more helper modules, a block, or alias:" if mixins.empty? && aliases.empty?
|
|
54
65
|
mixins.each do |mixin|
|
|
@@ -70,15 +81,6 @@ class GraphWeaver::Codegen
|
|
|
70
81
|
entry
|
|
71
82
|
end
|
|
72
83
|
|
|
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
84
|
# accessor names and path segments are interpolated verbatim into generated
|
|
83
85
|
# source, so — like module_name — they must be plain identifiers, never
|
|
84
86
|
# arbitrary text that could inject code
|
|
@@ -138,7 +140,7 @@ class GraphWeaver::Codegen
|
|
|
138
140
|
# naming it after whichever constants happened to exist made it a function
|
|
139
141
|
# of how many times THIS process had read the registry, and `generate`
|
|
140
142
|
# wrote a name a plain boot never creates.
|
|
141
|
-
def helper_module(graphql_name, block)
|
|
143
|
+
def helper_module(graphql_name, block, aliases)
|
|
142
144
|
namespace = helper_namespace
|
|
143
145
|
type = GraphWeaver::Inflect.camelize(graphql_name.to_s)
|
|
144
146
|
index = (helper_counts[[namespace.name, type]] += 1)
|
|
@@ -146,11 +148,67 @@ class GraphWeaver::Codegen
|
|
|
146
148
|
# reused rather than replaced, so re-declaring the same source (a Rails
|
|
147
149
|
# to_prepare reload) keeps the module already-loaded structs include
|
|
148
150
|
mod = const_under(namespace, name) { Module.new }
|
|
149
|
-
|
|
151
|
+
aliases.merge!(collect_aliases(graphql_name, mod, aliases, &block))
|
|
150
152
|
mod
|
|
151
153
|
end
|
|
152
154
|
private :helper_module
|
|
153
155
|
|
|
156
|
+
# Run the block with `alias_field` available — the alias: keyword said one
|
|
157
|
+
# line at a time — and hand back what it collected.
|
|
158
|
+
#
|
|
159
|
+
# It lives on the module's singleton for the length of the block and is
|
|
160
|
+
# removed after: a generated struct includes this module, and an
|
|
161
|
+
# `alias_field` left behind would be an instance method the wire never named.
|
|
162
|
+
def collect_aliases(graphql_name, mod, keyword_aliases, &block)
|
|
163
|
+
registry, collected = self, {}
|
|
164
|
+
mod.define_singleton_method(:alias_field) do |name, path = nil, **kw|
|
|
165
|
+
collected.merge!(registry.send(:one_alias, graphql_name, name, path, kw))
|
|
166
|
+
end
|
|
167
|
+
mod.module_eval(&block)
|
|
168
|
+
|
|
169
|
+
twice = keyword_aliases.keys & collected.keys
|
|
170
|
+
unless twice.empty?
|
|
171
|
+
raise ArgumentError, "extend_type(#{graphql_name.to_s.inspect}) declares alias " \
|
|
172
|
+
"#{twice.first.inspect} twice — once as alias:, once as alias_field; keep one"
|
|
173
|
+
end
|
|
174
|
+
collected
|
|
175
|
+
ensure
|
|
176
|
+
mod.singleton_class.send(:remove_method, :alias_field)
|
|
177
|
+
end
|
|
178
|
+
private :collect_aliases
|
|
179
|
+
|
|
180
|
+
ALIAS_FIELD_FORMS = %(one alias per line — alias_field "meta.tag", or alias_field :tag, "meta.tag"; ) +
|
|
181
|
+
%(a Hash or Array of paths goes on the alias: keyword)
|
|
182
|
+
private_constant :ALIAS_FIELD_FORMS
|
|
183
|
+
|
|
184
|
+
# One `alias_field` line, normalized the way the keyword's own paths are.
|
|
185
|
+
# The block takes no optional: — leniency has one spelling, on the keyword,
|
|
186
|
+
# because it is a property of the registration and not of one accessor.
|
|
187
|
+
def one_alias(graphql_name, name, path, kw)
|
|
188
|
+
if kw.key?(:optional)
|
|
189
|
+
keyword = path ? "alias: { #{name}: #{path.inspect} }" : "alias: #{name.inspect}"
|
|
190
|
+
raise ArgumentError, "alias_field is always strict — for a lenient alias use the keyword: " \
|
|
191
|
+
"extend_type(#{graphql_name.to_s.inspect}, #{keyword}, optional: true)"
|
|
192
|
+
end
|
|
193
|
+
raise ArgumentError, "unknown keyword: #{kw.keys.first}" unless kw.empty?
|
|
194
|
+
|
|
195
|
+
input = if path.nil? && name.is_a?(String)
|
|
196
|
+
name
|
|
197
|
+
elsif path.is_a?(String) && (name.is_a?(String) || name.is_a?(Symbol))
|
|
198
|
+
{ name => path }
|
|
199
|
+
end
|
|
200
|
+
if input.nil?
|
|
201
|
+
# spelled as a caller writes it — Hash#inspect changed between Ruby 3.3 and 3.4
|
|
202
|
+
given = [name, path].compact.map do |arg|
|
|
203
|
+
arg.is_a?(Hash) ? "{#{arg.map { |k, v| "#{k}: #{v.inspect}" }.join(", ")}}" : arg.inspect
|
|
204
|
+
end.join(", ")
|
|
205
|
+
raise ArgumentError, "alias_field #{given}: #{ALIAS_FIELD_FORMS}"
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
normalize_aliases(input, optional: false)
|
|
209
|
+
end
|
|
210
|
+
private :one_alias
|
|
211
|
+
|
|
154
212
|
# Where this registry's block-built helpers live: under a module named for
|
|
155
213
|
# the graph, so two graphs extending the same type get two constants and
|
|
156
214
|
# neither has to know the other exists.
|
data/lib/graph_weaver/codegen.rb
CHANGED
|
@@ -154,6 +154,12 @@ class GraphWeaver::Codegen
|
|
|
154
154
|
# queries so the build says them once, for GraphWeaver.untyped_scalars.
|
|
155
155
|
def untyped_scalars = @untyped_scalars.uniq.sort
|
|
156
156
|
|
|
157
|
+
# The block-built type helpers this walk included, by constant name. They are
|
|
158
|
+
# minted at registration, so no source file declares them and an app's
|
|
159
|
+
# `srb tc` can't resolve the include this emitted — the generate! workflow
|
|
160
|
+
# unions these across queries and declares them in an .rbi.
|
|
161
|
+
def block_helpers = @block_helpers.uniq.sort
|
|
162
|
+
|
|
157
163
|
# The shared types artifact: every type a schema shares across query modules,
|
|
158
164
|
# emitted once as a manifest (types.rb) plus one file per type under types/,
|
|
159
165
|
# so a schema migration diffs only the types it touched. Returns
|
|
@@ -245,6 +251,8 @@ class GraphWeaver::Codegen
|
|
|
245
251
|
@input_hops = []
|
|
246
252
|
@mapped_enums = {}
|
|
247
253
|
@used_unions = []
|
|
254
|
+
# block-built type helpers this walk included — see #block_helpers
|
|
255
|
+
@block_helpers = []
|
|
248
256
|
# requires the generated file needs (custom scalars, enum mappings,
|
|
249
257
|
# type helpers all contribute)
|
|
250
258
|
@requires = []
|
|
@@ -840,6 +848,7 @@ class GraphWeaver::Codegen
|
|
|
840
848
|
node = ObjectNode.new(class_name)
|
|
841
849
|
node.graphql_type = type.graphql_name
|
|
842
850
|
node.mixins = type_mixins(type.graphql_name)
|
|
851
|
+
node.overrides = abstract_mixin_members(type.graphql_name)
|
|
843
852
|
# class name => the result key that claimed it; the struct itself first,
|
|
844
853
|
# claimed by nothing (see pick_name)
|
|
845
854
|
taken = { class_name => nil }
|
|
@@ -1315,7 +1324,8 @@ class GraphWeaver::Codegen
|
|
|
1315
1324
|
child = type_ref(argument.type) { variable_core(argument.type.unwrap) }
|
|
1316
1325
|
@input_hops.pop
|
|
1317
1326
|
required = child.non_null? && !argument.default_value?
|
|
1318
|
-
node.fields << InputNode::Field.new(prop, argument.graphql_name, child, required
|
|
1327
|
+
node.fields << InputNode::Field.new(prop, argument.graphql_name, child, required,
|
|
1328
|
+
argument.type.to_type_signature)
|
|
1319
1329
|
end
|
|
1320
1330
|
check_input_props!(core, node)
|
|
1321
1331
|
node
|
|
@@ -1396,15 +1406,18 @@ class GraphWeaver::Codegen
|
|
|
1396
1406
|
"constant — map it onto one of yours: register_enum(#{core.graphql_name.inspect}, YourEnum)"
|
|
1397
1407
|
end
|
|
1398
1408
|
|
|
1399
|
-
|
|
1409
|
+
aliases = @registry.enum_registry[core.graphql_name]&.aliases_for(core.values.keys.sort) || {}
|
|
1410
|
+
EnumNode.new(class_name, enum_values(core, aliases), aliases)
|
|
1400
1411
|
end
|
|
1401
1412
|
|
|
1402
|
-
# A schema enum's wire values, sorted so output is
|
|
1403
|
-
# sources (SDL round-trips reorder values
|
|
1404
|
-
#
|
|
1405
|
-
#
|
|
1406
|
-
|
|
1407
|
-
|
|
1413
|
+
# A schema enum's constant-bearing wire values, sorted so output is
|
|
1414
|
+
# deterministic across schema sources (SDL round-trips reorder values
|
|
1415
|
+
# alphabetically). An aliased spelling is read as another of the values, so
|
|
1416
|
+
# it gets no constant. Values that differ only in case name the same T::Enum
|
|
1417
|
+
# constant, which raises at LOAD time ("Enum values must be assigned to
|
|
1418
|
+
# constants") — catch it here instead.
|
|
1419
|
+
def enum_values(core, aliases = {})
|
|
1420
|
+
values = core.values.keys.sort - aliases.keys
|
|
1408
1421
|
# `_` and `__` are legal GraphQL enum values and camelize to nothing, so
|
|
1409
1422
|
# the emitted `= new("_")` isn't even parseable — the file fails at load
|
|
1410
1423
|
# with a syntax error pointing into generated source
|
|
@@ -1415,12 +1428,18 @@ class GraphWeaver::Codegen
|
|
|
1415
1428
|
"one of yours: register_enum(#{core.graphql_name.inspect}, YourEnum)"
|
|
1416
1429
|
end
|
|
1417
1430
|
|
|
1418
|
-
|
|
1419
|
-
|
|
1431
|
+
# A schema mid-rename declares every value twice, so every pair collides:
|
|
1432
|
+
# name one, count the rest, and print the registration that fixes them all.
|
|
1433
|
+
collisions = values.group_by { |value| camelize(value.downcase) }.select { |_, group| group.size > 1 }
|
|
1434
|
+
if collisions.any?
|
|
1435
|
+
constant, group = collisions.first
|
|
1436
|
+
more = collisions.size - 1
|
|
1420
1437
|
raise GraphWeaver::Error,
|
|
1421
|
-
"enum #{core.graphql_name} values #{
|
|
1422
|
-
"#{
|
|
1423
|
-
"
|
|
1438
|
+
"enum #{core.graphql_name} values #{group.join(" and ")} both become the constant #{constant}" \
|
|
1439
|
+
"#{" (and #{more} more colliding pair#{"s" if more > 1})" unless more.zero?} — if each pair is one " \
|
|
1440
|
+
"value, say which spelling goes on the wire:\n " \
|
|
1441
|
+
"#{EnumType.alias_suggestion(core.graphql_name, collisions.values)}\n" \
|
|
1442
|
+
"or map the enum onto one of yours: register_enum(#{core.graphql_name.inspect}, YourEnum)"
|
|
1424
1443
|
end
|
|
1425
1444
|
|
|
1426
1445
|
values
|
|
@@ -1432,14 +1451,40 @@ class GraphWeaver::Codegen
|
|
|
1432
1451
|
return [] unless entry
|
|
1433
1452
|
|
|
1434
1453
|
@requires.concat(entry[:requires])
|
|
1435
|
-
entry[:mixins].map(&:name)
|
|
1454
|
+
names = entry[:mixins].map(&:name)
|
|
1455
|
+
# GraphWeaver::TypeHelpers is where extend_type mints a block's mixin, and
|
|
1456
|
+
# a module there exists in no source file — so generation has to declare it
|
|
1457
|
+
@block_helpers.concat(names.grep(BLOCK_HELPER))
|
|
1458
|
+
names
|
|
1459
|
+
end
|
|
1460
|
+
|
|
1461
|
+
# A block-built mixin's constant, by the namespace extend_type mints it under.
|
|
1462
|
+
BLOCK_HELPER = /\AGraphWeaver::TypeHelpers::/
|
|
1463
|
+
private_constant :BLOCK_HELPER
|
|
1464
|
+
|
|
1465
|
+
# Struct members a registered mixin declares abstract (docs/generated_modules.md
|
|
1466
|
+
# recommends that shape so a named helper carries sigs srb tc can check).
|
|
1467
|
+
# Sorbet demands the override be declared on whatever satisfies an abstract
|
|
1468
|
+
# sig, and nothing generation writes can be corrected by hand — a `const` has
|
|
1469
|
+
# no sig to insert `override.` into — so codegen derives it, the same way it
|
|
1470
|
+
# derives an alias's type. Mixin ancestors count: an inherited abstract sig is
|
|
1471
|
+
# one Sorbet demands the override for just the same.
|
|
1472
|
+
def abstract_mixin_members(graphql_name)
|
|
1473
|
+
entry = @registry.type_registry[graphql_name]
|
|
1474
|
+
return [] unless entry
|
|
1475
|
+
|
|
1476
|
+
entry[:mixins].flat_map { |mixin|
|
|
1477
|
+
T::AbstractUtils.declared_abstract_methods_for(mixin).map { |method| method.name.to_s }
|
|
1478
|
+
}.uniq
|
|
1436
1479
|
end
|
|
1480
|
+
private :abstract_mixin_members
|
|
1437
1481
|
|
|
1438
1482
|
# The MappedEnum node for a schema enum with a registered app-enum
|
|
1439
|
-
# mapping; nil when unregistered
|
|
1483
|
+
# mapping; nil when unregistered — or registered for alias: alone, which
|
|
1484
|
+
# says nothing about the Ruby type — falling back to a generated T::Enum.
|
|
1440
1485
|
def mapped_enum_node(core)
|
|
1441
1486
|
enum_type = @registry.enum_registry[core.graphql_name]
|
|
1442
|
-
return unless enum_type
|
|
1487
|
+
return unless enum_type&.type
|
|
1443
1488
|
|
|
1444
1489
|
@requires.concat(enum_type.requires)
|
|
1445
1490
|
@mapped_enums[core.graphql_name] ||= MappedEnum.new(enum_type, core.values.keys.sort)
|
data/lib/graph_weaver/coerce.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require "date" # Date/DateTime, named below
|
|
5
|
+
require "time" # Time.iso8601
|
|
5
6
|
|
|
6
7
|
require_relative "errors"
|
|
7
8
|
require_relative "internal/refusal"
|
|
@@ -38,19 +39,24 @@ module GraphWeaver
|
|
|
38
39
|
# app translates for a user. It defaults to the GraphQL scalar the rule
|
|
39
40
|
# is named for; generated code passes the schema's own name, so a
|
|
40
41
|
# `register_scalar("BigInt", Integer)` field refuses as a BigInt.
|
|
42
|
+
#
|
|
43
|
+
# A real number converts to the number the scalar names, and whether it
|
|
44
|
+
# is whole is asked of the value — so a BigDecimal off a decimal column
|
|
45
|
+
# and a Rational are ordinary arguments.
|
|
41
46
|
def integer(value, scalar = "Int")
|
|
42
47
|
case value
|
|
43
48
|
when Integer then value
|
|
44
|
-
when
|
|
49
|
+
when Numeric then whole(real(value, scalar), scalar)
|
|
45
50
|
when String then INTEGER.match?(value.strip) ? Integer(value.strip, 10) : unparseable(value, scalar)
|
|
46
51
|
else refuse(value, scalar)
|
|
47
52
|
end
|
|
48
53
|
end
|
|
49
54
|
|
|
55
|
+
# Float's own precision is what asking for a Float means, so a BigDecimal
|
|
56
|
+
# past it loses digits here rather than being refused.
|
|
50
57
|
def float(value, scalar = "Float")
|
|
51
58
|
case value
|
|
52
|
-
when
|
|
53
|
-
when Integer then finite(value.to_f, scalar)
|
|
59
|
+
when Numeric then finite(real(value, scalar).to_f, scalar)
|
|
54
60
|
when String then NUMBER.match?(value.strip) ? finite(Float(value.strip), scalar) : unparseable(value, scalar)
|
|
55
61
|
else refuse(value, scalar)
|
|
56
62
|
end
|
|
@@ -70,12 +76,16 @@ module GraphWeaver
|
|
|
70
76
|
end
|
|
71
77
|
end
|
|
72
78
|
|
|
79
|
+
# A timestamp string is ISO 8601, read by Time.iso8601 — the coercer
|
|
80
|
+
# graphql-ruby's own ISO8601DateTime reads input with, and the only
|
|
81
|
+
# shape its coerce_result writes. Time.parse also reads "Jan 15 2024
|
|
82
|
+
# 10:20", which no server sends and no generated struct should take.
|
|
73
83
|
def time(value, scalar = "Time")
|
|
74
84
|
case value
|
|
75
85
|
when Time then value
|
|
76
86
|
when DateTime then value.to_time # the same instant in another class
|
|
77
87
|
when Date then cross(value, scalar, TIME_HINT)
|
|
78
|
-
when String then parsing(scalar, value) { Time.
|
|
88
|
+
when String then parsing(scalar, value) { Time.iso8601(value) }
|
|
79
89
|
else time_like?(value) ? value.to_time : refuse(value, scalar)
|
|
80
90
|
end
|
|
81
91
|
end
|
|
@@ -194,8 +204,17 @@ module GraphWeaver
|
|
|
194
204
|
raise mismatch(ArgumentError, "#{expected(scalar)}, got #{shown(value)} — not a finite number", scalar)
|
|
195
205
|
end
|
|
196
206
|
|
|
207
|
+
# Complex is the one Numeric that is not a number a scalar can name, and
|
|
208
|
+
# #real? is the predicate for it — no class list to keep up to date.
|
|
209
|
+
def real(value, scalar)
|
|
210
|
+
return value if value.real?
|
|
211
|
+
|
|
212
|
+
refuse(value, scalar)
|
|
213
|
+
end
|
|
214
|
+
|
|
197
215
|
def whole(value, scalar = "Int")
|
|
198
|
-
# Integer(2.5) is 2 — a silent loss where refusing costs nothing
|
|
216
|
+
# Integer(2.5) is 2 — a silent loss where refusing costs nothing. `% 1`
|
|
217
|
+
# is exact for a BigDecimal and a Rational; asking Float would not be.
|
|
199
218
|
return value.to_i if value.finite? && (value % 1).zero?
|
|
200
219
|
|
|
201
220
|
raise mismatch(ArgumentError, "#{expected(scalar)}, got #{shown(value)} — not a whole number", scalar)
|
data/lib/graph_weaver/graph.rb
CHANGED
|
@@ -54,12 +54,15 @@ module GraphWeaver
|
|
|
54
54
|
# block runs once, at the declaration (GraphBuilder.build reads the
|
|
55
55
|
# registry there), and every read after replays the module it made.
|
|
56
56
|
def replay(registry, registration)
|
|
57
|
-
call, args, kwargs, block = registration
|
|
57
|
+
call, args, kwargs, block, aliases = registration
|
|
58
58
|
entry = registry.public_send(call, *args, **kwargs, &block)
|
|
59
|
+
# the block's alias_field lines ran with it, so replay them too
|
|
60
|
+
entry[:aliases].merge!(aliases) if aliases
|
|
59
61
|
return unless block && call == :extend_type
|
|
60
62
|
|
|
61
63
|
registration[1] = args + [entry[:mixins].last]
|
|
62
64
|
registration[3] = nil
|
|
65
|
+
registration[4] = entry[:aliases].dup
|
|
63
66
|
end
|
|
64
67
|
private :replay
|
|
65
68
|
|
data/lib/graph_weaver/hints.rb
CHANGED
|
@@ -68,7 +68,10 @@ module GraphWeaver
|
|
|
68
68
|
# you generated) rather than a bad value, and T::Enum's own KeyError says
|
|
69
69
|
# neither that nor which values exist. Raised bare so the enclosing
|
|
70
70
|
# Hints.field brands it with the field.
|
|
71
|
-
|
|
71
|
+
# aliases (register_enum alias:) is wire spelling => the value it reads as.
|
|
72
|
+
def self.enum(type, value, aliases = nil)
|
|
73
|
+
value = aliases.fetch(value, value) if aliases
|
|
74
|
+
|
|
72
75
|
type.try_deserialize(value) || drifted!(type, value, type.values.map(&:serialize))
|
|
73
76
|
end
|
|
74
77
|
|
|
@@ -21,18 +21,21 @@ module GraphWeaver
|
|
|
21
21
|
|
|
22
22
|
# serializer/coercer are code-as-data from the generated file; nil
|
|
23
23
|
# means identity (the wire value passes through untouched). coordinate
|
|
24
|
-
# is the schema's name for the slot ("PetFilter.species")
|
|
25
|
-
#
|
|
26
|
-
|
|
24
|
+
# is the schema's name for the slot ("PetFilter.species") and type its
|
|
25
|
+
# spelling of what goes there ("[Float!]!"), so a refusal can say where
|
|
26
|
+
# it happened, and in whose vocabulary, without reflecting at runtime.
|
|
27
|
+
Field = Data.define(:prop, :wire, :required, :serializer, :coercer, :coordinate, :type)
|
|
27
28
|
|
|
28
29
|
# An enum reaching the library as input — an execute kwarg or an input
|
|
29
30
|
# field — as the member or its wire value. Generated code calls these
|
|
30
31
|
# rather than T::Enum.deserialize / the wire table directly: both raise a
|
|
31
32
|
# bare KeyError naming an anonymous module and none of the values they
|
|
32
33
|
# would have taken.
|
|
33
|
-
def self.enum(type, value)
|
|
34
|
+
def self.enum(type, value, aliases = nil)
|
|
34
35
|
return value if value.is_a?(type)
|
|
35
36
|
|
|
37
|
+
value = aliases.fetch(value, value) if aliases
|
|
38
|
+
|
|
36
39
|
type.try_deserialize(value) || invalid_enum!(type, value, type.values.map(&:serialize))
|
|
37
40
|
end
|
|
38
41
|
|
|
@@ -260,11 +263,14 @@ module GraphWeaver
|
|
|
260
263
|
# out blaming the list that held the struct, in sorbet's words.
|
|
261
264
|
next if T::Utils.coerce(info[:type_object]).recursively_valid?(value)
|
|
262
265
|
|
|
263
|
-
|
|
266
|
+
# the SCHEMA's spelling, from the FIELDS row — #details[:type] is
|
|
267
|
+
# what an app translates for a user, and "T::Array[Float]" is the
|
|
268
|
+
# library's vocabulary leaking into theirs
|
|
269
|
+
field = T.unsafe(self).const_get(:FIELDS).find { |f| f.prop == prop }
|
|
270
|
+
type = field&.type || T::Utils.coerce(info[:type]).to_s
|
|
264
271
|
return GraphWeaver::InputError.new(
|
|
265
272
|
"#{prop}: expected #{type}, got #{GraphWeaver::Internal::Redact.shown(value, prop)}",
|
|
266
|
-
kind: :type_mismatch, path: [prop.to_s],
|
|
267
|
-
coordinate: T.unsafe(self).const_get(:FIELDS).find { |f| f.prop == prop }&.coordinate,
|
|
273
|
+
kind: :type_mismatch, path: [prop.to_s], coordinate: field&.coordinate,
|
|
268
274
|
value: GraphWeaver::Internal::Redact.value(prop, value),
|
|
269
275
|
details: { type: }, struct: self,
|
|
270
276
|
)
|
|
@@ -51,8 +51,11 @@ class GraphWeaver::Internal::Values
|
|
|
51
51
|
UNREGISTERED = "T.untyped"
|
|
52
52
|
|
|
53
53
|
# What JSON can hold. Anything else a pin offers is a Ruby object the
|
|
54
|
-
# registration has to serialize before it can stand in for a response
|
|
55
|
-
|
|
54
|
+
# registration has to serialize before it can stand in for a response —
|
|
55
|
+
# Integer and Float, not Numeric, because a BigDecimal is a Numeric that
|
|
56
|
+
# JSON has no spelling for, and taking one as written sent 12.5 where the
|
|
57
|
+
# registration says the server writes "12.5".
|
|
58
|
+
WIRE = [NilClass, TrueClass, FalseClass, Integer, Float, String, Symbol, Array, Hash].freeze
|
|
56
59
|
|
|
57
60
|
# The fallback, for a scalar nobody registered: its prop is T.untyped, so
|
|
58
61
|
# anything holds and a plausible shape beats a placeholder.
|
|
@@ -299,6 +299,73 @@ module GraphWeaver
|
|
|
299
299
|
end
|
|
300
300
|
end
|
|
301
301
|
|
|
302
|
+
# One query, checked against one schema. Both doors onto it —
|
|
303
|
+
# GraphWeaver.check_queries (every file on disk) and Client#check_query
|
|
304
|
+
# (a string) — report the same hashes and brand subgraphs the same way,
|
|
305
|
+
# because the implementation lives here rather than once each.
|
|
306
|
+
module QueryCheck
|
|
307
|
+
class << self
|
|
308
|
+
# A query's schema-validation errors as JSON-ready hashes, with the
|
|
309
|
+
# source position graphql-ruby reports. Unparseable counts as an error
|
|
310
|
+
# too — it doesn't validate either, and inline_fragments (which parses
|
|
311
|
+
# first) has already branded it with its position.
|
|
312
|
+
def errors(schema, source, shared, table = nil)
|
|
313
|
+
# path omitted: check_queries keys its report by file, so branding the
|
|
314
|
+
# message with it too would just print the path twice
|
|
315
|
+
schema.validate(Codegen.inline_fragments(source, shared)).map do |error|
|
|
316
|
+
detail = error.to_h
|
|
317
|
+
location = detail["locations"]&.first || {}
|
|
318
|
+
subgraphs = table ? attribute(table, detail["extensions"]) : []
|
|
319
|
+
entry = {
|
|
320
|
+
"message" => subgraphs.empty? ? error.message : "#{error.message} (#{subgraphs.join(", ")})",
|
|
321
|
+
"line" => location["line"],
|
|
322
|
+
"column" => location["column"],
|
|
323
|
+
}
|
|
324
|
+
subgraphs.empty? ? entry : entry.merge("subgraphs" => subgraphs)
|
|
325
|
+
end
|
|
326
|
+
rescue GraphWeaver::QueryValidationError => e
|
|
327
|
+
# an unparseable query: codegen folds the position (and the file) into
|
|
328
|
+
# the message, and this report keeps them separate — same splitter the
|
|
329
|
+
# rendered error uses, so the two can't drift apart
|
|
330
|
+
e.errors.map do |detail|
|
|
331
|
+
_path, _position, message = GraphWeaver::QueryValidationError.split(detail)
|
|
332
|
+
detail.transform_keys(&:to_s).merge("message" => message)
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# The routing table behind a dump path, when the dump is a composed
|
|
337
|
+
# supergraph: it says who resolves what, so a validation error can name
|
|
338
|
+
# the subgraph whose code to look at. nil for anything else — a plain
|
|
339
|
+
# schema, a url client, a live class are all unaffected.
|
|
340
|
+
def routing_table_for(path)
|
|
341
|
+
path = path.to_path if path.respond_to?(:to_path)
|
|
342
|
+
return unless path&.end_with?(".graphql", ".gql")
|
|
343
|
+
|
|
344
|
+
sdl = File.read(path)
|
|
345
|
+
SchemaLoader.routing_table(sdl) if SchemaLoader.federation_sdl?(sdl)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
private
|
|
349
|
+
|
|
350
|
+
# Which subgraphs a validation error is about, on a federated schema:
|
|
351
|
+
# "Field 'weight' doesn't exist on type 'Product'" is much less useful
|
|
352
|
+
# than the same line plus "(products)" — whose code to look at, whose
|
|
353
|
+
# team to talk to. graphql-ruby reports the coordinate structurally, so
|
|
354
|
+
# this is a lookup rather than message parsing. Both halves of the
|
|
355
|
+
# coordinate are required: an argument error reports typeName "Field"
|
|
356
|
+
# (the AST node kind, not a type), and looking that up would attribute
|
|
357
|
+
# confidently and wrongly.
|
|
358
|
+
def attribute(table, extensions)
|
|
359
|
+
return [] unless extensions
|
|
360
|
+
|
|
361
|
+
type_name, field_name = extensions.values_at("typeName", "fieldName")
|
|
362
|
+
return [] unless type_name && field_name
|
|
363
|
+
|
|
364
|
+
table.responsible(type_name, field_name)
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
302
369
|
# What makes two GraphQL requests the same request — and how one reads
|
|
303
370
|
# when an error has to quote it. A cassette matches on this, so the
|
|
304
371
|
# rules belong somewhere both the cassette and the error that reports a
|