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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +128 -0
- data/Gemfile.lock +2 -2
- data/README.md +1 -1
- data/docs/cassettes.md +23 -3
- data/docs/errors.md +2 -0
- data/docs/federation.md +8 -7
- data/docs/generated_modules.md +2 -2
- data/docs/getting_started.md +1 -1
- data/docs/logging.md +1 -1
- data/docs/testing.md +8 -7
- data/docs/upgrading.md +34 -12
- data/graph_weaver.gemspec +16 -2
- data/lib/generators/graph_weaver/install_generator.rb +15 -15
- data/lib/graph_weaver/client.rb +6 -2
- data/lib/graph_weaver/codegen/aliases.rb +10 -4
- data/lib/graph_weaver/codegen/emit.rb +11 -3
- data/lib/graph_weaver/codegen/enum_type.rb +1 -3
- data/lib/graph_weaver/codegen/scalar_type.rb +5 -4
- data/lib/graph_weaver/codegen/type_helpers.rb +1 -3
- data/lib/graph_weaver/codegen.rb +99 -22
- data/lib/graph_weaver/errors.rb +27 -6
- data/lib/graph_weaver/federation.rb +4 -17
- data/lib/graph_weaver/parsing.rb +1 -9
- data/lib/graph_weaver/rspec.rb +13 -7
- data/lib/graph_weaver/schema_loader.rb +30 -6
- data/lib/graph_weaver/schemas.rb +4 -2
- data/lib/graph_weaver/tasks.rb +24 -21
- data/lib/graph_weaver/testing/cassette.rb +89 -20
- data/lib/graph_weaver/testing/coverage.rb +7 -12
- data/lib/graph_weaver/testing/failure.rb +4 -2
- data/lib/graph_weaver/testing/fake_client.rb +1 -1
- data/lib/graph_weaver/testing/router.rb +68 -47
- data/lib/graph_weaver/testing/subgraphs.rb +11 -7
- data/lib/graph_weaver/testing.rb +8 -2
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +39 -14
- metadata +8 -9
- data/CLAUDE.md +0 -161
- data/DECISIONS.md +0 -309
- data/Makefile +0 -23
- data/NOTES.md +0 -182
- data/PLAN.md +0 -115
- 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}).
|
|
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}"
|
data/lib/graph_weaver/codegen.rb
CHANGED
|
@@ -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
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
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 }" —
|
|
440
|
-
#
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
868
|
-
#
|
|
869
|
-
#
|
|
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
|
-
#
|
|
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"
|
data/lib/graph_weaver/errors.rb
CHANGED
|
@@ -189,12 +189,32 @@ module GraphWeaver
|
|
|
189
189
|
extensions["code"] || @error_type
|
|
190
190
|
end
|
|
191
191
|
|
|
192
|
-
#
|
|
193
|
-
#
|
|
194
|
-
#
|
|
195
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
83
|
-
@
|
|
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))
|
data/lib/graph_weaver/parsing.rb
CHANGED
|
@@ -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:
|
|
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
|
data/lib/graph_weaver/rspec.rb
CHANGED
|
@@ -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
|
-
|
|
87
|
-
|
|
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
|
|
129
|
-
#
|
|
130
|
-
router.
|
|
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
|
-
|
|
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
|
-
#
|
|
722
|
-
#
|
|
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
|
|
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,
|
data/lib/graph_weaver/schemas.rb
CHANGED
|
@@ -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).
|
|
16
|
-
#
|
|
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
|
data/lib/graph_weaver/tasks.rb
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
260
|
-
|
|
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
|