graph_weaver 0.5.0 → 0.5.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +128 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +1 -1
  5. data/docs/cassettes.md +23 -3
  6. data/docs/errors.md +2 -0
  7. data/docs/federation.md +8 -7
  8. data/docs/generated_modules.md +2 -2
  9. data/docs/getting_started.md +1 -1
  10. data/docs/logging.md +1 -1
  11. data/docs/testing.md +8 -7
  12. data/docs/upgrading.md +34 -12
  13. data/graph_weaver.gemspec +16 -2
  14. data/lib/generators/graph_weaver/install_generator.rb +15 -15
  15. data/lib/graph_weaver/client.rb +6 -2
  16. data/lib/graph_weaver/codegen/aliases.rb +10 -4
  17. data/lib/graph_weaver/codegen/emit.rb +11 -3
  18. data/lib/graph_weaver/codegen/enum_type.rb +1 -3
  19. data/lib/graph_weaver/codegen/scalar_type.rb +5 -4
  20. data/lib/graph_weaver/codegen/type_helpers.rb +1 -3
  21. data/lib/graph_weaver/codegen.rb +99 -22
  22. data/lib/graph_weaver/errors.rb +27 -6
  23. data/lib/graph_weaver/federation.rb +4 -17
  24. data/lib/graph_weaver/parsing.rb +1 -9
  25. data/lib/graph_weaver/rspec.rb +13 -7
  26. data/lib/graph_weaver/schema_loader.rb +30 -6
  27. data/lib/graph_weaver/schemas.rb +4 -2
  28. data/lib/graph_weaver/tasks.rb +24 -21
  29. data/lib/graph_weaver/testing/cassette.rb +89 -20
  30. data/lib/graph_weaver/testing/coverage.rb +7 -12
  31. data/lib/graph_weaver/testing/failure.rb +4 -2
  32. data/lib/graph_weaver/testing/fake_client.rb +1 -1
  33. data/lib/graph_weaver/testing/router.rb +68 -47
  34. data/lib/graph_weaver/testing/subgraphs.rb +11 -7
  35. data/lib/graph_weaver/testing.rb +8 -2
  36. data/lib/graph_weaver/version.rb +1 -1
  37. data/lib/graph_weaver.rb +39 -14
  38. metadata +8 -9
  39. data/CLAUDE.md +0 -161
  40. data/DECISIONS.md +0 -309
  41. data/Makefile +0 -23
  42. data/NOTES.md +0 -182
  43. data/PLAN.md +0 -115
  44. data/REVIEW.md +0 -946
@@ -171,10 +171,6 @@ class GraphWeaver::Codegen
171
171
  end
172
172
  end
173
173
 
174
- # Pre-registered rather than user intent (see register_builtin_scalars!), so
175
- # generation doesn't hold a schema to them.
176
- BUILTIN_SCALARS = %w[ID String Int Float Boolean Date].freeze
177
-
178
174
  class << self
179
175
  # requires: is a require path or list of them; each must be a non-empty
180
176
  # String (it is emitted verbatim as `require "..."` atop the generated
@@ -258,4 +254,9 @@ class GraphWeaver::Codegen
258
254
  end
259
255
 
260
256
  register_builtin_scalars!
257
+
258
+ # Pre-registered rather than user intent, so generation doesn't hold a schema
259
+ # to them (validate_registration! skips these). Read off the registry the line
260
+ # above just filled: a seventh built-in shouldn't have to be named twice.
261
+ BUILTIN_SCALARS = scalar_registry.keys.freeze
261
262
  end
@@ -54,9 +54,7 @@ class GraphWeaver::Codegen
54
54
  # won't resolve yet — see EnumType for why that's a Rails initializer
55
55
  if mixin.is_a?(String)
56
56
  raise ArgumentError, "type helpers are the modules themselves, not their names — " \
57
- "extend_type(#{graphql_name.to_s.inspect}, #{mixin}). An autoloaded constant isn't " \
58
- "resolvable while config/initializers run; register from a " \
59
- "Rails.application.config.to_prepare block, which generation also runs first."
57
+ "extend_type(#{graphql_name.to_s.inspect}, #{mixin}). #{GraphWeaver::Codegen::AUTOLOAD_HINT}"
60
58
  end
61
59
  unless mixin.is_a?(Module) && mixin.name
62
60
  raise ArgumentError, "type helpers must be named modules, got #{mixin.inspect}"
@@ -15,12 +15,14 @@ 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.
22
23
  require_relative "hints"
23
24
  require_relative "input_struct"
25
+ require_relative "schema_loader"
24
26
  require_relative "representation"
25
27
  require_relative "inflect"
26
28
  require_relative "selection"
@@ -41,6 +43,13 @@ class GraphWeaver::Codegen
41
43
  # how anyone with sixty queries organizes them.
42
44
  DOCUMENT_GLOB = "**/*.{graphql,gql}"
43
45
 
46
+ # Why every registration takes the constant and never its name. register_enum
47
+ # and extend_type refuse a String for the same reason, so they say it in the
48
+ # same words — a reword has to reach both or one starts giving worse advice.
49
+ AUTOLOAD_HINT = "An autoloaded constant isn't resolvable while config/initializers " \
50
+ "run; register from a Rails.application.config.to_prepare block, which generation " \
51
+ "also runs first."
52
+
44
53
  attr_reader :module_name
45
54
 
46
55
  # A client is anything responding to `execute(query, variables:)`
@@ -436,22 +445,15 @@ class GraphWeaver::Codegen
436
445
  end
437
446
 
438
447
  # 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.
448
+ # "id organization { id }" — flattened to the dotted leaf paths the wire
449
+ # hash needs. The same reading the routing table does of the same syntax,
450
+ # so a supergraph and a subgraph SDL can't disagree about one key.
441
451
  def key_paths(entity, fields)
442
- selections = GraphQL.parse("{ #{fields} }").definitions.first.selections
443
- leaf_paths(selections)
452
+ GraphWeaver::SchemaLoader::RoutingTable.parse_field_set(fields)
444
453
  rescue GraphQL::ParseError => e
445
454
  raise GraphWeaver::Error, "#{entity.graphql_name} @key(fields: #{fields.inspect}) isn't a selection set: #{e.message}"
446
455
  end
447
456
 
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
457
  # The kwargs a builder takes: every key set's top-level field, once. Typed
456
458
  # from the schema — a leaf key field gets its registered scalar's Ruby
457
459
  # type, a nested one an open Hash whose shape the runtime checks.
@@ -632,6 +634,29 @@ class GraphWeaver::Codegen
632
634
  end
633
635
  end
634
636
 
637
+ # The @include/@skip a fragment carries applies to what it guards, so it has
638
+ # to travel with the selections into the child rather than being spent on the
639
+ # key. Re-wrapping in a guarded inline fragment says that in the vocabulary
640
+ # the walk already speaks, which is what keeps dispatchable_typename? and the
641
+ # __typename refusal honest for free.
642
+ GUARDED = [GraphQL::Language::Nodes::Directive.new(name: "include")].freeze
643
+ private_constant :GUARDED
644
+
645
+ # A key's merged sub-selections, keeping the conditionality of the occurrence
646
+ # each child came from: `pets @include(if:) { name } pets { species }` answers
647
+ # with `name` only when that occurrence ran, so those children have to admit
648
+ # nil. One occurrence needs none of this — the key is there exactly when it
649
+ # ran, and its own prop already says so.
650
+ def merged_selections(occurrences)
651
+ return occurrences.first.first.selections if occurrences.one?
652
+
653
+ occurrences.flat_map do |node, conditional|
654
+ next node.selections unless conditional || conditional?(node)
655
+
656
+ [GraphQL::Language::Nodes::InlineFragment.new(type: nil, directives: GUARDED, selections: node.selections)]
657
+ end
658
+ end
659
+
635
660
  def object_node(type, selections, class_name)
636
661
  node = ObjectNode.new(class_name)
637
662
  node.graphql_type = type.graphql_name
@@ -653,7 +678,7 @@ class GraphWeaver::Codegen
653
678
  NonNull.new(scalar_node("String"))
654
679
  else
655
680
  field_type = @schema.get_field(type.graphql_name, field_name).type
656
- sub_selections = field_nodes.flat_map(&:selections)
681
+ sub_selections = merged_selections(occurrences)
657
682
 
658
683
  case (core = field_type.unwrap).kind.name
659
684
  when "OBJECT"
@@ -822,7 +847,10 @@ class GraphWeaver::Codegen
822
847
  def nilable_type_ref(type, &core)
823
848
  case type.kind.name
824
849
  when "NON_NULL"
825
- nilable_type_ref(type.of_type, &core)
850
+ # only the NON_NULL around the narrowed member itself drops — `[Thing!]!`
851
+ # narrowed is a guaranteed array of nilable members, not a nilable array
852
+ inner = nilable_type_ref(type.of_type, &core)
853
+ inner.is_a?(List) ? NonNull.new(inner) : inner
826
854
  when "LIST"
827
855
  List.new(nilable_type_ref(type.of_type, &core))
828
856
  else
@@ -864,11 +892,58 @@ class GraphWeaver::Codegen
864
892
 
865
893
  # The one struct everything else deserializes into: a member the query didn't
866
894
  # 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.
895
+ # generated, so a new upstream member bends the result rather than breaking
896
+ # it. It carries what the abstract type itself guarantees, plus anything a
897
+ # `... on SomeInterface` asked for, since an unnamed member may implement it.
870
898
  def catch_all_member(type, selections, members)
871
- object_node(type, selections, catch_all_name(members))
899
+ node = object_node(type, selections, catch_all_name(members))
900
+ taken = node.fields.map(&:key)
901
+
902
+ # These are nilable whatever the schema promises: the member that arrives
903
+ # need not implement the interface, and then the server sends nothing.
904
+ sibling_conditions(type, selections).each do |condition, sub_selections|
905
+ object_node(condition, sub_selections, node.class_name).fields.each do |field|
906
+ next if taken.include?(field.key)
907
+
908
+ taken << field.key
909
+ child = field.node
910
+ node.fields << ObjectNode::Field.new(field.prop, field.key, child.is_a?(NonNull) ? child.of : child)
911
+ end
912
+ end
913
+
914
+ node.aliases = resolve_aliases(node)
915
+ node
916
+ end
917
+
918
+ # The abstract type conditions inside an abstract selection that a member the
919
+ # query never NAMED could still satisfy — `... on Named` under a union, or
920
+ # under a different interface. Returns condition => merged selections, so the
921
+ # same interface spread twice types once; concrete conditions are excluded,
922
+ # since a member they'd match already has a struct of its own.
923
+ def sibling_conditions(type, selections, visiting = Set.new, out = {})
924
+ selections.each do |selection|
925
+ case selection
926
+ when GraphQL::Language::Nodes::InlineFragment
927
+ sibling_condition(type, selection.type&.name, selection.selections, visiting, out)
928
+ when GraphQL::Language::Nodes::FragmentSpread
929
+ next if visiting.include?(selection.name)
930
+
931
+ fragment = @fragments.fetch(selection.name)
932
+ sibling_condition(type, fragment.type.name, fragment.selections, visiting | [selection.name], out)
933
+ end
934
+ end
935
+ out
936
+ end
937
+
938
+ def sibling_condition(type, name, selections, visiting, out)
939
+ condition = name ? @schema.get_type(name) : type
940
+ return unless condition
941
+ # same type condition restated — keep descending at this level
942
+ return sibling_conditions(type, selections, visiting, out) if condition.graphql_name == type.graphql_name
943
+ return unless condition.kind.abstract?
944
+
945
+ (out[condition] ||= []).concat(selections)
946
+ sibling_conditions(condition, selections, visiting, out)
872
947
  end
873
948
 
874
949
  # "Other", unless a real member already claims that name.
@@ -926,7 +1001,9 @@ class GraphWeaver::Codegen
926
1001
  end
927
1002
  end
928
1003
 
929
- # the input-side core kinds a variable (or input-object field) can have
1004
+ # The node for a core type, reached from a variable, an input-object field
1005
+ # or a result-side enum. All three share it so that one schema enum is one
1006
+ # Ruby type wherever it appears — see object_node's ENUM branch.
930
1007
  def variable_core(core)
931
1008
  case core.kind.name
932
1009
  when "SCALAR"
@@ -189,12 +189,32 @@ module GraphWeaver
189
189
  extensions["code"] || @error_type
190
190
  end
191
191
 
192
- # Message shapes servers use when they reject the *shape* of a query
193
- # (unknown field/type/argument). Heuristic by necessity: only Apollo
194
- # sets a standard code (GRAPHQL_VALIDATION_FAILED); graphql-ruby and
195
- # GitHub speak in messages.
192
+ # Codes a server sets when it rejects the *shape* of a query. Apollo has
193
+ # one flat code; graphql-ruby names the rule that fired, and it is the
194
+ # in-process client this library ships, so its drift-shaped rules are
195
+ # listed rather than guessed at from prose.
196
+ VALIDATION_CODES = T.let(%w[
197
+ GRAPHQL_VALIDATION_FAILED
198
+ undefinedField undefinedType undefinedDirective
199
+ argumentNotAccepted argumentType argumentLiteralsIncompatible
200
+ missingRequiredArguments missingRequiredInputObjectAttribute
201
+ cannotSpreadFragment fragmentOnNonCompositeType
202
+ variableMismatch variableRequiresValidType variableNotDefined
203
+ selectionMismatch invalidOneOfInputObject
204
+ ].to_set.freeze, T::Set[String])
205
+
206
+ # For servers that send no code at all. Variable coercion reports through
207
+ # the message in both dialects, and a required input field appearing is
208
+ # unambiguous here: a generated input struct enforces its own required
209
+ # fields, so the app cannot produce that error itself.
196
210
  VALIDATION_MESSAGE = T.let(
197
- /doesn't exist|Cannot query field|Unknown (field|type|argument)|isn't defined|undefined (field|type)/i,
211
+ Regexp.union(
212
+ /doesn't exist/i, /Cannot query field/i, /Unknown (field|type|argument)/i,
213
+ /is ?n[o']t defined/i, /undefined (field|type)/i, /No such type/i,
214
+ /can't be spread inside/i, /is missing required arguments/i,
215
+ /doesn't accept argument/i, /Field is not defined on/i,
216
+ /was provided invalid value for .+ \(Expected value to not be null\)/i,
217
+ ),
198
218
  Regexp,
199
219
  )
200
220
 
@@ -203,7 +223,8 @@ module GraphWeaver
203
223
  # changed after generation.
204
224
  sig { returns(T::Boolean) }
205
225
  def validation?
206
- code == "GRAPHQL_VALIDATION_FAILED" || VALIDATION_MESSAGE.match?(message)
226
+ # to_s: a nil code is never a member, and sorbet can't narrow a call
227
+ VALIDATION_CODES.include?(code.to_s) || VALIDATION_MESSAGE.match?(message)
207
228
  end
208
229
 
209
230
  # The codes servers use to say "you're going too fast". No standard
@@ -78,9 +78,10 @@ module GraphWeaver
78
78
  def initialize(supergraph: nil, subgraphs: nil, schemas: nil)
79
79
  source = (supergraph || GraphWeaver::SchemaLoader.locate_path).to_s
80
80
  @table = GraphWeaver::SchemaLoader.routing_table(source)
81
- # SDL passed as content has no name to print
82
- @source = source.include?("\n") ? "the supergraph" : source
83
- @given = named(subgraphs)
81
+ # SDL passed as content has no name to print — asked the way the
82
+ # loader asks it, which a one-line supergraph doesn't fool
83
+ @source = GraphWeaver::SchemaLoader.sdl_content?(source) ? "the supergraph" : source
84
+ @given = @table.named_subgraphs(subgraphs)
84
85
  @schemas = schemas || GraphWeaver::Schemas.loaded
85
86
  @stale = {}
86
87
  @uncomposed = {}
@@ -129,20 +130,6 @@ module GraphWeaver
129
130
  STALE = "stale — the supergraph carries these, no schema here defines them (recompose):"
130
131
  UNCOMPOSED = "not composed in — a schema here defines these, the supergraph doesn't carry them:"
131
132
 
132
- # `subgraphs:` with string keys, refusing a name this supergraph
133
- # doesn't have — the same check Testing::Subgraphs makes, and for the
134
- # same reason: a typo'd key would silently check nothing
135
- def named(given)
136
- map = (given || {}).to_h { |name, schema| [name.to_s, schema] }
137
- unknown = map.keys - @table.subgraphs
138
- if unknown.any?
139
- raise GraphWeaver::ConfigurationError, "subgraphs: names #{unknown.join(", ")}, which " \
140
- "this supergraph doesn't have (its subgraphs are #{@table.subgraphs.join(", ")})"
141
- end
142
-
143
- map
144
- end
145
-
146
133
  def compare
147
134
  @table.subgraphs.each do |name|
148
135
  next unless (fitting = comparable(name))
@@ -27,7 +27,7 @@ module GraphWeaver
27
27
  def parse(query, name: nil)
28
28
  # #schema is the mixin's one requirement of its includer, and a module
29
29
  # has no way to declare that short of an abstract interface
30
- GraphWeaver.parse(schema: T.unsafe(self).schema, query:, name:, client: parse_client)
30
+ GraphWeaver.parse(schema: T.unsafe(self).schema, query:, name:, client: self)
31
31
  end
32
32
 
33
33
  # Parse every query in a directory (subdirectories included) into typed
@@ -55,13 +55,5 @@ module GraphWeaver
55
55
  namespace.const_set(name, parse(path))
56
56
  end
57
57
  end
58
-
59
- private
60
-
61
- # What a parsed module executes through: this object, which holds the
62
- # schema and runs queries. Client is the one that overrides it — its own
63
- # #execute is the one-shot parse-and-run, not the client contract, so it
64
- # bakes the transport it wraps.
65
- def parse_client = self
66
58
  end
67
59
  end
@@ -83,9 +83,12 @@ module GraphWeaver
83
83
  # so there is no idiom to discover and no way to leak a client forward
84
84
  rspec_config.before(:each) do
85
85
  @__graph_weaver_prior_client = GraphWeaver.client
86
- @__graph_weaver_mode = GraphWeaver::Testing::RSpecIntegration.mode_for(
87
- RSpec.current_example&.metadata || {},
88
- )
86
+ metadata = RSpec.current_example&.metadata || {}
87
+ # what this example said, apart from what config.default_mode says
88
+ # for the ones that said nothing — a helper may only contradict the
89
+ # former
90
+ @__graph_weaver_tag = metadata[TAG] if metadata.key?(TAG)
91
+ @__graph_weaver_mode = GraphWeaver::Testing::RSpecIntegration.mode_for(metadata)
89
92
  if @__graph_weaver_mode
90
93
  GraphWeaver.client = GraphWeaver::Testing::RSpecIntegration.client_for(@__graph_weaver_mode)
91
94
  end
@@ -125,9 +128,9 @@ module GraphWeaver
125
128
  when :router
126
129
  router = config.built_router
127
130
  router.context = config.context
128
- # built once for the suite, so the trace has to be told where this
129
- # example starts — otherwise have_fetched reads the last one's
130
- router.reset_trace
131
+ # built once for the suite, so it has to be told where this example
132
+ # starts — the trace, and any faked subgraph's fabricated data
133
+ router.reset!
131
134
  end
132
135
  end
133
136
 
@@ -187,7 +190,10 @@ module GraphWeaver
187
190
  # two is then a mistake, and silently letting the later one win hides
188
191
  # which.
189
192
  def claim_mode!(mode)
190
- tagged = defined?(@__graph_weaver_mode) ? @__graph_weaver_mode : nil
193
+ # only an explicit tag can contradict a helper. config.default_mode
194
+ # is a fallback for examples that said nothing, so a helper is the
195
+ # example finally saying something — not a disagreement.
196
+ tagged = defined?(@__graph_weaver_tag) ? @__graph_weaver_tag : nil
191
197
  if tagged && tagged != mode
192
198
  # Kernel.raise: this module is mixed into every example group, so
193
199
  # it doesn't include Kernel for sorbet to find
@@ -44,7 +44,6 @@ module GraphWeaver::SchemaLoader
44
44
  def self.sdl_content?(source)
45
45
  source.match?(SDL_CONTENT)
46
46
  end
47
- private_class_method :sdl_content?
48
47
 
49
48
  def self.load_path(path)
50
49
  case File.extname(path)
@@ -718,8 +717,10 @@ module GraphWeaver::SchemaLoader
718
717
  end
719
718
  private_class_method :refresh_hint
720
719
 
721
- # a transport to the dump's recorded url, authenticated from whichever
722
- # ENV var the dump named (else GRAPHWEAVER_AUTH)
720
+ # A transport to the dump's recorded url, authenticated from whichever ENV
721
+ # var the dump named (else DEFAULT_AUTH_ENV). The single way to reach a
722
+ # dump's own server — building one at a call site is how `--auth MY_TOKEN`
723
+ # ends up honoured in some places and not others.
723
724
  def self.source_transport(path)
724
725
  meta = provenance(path)
725
726
  unless meta&.key?("url")
@@ -730,7 +731,6 @@ module GraphWeaver::SchemaLoader
730
731
 
731
732
  GraphWeaver.new(meta["url"], auth: ENV[auth_env(path)]).transport
732
733
  end
733
- private_class_method :source_transport
734
734
 
735
735
  # Where a dump came from, recorded into the file so it can be
736
736
  # re-verified later — a parsable header comment in SDL, a
@@ -938,6 +938,19 @@ module GraphWeaver::SchemaLoader
938
938
  owners(type_name, field_name)
939
939
  end
940
940
 
941
+ # A `subgraphs:` map with string keys, refusing a name this supergraph
942
+ # doesn't have — a typo'd key would otherwise silently configure nothing.
943
+ # Lives here so the test router and the drift check refuse identically:
944
+ # two copies of this drifted apart once already.
945
+ def named_subgraphs(given)
946
+ map = (given || {}).to_h { |name, schema| [name.to_s, schema] }
947
+ unknown = map.keys - subgraphs
948
+ return map if unknown.empty?
949
+
950
+ raise GraphWeaver::ConfigurationError, "subgraphs: names #{unknown.join(", ")}, which " \
951
+ "this supergraph doesn't have (its subgraphs are #{subgraphs.join(", ")})"
952
+ end
953
+
941
954
  # The @key field sets a subgraph will answer an `_entities` fetch on, each
942
955
  # as a list of dotted paths ("id organization { id }" => ["id",
943
956
  # "organization.id"]). A `resolvable: false` key declares a shape this
@@ -994,16 +1007,27 @@ module GraphWeaver::SchemaLoader
994
1007
 
995
1008
  # join__Graph's enum values ARE the subgraphs: ACCOUNTS
996
1009
  # @join__graph(name: "accounts", url: "...").
1010
+ #
1011
+ # This table reads the join spec under its default name, and only that —
1012
+ # a supergraph that renamed it (`@link(url: ".../join/v0.3", as: "j")`)
1013
+ # spells every marker `j__` and lands here with nothing found. Being told
1014
+ # a composed graph has no subgraphs is worse than being refused, so an
1015
+ # empty read is `unsupported` rather than an answer.
997
1016
  def read_graphs
998
1017
  enum = @document.definitions.find do |defn|
999
1018
  defn.is_a?(GraphQL::Language::Nodes::EnumTypeDefinition) && defn.name == "join__Graph"
1000
1019
  end
1001
- return unless enum
1002
1020
 
1003
- enum.values.each do |value|
1021
+ enum&.values&.each do |value|
1004
1022
  name = argument(value.directives.find { |d| d.name == "join__graph" }, "name")
1005
1023
  @names[value.name] = name if name
1006
1024
  end
1025
+
1026
+ return if @names.any?
1027
+
1028
+ @unsupported << "no join__Graph enum names the subgraphs — this schema declares a " \
1029
+ "composition spec, so either it merged nothing, or it renamed the join spec " \
1030
+ "(@link(url: \".../join/v0.3\", as: \"...\")), which this table doesn't follow"
1007
1031
  end
1008
1032
 
1009
1033
  # What each abstract type can be, from the SDL alone — a union's members,
@@ -12,8 +12,10 @@ module GraphWeaver
12
12
  # Two features ask exactly these two questions, and match a schema on the
13
13
  # coordinates it defines rather than on its class name: {Testing::Subgraphs}
14
14
  # (which schema serves which subgraph) and {Federation::Drift} (has a
15
- # subgraph changed without a recompose). They share the answers so the two
16
- # can't drift apart.
15
+ # subgraph changed without a recompose). What they share is this evidence,
16
+ # not the verdict: Subgraphs wants every type AND field, Drift only the
17
+ # types — a schema that lost a field is not a candidate to run against, but
18
+ # is exactly the one Drift has to recognize to report the loss.
17
19
  module Schemas
18
20
  class << self
19
21
  # Every named GraphQL::Schema in the process. An anonymous one is
@@ -24,6 +24,20 @@
24
24
  # rake graph_weaver:cassettes:check # fail if a recording no longer casts
25
25
  require_relative "../graph_weaver"
26
26
 
27
+ module GraphWeaver
28
+ # helpers the rake tasks share; not part of the library's API
29
+ module Tasks
30
+ # The composed supergraph a federation task reads: SUPERGRAPH=, else the
31
+ # conventional dump when that is what it is. Aborts naming the task, so
32
+ # the message says the command to retype.
33
+ def self.supergraph!(task)
34
+ ENV["SUPERGRAPH"] || GraphWeaver::SchemaLoader.locate_path ||
35
+ abort("pass the composed supergraph: rake graph_weaver:federation:#{task} " \
36
+ "SUPERGRAPH=supergraph.graphql")
37
+ end
38
+ end
39
+ end
40
+
27
41
  namespace :graph_weaver do
28
42
  # In Rails, boot the app first — initializers register scalars/enums/
29
43
  # helpers and they're baked into generated source. Rails defines
@@ -123,12 +137,7 @@ namespace :graph_weaver do
123
137
  task diff: :loaded do
124
138
  require "graph_weaver/federation"
125
139
 
126
- supergraph = ENV["SUPERGRAPH"] || GraphWeaver::SchemaLoader.locate_path
127
- unless supergraph
128
- abort "pass the composed supergraph: rake graph_weaver:federation:diff " \
129
- "SUPERGRAPH=supergraph.graphql"
130
- end
131
-
140
+ supergraph = GraphWeaver::Tasks.supergraph!("diff")
132
141
  drift = GraphWeaver::Federation::Drift.new(supergraph:)
133
142
  puts drift.report
134
143
 
@@ -152,11 +161,7 @@ namespace :graph_weaver do
152
161
  task subgraphs: :loaded do
153
162
  require "graph_weaver/testing"
154
163
 
155
- supergraph = ENV["SUPERGRAPH"] || GraphWeaver::SchemaLoader.locate_path
156
- unless supergraph
157
- abort "pass the composed supergraph: rake graph_weaver:federation:subgraphs " \
158
- "SUPERGRAPH=supergraph.graphql"
159
- end
164
+ supergraph = GraphWeaver::Tasks.supergraph!("subgraphs")
160
165
 
161
166
  # Testing::Router derives this map itself; this is for reading what
162
167
  # detection sees when it refuses, and for committing the map instead.
@@ -191,14 +196,8 @@ namespace :graph_weaver do
191
196
  task coverage: :loaded do
192
197
  require "graph_weaver/testing"
193
198
 
194
- supergraph = ENV["SUPERGRAPH"] || GraphWeaver::SchemaLoader.locate_path
195
- unless supergraph
196
- abort "pass the composed supergraph: rake graph_weaver:federation:coverage " \
197
- "SUPERGRAPH=supergraph.graphql"
198
- end
199
-
200
199
  puts GraphWeaver::Testing::Coverage.new(
201
- supergraph:,
200
+ supergraph: GraphWeaver::Tasks.supergraph!("coverage"),
202
201
  queries: ENV["QUERIES"] || GraphWeaver.queries_paths,
203
202
  ).report
204
203
  rescue GraphWeaver::Error => e
@@ -227,7 +226,9 @@ namespace :graph_weaver do
227
226
  Object.const_get(name) if Object.const_defined?(name)
228
227
  end
229
228
 
230
- dir = GraphWeaver::Testing.config.cassette_dir
229
+ # Testing.cassette_dir, not config.cassette_dir: the configured path is
230
+ # relative by default and rake runs from wherever it runs from
231
+ dir = GraphWeaver::Testing.cassette_dir
231
232
  checks = Dir[File.join(dir, "*.yml")].sort.map do |path|
232
233
  GraphWeaver::Testing::Cassette.new(path).check(modules)
233
234
  end
@@ -256,8 +257,10 @@ namespace :graph_weaver do
256
257
  task anonymize: :environment do
257
258
  require "graph_weaver/testing"
258
259
 
259
- schema = GraphWeaver::SchemaLoader.load(GraphWeaver.schema_path)
260
- Dir[File.join(GraphWeaver::Testing.config.cassette_dir, "*.yml")].sort.each do |path|
260
+ # locate, not schema_path: the dump is whichever supported extension is
261
+ # actually on disk, and every sibling task asks the same way
262
+ schema = GraphWeaver::SchemaLoader.locate or abort "no schema dump at #{GraphWeaver.schema_path}"
263
+ Dir[File.join(GraphWeaver::Testing.cassette_dir, "*.yml")].sort.each do |path|
261
264
  GraphWeaver::Testing::Cassette.new(path).anonymize!(schema:)
262
265
  puts "anonymized #{path}"
263
266
  end