graph_weaver 0.4.4 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1357 -0
- data/CLAUDE.md +100 -8
- data/DECISIONS.md +309 -0
- data/Gemfile.lock +23 -23
- data/NOTES.md +5 -5
- data/PLAN.md +106 -135
- data/README.md +115 -96
- data/REVIEW.md +946 -0
- data/docs/cassettes.md +75 -48
- data/docs/editors.md +82 -0
- data/docs/errors.md +32 -30
- data/docs/federation.md +520 -48
- data/docs/generated_modules.md +352 -137
- data/docs/getting_started.md +237 -67
- data/docs/logging.md +35 -6
- data/docs/real_world.md +21 -15
- data/docs/scalars.md +49 -136
- data/docs/testing.md +299 -52
- data/docs/transports.md +129 -30
- data/docs/upgrading.md +112 -0
- data/graph_weaver.gemspec +3 -1
- data/lib/generators/graph_weaver/install_generator.rb +259 -0
- data/lib/graph_weaver/client.rb +114 -111
- data/lib/graph_weaver/codegen/aliases.rb +217 -0
- data/lib/graph_weaver/codegen/emit.rb +272 -251
- data/lib/graph_weaver/codegen/enum_type.rb +27 -98
- data/lib/graph_weaver/codegen/nodes.rb +72 -13
- data/lib/graph_weaver/codegen/scalar_type.rb +72 -67
- data/lib/graph_weaver/codegen/type_helpers.rb +142 -0
- data/lib/graph_weaver/codegen.rb +617 -264
- data/lib/graph_weaver/errors.rb +127 -10
- data/lib/graph_weaver/federation.rb +272 -0
- data/lib/graph_weaver/hints.rb +12 -6
- data/lib/graph_weaver/in_process.rb +90 -0
- data/lib/graph_weaver/input_struct.rb +21 -2
- data/lib/graph_weaver/logging.rb +29 -0
- data/lib/graph_weaver/parsing.rb +67 -0
- data/lib/graph_weaver/query_module.rb +55 -0
- data/lib/graph_weaver/railtie.rb +23 -1
- data/lib/graph_weaver/representation.rb +74 -0
- data/lib/graph_weaver/response.rb +15 -1
- data/lib/graph_weaver/retry.rb +29 -8
- data/lib/graph_weaver/rspec.rb +214 -16
- data/lib/graph_weaver/schema_loader.rb +820 -57
- data/lib/graph_weaver/schemas.rb +46 -0
- data/lib/graph_weaver/selection.rb +59 -7
- data/lib/graph_weaver/tasks.rb +216 -21
- data/lib/graph_weaver/testing/cassette.rb +186 -62
- data/lib/graph_weaver/testing/coverage.rb +165 -0
- data/lib/graph_weaver/testing/failure.rb +10 -23
- data/lib/graph_weaver/testing/fake_client.rb +194 -28
- data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
- data/lib/graph_weaver/testing/router.rb +1431 -0
- data/lib/graph_weaver/testing/subgraphs.rb +130 -0
- data/lib/graph_weaver/testing.rb +204 -14
- data/lib/graph_weaver/transport/faraday.rb +31 -6
- data/lib/graph_weaver/transport/http.rb +99 -36
- data/lib/graph_weaver/transport.rb +74 -18
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +398 -170
- metadata +20 -3
|
@@ -22,6 +22,15 @@ class GraphWeaver::Codegen
|
|
|
22
22
|
|
|
23
23
|
def initialize(graphql_name, type, map: nil, fallback: nil, requires: nil)
|
|
24
24
|
@graphql_name = graphql_name.to_s
|
|
25
|
+
# A name, not the class, is what you write when the constant won't
|
|
26
|
+
# resolve yet — which in Rails means a config/initializers file, since
|
|
27
|
+
# autoloading is set up after those run. Say where it does resolve.
|
|
28
|
+
if type.is_a?(String)
|
|
29
|
+
raise ArgumentError, "type: is the T::Enum itself, not its name — " \
|
|
30
|
+
"register_enum(#{@graphql_name.inspect}, #{type}). An autoloaded constant isn't " \
|
|
31
|
+
"resolvable while config/initializers run; register from a " \
|
|
32
|
+
"Rails.application.config.to_prepare block, which generation also runs first."
|
|
33
|
+
end
|
|
25
34
|
unless type.is_a?(Class) && type < T::Enum
|
|
26
35
|
raise ArgumentError, "type: must be a T::Enum subclass, got #{type.inspect}"
|
|
27
36
|
end
|
|
@@ -32,7 +41,7 @@ class GraphWeaver::Codegen
|
|
|
32
41
|
@type = type
|
|
33
42
|
@map = map || {}
|
|
34
43
|
@fallback = fallback
|
|
35
|
-
@requires =
|
|
44
|
+
@requires = GraphWeaver::Codegen.normalize_requires!(requires, load: true)
|
|
36
45
|
|
|
37
46
|
if fallback && !type.values.include?(fallback)
|
|
38
47
|
raise ArgumentError, "fallback: must be a #{type} member, got #{fallback.inspect}"
|
|
@@ -72,110 +81,30 @@ class GraphWeaver::Codegen
|
|
|
72
81
|
end
|
|
73
82
|
|
|
74
83
|
class << self
|
|
75
|
-
# Map a GraphQL enum onto an app-owned T::Enum (see EnumType)
|
|
76
|
-
#
|
|
77
|
-
|
|
78
|
-
enum_registry[graphql_name.to_s] = EnumType.new(graphql_name, type, map:, fallback:, requires:)
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
# Bulk, inference-only form: register_enums("Species" => PetKind, ...)
|
|
82
|
-
def register_enums(mappings)
|
|
83
|
-
mappings.each { |graphql_name, type| register_enum(graphql_name, type) }
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
def enum_registry
|
|
87
|
-
@enum_registry ||= {}
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
# Attach app-owned helper modules to every struct generated from a
|
|
91
|
-
# GraphQL type — the logic stays in your code, generation wires it in:
|
|
92
|
-
#
|
|
93
|
-
# GraphWeaver.extend_type("Pet", PetHelpers)
|
|
94
|
-
#
|
|
95
|
-
# Or build the mixin inline — the block is module_eval'd into a fresh
|
|
96
|
-
# module auto-named GraphWeaver::TypeHelpers::<Type>. Handy for quick
|
|
97
|
-
# decoration; srb tc can't see into block-defined methods, so prefer
|
|
98
|
-
# a named module where static checking matters:
|
|
99
|
-
#
|
|
100
|
-
# GraphWeaver.extend_type("Pet") do
|
|
101
|
-
# def display_name = "#{name} the pet"
|
|
102
|
-
# end
|
|
103
|
-
#
|
|
104
|
-
# Additive: repeated registrations (and client-scoped ones) stack.
|
|
105
|
-
#
|
|
106
|
-
# alias: projects a (possibly nested) selected field onto a flat, typed
|
|
107
|
-
# accessor on the struct — the one derivation codegen can type itself, so
|
|
108
|
-
# it's emitted into the struct body where the field is in scope:
|
|
84
|
+
# Map a GraphQL enum onto an app-owned T::Enum (see EnumType). The one
|
|
85
|
+
# implementation — GraphWeaver.register_enum is a delegate, so the same
|
|
86
|
+
# call reaches it whichever door you came in by.
|
|
109
87
|
#
|
|
110
|
-
#
|
|
111
|
-
#
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
add_type_helpers(entry, graphql_name, mixins, requires, block, aliases)
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
# Pull alias: out of the keyword rest and normalize it; any other keyword
|
|
120
|
-
# is a typo worth flagging rather than silently dropping.
|
|
121
|
-
def take_aliases(kw)
|
|
122
|
-
aliases = normalize_aliases(kw.delete(:alias))
|
|
123
|
-
raise ArgumentError, "unknown keyword: #{kw.keys.first}" unless kw.empty?
|
|
124
|
-
aliases
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
# { accessor => [path, segments] } from a path string (accessor named
|
|
128
|
-
# after the last segment), an array of such, or an { accessor => path } hash.
|
|
129
|
-
def normalize_aliases(input)
|
|
130
|
-
case input
|
|
131
|
-
when nil then {}
|
|
132
|
-
when String then { input.split(".").last => input.split(".") }
|
|
133
|
-
when Array then input.to_h { |path| [path.split(".").last, path.split(".")] }
|
|
134
|
-
when Hash then input.to_h { |name, path| [name.to_s, path.to_s.split(".")] }
|
|
135
|
-
else raise ArgumentError, "alias: expects a String, Array, or Hash, got #{input.class}"
|
|
88
|
+
# A value map is a natural third *positional* guess, and Ruby's arity
|
|
89
|
+
# complaint ("given 3, expected 2") never mentions the keyword.
|
|
90
|
+
def register_enum(graphql_name, type, positional_map = nil, map: nil, fallback: nil, requires: nil)
|
|
91
|
+
if positional_map
|
|
92
|
+
raise GraphWeaver::Error, "register_enum: the value map is a keyword — " \
|
|
93
|
+
"register_enum(#{graphql_name.inspect}, #{type}, map: {...})"
|
|
136
94
|
end
|
|
137
|
-
end
|
|
138
95
|
|
|
139
|
-
|
|
140
|
-
@type_registry ||= {}
|
|
96
|
+
enum_registry[graphql_name.to_s] = EnumType.new(graphql_name, type, map:, fallback:, requires:)
|
|
141
97
|
end
|
|
142
98
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
def add_type_helpers(entry, graphql_name, mixins, requires, block, aliases = {})
|
|
146
|
-
mixins = mixins.dup
|
|
147
|
-
mixins << helper_module(graphql_name, block) if block
|
|
148
|
-
|
|
149
|
-
if mixins.empty? && aliases.empty?
|
|
150
|
-
raise ArgumentError, "pass one or more helper modules, a block, or alias:"
|
|
151
|
-
end
|
|
152
|
-
mixins.each do |mixin|
|
|
153
|
-
unless mixin.is_a?(Module) && mixin.name
|
|
154
|
-
raise ArgumentError, "type helpers must be named modules, got #{mixin.inspect}"
|
|
155
|
-
end
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
entry[:mixins].concat(mixins)
|
|
159
|
-
entry[:requires].concat(Array(requires))
|
|
160
|
-
(entry[:aliases] ||= {}).merge!(aliases)
|
|
161
|
-
entry
|
|
99
|
+
def enum_registry
|
|
100
|
+
@enum_registry ||= {}
|
|
162
101
|
end
|
|
163
102
|
|
|
164
|
-
#
|
|
165
|
-
#
|
|
166
|
-
def
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
count = 1
|
|
170
|
-
name = "#{base}V#{count += 1}" while GraphWeaver::TypeHelpers.const_defined?(name, false)
|
|
171
|
-
GraphWeaver::TypeHelpers.const_set(name, Module.new(&block))
|
|
103
|
+
# Drop every register_enum mapping. No pair with a clear_ twin the way
|
|
104
|
+
# scalars have one: there are no built-in enums to restore.
|
|
105
|
+
def reset_enums!
|
|
106
|
+
enum_registry.clear
|
|
107
|
+
self
|
|
172
108
|
end
|
|
173
|
-
private :helper_module
|
|
174
109
|
end
|
|
175
110
|
end
|
|
176
|
-
|
|
177
|
-
module GraphWeaver
|
|
178
|
-
# Home of block-built type helpers (extend_type with a block), which
|
|
179
|
-
# need constant names so generated files can reference them.
|
|
180
|
-
module TypeHelpers; end
|
|
181
|
-
end
|
|
@@ -56,9 +56,9 @@ class GraphWeaver::Codegen
|
|
|
56
56
|
!@scalar.serialize?
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
-
# coercion (
|
|
60
|
-
#
|
|
61
|
-
#
|
|
59
|
+
# coercion (coerce: per scalar, or GraphWeaver.auto_coerce for all):
|
|
60
|
+
# accept the value or its raw input and normalize before serializing.
|
|
61
|
+
# See ScalarType#coercion.
|
|
62
62
|
def coerce? = @scalar.coerce?
|
|
63
63
|
def coerce(expr) = @scalar.coerce_input(expr)
|
|
64
64
|
def coerce_input_type = @scalar.coerce_type
|
|
@@ -127,6 +127,19 @@ class GraphWeaver::Codegen
|
|
|
127
127
|
|
|
128
128
|
def serialize_identity? = @of.serialize_identity?
|
|
129
129
|
|
|
130
|
+
# A list coerces exactly as its elements do, per element: `sort: ["POPULARITY_DESC"]`
|
|
131
|
+
# has to accept a wire string the way `type: "ANIME"` does. Sorbet's runtime
|
|
132
|
+
# doesn't check element types, so without this a String reached .serialize
|
|
133
|
+
# and raised a NoMethodError naming neither the variable nor the enum.
|
|
134
|
+
def coerce? = !hash_coerce_identity?
|
|
135
|
+
def coerce(expr) = hash_coerce(expr, 1)
|
|
136
|
+
|
|
137
|
+
def coerce_input_type
|
|
138
|
+
element = @of.coerce? ? @of.coerce_input_type : @of.prop_type
|
|
139
|
+
element = "T.nilable(#{element})" if @of.coerce? && !@of.non_null? && element != "T.untyped"
|
|
140
|
+
"T::Array[#{element}]"
|
|
141
|
+
end
|
|
142
|
+
|
|
130
143
|
def hash_coerce(expr, depth)
|
|
131
144
|
var = "v#{depth}"
|
|
132
145
|
inner = if @of.non_null? || @of.hash_coerce_identity?
|
|
@@ -246,30 +259,45 @@ class GraphWeaver::Codegen
|
|
|
246
259
|
|
|
247
260
|
# A single-condition narrowing of an abstract field (`... on Pet { ... }`
|
|
248
261
|
# and nothing else): the member struct when the runtime type matches,
|
|
249
|
-
# nil when it doesn't
|
|
250
|
-
#
|
|
251
|
-
#
|
|
262
|
+
# nil when it doesn't. Always nilable, whatever the schema's nullability,
|
|
263
|
+
# because narrowing filters.
|
|
264
|
+
#
|
|
265
|
+
# typename: is the member's GraphQL name when the selection also carries an
|
|
266
|
+
# unconditional `__typename` — then the match is read off the tag. Without
|
|
267
|
+
# it there is nothing to read but the object's emptiness: a non-match
|
|
268
|
+
# carries none of the selected fields, so the hash arrives empty.
|
|
252
269
|
class NarrowedNode < Node
|
|
253
|
-
def initialize(of)
|
|
270
|
+
def initialize(of, typename: nil)
|
|
254
271
|
@of = of
|
|
272
|
+
@typename = typename
|
|
255
273
|
end
|
|
256
274
|
|
|
257
275
|
def class_name = @of.class_name
|
|
258
276
|
def bare_type = @of.bare_type
|
|
259
277
|
|
|
260
278
|
def cast(expr, depth)
|
|
261
|
-
|
|
279
|
+
if @typename
|
|
280
|
+
"(#{expr}[\"__typename\"] == #{@typename.inspect} ? #{@of.cast(expr, depth)} : nil)"
|
|
281
|
+
else
|
|
282
|
+
"(#{expr}.empty? ? nil : #{@of.cast(expr, depth)})"
|
|
283
|
+
end
|
|
262
284
|
end
|
|
263
285
|
|
|
264
286
|
def nested = @of
|
|
265
287
|
end
|
|
266
288
|
|
|
267
289
|
class UnionNode < Node
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
290
|
+
# class_name is writable: fields sharing one collapsed union settle on the
|
|
291
|
+
# alphabetically first of their keys, which the walk may reach second
|
|
292
|
+
attr_accessor :class_name
|
|
293
|
+
attr_reader :members # graphql type name => ObjectNode
|
|
294
|
+
# the struct an unnamed (or newly-added) __typename deserializes into
|
|
295
|
+
attr_reader :catch_all
|
|
296
|
+
|
|
297
|
+
def initialize(class_name, members, catch_all = nil)
|
|
271
298
|
@class_name = class_name
|
|
272
299
|
@members = members
|
|
300
|
+
@catch_all = catch_all
|
|
273
301
|
end
|
|
274
302
|
|
|
275
303
|
def bare_type = "#{class_name}::Type"
|
|
@@ -281,10 +309,10 @@ class GraphWeaver::Codegen
|
|
|
281
309
|
def nested = self
|
|
282
310
|
end
|
|
283
311
|
|
|
284
|
-
# A reference to a union hoisted into the shared
|
|
312
|
+
# A reference to a union hoisted into the shared types module (a named
|
|
285
313
|
# shared fragment spread as a whole union field): the query references
|
|
286
314
|
# <Name>::Type and dispatches through <Name>.from_h, where <Name> is the
|
|
287
|
-
# alias the query module gives
|
|
315
|
+
# alias the query module gives GraphQLTypes::<Name>. The type family lives
|
|
288
316
|
# once in the shared module, so the same union across queries is one Ruby
|
|
289
317
|
# type — nested is nil, nothing is emitted here.
|
|
290
318
|
class UnionRefNode < Node
|
|
@@ -309,10 +337,15 @@ class GraphWeaver::Codegen
|
|
|
309
337
|
Field = Struct.new(:prop, :wire, :node, :required)
|
|
310
338
|
|
|
311
339
|
attr_reader :class_name, :fields
|
|
340
|
+
# @oneOf: exactly one field may be supplied. The schema can't say so — every
|
|
341
|
+
# @oneOf field is nullable — so the generated struct carries the flag and
|
|
342
|
+
# InputStruct#serialize enforces it.
|
|
343
|
+
attr_accessor :one_of
|
|
312
344
|
|
|
313
345
|
def initialize(class_name)
|
|
314
346
|
@class_name = class_name
|
|
315
347
|
@fields = []
|
|
348
|
+
@one_of = false
|
|
316
349
|
end
|
|
317
350
|
|
|
318
351
|
def bare_type = class_name
|
|
@@ -332,4 +365,30 @@ class GraphWeaver::Codegen
|
|
|
332
365
|
# building a struct field from a caller-supplied plain hash value
|
|
333
366
|
def hash_coerce(expr, _depth) = "#{class_name}.coerce(#{expr})"
|
|
334
367
|
end
|
|
368
|
+
|
|
369
|
+
# One entity's `Representations` builder: the typed constructor for the
|
|
370
|
+
# references an `_entities(representations:)` query takes. `key_sets` are
|
|
371
|
+
# the type's @key field sets as dotted paths ("organization.id"), in
|
|
372
|
+
# declaration order; `params` the union of their top-level fields, which
|
|
373
|
+
# is what the generated method takes as kwargs. Not part of the node
|
|
374
|
+
# protocol — nothing casts or serializes through it — it's a shape emit
|
|
375
|
+
# walks, sitting beside the result tree rather than inside it.
|
|
376
|
+
class RepresentationNode
|
|
377
|
+
# `wire` is the GraphQL field name, `value` the emitted expression that
|
|
378
|
+
# puts the kwarg on the wire (a registered scalar serializes here)
|
|
379
|
+
Param = Struct.new(:kwarg, :wire, :type, :value, :required)
|
|
380
|
+
|
|
381
|
+
attr_reader :method_name, :graphql_type, :key_fields, :key_sets, :params
|
|
382
|
+
|
|
383
|
+
# key_fields are the @key(fields:) strings as written, kept for the
|
|
384
|
+
# comment above the builder — "organization { id }" reads better there
|
|
385
|
+
# than the flattened path it becomes
|
|
386
|
+
def initialize(method_name, graphql_type, key_fields, key_sets, params)
|
|
387
|
+
@method_name = method_name
|
|
388
|
+
@graphql_type = graphql_type
|
|
389
|
+
@key_fields = key_fields
|
|
390
|
+
@key_sets = key_sets
|
|
391
|
+
@params = params
|
|
392
|
+
end
|
|
393
|
+
end
|
|
335
394
|
end
|
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
require "date"
|
|
5
5
|
|
|
6
6
|
class GraphWeaver::Codegen
|
|
7
|
-
|
|
8
7
|
# How one GraphQL scalar maps to Ruby: the Sorbet prop type, the
|
|
9
8
|
# (optional) code emitted to deserialize a wire value into a rich Ruby
|
|
10
9
|
# object and serialize it back, and any requires the generated file
|
|
@@ -46,14 +45,13 @@ class GraphWeaver::Codegen
|
|
|
46
45
|
->(type, expr) { "#{type}.dump(#{expr})" }),
|
|
47
46
|
].freeze
|
|
48
47
|
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
to_s: "T.anything",
|
|
48
|
+
# How a built-in converts a loose variable input, and the widened kwarg
|
|
49
|
+
# it then accepts. Only the numerics can: a String/ID/Boolean input is
|
|
50
|
+
# already its own Ruby type, so there is nothing to convert.
|
|
51
|
+
Conversion = Struct.new(:via, :input_type)
|
|
52
|
+
CONVERSIONS = {
|
|
53
|
+
"Int" => Conversion.new(:to_i, "T.any(Integer, Float, String)"),
|
|
54
|
+
"Float" => Conversion.new(:to_f, "T.any(Float, Integer, String)"),
|
|
57
55
|
}.freeze
|
|
58
56
|
|
|
59
57
|
attr_reader :graphql_name, :type, :requires
|
|
@@ -73,49 +71,43 @@ class GraphWeaver::Codegen
|
|
|
73
71
|
validate_coerce!
|
|
74
72
|
end
|
|
75
73
|
|
|
76
|
-
# conversions applied to the four convertible built-ins when the
|
|
77
|
-
# global GraphWeaver.auto_coerce is on and no explicit coerce: given
|
|
78
|
-
AUTO_CONVERSIONS = {
|
|
79
|
-
"ID" => :to_s, "String" => :to_s, "Int" => :to_i, "Float" => :to_f,
|
|
80
|
-
}.freeze
|
|
81
|
-
|
|
82
74
|
def cast(expr) = @cast&.call(expr)
|
|
83
75
|
def cast? = !@cast.nil?
|
|
84
76
|
def serialize(expr) = @serialize&.call(expr)
|
|
85
77
|
def serialize? = !@serialize.nil?
|
|
86
|
-
def coerce? = !!
|
|
78
|
+
def coerce? = !!coercion
|
|
87
79
|
|
|
88
|
-
#
|
|
89
|
-
#
|
|
90
|
-
#
|
|
91
|
-
#
|
|
92
|
-
#
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
return
|
|
80
|
+
# How this scalar coerces a variable input, or nil for not at all.
|
|
81
|
+
# coerce: says WHETHER (explicit always wins); left unset the global
|
|
82
|
+
# GraphWeaver.auto_coerce decides — resolved HERE, at generation time,
|
|
83
|
+
# so registration order doesn't matter. The scalar itself says HOW: a
|
|
84
|
+
# convertible built-in converts, anything with a full cast/serialize
|
|
85
|
+
# pair parses. Nothing left to try means it can't coerce.
|
|
86
|
+
def coercion
|
|
87
|
+
return if @coerce == false
|
|
88
|
+
return if @coerce.nil? && !GraphWeaver.auto_coerce
|
|
96
89
|
|
|
97
|
-
|
|
90
|
+
CONVERSIONS[@graphql_name] || (:parse if cast? && serialize?)
|
|
98
91
|
end
|
|
99
92
|
|
|
100
|
-
# The code that normalizes a variable input before it's serialized.
|
|
101
|
-
#
|
|
102
|
-
#
|
|
103
|
-
#
|
|
104
|
-
#
|
|
105
|
-
#
|
|
106
|
-
# converted value goes on the wire natively (a Float, not "5.0").
|
|
93
|
+
# The code that normalizes a variable input before it's serialized.
|
|
94
|
+
# Parsing runs a raw value through the cast, guarded so an already-typed
|
|
95
|
+
# value passes through; a conversion just calls the method (5, "5" ->
|
|
96
|
+
# 5.0). serialize still runs afterward, but is identity for the
|
|
97
|
+
# convertible built-ins, so the converted value goes on the wire
|
|
98
|
+
# natively (a Float, not "5.0").
|
|
107
99
|
def coerce_input(expr)
|
|
108
|
-
case
|
|
109
|
-
when
|
|
110
|
-
when
|
|
100
|
+
case (how = coercion)
|
|
101
|
+
when :parse then "(#{expr}.is_a?(#{@type}) ? #{expr} : #{cast(expr)})"
|
|
102
|
+
when Conversion then "#{expr}.#{how.via}"
|
|
111
103
|
end
|
|
112
104
|
end
|
|
113
105
|
|
|
114
106
|
# the accepted Sorbet type for a coercible variable kwarg
|
|
115
107
|
def coerce_type
|
|
116
|
-
case
|
|
117
|
-
when
|
|
118
|
-
when
|
|
108
|
+
case (how = coercion)
|
|
109
|
+
when :parse then "T.any(#{@type}, String)"
|
|
110
|
+
when Conversion then how.input_type
|
|
119
111
|
end
|
|
120
112
|
end
|
|
121
113
|
|
|
@@ -123,7 +115,10 @@ class GraphWeaver::Codegen
|
|
|
123
115
|
|
|
124
116
|
def type_name(type)
|
|
125
117
|
case type
|
|
126
|
-
when Module
|
|
118
|
+
when Module
|
|
119
|
+
# an anonymous class has no name to emit — it would land as a literal
|
|
120
|
+
# `nil` in generated source
|
|
121
|
+
type.name || raise(ArgumentError, "type: must be a named class/module, got an anonymous one")
|
|
127
122
|
when String then type
|
|
128
123
|
else raise ArgumentError, "type: must be a class/module or String, got #{type.inspect}"
|
|
129
124
|
end
|
|
@@ -153,20 +148,48 @@ class GraphWeaver::Codegen
|
|
|
153
148
|
end
|
|
154
149
|
end
|
|
155
150
|
|
|
156
|
-
#
|
|
157
|
-
#
|
|
158
|
-
# than as a syntax error in the generated file. When a real class was
|
|
159
|
-
# given as type:, we're in a runtime with its deps loaded, so we also
|
|
160
|
-
# `require` each path to prove it resolves (a no-op for already-loaded
|
|
161
|
-
# libs, and it surfaces a typo now). With only a type-name string we
|
|
162
|
-
# can't assume the lib is installed at codegen time, so we don't try.
|
|
151
|
+
# With only a type-name string we can't assume the lib is installed at
|
|
152
|
+
# codegen time, so the paths aren't loaded — only shape-checked.
|
|
163
153
|
def normalize_requires(requires)
|
|
154
|
+
GraphWeaver::Codegen.normalize_requires!(requires, load: !@klass.nil?)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# coerce: true asks for something the scalar has to know how to do, so
|
|
158
|
+
# refuse a pass-through one now rather than emit a silent no-op.
|
|
159
|
+
def validate_coerce!
|
|
160
|
+
case @coerce
|
|
161
|
+
when false, nil then nil
|
|
162
|
+
when true
|
|
163
|
+
return if coercion
|
|
164
|
+
|
|
165
|
+
raise ArgumentError,
|
|
166
|
+
"coerce: true needs a cast and a serialize (#{@graphql_name} has neither, " \
|
|
167
|
+
"so there is nothing to coerce)"
|
|
168
|
+
else
|
|
169
|
+
raise ArgumentError, "coerce: must be true or false, got #{@coerce.inspect}"
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Pre-registered rather than user intent (see register_builtin_scalars!), so
|
|
175
|
+
# generation doesn't hold a schema to them.
|
|
176
|
+
BUILTIN_SCALARS = %w[ID String Int Float Boolean Date].freeze
|
|
177
|
+
|
|
178
|
+
class << self
|
|
179
|
+
# requires: is a require path or list of them; each must be a non-empty
|
|
180
|
+
# String (it is emitted verbatim as `require "..."` atop the generated
|
|
181
|
+
# file), caught here rather than as a syntax error in the generated file.
|
|
182
|
+
# load: when the registration handed us live constants — a class, a T::Enum,
|
|
183
|
+
# a helper module — we're in a runtime with its deps loaded, so each path is
|
|
184
|
+
# required to prove it resolves: a typo fails now, not in the generated file
|
|
185
|
+
# (a no-op for already-loaded libs).
|
|
186
|
+
def normalize_requires!(requires, load:)
|
|
164
187
|
Array(requires).each do |req|
|
|
165
188
|
unless req.is_a?(String) && !req.empty?
|
|
166
189
|
raise ArgumentError, "requires: must be a String or Array of Strings, got #{req.inspect}"
|
|
167
190
|
end
|
|
168
191
|
|
|
169
|
-
next unless
|
|
192
|
+
next unless load
|
|
170
193
|
|
|
171
194
|
begin
|
|
172
195
|
require req
|
|
@@ -176,23 +199,6 @@ class GraphWeaver::Codegen
|
|
|
176
199
|
end
|
|
177
200
|
end
|
|
178
201
|
|
|
179
|
-
# coerce: true round-trips through cast+serialize, so it needs both; a
|
|
180
|
-
# Symbol is a self-contained conversion and needs neither.
|
|
181
|
-
def validate_coerce!
|
|
182
|
-
case @coerce
|
|
183
|
-
when false, nil, Symbol then nil
|
|
184
|
-
when true
|
|
185
|
-
return if cast? && serialize?
|
|
186
|
-
|
|
187
|
-
raise ArgumentError,
|
|
188
|
-
"coerce: true needs both a cast and a serialize (#{@graphql_name} is missing one)"
|
|
189
|
-
else
|
|
190
|
-
raise ArgumentError, "coerce: must be true, false, or a Symbol method name, got #{@coerce.inspect}"
|
|
191
|
-
end
|
|
192
|
-
end
|
|
193
|
-
end
|
|
194
|
-
|
|
195
|
-
class << self
|
|
196
202
|
# Register (or override) how a GraphQL custom scalar deserializes into
|
|
197
203
|
# a Ruby object and serializes back onto the wire. See ScalarType for
|
|
198
204
|
# the accepted cast:/serialize:/requires: forms. Later registrations
|
|
@@ -239,9 +245,8 @@ class GraphWeaver::Codegen
|
|
|
239
245
|
# matches nothing and leaves them identity — which is exactly why we
|
|
240
246
|
# can name them with the real class constants. Date deserializes via
|
|
241
247
|
# ISO-8601 (it *does* define .parse, but we want iso8601 specifically,
|
|
242
|
-
# so it's explicit).
|
|
243
|
-
#
|
|
244
|
-
# conversion (see ScalarType::AUTO_CONVERSIONS).
|
|
248
|
+
# so it's explicit). Whether a variable of one accepts loose input is a
|
|
249
|
+
# separate, generation-time question — see coercion.
|
|
245
250
|
def register_builtin_scalars!
|
|
246
251
|
register_scalar "ID", String
|
|
247
252
|
register_scalar "String", String
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# What `extend_type` registers, and where its block-built mixins land.
|
|
5
|
+
#
|
|
6
|
+
# Three things ride on one call — helper modules mixed into every struct
|
|
7
|
+
# generated from a GraphQL type, the requires those modules need, and
|
|
8
|
+
# alias: paths that flatten a nested selection onto a typed accessor. They
|
|
9
|
+
# share a registry entry because they share a registration, and codegen
|
|
10
|
+
# reads all three off the same type name (see Codegen::Aliases for how a
|
|
11
|
+
# path is resolved against an actual selection).
|
|
12
|
+
|
|
13
|
+
class GraphWeaver::Codegen
|
|
14
|
+
class << self
|
|
15
|
+
# Attach app-owned helper modules to every struct generated from a
|
|
16
|
+
# GraphQL type — the logic stays in your code, generation wires it in:
|
|
17
|
+
#
|
|
18
|
+
# GraphWeaver.extend_type("Pet", PetHelpers)
|
|
19
|
+
#
|
|
20
|
+
# Or build the mixin inline — the block is module_eval'd into a fresh
|
|
21
|
+
# module auto-named GraphWeaver::TypeHelpers::<Type>. Handy for quick
|
|
22
|
+
# decoration; srb tc can't see into block-defined methods, so prefer
|
|
23
|
+
# a named module where static checking matters:
|
|
24
|
+
#
|
|
25
|
+
# GraphWeaver.extend_type("Pet") do
|
|
26
|
+
# def display_name = "#{name} the pet"
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# Additive: repeated registrations (and client-scoped ones) stack.
|
|
30
|
+
#
|
|
31
|
+
# alias: projects a (possibly nested) selected field onto a flat, typed
|
|
32
|
+
# accessor on the struct — the one derivation codegen can type itself, so
|
|
33
|
+
# it's emitted into the struct body where the field is in scope:
|
|
34
|
+
#
|
|
35
|
+
# GraphWeaver.extend_type("Widget", alias: { tag: "meta.tag" })
|
|
36
|
+
# GraphWeaver.extend_type("Widget", alias: "meta.tag") # accessor named `tag`
|
|
37
|
+
# GraphWeaver.extend_type("Widget", alias: ["meta.tag", "meta.color"])
|
|
38
|
+
#
|
|
39
|
+
# A path segment is a field, or `first`/`last` to pick one element out of a
|
|
40
|
+
# list hop (always nilable): `alias: { entity: "_entities.first" }`.
|
|
41
|
+
#
|
|
42
|
+
# optional: true makes the aliases lenient — a query whose selection doesn't
|
|
43
|
+
# fit the path just omits the accessor instead of failing generation. Use it
|
|
44
|
+
# for a root-type accessor (a Query alias every query would otherwise have to
|
|
45
|
+
# satisfy) or one that only fits some selections.
|
|
46
|
+
def extend_type(graphql_name, *mixins, requires: nil, **kw, &block)
|
|
47
|
+
aliases = take_aliases(kw)
|
|
48
|
+
mixins = mixins.dup
|
|
49
|
+
mixins << helper_module(graphql_name, block) if block
|
|
50
|
+
|
|
51
|
+
raise ArgumentError, "pass one or more helper modules, a block, or alias:" if mixins.empty? && aliases.empty?
|
|
52
|
+
mixins.each do |mixin|
|
|
53
|
+
# a name rather than the module is what you write when the constant
|
|
54
|
+
# won't resolve yet — see EnumType for why that's a Rails initializer
|
|
55
|
+
if mixin.is_a?(String)
|
|
56
|
+
raise ArgumentError, "type helpers are the modules themselves, not their names — " \
|
|
57
|
+
"extend_type(#{graphql_name.to_s.inspect}, #{mixin}). An autoloaded constant isn't " \
|
|
58
|
+
"resolvable while config/initializers run; register from a " \
|
|
59
|
+
"Rails.application.config.to_prepare block, which generation also runs first."
|
|
60
|
+
end
|
|
61
|
+
unless mixin.is_a?(Module) && mixin.name
|
|
62
|
+
raise ArgumentError, "type helpers must be named modules, got #{mixin.inspect}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
entry = type_registry[graphql_name.to_s] ||= { mixins: [], requires: [], aliases: {} }
|
|
67
|
+
entry[:mixins].concat(mixins)
|
|
68
|
+
entry[:requires].concat(GraphWeaver::Codegen.normalize_requires!(requires, load: true))
|
|
69
|
+
entry[:aliases].merge!(aliases)
|
|
70
|
+
entry
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Pull alias:/optional: out of the keyword rest and normalize; any other
|
|
74
|
+
# keyword is a typo worth flagging rather than silently dropping.
|
|
75
|
+
def take_aliases(kw)
|
|
76
|
+
aliases = normalize_aliases(kw.delete(:alias), optional: !!kw.delete(:optional))
|
|
77
|
+
raise ArgumentError, "unknown keyword: #{kw.keys.first}" unless kw.empty?
|
|
78
|
+
aliases
|
|
79
|
+
end
|
|
80
|
+
private :take_aliases
|
|
81
|
+
|
|
82
|
+
# accessor names and path segments are interpolated verbatim into generated
|
|
83
|
+
# source, so — like module_name — they must be plain identifiers, never
|
|
84
|
+
# arbitrary text that could inject code
|
|
85
|
+
ALIAS_NAME = /\A[a-zA-Z_]\w*[?!]?\z/
|
|
86
|
+
ALIAS_SEGMENT = /\A[a-zA-Z_]\w*\z/
|
|
87
|
+
|
|
88
|
+
# { accessor => { segments:, optional: } } from a path string (accessor
|
|
89
|
+
# named after the last segment), an array of such, or an { accessor => path }
|
|
90
|
+
# hash. `optional:` marks every alias in this registration as lenient.
|
|
91
|
+
def normalize_aliases(input, optional:)
|
|
92
|
+
pairs = case input
|
|
93
|
+
when nil then []
|
|
94
|
+
when String then [[input.split(".").last, input.split(".")]]
|
|
95
|
+
when Array then input.map { |path| [path.split(".").last, path.split(".")] }
|
|
96
|
+
when Hash then input.map { |name, path| [name.to_s, path.to_s.split(".")] }
|
|
97
|
+
else raise ArgumentError, "alias: expects a String, Array, or Hash, got #{input.class}"
|
|
98
|
+
end
|
|
99
|
+
pairs.to_h do |name, segments|
|
|
100
|
+
unless name.to_s.match?(ALIAS_NAME)
|
|
101
|
+
raise ArgumentError, "alias name #{name.inspect} is not a valid method name"
|
|
102
|
+
end
|
|
103
|
+
raise ArgumentError, "alias #{name.inspect} has an empty path" if segments.empty?
|
|
104
|
+
|
|
105
|
+
bad = segments.reject { |seg| seg.match?(ALIAS_SEGMENT) }
|
|
106
|
+
raise ArgumentError, "alias #{name.inspect} has an invalid path segment: #{bad.first.inspect}" if bad.any?
|
|
107
|
+
|
|
108
|
+
[name, { segments:, optional: }]
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
private :normalize_aliases
|
|
112
|
+
|
|
113
|
+
def type_registry
|
|
114
|
+
@type_registry ||= {}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Drop every extend_type registration (mixins, requires, alias: paths).
|
|
118
|
+
# The block-built mixin constants under GraphWeaver::TypeHelpers stay —
|
|
119
|
+
# generated files may still name them.
|
|
120
|
+
def reset_type_helpers!
|
|
121
|
+
type_registry.clear
|
|
122
|
+
self
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# a block-built mixin needs a name generated files can reference:
|
|
126
|
+
# GraphWeaver::TypeHelpers::Pet (suffixed on re-registration)
|
|
127
|
+
def helper_module(graphql_name, block)
|
|
128
|
+
base = GraphWeaver::Inflect.camelize(graphql_name.to_s)
|
|
129
|
+
name = base
|
|
130
|
+
count = 1
|
|
131
|
+
name = "#{base}V#{count += 1}" while GraphWeaver::TypeHelpers.const_defined?(name, false)
|
|
132
|
+
GraphWeaver::TypeHelpers.const_set(name, Module.new(&block))
|
|
133
|
+
end
|
|
134
|
+
private :helper_module
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
module GraphWeaver
|
|
139
|
+
# Home of block-built type helpers (extend_type with a block), which
|
|
140
|
+
# need constant names so generated files can reference them.
|
|
141
|
+
module TypeHelpers; end
|
|
142
|
+
end
|