graph_weaver 0.7.5 → 0.7.6

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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +2 -2
  3. data/docs/errors.md +7 -3
  4. data/docs/generated_modules.md +10 -6
  5. data/docs/getting_started.md +34 -17
  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 +25 -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 +55 -5
  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 +6 -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/rspec.rb +41 -17
  39. data/lib/graph_weaver/schema_diff.rb +24 -5
  40. data/lib/graph_weaver/schema_loader.rb +22 -9
  41. data/lib/graph_weaver/tasks.rb +71 -44
  42. data/lib/graph_weaver/testing/fake_client.rb +24 -21
  43. data/lib/graph_weaver/testing.rb +27 -7
  44. data/lib/graph_weaver/transport.rb +1 -1
  45. data/lib/graph_weaver/version.rb +1 -1
  46. data/lib/graph_weaver.rb +59 -35
  47. metadata +2 -2
@@ -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
 
@@ -152,15 +152,48 @@ module GraphWeaver
152
152
  # read a source off, so the source is where its modules already post.
153
153
  # That is what bootstraps a second graph's dump: URL= names one
154
154
  # 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
155
+ source = path ? GraphWeaver::SchemaLoader.provenance(path)&.dig("url") : (client_source if named_dump_path)
156
+ source || live_schema
157
+ end
158
+
159
+ # The server behind this graph, however it is named: the url the dump
160
+ # recorded, else the graphql-ruby class this process runs, else whatever
161
+ # this graph's client reaches. nil when there is nothing to ask — a dump
162
+ # with no provenance, and no client with a server behind it.
163
+ #
164
+ # The one source rule the three schema tasks share: `schema:refresh`
165
+ # rewrites the dump from it, `schema:diff` says how far it has moved, and
166
+ # `queries:check` validates the queries against it.
167
+ def source = dump_source || client_source
168
+
169
+ # The server this graph's client reaches, as something to ask: the url it
170
+ # posts to, else the graphql-ruby class it runs in-process. A client that
171
+ # runs one names a server as surely as a client that posts to one — and
172
+ # reading only the url is what let `source_transport` reach a schema class
173
+ # that `source` said wasn't there.
174
+ def client_source
175
+ client_url || GraphWeaver::Internal::Util.live_schema(client || GraphWeaver.client)
176
+ end
177
+ private :client_source
178
+
179
+ # How to reach that source. A schema class answers introspection itself. A
180
+ # url the dump recorded goes through SchemaLoader, which authenticates from
181
+ # the auth_env the dump named. A url that came from the graph's client is
182
+ # that client's own transport — headers, auth and all.
183
+ def source_transport
184
+ found = source
185
+ return found if found.is_a?(Module)
186
+ return GraphWeaver::SchemaLoader.source_transport(dump_path) if dump_source
187
+
188
+ target = client || GraphWeaver.client
189
+ target.respond_to?(:transport) ? target.transport : target
157
190
  end
158
191
 
159
192
  # 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
193
+ # else the app default, which is where its modules go too. What `rake
162
194
  # graph_weaver:graphs` reports; a client with no url (a schema class
163
- # running in-process) has none to report.
195
+ # running in-process) has none to report, and `client_source` is the
196
+ # question that covers both.
164
197
  def client_url
165
198
  target = client || GraphWeaver.client
166
199
  target = (target.transport if target.respond_to?(:transport)) || target
@@ -248,6 +281,7 @@ module GraphWeaver
248
281
  def self.build(name, &block)
249
282
  builder = new(name)
250
283
  builder.instance_eval(&block)
284
+ refuse_callable_client!(name, builder.settings[:client])
251
285
  graph = GraphWeaver::Graph.new(name:, registrations: builder.registrations, **builder.settings)
252
286
  # the block's registrations are applied at generation; run them once
253
287
  # here so a bad one is a mistake in the block, said where it is written
@@ -265,6 +299,22 @@ module GraphWeaver
265
299
  "Declare graph #{name.inspect} from one.", e.backtrace
266
300
  end
267
301
 
302
+ # `schema` takes a callable — that is how an initializer names a class
303
+ # Zeitwerk hasn't loaded — and the same page says so, which makes one
304
+ # here an easy mistake. It was accepted, and surfaced much later as
305
+ # `undefined method 'execute' for an instance of Proc` under a rake
306
+ # backtrace. A client needs no lambda: the string form names the
307
+ # constant and is resolved when a module calls it.
308
+ def self.refuse_callable_client!(name, client)
309
+ return unless client.respond_to?(:call) && !client.respond_to?(:execute)
310
+
311
+ raise ArgumentError, "client in graph #{name.inspect} takes the object your modules " \
312
+ "call, and a callable doesn't answer #execute — name the object (client " \
313
+ "GraphWeaver.new(\"https://api.example.com/graphql\")), or its constant (client " \
314
+ "\"Billing::Schema\"), which is resolved when a module calls it and so needs no lambda"
315
+ end
316
+ private_class_method :refuse_callable_client!
317
+
268
318
  def initialize(name)
269
319
  @name = name
270
320
  @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
@@ -24,7 +24,13 @@ module GraphWeaver
24
24
  # is the schema's name for the slot ("PetFilter.species") and type its
25
25
  # spelling of what goes there ("[Float!]!"), so a refusal can say where
26
26
  # it happened, and in whose vocabulary, without reflecting at runtime.
27
- Field = Data.define(:prop, :wire, :required, :serializer, :coercer, :coordinate, :type)
27
+ Field = Data.define(:prop, :wire, :required, :serializer, :coercer, :coordinate, :type) do
28
+ # How a message names this field. The prop is what you type in Ruby, so
29
+ # it leads; the wire name is what you grep the .graphql for, so it comes
30
+ # along where the two differ. Nothing structured reads this — #path,
31
+ # #field and #coordinate are the schema's spelling either way.
32
+ def label = (wire == prop.to_s) ? prop.to_s : "#{prop} (#{wire})"
33
+ end
28
34
 
29
35
  # An enum reaching the library as input — an execute kwarg or an input
30
36
  # field — as the member or its wire value. Generated code calls these
@@ -70,6 +76,25 @@ module GraphWeaver
70
76
  table.fetch(value) { invalid_enum!(type, value, table.keys) }
71
77
  end
72
78
 
79
+ # The way back out: a member => the wire value it sends as. Generation
80
+ # makes that table total, so a miss is a T::Enum that grew a member since
81
+ # — and Hash#fetch's KeyError named the anonymous table rather than the
82
+ # member or anything it could have sent. Also the result side's `as_json`,
83
+ # where nothing wraps a raised KeyError into a GraphWeaver error at all.
84
+ def self.enum_wire(graphql_name, table, member)
85
+ table.fetch(member) do
86
+ raise GraphWeaver::Internal::Refusal.brand(
87
+ GraphWeaver::Error.new(
88
+ # a T::Enum member inspects as #<Type::Name>
89
+ "#{member.inspect[2..-2]} maps onto no #{graphql_name} value, so there is nothing to " \
90
+ "send for it — expected one of: #{table.values.sort.join(", ")}; a member added since " \
91
+ "you generated needs a regenerate",
92
+ ),
93
+ :not_a_member, members: table.values.sort,
94
+ )
95
+ end
96
+ end
97
+
73
98
  # Names the input field a coercion refused — a scalar's coercer, an
74
99
  # enum's, or a nested input's — since the complaint underneath is about
75
100
  # the value alone. A nested error that already named a field keeps its
@@ -83,7 +108,7 @@ module GraphWeaver
83
108
  raise e.within(field.wire, prop:) if e.field && !redact.filtered?(prop)
84
109
 
85
110
  raise GraphWeaver::InputError.new(
86
- "#{prop}: #{redact.detail(prop, e.message)}",
111
+ "#{field.label}: #{redact.detail(prop, e.message)}",
87
112
  kind: e.kind, path: [field.wire, *e.path], coordinate: e.coordinate || field.coordinate,
88
113
  # #value is the value AT #path: this layer owns it only when nothing
89
114
  # inner named a field (so a missing one stays valueless, as it is)
@@ -93,7 +118,7 @@ module GraphWeaver
93
118
  rescue StandardError => e
94
119
  redact = GraphWeaver::Internal::Redact
95
120
  raise GraphWeaver::InputError.new(
96
- "#{prop}: #{redact.detail(prop, e.message)}",
121
+ "#{field.label}: #{redact.detail(prop, e.message)}",
97
122
  kind: GraphWeaver::Internal::Refusal.kind_of(e), path: [field.wire],
98
123
  coordinate: field.coordinate, value: redact.value(prop, raw),
99
124
  details: GraphWeaver::Internal::Refusal.details_of(e), struct:,
@@ -284,7 +309,7 @@ module GraphWeaver
284
309
  field = T.unsafe(self).const_get(:FIELDS).find { |f| f.prop == prop }
285
310
  type = field&.type || T::Utils.coerce(info[:type]).to_s
286
311
  return GraphWeaver::InputError.new(
287
- "#{prop}: expected #{type}, got #{GraphWeaver::Internal::Redact.shown(value, prop)}",
312
+ "#{field&.label || prop}: expected #{type}, got #{GraphWeaver::Internal::Redact.shown(value, prop)}",
288
313
  kind: :type_mismatch, path: [prop.to_s], coordinate: field&.coordinate,
289
314
  value: GraphWeaver::Internal::Redact.value(prop, value),
290
315
  details: { type: }, struct: self,
@@ -10,9 +10,9 @@ module GraphWeaver
10
10
  # suite-wide ones while the block that set them is still on the stack,
11
11
  # and a fake built with its own checks them then.
12
12
  module Overrides
13
- # The key a Hash `list_size:` says its fallback under — every list it
14
- # doesn't name.
15
- LIST_SIZE_DEFAULT = "default"
13
+ # The key a per-field Hash (`list_size:`, `null_chance:`) says its
14
+ # fallback under — everything it doesn't name.
15
+ DEFAULT_KEY = "default"
16
16
 
17
17
  class << self
18
18
  # A pin key names something in the schema: a type ("Money",
@@ -51,16 +51,22 @@ module GraphWeaver
51
51
  # minus the bare type name: a type says nothing about how long any one
52
52
  # of its fields is.
53
53
  def validate_list_size!(schema, list_size)
54
- return unless list_size.is_a?(Hash)
55
-
56
- list_size.each do |key, value|
57
- unless value.is_a?(Integer) || value.is_a?(Range)
58
- raise GraphWeaver::Error, "list_size: #{key.to_s.inspect} must be an Integer or a " \
59
- "Range — how long an unbounded list is — got #{value.inspect}"
60
- end
61
- next if key.to_s == LIST_SIZE_DEFAULT
54
+ reaches = ->(type) { type.list? }
55
+ validate_per_field!(schema, list_size, "list_size",
56
+ reaches:, unreached: "is not a list and has no length to set — name a list field") do |value|
57
+ "an Integer or a Range of them, neither negative — how long an unbounded list is" unless
58
+ length?(value)
59
+ end
60
+ end
62
61
 
63
- validate_field_key!(schema, key.to_s, "list_size: key")
62
+ # A Hash `null_chance:` is keyed the same way, one nullable field at a
63
+ # time.
64
+ def validate_null_chance!(schema, null_chance)
65
+ reaches = method(:nullable_anywhere?)
66
+ validate_per_field!(schema, null_chance, "null_chance",
67
+ reaches:, unreached: "can never come back null — name a nullable field") do |value|
68
+ "a number from 0 to 1 — how often a nullable field comes back null" unless
69
+ value.is_a?(Numeric) && (0..1).cover?(value)
64
70
  end
65
71
  end
66
72
 
@@ -75,6 +81,83 @@ module GraphWeaver
75
81
 
76
82
  private
77
83
 
84
+ # The two shapes both per-field options take: one value for every
85
+ # field, or a Hash keyed by field — a "Type.field" coordinate or a
86
+ # bare field name — with DEFAULT_KEY for the rest. The block says
87
+ # what a value has to be, in the words the refusal uses, and says it
88
+ # of both shapes: a plain `null_chance: 7` used to sail through and
89
+ # null everything, a plain `list_size: "3"` to die inside the
90
+ # fabricator.
91
+ def validate_per_field!(schema, option, name, reaches:, unreached:)
92
+ unless option.is_a?(Hash)
93
+ refuse_value!(name, nil, option, yield(option))
94
+ return
95
+ end
96
+
97
+ option.each do |key, value|
98
+ refuse_value!(name, key, value, yield(value))
99
+ next if key.to_s == DEFAULT_KEY
100
+
101
+ label = "#{name}: key"
102
+ validate_field_key!(schema, key.to_s, label)
103
+ reaches!(schema, key.to_s, label, reaches, unreached)
104
+ end
105
+ end
106
+
107
+ # A key naming a field the option can never reach is inert, which is
108
+ # the silent green every other key check exists to stop: the
109
+ # fabricator asks `null_chance` at nullable positions only and
110
+ # `list_size` at lists only, so it never looks this key up.
111
+ def reaches!(schema, key, label, reaches, unreached)
112
+ types = field_types(schema, key)
113
+ return if types.empty? || types.any? { |type| reaches.call(type) }
114
+
115
+ spelled = types.map(&:to_type_signature).uniq.sort.join(", ")
116
+ raise GraphWeaver::Error, "#{label} #{key.inspect} (#{spelled}) #{unreached}, " \
117
+ "or drop the key"
118
+ end
119
+
120
+ # Every field a per-field key names: the one a coordinate points at,
121
+ # or every field of that name in the schema for a bare one.
122
+ def field_types(schema, key)
123
+ type_name, field_name = key.split(".", 2)
124
+ # introspection fields (__typename) are real but absent from #fields
125
+ return [] if (field_name || type_name).start_with?("__")
126
+ return [schema.get_type(type_name).fields.fetch(field_name).type] if field_name
127
+
128
+ schema.types.each_value.filter_map do |type|
129
+ type.fields[type_name]&.type if type.respond_to?(:fields)
130
+ end
131
+ end
132
+
133
+ # Whether null_chance has a position in this type to reach. The outer
134
+ # wrapper isn't the whole answer: `[Pet]!` is non-null and its ELEMENTS
135
+ # are nullable, which is where the fabricator puts the nulls.
136
+ def nullable_anywhere?(type)
137
+ return true unless type.non_null?
138
+
139
+ inner = type.of_type
140
+ inner.list? && nullable_anywhere?(inner.of_type)
141
+ end
142
+
143
+ def refuse_value!(name, key, value, wanted)
144
+ return unless wanted
145
+
146
+ raise GraphWeaver::Error, "#{name}:#{" #{key.to_s.inspect}" if key} must be #{wanted} — " \
147
+ "got #{value.inspect}"
148
+ end
149
+
150
+ # A length is a count the fabricator can build an Array of: Array.new(-1)
151
+ # is "negative array size" out of its guts, and a Range the seeded rng
152
+ # can't sample (endless, or beginless) is worse.
153
+ def length?(value)
154
+ case value
155
+ when Integer then !value.negative?
156
+ when Range then [value.begin, value.end].all? { |edge| edge.is_a?(Integer) && !edge.negative? }
157
+ else false
158
+ end
159
+ end
160
+
78
161
  # A proc taking anything else can't be called at fabrication time,
79
162
  # and the ArgumentError it would raise there names no pin.
80
163
  def validate_arity!(key, value)
@@ -113,20 +196,49 @@ module GraphWeaver
113
196
  known = field_names(schema)
114
197
  return if known.include?(type_name)
115
198
 
199
+ # a type name reads like a reasonable key here — `null_chance: {
200
+ # "Person" => 1.0 }` looks like "null the whole subtree" — and
201
+ # did_you_mean sent it to the nearest FIELD ('person') instead of
202
+ # saying these options are keyed by field
203
+ named = schema.get_type(type_name)
204
+ type_key!(label, key, named) if named
116
205
  bad!(label, key, "matches no field in this schema", known, type_name)
117
206
  end
118
207
 
119
208
  coordinate!(schema, label, key, type_name, field_name)
120
209
  end
121
210
 
211
+ def type_key!(label, key, type)
212
+ reach = type.respond_to?(:fields) ? "#{type.graphql_name}.<field>".inspect : "a \"Type.field\" coordinate"
213
+ raise GraphWeaver::Error, "#{label} #{key.inspect} names " \
214
+ "#{type.kind.name.downcase.tr("_", " ")} #{type.graphql_name}, and a key here names " \
215
+ "one field — #{reach}, or a bare field name"
216
+ end
217
+
122
218
  def coordinate!(schema, label, key, type_name, field_name)
123
219
  type = schema.get_type(type_name)
124
220
  unless type.respond_to?(:fields)
125
221
  bad!(label, key, "names no object type in this schema", schema.types.keys, type_name)
126
222
  end
127
- return if type.fields.key?(field_name)
223
+ unless type.fields.key?(field_name)
224
+ bad!(label, key, "is not a field of #{type_name}", type.fields.keys, field_name)
225
+ end
226
+
227
+ concrete!(schema, label, key, type, field_name)
228
+ end
229
+
230
+ # The abstract-type refusal, in the coordinate form. An interface
231
+ # declares the field, so "Named.name" reads as a key that must work
232
+ # — and it matches nothing: the walk picks a member before it builds
233
+ # a coordinate, so every key it looks up is "Person.name".
234
+ def concrete!(schema, label, key, type, field_name)
235
+ return unless type.kind.abstract?
128
236
 
129
- bad!(label, key, "is not a field of #{type_name}", type.fields.keys, field_name)
237
+ members = schema.possible_types(type)
238
+ .map { |member| "#{member.graphql_name}.#{field_name}".inspect }.sort
239
+ raise GraphWeaver::Error, "#{label} #{key.inspect} names #{type.kind.name.downcase} " \
240
+ "#{type.graphql_name}, and a fake only ever holds a concrete type: name the " \
241
+ "concrete type — #{members.join(", ")}"
130
242
  end
131
243
 
132
244
  # A type pin says what every value of that type is, and the fake only
@@ -97,15 +97,20 @@ module GraphWeaver
97
97
  # :wire takes no client slot: it serves the resolvers at the
98
98
  # endpoint each client already posts to — the transport you ship,
99
99
  # running unchanged, is the whole point
100
- return if @mode == :wire
100
+ return if @mode == :wire && GraphWeaver.graphs.all?(&:client_url)
101
101
  # :live is the app's own clients, untouched — so with nothing
102
102
  # standing in there is nothing to look up
103
103
  return if @mode == :live && !built?
104
104
 
105
+ graph = graph_for!(mod)
106
+ # a graph whose client posts to no url has no wire to be served at,
107
+ # so :wire serves it here instead — the same pick, one hop shorter
108
+ return if @mode == :wire && graph&.client_url
109
+
105
110
  # a helper's entry wins whatever the example's mode is, and :live
106
111
  # builds nothing of its own, so an untagged example's other graphs
107
112
  # still resolve their own clients
108
- standin(graph_for!(mod))
113
+ standin(graph)
109
114
  end
110
115
 
111
116
  # The stand-in `graph`'s modules run against under the installed mode,
@@ -165,12 +170,29 @@ module GraphWeaver
165
170
  return :in_process if config.schema_class?(graph)
166
171
  return :fake if config.schema || graph&.named_schema?
167
172
 
168
- raise GraphWeaver::Error, ":wire serves your schema at the endpoint your client posts " \
169
- "to, and #{graph&.name ? "graph #{graph.name.inspect}" : "this app"} has none to " \
170
- "serve — no live GraphQL::Schema class, no composed supergraph, and no type " \
173
+ raise GraphWeaver::Error, nothing_to_serve(graph)
174
+ end
175
+
176
+ # Two graphs are in different states here, and the served one's
177
+ # sentences are all false for the other: a graph whose client posts
178
+ # nowhere has no endpoint to stub, no stub of ours to introspect, and
179
+ # no url to refresh a dump from — what it is missing is a schema.
180
+ def nothing_to_serve(graph)
181
+ missing = "no live GraphQL::Schema class, no composed supergraph, and no type " \
171
182
  "information (nothing at #{GraphWeaver.schema_path}, and " \
172
- "GraphWeaver::Testing.config.schema is unset). Your client's own schema can't stand " \
173
- "in here: reading it introspects the endpoint :wire has stubbed. Commit a dump " \
183
+ "GraphWeaver::Testing.config.schema is unset)"
184
+ if graph&.name && !graph.client_url
185
+ return ":wire has no endpoint for graph #{graph.name.inspect} — its client posts to " \
186
+ "none, so its modules run above the wire, against the most faithful stand-in the " \
187
+ "graph has. It has #{missing}. Name one where the graph is declared: " \
188
+ "GraphWeaver.graph(#{graph.name.inspect}) { schema -> { MySchema } }, or a dump " \
189
+ "(schema \"schema.graphql\"). Or tag the example graphql: :live."
190
+ end
191
+
192
+ ":wire serves your schema at the endpoint your client posts to, and " \
193
+ "#{graph&.name ? "graph #{graph.name.inspect}" : "this app"} has none to serve — " \
194
+ "#{missing}. Your client's own schema can't stand in here: reading it introspects " \
195
+ "the endpoint :wire has stubbed. Commit a dump " \
174
196
  "(rake graph_weaver:schema:refresh URL=…), or tag the example graphql: :live."
175
197
  end
176
198