graph_weaver 0.6.1 → 0.7.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 +1447 -1
- data/Gemfile +8 -0
- data/Gemfile.lock +151 -2
- data/README.md +20 -6
- data/docs/alternatives.md +201 -0
- data/docs/cassettes.md +17 -1
- data/docs/errors.md +382 -17
- data/docs/federation.md +469 -63
- data/docs/generated_modules.md +231 -15
- data/docs/getting_started.md +497 -104
- data/docs/i18n.md +234 -0
- data/docs/logging.md +160 -24
- data/docs/real_world.md +28 -0
- data/docs/scalars.md +190 -26
- data/docs/testing.md +457 -58
- data/docs/transports.md +164 -19
- data/docs/upgrading.md +328 -3
- data/graph_weaver.gemspec +7 -0
- data/lib/generators/graph_weaver/install_generator.rb +138 -4
- data/lib/graph_weaver/client.rb +47 -10
- data/lib/graph_weaver/codegen/aliases.rb +7 -5
- data/lib/graph_weaver/codegen/emit.rb +98 -29
- data/lib/graph_weaver/codegen/enum_type.rb +2 -1
- data/lib/graph_weaver/codegen/nodes.rb +39 -6
- data/lib/graph_weaver/codegen/registry.rb +175 -0
- data/lib/graph_weaver/codegen/scalar_type.rb +123 -30
- data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
- data/lib/graph_weaver/codegen.rb +404 -197
- data/lib/graph_weaver/coerce.rb +155 -26
- data/lib/graph_weaver/errors.rb +264 -34
- data/lib/graph_weaver/federation.rb +119 -26
- data/lib/graph_weaver/graph.rb +315 -0
- data/lib/graph_weaver/hints.rb +100 -24
- data/lib/graph_weaver/in_process.rb +17 -11
- data/lib/graph_weaver/input_struct.rb +119 -32
- data/lib/graph_weaver/internal/endpoint.rb +78 -0
- data/lib/graph_weaver/internal/headers.rb +51 -0
- data/lib/graph_weaver/internal/overrides.rb +67 -5
- data/lib/graph_weaver/internal/planner.rb +45 -15
- data/lib/graph_weaver/internal/refusal.rb +49 -0
- data/lib/graph_weaver/internal/schemas.rb +23 -9
- data/lib/graph_weaver/internal/selection.rb +34 -0
- data/lib/graph_weaver/internal/server_input.rb +251 -0
- data/lib/graph_weaver/internal/test_clients.rb +276 -0
- data/lib/graph_weaver/internal/unused.rb +287 -0
- data/lib/graph_weaver/internal/values.rb +40 -4
- data/lib/graph_weaver/internal.rb +183 -1
- data/lib/graph_weaver/log_subscriber.rb +66 -0
- data/lib/graph_weaver/logging.rb +136 -12
- data/lib/graph_weaver/query_module.rb +36 -3
- data/lib/graph_weaver/railtie.rb +237 -17
- data/lib/graph_weaver/representation.rb +55 -17
- data/lib/graph_weaver/result_struct.rb +90 -0
- data/lib/graph_weaver/retry.rb +33 -5
- data/lib/graph_weaver/rspec.rb +404 -93
- data/lib/graph_weaver/schema_loader.rb +221 -49
- data/lib/graph_weaver/tasks.rb +380 -89
- data/lib/graph_weaver/testing/cassette.rb +6 -5
- data/lib/graph_weaver/testing/endpoint.rb +106 -0
- data/lib/graph_weaver/testing/failure.rb +69 -12
- data/lib/graph_weaver/testing/fake_client.rb +133 -44
- data/lib/graph_weaver/testing/router.rb +58 -11
- data/lib/graph_weaver/testing.rb +200 -58
- data/lib/graph_weaver/transport/faraday.rb +41 -8
- data/lib/graph_weaver/transport/http.rb +46 -4
- data/lib/graph_weaver/transport.rb +109 -26
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +474 -106
- metadata +56 -1
|
@@ -158,8 +158,12 @@ class GraphWeaver::Codegen
|
|
|
158
158
|
else
|
|
159
159
|
"#{var}&.then { |v#{depth + 1}| #{@of.hash_coerce("v#{depth + 1}", depth + 2)} }"
|
|
160
160
|
end
|
|
161
|
+
return "#{expr}.map { |#{var}| #{inner} }" if hash_coerce_identity?
|
|
161
162
|
|
|
162
|
-
|
|
163
|
+
# the index is a path segment — `where._and.0._not.species` needs the 0
|
|
164
|
+
# to name one form field
|
|
165
|
+
idx = "i#{depth}"
|
|
166
|
+
"#{expr}.map.with_index { |#{var}, #{idx}| GraphWeaver::InputStruct.element(#{idx}, #{var}) { #{inner} } }"
|
|
163
167
|
end
|
|
164
168
|
|
|
165
169
|
def hash_coerce_identity? = @of.hash_coerce_identity?
|
|
@@ -190,6 +194,11 @@ class GraphWeaver::Codegen
|
|
|
190
194
|
"#{class_name}.from_h(#{expr})"
|
|
191
195
|
end
|
|
192
196
|
|
|
197
|
+
# a composite renders itself — #as_json is the mirror of .from_h
|
|
198
|
+
def serialize(expr, _depth)
|
|
199
|
+
"#{expr}.as_json"
|
|
200
|
+
end
|
|
201
|
+
|
|
193
202
|
def nested = self
|
|
194
203
|
end
|
|
195
204
|
|
|
@@ -253,8 +262,16 @@ class GraphWeaver::Codegen
|
|
|
253
262
|
end
|
|
254
263
|
end
|
|
255
264
|
|
|
265
|
+
# The fallback member is in no wire table — several wire values collapse
|
|
266
|
+
# into it, so `invert` keeps none — and it is exactly the member a drifted
|
|
267
|
+
# response casts to. Its own #serialize is the only spelling left, and it
|
|
268
|
+
# casts back to the fallback, so a result still round-trips through
|
|
269
|
+
# #as_json. Without a fallback the table is total and a miss is a real
|
|
270
|
+
# mistake, so it still raises.
|
|
256
271
|
def serialize(expr, _depth)
|
|
257
|
-
"#{const_prefix}_TO_WIRE.fetch(#{expr})"
|
|
272
|
+
return "#{const_prefix}_TO_WIRE.fetch(#{expr})" unless @fallback
|
|
273
|
+
|
|
274
|
+
"#{const_prefix}_TO_WIRE.fetch(#{expr}) { |member| member.serialize }"
|
|
258
275
|
end
|
|
259
276
|
|
|
260
277
|
def leaf? = true
|
|
@@ -298,6 +315,8 @@ class GraphWeaver::Codegen
|
|
|
298
315
|
end
|
|
299
316
|
end
|
|
300
317
|
|
|
318
|
+
def serialize(expr, depth) = @of.serialize(expr, depth)
|
|
319
|
+
def serialize_identity? = @of.serialize_identity?
|
|
301
320
|
def nested = @of
|
|
302
321
|
end
|
|
303
322
|
|
|
@@ -306,10 +325,13 @@ class GraphWeaver::Codegen
|
|
|
306
325
|
# alphabetically first of their keys, which the walk may reach second
|
|
307
326
|
attr_accessor :class_name
|
|
308
327
|
attr_reader :members # graphql type name => ObjectNode
|
|
309
|
-
#
|
|
328
|
+
# The struct an unnamed (or newly-added) __typename deserializes into.
|
|
329
|
+
# Required, not defaulted: a dispatch without one has to refuse a member
|
|
330
|
+
# the schema grew, and "the query named every member today" is not a
|
|
331
|
+
# reason to break tomorrow's response.
|
|
310
332
|
attr_reader :catch_all
|
|
311
333
|
|
|
312
|
-
def initialize(class_name, members, catch_all
|
|
334
|
+
def initialize(class_name, members, catch_all)
|
|
313
335
|
@class_name = class_name
|
|
314
336
|
@members = members
|
|
315
337
|
@catch_all = catch_all
|
|
@@ -321,6 +343,10 @@ class GraphWeaver::Codegen
|
|
|
321
343
|
"#{class_name}.from_h(#{expr})"
|
|
322
344
|
end
|
|
323
345
|
|
|
346
|
+
def serialize(expr, _depth)
|
|
347
|
+
"#{expr}.as_json"
|
|
348
|
+
end
|
|
349
|
+
|
|
324
350
|
def nested = self
|
|
325
351
|
end
|
|
326
352
|
|
|
@@ -342,6 +368,10 @@ class GraphWeaver::Codegen
|
|
|
342
368
|
def cast(expr, _depth)
|
|
343
369
|
"#{class_name}.from_h(#{expr})"
|
|
344
370
|
end
|
|
371
|
+
|
|
372
|
+
def serialize(expr, _depth)
|
|
373
|
+
"#{expr}.as_json"
|
|
374
|
+
end
|
|
345
375
|
end
|
|
346
376
|
|
|
347
377
|
# An input-object variable: emitted as a module-level T::Struct whose
|
|
@@ -351,14 +381,17 @@ class GraphWeaver::Codegen
|
|
|
351
381
|
class InputNode < Node
|
|
352
382
|
Field = Struct.new(:prop, :wire, :node, :required)
|
|
353
383
|
|
|
354
|
-
|
|
384
|
+
# graphql_name as well as class_name: a schema coordinate is spelled the
|
|
385
|
+
# schema's way (pokemon_bool_exp.name), which camelize has already lost.
|
|
386
|
+
attr_reader :class_name, :graphql_name, :fields
|
|
355
387
|
# @oneOf: exactly one field may be supplied. The schema can't say so — every
|
|
356
388
|
# @oneOf field is nullable — so the generated struct carries the flag and
|
|
357
389
|
# InputStruct#serialize enforces it.
|
|
358
390
|
attr_accessor :one_of
|
|
359
391
|
|
|
360
|
-
def initialize(class_name)
|
|
392
|
+
def initialize(class_name, graphql_name = class_name)
|
|
361
393
|
@class_name = class_name
|
|
394
|
+
@graphql_name = graphql_name
|
|
362
395
|
@fields = []
|
|
363
396
|
@one_of = false
|
|
364
397
|
end
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# One graph's registrations: the scalar codecs, the enum mappings, and the
|
|
5
|
+
# type helpers a generation reads. The three tables move together because a
|
|
6
|
+
# registration is scoped to the schema it describes — an app with two schemas
|
|
7
|
+
# registers `Money` for each, or for only one, and neither answer is the
|
|
8
|
+
# other's (see GraphWeaver.graph).
|
|
9
|
+
#
|
|
10
|
+
# The methods themselves live in Registrations, which the three codegen/*.rb
|
|
11
|
+
# files fill in; this is where they get somewhere to write. Codegen's class
|
|
12
|
+
# methods (GraphWeaver.register_scalar and friends) delegate here, to the
|
|
13
|
+
# default graph's registry — so a single-schema app never meets this class.
|
|
14
|
+
|
|
15
|
+
class GraphWeaver::Codegen
|
|
16
|
+
# The registrations one graph generates with: the scalar codecs, the enum
|
|
17
|
+
# mappings and the type helpers, plus what a schema can say about them.
|
|
18
|
+
# Not API (private_constant below) — an app writes register_scalar/
|
|
19
|
+
# register_enum/extend_type, at the top level (the default graph) or in a
|
|
20
|
+
# graph block, and never names the object holding them.
|
|
21
|
+
# codegen/{scalar,enum,type_helpers}.rb fill in the registering half.
|
|
22
|
+
class Registry
|
|
23
|
+
# What a registry's names must be in the schema. extend_type decorates
|
|
24
|
+
# whatever composite a query reaches, so it demands no particular kind.
|
|
25
|
+
REGISTERED_KIND = { "scalar" => "SCALAR", "enum" => "ENUM" }.freeze
|
|
26
|
+
# the type registry is reached via extend_type; scalars/enums via register_*
|
|
27
|
+
REGISTRATION_METHOD = { "type" => "extend_type", "scalar" => "register_scalar", "enum" => "register_enum" }.freeze
|
|
28
|
+
private_constant :REGISTERED_KIND, :REGISTRATION_METHOD
|
|
29
|
+
|
|
30
|
+
# Every registration this schema can't match, one sentence each. The answer
|
|
31
|
+
# depends on the schema and the registry alone, not on any one document, so
|
|
32
|
+
# a whole generate! run gets the same list — which is what lets the build
|
|
33
|
+
# report it once (see GraphWeaver.unmatched_registrations).
|
|
34
|
+
#
|
|
35
|
+
# The built-in scalars are pre-registered entries in the same table rather
|
|
36
|
+
# than user intent, so they're exempt — a schema with no Date scalar is not
|
|
37
|
+
# a mistake.
|
|
38
|
+
def unmatched_registrations(schema)
|
|
39
|
+
{
|
|
40
|
+
"enum" => enum_registry,
|
|
41
|
+
"scalar" => scalar_registry.except(*BUILTIN_SCALARS),
|
|
42
|
+
"type" => type_registry,
|
|
43
|
+
}.flat_map do |kind, registry|
|
|
44
|
+
registry.keys.filter_map { |name| validate_registration!(schema, kind, name) }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# A registry serves one graph, but a generation sees one schema — so a
|
|
49
|
+
# registration fails generation only where THIS schema can disprove it: a
|
|
50
|
+
# name it declares as something else, or a coordinate whose field it declares
|
|
51
|
+
# as a composite. A name it can't match at all proves nothing, because an
|
|
52
|
+
# entity type is declared by every subgraph that references it while its
|
|
53
|
+
# fields are split among them; that returns the sentence to say instead.
|
|
54
|
+
def validate_registration!(schema, kind, name)
|
|
55
|
+
method = REGISTRATION_METHOD.fetch(kind)
|
|
56
|
+
# register_scalar("Type.field", ...) overrides one field's scalar — validate
|
|
57
|
+
# the field, not that a type named "Type.field" exists.
|
|
58
|
+
return validate_scalar_field!(schema, name, method) if kind == "scalar" && name.include?(".")
|
|
59
|
+
|
|
60
|
+
type = schema.get_type(name)
|
|
61
|
+
unless type
|
|
62
|
+
return unmatched(schema, method, name, kind, GraphWeaver::Internal::Util.did_you_mean(schema.types.keys, name))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
expected = REGISTERED_KIND[kind]
|
|
66
|
+
return if expected.nil? || type.kind.name == expected
|
|
67
|
+
|
|
68
|
+
found = type.kind.name.downcase.tr("_", " ")
|
|
69
|
+
# a leaf registered as the other kind has a method that would have worked
|
|
70
|
+
other = REGISTERED_KIND.key(type.kind.name)
|
|
71
|
+
raise GraphWeaver::Error,
|
|
72
|
+
"#{method}(#{name.inspect}) names #{article(found)} #{found}, not #{article(kind)} " \
|
|
73
|
+
"#{kind}#{other ? " — use #{REGISTRATION_METHOD.fetch(other)}" : ""}"
|
|
74
|
+
end
|
|
75
|
+
private :validate_registration!
|
|
76
|
+
|
|
77
|
+
# A per-field override, register_scalar("Type.field", ...). Neither an absent
|
|
78
|
+
# type nor an absent field is disprovable here; what is, is a field this
|
|
79
|
+
# schema declares as something a scalar codec could never read.
|
|
80
|
+
def validate_scalar_field!(schema, name, method)
|
|
81
|
+
type_name, field_name = name.split(".", 2)
|
|
82
|
+
type = schema.get_type(type_name)
|
|
83
|
+
unless type
|
|
84
|
+
near = GraphWeaver::Internal::Util.did_you_mean(schema.types.keys, type_name)
|
|
85
|
+
return unmatched(schema, method, name, "scalar field", near && "#{near}.#{field_name}")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
fields = type.respond_to?(:fields) ? type.fields : {}
|
|
89
|
+
field = fields[field_name]
|
|
90
|
+
unless field
|
|
91
|
+
near = GraphWeaver::Internal::Util.did_you_mean(fields.keys, field_name)
|
|
92
|
+
return unmatched(schema, method, name, "scalar field", near && "#{type_name}.#{near}")
|
|
93
|
+
end
|
|
94
|
+
return if field.type.unwrap.kind.name == "SCALAR"
|
|
95
|
+
|
|
96
|
+
raise GraphWeaver::Error,
|
|
97
|
+
"#{method}(#{name.inspect}): #{name} isn't a scalar field (it's #{field.type.unwrap.kind.name.downcase})"
|
|
98
|
+
end
|
|
99
|
+
private :validate_scalar_field!
|
|
100
|
+
|
|
101
|
+
# What to say about a name this schema has nothing for. Registrations are
|
|
102
|
+
# graph-scoped — federation composes by name, so one `Money` codec serves
|
|
103
|
+
# every subgraph that declares it — which is exactly why this schema can't
|
|
104
|
+
# tell a typo from a registration for the subgraph next door. Say both.
|
|
105
|
+
def unmatched(schema, method, name, what, suggestion)
|
|
106
|
+
hint = suggestion ? " (did you mean '#{suggestion}'?)" : ""
|
|
107
|
+
"#{method}(#{name.inspect}) matches no #{what} in #{schema.name || "this schema"} " \
|
|
108
|
+
"— a typo#{hint}, or a registration for another schema"
|
|
109
|
+
end
|
|
110
|
+
private :unmatched
|
|
111
|
+
|
|
112
|
+
def article(word) = GraphWeaver::Internal::Util.article(word)
|
|
113
|
+
private :article
|
|
114
|
+
|
|
115
|
+
# Every table back to its starting state — scalars (built-ins restored),
|
|
116
|
+
# enum mappings, and type helpers. The clean slate between tests, and the
|
|
117
|
+
# one call that stays right when a fourth kind of registration shows up.
|
|
118
|
+
def reset_registrations!
|
|
119
|
+
reset_scalars!
|
|
120
|
+
reset_enums!
|
|
121
|
+
reset_type_helpers!
|
|
122
|
+
self
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def initialize = register_builtin_scalars!
|
|
126
|
+
|
|
127
|
+
# A graph starts from the top-level registrations and adds its own, so the
|
|
128
|
+
# three tables are copied rather than shared — an app that registered Money
|
|
129
|
+
# before it had a second schema keeps it, and a graph block can't reach back.
|
|
130
|
+
def initialize_copy(other)
|
|
131
|
+
super
|
|
132
|
+
@scalar_registry = other.scalar_registry.dup
|
|
133
|
+
@enum_registry = other.enum_registry.dup
|
|
134
|
+
# the entry is a hash of mutable arrays, so each one is copied too
|
|
135
|
+
@type_registry = other.type_registry.transform_values { |e| e.transform_values(&:dup) }
|
|
136
|
+
@helper_counts = other.send(:helper_counts).dup
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# The default graph's registrations — where a top-level
|
|
141
|
+
# GraphWeaver.register_scalar writes, and what a generation uses unless a
|
|
142
|
+
# graph hands it its own.
|
|
143
|
+
def self.registry = @registry ||= Registry.new
|
|
144
|
+
|
|
145
|
+
# Pre-registered rather than user intent, so generation doesn't hold a schema
|
|
146
|
+
# to them (validate_registration! skips these). Read off a fresh registry: a
|
|
147
|
+
# seventh built-in shouldn't have to be named twice.
|
|
148
|
+
BUILTIN_SCALARS = Registry.new.scalar_registry.keys.freeze
|
|
149
|
+
|
|
150
|
+
class << self
|
|
151
|
+
# The default graph's registry answers every one of these — the surface an
|
|
152
|
+
# app has used since before graphs existed, unchanged.
|
|
153
|
+
def register_scalar(...) = registry.register_scalar(...)
|
|
154
|
+
def register_enum(...) = registry.register_enum(...)
|
|
155
|
+
def extend_type(...) = registry.extend_type(...)
|
|
156
|
+
def scalar(...) = registry.scalar(...)
|
|
157
|
+
def scalar_registry = registry.scalar_registry
|
|
158
|
+
def enum_registry = registry.enum_registry
|
|
159
|
+
def type_registry = registry.type_registry
|
|
160
|
+
def unmatched_registrations(...) = registry.unmatched_registrations(...)
|
|
161
|
+
def clear_scalars! = registry.clear_scalars! && self
|
|
162
|
+
def reset_scalars! = registry.reset_scalars! && self
|
|
163
|
+
def reset_enums! = registry.reset_enums! && self
|
|
164
|
+
def reset_type_helpers! = registry.reset_type_helpers! && self
|
|
165
|
+
|
|
166
|
+
# Returns Codegen, not the registry: these are the documented calls, and
|
|
167
|
+
# their value has always been something you can keep chaining off.
|
|
168
|
+
def reset_registrations! = registry.reset_registrations! && self
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Nothing outside the gem names it, and it can't move under Internal either:
|
|
172
|
+
# its methods build a ScalarType and an EnumType, which are private here.
|
|
173
|
+
# Private at load, not in a method body — see spec/registry_spec.rb.
|
|
174
|
+
private_constant :Registry
|
|
175
|
+
end
|
|
@@ -52,15 +52,17 @@ class GraphWeaver::Codegen
|
|
|
52
52
|
->(klass, value) { klass.dump(value) }),
|
|
53
53
|
].freeze
|
|
54
54
|
|
|
55
|
-
# A scalar
|
|
56
|
-
# whole rule, so key on that — a custom scalar registered as a
|
|
57
|
-
# String gets the same check. ID is the exception GraphQL itself
|
|
58
|
-
# (see Coerce.id), matched by GraphQL name in #coercer.
|
|
55
|
+
# A scalar whose registration named no `cast:` of its own: its Ruby type
|
|
56
|
+
# is the whole rule, so key on that — a custom scalar registered as a
|
|
57
|
+
# plain String gets the same check. ID is the exception GraphQL itself
|
|
58
|
+
# names (see Coerce.id), matched by GraphQL name in #coercer.
|
|
59
59
|
COERCERS = {
|
|
60
60
|
"Integer" => "integer",
|
|
61
61
|
"Float" => "float",
|
|
62
62
|
"String" => "string",
|
|
63
63
|
"T::Boolean" => "boolean",
|
|
64
|
+
"Date" => "date",
|
|
65
|
+
"Time" => "time",
|
|
64
66
|
}.freeze
|
|
65
67
|
|
|
66
68
|
# What the library already knows about a Ruby type, so registering one
|
|
@@ -72,11 +74,25 @@ class GraphWeaver::Codegen
|
|
|
72
74
|
# more than the ISO 8601 a Date scalar carries.
|
|
73
75
|
# - the file to require, so the generated source stands alone.
|
|
74
76
|
# Only types whose wire form is unambiguous belong here.
|
|
77
|
+
#
|
|
78
|
+
# `call:` is the runnable twin of a Proc `serialize:`, which builds source
|
|
79
|
+
# and so can't be run — the testing harness needs both (see
|
|
80
|
+
# #serialize_value).
|
|
81
|
+
TIMESTAMP = ->(expr) { "GraphWeaver::Coerce.timestamp(#{expr})" }
|
|
82
|
+
TIMESTAMP_CALL = ->(value) { GraphWeaver::Coerce.timestamp(value) }
|
|
83
|
+
|
|
75
84
|
STDLIB = {
|
|
76
85
|
"BigDecimal" => { serialize: [:to_s, "F"], requires: "bigdecimal" },
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
"
|
|
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
|
+
# strftime, not #iso8601: DateTime < Date passes the is_a? guard, and its
|
|
90
|
+
# #iso8601 writes a timestamp where the schema said a date goes
|
|
91
|
+
"Date" => { cast: :iso8601, serialize: [:strftime, "%F"], requires: "date" },
|
|
92
|
+
# #iso8601 takes no precision, so it writes whole seconds and a
|
|
93
|
+
# sub-second timestamp goes back out poorer than it came in
|
|
94
|
+
"Time" => { cast: :parse, serialize: TIMESTAMP, call: TIMESTAMP_CALL, requires: "time" },
|
|
95
|
+
"DateTime" => { cast: :iso8601, serialize: TIMESTAMP, call: TIMESTAMP_CALL, requires: "date" },
|
|
80
96
|
}.freeze
|
|
81
97
|
|
|
82
98
|
# Everything JSON.parse can hand back. A registered type outside this set
|
|
@@ -104,10 +120,13 @@ class GraphWeaver::Codegen
|
|
|
104
120
|
else
|
|
105
121
|
GraphWeaver::Codegen.normalize_requires!(requires, load: !@klass.nil?)
|
|
106
122
|
end
|
|
123
|
+
@cast_given = cast unless cast == :itself
|
|
107
124
|
codec = @klass && CODECS.find { |c| @klass.respond_to?(c.probe) }
|
|
108
125
|
@cast = normalize_cast(cast || known[:cast], codec&.cast || kernel_cast)
|
|
109
126
|
@serialize = normalize_serialize(serialize || known[:serialize], codec&.serialize)
|
|
110
|
-
@serialize_value =
|
|
127
|
+
@serialize_value = (known[:call] if serialize.nil?) ||
|
|
128
|
+
runtime_serialize(serialize || known[:serialize], codec)
|
|
129
|
+
warn_half_a_value_object
|
|
111
130
|
end
|
|
112
131
|
|
|
113
132
|
def cast(expr) = @cast&.call(expr)
|
|
@@ -128,21 +147,74 @@ class GraphWeaver::Codegen
|
|
|
128
147
|
|
|
129
148
|
# The code that normalizes a loose input — a Rails param — into this
|
|
130
149
|
# scalar's Ruby type before it is serialized, or nil for nothing to do.
|
|
131
|
-
#
|
|
132
|
-
#
|
|
133
|
-
#
|
|
134
|
-
#
|
|
150
|
+
# The Ruby type's own rule in Coerce is the check, which is what
|
|
151
|
+
# `.checked(:never)` on the generated sig gives up. A registration that
|
|
152
|
+
# named its own `cast:` says how to build the Ruby object instead — but
|
|
153
|
+
# the guard in front of it and the verdict when it refuses are still the
|
|
154
|
+
# library's, so both go through Coerce.cast rather than a bare `is_a?`
|
|
155
|
+
# (a DateTime is one of those, and is not a Date on any wire).
|
|
156
|
+
# The schema's name for the scalar travels with the value, so a refusal
|
|
157
|
+
# reports GraphQL vocabulary rather than the Ruby type it maps to —
|
|
158
|
+
# register_scalar("Money", BigDecimal) refuses a Money, not a BigDecimal.
|
|
135
159
|
def coerce_input(expr)
|
|
136
|
-
if
|
|
137
|
-
"
|
|
138
|
-
elsif
|
|
139
|
-
"GraphWeaver::Coerce
|
|
160
|
+
if (fn = coercer)
|
|
161
|
+
"GraphWeaver::Coerce.#{fn}(#{expr}, #{@graphql_name.inspect})"
|
|
162
|
+
elsif cast?
|
|
163
|
+
"GraphWeaver::Coerce.cast(#{@type}, #{expr}, #{@graphql_name.inspect}) { |raw| #{cast("raw")} }"
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Why this registration can't read the JSON at `where`, and what to do about
|
|
168
|
+
# it. Two different mistakes land here, so say which: a type: given by name
|
|
169
|
+
# was never probed (there is no class in hand to probe), while a class was
|
|
170
|
+
# probed and matched nothing. Raised by Codegen#refuse_uncastable!, which
|
|
171
|
+
# owns the question of whether the wire could satisfy the prop at all.
|
|
172
|
+
def uncastable_message(where)
|
|
173
|
+
article = GraphWeaver::Internal::Util.article(@type)
|
|
174
|
+
head = "register_scalar(#{@graphql_name.inspect}, #{@klass ? @type : @type.inspect}) has no " \
|
|
175
|
+
"cast, so nothing builds #{article} #{@type} out of the JSON at #{where}"
|
|
176
|
+
if @klass.nil?
|
|
177
|
+
"#{head} — a type: given by name is never probed, since there is no class in hand. Pass " \
|
|
178
|
+
"the class (register_scalar(#{@graphql_name.inspect}, #{@type})) to infer a cast from " \
|
|
179
|
+
"it, or name one yourself (cast: :parse names a class method, " \
|
|
180
|
+
"cast: ->(v) { \"#{@type}.parse(\#{v})\" } emits any expression)"
|
|
181
|
+
else
|
|
182
|
+
"#{head} — #{@type} defines no .parse and no .load, and Kernel has no #{@type} conversion " \
|
|
183
|
+
"function, so there was nothing to infer. Give it a cast (cast: :parse names a class " \
|
|
184
|
+
"method, cast: ->(v) { \"#{@type}.new(\#{v})\" } emits any expression), or register a " \
|
|
185
|
+
"type the wire already parses into"
|
|
140
186
|
end
|
|
141
187
|
end
|
|
142
188
|
|
|
143
189
|
private
|
|
144
190
|
|
|
191
|
+
# A result compares its props with eql?, so that it and #hash agree on what
|
|
192
|
+
# "same" means. A type that leaves eql? at Object's compares by identity,
|
|
193
|
+
# so two results parsed from the same bytes are unequal and useless as hash
|
|
194
|
+
# keys — whether it defined == (the common Ruby idiom, and the leaf itself
|
|
195
|
+
# then compares fine) or no equality at all. Nothing here can fix that;
|
|
196
|
+
# only the type can.
|
|
197
|
+
def warn_half_a_value_object
|
|
198
|
+
# a Module type names a duck the gem never sees an instance of; a T::Enum's
|
|
199
|
+
# values are singletons, so identity already is equality
|
|
200
|
+
return unless @klass.is_a?(Class) && !(@klass < T::Enum) && !defines?(:eql?)
|
|
201
|
+
|
|
202
|
+
GraphWeaver::Internal::Log.log(:warn) do
|
|
203
|
+
"register_scalar(#{@graphql_name.inspect}, #{@type}): #{@type} inherits #eql? and #hash, " \
|
|
204
|
+
"so its instances compare by identity — two results parsed from the same response won't " \
|
|
205
|
+
"be equal and a result won't work as a hash key — define ==, eql? and hash off the same " \
|
|
206
|
+
"values (alias_method :eql?, :== is the usual shortcut)"
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def defines?(method)
|
|
211
|
+
![BasicObject, Kernel, Object].include?(@klass.instance_method(method).owner)
|
|
212
|
+
end
|
|
213
|
+
|
|
145
214
|
def coercer
|
|
215
|
+
# a cast: the registration named is the whole rule (:itself asks for
|
|
216
|
+
# no rich object, which is not the same as asking for no check)
|
|
217
|
+
return unless @cast_given.nil?
|
|
146
218
|
return "id" if @graphql_name == "ID" && @type == "String"
|
|
147
219
|
|
|
148
220
|
COERCERS[@type]
|
|
@@ -170,13 +242,30 @@ class GraphWeaver::Codegen
|
|
|
170
242
|
->(type, expr) { "#{type}(#{expr})" }
|
|
171
243
|
end
|
|
172
244
|
|
|
245
|
+
# A Proc here builds SOURCE for the generated file, so a proc that
|
|
246
|
+
# converts a value (`->(v) { v.to_sym }`) can't work — it interpolates to
|
|
247
|
+
# nothing and every response fails far from the registration. Probe it
|
|
248
|
+
# once now, where the message can name the spelling.
|
|
249
|
+
def source_builder!(option, proc)
|
|
250
|
+
probe = proc.arity.zero? ? proc.call : proc.call("v")
|
|
251
|
+
return proc if probe.is_a?(String)
|
|
252
|
+
|
|
253
|
+
raise ArgumentError, "#{option}: a Proc must return the Ruby source to emit — " \
|
|
254
|
+
"e.g. #{option}: ->(v) { \"#{@type}.parse(\#{v})\" } — got #{probe.inspect}; " \
|
|
255
|
+
"a Symbol names a method instead (#{option}: :parse)"
|
|
256
|
+
rescue ArgumentError => e
|
|
257
|
+
raise if e.message.start_with?("#{option}:")
|
|
258
|
+
|
|
259
|
+
raise ArgumentError, "#{option}: a Proc takes one argument, the expression to wrap — #{e.message}"
|
|
260
|
+
end
|
|
261
|
+
|
|
173
262
|
# nil infers via the matched codec; :itself opts out (identity); a
|
|
174
263
|
# Symbol is a class method on the type — Money.parse(expr)
|
|
175
264
|
def normalize_cast(cast, inferred)
|
|
176
265
|
case cast
|
|
177
266
|
when :itself then nil
|
|
178
267
|
when nil then inferred && ->(expr) { inferred.call(@type, expr) }
|
|
179
|
-
when Proc then cast
|
|
268
|
+
when Proc then source_builder!(:cast, cast)
|
|
180
269
|
when Symbol then ->(expr) { "#{@type}.#{cast}(#{expr})" }
|
|
181
270
|
else raise ArgumentError, "cast: must be a Symbol, Proc, :itself, or nil, got #{cast.inspect}"
|
|
182
271
|
end
|
|
@@ -189,7 +278,7 @@ class GraphWeaver::Codegen
|
|
|
189
278
|
case serialize
|
|
190
279
|
when :itself then nil
|
|
191
280
|
when nil then inferred && ->(expr) { inferred.call(@type, expr) }
|
|
192
|
-
when Proc then serialize
|
|
281
|
+
when Proc then source_builder!(:serialize, serialize)
|
|
193
282
|
when Symbol then ->(expr) { "#{expr}.#{serialize}" }
|
|
194
283
|
when Array
|
|
195
284
|
method, *args = serialize
|
|
@@ -238,7 +327,10 @@ class GraphWeaver::Codegen
|
|
|
238
327
|
end
|
|
239
328
|
end
|
|
240
329
|
end
|
|
330
|
+
end
|
|
241
331
|
|
|
332
|
+
# The scalar half of one graph's registrations — see Codegen::Registry.
|
|
333
|
+
class Registry
|
|
242
334
|
# Register (or override) how a GraphQL custom scalar deserializes into
|
|
243
335
|
# a Ruby object and serializes back onto the wire. See ScalarType for
|
|
244
336
|
# the accepted cast:/serialize:/requires: forms. Later registrations
|
|
@@ -284,10 +376,9 @@ class GraphWeaver::Codegen
|
|
|
284
376
|
# The five the spec names stay pass-through: their Ruby classes (String,
|
|
285
377
|
# Integer) define neither .parse nor .load, so inference matches nothing
|
|
286
378
|
# and leaves them identity — which is exactly why we can name them with
|
|
287
|
-
# the real class constants. Float is the exception
|
|
288
|
-
#
|
|
289
|
-
#
|
|
290
|
-
# without accepting the garbage `.to_f` would silently turn into 0.0.
|
|
379
|
+
# the real class constants. Float is the exception, and its rule lives in
|
|
380
|
+
# STDLIB with the others, so `register_scalar "Ratio", Float` reads the
|
|
381
|
+
# wire exactly as the built-in Float does.
|
|
291
382
|
#
|
|
292
383
|
# The rest are names, not guesses: graphql-ruby ships all but DateTime as
|
|
293
384
|
# its own scalars, and this library runs a graphql-ruby schema in-process.
|
|
@@ -300,7 +391,7 @@ class GraphWeaver::Codegen
|
|
|
300
391
|
register_scalar "ID", String
|
|
301
392
|
register_scalar "String", String
|
|
302
393
|
register_scalar "Int", Integer
|
|
303
|
-
register_scalar "Float", Float
|
|
394
|
+
register_scalar "Float", Float
|
|
304
395
|
register_scalar "Boolean", "T::Boolean"
|
|
305
396
|
register_scalar "Date", Date
|
|
306
397
|
register_scalar "ISO8601Date", Date
|
|
@@ -314,18 +405,20 @@ class GraphWeaver::Codegen
|
|
|
314
405
|
# untyped on purpose: registering it says so, rather than leaving JSON
|
|
315
406
|
# in the "unregistered custom scalars" report every generation
|
|
316
407
|
register_scalar "JSON", "T.untyped"
|
|
408
|
+
# the objects, not the names: a later register_scalar("DateTime", ...)
|
|
409
|
+
# replaces the entry, and that is app intent rather than a pre-registration
|
|
410
|
+
@builtin_entries = scalar_registry.values.freeze
|
|
317
411
|
end
|
|
318
412
|
private :register_builtin_scalars!
|
|
413
|
+
|
|
414
|
+
# Whether this name still holds the entry pre-registration put there.
|
|
415
|
+
def builtin_scalar?(name)
|
|
416
|
+
entry = scalar_registry[name]
|
|
417
|
+
@builtin_entries.any? { |builtin| builtin.equal?(entry) }
|
|
418
|
+
end
|
|
319
419
|
end
|
|
320
420
|
|
|
321
421
|
# codegen's own record of a registration; users get one back from
|
|
322
422
|
# `.scalar` but never name the class
|
|
323
423
|
private_constant :ScalarType
|
|
324
|
-
|
|
325
|
-
register_builtin_scalars!
|
|
326
|
-
|
|
327
|
-
# Pre-registered rather than user intent, so generation doesn't hold a schema
|
|
328
|
-
# to them (validate_registration! skips these). Read off the registry the line
|
|
329
|
-
# above just filled: a seventh built-in shouldn't have to be named twice.
|
|
330
|
-
BUILTIN_SCALARS = scalar_registry.keys.freeze
|
|
331
424
|
end
|
|
@@ -11,16 +11,18 @@
|
|
|
11
11
|
# path is resolved against an actual selection).
|
|
12
12
|
|
|
13
13
|
class GraphWeaver::Codegen
|
|
14
|
-
|
|
14
|
+
# The extend_type half of one graph's registrations — see Codegen::Registry.
|
|
15
|
+
class Registry
|
|
15
16
|
# Attach app-owned helper modules to every struct generated from a
|
|
16
17
|
# GraphQL type — the logic stays in your code, generation wires it in:
|
|
17
18
|
#
|
|
18
19
|
# GraphWeaver.extend_type("Pet", PetHelpers)
|
|
19
20
|
#
|
|
20
21
|
# Or build the mixin inline — the block is module_eval'd into a fresh
|
|
21
|
-
# module
|
|
22
|
-
#
|
|
23
|
-
#
|
|
22
|
+
# module named for where it is written and what it extends:
|
|
23
|
+
# GraphWeaver::TypeHelpers::Pet, or ::Billing::Pet in graph :billing.
|
|
24
|
+
# Handy for quick decoration; srb tc can't see into block-defined methods,
|
|
25
|
+
# so prefer a named module where static checking matters:
|
|
24
26
|
#
|
|
25
27
|
# GraphWeaver.extend_type("Pet") do
|
|
26
28
|
# def display_name = "#{name} the pet"
|
|
@@ -118,19 +120,62 @@ class GraphWeaver::Codegen
|
|
|
118
120
|
# generated files may still name them.
|
|
119
121
|
def reset_type_helpers!
|
|
120
122
|
type_registry.clear
|
|
123
|
+
helper_counts.clear
|
|
121
124
|
self
|
|
122
125
|
end
|
|
123
126
|
|
|
124
|
-
#
|
|
125
|
-
#
|
|
127
|
+
# Which graph's registrations this registry holds — nil for the top-level
|
|
128
|
+
# one. Block-built helpers are named for it, so two graphs extending the
|
|
129
|
+
# same type get two constants (see helper_module).
|
|
130
|
+
attr_accessor :graph_name
|
|
131
|
+
|
|
132
|
+
# A block-built mixin needs a name generated files can reference:
|
|
133
|
+
# GraphWeaver::TypeHelpers::Pet, or ::Billing::Pet in graph :billing (V2,
|
|
134
|
+
# V3… for a second and third block on the same type in the same place).
|
|
135
|
+
#
|
|
136
|
+
# The name is a function of the source and nothing else — where the block
|
|
137
|
+
# is written and what it extends. It gets baked into generated code, so
|
|
138
|
+
# naming it after whichever constants happened to exist made it a function
|
|
139
|
+
# of how many times THIS process had read the registry, and `generate`
|
|
140
|
+
# wrote a name a plain boot never creates.
|
|
126
141
|
def helper_module(graphql_name, block)
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
name = "#{
|
|
131
|
-
|
|
142
|
+
namespace = helper_namespace
|
|
143
|
+
type = GraphWeaver::Inflect.camelize(graphql_name.to_s)
|
|
144
|
+
index = (helper_counts[[namespace.name, type]] += 1)
|
|
145
|
+
name = index == 1 ? type : "#{type}V#{index}"
|
|
146
|
+
# reused rather than replaced, so re-declaring the same source (a Rails
|
|
147
|
+
# to_prepare reload) keeps the module already-loaded structs include
|
|
148
|
+
mod = const_under(namespace, name) { Module.new }
|
|
149
|
+
mod.module_eval(&block)
|
|
150
|
+
mod
|
|
132
151
|
end
|
|
133
152
|
private :helper_module
|
|
153
|
+
|
|
154
|
+
# Where this registry's block-built helpers live: under a module named for
|
|
155
|
+
# the graph, so two graphs extending the same type get two constants and
|
|
156
|
+
# neither has to know the other exists.
|
|
157
|
+
def helper_namespace
|
|
158
|
+
return GraphWeaver::TypeHelpers unless graph_name
|
|
159
|
+
|
|
160
|
+
const_under(GraphWeaver::TypeHelpers, GraphWeaver::Inflect.camelize(graph_name.to_s)) { Module.new }
|
|
161
|
+
end
|
|
162
|
+
private :helper_namespace
|
|
163
|
+
|
|
164
|
+
def const_under(namespace, name)
|
|
165
|
+
return namespace.const_get(name, false) if namespace.const_defined?(name, false)
|
|
166
|
+
|
|
167
|
+
namespace.const_set(name, yield)
|
|
168
|
+
end
|
|
169
|
+
private :const_under
|
|
170
|
+
|
|
171
|
+
# How many block-built helpers this registry has already named for a type.
|
|
172
|
+
# Per registry, not per process: a graph's registrations are replayed over
|
|
173
|
+
# a fresh copy of the top-level registry on every read, so the same source
|
|
174
|
+
# counts the same way every time.
|
|
175
|
+
def helper_counts
|
|
176
|
+
@helper_counts ||= Hash.new(0)
|
|
177
|
+
end
|
|
178
|
+
private :helper_counts
|
|
134
179
|
end
|
|
135
180
|
end
|
|
136
181
|
|