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.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +537 -0
  3. data/Gemfile.lock +19 -19
  4. data/README.md +74 -53
  5. data/docs/cassettes.md +29 -4
  6. data/docs/editors.md +3 -1
  7. data/docs/errors.md +75 -16
  8. data/docs/federation.md +206 -155
  9. data/docs/generated_modules.md +223 -166
  10. data/docs/getting_started.md +106 -82
  11. data/docs/logging.md +35 -5
  12. data/docs/scalars.md +119 -24
  13. data/docs/testing.md +196 -155
  14. data/docs/transports.md +47 -19
  15. data/docs/upgrading.md +243 -22
  16. data/graph_weaver.gemspec +16 -2
  17. data/lib/generators/graph_weaver/install_generator.rb +31 -16
  18. data/lib/graph_weaver/client.rb +52 -15
  19. data/lib/graph_weaver/codegen/aliases.rb +15 -8
  20. data/lib/graph_weaver/codegen/emit.rb +107 -42
  21. data/lib/graph_weaver/codegen/enum_type.rb +4 -3
  22. data/lib/graph_weaver/codegen/nodes.rb +42 -21
  23. data/lib/graph_weaver/codegen/scalar_type.rb +87 -83
  24. data/lib/graph_weaver/codegen/type_helpers.rb +2 -3
  25. data/lib/graph_weaver/codegen.rb +382 -105
  26. data/lib/graph_weaver/coerce.rb +113 -0
  27. data/lib/graph_weaver/errors.rb +57 -13
  28. data/lib/graph_weaver/federation.rb +10 -22
  29. data/lib/graph_weaver/hints.rb +76 -2
  30. data/lib/graph_weaver/in_process.rb +11 -8
  31. data/lib/graph_weaver/inflect.rb +2 -0
  32. data/lib/graph_weaver/input_struct.rb +115 -12
  33. data/lib/graph_weaver/internal/overrides.rb +101 -0
  34. data/lib/graph_weaver/internal/planner.rb +868 -0
  35. data/lib/graph_weaver/internal/schemas.rb +50 -0
  36. data/lib/graph_weaver/internal/selection.rb +127 -0
  37. data/lib/graph_weaver/{testing → internal}/subgraphs.rb +45 -43
  38. data/lib/graph_weaver/internal/values.rb +181 -0
  39. data/lib/graph_weaver/internal.rb +206 -0
  40. data/lib/graph_weaver/logging.rb +108 -20
  41. data/lib/graph_weaver/parsing.rb +6 -13
  42. data/lib/graph_weaver/query_module.rb +2 -0
  43. data/lib/graph_weaver/railtie.rb +113 -14
  44. data/lib/graph_weaver/representation.rb +30 -2
  45. data/lib/graph_weaver/response.rb +15 -0
  46. data/lib/graph_weaver/retry.rb +54 -22
  47. data/lib/graph_weaver/rspec.rb +63 -18
  48. data/lib/graph_weaver/schema_diff.rb +293 -0
  49. data/lib/graph_weaver/schema_loader.rb +126 -35
  50. data/lib/graph_weaver/tasks.rb +88 -36
  51. data/lib/graph_weaver/testing/cassette.rb +131 -78
  52. data/lib/graph_weaver/testing/coverage.rb +11 -15
  53. data/lib/graph_weaver/testing/failure.rb +14 -8
  54. data/lib/graph_weaver/testing/fake_client.rb +253 -60
  55. data/lib/graph_weaver/testing/fake_subgraph.rb +19 -8
  56. data/lib/graph_weaver/testing/router.rb +147 -840
  57. data/lib/graph_weaver/testing.rb +40 -83
  58. data/lib/graph_weaver/transport/faraday.rb +1 -1
  59. data/lib/graph_weaver/transport/http.rb +29 -12
  60. data/lib/graph_weaver/transport.rb +11 -34
  61. data/lib/graph_weaver/version.rb +1 -1
  62. data/lib/graph_weaver.rb +221 -118
  63. metadata +17 -13
  64. data/CLAUDE.md +0 -161
  65. data/DECISIONS.md +0 -309
  66. data/Makefile +0 -23
  67. data/NOTES.md +0 -182
  68. data/PLAN.md +0 -115
  69. data/REVIEW.md +0 -946
  70. data/lib/graph_weaver/schemas.rb +0 -46
  71. data/lib/graph_weaver/selection.rb +0 -120
  72. data/lib/graph_weaver/testing/values.rb +0 -98
@@ -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 scalar registry),
19
- # codegen/nodes.rb (the typed IR), codegen/aliases.rb (registered alias
20
- # paths), codegen/emit.rb (source emission); this file holds the public
21
- # API and the query walk.
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
- attr_reader :module_name
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=). module_name:
53
- # defaults to the operation's
54
- # name; default_module_name: is parse's container-scoped fallback (file
55
- # generation stays strict — a checked-in file deserves a deliberate
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:, module_name: nil, client: nil, default_module_name: nil,
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
- @path = path
69
- @module_name = module_name
70
- @default_module_name = default_module_name
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 = self.class.client_const(client)
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
- def self.client_const(client)
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:, module_name: nil, client: nil, path: nil)
96
- new(schema:, query:, module_name:, client:, path:).generate
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:, module_name: nil, client: nil, path: nil)
105
- client_const = client_const(client)
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:, module_name:, client: client_const, path:,
108
- default_module_name: "Query")
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.module_name)
114
- GraphWeaver.log(:debug) { "parsed #{codegen.module_name} (dynamic module, #{source.bytesize} bytes)" }
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 #{@module_name}::#{class_name}, " \
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 @module_name&.match?(CONSTANT_NAME)
268
+ return if @name&.match?(CONSTANT_NAME)
237
269
 
238
- problem = "#{subject} must be a constant name, got #{@module_name.inspect}"
239
- # An explicit module_name: is an argument wrong on its face. A derived one
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
- @module_name ||= operation.name || @default_module_name
294
- unless @module_name
295
- raise ArgumentError, "module_name: required for anonymous operations"
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!("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 || @module_name.split("::").last
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 }" — so parse it and flatten to the leaf paths the
440
- # wire hash needs. Dotted, since a GraphQL name can't contain a dot.
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
- selections = GraphQL.parse("{ #{fields} }").definitions.first.selections
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 = node.serialize_identity? ? kwarg : "#{kwarg}&.then { |v1| #{node.serialize("v1", 2)} }"
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
- # A registration names a type in a specific schema a typo'd name would
490
- # otherwise be a silent no-op, the most confusing failure mode available.
491
- # Called at generation for every registration in play.
518
+ # The kwarg's trip onto the wirethe 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 exists and is a scalar, not that a type named "Type.field" exists.
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
- raise GraphWeaver::Error, "register_scalar(#{name.inspect}) matches no scalar field in this schema"
499
- end
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
- return if schema.get_type(name)
571
+ expected = REGISTERED_KIND[kind]
572
+ return if expected.nil? || type.kind.name == expected
502
573
 
503
- suggestion = GraphWeaver.did_you_mean(schema.types.keys, name)
504
- hint = suggestion ? " did you mean '#{suggestion}'?" : ""
505
- # the type registry is reached via extend_type; scalars/enums via register_*
506
- method = kind == "type" ? "extend_type" : "register_#{kind}"
507
- raise GraphWeaver::Error, "#{method}(#{name.inspect}) matches no type in this schema#{hint}"
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
- # Whether `coordinate` ("Type.field") names an existing scalar field — the
511
- # validation for a per-field register_scalar override.
512
- def self.scalar_field?(schema, coordinate)
513
- type_name, field_name = coordinate.split(".", 2)
514
- return false unless field_name
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
- field = schema.get_field(type_name, field_name)
517
- !!field && field.type.unwrap.kind.name == "SCALAR"
518
- rescue StandardError
519
- false
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
- Array(paths).flat_map { |dir| Dir[File.join(dir, DOCUMENT_GLOB)].sort }.each_with_object({}) do |file, out|
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, "#{file}: fragment files define only fragments, no operations"
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 #{file}; rename one"
636
+ "duplicate shared fragment '#{frag.name}' — defined in #{earlier} and #{reported}; rename one"
537
637
  end
538
- source[frag.name] = file
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
- # Every registration this generation could consult. The built-in scalars are
623
- # pre-registered entries in the same table rather than user intent, so
624
- # they're exempt a schema with no Date scalar is not a mistake.
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
- "enum" => GraphWeaver::Codegen.enum_registry,
628
- "scalar" => GraphWeaver::Codegen.scalar_registry.except(*BUILTIN_SCALARS),
629
- "type" => GraphWeaver::Codegen.type_registry,
630
- }.each do |kind, registry|
631
- registry.each_key { |name| self.class.validate_registration!(@schema, kind, name) }
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 = field_nodes.flat_map(&: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
- bare = bare_fields(sub_selections) - ["__typename"]
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 && bare.empty? &&
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
- # result keys selected as plain fields (outside any type condition)
785
- def bare_fields(selections)
786
- selections.grep(GraphQL::Language::Nodes::Field).map { |field| field.alias || field.name }
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
- nilable_type_ref(type.of_type, &core)
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. It carries only what the abstract type itself guarantees (an
868
- # interface's selected interface-level fields; for a union, just __typename),
869
- # so a new upstream member bends the result rather than breaking it.
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
- # the input-side core kinds a variable (or input-object field) can have
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
- scalar = (coordinate && registry[coordinate]) || registry[name.to_s]
1028
- if scalar.nil?
1029
- @untyped_scalars << name.to_s
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