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
data/lib/graph_weaver/codegen.rb
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
require "graphql"
|
|
5
5
|
require "sorbet-runtime"
|
|
6
6
|
|
|
7
|
+
require_relative "result_struct"
|
|
8
|
+
|
|
7
9
|
# Generates plain, statically-typecheckable Ruby from a GraphQL query +
|
|
8
10
|
# schema: nested T::Structs, from_h casting code, and a sig'd execute
|
|
9
11
|
# method. The output is source on disk, so srb tc sees the exact result
|
|
@@ -32,6 +34,8 @@ require_relative "codegen/scalar_type"
|
|
|
32
34
|
require_relative "codegen/nodes"
|
|
33
35
|
require_relative "codegen/aliases"
|
|
34
36
|
require_relative "codegen/emit"
|
|
37
|
+
# after the three files that fill Registrations in — it gives them a home
|
|
38
|
+
require_relative "codegen/registry"
|
|
35
39
|
|
|
36
40
|
class GraphWeaver::Codegen
|
|
37
41
|
include GraphWeaver::Inflect
|
|
@@ -61,7 +65,9 @@ class GraphWeaver::Codegen
|
|
|
61
65
|
#
|
|
62
66
|
# client: (a constant, or its name as a string) becomes the generated
|
|
63
67
|
# module's baked default; when omitted, generated code falls back to
|
|
64
|
-
# the app default (GraphWeaver.client=).
|
|
68
|
+
# the app default (GraphWeaver.client=). graph_name: is the graph the
|
|
69
|
+
# module belongs to, baked in so a test mode can build its stand-in from
|
|
70
|
+
# the right schema. name: is the module the file
|
|
65
71
|
# defines, defaulting to the operation's own name; default_name: is
|
|
66
72
|
# parse's container-scoped fallback (file generation stays strict — a
|
|
67
73
|
# checked-in file deserves a deliberate name). types_namespace: is the shared-types workflow (see
|
|
@@ -73,9 +79,13 @@ class GraphWeaver::Codegen
|
|
|
73
79
|
# shared module (see used_union_names). path: is the file the query was read
|
|
74
80
|
# from, named alongside line and column in validation errors.
|
|
75
81
|
def initialize(schema:, query:, name: nil, client: nil, default_name: nil,
|
|
76
|
-
types_namespace: nil, hoistable_unions: nil, path: nil, module_name: nil
|
|
82
|
+
types_namespace: nil, hoistable_unions: nil, path: nil, module_name: nil,
|
|
83
|
+
graph_name: nil, registry: GraphWeaver::Codegen.registry)
|
|
77
84
|
renamed!(module_name)
|
|
78
85
|
@schema = schema
|
|
86
|
+
# the registrations this generation reads — one graph's, or the default
|
|
87
|
+
# graph's, which is what every top-level register_scalar writes to
|
|
88
|
+
@registry = registry
|
|
79
89
|
@query = query.strip
|
|
80
90
|
# only ever quoted in a message, so it is stored the way it is reported
|
|
81
91
|
@path = path && GraphWeaver::Internal::Util.relative(path)
|
|
@@ -87,11 +97,26 @@ class GraphWeaver::Codegen
|
|
|
87
97
|
# scalars this generation had no registration for (see report_untyped_scalars)
|
|
88
98
|
@untyped_scalars = []
|
|
89
99
|
@client_const = CLIENT_CONST.call(client)
|
|
100
|
+
# the graph this module belongs to, baked in beside the client: a test
|
|
101
|
+
# mode builds its stand-in from the module's own schema, and only the
|
|
102
|
+
# module can say whose that is (GraphWeaver::Internal::TestClients).
|
|
103
|
+
# A Symbol, as GraphWeaver.graph makes it — the name is the identity.
|
|
104
|
+
@graph_name = graph_name&.to_sym
|
|
90
105
|
|
|
91
106
|
if client && @client_const.nil?
|
|
92
107
|
# a live object can't be spelled in generated source — parse can
|
|
93
108
|
# set one via the module's writer, but file generation cannot
|
|
94
|
-
raise ArgumentError, "client: must be a named constant or String (got #{client.inspect})
|
|
109
|
+
raise ArgumentError, "client: must be a named constant or String (got #{client.inspect}) — " \
|
|
110
|
+
"put the object in a constant and name it, client: \"MyApi::CLIENT\"; pass live objects to parse"
|
|
111
|
+
end
|
|
112
|
+
# The String is written into the module verbatim, so anything that isn't a
|
|
113
|
+
# constant path emits source that doesn't parse. A url is the way to get
|
|
114
|
+
# here — it is where the endpoint is spelled everywhere else — so the fix
|
|
115
|
+
# names the value that was passed.
|
|
116
|
+
if @client_const && !@client_const.match?(CONSTANT_NAME)
|
|
117
|
+
raise ArgumentError, "client: #{@client_const.inspect} isn't a constant — generated source " \
|
|
118
|
+
"spells this name, so it has to be one: CLIENT = GraphWeaver.new(#{@client_const.inspect}), " \
|
|
119
|
+
"then client \"CLIENT\""
|
|
95
120
|
end
|
|
96
121
|
end
|
|
97
122
|
|
|
@@ -126,10 +151,10 @@ class GraphWeaver::Codegen
|
|
|
126
151
|
# file, but invisible to srb tc — use the build step for static typing.
|
|
127
152
|
# Evaluates into an anonymous container, so no global constants leak;
|
|
128
153
|
# client: additionally accepts a live object (set via .client=).
|
|
129
|
-
def self.parse(schema:, query:, name: nil, client: nil, path: nil, module_name: nil)
|
|
154
|
+
def self.parse(schema:, query:, name: nil, client: nil, path: nil, module_name: nil, graph_name: nil)
|
|
130
155
|
client_const = CLIENT_CONST.call(client)
|
|
131
156
|
|
|
132
|
-
codegen = new(schema:, query:, name:, client: client_const, path:, module_name:,
|
|
157
|
+
codegen = new(schema:, query:, name:, client: client_const, path:, module_name:, graph_name:,
|
|
133
158
|
default_name: "Query")
|
|
134
159
|
source = codegen.generate
|
|
135
160
|
|
|
@@ -148,16 +173,6 @@ class GraphWeaver::Codegen
|
|
|
148
173
|
mod
|
|
149
174
|
end
|
|
150
175
|
|
|
151
|
-
# Every registry back to its starting state — scalars (built-ins restored),
|
|
152
|
-
# enum mappings, and type helpers. The clean slate between tests, and the
|
|
153
|
-
# one call that stays right when a fourth kind of registration shows up.
|
|
154
|
-
def self.reset_registrations!
|
|
155
|
-
reset_scalars!
|
|
156
|
-
reset_enums!
|
|
157
|
-
reset_type_helpers!
|
|
158
|
-
self
|
|
159
|
-
end
|
|
160
|
-
|
|
161
176
|
# The schema-level types this walk touched, by GraphQL name — the generate!
|
|
162
177
|
# workflow unions these across queries to decide what the shared types module
|
|
163
178
|
# must contain.
|
|
@@ -170,6 +185,11 @@ class GraphWeaver::Codegen
|
|
|
170
185
|
# must contain.
|
|
171
186
|
def used_union_names = @used_unions.dup
|
|
172
187
|
|
|
188
|
+
# The custom scalars this walk found no registration for (see
|
|
189
|
+
# report_untyped_scalars) — the generate! workflow unions these across
|
|
190
|
+
# queries so the build says them once, for GraphWeaver.untyped_scalars.
|
|
191
|
+
def untyped_scalars = @untyped_scalars.uniq.sort
|
|
192
|
+
|
|
173
193
|
# The shared types artifact: every type a schema shares across query modules,
|
|
174
194
|
# emitted once as a manifest (types.rb) plus one file per type under types/,
|
|
175
195
|
# so a schema migration diffs only the types it touched. Returns
|
|
@@ -253,6 +273,12 @@ class GraphWeaver::Codegen
|
|
|
253
273
|
def reset_walk_state!
|
|
254
274
|
@enums = {}
|
|
255
275
|
@variable_inputs = {}
|
|
276
|
+
# the variable declaration the input walk descends from, and the field
|
|
277
|
+
# hops it has taken since — an input collision is about a type the user
|
|
278
|
+
# usually never named, so the refusal has to point back at what they wrote
|
|
279
|
+
@input_from = nil
|
|
280
|
+
@input_list = false
|
|
281
|
+
@input_hops = []
|
|
256
282
|
@mapped_enums = {}
|
|
257
283
|
@used_unions = []
|
|
258
284
|
# requires the generated file needs (custom scalars, enum mappings,
|
|
@@ -265,6 +291,15 @@ class GraphWeaver::Codegen
|
|
|
265
291
|
CONSTANT_NAME = /\A[A-Z]\w*(::[A-Z]\w*)*\z/
|
|
266
292
|
|
|
267
293
|
def validate_module_name!(subject)
|
|
294
|
+
# Generated code spells Sorbet's T inside the module's own body (T.let,
|
|
295
|
+
# T::Struct), and constant lookup finds a top-level T first — so a module
|
|
296
|
+
# whose path starts with one shadows the thing it depends on. A generated
|
|
297
|
+
# file can't get here (a file name always gains a Query/Mutation word);
|
|
298
|
+
# an explicit name:, a namespace, and parse can.
|
|
299
|
+
if @name&.split("::")&.first == "T"
|
|
300
|
+
raise ArgumentError, "#{subject} #{@name.inspect} — a top-level module named T shadows " \
|
|
301
|
+
"Sorbet's T, which generated code uses in its own body (T.let, T::Struct)"
|
|
302
|
+
end
|
|
268
303
|
return if @name&.match?(CONSTANT_NAME)
|
|
269
304
|
|
|
270
305
|
problem = "#{subject} must be a constant name, got #{@name.inspect}"
|
|
@@ -299,23 +334,90 @@ class GraphWeaver::Codegen
|
|
|
299
334
|
# Ruby local is unreachable by a GraphQL variable name, so this is a guard
|
|
300
335
|
# rather than a rename.
|
|
301
336
|
RESERVED_KWARGS = %w[client variables].to_set.freeze
|
|
302
|
-
#
|
|
303
|
-
#
|
|
304
|
-
#
|
|
305
|
-
#
|
|
306
|
-
|
|
307
|
-
|
|
337
|
+
# One rule: a prop may not shadow a method its struct answers. Three kinds of
|
|
338
|
+
# name land in it — the ones T::Struct and Object answer PUBLICLY, the hooks
|
|
339
|
+
# Ruby and Rails call on any object without it defining one, and the ones the
|
|
340
|
+
# gem's own mixins define.
|
|
341
|
+
#
|
|
342
|
+
# Kernel's PRIVATE methods are deliberately NOT here. A struct doesn't answer
|
|
343
|
+
# them, so a prop shadows one only for a bare call from inside the struct —
|
|
344
|
+
# which is the gem's code to keep qualified (`Kernel.raise` in hints.rb and
|
|
345
|
+
# input_struct.rb), not the schema's to avoid. Reserving them refused real
|
|
346
|
+
# columns: a Hasura bool_exp has one input field per column, and `format`,
|
|
347
|
+
# `select`, `test`, `open`, `load` and `pp` are all columns somebody has.
|
|
348
|
+
#
|
|
349
|
+
# Listed, not derived from the live T::Struct: derivation made generation a
|
|
350
|
+
# function of require order — with ActiveSupport loaded first `as_json` was
|
|
351
|
+
# refused, loaded second it became a prop that shadowed the real #as_json —
|
|
352
|
+
# and generated output must depend on nothing but the schema, the query and
|
|
353
|
+
# the gem. sorbet-runtime's own BANNED_METHOD_NAMES is that same snapshot of
|
|
354
|
+
# `Object.instance_methods`, so it can't be leaned on either. A name a later
|
|
355
|
+
# Ruby adds and this misses is loud anyway: the generated file raises
|
|
356
|
+
# ArgumentError when it is required.
|
|
357
|
+
#
|
|
358
|
+
# The gem's own mixins stay derived — they are the gem's to track, so adding
|
|
359
|
+
# a method to one reserves its name without a second edit here.
|
|
360
|
+
RESERVED_PROPS = (GENERATED_METHODS +
|
|
361
|
+
# what T::Struct and Object answer publicly. `to_yaml` and `pretty_print*`
|
|
362
|
+
# ride in on psych and pp, which land on Object whenever they are loaded —
|
|
363
|
+
# listed unconditionally, since what else is loaded is not generation's
|
|
364
|
+
# business.
|
|
365
|
+
%w[
|
|
366
|
+
! != !~ <=> == === __id__ __send__
|
|
367
|
+
class clone define_singleton_method deserialize display dup enum_for eql?
|
|
368
|
+
equal? extend freeze frozen? hash inspect instance_eval instance_exec
|
|
369
|
+
instance_of? instance_variable_defined? instance_variable_get instance_variable_set
|
|
370
|
+
instance_variables is_a? itself kind_of? method methods nil? object_id
|
|
371
|
+
pretty_inspect pretty_print pretty_print_cycle pretty_print_inspect
|
|
372
|
+
pretty_print_instance_variables private_methods protected_methods
|
|
373
|
+
public_method public_methods public_send remove_instance_variable respond_to?
|
|
374
|
+
send serialize singleton_class singleton_method singleton_methods tap then
|
|
375
|
+
to_enum to_s to_yaml with yield_self
|
|
376
|
+
] +
|
|
377
|
+
# hooks called on an object that doesn't define them: `initialize` from
|
|
378
|
+
# .new, `initialize_copy` from dup, `to_ary` from `puts`, `deconstruct`
|
|
379
|
+
# from an array pattern, `as_json` from `render json:`
|
|
380
|
+
%w[
|
|
381
|
+
as_json deconstruct deconstruct_keys each initialize initialize_clone
|
|
382
|
+
initialize_copy initialize_dup presence to_a to_ary to_hash to_int to_json
|
|
383
|
+
to_param to_proc to_query to_str try
|
|
384
|
+
] +
|
|
385
|
+
[GraphWeaver::ResultStruct, GraphWeaver::Hints, GraphWeaver::InputStruct].flat_map { |mod|
|
|
386
|
+
(mod.instance_methods(false) + mod.private_instance_methods(false)).map(&:to_s)
|
|
387
|
+
}).freeze
|
|
388
|
+
private_constant :RUBY_KEYWORDS, :GENERATED_METHODS, :RESERVED_KWARGS, :RESERVED_PROPS
|
|
389
|
+
|
|
390
|
+
# The Ruby name for a GraphQL field or result key: snake_case, plus a
|
|
391
|
+
# trailing underscore when that would shadow a method the struct answers
|
|
392
|
+
# (`class` -> `class_`, `hash` -> `hash_`). A schema's field name is not the
|
|
393
|
+
# user's to rename, and `class`, `hash`, `display` and `supplied` are all
|
|
394
|
+
# columns somebody has — so renaming beats refusing. Only the Ruby side
|
|
395
|
+
# moves: the wire name, and everything read from or written to the wire,
|
|
396
|
+
# is untouched.
|
|
397
|
+
#
|
|
398
|
+
# Public because the rule has readers outside generation: Hints maps a wire
|
|
399
|
+
# key onto the prop that holds it, and the round-trip harness checks what
|
|
400
|
+
# came back against them. A second spelling of the rule is how the two sides
|
|
401
|
+
# drift apart.
|
|
402
|
+
def self.prop_name(graphql_name)
|
|
403
|
+
prop = GraphWeaver::Inflect.underscore(graphql_name)
|
|
404
|
+
RESERVED_PROPS.include?(prop) ? "#{prop}_" : prop
|
|
405
|
+
end
|
|
308
406
|
|
|
309
407
|
def generate
|
|
310
|
-
begin
|
|
311
|
-
|
|
408
|
+
document = begin
|
|
409
|
+
GraphQL.parse(@query)
|
|
312
410
|
rescue GraphQL::ParseError => e
|
|
313
411
|
# unparseable queries wrap like invalid ones — everything raised
|
|
314
412
|
# here descends from GraphWeaver::Error
|
|
315
|
-
raise GraphWeaver::
|
|
413
|
+
raise GraphWeaver::QueryValidationError.new([detail(e.message, e.line, e.col)])
|
|
316
414
|
end
|
|
415
|
+
|
|
416
|
+
refuse_incremental!(document)
|
|
417
|
+
|
|
418
|
+
errors = @schema.validate(document)
|
|
317
419
|
if errors.any?
|
|
318
|
-
raise GraphWeaver::
|
|
420
|
+
raise GraphWeaver::QueryValidationError.new(errors.map { |e| validation_detail(e) })
|
|
319
421
|
end
|
|
320
422
|
|
|
321
423
|
validate_registrations!
|
|
@@ -340,7 +442,7 @@ class GraphWeaver::Codegen
|
|
|
340
442
|
# operationName the document doesn't declare). The conventional .graphql
|
|
341
443
|
# file names nothing, so without this every trace arrives anonymous.
|
|
342
444
|
operation_name = operation.name || @name.split("::").last
|
|
343
|
-
@query = declare_operation_name(
|
|
445
|
+
@query = declare_operation_name(operation_name) unless operation.name
|
|
344
446
|
|
|
345
447
|
emit_module(root, variables, representation_nodes(operation, root_type), operation_name)
|
|
346
448
|
.tap { report_untyped_scalars }
|
|
@@ -352,12 +454,51 @@ class GraphWeaver::Codegen
|
|
|
352
454
|
# document exactly as written — re-printing the AST would reformat the query
|
|
353
455
|
# the reader reviews. The module name is already constrained to
|
|
354
456
|
# /[A-Z]\w*(::[A-Z]\w*)*/, so its last segment is always a legal GraphQL name.
|
|
355
|
-
def declare_operation_name(
|
|
356
|
-
at =
|
|
457
|
+
def declare_operation_name(name)
|
|
458
|
+
at = operation_offset
|
|
357
459
|
keyword = @query[at..].to_s[/\A(?:query|mutation|subscription)\b/]
|
|
358
|
-
|
|
460
|
+
named = if keyword
|
|
461
|
+
"#{@query[0, at + keyword.length]} #{name}#{@query[(at + keyword.length)..]}"
|
|
462
|
+
else # `{ ... }` shorthand
|
|
463
|
+
"#{@query[0, at]}query #{name} #{@query[at..]}"
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
declares!(named, name)
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
# Where the operation starts in @query, in characters. graphql-ruby subtracts
|
|
470
|
+
# a CHARACTER line start from the lexer's BYTE position
|
|
471
|
+
# (Language::Parser#column_at over Lexer's @scanner.pos), so line start plus
|
|
472
|
+
# col is a byte offset — and col falls back to a whole-document offset for a
|
|
473
|
+
# token with no newline after it, which .strip guarantees for the last line,
|
|
474
|
+
# hence the copy that ends in one.
|
|
475
|
+
def operation_offset
|
|
476
|
+
operation = sole_operation("#{@query}\n")
|
|
477
|
+
bytes = @query.lines.first(operation.line - 1).sum(&:length) + operation.col - 1
|
|
478
|
+
@query.byteslice(0, bytes).length
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
# The splice above is arithmetic over a position graphql-ruby reports, so it
|
|
482
|
+
# can land wrong and still emit a module that looks fine — and OPERATION_NAME
|
|
483
|
+
# would then be an operationName the document doesn't declare, which every
|
|
484
|
+
# server rejects. Nothing else re-reads the query, so this is the only place
|
|
485
|
+
# that can refuse.
|
|
486
|
+
def declares!(query, name)
|
|
487
|
+
declared = begin
|
|
488
|
+
sole_operation(query)&.name
|
|
489
|
+
rescue GraphQL::ParseError
|
|
490
|
+
nil
|
|
491
|
+
end
|
|
492
|
+
return query if declared == name
|
|
493
|
+
|
|
494
|
+
raise GraphWeaver::Error, "#{@path ? "#{@path}: " : ""}could not name the anonymous operation — " \
|
|
495
|
+
"the document GraphWeaver would send does not declare #{name.inspect}. Name the operation in " \
|
|
496
|
+
"the query itself (`query #{name} { ... }`) and please report this as a bug."
|
|
497
|
+
end
|
|
359
498
|
|
|
360
|
-
|
|
499
|
+
# load_operation has already refused a document with a second one.
|
|
500
|
+
def sole_operation(query)
|
|
501
|
+
GraphQL.parse(query).definitions.grep(GraphQL::Language::Nodes::OperationDefinition).first
|
|
361
502
|
end
|
|
362
503
|
|
|
363
504
|
# The operation's variables as execute's kwarg surface: one VarDef each,
|
|
@@ -365,6 +506,9 @@ class GraphWeaver::Codegen
|
|
|
365
506
|
# optional kwargs default to nil and are omitted from the wire.
|
|
366
507
|
def build_variables(operation)
|
|
367
508
|
variables = operation.variables.map do |var|
|
|
509
|
+
@input_from = "$#{var.name}"
|
|
510
|
+
@input_list = ast_list?(var.type)
|
|
511
|
+
@input_hops = []
|
|
368
512
|
node = ast_type_ref(var.type)
|
|
369
513
|
required = node.non_null? && var.default_value.nil?
|
|
370
514
|
kwarg = underscore(var.name)
|
|
@@ -475,46 +619,95 @@ class GraphWeaver::Codegen
|
|
|
475
619
|
# "id organization { id }" — flattened to the dotted leaf paths the wire
|
|
476
620
|
# hash needs. The same reading the routing table does of the same syntax,
|
|
477
621
|
# so a supergraph and a subgraph SDL can't disagree about one key.
|
|
622
|
+
#
|
|
623
|
+
# Each hop the schema declares as a list is marked: "id lineItems { sku }"
|
|
624
|
+
# over a `[LineItem!]!` becomes "lineItems[].sku". The runtime builds the
|
|
625
|
+
# wire representation from these paths and nothing else, so list-ness has
|
|
626
|
+
# to travel in them — otherwise a list arrives as one object and the
|
|
627
|
+
# subgraph is asked about an entity that doesn't exist.
|
|
478
628
|
def key_paths(entity, fields)
|
|
479
|
-
GraphWeaver::SchemaLoader::RoutingTable.parse_field_set(fields)
|
|
629
|
+
GraphWeaver::SchemaLoader::RoutingTable.parse_field_set(fields).map do |path|
|
|
630
|
+
mark_lists(entity.graphql_name, path)
|
|
631
|
+
end
|
|
480
632
|
rescue GraphQL::ParseError => e
|
|
481
633
|
raise GraphWeaver::Error, "#{entity.graphql_name} @key(fields: #{fields.inspect}) isn't a selection set: #{e.message}"
|
|
482
634
|
end
|
|
483
635
|
|
|
636
|
+
# A hop the schema doesn't declare is left as written — key_params names
|
|
637
|
+
# the first of those, and a deeper one is the runtime's to complain about.
|
|
638
|
+
def mark_lists(type_name, path)
|
|
639
|
+
path.split(".").map do |segment|
|
|
640
|
+
field = @schema.get_field(type_name, segment) or next segment
|
|
641
|
+
|
|
642
|
+
type_name = field.type.unwrap.graphql_name
|
|
643
|
+
field.type.list? ? "#{segment}#{GraphWeaver::Representation::LIST_HOP}" : segment
|
|
644
|
+
end.join(".")
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
# The leaf a nested @key passes through as: an open hash the runtime
|
|
648
|
+
# narrows to the declared sub-paths, rather than the sig. Identity
|
|
649
|
+
# everywhere, so `key_node` can wrap it in the same List a scalar leaf gets.
|
|
650
|
+
class OpaqueHash < Node
|
|
651
|
+
def bare_type = "T::Hash[T.untyped, T.untyped]"
|
|
652
|
+
def identity? = true
|
|
653
|
+
def serialize_identity? = true
|
|
654
|
+
def hash_coerce_identity? = true
|
|
655
|
+
end
|
|
656
|
+
private_constant :OpaqueHash
|
|
657
|
+
|
|
484
658
|
# The kwargs a builder takes: every key set's top-level field, once. Typed
|
|
485
659
|
# from the schema — a leaf key field gets its registered scalar's Ruby
|
|
486
|
-
# type, a nested one an open Hash whose shape the runtime checks
|
|
660
|
+
# type, a nested one an open Hash whose shape the runtime checks, and a key
|
|
661
|
+
# field the schema declares as a list takes a list of whichever it is.
|
|
487
662
|
def key_params(entity, key_sets, required:)
|
|
488
|
-
key_sets.flatten.map { |path|
|
|
663
|
+
key_sets.flatten.map { |path|
|
|
664
|
+
path.split(".").first.delete_suffix(GraphWeaver::Representation::LIST_HOP)
|
|
665
|
+
}.uniq.map do |name|
|
|
489
666
|
field = @schema.get_field(entity.graphql_name, name)
|
|
490
667
|
unless field
|
|
491
668
|
raise GraphWeaver::Error, "#{entity.graphql_name} @key names #{name.inspect}, which the type doesn't declare"
|
|
492
669
|
end
|
|
493
670
|
|
|
494
|
-
kwarg =
|
|
495
|
-
if RUBY_KEYWORDS.include?(kwarg)
|
|
496
|
-
raise GraphWeaver::Error,
|
|
497
|
-
"#{entity.graphql_name} @key field #{name.inspect} would become the kwarg '#{kwarg}:', " \
|
|
498
|
-
"which generated code can't declare (a Ruby keyword)"
|
|
499
|
-
end
|
|
500
|
-
|
|
671
|
+
kwarg = key_kwarg(name)
|
|
501
672
|
core = field.type.unwrap
|
|
502
|
-
if core.kind.name == "SCALAR"
|
|
503
|
-
|
|
504
|
-
type = required ? node.bare_type : node.prop_type
|
|
505
|
-
value = representation_value(entity, name, kwarg, node)
|
|
673
|
+
leaf = if core.kind.name == "SCALAR"
|
|
674
|
+
scalar_node(core.graphql_name, "#{entity.graphql_name}.#{name}")
|
|
506
675
|
else
|
|
507
|
-
# a nested key set
|
|
508
|
-
|
|
509
|
-
type = "T::Hash[T.untyped, T.untyped]"
|
|
510
|
-
type = "T.nilable(#{type})" unless required
|
|
511
|
-
value = kwarg
|
|
676
|
+
# a nested key set, or an enum one
|
|
677
|
+
OpaqueHash.new
|
|
512
678
|
end
|
|
513
|
-
|
|
514
|
-
|
|
679
|
+
# the outermost non-null is `required`'s call rather than the SDL's: an
|
|
680
|
+
# entity with two @keys makes every kwarg optional however it declares
|
|
681
|
+
# the fields
|
|
682
|
+
node = key_node(field.type.non_null? ? field.type.of_type : field.type, leaf)
|
|
683
|
+
type = required ? node.bare_type : node.prop_type
|
|
684
|
+
|
|
685
|
+
RepresentationNode::Param.new(kwarg, name, type,
|
|
686
|
+
representation_value(entity, name, kwarg, node), required)
|
|
515
687
|
end
|
|
516
688
|
end
|
|
517
689
|
|
|
690
|
+
# A @key field's list wrappers, rebuilt around its leaf node — `@key(fields:
|
|
691
|
+
# "id lineItems { sku }")` over a `[LineItem!]!` takes a list of hashes, and
|
|
692
|
+
# `Representation` walks it to reach the leaf of every element.
|
|
693
|
+
def key_node(type, leaf)
|
|
694
|
+
return NonNull.new(key_node(type.of_type, leaf)) if type.non_null?
|
|
695
|
+
return List.new(key_node(type.of_type, leaf)) if type.list?
|
|
696
|
+
|
|
697
|
+
leaf
|
|
698
|
+
end
|
|
699
|
+
|
|
700
|
+
# A @key field's kwarg. The prop rule, plus the one thing a kwarg can't be
|
|
701
|
+
# that a prop can: a Ruby keyword, since a kwarg is declared and passed
|
|
702
|
+
# bare. Renaming rather than refusing, because a subgraph's @key field is
|
|
703
|
+
# even less the user's to rename than a schema's field name — there is no
|
|
704
|
+
# escape to point them at. The SAME underscore the entity struct's prop
|
|
705
|
+
# took, or reading `slot.hash_` and writing it back is an ArgumentError.
|
|
706
|
+
def key_kwarg(name)
|
|
707
|
+
kwarg = GraphWeaver::Codegen.prop_name(name)
|
|
708
|
+
RUBY_KEYWORDS.include?(kwarg) ? "#{kwarg}_" : kwarg
|
|
709
|
+
end
|
|
710
|
+
|
|
518
711
|
# The kwarg's trip onto the wire — the same normalize-then-serialize an
|
|
519
712
|
# execute kwarg gets (see Emit#variable_serialize), since the builder's sig
|
|
520
713
|
# is `.checked(:never)` too and a representation is built from params just
|
|
@@ -528,96 +721,6 @@ class GraphWeaver::Codegen
|
|
|
528
721
|
"{ |v1| #{inner} }"
|
|
529
722
|
end
|
|
530
723
|
|
|
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.
|
|
562
|
-
def self.validate_registration!(schema, kind, name)
|
|
563
|
-
method = REGISTRATION_METHOD.fetch(kind)
|
|
564
|
-
# register_scalar("Type.field", ...) overrides one field's scalar — validate
|
|
565
|
-
# the field, not that a type named "Type.field" exists.
|
|
566
|
-
return validate_scalar_field!(schema, name, method) if kind == "scalar" && name.include?(".")
|
|
567
|
-
|
|
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
|
|
570
|
-
|
|
571
|
-
expected = REGISTERED_KIND[kind]
|
|
572
|
-
return if expected.nil? || type.kind.name == expected
|
|
573
|
-
|
|
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)}" : ""}"
|
|
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
|
|
593
|
-
|
|
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"
|
|
601
|
-
|
|
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"
|
|
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
|
|
620
|
-
|
|
621
724
|
# Parse every fragment file under `paths` into one { name => FragmentDefinition }
|
|
622
725
|
# map — reusable fragments a query can spread. Fragment files hold only
|
|
623
726
|
# fragments (no operations); names are unique across them.
|
|
@@ -664,7 +767,7 @@ class GraphWeaver::Codegen
|
|
|
664
767
|
GraphQL.parse(query)
|
|
665
768
|
rescue GraphQL::ParseError => e
|
|
666
769
|
prefix = [path && GraphWeaver::Internal::Util.relative(path), e.line, e.col].compact.join(":")
|
|
667
|
-
raise GraphWeaver::
|
|
770
|
+
raise GraphWeaver::QueryValidationError.new(
|
|
668
771
|
[{ message: prefix.empty? ? e.message : "#{prefix} #{e.message}", line: e.line, column: e.col }],
|
|
669
772
|
)
|
|
670
773
|
end
|
|
@@ -705,14 +808,32 @@ class GraphWeaver::Codegen
|
|
|
705
808
|
end
|
|
706
809
|
private_class_method :fragment_spreads
|
|
707
810
|
|
|
811
|
+
# @defer/@stream deliver the answer in instalments over a multipart body,
|
|
812
|
+
# and generated code reads one JSON response — so there is nothing to
|
|
813
|
+
# generate, whether or not the schema in hand declares the directives.
|
|
814
|
+
# Above validation because most schemas don't: "Directive @defer is not
|
|
815
|
+
# defined" reads like a typo, and it stops being raised at all the day a
|
|
816
|
+
# supergraph @links the defer spec.
|
|
817
|
+
def refuse_incremental!(document)
|
|
818
|
+
node = GraphWeaver::Internal::Selection.incremental_directive(document) or return
|
|
819
|
+
|
|
820
|
+
raise GraphWeaver::QueryValidationError.new([detail(
|
|
821
|
+
"this query carries @#{node.name}, and the answer would arrive in more than one payload " \
|
|
822
|
+
"(incremental delivery) — generated code reads a single JSON response, so drop the " \
|
|
823
|
+
"directive or move those selections into their own query",
|
|
824
|
+
node.line, node.col,
|
|
825
|
+
)])
|
|
826
|
+
end
|
|
827
|
+
private :refuse_incremental!
|
|
828
|
+
|
|
708
829
|
# Structured shape for a schema-validation error: message plus its first
|
|
709
|
-
# source location, so
|
|
830
|
+
# source location, so QueryValidationError#errors is inspectable.
|
|
710
831
|
def validation_detail(error)
|
|
711
832
|
loc = (error.to_h["locations"]&.first if error.respond_to?(:to_h))
|
|
712
833
|
detail(error.message, loc && loc["line"], loc && loc["column"])
|
|
713
834
|
end
|
|
714
835
|
|
|
715
|
-
# One
|
|
836
|
+
# One QueryValidationError entry, its message prefixed "file:line:col" like a
|
|
716
837
|
# compiler — the position is captured either way, and without it a project
|
|
717
838
|
# with thirty query files leaves the reader hunting for the typo.
|
|
718
839
|
def detail(message, line, column)
|
|
@@ -725,7 +846,7 @@ class GraphWeaver::Codegen
|
|
|
725
846
|
# The build channel prints the same list once per run; see
|
|
726
847
|
# GraphWeaver.unmatched_registrations.
|
|
727
848
|
def validate_registrations!
|
|
728
|
-
|
|
849
|
+
@registry.unmatched_registrations(@schema).each { |message| GraphWeaver::Internal::Log.log(:warn) { message } }
|
|
729
850
|
end
|
|
730
851
|
|
|
731
852
|
# The @include/@skip a fragment carries applies to what it guards, so it has
|
|
@@ -755,7 +876,9 @@ class GraphWeaver::Codegen
|
|
|
755
876
|
node = ObjectNode.new(class_name)
|
|
756
877
|
node.graphql_type = type.graphql_name
|
|
757
878
|
node.mixins = type_mixins(type.graphql_name)
|
|
758
|
-
|
|
879
|
+
# class name => the result key that claimed it; the struct itself first,
|
|
880
|
+
# claimed by nothing (see pick_name)
|
|
881
|
+
taken = { class_name => nil }
|
|
759
882
|
# Dedup structurally-identical dispatch-union fields on this struct: the
|
|
760
883
|
# same union selected two ways (unblockOptions vs selectedOption) shares
|
|
761
884
|
# one Ruby type, so consumers get one exhaustive `case ... T.absurd`.
|
|
@@ -765,7 +888,7 @@ class GraphWeaver::Codegen
|
|
|
765
888
|
gather_conditional(type, selections).each do |key, occurrences|
|
|
766
889
|
field_nodes = occurrences.map(&:first)
|
|
767
890
|
field_name = field_nodes.first.name
|
|
768
|
-
prop =
|
|
891
|
+
prop = GraphWeaver::Codegen.prop_name(key)
|
|
769
892
|
check_output_prop!(type, key, prop, props)
|
|
770
893
|
|
|
771
894
|
child = if field_name == "__typename"
|
|
@@ -867,7 +990,7 @@ class GraphWeaver::Codegen
|
|
|
867
990
|
def check_shadowing!(node, scope = {})
|
|
868
991
|
case node
|
|
869
992
|
when UnionNode
|
|
870
|
-
members = node.members.each_value.to_a + [node.catch_all]
|
|
993
|
+
members = node.members.each_value.to_a + [node.catch_all]
|
|
871
994
|
inner = scope.merge(members.to_h { |m| [m.class_name, "the member struct #{m.class_name}"] })
|
|
872
995
|
members.each { |member| check_shadowing!(member, inner) }
|
|
873
996
|
when ObjectNode
|
|
@@ -920,20 +1043,12 @@ class GraphWeaver::Codegen
|
|
|
920
1043
|
node
|
|
921
1044
|
end
|
|
922
1045
|
|
|
923
|
-
#
|
|
924
|
-
#
|
|
925
|
-
#
|
|
926
|
-
#
|
|
1046
|
+
# The one way a result key can still fail to become a prop: a second key that
|
|
1047
|
+
# underscores onto an earlier one. That emits a struct that misbehaves (an
|
|
1048
|
+
# ArgumentError at require time), so refuse here; an alias in the query fixes
|
|
1049
|
+
# it. A reserved name doesn't get here — prop_name renames it. `props`
|
|
1050
|
+
# accumulates prop => key.
|
|
927
1051
|
def check_output_prop!(type, key, prop, props)
|
|
928
|
-
# Keywords are fine: `const :next` and `next: data["next"]` are legal, and
|
|
929
|
-
# the one place a prop is read bare (an alias delegator) qualifies it.
|
|
930
|
-
# `pageInfo { next }` and `filter { in }` are ordinary API shapes.
|
|
931
|
-
if STRUCT_METHODS.include?(prop)
|
|
932
|
-
raise GraphWeaver::Error,
|
|
933
|
-
"#{type.graphql_name}.#{key} would become prop '#{prop}', which every generated struct " \
|
|
934
|
-
"already defines — alias it in the query (`#{prop}Value: #{key}`)"
|
|
935
|
-
end
|
|
936
|
-
|
|
937
1052
|
if (earlier = props[prop])
|
|
938
1053
|
raise GraphWeaver::Error,
|
|
939
1054
|
"result keys #{earlier.inspect} and #{key.inspect} on #{type.graphql_name} both map to the " \
|
|
@@ -1032,18 +1147,35 @@ class GraphWeaver::Codegen
|
|
|
1032
1147
|
# about. Dispatch reads __typename, so the query must select it; for
|
|
1033
1148
|
# interfaces the interface-level fields gather into every member.
|
|
1034
1149
|
def union_members(type, selections)
|
|
1035
|
-
unless dispatchable_typename?(type, selections)
|
|
1036
|
-
raise ArgumentError,
|
|
1037
|
-
"select __typename on #{type.graphql_name} so the union can dispatch — unaliased and " \
|
|
1038
|
-
"not under @skip/@include, since from_h reads it on every response — or narrow to a " \
|
|
1039
|
-
"single `... on Type` condition (no dispatch needed)"
|
|
1040
|
-
end
|
|
1150
|
+
raise ArgumentError, typename_refusal(type, selections) unless dispatchable_typename?(type, selections)
|
|
1041
1151
|
|
|
1042
1152
|
selected_members(type, selections).sort_by(&:graphql_name).to_h do |possible|
|
|
1043
1153
|
[possible.graphql_name, object_node(possible, selections, camelize(possible.graphql_name))]
|
|
1044
1154
|
end
|
|
1045
1155
|
end
|
|
1046
1156
|
|
|
1157
|
+
# Why a selection can't dispatch. The near miss is worth its own sentence:
|
|
1158
|
+
# putting __typename in every `... on Type` and nowhere else looks like
|
|
1159
|
+
# compliance — it is what Linear's published SDK documents do — and the tag
|
|
1160
|
+
# really is on the wire, but from_h reads it before it knows which member is
|
|
1161
|
+
# live, and a member the query never named would carry none at all.
|
|
1162
|
+
def typename_refusal(type, selections)
|
|
1163
|
+
base = "select __typename on #{type.graphql_name} so the union can dispatch — unaliased and " \
|
|
1164
|
+
"not under @skip/@include, since from_h reads it on every response — or narrow to a " \
|
|
1165
|
+
"single `... on Type` condition (no dispatch needed)"
|
|
1166
|
+
return base unless member_typename?(type, selections)
|
|
1167
|
+
|
|
1168
|
+
"#{base}. The __typename in each `... on Type` here is read only after the dispatch it " \
|
|
1169
|
+
"would decide, so it can't stand in for one on #{type.graphql_name} itself"
|
|
1170
|
+
end
|
|
1171
|
+
|
|
1172
|
+
# Does every member the selection names carry its own unconditional
|
|
1173
|
+
# __typename? Asked only to explain a refusal, never to permit one.
|
|
1174
|
+
def member_typename?(type, selections)
|
|
1175
|
+
members = selected_members(type, selections).to_a
|
|
1176
|
+
members.any? && members.all? { |member| dispatchable_typename?(member, selections) }
|
|
1177
|
+
end
|
|
1178
|
+
|
|
1047
1179
|
# The concrete types a selection names through its type conditions, kept to
|
|
1048
1180
|
# the abstract type's own members. A condition naming another abstract type
|
|
1049
1181
|
# (`... on Named` inside a union) stands for the members it covers, since its
|
|
@@ -1127,9 +1259,9 @@ class GraphWeaver::Codegen
|
|
|
1127
1259
|
|
|
1128
1260
|
# A name-independent structural fingerprint of a union's members, so two
|
|
1129
1261
|
# occurrences that generate identical structs collapse to one Ruby type.
|
|
1130
|
-
def union_signature(members, catch_all
|
|
1262
|
+
def union_signature(members, catch_all)
|
|
1131
1263
|
parts = members.map { |gname, member| "#{gname}=#{signature(member)}" }
|
|
1132
|
-
parts << "*=#{signature(catch_all)}"
|
|
1264
|
+
parts << "*=#{signature(catch_all)}"
|
|
1133
1265
|
parts.sort.join(",")
|
|
1134
1266
|
end
|
|
1135
1267
|
|
|
@@ -1191,28 +1323,101 @@ class GraphWeaver::Codegen
|
|
|
1191
1323
|
def input_node(core)
|
|
1192
1324
|
return @variable_inputs[core.graphql_name] if @variable_inputs.key?(core.graphql_name)
|
|
1193
1325
|
|
|
1194
|
-
|
|
1326
|
+
class_name = camelize(core.graphql_name)
|
|
1327
|
+
# enum_node's twin. Without it `class Result` was emitted twice, the second
|
|
1328
|
+
# reopening the first into one struct answering for both the variable and
|
|
1329
|
+
# the response — and `class QUERY` raised a bare TypeError about the heredoc.
|
|
1330
|
+
if MODULE_RESERVED.include?(class_name)
|
|
1331
|
+
raise GraphWeaver::Error,
|
|
1332
|
+
"input type #{core.graphql_name} generates #{class_name}, which collides with a generated " \
|
|
1333
|
+
"constant — every query module defines #{MODULE_RESERVED.to_a.join(", ")}, so an input type " \
|
|
1334
|
+
"by one of those names has no room here"
|
|
1335
|
+
end
|
|
1336
|
+
|
|
1337
|
+
node = @variable_inputs[core.graphql_name] = InputNode.new(class_name, core.graphql_name)
|
|
1195
1338
|
node.one_of = core.respond_to?(:one_of?) && core.one_of?
|
|
1196
1339
|
# sorted so output is deterministic across schema sources
|
|
1197
1340
|
core.arguments.values.sort_by(&:graphql_name).each do |argument|
|
|
1198
|
-
prop = underscore(argument.graphql_name)
|
|
1199
1341
|
# Keywords are fine here: nothing reads an input prop bare (serialize goes
|
|
1200
1342
|
# through public_send), and `const :in` is legal — which matters, since a
|
|
1201
1343
|
# schema's field name is not the user's to rename. `Tricky.in` filters are
|
|
1202
1344
|
# standard Hasura/Gatsby shape.
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
"input field #{core.graphql_name}.#{argument.graphql_name} would become prop '#{prop}', " \
|
|
1206
|
-
"which collides with a method every struct defines"
|
|
1207
|
-
end
|
|
1208
|
-
|
|
1345
|
+
prop = GraphWeaver::Codegen.prop_name(argument.graphql_name)
|
|
1346
|
+
@input_hops.push(InputHop.new(argument.graphql_name, list_type?(argument.type)))
|
|
1209
1347
|
child = type_ref(argument.type) { variable_core(argument.type.unwrap) }
|
|
1348
|
+
@input_hops.pop
|
|
1210
1349
|
required = child.non_null? && !argument.default_value?
|
|
1211
1350
|
node.fields << InputNode::Field.new(prop, argument.graphql_name, child, required)
|
|
1212
1351
|
end
|
|
1352
|
+
check_input_props!(core, node)
|
|
1213
1353
|
node
|
|
1214
1354
|
end
|
|
1215
1355
|
|
|
1356
|
+
# check_output_prop!'s twin: two input fields that underscore onto one prop
|
|
1357
|
+
# emit `const :x` twice, which raises ArgumentError when the generated file
|
|
1358
|
+
# is REQUIRED — a stack trace with no schema in it. A schema's field name
|
|
1359
|
+
# isn't the user's to rename and an input struct has no alias, so the fix is
|
|
1360
|
+
# to keep the type out of the variables.
|
|
1361
|
+
def check_input_props!(core, node)
|
|
1362
|
+
collision = node.fields.group_by(&:prop).find { |_, group| group.size > 1 }
|
|
1363
|
+
return unless collision
|
|
1364
|
+
|
|
1365
|
+
fields = collision.last.map { |field| field.wire.inspect }.join(" and ")
|
|
1366
|
+
raise GraphWeaver::Error,
|
|
1367
|
+
"input fields #{fields} on #{core.graphql_name} both map to the prop " \
|
|
1368
|
+
"'#{collision.first}' — #{input_collision_advice(core)}"
|
|
1369
|
+
end
|
|
1370
|
+
|
|
1371
|
+
# One hop of the input walk: the field taken, and whether taking it crosses
|
|
1372
|
+
# a list.
|
|
1373
|
+
InputHop = Struct.new(:name, :list)
|
|
1374
|
+
private_constant :InputHop
|
|
1375
|
+
|
|
1376
|
+
def list_type?(type)
|
|
1377
|
+
type = type.of_type if type.kind.name == "NON_NULL"
|
|
1378
|
+
type.kind.name == "LIST"
|
|
1379
|
+
end
|
|
1380
|
+
|
|
1381
|
+
# What to do about an input collision, said in terms of the declaration the
|
|
1382
|
+
# user wrote: the colliding type is usually several hops below it, and
|
|
1383
|
+
# naming the type alone sends them looking for a variable they never
|
|
1384
|
+
# declared. The escape — inline the path as a literal, keeping a variable
|
|
1385
|
+
# per leaf — exists only when no hop crosses a list, since a literal list
|
|
1386
|
+
# can't stand in for a length only the runtime knows.
|
|
1387
|
+
def input_collision_advice(core)
|
|
1388
|
+
from = @input_from || "a variable that reaches it"
|
|
1389
|
+
path = @input_hops.map(&:name).join(".")
|
|
1390
|
+
crossed = @input_hops.find(&:list)
|
|
1391
|
+
|
|
1392
|
+
if crossed || @input_list
|
|
1393
|
+
blocked =
|
|
1394
|
+
if crossed
|
|
1395
|
+
"#{from} reaches #{core.graphql_name} through #{path}, and #{crossed.name} is a list"
|
|
1396
|
+
else
|
|
1397
|
+
"#{from} is declared as a list of #{core.graphql_name}"
|
|
1398
|
+
end
|
|
1399
|
+
return "#{blocked} — a literal list can't stand in for a length only the runtime knows, so " \
|
|
1400
|
+
"no form of this query generates. Keep #{core.graphql_name} out of the variables."
|
|
1401
|
+
end
|
|
1402
|
+
|
|
1403
|
+
if @input_hops.any?
|
|
1404
|
+
return "#{from} reaches #{core.graphql_name} through #{path} — drop #{from} and write that " \
|
|
1405
|
+
"path as a literal in the query, with a variable per field of #{core.graphql_name}"
|
|
1406
|
+
end
|
|
1407
|
+
|
|
1408
|
+
"pass #{core.graphql_name} as a literal in the query, with a variable per field, instead of " \
|
|
1409
|
+
"declaring #{from}"
|
|
1410
|
+
end
|
|
1411
|
+
|
|
1412
|
+
# Does this AST type reference cross a list on its way to the core type?
|
|
1413
|
+
def ast_list?(ast_type)
|
|
1414
|
+
case ast_type
|
|
1415
|
+
when GraphQL::Language::Nodes::ListType then true
|
|
1416
|
+
when GraphQL::Language::Nodes::NonNullType then ast_list?(ast_type.of_type)
|
|
1417
|
+
else false
|
|
1418
|
+
end
|
|
1419
|
+
end
|
|
1420
|
+
|
|
1216
1421
|
# The module-level T::Enum for a schema enum, named for the enum itself —
|
|
1217
1422
|
# it is shared by every field and variable of that type.
|
|
1218
1423
|
def enum_node(core)
|
|
@@ -1255,7 +1460,7 @@ class GraphWeaver::Codegen
|
|
|
1255
1460
|
|
|
1256
1461
|
# Registered helper-module names for a GraphQL type, collecting their requires.
|
|
1257
1462
|
def type_mixins(graphql_name)
|
|
1258
|
-
entry =
|
|
1463
|
+
entry = @registry.type_registry[graphql_name]
|
|
1259
1464
|
return [] unless entry
|
|
1260
1465
|
|
|
1261
1466
|
@requires.concat(entry[:requires])
|
|
@@ -1265,7 +1470,7 @@ class GraphWeaver::Codegen
|
|
|
1265
1470
|
# The MappedEnum node for a schema enum with a registered app-enum
|
|
1266
1471
|
# mapping; nil when unregistered, falling back to a generated T::Enum.
|
|
1267
1472
|
def mapped_enum_node(core)
|
|
1268
|
-
enum_type =
|
|
1473
|
+
enum_type = @registry.enum_registry[core.graphql_name]
|
|
1269
1474
|
return unless enum_type
|
|
1270
1475
|
|
|
1271
1476
|
@requires.concat(enum_type.requires)
|
|
@@ -1277,9 +1482,9 @@ class GraphWeaver::Codegen
|
|
|
1277
1482
|
# Resolution, most specific first: a per-field override (`Type.field`), then
|
|
1278
1483
|
# the scalar-name registration.
|
|
1279
1484
|
def scalar_node(name, coordinate = nil, result: false)
|
|
1280
|
-
|
|
1281
|
-
@untyped_scalars << name.to_s unless (coordinate &&
|
|
1282
|
-
scalar =
|
|
1485
|
+
scalars = @registry.scalar_registry
|
|
1486
|
+
@untyped_scalars << name.to_s unless (coordinate && scalars[coordinate]) || scalars[name.to_s]
|
|
1487
|
+
scalar = @registry.scalar(name, coordinate)
|
|
1283
1488
|
refuse_uncastable!(scalar, coordinate || name) if result
|
|
1284
1489
|
@requires.concat(scalar.requires)
|
|
1285
1490
|
Scalar.new(scalar)
|
|
@@ -1296,11 +1501,9 @@ class GraphWeaver::Codegen
|
|
|
1296
1501
|
klass = Object.const_get(scalar.type)
|
|
1297
1502
|
return unless klass.is_a?(Class) && ScalarType::WIRE_CLASSES.none? { |native| native <= klass }
|
|
1298
1503
|
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
"class method, cast: ->(v) { \"#{scalar.type}(\#{v})\" } emits any expression), or register " \
|
|
1303
|
-
"a type the wire already parses into"
|
|
1504
|
+
# why there is no cast — and so what to do — is the registration's own
|
|
1505
|
+
# knowledge, not the walk's
|
|
1506
|
+
raise GraphWeaver::Error, scalar.uncastable_message(where)
|
|
1304
1507
|
rescue ::NameError
|
|
1305
1508
|
nil # a type: given as a String names a class this process may not have
|
|
1306
1509
|
end
|
|
@@ -1308,14 +1511,15 @@ class GraphWeaver::Codegen
|
|
|
1308
1511
|
# An unregistered custom scalar passes through as T.untyped — legitimate
|
|
1309
1512
|
# (nobody needs a codec for every scalar), but it's the one hole in an
|
|
1310
1513
|
# otherwise exact result type, so name the holes rather than leave them
|
|
1311
|
-
# silent. Informational: not a warning, never an error.
|
|
1514
|
+
# silent. Informational: not a warning, never an error. The logger is the
|
|
1515
|
+
# runtime channel, so a console `parse` says it too; the build channel prints
|
|
1516
|
+
# the same list once per run (see GraphWeaver.untyped_scalars).
|
|
1312
1517
|
def report_untyped_scalars
|
|
1313
|
-
names =
|
|
1518
|
+
names = untyped_scalars
|
|
1314
1519
|
return if names.empty?
|
|
1315
1520
|
|
|
1316
1521
|
GraphWeaver::Internal::Log.log(:info) do
|
|
1317
|
-
|
|
1318
|
-
"#{names.join(", ")} (register with GraphWeaver.register_scalar)"
|
|
1522
|
+
GraphWeaver::Internal::Util.untyped_scalars_report(@graph_name => names)
|
|
1319
1523
|
end
|
|
1320
1524
|
end
|
|
1321
1525
|
|
|
@@ -1336,12 +1540,14 @@ class GraphWeaver::Codegen
|
|
|
1336
1540
|
# nothing else, so adding, removing, or reordering an unrelated selection can
|
|
1337
1541
|
# never rename it. Generated code is app-code API; a name that shifts under
|
|
1338
1542
|
# an unrelated edit is a silent break. `taken` is the names claimed in this
|
|
1339
|
-
# struct's scope, its first entry the struct
|
|
1543
|
+
# struct's scope => the key that claimed each, its first entry the struct
|
|
1544
|
+
# itself (claimed by nothing) — the key is what a collision has to name.
|
|
1340
1545
|
#
|
|
1341
1546
|
# (Union members are the exception: they are named for the type condition
|
|
1342
1547
|
# that produces them, which is equally position-determined.)
|
|
1343
1548
|
def pick_name(key, taken)
|
|
1344
1549
|
name = camelize(key)
|
|
1550
|
+
struct = taken.keys.first
|
|
1345
1551
|
|
|
1346
1552
|
# a key that camelizes to no constant at all ("_", "_1") would emit
|
|
1347
1553
|
# `class < T::Struct`
|
|
@@ -1350,18 +1556,19 @@ class GraphWeaver::Codegen
|
|
|
1350
1556
|
"result key #{key.inspect} makes no class name (#{name.inspect}) — alias it to one starting with a letter"
|
|
1351
1557
|
end
|
|
1352
1558
|
|
|
1353
|
-
if name ==
|
|
1559
|
+
if name == struct
|
|
1354
1560
|
# would shadow the struct it nests in — the parent's own `returns(Name)`
|
|
1355
1561
|
# resolves lexically and would find the child
|
|
1356
1562
|
suffix = 2
|
|
1357
|
-
suffix += 1 while taken.
|
|
1563
|
+
suffix += 1 while taken.key?("#{name}#{suffix}")
|
|
1358
1564
|
name = "#{name}#{suffix}"
|
|
1359
|
-
elsif taken
|
|
1565
|
+
elsif (earlier = taken[name])
|
|
1360
1566
|
raise GraphWeaver::Error,
|
|
1361
|
-
"result keys
|
|
1567
|
+
"result keys #{earlier.inspect} and #{key.inspect} on #{struct} both generate the class " \
|
|
1568
|
+
"#{name} — alias one to a distinct name"
|
|
1362
1569
|
end
|
|
1363
1570
|
|
|
1364
|
-
taken
|
|
1571
|
+
taken[name] = key
|
|
1365
1572
|
name
|
|
1366
1573
|
end
|
|
1367
1574
|
|