graph_weaver 0.5.1 → 0.6.1

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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +432 -0
  3. data/Gemfile.lock +19 -19
  4. data/README.md +75 -54
  5. data/docs/cassettes.md +6 -1
  6. data/docs/editors.md +3 -1
  7. data/docs/errors.md +73 -16
  8. data/docs/federation.md +201 -151
  9. data/docs/generated_modules.md +222 -165
  10. data/docs/getting_started.md +106 -82
  11. data/docs/logging.md +34 -4
  12. data/docs/real_world.md +4 -4
  13. data/docs/scalars.md +206 -46
  14. data/docs/testing.md +191 -151
  15. data/docs/transports.md +47 -19
  16. data/docs/upgrading.md +210 -11
  17. data/lib/generators/graph_weaver/install_generator.rb +16 -1
  18. data/lib/graph_weaver/client.rb +46 -13
  19. data/lib/graph_weaver/codegen/aliases.rb +5 -4
  20. data/lib/graph_weaver/codegen/emit.rb +96 -39
  21. data/lib/graph_weaver/codegen/enum_type.rb +3 -0
  22. data/lib/graph_weaver/codegen/nodes.rb +42 -21
  23. data/lib/graph_weaver/codegen/scalar_type.rb +167 -98
  24. data/lib/graph_weaver/codegen/type_helpers.rb +1 -0
  25. data/lib/graph_weaver/codegen.rb +279 -84
  26. data/lib/graph_weaver/coerce.rb +113 -0
  27. data/lib/graph_weaver/errors.rb +30 -7
  28. data/lib/graph_weaver/federation.rb +6 -5
  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 +39 -41
  38. data/lib/graph_weaver/internal/values.rb +184 -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 +5 -4
  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 +50 -11
  48. data/lib/graph_weaver/schema_diff.rb +293 -0
  49. data/lib/graph_weaver/schema_loader.rb +96 -29
  50. data/lib/graph_weaver/tasks.rb +78 -29
  51. data/lib/graph_weaver/testing/cassette.rb +49 -65
  52. data/lib/graph_weaver/testing/coverage.rb +5 -4
  53. data/lib/graph_weaver/testing/failure.rb +10 -6
  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 +94 -808
  57. data/lib/graph_weaver/testing.rb +35 -84
  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 +203 -119
  63. metadata +10 -5
  64. data/lib/graph_weaver/schemas.rb +0 -48
  65. data/lib/graph_weaver/selection.rb +0 -120
  66. data/lib/graph_weaver/testing/values.rb +0 -98
@@ -20,12 +20,13 @@ require "sorbet-runtime"
20
20
  # registry), codegen/nodes.rb (the typed IR), codegen/aliases.rb (resolving
21
21
  # registered alias paths against a node), codegen/emit.rb (source emission);
22
22
  # this file holds the public API and the query walk.
23
+ require_relative "internal"
23
24
  require_relative "hints"
24
25
  require_relative "input_struct"
25
26
  require_relative "schema_loader"
26
27
  require_relative "representation"
27
28
  require_relative "inflect"
28
- require_relative "selection"
29
+ require_relative "internal/selection"
29
30
  require_relative "codegen/enum_type"
30
31
  require_relative "codegen/scalar_type"
31
32
  require_relative "codegen/nodes"
@@ -34,9 +35,11 @@ require_relative "codegen/emit"
34
35
 
35
36
  class GraphWeaver::Codegen
36
37
  include GraphWeaver::Inflect
37
- include GraphWeaver::Selection
38
+ include GraphWeaver::Internal::Selection
38
39
  include Aliases
39
40
  include Emit
41
+ # the walk's two halves, split for file size rather than for reuse
42
+ private_constant :Aliases, :Emit
40
43
 
41
44
  # How a directory of GraphQL documents is scanned: both extensions the rest of
42
45
  # the library already accepts, and nested — `queries/admin/pets.graphql` is
@@ -50,7 +53,7 @@ class GraphWeaver::Codegen
50
53
  "run; register from a Rails.application.config.to_prepare block, which generation " \
51
54
  "also runs first."
52
55
 
53
- attr_reader :module_name
56
+ attr_reader :name
54
57
 
55
58
  # A client is anything responding to `execute(query, variables:)`
56
59
  # whose result `to_h`s into {"data" => ..., "errors" => ...} — a
@@ -58,11 +61,10 @@ class GraphWeaver::Codegen
58
61
  #
59
62
  # client: (a constant, or its name as a string) becomes the generated
60
63
  # module's baked default; when omitted, generated code falls back to
61
- # the app default (GraphWeaver.client=). module_name:
62
- # defaults to the operation's
63
- # name; default_module_name: is parse's container-scoped fallback (file
64
- # generation stays strict — a checked-in file deserves a deliberate
65
- # name). types_namespace: is the shared-types workflow (see
64
+ # the app default (GraphWeaver.client=). name: is the module the file
65
+ # defines, defaulting to the operation's own name; default_name: is
66
+ # parse's container-scoped fallback (file generation stays strict — a
67
+ # checked-in file deserves a deliberate name). types_namespace: is the shared-types workflow (see
66
68
  # GraphWeaver.generate!): input types, schema enums, and unions hoisted from
67
69
  # shared fragments live once in that module and the query module aliases what
68
70
  # it uses. hoistable_unions: is the set of shared fragment names this query
@@ -70,19 +72,21 @@ class GraphWeaver::Codegen
70
72
  # whole-union field spread as one of them resolves to a canonical type in the
71
73
  # shared module (see used_union_names). path: is the file the query was read
72
74
  # from, named alongside line and column in validation errors.
73
- def initialize(schema:, query:, module_name: nil, client: nil, default_module_name: nil,
74
- types_namespace: nil, hoistable_unions: nil, path: nil)
75
+ def initialize(schema:, query:, name: nil, client: nil, default_name: nil,
76
+ types_namespace: nil, hoistable_unions: nil, path: nil, module_name: nil)
77
+ renamed!(module_name)
75
78
  @schema = schema
76
79
  @query = query.strip
77
- @path = path
78
- @module_name = module_name
79
- @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
80
84
  @types_namespace = types_namespace
81
85
  @hoistable_unions = hoistable_unions || []
82
86
  @used_unions = []
83
87
  # scalars this generation had no registration for (see report_untyped_scalars)
84
88
  @untyped_scalars = []
85
- @client_const = self.class.client_const(client)
89
+ @client_const = CLIENT_CONST.call(client)
86
90
 
87
91
  if client && @client_const.nil?
88
92
  # a live object can't be spelled in generated source — parse can
@@ -91,18 +95,30 @@ class GraphWeaver::Codegen
91
95
  end
92
96
  end
93
97
 
98
+ # 0.5 spelled it module_name:, in two of the three doors. One knob, one
99
+ # spelling — but a silent "unknown keyword" would send the caller hunting.
100
+ def renamed!(module_name)
101
+ return unless module_name
102
+
103
+ raise ArgumentError, "module_name: is now name: (got #{module_name.inspect})"
104
+ end
105
+ private :renamed!
106
+
94
107
  # The constant name a client can be referenced by in generated
95
108
  # source — nil when it can't be (live objects, anonymous modules).
96
- 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|
97
112
  case client
98
113
  when String then client
99
114
  when Module then client.name
100
115
  end
101
116
  end
117
+ private_constant :CLIENT_CONST
102
118
 
103
119
  # one-step shorthand
104
- def self.generate(schema:, query:, module_name: nil, client: nil, path: nil)
105
- 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
106
122
  end
107
123
 
108
124
  # Development convenience: generate + eval in one step, no build
@@ -110,17 +126,22 @@ class GraphWeaver::Codegen
110
126
  # file, but invisible to srb tc — use the build step for static typing.
111
127
  # Evaluates into an anonymous container, so no global constants leak;
112
128
  # client: additionally accepts a live object (set via .client=).
113
- def self.parse(schema:, query:, module_name: nil, client: nil, path: nil)
114
- 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)
115
131
 
116
- codegen = new(schema:, query:, module_name:, client: client_const, path:,
117
- default_module_name: "Query")
132
+ codegen = new(schema:, query:, name:, client: client_const, path:, module_name:,
133
+ default_name: "Query")
118
134
  source = codegen.generate
119
135
 
120
136
  container = Module.new
137
+ # otherwise every runtime error names the container by address
138
+ # ("#<Module:0x...>::Result::Person"); assigning the module to a constant,
139
+ # which is the documented usage, replaces this with the real path
140
+ # T.unsafe: sorbet's Module RBI predates set_temporary_name (Ruby 3.3)
141
+ T.unsafe(container).set_temporary_name("GraphWeaver.parse")
121
142
  container.module_eval(source, "(graph_weaver)", 1)
122
- mod = container.const_get(codegen.module_name)
123
- 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)" }
124
145
  # live objects (or anonymous modules) can't be referenced from
125
146
  # generated source — set them via the module's writer instead
126
147
  mod.client = client if client && client_const.nil?
@@ -178,6 +199,7 @@ class GraphWeaver::Codegen
178
199
  inputs.sort.each { |name| input_node(@schema.get_type(name)) }
179
200
  enums.uniq.sort.each { |name| variable_core(@schema.get_type(name)) }
180
201
  check_shared_collisions!(unions)
202
+ union_nodes.each { |union| check_shadowing!(union) }
181
203
 
182
204
  emit_types_files(union_nodes).tap { report_untyped_scalars }
183
205
  end
@@ -185,6 +207,7 @@ class GraphWeaver::Codegen
185
207
  # module-level constants every generated query module defines — a shared
186
208
  # type aliased to one of these would clash at load
187
209
  MODULE_RESERVED = %w[Result QUERY Representations].to_set.freeze
210
+ private_constant :MODULE_RESERVED
188
211
 
189
212
  # One hoisted shared fragment, built against the schema and named for the
190
213
  # fragment rather than the field that spread it.
@@ -220,7 +243,7 @@ class GraphWeaver::Codegen
220
243
  claim = taken[class_name] or next
221
244
 
222
245
  raise GraphWeaver::Error,
223
- "shared fragment #{name.inspect} hoists to #{@module_name}::#{class_name}, " \
246
+ "shared fragment #{name.inspect} hoists to #{@name}::#{class_name}, " \
224
247
  "where #{claim} already generates — rename the fragment"
225
248
  end
226
249
  end
@@ -242,10 +265,10 @@ class GraphWeaver::Codegen
242
265
  CONSTANT_NAME = /\A[A-Z]\w*(::[A-Z]\w*)*\z/
243
266
 
244
267
  def validate_module_name!(subject)
245
- return if @module_name&.match?(CONSTANT_NAME)
268
+ return if @name&.match?(CONSTANT_NAME)
246
269
 
247
- problem = "#{subject} must be a constant name, got #{@module_name.inspect}"
248
- # 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
249
272
  # is a verdict on a FILE — a numeric prefix (01_home.graphql) is the usual
250
273
  # way in — so it names the file, says the fix is a rename, and brands so
251
274
  # `rake graph_weaver:generate` aborts on it instead of burying it under a
@@ -258,6 +281,7 @@ class GraphWeaver::Codegen
258
281
  private :validate_module_name!
259
282
 
260
283
  VarDef = Struct.new(:kwarg, :wire, :node, :required)
284
+ private_constant :VarDef
261
285
 
262
286
  # Names generated Ruby can't spell bare — as a kwarg, a local, or a method
263
287
  # name. As a prop they're fine (`const :next`), since a prop is only ever
@@ -280,6 +304,7 @@ class GraphWeaver::Codegen
280
304
  # ArgumentError at require time. Derived rather than listed, so it tracks
281
305
  # whatever the Ruby and sorbet-runtime in play actually define.
282
306
  STRUCT_METHODS = (GENERATED_METHODS + T::Struct.instance_methods.map(&:to_s)).freeze
307
+ private_constant :RUBY_KEYWORDS, :GENERATED_METHODS, :RESERVED_KWARGS, :STRUCT_METHODS
283
308
 
284
309
  def generate
285
310
  begin
@@ -299,21 +324,22 @@ class GraphWeaver::Codegen
299
324
  operation = load_operation(@query)
300
325
  root_type = operation_root_type(operation)
301
326
 
302
- @module_name ||= operation.name || @default_module_name
303
- unless @module_name
304
- 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"
305
330
  end
306
331
 
307
- validate_module_name!("module_name:")
332
+ validate_module_name!("name:")
308
333
 
309
334
  variables = build_variables(operation)
310
335
  root = object_node(root_type, operation.selections, "Result")
336
+ check_shadowing!(root)
311
337
 
312
338
  # An anonymous operation takes the module's name — declared in the document
313
339
  # AND sent as operationName, which have to agree (a server rejects an
314
340
  # operationName the document doesn't declare). The conventional .graphql
315
341
  # file names nothing, so without this every trace arrives anonymous.
316
- operation_name = operation.name || @module_name.split("::").last
342
+ operation_name = operation.name || @name.split("::").last
317
343
  @query = declare_operation_name(operation, operation_name) unless operation.name
318
344
 
319
345
  emit_module(root, variables, representation_nodes(operation, root_type), operation_name)
@@ -405,6 +431,7 @@ class GraphWeaver::Codegen
405
431
  # The subgraph spec's representation scalar. A field taking one is the
406
432
  # entity resolver, whatever it's called.
407
433
  REPRESENTATION_SCALAR = "_Any"
434
+ private_constant :REPRESENTATION_SCALAR
408
435
 
409
436
  def representation_field?(definition)
410
437
  definition.arguments.each_value.any? { |argument| argument.type.unwrap.graphql_name == REPRESENTATION_SCALAR }
@@ -475,7 +502,7 @@ class GraphWeaver::Codegen
475
502
  if core.kind.name == "SCALAR"
476
503
  node = scalar_node(core.graphql_name, "#{entity.graphql_name}.#{name}")
477
504
  type = required ? node.bare_type : node.prop_type
478
- value = node.serialize_identity? ? kwarg : "#{kwarg}&.then { |v1| #{node.serialize("v1", 2)} }"
505
+ value = representation_value(entity, name, kwarg, node)
479
506
  else
480
507
  # a nested key set — or an enum/composite one — passes through as an
481
508
  # open hash, narrowed to the declared sub-paths by the runtime
@@ -488,38 +515,108 @@ class GraphWeaver::Codegen
488
515
  end
489
516
  end
490
517
 
491
- # A registration names a type in a specific schema a typo'd name would
492
- # otherwise be a silent no-op, the most confusing failure mode available.
493
- # 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.
494
562
  def self.validate_registration!(schema, kind, name)
563
+ method = REGISTRATION_METHOD.fetch(kind)
495
564
  # register_scalar("Type.field", ...) overrides one field's scalar — validate
496
- # the field exists and is a scalar, not that a type named "Type.field" exists.
497
- if kind == "scalar" && name.include?(".")
498
- return if scalar_field?(schema, name)
565
+ # the field, not that a type named "Type.field" exists.
566
+ return validate_scalar_field!(schema, name, method) if kind == "scalar" && name.include?(".")
499
567
 
500
- raise GraphWeaver::Error, "register_scalar(#{name.inspect}) matches no scalar field in this schema"
501
- 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
502
570
 
503
- return if schema.get_type(name)
571
+ expected = REGISTERED_KIND[kind]
572
+ return if expected.nil? || type.kind.name == expected
504
573
 
505
- suggestion = GraphWeaver.did_you_mean(schema.types.keys, name)
506
- hint = suggestion ? " did you mean '#{suggestion}'?" : ""
507
- # the type registry is reached via extend_type; scalars/enums via register_*
508
- method = kind == "type" ? "extend_type" : "register_#{kind}"
509
- 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)}" : ""}"
510
580
  end
581
+ private_class_method :validate_registration!
582
+
583
+ # A per-field override, register_scalar("Type.field", ...). Neither an absent
584
+ # type nor an absent field is disprovable here; what is, is a field this
585
+ # schema declares as something a scalar codec could never read.
586
+ def self.validate_scalar_field!(schema, name, method)
587
+ type_name, field_name = name.split(".", 2)
588
+ type = schema.get_type(type_name)
589
+ unless type
590
+ near = GraphWeaver::Internal::Util.did_you_mean(schema.types.keys, type_name)
591
+ return unmatched(schema, method, name, "scalar field", near && "#{near}.#{field_name}")
592
+ end
511
593
 
512
- # Whether `coordinate` ("Type.field") names an existing scalar field — the
513
- # validation for a per-field register_scalar override.
514
- def self.scalar_field?(schema, coordinate)
515
- type_name, field_name = coordinate.split(".", 2)
516
- 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"
517
601
 
518
- field = schema.get_field(type_name, field_name)
519
- !!field && field.type.unwrap.kind.name == "SCALAR"
520
- rescue StandardError
521
- 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"
522
615
  end
616
+ private_class_method :unmatched
617
+
618
+ def self.article(word) = word.downcase.start_with?(/[aeiou]/) ? "an" : "a"
619
+ private_class_method :article
523
620
 
524
621
  # Parse every fragment file under `paths` into one { name => FragmentDefinition }
525
622
  # map — reusable fragments a query can spread. Fragment files hold only
@@ -527,17 +624,18 @@ class GraphWeaver::Codegen
527
624
  def self.load_fragments(paths)
528
625
  source = {} # fragment name => the file that defined it, for the collision message
529
626
 
530
- 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|
531
628
  doc = parse_document(File.read(file), file)
629
+ reported = GraphWeaver::Internal::Util.relative(file)
532
630
  if doc.definitions.grep(GraphQL::Language::Nodes::OperationDefinition).any?
533
- raise GraphWeaver::Error, "#{file}: fragment files define only fragments, no operations"
631
+ raise GraphWeaver::Error, "#{reported}: fragment files define only fragments, no operations"
534
632
  end
535
633
  doc.definitions.grep(GraphQL::Language::Nodes::FragmentDefinition).each do |frag|
536
634
  if (earlier = source[frag.name])
537
635
  raise GraphWeaver::Error,
538
- "duplicate shared fragment '#{frag.name}' — defined in #{earlier} and #{file}; rename one"
636
+ "duplicate shared fragment '#{frag.name}' — defined in #{earlier} and #{reported}; rename one"
539
637
  end
540
- source[frag.name] = file
638
+ source[frag.name] = reported
541
639
  out[frag.name] = frag
542
640
  end
543
641
  end
@@ -565,11 +663,12 @@ class GraphWeaver::Codegen
565
663
  def self.parse_document(query, path = nil)
566
664
  GraphQL.parse(query)
567
665
  rescue GraphQL::ParseError => e
568
- prefix = [path, e.line, e.col].compact.join(":")
666
+ prefix = [path && GraphWeaver::Internal::Util.relative(path), e.line, e.col].compact.join(":")
569
667
  raise GraphWeaver::ValidationError.new(
570
668
  [{ message: prefix.empty? ? e.message : "#{prefix} #{e.message}", line: e.line, column: e.col }],
571
669
  )
572
670
  end
671
+ private_class_method :parse_document
573
672
 
574
673
  # Append the shared fragments a query spreads (transitively) to its source, so
575
674
  # the sent query is self-contained. Unused shared fragments are left out.
@@ -621,17 +720,12 @@ class GraphWeaver::Codegen
621
720
  { message: prefix.empty? ? message : "#{prefix} #{message}", line:, column: }
622
721
  end
623
722
 
624
- # Every registration this generation could consult. The built-in scalars are
625
- # pre-registered entries in the same table rather than user intent, so
626
- # 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.
627
727
  def validate_registrations!
628
- {
629
- "enum" => GraphWeaver::Codegen.enum_registry,
630
- "scalar" => GraphWeaver::Codegen.scalar_registry.except(*BUILTIN_SCALARS),
631
- "type" => GraphWeaver::Codegen.type_registry,
632
- }.each do |kind, registry|
633
- registry.each_key { |name| self.class.validate_registration!(@schema, kind, name) }
634
- end
728
+ self.class.unmatched_registrations(@schema).each { |message| GraphWeaver::Internal::Log.log(:warn) { message } }
635
729
  end
636
730
 
637
731
  # The @include/@skip a fragment carries applies to what it guards, so it has
@@ -686,7 +780,7 @@ class GraphWeaver::Codegen
686
780
  type_ref(field_type) { object_node(core, sub_selections, name) }
687
781
  when "UNION", "INTERFACE"
688
782
  conditions = concrete_conditions(core, sub_selections)
689
- bare = bare_fields(sub_selections) - ["__typename"]
783
+ shared = abstract_level_fields(core, sub_selections)
690
784
 
691
785
  if conditions.empty?
692
786
  # abstract-level fields only — every member shares them, so one
@@ -694,7 +788,7 @@ class GraphWeaver::Codegen
694
788
  # union that selection can only be __typename)
695
789
  name = pick_name(key, taken)
696
790
  type_ref(field_type) { object_node(core, sub_selections, name) }
697
- elsif conditions.size == 1 && bare.empty? &&
791
+ elsif conditions.size == 1 && shared.empty? &&
698
792
  (member = @schema.get_type(conditions.first)).kind.name == "OBJECT"
699
793
  # a single `... on X` condition: narrow to X's struct — nil
700
794
  # when the runtime type doesn't match (narrowing filters).
@@ -742,7 +836,7 @@ class GraphWeaver::Codegen
742
836
  type_ref(field_type) { variable_core(core) }
743
837
  when "SCALAR"
744
838
  coordinate = "#{type.graphql_name}.#{field_name}"
745
- type_ref(field_type) { scalar_node(core.graphql_name, coordinate) }
839
+ type_ref(field_type) { scalar_node(core.graphql_name, coordinate, result: true) }
746
840
  else
747
841
  raise GraphWeaver::Error, "unsupported kind: #{core.kind.name}"
748
842
  end
@@ -763,6 +857,69 @@ class GraphWeaver::Codegen
763
857
  node
764
858
  end
765
859
 
860
+ # A generated class name is only ever a name; Ruby resolves it lexically. So
861
+ # a struct nesting `class Date < T::Struct` (from a result key `date`) turns
862
+ # a sibling `Date` scalar prop into that struct, and `Date.iso8601` into a
863
+ # NoMethodError — the file typechecks against itself and means something
864
+ # else. Refuse instead, naming both keys: aliasing either one in the query
865
+ # fixes it. `scope` is what enclosing structs have already introduced, since
866
+ # a nested class shadows for everything lexically inside it too.
867
+ def check_shadowing!(node, scope = {})
868
+ case node
869
+ when UnionNode
870
+ members = node.members.each_value.to_a + [node.catch_all].compact
871
+ inner = scope.merge(members.to_h { |m| [m.class_name, "the member struct #{m.class_name}"] })
872
+ members.each { |member| check_shadowing!(member, inner) }
873
+ when ObjectNode
874
+ nested = node.fields.filter_map { |field|
875
+ child = field.node.nested
876
+ [child, field.key] if child && !module_level?(child)
877
+ }.uniq(&:first)
878
+ inner = scope.merge(nested.to_h { |child, key| [child.class_name, "the class result key #{key.inspect} generates"] })
879
+
880
+ external_constants(node).each do |name, source|
881
+ shadow = inner[name] or next
882
+
883
+ raise GraphWeaver::Error,
884
+ "#{source} resolves to #{name}, but #{shadow} is also named #{name} and shadows it " \
885
+ "inside #{node.class_name} — alias one in the query to a distinct name"
886
+ end
887
+
888
+ nested.each { |child, _| check_shadowing!(child, inner) }
889
+ end
890
+ end
891
+
892
+ # Constants a struct's body names but doesn't define: the runtimes it always
893
+ # mentions, a registered scalar's Ruby type, a module-level enum, a hoisted
894
+ # union's alias, an extend_type mixin. Keyed by the constant's first segment,
895
+ # which is all Ruby resolves — `Money::Amount` goes through `Money`.
896
+ def external_constants(node)
897
+ refs = { "T" => "the Sorbet runtime", "GraphWeaver" => "the GraphWeaver runtime" }
898
+ node.mixins.each { |mixin| refs[root_constant(mixin)] ||= "the mixin #{mixin} registered with extend_type" }
899
+
900
+ node.fields.each do |field|
901
+ child = field.node.nested
902
+ name = if child
903
+ next unless module_level?(child)
904
+
905
+ child.class_name
906
+ else
907
+ root_constant(unwrapped(field.node).bare_type)
908
+ end
909
+ refs[name] ||= "result key #{field.key.inspect}"
910
+ end
911
+ refs
912
+ end
913
+
914
+ def root_constant(name) = name[/\A[A-Za-z_]\w*/]
915
+
916
+ # a leaf node with its NON_NULL/LIST wrappers removed
917
+ def unwrapped(node)
918
+ node = T.let(node, T.untyped)
919
+ node = node.of while node.is_a?(NonNull) || node.is_a?(List)
920
+ node
921
+ end
922
+
766
923
  # Both ways a result key can fail to become a prop — a name the struct
767
924
  # already answers, or a second key that underscores onto an earlier one.
768
925
  # Either emits a file that raises ArgumentError at require time, so refuse
@@ -806,9 +963,19 @@ class GraphWeaver::Codegen
806
963
  end.compact.uniq - [core.graphql_name]
807
964
  end
808
965
 
809
- # result keys selected as plain fields (outside any type condition)
810
- def bare_fields(selections)
811
- 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
812
979
  end
813
980
 
814
981
  # The fragment name when a selection is exactly one bare fragment spread
@@ -1065,6 +1232,16 @@ class GraphWeaver::Codegen
1065
1232
  # ("Enum values must be assigned to constants") — catch it here instead.
1066
1233
  def enum_values(core)
1067
1234
  values = core.values.keys.sort
1235
+ # `_` and `__` are legal GraphQL enum values and camelize to nothing, so
1236
+ # the emitted `= new("_")` isn't even parseable — the file fails at load
1237
+ # with a syntax error pointing into generated source
1238
+ nameless = values.find { |value| !camelize(value.downcase).match?(/\A[A-Z]/) }
1239
+ if nameless
1240
+ raise GraphWeaver::Error,
1241
+ "enum #{core.graphql_name} value #{nameless} makes no constant name — map the enum onto " \
1242
+ "one of yours: register_enum(#{core.graphql_name.inspect}, YourEnum)"
1243
+ end
1244
+
1068
1245
  collision = values.group_by { |value| camelize(value.downcase) }.find { |_, group| group.size > 1 }
1069
1246
  if collision
1070
1247
  raise GraphWeaver::Error,
@@ -1099,17 +1276,35 @@ class GraphWeaver::Codegen
1099
1276
  # generated file can require them (collected across the whole query).
1100
1277
  # Resolution, most specific first: a per-field override (`Type.field`), then
1101
1278
  # the scalar-name registration.
1102
- def scalar_node(name, coordinate = nil)
1279
+ def scalar_node(name, coordinate = nil, result: false)
1103
1280
  registry = GraphWeaver::Codegen.scalar_registry
1104
- scalar = (coordinate && registry[coordinate]) || registry[name.to_s]
1105
- if scalar.nil?
1106
- @untyped_scalars << name.to_s
1107
- scalar = GraphWeaver::Codegen.scalar(name)
1108
- end
1281
+ @untyped_scalars << name.to_s unless (coordinate && registry[coordinate]) || registry[name.to_s]
1282
+ scalar = GraphWeaver::Codegen.scalar(name, coordinate)
1283
+ refuse_uncastable!(scalar, coordinate || name) if result
1109
1284
  @requires.concat(scalar.requires)
1110
1285
  Scalar.new(scalar)
1111
1286
  end
1112
1287
 
1288
+ # A registered type nothing on the wire can be, with no cast to build one:
1289
+ # the prop is unsatisfiable, so every response fails — at runtime, a long
1290
+ # way from the registration that caused it. Reaches an app's own value
1291
+ # object, since the stdlib types people reach for (BigDecimal, Time) infer
1292
+ # a cast; see ScalarType.
1293
+ def refuse_uncastable!(scalar, where)
1294
+ return if scalar.cast?
1295
+
1296
+ klass = Object.const_get(scalar.type)
1297
+ return unless klass.is_a?(Class) && ScalarType::WIRE_CLASSES.none? { |native| native <= klass }
1298
+
1299
+ raise GraphWeaver::Error,
1300
+ "register_scalar(#{scalar.graphql_name.inspect}, #{scalar.type}) has no cast, so nothing " \
1301
+ "builds a #{scalar.type} out of the JSON at #{where} — give it one (cast: :parse names a " \
1302
+ "class method, cast: ->(v) { \"#{scalar.type}(\#{v})\" } emits any expression), or register " \
1303
+ "a type the wire already parses into"
1304
+ rescue ::NameError
1305
+ nil # a type: given as a String names a class this process may not have
1306
+ end
1307
+
1113
1308
  # An unregistered custom scalar passes through as T.untyped — legitimate
1114
1309
  # (nobody needs a codec for every scalar), but it's the one hole in an
1115
1310
  # otherwise exact result type, so name the holes rather than leave them
@@ -1118,7 +1313,7 @@ class GraphWeaver::Codegen
1118
1313
  names = @untyped_scalars.uniq.sort
1119
1314
  return if names.empty?
1120
1315
 
1121
- GraphWeaver.log(:info) do
1316
+ GraphWeaver::Internal::Log.log(:info) do
1122
1317
  "#{names.size} unregistered custom scalar#{"s" unless names.one?} → T.untyped: " \
1123
1318
  "#{names.join(", ")} (register with GraphWeaver.register_scalar)"
1124
1319
  end