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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +43 -43
  3. data/docs/errors.md +7 -3
  4. data/docs/generated_modules.md +66 -16
  5. data/docs/getting_started.md +39 -19
  6. data/docs/logging.md +79 -35
  7. data/docs/migrating.md +11 -4
  8. data/docs/scalars.md +12 -3
  9. data/docs/testing.md +75 -13
  10. data/docs/upgrading.md +43 -3
  11. data/examples/README.md +4 -2
  12. data/examples/github/generate.rb +22 -8
  13. data/examples/github/generated/star_mutation.rb +2 -2
  14. data/examples/github/generated/stargazers_query.rb +2 -2
  15. data/examples/github/generated/starred_query.rb +2 -2
  16. data/examples/github/run.rb +1 -0
  17. data/examples/github/setup.rb +16 -8
  18. data/graph_weaver.gemspec +15 -6
  19. data/lib/generators/graph_weaver/install_generator.rb +20 -2
  20. data/lib/graph_weaver/client.rb +0 -23
  21. data/lib/graph_weaver/codegen/aliases.rb +15 -3
  22. data/lib/graph_weaver/codegen/emit.rb +7 -6
  23. data/lib/graph_weaver/codegen/enum_type.rb +26 -2
  24. data/lib/graph_weaver/codegen/nodes.rb +21 -2
  25. data/lib/graph_weaver/codegen.rb +106 -54
  26. data/lib/graph_weaver/graph.rb +67 -6
  27. data/lib/graph_weaver/in_process.rb +1 -1
  28. data/lib/graph_weaver/input_struct.rb +29 -4
  29. data/lib/graph_weaver/internal/overrides.rb +126 -14
  30. data/lib/graph_weaver/internal/test_clients.rb +29 -7
  31. data/lib/graph_weaver/internal/unused.rb +30 -11
  32. data/lib/graph_weaver/internal/values.rb +17 -5
  33. data/lib/graph_weaver/internal.rb +23 -6
  34. data/lib/graph_weaver/log_subscriber.rb +27 -17
  35. data/lib/graph_weaver/logging.rb +89 -53
  36. data/lib/graph_weaver/parsing.rb +32 -3
  37. data/lib/graph_weaver/query_module.rb +48 -8
  38. data/lib/graph_weaver/railtie.rb +250 -11
  39. data/lib/graph_weaver/rspec.rb +41 -17
  40. data/lib/graph_weaver/schema_diff.rb +24 -5
  41. data/lib/graph_weaver/schema_loader.rb +22 -9
  42. data/lib/graph_weaver/tasks.rb +71 -44
  43. data/lib/graph_weaver/testing/fake_client.rb +24 -21
  44. data/lib/graph_weaver/testing.rb +27 -7
  45. data/lib/graph_weaver/transport.rb +1 -1
  46. data/lib/graph_weaver/version.rb +1 -1
  47. data/lib/graph_weaver.rb +76 -43
  48. 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
- s.files = `git ls-files * ':!:spec' ':!:sorbet' ':!:bin' \
18
- ':!:CLAUDE.md' ':!:PLAN.md' ':!:REVIEW.md' ':!:NOTES.md' \
19
- ':!:DECISIONS.md' ':!:CHANGELOG.md' ':!:Makefile' ':!:design' \
20
- ':!:research'`.split("\n") + [".yardopts"]
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
- # already marked — a re-run, or done by hand
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.
@@ -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
- private_constant :ALIAS_RESERVED, :LIST_SELECTORS, :UnknownSegment
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 GraphWeaver::Error,
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
- # so the gem gets to bracket every request without a line of it landing
526
- # in every generated file
527
- call = "dispatch(variables, client:)"
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 << " from_response(#{call})"
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 << " from_response(#{call}).data!"
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
- [from_wire, to_wire(from_wire, aliases)]
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 ")} both map onto the #{type} member " \
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
- return "#{const_prefix}_TO_WIRE.fetch(#{expr})" unless @fallback
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
@@ -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
- return object_node(type, fragment.selections, class_name) if type.kind.object?
220
-
221
- members = union_members(type, fragment.selections)
222
- UnionNode.new(class_name, members, catch_all_member(type, fragment.selections, members))
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. One shared module means one namespace, so a fragment
229
- # named after a type it doesn't describe has to refuse rather than overwrite.
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] or next
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
- raise GraphWeaver::Error,
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
- # Which of four shapes an abstract-typed field generates. The selection
928
- # decides, not the schema: what it narrows to, and how it was spread.
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
- conditions = concrete_conditions(field.core, field.selections)
931
- shared = abstract_level_fields(field.core, field.selections)
932
-
933
- if conditions.empty?
934
- abstract_level_struct(field)
935
- elsif conditions.size == 1 && shared.empty? &&
936
- (member = @schema.get_type(conditions.first)).kind.name == "OBJECT"
937
- narrowed_struct(field, member)
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. With `__typename` selected the
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.graphql_name if dispatchable_typename?(field.core, field.selections)
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 — for a union, one exhaustive `case ... T.absurd`.
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
- node_class = core.kind.object? ? HoistedRefNode : UnionRefNode
980
- ref = node_class.new(camelize(frag), core.graphql_name)
981
- type_ref(field_type) { ref }
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 ")} both become the constant #{constant}" \
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 { |member| check_abstract_mixins!(member, inner) }
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 { |field| check_abstract_mixins!(field.node.nested, inner) if field.node.nested }
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 #{missing.one? ? "it" : "them"}, and every struct generated from " \
1584
- "#{node.graphql_type} includes the mixin, so `srb tc` fails on this one. Select " \
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
 
@@ -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 || (namespace ? "#{namespace}::#{GraphWeaver.types_module}" : GraphWeaver.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
- url = path ? GraphWeaver::SchemaLoader.provenance(path)&.dig("url") : (client_url if named_dump_path)
156
- url || live_schema
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.instrument(GraphWeaver::EXECUTE_EVENT, payload) do
58
+ GraphWeaver::Internal::Log.instrument_request(payload) do
59
59
  perform(query, variables, operation_name)
60
60
  end
61
61
  end