graph_weaver 0.5.1 → 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 +409 -0
- data/Gemfile.lock +19 -19
- data/README.md +74 -53
- data/docs/cassettes.md +6 -1
- data/docs/editors.md +3 -1
- data/docs/errors.md +73 -16
- data/docs/federation.md +201 -151
- data/docs/generated_modules.md +222 -165
- data/docs/getting_started.md +105 -81
- data/docs/logging.md +34 -4
- data/docs/scalars.md +119 -24
- data/docs/testing.md +191 -151
- data/docs/transports.md +47 -19
- data/docs/upgrading.md +210 -11
- data/lib/generators/graph_weaver/install_generator.rb +16 -1
- data/lib/graph_weaver/client.rb +46 -13
- data/lib/graph_weaver/codegen/aliases.rb +5 -4
- data/lib/graph_weaver/codegen/emit.rb +96 -39
- data/lib/graph_weaver/codegen/enum_type.rb +3 -0
- data/lib/graph_weaver/codegen/nodes.rb +42 -21
- data/lib/graph_weaver/codegen/scalar_type.rb +82 -79
- data/lib/graph_weaver/codegen/type_helpers.rb +1 -0
- data/lib/graph_weaver/codegen.rb +284 -84
- data/lib/graph_weaver/coerce.rb +113 -0
- data/lib/graph_weaver/errors.rb +30 -7
- data/lib/graph_weaver/federation.rb +6 -5
- 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 +39 -41
- 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 +5 -4
- 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 +50 -11
- data/lib/graph_weaver/schema_diff.rb +293 -0
- data/lib/graph_weaver/schema_loader.rb +96 -29
- data/lib/graph_weaver/tasks.rb +78 -29
- data/lib/graph_weaver/testing/cassette.rb +49 -65
- data/lib/graph_weaver/testing/coverage.rb +5 -4
- data/lib/graph_weaver/testing/failure.rb +10 -6
- 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 +94 -808
- data/lib/graph_weaver/testing.rb +35 -84
- 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 +188 -110
- metadata +10 -5
- data/lib/graph_weaver/schemas.rb +0 -48
- data/lib/graph_weaver/selection.rb +0 -120
- data/lib/graph_weaver/testing/values.rb +0 -98
data/lib/graph_weaver/codegen.rb
CHANGED
|
@@ -20,12 +20,13 @@ require "sorbet-runtime"
|
|
|
20
20
|
# registry), codegen/nodes.rb (the typed IR), codegen/aliases.rb (resolving
|
|
21
21
|
# registered alias paths against a node), codegen/emit.rb (source emission);
|
|
22
22
|
# this file holds the public API and the query walk.
|
|
23
|
+
require_relative "internal"
|
|
23
24
|
require_relative "hints"
|
|
24
25
|
require_relative "input_struct"
|
|
25
26
|
require_relative "schema_loader"
|
|
26
27
|
require_relative "representation"
|
|
27
28
|
require_relative "inflect"
|
|
28
|
-
require_relative "selection"
|
|
29
|
+
require_relative "internal/selection"
|
|
29
30
|
require_relative "codegen/enum_type"
|
|
30
31
|
require_relative "codegen/scalar_type"
|
|
31
32
|
require_relative "codegen/nodes"
|
|
@@ -34,9 +35,11 @@ require_relative "codegen/emit"
|
|
|
34
35
|
|
|
35
36
|
class GraphWeaver::Codegen
|
|
36
37
|
include GraphWeaver::Inflect
|
|
37
|
-
include GraphWeaver::Selection
|
|
38
|
+
include GraphWeaver::Internal::Selection
|
|
38
39
|
include Aliases
|
|
39
40
|
include Emit
|
|
41
|
+
# the walk's two halves, split for file size rather than for reuse
|
|
42
|
+
private_constant :Aliases, :Emit
|
|
40
43
|
|
|
41
44
|
# How a directory of GraphQL documents is scanned: both extensions the rest of
|
|
42
45
|
# the library already accepts, and nested — `queries/admin/pets.graphql` is
|
|
@@ -50,7 +53,7 @@ class GraphWeaver::Codegen
|
|
|
50
53
|
"run; register from a Rails.application.config.to_prepare block, which generation " \
|
|
51
54
|
"also runs first."
|
|
52
55
|
|
|
53
|
-
attr_reader :
|
|
56
|
+
attr_reader :name
|
|
54
57
|
|
|
55
58
|
# A client is anything responding to `execute(query, variables:)`
|
|
56
59
|
# whose result `to_h`s into {"data" => ..., "errors" => ...} — a
|
|
@@ -58,11 +61,10 @@ class GraphWeaver::Codegen
|
|
|
58
61
|
#
|
|
59
62
|
# client: (a constant, or its name as a string) becomes the generated
|
|
60
63
|
# module's baked default; when omitted, generated code falls back to
|
|
61
|
-
# the app default (GraphWeaver.client=).
|
|
62
|
-
#
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
# 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
|
|
66
68
|
# GraphWeaver.generate!): input types, schema enums, and unions hoisted from
|
|
67
69
|
# shared fragments live once in that module and the query module aliases what
|
|
68
70
|
# it uses. hoistable_unions: is the set of shared fragment names this query
|
|
@@ -70,19 +72,21 @@ class GraphWeaver::Codegen
|
|
|
70
72
|
# whole-union field spread as one of them resolves to a canonical type in the
|
|
71
73
|
# shared module (see used_union_names). path: is the file the query was read
|
|
72
74
|
# from, named alongside line and column in validation errors.
|
|
73
|
-
def initialize(schema:, query:,
|
|
74
|
-
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)
|
|
75
78
|
@schema = schema
|
|
76
79
|
@query = query.strip
|
|
77
|
-
|
|
78
|
-
@
|
|
79
|
-
@
|
|
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
|
|
80
84
|
@types_namespace = types_namespace
|
|
81
85
|
@hoistable_unions = hoistable_unions || []
|
|
82
86
|
@used_unions = []
|
|
83
87
|
# scalars this generation had no registration for (see report_untyped_scalars)
|
|
84
88
|
@untyped_scalars = []
|
|
85
|
-
@client_const =
|
|
89
|
+
@client_const = CLIENT_CONST.call(client)
|
|
86
90
|
|
|
87
91
|
if client && @client_const.nil?
|
|
88
92
|
# a live object can't be spelled in generated source — parse can
|
|
@@ -91,18 +95,30 @@ class GraphWeaver::Codegen
|
|
|
91
95
|
end
|
|
92
96
|
end
|
|
93
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
|
+
|
|
94
107
|
# The constant name a client can be referenced by in generated
|
|
95
108
|
# source — nil when it can't be (live objects, anonymous modules).
|
|
96
|
-
|
|
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|
|
|
97
112
|
case client
|
|
98
113
|
when String then client
|
|
99
114
|
when Module then client.name
|
|
100
115
|
end
|
|
101
116
|
end
|
|
117
|
+
private_constant :CLIENT_CONST
|
|
102
118
|
|
|
103
119
|
# one-step shorthand
|
|
104
|
-
def self.generate(schema:, query:,
|
|
105
|
-
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
|
|
106
122
|
end
|
|
107
123
|
|
|
108
124
|
# Development convenience: generate + eval in one step, no build
|
|
@@ -110,17 +126,22 @@ class GraphWeaver::Codegen
|
|
|
110
126
|
# file, but invisible to srb tc — use the build step for static typing.
|
|
111
127
|
# Evaluates into an anonymous container, so no global constants leak;
|
|
112
128
|
# client: additionally accepts a live object (set via .client=).
|
|
113
|
-
def self.parse(schema:, query:,
|
|
114
|
-
client_const =
|
|
129
|
+
def self.parse(schema:, query:, name: nil, client: nil, path: nil, module_name: nil)
|
|
130
|
+
client_const = CLIENT_CONST.call(client)
|
|
115
131
|
|
|
116
|
-
codegen = new(schema:, query:,
|
|
117
|
-
|
|
132
|
+
codegen = new(schema:, query:, name:, client: client_const, path:, module_name:,
|
|
133
|
+
default_name: "Query")
|
|
118
134
|
source = codegen.generate
|
|
119
135
|
|
|
120
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")
|
|
121
142
|
container.module_eval(source, "(graph_weaver)", 1)
|
|
122
|
-
mod = container.const_get(codegen.
|
|
123
|
-
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)" }
|
|
124
145
|
# live objects (or anonymous modules) can't be referenced from
|
|
125
146
|
# generated source — set them via the module's writer instead
|
|
126
147
|
mod.client = client if client && client_const.nil?
|
|
@@ -178,6 +199,7 @@ class GraphWeaver::Codegen
|
|
|
178
199
|
inputs.sort.each { |name| input_node(@schema.get_type(name)) }
|
|
179
200
|
enums.uniq.sort.each { |name| variable_core(@schema.get_type(name)) }
|
|
180
201
|
check_shared_collisions!(unions)
|
|
202
|
+
union_nodes.each { |union| check_shadowing!(union) }
|
|
181
203
|
|
|
182
204
|
emit_types_files(union_nodes).tap { report_untyped_scalars }
|
|
183
205
|
end
|
|
@@ -185,6 +207,7 @@ class GraphWeaver::Codegen
|
|
|
185
207
|
# module-level constants every generated query module defines — a shared
|
|
186
208
|
# type aliased to one of these would clash at load
|
|
187
209
|
MODULE_RESERVED = %w[Result QUERY Representations].to_set.freeze
|
|
210
|
+
private_constant :MODULE_RESERVED
|
|
188
211
|
|
|
189
212
|
# One hoisted shared fragment, built against the schema and named for the
|
|
190
213
|
# fragment rather than the field that spread it.
|
|
@@ -220,7 +243,7 @@ class GraphWeaver::Codegen
|
|
|
220
243
|
claim = taken[class_name] or next
|
|
221
244
|
|
|
222
245
|
raise GraphWeaver::Error,
|
|
223
|
-
"shared fragment #{name.inspect} hoists to #{@
|
|
246
|
+
"shared fragment #{name.inspect} hoists to #{@name}::#{class_name}, " \
|
|
224
247
|
"where #{claim} already generates — rename the fragment"
|
|
225
248
|
end
|
|
226
249
|
end
|
|
@@ -242,10 +265,10 @@ class GraphWeaver::Codegen
|
|
|
242
265
|
CONSTANT_NAME = /\A[A-Z]\w*(::[A-Z]\w*)*\z/
|
|
243
266
|
|
|
244
267
|
def validate_module_name!(subject)
|
|
245
|
-
return if @
|
|
268
|
+
return if @name&.match?(CONSTANT_NAME)
|
|
246
269
|
|
|
247
|
-
problem = "#{subject} must be a constant name, got #{@
|
|
248
|
-
# 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
|
|
249
272
|
# is a verdict on a FILE — a numeric prefix (01_home.graphql) is the usual
|
|
250
273
|
# way in — so it names the file, says the fix is a rename, and brands so
|
|
251
274
|
# `rake graph_weaver:generate` aborts on it instead of burying it under a
|
|
@@ -258,6 +281,7 @@ class GraphWeaver::Codegen
|
|
|
258
281
|
private :validate_module_name!
|
|
259
282
|
|
|
260
283
|
VarDef = Struct.new(:kwarg, :wire, :node, :required)
|
|
284
|
+
private_constant :VarDef
|
|
261
285
|
|
|
262
286
|
# Names generated Ruby can't spell bare — as a kwarg, a local, or a method
|
|
263
287
|
# name. As a prop they're fine (`const :next`), since a prop is only ever
|
|
@@ -280,6 +304,7 @@ class GraphWeaver::Codegen
|
|
|
280
304
|
# ArgumentError at require time. Derived rather than listed, so it tracks
|
|
281
305
|
# whatever the Ruby and sorbet-runtime in play actually define.
|
|
282
306
|
STRUCT_METHODS = (GENERATED_METHODS + T::Struct.instance_methods.map(&:to_s)).freeze
|
|
307
|
+
private_constant :RUBY_KEYWORDS, :GENERATED_METHODS, :RESERVED_KWARGS, :STRUCT_METHODS
|
|
283
308
|
|
|
284
309
|
def generate
|
|
285
310
|
begin
|
|
@@ -299,21 +324,22 @@ class GraphWeaver::Codegen
|
|
|
299
324
|
operation = load_operation(@query)
|
|
300
325
|
root_type = operation_root_type(operation)
|
|
301
326
|
|
|
302
|
-
@
|
|
303
|
-
unless @
|
|
304
|
-
raise ArgumentError, "
|
|
327
|
+
@name ||= operation.name || @default_name
|
|
328
|
+
unless @name
|
|
329
|
+
raise ArgumentError, "name: required for anonymous operations"
|
|
305
330
|
end
|
|
306
331
|
|
|
307
|
-
validate_module_name!("
|
|
332
|
+
validate_module_name!("name:")
|
|
308
333
|
|
|
309
334
|
variables = build_variables(operation)
|
|
310
335
|
root = object_node(root_type, operation.selections, "Result")
|
|
336
|
+
check_shadowing!(root)
|
|
311
337
|
|
|
312
338
|
# An anonymous operation takes the module's name — declared in the document
|
|
313
339
|
# AND sent as operationName, which have to agree (a server rejects an
|
|
314
340
|
# operationName the document doesn't declare). The conventional .graphql
|
|
315
341
|
# file names nothing, so without this every trace arrives anonymous.
|
|
316
|
-
operation_name = operation.name || @
|
|
342
|
+
operation_name = operation.name || @name.split("::").last
|
|
317
343
|
@query = declare_operation_name(operation, operation_name) unless operation.name
|
|
318
344
|
|
|
319
345
|
emit_module(root, variables, representation_nodes(operation, root_type), operation_name)
|
|
@@ -405,6 +431,7 @@ class GraphWeaver::Codegen
|
|
|
405
431
|
# The subgraph spec's representation scalar. A field taking one is the
|
|
406
432
|
# entity resolver, whatever it's called.
|
|
407
433
|
REPRESENTATION_SCALAR = "_Any"
|
|
434
|
+
private_constant :REPRESENTATION_SCALAR
|
|
408
435
|
|
|
409
436
|
def representation_field?(definition)
|
|
410
437
|
definition.arguments.each_value.any? { |argument| argument.type.unwrap.graphql_name == REPRESENTATION_SCALAR }
|
|
@@ -475,7 +502,7 @@ class GraphWeaver::Codegen
|
|
|
475
502
|
if core.kind.name == "SCALAR"
|
|
476
503
|
node = scalar_node(core.graphql_name, "#{entity.graphql_name}.#{name}")
|
|
477
504
|
type = required ? node.bare_type : node.prop_type
|
|
478
|
-
value =
|
|
505
|
+
value = representation_value(entity, name, kwarg, node)
|
|
479
506
|
else
|
|
480
507
|
# a nested key set — or an enum/composite one — passes through as an
|
|
481
508
|
# open hash, narrowed to the declared sub-paths by the runtime
|
|
@@ -488,38 +515,108 @@ class GraphWeaver::Codegen
|
|
|
488
515
|
end
|
|
489
516
|
end
|
|
490
517
|
|
|
491
|
-
#
|
|
492
|
-
#
|
|
493
|
-
#
|
|
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.
|
|
494
562
|
def self.validate_registration!(schema, kind, name)
|
|
563
|
+
method = REGISTRATION_METHOD.fetch(kind)
|
|
495
564
|
# register_scalar("Type.field", ...) overrides one field's scalar — validate
|
|
496
|
-
# the field
|
|
497
|
-
if kind == "scalar" && name.include?(".")
|
|
498
|
-
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?(".")
|
|
499
567
|
|
|
500
|
-
|
|
501
|
-
|
|
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
|
|
502
570
|
|
|
503
|
-
|
|
571
|
+
expected = REGISTERED_KIND[kind]
|
|
572
|
+
return if expected.nil? || type.kind.name == expected
|
|
504
573
|
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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)}" : ""}"
|
|
510
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
|
|
511
593
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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"
|
|
517
601
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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"
|
|
522
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
|
|
523
620
|
|
|
524
621
|
# Parse every fragment file under `paths` into one { name => FragmentDefinition }
|
|
525
622
|
# map — reusable fragments a query can spread. Fragment files hold only
|
|
@@ -527,17 +624,18 @@ class GraphWeaver::Codegen
|
|
|
527
624
|
def self.load_fragments(paths)
|
|
528
625
|
source = {} # fragment name => the file that defined it, for the collision message
|
|
529
626
|
|
|
530
|
-
|
|
627
|
+
GraphWeaver::Internal::Util.query_files(paths).each_with_object({}) do |file, out|
|
|
531
628
|
doc = parse_document(File.read(file), file)
|
|
629
|
+
reported = GraphWeaver::Internal::Util.relative(file)
|
|
532
630
|
if doc.definitions.grep(GraphQL::Language::Nodes::OperationDefinition).any?
|
|
533
|
-
raise GraphWeaver::Error, "#{
|
|
631
|
+
raise GraphWeaver::Error, "#{reported}: fragment files define only fragments, no operations"
|
|
534
632
|
end
|
|
535
633
|
doc.definitions.grep(GraphQL::Language::Nodes::FragmentDefinition).each do |frag|
|
|
536
634
|
if (earlier = source[frag.name])
|
|
537
635
|
raise GraphWeaver::Error,
|
|
538
|
-
"duplicate shared fragment '#{frag.name}' — defined in #{earlier} and #{
|
|
636
|
+
"duplicate shared fragment '#{frag.name}' — defined in #{earlier} and #{reported}; rename one"
|
|
539
637
|
end
|
|
540
|
-
source[frag.name] =
|
|
638
|
+
source[frag.name] = reported
|
|
541
639
|
out[frag.name] = frag
|
|
542
640
|
end
|
|
543
641
|
end
|
|
@@ -565,11 +663,12 @@ class GraphWeaver::Codegen
|
|
|
565
663
|
def self.parse_document(query, path = nil)
|
|
566
664
|
GraphQL.parse(query)
|
|
567
665
|
rescue GraphQL::ParseError => e
|
|
568
|
-
prefix = [path, e.line, e.col].compact.join(":")
|
|
666
|
+
prefix = [path && GraphWeaver::Internal::Util.relative(path), e.line, e.col].compact.join(":")
|
|
569
667
|
raise GraphWeaver::ValidationError.new(
|
|
570
668
|
[{ message: prefix.empty? ? e.message : "#{prefix} #{e.message}", line: e.line, column: e.col }],
|
|
571
669
|
)
|
|
572
670
|
end
|
|
671
|
+
private_class_method :parse_document
|
|
573
672
|
|
|
574
673
|
# Append the shared fragments a query spreads (transitively) to its source, so
|
|
575
674
|
# the sent query is self-contained. Unused shared fragments are left out.
|
|
@@ -621,17 +720,12 @@ class GraphWeaver::Codegen
|
|
|
621
720
|
{ message: prefix.empty? ? message : "#{prefix} #{message}", line:, column: }
|
|
622
721
|
end
|
|
623
722
|
|
|
624
|
-
#
|
|
625
|
-
#
|
|
626
|
-
#
|
|
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.
|
|
627
727
|
def validate_registrations!
|
|
628
|
-
{
|
|
629
|
-
"enum" => GraphWeaver::Codegen.enum_registry,
|
|
630
|
-
"scalar" => GraphWeaver::Codegen.scalar_registry.except(*BUILTIN_SCALARS),
|
|
631
|
-
"type" => GraphWeaver::Codegen.type_registry,
|
|
632
|
-
}.each do |kind, registry|
|
|
633
|
-
registry.each_key { |name| self.class.validate_registration!(@schema, kind, name) }
|
|
634
|
-
end
|
|
728
|
+
self.class.unmatched_registrations(@schema).each { |message| GraphWeaver::Internal::Log.log(:warn) { message } }
|
|
635
729
|
end
|
|
636
730
|
|
|
637
731
|
# The @include/@skip a fragment carries applies to what it guards, so it has
|
|
@@ -686,7 +780,7 @@ class GraphWeaver::Codegen
|
|
|
686
780
|
type_ref(field_type) { object_node(core, sub_selections, name) }
|
|
687
781
|
when "UNION", "INTERFACE"
|
|
688
782
|
conditions = concrete_conditions(core, sub_selections)
|
|
689
|
-
|
|
783
|
+
shared = abstract_level_fields(core, sub_selections)
|
|
690
784
|
|
|
691
785
|
if conditions.empty?
|
|
692
786
|
# abstract-level fields only — every member shares them, so one
|
|
@@ -694,7 +788,7 @@ class GraphWeaver::Codegen
|
|
|
694
788
|
# union that selection can only be __typename)
|
|
695
789
|
name = pick_name(key, taken)
|
|
696
790
|
type_ref(field_type) { object_node(core, sub_selections, name) }
|
|
697
|
-
elsif conditions.size == 1 &&
|
|
791
|
+
elsif conditions.size == 1 && shared.empty? &&
|
|
698
792
|
(member = @schema.get_type(conditions.first)).kind.name == "OBJECT"
|
|
699
793
|
# a single `... on X` condition: narrow to X's struct — nil
|
|
700
794
|
# when the runtime type doesn't match (narrowing filters).
|
|
@@ -742,7 +836,7 @@ class GraphWeaver::Codegen
|
|
|
742
836
|
type_ref(field_type) { variable_core(core) }
|
|
743
837
|
when "SCALAR"
|
|
744
838
|
coordinate = "#{type.graphql_name}.#{field_name}"
|
|
745
|
-
type_ref(field_type) { scalar_node(core.graphql_name, coordinate) }
|
|
839
|
+
type_ref(field_type) { scalar_node(core.graphql_name, coordinate, result: true) }
|
|
746
840
|
else
|
|
747
841
|
raise GraphWeaver::Error, "unsupported kind: #{core.kind.name}"
|
|
748
842
|
end
|
|
@@ -763,6 +857,69 @@ class GraphWeaver::Codegen
|
|
|
763
857
|
node
|
|
764
858
|
end
|
|
765
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
|
+
|
|
766
923
|
# Both ways a result key can fail to become a prop — a name the struct
|
|
767
924
|
# already answers, or a second key that underscores onto an earlier one.
|
|
768
925
|
# Either emits a file that raises ArgumentError at require time, so refuse
|
|
@@ -806,9 +963,19 @@ class GraphWeaver::Codegen
|
|
|
806
963
|
end.compact.uniq - [core.graphql_name]
|
|
807
964
|
end
|
|
808
965
|
|
|
809
|
-
#
|
|
810
|
-
|
|
811
|
-
|
|
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
|
|
812
979
|
end
|
|
813
980
|
|
|
814
981
|
# The fragment name when a selection is exactly one bare fragment spread
|
|
@@ -1065,6 +1232,16 @@ class GraphWeaver::Codegen
|
|
|
1065
1232
|
# ("Enum values must be assigned to constants") — catch it here instead.
|
|
1066
1233
|
def enum_values(core)
|
|
1067
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
|
+
|
|
1068
1245
|
collision = values.group_by { |value| camelize(value.downcase) }.find { |_, group| group.size > 1 }
|
|
1069
1246
|
if collision
|
|
1070
1247
|
raise GraphWeaver::Error,
|
|
@@ -1099,17 +1276,40 @@ class GraphWeaver::Codegen
|
|
|
1099
1276
|
# generated file can require them (collected across the whole query).
|
|
1100
1277
|
# Resolution, most specific first: a per-field override (`Type.field`), then
|
|
1101
1278
|
# the scalar-name registration.
|
|
1102
|
-
def scalar_node(name, coordinate = nil)
|
|
1279
|
+
def scalar_node(name, coordinate = nil, result: false)
|
|
1103
1280
|
registry = GraphWeaver::Codegen.scalar_registry
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
scalar = GraphWeaver::Codegen.scalar(name)
|
|
1108
|
-
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
|
|
1109
1284
|
@requires.concat(scalar.requires)
|
|
1110
1285
|
Scalar.new(scalar)
|
|
1111
1286
|
end
|
|
1112
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
|
+
|
|
1113
1313
|
# An unregistered custom scalar passes through as T.untyped — legitimate
|
|
1114
1314
|
# (nobody needs a codec for every scalar), but it's the one hole in an
|
|
1115
1315
|
# otherwise exact result type, so name the holes rather than leave them
|
|
@@ -1118,7 +1318,7 @@ class GraphWeaver::Codegen
|
|
|
1118
1318
|
names = @untyped_scalars.uniq.sort
|
|
1119
1319
|
return if names.empty?
|
|
1120
1320
|
|
|
1121
|
-
GraphWeaver.log(:info) do
|
|
1321
|
+
GraphWeaver::Internal::Log.log(:info) do
|
|
1122
1322
|
"#{names.size} unregistered custom scalar#{"s" unless names.one?} → T.untyped: " \
|
|
1123
1323
|
"#{names.join(", ")} (register with GraphWeaver.register_scalar)"
|
|
1124
1324
|
end
|