graph_weaver 0.5.0 → 0.6.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 +537 -0
- data/Gemfile.lock +19 -19
- data/README.md +74 -53
- data/docs/cassettes.md +29 -4
- data/docs/editors.md +3 -1
- data/docs/errors.md +75 -16
- data/docs/federation.md +206 -155
- data/docs/generated_modules.md +223 -166
- data/docs/getting_started.md +106 -82
- data/docs/logging.md +35 -5
- data/docs/scalars.md +119 -24
- data/docs/testing.md +196 -155
- data/docs/transports.md +47 -19
- data/docs/upgrading.md +243 -22
- data/graph_weaver.gemspec +16 -2
- data/lib/generators/graph_weaver/install_generator.rb +31 -16
- data/lib/graph_weaver/client.rb +52 -15
- data/lib/graph_weaver/codegen/aliases.rb +15 -8
- data/lib/graph_weaver/codegen/emit.rb +107 -42
- data/lib/graph_weaver/codegen/enum_type.rb +4 -3
- data/lib/graph_weaver/codegen/nodes.rb +42 -21
- data/lib/graph_weaver/codegen/scalar_type.rb +87 -83
- data/lib/graph_weaver/codegen/type_helpers.rb +2 -3
- data/lib/graph_weaver/codegen.rb +382 -105
- data/lib/graph_weaver/coerce.rb +113 -0
- data/lib/graph_weaver/errors.rb +57 -13
- data/lib/graph_weaver/federation.rb +10 -22
- data/lib/graph_weaver/hints.rb +76 -2
- data/lib/graph_weaver/in_process.rb +11 -8
- data/lib/graph_weaver/inflect.rb +2 -0
- data/lib/graph_weaver/input_struct.rb +115 -12
- data/lib/graph_weaver/internal/overrides.rb +101 -0
- data/lib/graph_weaver/internal/planner.rb +868 -0
- data/lib/graph_weaver/internal/schemas.rb +50 -0
- data/lib/graph_weaver/internal/selection.rb +127 -0
- data/lib/graph_weaver/{testing → internal}/subgraphs.rb +45 -43
- data/lib/graph_weaver/internal/values.rb +181 -0
- data/lib/graph_weaver/internal.rb +206 -0
- data/lib/graph_weaver/logging.rb +108 -20
- data/lib/graph_weaver/parsing.rb +6 -13
- data/lib/graph_weaver/query_module.rb +2 -0
- data/lib/graph_weaver/railtie.rb +113 -14
- data/lib/graph_weaver/representation.rb +30 -2
- data/lib/graph_weaver/response.rb +15 -0
- data/lib/graph_weaver/retry.rb +54 -22
- data/lib/graph_weaver/rspec.rb +63 -18
- data/lib/graph_weaver/schema_diff.rb +293 -0
- data/lib/graph_weaver/schema_loader.rb +126 -35
- data/lib/graph_weaver/tasks.rb +88 -36
- data/lib/graph_weaver/testing/cassette.rb +131 -78
- data/lib/graph_weaver/testing/coverage.rb +11 -15
- data/lib/graph_weaver/testing/failure.rb +14 -8
- data/lib/graph_weaver/testing/fake_client.rb +253 -60
- data/lib/graph_weaver/testing/fake_subgraph.rb +19 -8
- data/lib/graph_weaver/testing/router.rb +147 -840
- data/lib/graph_weaver/testing.rb +40 -83
- data/lib/graph_weaver/transport/faraday.rb +1 -1
- data/lib/graph_weaver/transport/http.rb +29 -12
- data/lib/graph_weaver/transport.rb +11 -34
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +221 -118
- metadata +17 -13
- data/CLAUDE.md +0 -161
- data/DECISIONS.md +0 -309
- data/Makefile +0 -23
- data/NOTES.md +0 -182
- data/PLAN.md +0 -115
- data/REVIEW.md +0 -946
- data/lib/graph_weaver/schemas.rb +0 -46
- data/lib/graph_weaver/selection.rb +0 -120
- data/lib/graph_weaver/testing/values.rb +0 -98
data/lib/graph_weaver/codegen.rb
CHANGED
|
@@ -15,15 +15,18 @@ require "sorbet-runtime"
|
|
|
15
15
|
# T::Enum), and typed variables (kwargs on execute). Subscriptions are
|
|
16
16
|
# still open.
|
|
17
17
|
#
|
|
18
|
-
# Split across: codegen/scalar_type.rb (the
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
18
|
+
# Split across: codegen/scalar_type.rb and codegen/enum_type.rb (the leaf
|
|
19
|
+
# registries), codegen/type_helpers.rb (extend_type and the alias/mixin
|
|
20
|
+
# registry), codegen/nodes.rb (the typed IR), codegen/aliases.rb (resolving
|
|
21
|
+
# registered alias paths against a node), codegen/emit.rb (source emission);
|
|
22
|
+
# this file holds the public API and the query walk.
|
|
23
|
+
require_relative "internal"
|
|
22
24
|
require_relative "hints"
|
|
23
25
|
require_relative "input_struct"
|
|
26
|
+
require_relative "schema_loader"
|
|
24
27
|
require_relative "representation"
|
|
25
28
|
require_relative "inflect"
|
|
26
|
-
require_relative "selection"
|
|
29
|
+
require_relative "internal/selection"
|
|
27
30
|
require_relative "codegen/enum_type"
|
|
28
31
|
require_relative "codegen/scalar_type"
|
|
29
32
|
require_relative "codegen/nodes"
|
|
@@ -32,16 +35,25 @@ require_relative "codegen/emit"
|
|
|
32
35
|
|
|
33
36
|
class GraphWeaver::Codegen
|
|
34
37
|
include GraphWeaver::Inflect
|
|
35
|
-
include GraphWeaver::Selection
|
|
38
|
+
include GraphWeaver::Internal::Selection
|
|
36
39
|
include Aliases
|
|
37
40
|
include Emit
|
|
41
|
+
# the walk's two halves, split for file size rather than for reuse
|
|
42
|
+
private_constant :Aliases, :Emit
|
|
38
43
|
|
|
39
44
|
# How a directory of GraphQL documents is scanned: both extensions the rest of
|
|
40
45
|
# the library already accepts, and nested — `queries/admin/pets.graphql` is
|
|
41
46
|
# how anyone with sixty queries organizes them.
|
|
42
47
|
DOCUMENT_GLOB = "**/*.{graphql,gql}"
|
|
43
48
|
|
|
44
|
-
|
|
49
|
+
# Why every registration takes the constant and never its name. register_enum
|
|
50
|
+
# and extend_type refuse a String for the same reason, so they say it in the
|
|
51
|
+
# same words — a reword has to reach both or one starts giving worse advice.
|
|
52
|
+
AUTOLOAD_HINT = "An autoloaded constant isn't resolvable while config/initializers " \
|
|
53
|
+
"run; register from a Rails.application.config.to_prepare block, which generation " \
|
|
54
|
+
"also runs first."
|
|
55
|
+
|
|
56
|
+
attr_reader :name
|
|
45
57
|
|
|
46
58
|
# A client is anything responding to `execute(query, variables:)`
|
|
47
59
|
# whose result `to_h`s into {"data" => ..., "errors" => ...} — a
|
|
@@ -49,11 +61,10 @@ class GraphWeaver::Codegen
|
|
|
49
61
|
#
|
|
50
62
|
# client: (a constant, or its name as a string) becomes the generated
|
|
51
63
|
# module's baked default; when omitted, generated code falls back to
|
|
52
|
-
# the app default (GraphWeaver.client=).
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
# name). types_namespace: is the shared-types workflow (see
|
|
64
|
+
# the app default (GraphWeaver.client=). name: is the module the file
|
|
65
|
+
# defines, defaulting to the operation's own name; default_name: is
|
|
66
|
+
# parse's container-scoped fallback (file generation stays strict — a
|
|
67
|
+
# checked-in file deserves a deliberate name). types_namespace: is the shared-types workflow (see
|
|
57
68
|
# GraphWeaver.generate!): input types, schema enums, and unions hoisted from
|
|
58
69
|
# shared fragments live once in that module and the query module aliases what
|
|
59
70
|
# it uses. hoistable_unions: is the set of shared fragment names this query
|
|
@@ -61,19 +72,21 @@ class GraphWeaver::Codegen
|
|
|
61
72
|
# whole-union field spread as one of them resolves to a canonical type in the
|
|
62
73
|
# shared module (see used_union_names). path: is the file the query was read
|
|
63
74
|
# from, named alongside line and column in validation errors.
|
|
64
|
-
def initialize(schema:, query:,
|
|
65
|
-
types_namespace: nil, hoistable_unions: nil, path: nil)
|
|
75
|
+
def initialize(schema:, query:, name: nil, client: nil, default_name: nil,
|
|
76
|
+
types_namespace: nil, hoistable_unions: nil, path: nil, module_name: nil)
|
|
77
|
+
renamed!(module_name)
|
|
66
78
|
@schema = schema
|
|
67
79
|
@query = query.strip
|
|
68
|
-
|
|
69
|
-
@
|
|
70
|
-
@
|
|
80
|
+
# only ever quoted in a message, so it is stored the way it is reported
|
|
81
|
+
@path = path && GraphWeaver::Internal::Util.relative(path)
|
|
82
|
+
@name = name
|
|
83
|
+
@default_name = default_name
|
|
71
84
|
@types_namespace = types_namespace
|
|
72
85
|
@hoistable_unions = hoistable_unions || []
|
|
73
86
|
@used_unions = []
|
|
74
87
|
# scalars this generation had no registration for (see report_untyped_scalars)
|
|
75
88
|
@untyped_scalars = []
|
|
76
|
-
@client_const =
|
|
89
|
+
@client_const = CLIENT_CONST.call(client)
|
|
77
90
|
|
|
78
91
|
if client && @client_const.nil?
|
|
79
92
|
# a live object can't be spelled in generated source — parse can
|
|
@@ -82,18 +95,30 @@ class GraphWeaver::Codegen
|
|
|
82
95
|
end
|
|
83
96
|
end
|
|
84
97
|
|
|
98
|
+
# 0.5 spelled it module_name:, in two of the three doors. One knob, one
|
|
99
|
+
# spelling — but a silent "unknown keyword" would send the caller hunting.
|
|
100
|
+
def renamed!(module_name)
|
|
101
|
+
return unless module_name
|
|
102
|
+
|
|
103
|
+
raise ArgumentError, "module_name: is now name: (got #{module_name.inspect})"
|
|
104
|
+
end
|
|
105
|
+
private :renamed!
|
|
106
|
+
|
|
85
107
|
# The constant name a client can be referenced by in generated
|
|
86
108
|
# source — nil when it can't be (live objects, anonymous modules).
|
|
87
|
-
|
|
109
|
+
# A lambda rather than a method: both `parse` and `initialize` need it,
|
|
110
|
+
# from the class and from an instance.
|
|
111
|
+
CLIENT_CONST = lambda do |client|
|
|
88
112
|
case client
|
|
89
113
|
when String then client
|
|
90
114
|
when Module then client.name
|
|
91
115
|
end
|
|
92
116
|
end
|
|
117
|
+
private_constant :CLIENT_CONST
|
|
93
118
|
|
|
94
119
|
# one-step shorthand
|
|
95
|
-
def self.generate(schema:, query:,
|
|
96
|
-
new(schema:, query:,
|
|
120
|
+
def self.generate(schema:, query:, name: nil, client: nil, path: nil, module_name: nil)
|
|
121
|
+
new(schema:, query:, name:, client:, path:, module_name:).generate
|
|
97
122
|
end
|
|
98
123
|
|
|
99
124
|
# Development convenience: generate + eval in one step, no build
|
|
@@ -101,17 +126,22 @@ class GraphWeaver::Codegen
|
|
|
101
126
|
# file, but invisible to srb tc — use the build step for static typing.
|
|
102
127
|
# Evaluates into an anonymous container, so no global constants leak;
|
|
103
128
|
# client: additionally accepts a live object (set via .client=).
|
|
104
|
-
def self.parse(schema:, query:,
|
|
105
|
-
client_const =
|
|
129
|
+
def self.parse(schema:, query:, name: nil, client: nil, path: nil, module_name: nil)
|
|
130
|
+
client_const = CLIENT_CONST.call(client)
|
|
106
131
|
|
|
107
|
-
codegen = new(schema:, query:,
|
|
108
|
-
|
|
132
|
+
codegen = new(schema:, query:, name:, client: client_const, path:, module_name:,
|
|
133
|
+
default_name: "Query")
|
|
109
134
|
source = codegen.generate
|
|
110
135
|
|
|
111
136
|
container = Module.new
|
|
137
|
+
# otherwise every runtime error names the container by address
|
|
138
|
+
# ("#<Module:0x...>::Result::Person"); assigning the module to a constant,
|
|
139
|
+
# which is the documented usage, replaces this with the real path
|
|
140
|
+
# T.unsafe: sorbet's Module RBI predates set_temporary_name (Ruby 3.3)
|
|
141
|
+
T.unsafe(container).set_temporary_name("GraphWeaver.parse")
|
|
112
142
|
container.module_eval(source, "(graph_weaver)", 1)
|
|
113
|
-
mod = container.const_get(codegen.
|
|
114
|
-
GraphWeaver.log(:debug) { "parsed #{codegen.
|
|
143
|
+
mod = container.const_get(codegen.name)
|
|
144
|
+
GraphWeaver::Internal::Log.log(:debug) { "parsed #{codegen.name} (dynamic module, #{source.bytesize} bytes)" }
|
|
115
145
|
# live objects (or anonymous modules) can't be referenced from
|
|
116
146
|
# generated source — set them via the module's writer instead
|
|
117
147
|
mod.client = client if client && client_const.nil?
|
|
@@ -169,6 +199,7 @@ class GraphWeaver::Codegen
|
|
|
169
199
|
inputs.sort.each { |name| input_node(@schema.get_type(name)) }
|
|
170
200
|
enums.uniq.sort.each { |name| variable_core(@schema.get_type(name)) }
|
|
171
201
|
check_shared_collisions!(unions)
|
|
202
|
+
union_nodes.each { |union| check_shadowing!(union) }
|
|
172
203
|
|
|
173
204
|
emit_types_files(union_nodes).tap { report_untyped_scalars }
|
|
174
205
|
end
|
|
@@ -176,6 +207,7 @@ class GraphWeaver::Codegen
|
|
|
176
207
|
# module-level constants every generated query module defines — a shared
|
|
177
208
|
# type aliased to one of these would clash at load
|
|
178
209
|
MODULE_RESERVED = %w[Result QUERY Representations].to_set.freeze
|
|
210
|
+
private_constant :MODULE_RESERVED
|
|
179
211
|
|
|
180
212
|
# One hoisted shared fragment, built against the schema and named for the
|
|
181
213
|
# fragment rather than the field that spread it.
|
|
@@ -211,7 +243,7 @@ class GraphWeaver::Codegen
|
|
|
211
243
|
claim = taken[class_name] or next
|
|
212
244
|
|
|
213
245
|
raise GraphWeaver::Error,
|
|
214
|
-
"shared fragment #{name.inspect} hoists to #{@
|
|
246
|
+
"shared fragment #{name.inspect} hoists to #{@name}::#{class_name}, " \
|
|
215
247
|
"where #{claim} already generates — rename the fragment"
|
|
216
248
|
end
|
|
217
249
|
end
|
|
@@ -233,10 +265,10 @@ class GraphWeaver::Codegen
|
|
|
233
265
|
CONSTANT_NAME = /\A[A-Z]\w*(::[A-Z]\w*)*\z/
|
|
234
266
|
|
|
235
267
|
def validate_module_name!(subject)
|
|
236
|
-
return if @
|
|
268
|
+
return if @name&.match?(CONSTANT_NAME)
|
|
237
269
|
|
|
238
|
-
problem = "#{subject} must be a constant name, got #{@
|
|
239
|
-
# An explicit
|
|
270
|
+
problem = "#{subject} must be a constant name, got #{@name.inspect}"
|
|
271
|
+
# An explicit name: is an argument wrong on its face. A derived one
|
|
240
272
|
# is a verdict on a FILE — a numeric prefix (01_home.graphql) is the usual
|
|
241
273
|
# way in — so it names the file, says the fix is a rename, and brands so
|
|
242
274
|
# `rake graph_weaver:generate` aborts on it instead of burying it under a
|
|
@@ -249,6 +281,7 @@ class GraphWeaver::Codegen
|
|
|
249
281
|
private :validate_module_name!
|
|
250
282
|
|
|
251
283
|
VarDef = Struct.new(:kwarg, :wire, :node, :required)
|
|
284
|
+
private_constant :VarDef
|
|
252
285
|
|
|
253
286
|
# Names generated Ruby can't spell bare — as a kwarg, a local, or a method
|
|
254
287
|
# name. As a prop they're fine (`const :next`), since a prop is only ever
|
|
@@ -271,6 +304,7 @@ class GraphWeaver::Codegen
|
|
|
271
304
|
# ArgumentError at require time. Derived rather than listed, so it tracks
|
|
272
305
|
# whatever the Ruby and sorbet-runtime in play actually define.
|
|
273
306
|
STRUCT_METHODS = (GENERATED_METHODS + T::Struct.instance_methods.map(&:to_s)).freeze
|
|
307
|
+
private_constant :RUBY_KEYWORDS, :GENERATED_METHODS, :RESERVED_KWARGS, :STRUCT_METHODS
|
|
274
308
|
|
|
275
309
|
def generate
|
|
276
310
|
begin
|
|
@@ -290,21 +324,22 @@ class GraphWeaver::Codegen
|
|
|
290
324
|
operation = load_operation(@query)
|
|
291
325
|
root_type = operation_root_type(operation)
|
|
292
326
|
|
|
293
|
-
@
|
|
294
|
-
unless @
|
|
295
|
-
raise ArgumentError, "
|
|
327
|
+
@name ||= operation.name || @default_name
|
|
328
|
+
unless @name
|
|
329
|
+
raise ArgumentError, "name: required for anonymous operations"
|
|
296
330
|
end
|
|
297
331
|
|
|
298
|
-
validate_module_name!("
|
|
332
|
+
validate_module_name!("name:")
|
|
299
333
|
|
|
300
334
|
variables = build_variables(operation)
|
|
301
335
|
root = object_node(root_type, operation.selections, "Result")
|
|
336
|
+
check_shadowing!(root)
|
|
302
337
|
|
|
303
338
|
# An anonymous operation takes the module's name — declared in the document
|
|
304
339
|
# AND sent as operationName, which have to agree (a server rejects an
|
|
305
340
|
# operationName the document doesn't declare). The conventional .graphql
|
|
306
341
|
# file names nothing, so without this every trace arrives anonymous.
|
|
307
|
-
operation_name = operation.name || @
|
|
342
|
+
operation_name = operation.name || @name.split("::").last
|
|
308
343
|
@query = declare_operation_name(operation, operation_name) unless operation.name
|
|
309
344
|
|
|
310
345
|
emit_module(root, variables, representation_nodes(operation, root_type), operation_name)
|
|
@@ -396,6 +431,7 @@ class GraphWeaver::Codegen
|
|
|
396
431
|
# The subgraph spec's representation scalar. A field taking one is the
|
|
397
432
|
# entity resolver, whatever it's called.
|
|
398
433
|
REPRESENTATION_SCALAR = "_Any"
|
|
434
|
+
private_constant :REPRESENTATION_SCALAR
|
|
399
435
|
|
|
400
436
|
def representation_field?(definition)
|
|
401
437
|
definition.arguments.each_value.any? { |argument| argument.type.unwrap.graphql_name == REPRESENTATION_SCALAR }
|
|
@@ -436,22 +472,15 @@ class GraphWeaver::Codegen
|
|
|
436
472
|
end
|
|
437
473
|
|
|
438
474
|
# A @key field set is a GraphQL selection set — "upc sku", or a nested
|
|
439
|
-
# "id organization { id }" —
|
|
440
|
-
#
|
|
475
|
+
# "id organization { id }" — flattened to the dotted leaf paths the wire
|
|
476
|
+
# hash needs. The same reading the routing table does of the same syntax,
|
|
477
|
+
# so a supergraph and a subgraph SDL can't disagree about one key.
|
|
441
478
|
def key_paths(entity, fields)
|
|
442
|
-
|
|
443
|
-
leaf_paths(selections)
|
|
479
|
+
GraphWeaver::SchemaLoader::RoutingTable.parse_field_set(fields)
|
|
444
480
|
rescue GraphQL::ParseError => e
|
|
445
481
|
raise GraphWeaver::Error, "#{entity.graphql_name} @key(fields: #{fields.inspect}) isn't a selection set: #{e.message}"
|
|
446
482
|
end
|
|
447
483
|
|
|
448
|
-
def leaf_paths(selections, prefix = [])
|
|
449
|
-
selections.flat_map do |node|
|
|
450
|
-
path = prefix + [node.name]
|
|
451
|
-
node.selections.empty? ? [path.join(".")] : leaf_paths(node.selections, path)
|
|
452
|
-
end
|
|
453
|
-
end
|
|
454
|
-
|
|
455
484
|
# The kwargs a builder takes: every key set's top-level field, once. Typed
|
|
456
485
|
# from the schema — a leaf key field gets its registered scalar's Ruby
|
|
457
486
|
# type, a nested one an open Hash whose shape the runtime checks.
|
|
@@ -473,7 +502,7 @@ class GraphWeaver::Codegen
|
|
|
473
502
|
if core.kind.name == "SCALAR"
|
|
474
503
|
node = scalar_node(core.graphql_name, "#{entity.graphql_name}.#{name}")
|
|
475
504
|
type = required ? node.bare_type : node.prop_type
|
|
476
|
-
value =
|
|
505
|
+
value = representation_value(entity, name, kwarg, node)
|
|
477
506
|
else
|
|
478
507
|
# a nested key set — or an enum/composite one — passes through as an
|
|
479
508
|
# open hash, narrowed to the declared sub-paths by the runtime
|
|
@@ -486,38 +515,108 @@ class GraphWeaver::Codegen
|
|
|
486
515
|
end
|
|
487
516
|
end
|
|
488
517
|
|
|
489
|
-
#
|
|
490
|
-
#
|
|
491
|
-
#
|
|
518
|
+
# The kwarg's trip onto the wire — the same normalize-then-serialize an
|
|
519
|
+
# execute kwarg gets (see Emit#variable_serialize), since the builder's sig
|
|
520
|
+
# is `.checked(:never)` too and a representation is built from params just
|
|
521
|
+
# as often. Identity both ways emits the bare kwarg.
|
|
522
|
+
def representation_value(entity, name, kwarg, node)
|
|
523
|
+
return kwarg if !node.coerce? && node.serialize_identity?
|
|
524
|
+
|
|
525
|
+
inner = node.coerce? ? node.coerce("v1") : "v1"
|
|
526
|
+
inner = node.serialize(inner, 2) unless node.serialize_identity?
|
|
527
|
+
"GraphWeaver::Representation.field(#{entity.graphql_name.inspect}, #{name.inspect}, #{kwarg}) " \
|
|
528
|
+
"{ |v1| #{inner} }"
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
# What a registry's names must be in the schema. extend_type decorates
|
|
532
|
+
# whatever composite a query reaches, so it demands no particular kind.
|
|
533
|
+
REGISTERED_KIND = { "scalar" => "SCALAR", "enum" => "ENUM" }.freeze
|
|
534
|
+
# the type registry is reached via extend_type; scalars/enums via register_*
|
|
535
|
+
REGISTRATION_METHOD = { "type" => "extend_type", "scalar" => "register_scalar", "enum" => "register_enum" }.freeze
|
|
536
|
+
private_constant :REGISTERED_KIND, :REGISTRATION_METHOD
|
|
537
|
+
|
|
538
|
+
# Every registration this schema can't match, one sentence each. The answer
|
|
539
|
+
# depends on the schema and the registry alone, not on any one document, so
|
|
540
|
+
# a whole generate! run gets the same list — which is what lets the build
|
|
541
|
+
# report it once (see GraphWeaver.unmatched_registrations).
|
|
542
|
+
#
|
|
543
|
+
# The built-in scalars are pre-registered entries in the same table rather
|
|
544
|
+
# than user intent, so they're exempt — a schema with no Date scalar is not
|
|
545
|
+
# a mistake.
|
|
546
|
+
def self.unmatched_registrations(schema)
|
|
547
|
+
{
|
|
548
|
+
"enum" => enum_registry,
|
|
549
|
+
"scalar" => scalar_registry.except(*BUILTIN_SCALARS),
|
|
550
|
+
"type" => type_registry,
|
|
551
|
+
}.flat_map do |kind, registry|
|
|
552
|
+
registry.keys.filter_map { |name| validate_registration!(schema, kind, name) }
|
|
553
|
+
end
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
# One registry serves the whole graph, but a generation sees one schema — so
|
|
557
|
+
# a registration fails generation only where THIS schema can disprove it: a
|
|
558
|
+
# name it declares as something else, or a coordinate whose field it declares
|
|
559
|
+
# as a composite. A name it can't match at all proves nothing, because an
|
|
560
|
+
# entity type is declared by every subgraph that references it while its
|
|
561
|
+
# fields are split among them; that returns the sentence to say instead.
|
|
492
562
|
def self.validate_registration!(schema, kind, name)
|
|
563
|
+
method = REGISTRATION_METHOD.fetch(kind)
|
|
493
564
|
# register_scalar("Type.field", ...) overrides one field's scalar — validate
|
|
494
|
-
# the field
|
|
495
|
-
if kind == "scalar" && name.include?(".")
|
|
496
|
-
return if scalar_field?(schema, name)
|
|
565
|
+
# the field, not that a type named "Type.field" exists.
|
|
566
|
+
return validate_scalar_field!(schema, name, method) if kind == "scalar" && name.include?(".")
|
|
497
567
|
|
|
498
|
-
|
|
499
|
-
|
|
568
|
+
type = schema.get_type(name)
|
|
569
|
+
return unmatched(schema, method, name, kind, GraphWeaver::Internal::Util.did_you_mean(schema.types.keys, name)) unless type
|
|
500
570
|
|
|
501
|
-
|
|
571
|
+
expected = REGISTERED_KIND[kind]
|
|
572
|
+
return if expected.nil? || type.kind.name == expected
|
|
502
573
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
574
|
+
found = type.kind.name.downcase.tr("_", " ")
|
|
575
|
+
# a leaf registered as the other kind has a method that would have worked
|
|
576
|
+
other = REGISTERED_KIND.key(type.kind.name)
|
|
577
|
+
raise GraphWeaver::Error,
|
|
578
|
+
"#{method}(#{name.inspect}) names #{article(found)} #{found}, not #{article(kind)} " \
|
|
579
|
+
"#{kind}#{other ? " — use #{REGISTRATION_METHOD.fetch(other)}" : ""}"
|
|
508
580
|
end
|
|
581
|
+
private_class_method :validate_registration!
|
|
582
|
+
|
|
583
|
+
# A per-field override, register_scalar("Type.field", ...). Neither an absent
|
|
584
|
+
# type nor an absent field is disprovable here; what is, is a field this
|
|
585
|
+
# schema declares as something a scalar codec could never read.
|
|
586
|
+
def self.validate_scalar_field!(schema, name, method)
|
|
587
|
+
type_name, field_name = name.split(".", 2)
|
|
588
|
+
type = schema.get_type(type_name)
|
|
589
|
+
unless type
|
|
590
|
+
near = GraphWeaver::Internal::Util.did_you_mean(schema.types.keys, type_name)
|
|
591
|
+
return unmatched(schema, method, name, "scalar field", near && "#{near}.#{field_name}")
|
|
592
|
+
end
|
|
509
593
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
594
|
+
fields = type.respond_to?(:fields) ? type.fields : {}
|
|
595
|
+
field = fields[field_name]
|
|
596
|
+
unless field
|
|
597
|
+
near = GraphWeaver::Internal::Util.did_you_mean(fields.keys, field_name)
|
|
598
|
+
return unmatched(schema, method, name, "scalar field", near && "#{type_name}.#{near}")
|
|
599
|
+
end
|
|
600
|
+
return if field.type.unwrap.kind.name == "SCALAR"
|
|
515
601
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
602
|
+
raise GraphWeaver::Error,
|
|
603
|
+
"#{method}(#{name.inspect}): #{name} isn't a scalar field (it's #{field.type.unwrap.kind.name.downcase})"
|
|
604
|
+
end
|
|
605
|
+
private_class_method :validate_scalar_field!
|
|
606
|
+
|
|
607
|
+
# What to say about a name this schema has nothing for. Registrations are
|
|
608
|
+
# graph-scoped — federation composes by name, so one `Money` codec serves
|
|
609
|
+
# every subgraph that declares it — which is exactly why this schema can't
|
|
610
|
+
# tell a typo from a registration for the subgraph next door. Say both.
|
|
611
|
+
def self.unmatched(schema, method, name, what, suggestion)
|
|
612
|
+
hint = suggestion ? " (did you mean '#{suggestion}'?)" : ""
|
|
613
|
+
"#{method}(#{name.inspect}) matches no #{what} in #{schema.name || "this schema"} " \
|
|
614
|
+
"— a typo#{hint}, or a registration for another schema"
|
|
520
615
|
end
|
|
616
|
+
private_class_method :unmatched
|
|
617
|
+
|
|
618
|
+
def self.article(word) = word.downcase.start_with?(/[aeiou]/) ? "an" : "a"
|
|
619
|
+
private_class_method :article
|
|
521
620
|
|
|
522
621
|
# Parse every fragment file under `paths` into one { name => FragmentDefinition }
|
|
523
622
|
# map — reusable fragments a query can spread. Fragment files hold only
|
|
@@ -525,17 +624,18 @@ class GraphWeaver::Codegen
|
|
|
525
624
|
def self.load_fragments(paths)
|
|
526
625
|
source = {} # fragment name => the file that defined it, for the collision message
|
|
527
626
|
|
|
528
|
-
|
|
627
|
+
GraphWeaver::Internal::Util.query_files(paths).each_with_object({}) do |file, out|
|
|
529
628
|
doc = parse_document(File.read(file), file)
|
|
629
|
+
reported = GraphWeaver::Internal::Util.relative(file)
|
|
530
630
|
if doc.definitions.grep(GraphQL::Language::Nodes::OperationDefinition).any?
|
|
531
|
-
raise GraphWeaver::Error, "#{
|
|
631
|
+
raise GraphWeaver::Error, "#{reported}: fragment files define only fragments, no operations"
|
|
532
632
|
end
|
|
533
633
|
doc.definitions.grep(GraphQL::Language::Nodes::FragmentDefinition).each do |frag|
|
|
534
634
|
if (earlier = source[frag.name])
|
|
535
635
|
raise GraphWeaver::Error,
|
|
536
|
-
"duplicate shared fragment '#{frag.name}' — defined in #{earlier} and #{
|
|
636
|
+
"duplicate shared fragment '#{frag.name}' — defined in #{earlier} and #{reported}; rename one"
|
|
537
637
|
end
|
|
538
|
-
source[frag.name] =
|
|
638
|
+
source[frag.name] = reported
|
|
539
639
|
out[frag.name] = frag
|
|
540
640
|
end
|
|
541
641
|
end
|
|
@@ -563,11 +663,12 @@ class GraphWeaver::Codegen
|
|
|
563
663
|
def self.parse_document(query, path = nil)
|
|
564
664
|
GraphQL.parse(query)
|
|
565
665
|
rescue GraphQL::ParseError => e
|
|
566
|
-
prefix = [path, e.line, e.col].compact.join(":")
|
|
666
|
+
prefix = [path && GraphWeaver::Internal::Util.relative(path), e.line, e.col].compact.join(":")
|
|
567
667
|
raise GraphWeaver::ValidationError.new(
|
|
568
668
|
[{ message: prefix.empty? ? e.message : "#{prefix} #{e.message}", line: e.line, column: e.col }],
|
|
569
669
|
)
|
|
570
670
|
end
|
|
671
|
+
private_class_method :parse_document
|
|
571
672
|
|
|
572
673
|
# Append the shared fragments a query spreads (transitively) to its source, so
|
|
573
674
|
# the sent query is self-contained. Unused shared fragments are left out.
|
|
@@ -619,16 +720,34 @@ class GraphWeaver::Codegen
|
|
|
619
720
|
{ message: prefix.empty? ? message : "#{prefix} #{message}", line:, column: }
|
|
620
721
|
end
|
|
621
722
|
|
|
622
|
-
#
|
|
623
|
-
#
|
|
624
|
-
#
|
|
723
|
+
# Raises on the registrations this schema disproves, and narrates the rest
|
|
724
|
+
# on the logger — the runtime channel, so a console `parse` says it too.
|
|
725
|
+
# The build channel prints the same list once per run; see
|
|
726
|
+
# GraphWeaver.unmatched_registrations.
|
|
625
727
|
def validate_registrations!
|
|
626
|
-
{
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
728
|
+
self.class.unmatched_registrations(@schema).each { |message| GraphWeaver::Internal::Log.log(:warn) { message } }
|
|
729
|
+
end
|
|
730
|
+
|
|
731
|
+
# The @include/@skip a fragment carries applies to what it guards, so it has
|
|
732
|
+
# to travel with the selections into the child rather than being spent on the
|
|
733
|
+
# key. Re-wrapping in a guarded inline fragment says that in the vocabulary
|
|
734
|
+
# the walk already speaks, which is what keeps dispatchable_typename? and the
|
|
735
|
+
# __typename refusal honest for free.
|
|
736
|
+
GUARDED = [GraphQL::Language::Nodes::Directive.new(name: "include")].freeze
|
|
737
|
+
private_constant :GUARDED
|
|
738
|
+
|
|
739
|
+
# A key's merged sub-selections, keeping the conditionality of the occurrence
|
|
740
|
+
# each child came from: `pets @include(if:) { name } pets { species }` answers
|
|
741
|
+
# with `name` only when that occurrence ran, so those children have to admit
|
|
742
|
+
# nil. One occurrence needs none of this — the key is there exactly when it
|
|
743
|
+
# ran, and its own prop already says so.
|
|
744
|
+
def merged_selections(occurrences)
|
|
745
|
+
return occurrences.first.first.selections if occurrences.one?
|
|
746
|
+
|
|
747
|
+
occurrences.flat_map do |node, conditional|
|
|
748
|
+
next node.selections unless conditional || conditional?(node)
|
|
749
|
+
|
|
750
|
+
[GraphQL::Language::Nodes::InlineFragment.new(type: nil, directives: GUARDED, selections: node.selections)]
|
|
632
751
|
end
|
|
633
752
|
end
|
|
634
753
|
|
|
@@ -653,7 +772,7 @@ class GraphWeaver::Codegen
|
|
|
653
772
|
NonNull.new(scalar_node("String"))
|
|
654
773
|
else
|
|
655
774
|
field_type = @schema.get_field(type.graphql_name, field_name).type
|
|
656
|
-
sub_selections =
|
|
775
|
+
sub_selections = merged_selections(occurrences)
|
|
657
776
|
|
|
658
777
|
case (core = field_type.unwrap).kind.name
|
|
659
778
|
when "OBJECT"
|
|
@@ -661,7 +780,7 @@ class GraphWeaver::Codegen
|
|
|
661
780
|
type_ref(field_type) { object_node(core, sub_selections, name) }
|
|
662
781
|
when "UNION", "INTERFACE"
|
|
663
782
|
conditions = concrete_conditions(core, sub_selections)
|
|
664
|
-
|
|
783
|
+
shared = abstract_level_fields(core, sub_selections)
|
|
665
784
|
|
|
666
785
|
if conditions.empty?
|
|
667
786
|
# abstract-level fields only — every member shares them, so one
|
|
@@ -669,7 +788,7 @@ class GraphWeaver::Codegen
|
|
|
669
788
|
# union that selection can only be __typename)
|
|
670
789
|
name = pick_name(key, taken)
|
|
671
790
|
type_ref(field_type) { object_node(core, sub_selections, name) }
|
|
672
|
-
elsif conditions.size == 1 &&
|
|
791
|
+
elsif conditions.size == 1 && shared.empty? &&
|
|
673
792
|
(member = @schema.get_type(conditions.first)).kind.name == "OBJECT"
|
|
674
793
|
# a single `... on X` condition: narrow to X's struct — nil
|
|
675
794
|
# when the runtime type doesn't match (narrowing filters).
|
|
@@ -717,7 +836,7 @@ class GraphWeaver::Codegen
|
|
|
717
836
|
type_ref(field_type) { variable_core(core) }
|
|
718
837
|
when "SCALAR"
|
|
719
838
|
coordinate = "#{type.graphql_name}.#{field_name}"
|
|
720
|
-
type_ref(field_type) { scalar_node(core.graphql_name, coordinate) }
|
|
839
|
+
type_ref(field_type) { scalar_node(core.graphql_name, coordinate, result: true) }
|
|
721
840
|
else
|
|
722
841
|
raise GraphWeaver::Error, "unsupported kind: #{core.kind.name}"
|
|
723
842
|
end
|
|
@@ -738,6 +857,69 @@ class GraphWeaver::Codegen
|
|
|
738
857
|
node
|
|
739
858
|
end
|
|
740
859
|
|
|
860
|
+
# A generated class name is only ever a name; Ruby resolves it lexically. So
|
|
861
|
+
# a struct nesting `class Date < T::Struct` (from a result key `date`) turns
|
|
862
|
+
# a sibling `Date` scalar prop into that struct, and `Date.iso8601` into a
|
|
863
|
+
# NoMethodError — the file typechecks against itself and means something
|
|
864
|
+
# else. Refuse instead, naming both keys: aliasing either one in the query
|
|
865
|
+
# fixes it. `scope` is what enclosing structs have already introduced, since
|
|
866
|
+
# a nested class shadows for everything lexically inside it too.
|
|
867
|
+
def check_shadowing!(node, scope = {})
|
|
868
|
+
case node
|
|
869
|
+
when UnionNode
|
|
870
|
+
members = node.members.each_value.to_a + [node.catch_all].compact
|
|
871
|
+
inner = scope.merge(members.to_h { |m| [m.class_name, "the member struct #{m.class_name}"] })
|
|
872
|
+
members.each { |member| check_shadowing!(member, inner) }
|
|
873
|
+
when ObjectNode
|
|
874
|
+
nested = node.fields.filter_map { |field|
|
|
875
|
+
child = field.node.nested
|
|
876
|
+
[child, field.key] if child && !module_level?(child)
|
|
877
|
+
}.uniq(&:first)
|
|
878
|
+
inner = scope.merge(nested.to_h { |child, key| [child.class_name, "the class result key #{key.inspect} generates"] })
|
|
879
|
+
|
|
880
|
+
external_constants(node).each do |name, source|
|
|
881
|
+
shadow = inner[name] or next
|
|
882
|
+
|
|
883
|
+
raise GraphWeaver::Error,
|
|
884
|
+
"#{source} resolves to #{name}, but #{shadow} is also named #{name} and shadows it " \
|
|
885
|
+
"inside #{node.class_name} — alias one in the query to a distinct name"
|
|
886
|
+
end
|
|
887
|
+
|
|
888
|
+
nested.each { |child, _| check_shadowing!(child, inner) }
|
|
889
|
+
end
|
|
890
|
+
end
|
|
891
|
+
|
|
892
|
+
# Constants a struct's body names but doesn't define: the runtimes it always
|
|
893
|
+
# mentions, a registered scalar's Ruby type, a module-level enum, a hoisted
|
|
894
|
+
# union's alias, an extend_type mixin. Keyed by the constant's first segment,
|
|
895
|
+
# which is all Ruby resolves — `Money::Amount` goes through `Money`.
|
|
896
|
+
def external_constants(node)
|
|
897
|
+
refs = { "T" => "the Sorbet runtime", "GraphWeaver" => "the GraphWeaver runtime" }
|
|
898
|
+
node.mixins.each { |mixin| refs[root_constant(mixin)] ||= "the mixin #{mixin} registered with extend_type" }
|
|
899
|
+
|
|
900
|
+
node.fields.each do |field|
|
|
901
|
+
child = field.node.nested
|
|
902
|
+
name = if child
|
|
903
|
+
next unless module_level?(child)
|
|
904
|
+
|
|
905
|
+
child.class_name
|
|
906
|
+
else
|
|
907
|
+
root_constant(unwrapped(field.node).bare_type)
|
|
908
|
+
end
|
|
909
|
+
refs[name] ||= "result key #{field.key.inspect}"
|
|
910
|
+
end
|
|
911
|
+
refs
|
|
912
|
+
end
|
|
913
|
+
|
|
914
|
+
def root_constant(name) = name[/\A[A-Za-z_]\w*/]
|
|
915
|
+
|
|
916
|
+
# a leaf node with its NON_NULL/LIST wrappers removed
|
|
917
|
+
def unwrapped(node)
|
|
918
|
+
node = T.let(node, T.untyped)
|
|
919
|
+
node = node.of while node.is_a?(NonNull) || node.is_a?(List)
|
|
920
|
+
node
|
|
921
|
+
end
|
|
922
|
+
|
|
741
923
|
# Both ways a result key can fail to become a prop — a name the struct
|
|
742
924
|
# already answers, or a second key that underscores onto an earlier one.
|
|
743
925
|
# Either emits a file that raises ArgumentError at require time, so refuse
|
|
@@ -781,9 +963,19 @@ class GraphWeaver::Codegen
|
|
|
781
963
|
end.compact.uniq - [core.graphql_name]
|
|
782
964
|
end
|
|
783
965
|
|
|
784
|
-
#
|
|
785
|
-
|
|
786
|
-
|
|
966
|
+
# Result keys the abstract type itself answers — every member carries them,
|
|
967
|
+
# so their presence rules out narrowing to one. Walked as the type sees it,
|
|
968
|
+
# not read off the top level: `... on Named { name }` under a Named field is
|
|
969
|
+
# the same selection as a bare `name`, and treating it as a type condition
|
|
970
|
+
# would narrow the field away and drop what the server sent for every other
|
|
971
|
+
# member. `__typename` is excluded — it's the dispatch tag, not a field.
|
|
972
|
+
def abstract_level_fields(core, selections)
|
|
973
|
+
keys = gather_conditional(core, selections).keys
|
|
974
|
+
# __typename is the dispatch tag rather than a field — but only where it can
|
|
975
|
+
# actually be read. One behind @skip/@include still arrives for the member a
|
|
976
|
+
# narrowing means to filter out, and then the object isn't empty and
|
|
977
|
+
# "empty means no match" casts a Review into an Announcement.
|
|
978
|
+
dispatchable_typename?(core, selections) ? keys - ["__typename"] : keys
|
|
787
979
|
end
|
|
788
980
|
|
|
789
981
|
# The fragment name when a selection is exactly one bare fragment spread
|
|
@@ -822,7 +1014,10 @@ class GraphWeaver::Codegen
|
|
|
822
1014
|
def nilable_type_ref(type, &core)
|
|
823
1015
|
case type.kind.name
|
|
824
1016
|
when "NON_NULL"
|
|
825
|
-
|
|
1017
|
+
# only the NON_NULL around the narrowed member itself drops — `[Thing!]!`
|
|
1018
|
+
# narrowed is a guaranteed array of nilable members, not a nilable array
|
|
1019
|
+
inner = nilable_type_ref(type.of_type, &core)
|
|
1020
|
+
inner.is_a?(List) ? NonNull.new(inner) : inner
|
|
826
1021
|
when "LIST"
|
|
827
1022
|
List.new(nilable_type_ref(type.of_type, &core))
|
|
828
1023
|
else
|
|
@@ -864,11 +1059,58 @@ class GraphWeaver::Codegen
|
|
|
864
1059
|
|
|
865
1060
|
# The one struct everything else deserializes into: a member the query didn't
|
|
866
1061
|
# name, and — the point — a member the schema grows AFTER this file was
|
|
867
|
-
# generated
|
|
868
|
-
#
|
|
869
|
-
#
|
|
1062
|
+
# generated, so a new upstream member bends the result rather than breaking
|
|
1063
|
+
# it. It carries what the abstract type itself guarantees, plus anything a
|
|
1064
|
+
# `... on SomeInterface` asked for, since an unnamed member may implement it.
|
|
870
1065
|
def catch_all_member(type, selections, members)
|
|
871
|
-
object_node(type, selections, catch_all_name(members))
|
|
1066
|
+
node = object_node(type, selections, catch_all_name(members))
|
|
1067
|
+
taken = node.fields.map(&:key)
|
|
1068
|
+
|
|
1069
|
+
# These are nilable whatever the schema promises: the member that arrives
|
|
1070
|
+
# need not implement the interface, and then the server sends nothing.
|
|
1071
|
+
sibling_conditions(type, selections).each do |condition, sub_selections|
|
|
1072
|
+
object_node(condition, sub_selections, node.class_name).fields.each do |field|
|
|
1073
|
+
next if taken.include?(field.key)
|
|
1074
|
+
|
|
1075
|
+
taken << field.key
|
|
1076
|
+
child = field.node
|
|
1077
|
+
node.fields << ObjectNode::Field.new(field.prop, field.key, child.is_a?(NonNull) ? child.of : child)
|
|
1078
|
+
end
|
|
1079
|
+
end
|
|
1080
|
+
|
|
1081
|
+
node.aliases = resolve_aliases(node)
|
|
1082
|
+
node
|
|
1083
|
+
end
|
|
1084
|
+
|
|
1085
|
+
# The abstract type conditions inside an abstract selection that a member the
|
|
1086
|
+
# query never NAMED could still satisfy — `... on Named` under a union, or
|
|
1087
|
+
# under a different interface. Returns condition => merged selections, so the
|
|
1088
|
+
# same interface spread twice types once; concrete conditions are excluded,
|
|
1089
|
+
# since a member they'd match already has a struct of its own.
|
|
1090
|
+
def sibling_conditions(type, selections, visiting = Set.new, out = {})
|
|
1091
|
+
selections.each do |selection|
|
|
1092
|
+
case selection
|
|
1093
|
+
when GraphQL::Language::Nodes::InlineFragment
|
|
1094
|
+
sibling_condition(type, selection.type&.name, selection.selections, visiting, out)
|
|
1095
|
+
when GraphQL::Language::Nodes::FragmentSpread
|
|
1096
|
+
next if visiting.include?(selection.name)
|
|
1097
|
+
|
|
1098
|
+
fragment = @fragments.fetch(selection.name)
|
|
1099
|
+
sibling_condition(type, fragment.type.name, fragment.selections, visiting | [selection.name], out)
|
|
1100
|
+
end
|
|
1101
|
+
end
|
|
1102
|
+
out
|
|
1103
|
+
end
|
|
1104
|
+
|
|
1105
|
+
def sibling_condition(type, name, selections, visiting, out)
|
|
1106
|
+
condition = name ? @schema.get_type(name) : type
|
|
1107
|
+
return unless condition
|
|
1108
|
+
# same type condition restated — keep descending at this level
|
|
1109
|
+
return sibling_conditions(type, selections, visiting, out) if condition.graphql_name == type.graphql_name
|
|
1110
|
+
return unless condition.kind.abstract?
|
|
1111
|
+
|
|
1112
|
+
(out[condition] ||= []).concat(selections)
|
|
1113
|
+
sibling_conditions(condition, selections, visiting, out)
|
|
872
1114
|
end
|
|
873
1115
|
|
|
874
1116
|
# "Other", unless a real member already claims that name.
|
|
@@ -926,7 +1168,9 @@ class GraphWeaver::Codegen
|
|
|
926
1168
|
end
|
|
927
1169
|
end
|
|
928
1170
|
|
|
929
|
-
#
|
|
1171
|
+
# The node for a core type, reached from a variable, an input-object field
|
|
1172
|
+
# or a result-side enum. All three share it so that one schema enum is one
|
|
1173
|
+
# Ruby type wherever it appears — see object_node's ENUM branch.
|
|
930
1174
|
def variable_core(core)
|
|
931
1175
|
case core.kind.name
|
|
932
1176
|
when "SCALAR"
|
|
@@ -988,6 +1232,16 @@ class GraphWeaver::Codegen
|
|
|
988
1232
|
# ("Enum values must be assigned to constants") — catch it here instead.
|
|
989
1233
|
def enum_values(core)
|
|
990
1234
|
values = core.values.keys.sort
|
|
1235
|
+
# `_` and `__` are legal GraphQL enum values and camelize to nothing, so
|
|
1236
|
+
# the emitted `= new("_")` isn't even parseable — the file fails at load
|
|
1237
|
+
# with a syntax error pointing into generated source
|
|
1238
|
+
nameless = values.find { |value| !camelize(value.downcase).match?(/\A[A-Z]/) }
|
|
1239
|
+
if nameless
|
|
1240
|
+
raise GraphWeaver::Error,
|
|
1241
|
+
"enum #{core.graphql_name} value #{nameless} makes no constant name — map the enum onto " \
|
|
1242
|
+
"one of yours: register_enum(#{core.graphql_name.inspect}, YourEnum)"
|
|
1243
|
+
end
|
|
1244
|
+
|
|
991
1245
|
collision = values.group_by { |value| camelize(value.downcase) }.find { |_, group| group.size > 1 }
|
|
992
1246
|
if collision
|
|
993
1247
|
raise GraphWeaver::Error,
|
|
@@ -1022,17 +1276,40 @@ class GraphWeaver::Codegen
|
|
|
1022
1276
|
# generated file can require them (collected across the whole query).
|
|
1023
1277
|
# Resolution, most specific first: a per-field override (`Type.field`), then
|
|
1024
1278
|
# the scalar-name registration.
|
|
1025
|
-
def scalar_node(name, coordinate = nil)
|
|
1279
|
+
def scalar_node(name, coordinate = nil, result: false)
|
|
1026
1280
|
registry = GraphWeaver::Codegen.scalar_registry
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
scalar = GraphWeaver::Codegen.scalar(name)
|
|
1031
|
-
end
|
|
1281
|
+
@untyped_scalars << name.to_s unless (coordinate && registry[coordinate]) || registry[name.to_s]
|
|
1282
|
+
scalar = GraphWeaver::Codegen.scalar(name, coordinate)
|
|
1283
|
+
refuse_uncastable!(scalar, coordinate || name) if result
|
|
1032
1284
|
@requires.concat(scalar.requires)
|
|
1033
1285
|
Scalar.new(scalar)
|
|
1034
1286
|
end
|
|
1035
1287
|
|
|
1288
|
+
# Everything JSON.parse can hand back. A registered type outside this set
|
|
1289
|
+
# has to be BUILT from one of them, which is what cast: is for.
|
|
1290
|
+
WIRE_CLASSES = [String, Integer, Float, Hash, Array, TrueClass, FalseClass].freeze
|
|
1291
|
+
private_constant :WIRE_CLASSES
|
|
1292
|
+
|
|
1293
|
+
# A registered type nothing on the wire can be, with no cast to build one:
|
|
1294
|
+
# the prop is unsatisfiable, so every response fails — at runtime, a long
|
|
1295
|
+
# way from the registration that caused it. BigDecimal is the one people
|
|
1296
|
+
# reach for (it defines neither .parse nor .load, so inference finds no
|
|
1297
|
+
# codec and leaves the value untouched).
|
|
1298
|
+
def refuse_uncastable!(scalar, where)
|
|
1299
|
+
return if scalar.cast?
|
|
1300
|
+
|
|
1301
|
+
klass = Object.const_get(scalar.type)
|
|
1302
|
+
return unless klass.is_a?(Class) && WIRE_CLASSES.none? { |native| native <= klass }
|
|
1303
|
+
|
|
1304
|
+
raise GraphWeaver::Error,
|
|
1305
|
+
"register_scalar(#{scalar.graphql_name.inspect}, #{scalar.type}) has no cast, so nothing " \
|
|
1306
|
+
"builds a #{scalar.type} out of the JSON at #{where} — give it one (cast: :parse names a " \
|
|
1307
|
+
"class method, cast: ->(v) { \"#{scalar.type}(\#{v})\" } emits any expression), or register " \
|
|
1308
|
+
"a type the wire already parses into"
|
|
1309
|
+
rescue ::NameError
|
|
1310
|
+
nil # a type: given as a String names a class this process may not have
|
|
1311
|
+
end
|
|
1312
|
+
|
|
1036
1313
|
# An unregistered custom scalar passes through as T.untyped — legitimate
|
|
1037
1314
|
# (nobody needs a codec for every scalar), but it's the one hole in an
|
|
1038
1315
|
# otherwise exact result type, so name the holes rather than leave them
|
|
@@ -1041,7 +1318,7 @@ class GraphWeaver::Codegen
|
|
|
1041
1318
|
names = @untyped_scalars.uniq.sort
|
|
1042
1319
|
return if names.empty?
|
|
1043
1320
|
|
|
1044
|
-
GraphWeaver.log(:info) do
|
|
1321
|
+
GraphWeaver::Internal::Log.log(:info) do
|
|
1045
1322
|
"#{names.size} unregistered custom scalar#{"s" unless names.one?} → T.untyped: " \
|
|
1046
1323
|
"#{names.join(", ")} (register with GraphWeaver.register_scalar)"
|
|
1047
1324
|
end
|