graph_weaver 0.7.5 → 0.7.7
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/Gemfile.lock +43 -43
- data/docs/errors.md +7 -3
- data/docs/generated_modules.md +66 -16
- data/docs/getting_started.md +39 -19
- data/docs/logging.md +79 -35
- data/docs/migrating.md +11 -4
- data/docs/scalars.md +12 -3
- data/docs/testing.md +75 -13
- data/docs/upgrading.md +43 -3
- data/examples/README.md +4 -2
- data/examples/github/generate.rb +22 -8
- data/examples/github/generated/star_mutation.rb +2 -2
- data/examples/github/generated/stargazers_query.rb +2 -2
- data/examples/github/generated/starred_query.rb +2 -2
- data/examples/github/run.rb +1 -0
- data/examples/github/setup.rb +16 -8
- data/graph_weaver.gemspec +15 -6
- data/lib/generators/graph_weaver/install_generator.rb +20 -2
- data/lib/graph_weaver/client.rb +0 -23
- data/lib/graph_weaver/codegen/aliases.rb +15 -3
- data/lib/graph_weaver/codegen/emit.rb +7 -6
- data/lib/graph_weaver/codegen/enum_type.rb +26 -2
- data/lib/graph_weaver/codegen/nodes.rb +21 -2
- data/lib/graph_weaver/codegen.rb +106 -54
- data/lib/graph_weaver/graph.rb +67 -6
- data/lib/graph_weaver/in_process.rb +1 -1
- data/lib/graph_weaver/input_struct.rb +29 -4
- data/lib/graph_weaver/internal/overrides.rb +126 -14
- data/lib/graph_weaver/internal/test_clients.rb +29 -7
- data/lib/graph_weaver/internal/unused.rb +30 -11
- data/lib/graph_weaver/internal/values.rb +17 -5
- data/lib/graph_weaver/internal.rb +23 -6
- data/lib/graph_weaver/log_subscriber.rb +27 -17
- data/lib/graph_weaver/logging.rb +89 -53
- data/lib/graph_weaver/parsing.rb +32 -3
- data/lib/graph_weaver/query_module.rb +48 -8
- data/lib/graph_weaver/railtie.rb +250 -11
- data/lib/graph_weaver/rspec.rb +41 -17
- data/lib/graph_weaver/schema_diff.rb +24 -5
- data/lib/graph_weaver/schema_loader.rb +22 -9
- data/lib/graph_weaver/tasks.rb +71 -44
- data/lib/graph_weaver/testing/fake_client.rb +24 -21
- data/lib/graph_weaver/testing.rb +27 -7
- data/lib/graph_weaver/transport.rb +1 -1
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +76 -43
- metadata +2 -2
data/graph_weaver.gemspec
CHANGED
|
@@ -5,8 +5,6 @@ Gem::Specification.new do |s|
|
|
|
5
5
|
# the README tagline, verbatim — the two pitches drifted apart once already,
|
|
6
6
|
# so spec/gemspec_spec.rb pins them together
|
|
7
7
|
s.description = "Your .graphql files, compiled into Sorbet types — and the fakes to test them."
|
|
8
|
-
# ".yardopts" explicitly: `git ls-files *` skips dotfiles, and
|
|
9
|
-
# rubydoc.info needs it shipped to render docstrings as markdown
|
|
10
8
|
# CLAUDE.md/PLAN.md/REVIEW.md/NOTES.md/DECISIONS.md are written for whoever
|
|
11
9
|
# works on the gem, not whoever installs it — and REVIEW.md carries examples
|
|
12
10
|
# from before the API it describes was rewritten
|
|
@@ -14,10 +12,21 @@ Gem::Specification.new do |s|
|
|
|
14
12
|
# someone who only has the installed gem, not a checkout
|
|
15
13
|
# CHANGELOG.md doesn't (259 KB, ~16% of the package) — changelog_uri below
|
|
16
14
|
# points at the GitHub copy instead
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
unpackaged = %w[spec sorbet bin design research CLAUDE.md PLAN.md REVIEW.md
|
|
16
|
+
NOTES.md DECISIONS.md CHANGELOG.md Makefile]
|
|
17
|
+
# git knows what is tracked; a checkout that isn't a repo — an unpacked gem,
|
|
18
|
+
# a vendored copy, a shallow CI export — has no such answer, and git's
|
|
19
|
+
# "fatal:" on stderr lands in the output of every subprocess a spec captures.
|
|
20
|
+
# So ask quietly, and walk the tree rather than ship nothing.
|
|
21
|
+
files = `git ls-files * #{unpackaged.map { |path| "':!:#{path}'" }.join(" ")} 2>/dev/null`.split("\n")
|
|
22
|
+
if files.empty?
|
|
23
|
+
files = Dir.glob("**/*", base: __dir__).reject do |path|
|
|
24
|
+
unpackaged.include?(path.split("/").first) || File.directory?(File.join(__dir__, path))
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
# ".yardopts" explicitly: neither list carries a dotfile, and rubydoc.info
|
|
28
|
+
# needs it shipped to render docstrings as markdown
|
|
29
|
+
s.files = files + [".yardopts"]
|
|
21
30
|
s.homepage = "https://github.com/dpep/graph_weaver"
|
|
22
31
|
s.license = "MIT"
|
|
23
32
|
s.name = "graph_weaver"
|
|
@@ -128,8 +128,7 @@ module GraphWeaver
|
|
|
128
128
|
# .gitattributes turns no tooling on, so there is no app this surprises.
|
|
129
129
|
def mark_generated_for_github
|
|
130
130
|
body = File.read(gitattributes) if File.exist?(gitattributes)
|
|
131
|
-
|
|
132
|
-
globs = linguist_globs.reject { |glob| body&.include?(glob) }
|
|
131
|
+
globs = linguist_globs.reject { |glob| marked?(body, glob) }
|
|
133
132
|
return if globs.empty?
|
|
134
133
|
|
|
135
134
|
lines = globs.map { |glob| "#{glob} linguist-generated" }.join("\n")
|
|
@@ -246,6 +245,25 @@ module GraphWeaver
|
|
|
246
245
|
# everything beneath dir — spelled as git reads it, not as rubocop does.
|
|
247
246
|
def linguist_globs = generated_outputs.map { |dir| File.join(dir, "**") }
|
|
248
247
|
|
|
248
|
+
# git's own spellings of the one attribute: set, unset (`-`), unspecified
|
|
249
|
+
# (`!`), or valued.
|
|
250
|
+
LINGUIST = /\A[-!]?linguist-generated(?:=|\z)/
|
|
251
|
+
|
|
252
|
+
# Already marked — a re-run, or done by hand. A .gitattributes line is
|
|
253
|
+
# `<pattern> <attribute>…`, with `#` opening a comment, and the same
|
|
254
|
+
# directory is `dir/**`, `dir/*`, `dir/` or `dir`. Searching the body as
|
|
255
|
+
# one string instead let a comment that mentions the path suppress the
|
|
256
|
+
# mark, and saw only the spelling this generator writes.
|
|
257
|
+
def marked?(body, glob)
|
|
258
|
+
dir = glob.delete_suffix("/**")
|
|
259
|
+
body.to_s.lines.any? do |line|
|
|
260
|
+
pattern, *attributes = line.split
|
|
261
|
+
next false if pattern.nil? || pattern.start_with?("#")
|
|
262
|
+
|
|
263
|
+
pattern.chomp("**").chomp("*").chomp("/") == dir && attributes.any? { |a| LINGUIST.match?(a) }
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
249
267
|
# This install run is the one moment the user is guaranteed to be
|
|
250
268
|
# reading, and a composed supergraph changes what the next steps are:
|
|
251
269
|
# the test client is the interesting one, and there's a CI gate to add.
|
data/lib/graph_weaver/client.rb
CHANGED
|
@@ -190,29 +190,6 @@ class GraphWeaver::Client
|
|
|
190
190
|
run(query, **variables).data!
|
|
191
191
|
end
|
|
192
192
|
|
|
193
|
-
# Does this query validate? The string form of GraphWeaver.check_queries,
|
|
194
|
-
# answering with the same JSON-ready hashes — `message`, `line`, `column`,
|
|
195
|
-
# plus `subgraphs` where a supergraph brands them — so an empty array means
|
|
196
|
-
# it validates:
|
|
197
|
-
#
|
|
198
|
-
# client.check_query("query { viewer { login } }") # => []
|
|
199
|
-
# client.check_query("query { viewer { lgoin } }")
|
|
200
|
-
# # => [{ "message" => "Field 'lgoin' doesn't exist on type 'User'",
|
|
201
|
-
# # "line" => 1, "column" => 17 }]
|
|
202
|
-
#
|
|
203
|
-
# Checked against this client's own schema — what `execute` would run
|
|
204
|
-
# against — so a url client introspects on first use as it always does, and
|
|
205
|
-
# nothing re-introspects the way check_queries defaults to. An unparseable
|
|
206
|
-
# source is an entry like any other; nothing here raises for a bad query.
|
|
207
|
-
# Shared fragments are inlined from fragments: the same way every other door
|
|
208
|
-
# inlines them.
|
|
209
|
-
def check_query(source, fragments: GraphWeaver.fragments_paths)
|
|
210
|
-
GraphWeaver::Internal::QueryCheck.errors(
|
|
211
|
-
schema, source, GraphWeaver::Codegen.load_fragments(fragments),
|
|
212
|
-
GraphWeaver::Internal::QueryCheck.routing_table_for(schema_source),
|
|
213
|
-
)
|
|
214
|
-
end
|
|
215
|
-
|
|
216
193
|
private
|
|
217
194
|
|
|
218
195
|
# Anything already speaking the client contract — another Client,
|
|
@@ -34,6 +34,13 @@ class GraphWeaver::Codegen
|
|
|
34
34
|
rescue UnknownSegment => e
|
|
35
35
|
# names nothing in the schema, so no selection would fit — offering
|
|
36
36
|
# optional: as the way out would just hide the typo
|
|
37
|
+
raise e.class, qualify(node, e.message)
|
|
38
|
+
rescue HoistedSegment => e
|
|
39
|
+
# optional: still skips it, but the message doesn't offer it: the two
|
|
40
|
+
# fixes it names are the only ones that end with an accessor, and no
|
|
41
|
+
# change to the fragment would make the path fit
|
|
42
|
+
next nil if spec[:optional]
|
|
43
|
+
|
|
37
44
|
raise e.class, qualify(node, e.message)
|
|
38
45
|
rescue GraphWeaver::Error => e
|
|
39
46
|
# a path that doesn't fit THIS query's selection: optional simply
|
|
@@ -86,7 +93,10 @@ class GraphWeaver::Codegen
|
|
|
86
93
|
# ever satisfy it, so it's a typo (or a wire-cased name), not a path that
|
|
87
94
|
# doesn't fit this query. optional: skips the latter, never this.
|
|
88
95
|
UnknownSegment = Class.new(GraphWeaver::Error)
|
|
89
|
-
|
|
96
|
+
|
|
97
|
+
# A segment inside a struct another module owns — see refuse_hoisted!.
|
|
98
|
+
HoistedSegment = Class.new(GraphWeaver::Error)
|
|
99
|
+
private_constant :ALIAS_RESERVED, :LIST_SELECTORS, :UnknownSegment, :HoistedSegment
|
|
90
100
|
|
|
91
101
|
# Walk a dotted path through this struct's selected shape, building the
|
|
92
102
|
# delegator expression (`meta&.tag`, `_entities.first&.name`) and its return
|
|
@@ -158,11 +168,13 @@ class GraphWeaver::Codegen
|
|
|
158
168
|
|
|
159
169
|
# A hop landing on a hoisted shared fragment: the struct is another
|
|
160
170
|
# module's, so the path stops here. Which query hoists is a property of how
|
|
161
|
-
# that query spreads the fragment, so optional: still skips it
|
|
171
|
+
# that query spreads the fragment, so optional: still skips it — but
|
|
172
|
+
# selecting differently inside the fragment never makes the path fit, so
|
|
173
|
+
# the message names only the two fixes that do.
|
|
162
174
|
def refuse_hoisted!(node, name, ref, seg)
|
|
163
175
|
return unless ref
|
|
164
176
|
|
|
165
|
-
raise
|
|
177
|
+
raise HoistedSegment,
|
|
166
178
|
"alias #{name.inspect} on #{node.graphql_type}: '#{seg}' is inside the shared fragment " \
|
|
167
179
|
"#{ref.class_name}, which hoists to #{@types_namespace}::#{ref.class_name} — a path can't " \
|
|
168
180
|
"read into it. Register the alias on #{ref.graphql_type}, or select a field beside the " \
|
|
@@ -521,10 +521,11 @@ class GraphWeaver::Codegen
|
|
|
521
521
|
flag ? "#{var.kwarg}: (#{flag} = true; nil)" : "#{var.kwarg}: nil"
|
|
522
522
|
} + ["client: nil"]
|
|
523
523
|
|
|
524
|
-
# QueryModule#dispatch reads QUERY/OPERATION_NAME/GRAPH off the module
|
|
525
|
-
#
|
|
526
|
-
#
|
|
527
|
-
|
|
524
|
+
# QueryModule#dispatch reads QUERY/OPERATION_NAME/GRAPH off the module
|
|
525
|
+
# and closes the operation event over the cast the block does, so the
|
|
526
|
+
# gem brackets the whole call — request and cast — without a line of it
|
|
527
|
+
# landing in every generated file
|
|
528
|
+
call = "dispatch(variables, client:) { |raw| from_response(raw) }"
|
|
528
529
|
|
|
529
530
|
# execute returns the full envelope; execute! is the strict shortcut for
|
|
530
531
|
# the typed result, or a raised QueryError.
|
|
@@ -534,7 +535,7 @@ class GraphWeaver::Codegen
|
|
|
534
535
|
out << " def self.execute(#{kwargs.join(", ")})"
|
|
535
536
|
emit_variables(out, required, optional, omitted)
|
|
536
537
|
out << ""
|
|
537
|
-
out << "
|
|
538
|
+
out << " #{call}"
|
|
538
539
|
out << " end"
|
|
539
540
|
out << ""
|
|
540
541
|
out << " sig { params(#{sig_params.join(", ")}).returns(Result).checked(:never) }"
|
|
@@ -547,7 +548,7 @@ class GraphWeaver::Codegen
|
|
|
547
548
|
# left out is exactly what a Ruby call can't forward
|
|
548
549
|
emit_variables(out, required, optional, omitted)
|
|
549
550
|
out << ""
|
|
550
|
-
out << "
|
|
551
|
+
out << " #{call}.data!"
|
|
551
552
|
end
|
|
552
553
|
out << " end"
|
|
553
554
|
out << ""
|
|
@@ -83,7 +83,31 @@ class GraphWeaver::Codegen
|
|
|
83
83
|
"add them, pin with map:, or absorb with fallback:"
|
|
84
84
|
end
|
|
85
85
|
|
|
86
|
-
|
|
86
|
+
wire = to_wire(from_wire, aliases)
|
|
87
|
+
warn_unsendable(wire)
|
|
88
|
+
[from_wire, wire]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The other direction, which nothing said: a member no wire value maps back
|
|
92
|
+
# from. A warning, not a refusal — one app-wide T::Enum serving two APIs
|
|
93
|
+
# that each expose a subset is a real shape, and the call that carries such
|
|
94
|
+
# a member is refused by name at runtime (InputStruct.enum_wire), so no
|
|
95
|
+
# wrong answer gets through either way. Warned on the channel
|
|
96
|
+
# validate_registrations! already narrates a mismatched registration on.
|
|
97
|
+
# fallback: is exempt: standing for what the schema doesn't declare is
|
|
98
|
+
# exactly what it is for.
|
|
99
|
+
def warn_unsendable(wire)
|
|
100
|
+
unsendable = type.values - wire.keys - [fallback].compact
|
|
101
|
+
return if unsendable.empty?
|
|
102
|
+
|
|
103
|
+
# a T::Enum member inspects as #<Type::Name>
|
|
104
|
+
names = unsendable.map { |member| member.inspect[2..-2] }.join(", ")
|
|
105
|
+
them = unsendable.one? ? "it" : "them"
|
|
106
|
+
GraphWeaver::Internal::Log.log(:warn) do
|
|
107
|
+
"#{graphql_name} has no value for #{type} member(s) #{names} — a kwarg carrying " \
|
|
108
|
+
"#{them} is refused at the call; map onto a matching T::Enum, or name the drift " \
|
|
109
|
+
"member with fallback:"
|
|
110
|
+
end
|
|
87
111
|
end
|
|
88
112
|
|
|
89
113
|
# alias spelling => the value it is read as, checked against what the
|
|
@@ -185,7 +209,7 @@ class GraphWeaver::Codegen
|
|
|
185
209
|
groups = ambiguous.values.map { |pairs| pairs.map(&:first) }
|
|
186
210
|
more = ambiguous.size - 1
|
|
187
211
|
raise GraphWeaver::Error,
|
|
188
|
-
"enum #{graphql_name}: #{groups.first.join(" and ")}
|
|
212
|
+
"enum #{graphql_name}: #{groups.first.join(" and ")} all map onto the #{type} member " \
|
|
189
213
|
"#{member.serialize.to_s.inspect}#{" (and #{more} more)" unless more.zero?} — say which spelling " \
|
|
190
214
|
"goes on the wire:\n #{EnumType.alias_suggestion(graphql_name, groups, type)}"
|
|
191
215
|
end
|
|
@@ -293,7 +293,9 @@ class GraphWeaver::Codegen
|
|
|
293
293
|
# #as_json. Without a fallback the table is total and a miss is a real
|
|
294
294
|
# mistake, so it still raises.
|
|
295
295
|
def serialize(expr, _depth)
|
|
296
|
-
|
|
296
|
+
unless @fallback
|
|
297
|
+
return "GraphWeaver::InputStruct.enum_wire(#{@graphql_name.inspect}, #{const_prefix}_TO_WIRE, #{expr})"
|
|
298
|
+
end
|
|
297
299
|
|
|
298
300
|
"#{const_prefix}_TO_WIRE.fetch(#{expr}) { |member| member.serialize }"
|
|
299
301
|
end
|
|
@@ -406,6 +408,23 @@ class GraphWeaver::Codegen
|
|
|
406
408
|
def bare_type = "#{class_name}::Type"
|
|
407
409
|
end
|
|
408
410
|
|
|
411
|
+
# An abstract fragment narrowing to one member hoists to that member's
|
|
412
|
+
# struct, and narrowing filters — so the match is tested at the reference,
|
|
413
|
+
# the way NarrowedNode does it, rather than inside a T::Struct.from_h that
|
|
414
|
+
# has no way to answer nil. See NarrowedNode for what `typename` being
|
|
415
|
+
# absent means.
|
|
416
|
+
class NarrowedRefNode < HoistedRefNode
|
|
417
|
+
def initialize(class_name, graphql_type = nil, typename: nil)
|
|
418
|
+
super(class_name, graphql_type)
|
|
419
|
+
@typename = typename
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def cast(expr, depth)
|
|
423
|
+
match = @typename ? "#{expr}[\"__typename\"] == #{@typename.inspect}" : "!#{expr}.empty?"
|
|
424
|
+
"(#{match} ? #{super} : nil)"
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
|
|
409
428
|
# An input-object variable: emitted as a module-level T::Struct whose
|
|
410
429
|
# serialize produces the wire hash. Inputs never cast FROM the wire.
|
|
411
430
|
# Joins the coerce protocol so execute kwargs accept plain hashes,
|
|
@@ -479,5 +498,5 @@ class GraphWeaver::Codegen
|
|
|
479
498
|
# from inside the walk.
|
|
480
499
|
private_constant :Node, :Scalar, :NonNull, :List, :ObjectNode, :EnumNode,
|
|
481
500
|
:MappedEnum, :NarrowedNode, :UnionNode, :HoistedRefNode, :UnionRefNode,
|
|
482
|
-
:InputNode, :RepresentationNode
|
|
501
|
+
:NarrowedRefNode, :InputNode, :RepresentationNode
|
|
483
502
|
end
|
data/lib/graph_weaver/codegen.rb
CHANGED
|
@@ -191,7 +191,7 @@ class GraphWeaver::Codegen
|
|
|
191
191
|
check_shared_collisions!(hoisted)
|
|
192
192
|
nodes.each do |node|
|
|
193
193
|
check_shadowing!(node)
|
|
194
|
-
check_abstract_mixins!(node)
|
|
194
|
+
check_abstract_mixins!(node, hoisted: true)
|
|
195
195
|
end
|
|
196
196
|
|
|
197
197
|
emit_types_files(nodes).tap { report_untyped_scalars }
|
|
@@ -216,30 +216,44 @@ class GraphWeaver::Codegen
|
|
|
216
216
|
|
|
217
217
|
fragment = fragments.fetch(name)
|
|
218
218
|
type = @schema.get_type(fragment.type.name)
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
219
|
+
selections = fragment.selections
|
|
220
|
+
return object_node(type, selections, class_name) if type.kind.object?
|
|
221
|
+
|
|
222
|
+
# the abstract shape is the fragment's own selections' to decide, exactly
|
|
223
|
+
# as it would be at a field spelling them inline — see abstract_shape
|
|
224
|
+
case (shape = abstract_shape(type, selections))
|
|
225
|
+
when :abstract_level then object_node(type, selections, class_name)
|
|
226
|
+
when :dispatch
|
|
227
|
+
members = union_members(type, selections)
|
|
228
|
+
UnionNode.new(class_name, members, catch_all_member(type, selections, members))
|
|
229
|
+
else
|
|
230
|
+
narrowing_tag!(shape, type, selections)
|
|
231
|
+
object_node(shape, selections, class_name)
|
|
232
|
+
end
|
|
223
233
|
end
|
|
224
234
|
private :hoisted_fragment
|
|
225
235
|
|
|
226
236
|
# Schema type names are unique, so an input and an enum can never land on the
|
|
227
237
|
# same name — but a hoisted type is named for its FRAGMENT, which the schema
|
|
228
|
-
# knows nothing about
|
|
229
|
-
#
|
|
238
|
+
# knows nothing about: two fragments camelize onto one name as readily as one
|
|
239
|
+
# lands on a type's. One shared module means one namespace, so either
|
|
240
|
+
# collision refuses rather than overwrites. Sorted, so which query file was
|
|
241
|
+
# walked first doesn't decide which fragment is named as the incumbent.
|
|
230
242
|
def check_shared_collisions!(names)
|
|
231
243
|
taken = {}
|
|
232
244
|
@enums.each { |graphql_name, node| taken[node.class_name] = "the schema enum #{graphql_name}" }
|
|
233
245
|
@mapped_enums.each_key { |graphql_name| taken[camelize(graphql_name)] = "the schema enum #{graphql_name}" }
|
|
234
246
|
@variable_inputs.each { |graphql_name, node| taken[node.class_name] = "the input type #{graphql_name}" }
|
|
235
247
|
|
|
236
|
-
names.each do |name|
|
|
248
|
+
names.uniq.sort.each do |name|
|
|
237
249
|
class_name = camelize(name)
|
|
238
|
-
claim = taken[class_name]
|
|
250
|
+
if (claim = taken[class_name])
|
|
251
|
+
raise GraphWeaver::Error,
|
|
252
|
+
"shared fragment #{name.inspect} hoists to #{@name}::#{class_name}, " \
|
|
253
|
+
"where #{claim} already generates — rename the fragment"
|
|
254
|
+
end
|
|
239
255
|
|
|
240
|
-
|
|
241
|
-
"shared fragment #{name.inspect} hoists to #{@name}::#{class_name}, " \
|
|
242
|
-
"where #{claim} already generates — rename the fragment"
|
|
256
|
+
taken[class_name] = "the shared fragment #{name.inspect}"
|
|
243
257
|
end
|
|
244
258
|
end
|
|
245
259
|
private :check_shared_collisions!
|
|
@@ -924,24 +938,37 @@ class GraphWeaver::Codegen
|
|
|
924
938
|
end
|
|
925
939
|
private_constant :AbstractField
|
|
926
940
|
|
|
927
|
-
#
|
|
928
|
-
#
|
|
941
|
+
# An abstract-typed field. Hoisting is asked FIRST, as it is for an object
|
|
942
|
+
# field: a whole field spread as one shared fragment is one shared type
|
|
943
|
+
# whatever shape that fragment has, and which shape it has must not be what
|
|
944
|
+
# decides — adding a second `... on` to a fragment would otherwise move every
|
|
945
|
+
# consumer's constant.
|
|
929
946
|
def abstract_field(field)
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
elsif (frag = hoistable_spread(field.core, field.selections))
|
|
939
|
-
hoisted_ref(field.type, frag)
|
|
940
|
-
else
|
|
941
|
-
dispatch_union(field)
|
|
947
|
+
if (frag = hoistable_spread(field.core, field.selections))
|
|
948
|
+
return hoisted_ref(field.type, frag)
|
|
949
|
+
end
|
|
950
|
+
|
|
951
|
+
case (shape = abstract_shape(field.core, field.selections))
|
|
952
|
+
when :abstract_level then abstract_level_struct(field)
|
|
953
|
+
when :dispatch then dispatch_union(field)
|
|
954
|
+
else narrowed_struct(field, shape)
|
|
942
955
|
end
|
|
943
956
|
end
|
|
944
957
|
|
|
958
|
+
# Which of three shapes an abstract selection generates, decided by the
|
|
959
|
+
# selection rather than the schema: :abstract_level when it names no concrete
|
|
960
|
+
# type, the one object member when it narrows to exactly that and nothing
|
|
961
|
+
# else, :dispatch otherwise. Both the hoisted type and every reference to it
|
|
962
|
+
# ask this, so the two can't disagree about what was built.
|
|
963
|
+
def abstract_shape(core, selections)
|
|
964
|
+
conditions = concrete_conditions(core, selections)
|
|
965
|
+
return :abstract_level if conditions.empty?
|
|
966
|
+
return :dispatch unless conditions.size == 1 && abstract_level_fields(core, selections).empty?
|
|
967
|
+
|
|
968
|
+
member = @schema.get_type(conditions.first)
|
|
969
|
+
member.kind.name == "OBJECT" ? member : :dispatch
|
|
970
|
+
end
|
|
971
|
+
|
|
945
972
|
# Every member carries the abstract-level fields, so one struct answers for
|
|
946
973
|
# all of them and there is nothing to dispatch on — for a union, the only
|
|
947
974
|
# selection that can get here is __typename.
|
|
@@ -951,34 +978,47 @@ class GraphWeaver::Codegen
|
|
|
951
978
|
end
|
|
952
979
|
|
|
953
980
|
# Narrowing to the one member a `... on X` names filters: the field is nil
|
|
954
|
-
# whenever the runtime type doesn't match.
|
|
955
|
-
# match is read off the tag; without one there is nothing to read but
|
|
956
|
-
# emptiness, and a fragment whose every field hides behind @skip/@include
|
|
957
|
-
# would make a real match indistinguishable from a miss ({} either way) —
|
|
958
|
-
# refuse rather than guess.
|
|
981
|
+
# whenever the runtime type doesn't match.
|
|
959
982
|
def narrowed_struct(field, member)
|
|
960
|
-
tag = member
|
|
961
|
-
unless tag || unconditional_field?(member, field.selections)
|
|
962
|
-
raise GraphWeaver::Error,
|
|
963
|
-
"narrowed `... on #{member.graphql_name}` needs at least one field not under " \
|
|
964
|
-
"@skip/@include (or a `__typename` to match on) — an all-conditional selection " \
|
|
965
|
-
"makes a match indistinguishable from nil"
|
|
966
|
-
end
|
|
967
|
-
|
|
983
|
+
tag = narrowing_tag!(member, field.core, field.selections)
|
|
968
984
|
name = pick_name(field.key, field.taken)
|
|
969
985
|
nilable_type_ref(field.type) { NarrowedNode.new(object_node(member, field.selections, name), typename: tag) }
|
|
970
986
|
end
|
|
971
987
|
|
|
988
|
+
# What a narrowed selection matches on: the member's name when `__typename`
|
|
989
|
+
# is selected, else nil for "the object came back empty". A fragment whose
|
|
990
|
+
# every field hides behind @skip/@include leaves nothing to read either way
|
|
991
|
+
# ({} on a match and on a miss alike) — refuse rather than guess.
|
|
992
|
+
def narrowing_tag!(member, core, selections)
|
|
993
|
+
return member.graphql_name if dispatchable_typename?(core, selections)
|
|
994
|
+
return if unconditional_field?(member, selections)
|
|
995
|
+
|
|
996
|
+
raise GraphWeaver::Error,
|
|
997
|
+
"narrowed `... on #{member.graphql_name}` needs at least one field not under " \
|
|
998
|
+
"@skip/@include (or a `__typename` to match on) — an all-conditional selection " \
|
|
999
|
+
"makes a match indistinguishable from nil"
|
|
1000
|
+
end
|
|
1001
|
+
|
|
972
1002
|
# A whole field spread as one named shared fragment points at the type
|
|
973
1003
|
# hoisted into the shared types module, so the same shape across queries is
|
|
974
|
-
# one Ruby type
|
|
1004
|
+
# one Ruby type. Which node stands in for it follows the shape hoisted_fragment
|
|
1005
|
+
# built: a dispatch module for a union, a struct otherwise — and a narrowed
|
|
1006
|
+
# one still filters, so the match is read here rather than inside a struct
|
|
1007
|
+
# whose from_h cannot answer nil.
|
|
975
1008
|
def hoisted_ref(field_type, frag)
|
|
976
1009
|
@used_fragments << frag unless @used_fragments.include?(frag)
|
|
977
|
-
# an abstract type hoists to a dispatch module, an object type to a struct
|
|
978
1010
|
core = field_type.unwrap
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
1011
|
+
name = camelize(frag)
|
|
1012
|
+
return type_ref(field_type) { HoistedRefNode.new(name, core.graphql_name) } if core.kind.object?
|
|
1013
|
+
|
|
1014
|
+
selections = @fragments.fetch(frag).selections
|
|
1015
|
+
case (shape = abstract_shape(core, selections))
|
|
1016
|
+
when :abstract_level then type_ref(field_type) { HoistedRefNode.new(name, core.graphql_name) }
|
|
1017
|
+
when :dispatch then type_ref(field_type) { UnionRefNode.new(name, core.graphql_name) }
|
|
1018
|
+
else
|
|
1019
|
+
tag = narrowing_tag!(shape, core, selections)
|
|
1020
|
+
nilable_type_ref(field_type) { NarrowedRefNode.new(name, core.graphql_name, typename: tag) }
|
|
1021
|
+
end
|
|
982
1022
|
end
|
|
983
1023
|
|
|
984
1024
|
# One member struct per type the selection names, chosen at runtime off
|
|
@@ -1500,7 +1540,7 @@ class GraphWeaver::Codegen
|
|
|
1500
1540
|
constant, group = collisions.first
|
|
1501
1541
|
more = collisions.size - 1
|
|
1502
1542
|
raise GraphWeaver::Error,
|
|
1503
|
-
"enum #{core.graphql_name} values #{group.join(" and ")}
|
|
1543
|
+
"enum #{core.graphql_name} values #{group.join(" and ")} all become the constant #{constant}" \
|
|
1504
1544
|
"#{" (and #{more} more colliding pair#{"s" if more > 1})" unless more.zero?} — if each pair is one " \
|
|
1505
1545
|
"value, say which spelling goes on the wire:\n " \
|
|
1506
1546
|
"#{EnumType.alias_suggestion(core.graphql_name, collisions.values)}\n" \
|
|
@@ -1549,19 +1589,23 @@ class GraphWeaver::Codegen
|
|
|
1549
1589
|
# query selecting a subset generated fine and failed in the app's own
|
|
1550
1590
|
# `srb tc`, two tools from the query that fell short. Walked from the root the
|
|
1551
1591
|
# way shadowing is, so the refusal can name the struct by its path.
|
|
1552
|
-
def check_abstract_mixins!(node, path = [])
|
|
1592
|
+
def check_abstract_mixins!(node, path = [], hoisted: false)
|
|
1553
1593
|
case node
|
|
1554
1594
|
when UnionNode
|
|
1555
1595
|
inner = path + [node.class_name]
|
|
1556
|
-
(node.members.each_value.to_a + [node.catch_all]).each
|
|
1596
|
+
(node.members.each_value.to_a + [node.catch_all]).each do |member|
|
|
1597
|
+
check_abstract_mixins!(member, inner, hoisted:)
|
|
1598
|
+
end
|
|
1557
1599
|
when ObjectNode
|
|
1558
1600
|
inner = path + [node.class_name]
|
|
1559
|
-
refuse_unsatisfiable_mixin!(node, inner)
|
|
1560
|
-
node.fields.each
|
|
1601
|
+
refuse_unsatisfiable_mixin!(node, inner, hoisted:)
|
|
1602
|
+
node.fields.each do |field|
|
|
1603
|
+
check_abstract_mixins!(field.node.nested, inner, hoisted:) if field.node.nested
|
|
1604
|
+
end
|
|
1561
1605
|
end
|
|
1562
1606
|
end
|
|
1563
1607
|
|
|
1564
|
-
def refuse_unsatisfiable_mixin!(node, path)
|
|
1608
|
+
def refuse_unsatisfiable_mixin!(node, path, hoisted:)
|
|
1565
1609
|
return if node.overrides.empty?
|
|
1566
1610
|
|
|
1567
1611
|
mixins = @registry.type_registry.dig(node.graphql_type, :mixins) || []
|
|
@@ -1577,13 +1621,21 @@ class GraphWeaver::Codegen
|
|
|
1577
1621
|
mixin = mixins.find { |m|
|
|
1578
1622
|
T::AbstractUtils.declared_abstract_methods_for(m).any? { |method| missing.include?(method.name.to_s) }
|
|
1579
1623
|
}
|
|
1624
|
+
them = missing.one? ? "it" : "them"
|
|
1625
|
+
# the second door is "hoist it", which a struct built inside the shared
|
|
1626
|
+
# types module has already been through
|
|
1627
|
+
fix = if hoisted
|
|
1628
|
+
"Select #{them} in the fragment."
|
|
1629
|
+
else
|
|
1630
|
+
"Select #{them} here, or select #{node.graphql_type} through one shared " \
|
|
1631
|
+
"fragment (`{ ...Frag }`), which hoists one struct for every query to share."
|
|
1632
|
+
end
|
|
1633
|
+
|
|
1580
1634
|
raise GraphWeaver::Error,
|
|
1581
1635
|
"#{[@name, *path].join("::")} includes #{mixin.name}, which declares " \
|
|
1582
1636
|
"#{GraphWeaver::Internal::Util.sample(missing.map(&:inspect))} abstract — this selection does " \
|
|
1583
|
-
"not provide #{
|
|
1584
|
-
"#{node.graphql_type} includes the mixin, so `srb tc` fails on this one.
|
|
1585
|
-
"#{missing.one? ? "it" : "them"} here, or select #{node.graphql_type} through one shared " \
|
|
1586
|
-
"fragment (`{ ...Frag }`), which hoists one struct for every query to share."
|
|
1637
|
+
"not provide #{them}, and every struct generated from " \
|
|
1638
|
+
"#{node.graphql_type} includes the mixin, so `srb tc` fails on this one. #{fix}"
|
|
1587
1639
|
end
|
|
1588
1640
|
private :check_abstract_mixins!, :refuse_unsatisfiable_mixin!
|
|
1589
1641
|
|
data/lib/graph_weaver/graph.rb
CHANGED
|
@@ -93,8 +93,19 @@ module GraphWeaver
|
|
|
93
93
|
# constant; this is the one knob that settles both.
|
|
94
94
|
def namespace = @namespace
|
|
95
95
|
|
|
96
|
+
# The shared types module. Inside a namespace the default is plain `Types`,
|
|
97
|
+
# so `namespace:` alone leaves a tree Zeitwerk names correctly — types.rb
|
|
98
|
+
# holds it, and Zeitwerk reads that file as `<Namespace>::Types`. At the top
|
|
99
|
+
# level it stays GraphQLTypes, where a bare ::Types would collide with
|
|
100
|
+
# graphql-ruby's own Types:: convention.
|
|
101
|
+
NESTED_TYPES_MODULE = "Types"
|
|
102
|
+
private_constant :NESTED_TYPES_MODULE
|
|
103
|
+
|
|
96
104
|
def types_module
|
|
97
|
-
@types_module
|
|
105
|
+
return @types_module if @types_module
|
|
106
|
+
return GraphWeaver.types_module unless namespace
|
|
107
|
+
|
|
108
|
+
"#{namespace}::#{GraphWeaver.types_module(NESTED_TYPES_MODULE)}"
|
|
98
109
|
end
|
|
99
110
|
|
|
100
111
|
# The graphql-ruby schema, however it was named: a class, a Client, a path
|
|
@@ -152,15 +163,48 @@ module GraphWeaver
|
|
|
152
163
|
# read a source off, so the source is where its modules already post.
|
|
153
164
|
# That is what bootstraps a second graph's dump: URL= names one
|
|
154
165
|
# endpoint, and each graph has its own.
|
|
155
|
-
|
|
156
|
-
|
|
166
|
+
source = path ? GraphWeaver::SchemaLoader.provenance(path)&.dig("url") : (client_source if named_dump_path)
|
|
167
|
+
source || live_schema
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# The server behind this graph, however it is named: the url the dump
|
|
171
|
+
# recorded, else the graphql-ruby class this process runs, else whatever
|
|
172
|
+
# this graph's client reaches. nil when there is nothing to ask — a dump
|
|
173
|
+
# with no provenance, and no client with a server behind it.
|
|
174
|
+
#
|
|
175
|
+
# The one source rule the three schema tasks share: `schema:refresh`
|
|
176
|
+
# rewrites the dump from it, `schema:diff` says how far it has moved, and
|
|
177
|
+
# `queries:check` validates the queries against it.
|
|
178
|
+
def source = dump_source || client_source
|
|
179
|
+
|
|
180
|
+
# The server this graph's client reaches, as something to ask: the url it
|
|
181
|
+
# posts to, else the graphql-ruby class it runs in-process. A client that
|
|
182
|
+
# runs one names a server as surely as a client that posts to one — and
|
|
183
|
+
# reading only the url is what let `source_transport` reach a schema class
|
|
184
|
+
# that `source` said wasn't there.
|
|
185
|
+
def client_source
|
|
186
|
+
client_url || GraphWeaver::Internal::Util.live_schema(client || GraphWeaver.client)
|
|
187
|
+
end
|
|
188
|
+
private :client_source
|
|
189
|
+
|
|
190
|
+
# How to reach that source. A schema class answers introspection itself. A
|
|
191
|
+
# url the dump recorded goes through SchemaLoader, which authenticates from
|
|
192
|
+
# the auth_env the dump named. A url that came from the graph's client is
|
|
193
|
+
# that client's own transport — headers, auth and all.
|
|
194
|
+
def source_transport
|
|
195
|
+
found = source
|
|
196
|
+
return found if found.is_a?(Module)
|
|
197
|
+
return GraphWeaver::SchemaLoader.source_transport(dump_path) if dump_source
|
|
198
|
+
|
|
199
|
+
target = client || GraphWeaver.client
|
|
200
|
+
target.respond_to?(:transport) ? target.transport : target
|
|
157
201
|
end
|
|
158
202
|
|
|
159
203
|
# The url this graph's modules post to, or nil — the graph's own client,
|
|
160
|
-
# else the app default, which is where its modules go too. What
|
|
161
|
-
# `schema:refresh` bootstraps a missing dump from, and what `rake
|
|
204
|
+
# else the app default, which is where its modules go too. What `rake
|
|
162
205
|
# graph_weaver:graphs` reports; a client with no url (a schema class
|
|
163
|
-
# running in-process) has none to report
|
|
206
|
+
# running in-process) has none to report, and `client_source` is the
|
|
207
|
+
# question that covers both.
|
|
164
208
|
def client_url
|
|
165
209
|
target = client || GraphWeaver.client
|
|
166
210
|
target = (target.transport if target.respond_to?(:transport)) || target
|
|
@@ -248,6 +292,7 @@ module GraphWeaver
|
|
|
248
292
|
def self.build(name, &block)
|
|
249
293
|
builder = new(name)
|
|
250
294
|
builder.instance_eval(&block)
|
|
295
|
+
refuse_callable_client!(name, builder.settings[:client])
|
|
251
296
|
graph = GraphWeaver::Graph.new(name:, registrations: builder.registrations, **builder.settings)
|
|
252
297
|
# the block's registrations are applied at generation; run them once
|
|
253
298
|
# here so a bad one is a mistake in the block, said where it is written
|
|
@@ -265,6 +310,22 @@ module GraphWeaver
|
|
|
265
310
|
"Declare graph #{name.inspect} from one.", e.backtrace
|
|
266
311
|
end
|
|
267
312
|
|
|
313
|
+
# `schema` takes a callable — that is how an initializer names a class
|
|
314
|
+
# Zeitwerk hasn't loaded — and the same page says so, which makes one
|
|
315
|
+
# here an easy mistake. It was accepted, and surfaced much later as
|
|
316
|
+
# `undefined method 'execute' for an instance of Proc` under a rake
|
|
317
|
+
# backtrace. A client needs no lambda: the string form names the
|
|
318
|
+
# constant and is resolved when a module calls it.
|
|
319
|
+
def self.refuse_callable_client!(name, client)
|
|
320
|
+
return unless client.respond_to?(:call) && !client.respond_to?(:execute)
|
|
321
|
+
|
|
322
|
+
raise ArgumentError, "client in graph #{name.inspect} takes the object your modules " \
|
|
323
|
+
"call, and a callable doesn't answer #execute — name the object (client " \
|
|
324
|
+
"GraphWeaver.new(\"https://api.example.com/graphql\")), or its constant (client " \
|
|
325
|
+
"\"Billing::Schema\"), which is resolved when a module calls it and so needs no lambda"
|
|
326
|
+
end
|
|
327
|
+
private_class_method :refuse_callable_client!
|
|
328
|
+
|
|
268
329
|
def initialize(name)
|
|
269
330
|
@name = name
|
|
270
331
|
@settings = {}
|
|
@@ -55,7 +55,7 @@ class GraphWeaver::InProcess
|
|
|
55
55
|
payload = { url: nil, schema: schema_label, operation: operation_name, client: self.class,
|
|
56
56
|
kind: GraphWeaver::Internal::Wire.kind(query) }
|
|
57
57
|
|
|
58
|
-
GraphWeaver::Internal::Log.
|
|
58
|
+
GraphWeaver::Internal::Log.instrument_request(payload) do
|
|
59
59
|
perform(query, variables, operation_name)
|
|
60
60
|
end
|
|
61
61
|
end
|