graph_weaver 0.7.4 → 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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +2 -2
  3. data/README.md +1 -0
  4. data/docs/errors.md +12 -5
  5. data/docs/generated_modules.md +134 -17
  6. data/docs/getting_started.md +182 -20
  7. data/docs/logging.md +79 -35
  8. data/docs/migrating.md +126 -0
  9. data/docs/scalars.md +50 -6
  10. data/docs/testing.md +78 -14
  11. data/docs/upgrading.md +44 -2
  12. data/examples/README.md +4 -2
  13. data/examples/github/generate.rb +22 -8
  14. data/examples/github/generated/star_mutation.rb +2 -2
  15. data/examples/github/generated/stargazers_query.rb +2 -2
  16. data/examples/github/generated/starred_query.rb +2 -2
  17. data/examples/github/run.rb +1 -0
  18. data/examples/github/setup.rb +16 -8
  19. data/graph_weaver.gemspec +15 -6
  20. data/lib/generators/graph_weaver/install_generator.rb +49 -2
  21. data/lib/graph_weaver/client.rb +0 -23
  22. data/lib/graph_weaver/codegen/aliases.rb +36 -3
  23. data/lib/graph_weaver/codegen/emit.rb +20 -15
  24. data/lib/graph_weaver/codegen/enum_type.rb +52 -11
  25. data/lib/graph_weaver/codegen/nodes.rb +75 -32
  26. data/lib/graph_weaver/codegen.rb +260 -100
  27. data/lib/graph_weaver/coerce.rb +1 -1
  28. data/lib/graph_weaver/federation.rb +1 -6
  29. data/lib/graph_weaver/graph.rb +55 -5
  30. data/lib/graph_weaver/hints.rb +20 -5
  31. data/lib/graph_weaver/in_process.rb +2 -4
  32. data/lib/graph_weaver/input_struct.rb +50 -10
  33. data/lib/graph_weaver/internal/overrides.rb +126 -14
  34. data/lib/graph_weaver/internal/subgraphs.rb +1 -10
  35. data/lib/graph_weaver/internal/test_clients.rb +29 -7
  36. data/lib/graph_weaver/internal/unused.rb +62 -18
  37. data/lib/graph_weaver/internal/values.rb +24 -7
  38. data/lib/graph_weaver/internal.rb +23 -6
  39. data/lib/graph_weaver/log_subscriber.rb +27 -17
  40. data/lib/graph_weaver/logging.rb +115 -82
  41. data/lib/graph_weaver/parsing.rb +32 -3
  42. data/lib/graph_weaver/query_module.rb +67 -12
  43. data/lib/graph_weaver/railtie.rb +7 -2
  44. data/lib/graph_weaver/rspec.rb +41 -18
  45. data/lib/graph_weaver/schema_diff.rb +24 -5
  46. data/lib/graph_weaver/schema_loader.rb +29 -17
  47. data/lib/graph_weaver/tasks.rb +98 -16
  48. data/lib/graph_weaver/testing/fake_client.rb +28 -31
  49. data/lib/graph_weaver/testing/router.rb +26 -25
  50. data/lib/graph_weaver/testing.rb +27 -8
  51. data/lib/graph_weaver/transport.rb +1 -1
  52. data/lib/graph_weaver/version.rb +1 -1
  53. data/lib/graph_weaver.rb +72 -42
  54. metadata +3 -2
@@ -66,15 +66,15 @@ class GraphWeaver::Codegen
66
66
  # defines, defaulting to the operation's own name; default_name: is
67
67
  # parse's container-scoped fallback (file generation stays strict — a
68
68
  # checked-in file deserves a deliberate name). types_namespace: is the shared-types workflow (see
69
- # GraphWeaver.generate!): input types, schema enums, and unions hoisted from
70
- # shared fragments live once in that module and the query module aliases what
71
- # it uses. hoistable_unions: is the set of shared fragment names this query
72
- # may hoist (spreads it inlined, minus any it shadows locally) — a
73
- # whole-union field spread as one of them resolves to a canonical type in the
74
- # shared module (see used_union_names). path: is the file the query was read
69
+ # GraphWeaver.generate!): input types, schema enums, and the types hoisted out
70
+ # of shared fragments live once in that module and the query module aliases
71
+ # what it uses. hoistable_fragments: is the set of shared fragment names this
72
+ # query may hoist (spreads it inlined, minus any it shadows locally) — a whole
73
+ # field spread as one of them resolves to a canonical type in the shared
74
+ # module (see used_fragment_names). path: is the file the query was read
75
75
  # from, named alongside line and column in validation errors.
76
76
  def initialize(schema:, query:, name: nil, default_name: nil,
77
- types_namespace: nil, hoistable_unions: nil, path: nil, module_name: nil,
77
+ types_namespace: nil, hoistable_fragments: nil, path: nil, module_name: nil,
78
78
  graph_name: nil, registry: GraphWeaver::Codegen.registry)
79
79
  renamed!(module_name)
80
80
  @schema = schema
@@ -87,8 +87,8 @@ class GraphWeaver::Codegen
87
87
  @name = name
88
88
  @default_name = default_name
89
89
  @types_namespace = types_namespace
90
- @hoistable_unions = hoistable_unions || []
91
- @used_unions = []
90
+ @hoistable_fragments = hoistable_fragments || []
91
+ @used_fragments = []
92
92
  # scalars this generation had no registration for (see report_untyped_scalars)
93
93
  @untyped_scalars = []
94
94
  # the graph this module belongs to: its client and, under a test mode,
@@ -144,10 +144,10 @@ class GraphWeaver::Codegen
144
144
  { inputs: @variable_inputs.keys, enums: @enums.keys, mapped: @mapped_enums.keys }
145
145
  end
146
146
 
147
- # The shared union fragments this query hoisted, by name — the generate!
148
- # workflow unions these across queries to decide what the shared types module
149
- # must contain.
150
- def used_union_names = @used_unions.dup
147
+ # The shared fragments this query hoisted, by name — the generate! workflow
148
+ # unions these across queries to decide what the shared types module must
149
+ # contain.
150
+ def used_fragment_names = @used_fragments.dup
151
151
 
152
152
  # The custom scalars this walk found no registration for (see
153
153
  # report_untyped_scalars) — the generate! workflow unions these across
@@ -172,26 +172,29 @@ class GraphWeaver::Codegen
172
172
  # T::Enum, or the wire tables for one mapped onto an app enum
173
173
  # (register_enum) — so a value read out of one query's result hands
174
174
  # straight back into another's variable;
175
- # - unions: each named shared fragment a query spread as a whole union field,
176
- # so the same union across queries is one Ruby type family. `fragments` is
177
- # the loaded shared-fragment table (nested spreads resolve through it).
175
+ # - hoisted: each named shared fragment a query spread as a whole field, so
176
+ # the same shape across queries is one Ruby type. `fragments` is the loaded
177
+ # shared-fragment table (nested spreads resolve through it).
178
178
  #
179
- # Unions are built first: a hoisted fragment's own selections are the one
180
- # place a query walk never reaches, so the enums they touch are only known
181
- # once the fragments are built.
182
- def generate_types(inputs:, enums:, unions:, fragments:)
179
+ # Hoisted fragments are built first: their own selections are the one place a
180
+ # query walk never reaches, so the enums they touch are only known once the
181
+ # fragments are built.
182
+ def generate_types(inputs:, enums:, hoisted:, fragments:)
183
183
  validate_module_name!("types module name")
184
184
  reset_walk_state!
185
185
  # nested spreads inside a shared fragment resolve through the whole table
186
186
  @fragments = fragments
187
187
 
188
- union_nodes = unions.uniq.sort.map { |name| hoisted_union(fragments, name) }
188
+ nodes = hoisted.uniq.sort.map { |name| hoisted_fragment(fragments, name) }
189
189
  inputs.sort.each { |name| input_node(@schema.get_type(name)) }
190
190
  enums.uniq.sort.each { |name| variable_core(@schema.get_type(name)) }
191
- check_shared_collisions!(unions)
192
- union_nodes.each { |union| check_shadowing!(union) }
191
+ check_shared_collisions!(hoisted)
192
+ nodes.each do |node|
193
+ check_shadowing!(node)
194
+ check_abstract_mixins!(node, hoisted: true)
195
+ end
193
196
 
194
- emit_types_files(union_nodes).tap { report_untyped_scalars }
197
+ emit_types_files(nodes).tap { report_untyped_scalars }
195
198
  end
196
199
 
197
200
  # module-level constants every generated query module defines — a shared
@@ -201,7 +204,7 @@ class GraphWeaver::Codegen
201
204
 
202
205
  # One hoisted shared fragment, built against the schema and named for the
203
206
  # fragment rather than the field that spread it.
204
- def hoisted_union(fragments, name)
207
+ def hoisted_fragment(fragments, name)
205
208
  class_name = camelize(name)
206
209
  # the query module aliases <class_name> = <shared module>::<class_name>; a
207
210
  # name that camelizes to a generated module-level constant (the Result
@@ -213,28 +216,44 @@ class GraphWeaver::Codegen
213
216
 
214
217
  fragment = fragments.fetch(name)
215
218
  type = @schema.get_type(fragment.type.name)
216
- members = union_members(type, fragment.selections)
217
- 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
218
233
  end
219
- private :hoisted_union
234
+ private :hoisted_fragment
220
235
 
221
236
  # Schema type names are unique, so an input and an enum can never land on the
222
- # same name — but a hoisted union is named for its FRAGMENT, which the schema
223
- # knows nothing about. One shared module means one namespace, so a fragment
224
- # named after a type it doesn't describe has to refuse rather than overwrite.
237
+ # same name — but a hoisted type is named for its FRAGMENT, which the schema
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.
225
242
  def check_shared_collisions!(names)
226
243
  taken = {}
227
244
  @enums.each { |graphql_name, node| taken[node.class_name] = "the schema enum #{graphql_name}" }
228
245
  @mapped_enums.each_key { |graphql_name| taken[camelize(graphql_name)] = "the schema enum #{graphql_name}" }
229
246
  @variable_inputs.each { |graphql_name, node| taken[node.class_name] = "the input type #{graphql_name}" }
230
247
 
231
- names.each do |name|
248
+ names.uniq.sort.each do |name|
232
249
  class_name = camelize(name)
233
- 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
234
255
 
235
- raise GraphWeaver::Error,
236
- "shared fragment #{name.inspect} hoists to #{@name}::#{class_name}, " \
237
- "where #{claim} already generates — rename the fragment"
256
+ taken[class_name] = "the shared fragment #{name.inspect}"
238
257
  end
239
258
  end
240
259
  private :check_shared_collisions!
@@ -250,7 +269,7 @@ class GraphWeaver::Codegen
250
269
  @input_list = false
251
270
  @input_hops = []
252
271
  @mapped_enums = {}
253
- @used_unions = []
272
+ @used_fragments = []
254
273
  # block-built type helpers this walk included — see #block_helpers
255
274
  @block_helpers = []
256
275
  # requires the generated file needs (custom scalars, enum mappings,
@@ -408,6 +427,7 @@ class GraphWeaver::Codegen
408
427
  variables = build_variables(operation)
409
428
  root = object_node(root_type, operation.selections, "Result")
410
429
  check_shadowing!(root)
430
+ check_abstract_mixins!(root)
411
431
 
412
432
  # An anonymous operation takes the module's name — declared in the document
413
433
  # AND sent as operationName, which have to agree (a server rejects an
@@ -719,7 +739,7 @@ class GraphWeaver::Codegen
719
739
  # The shared fragments a query spreads (transitively), excluding any it
720
740
  # shadows with a local definition of the same name — the names
721
741
  # inline_fragments appends, and the set the generate! workflow may hoist
722
- # when they sit on a whole-union field.
742
+ # when they are a field's whole selection.
723
743
  def self.shared_fragment_spreads(query, shared, path = nil)
724
744
  # parsed even with nothing to spread: this is the first look at the document
725
745
  # on the generate! path, so it's where a syntax error gets branded and
@@ -872,59 +892,16 @@ class GraphWeaver::Codegen
872
892
 
873
893
  case (core = field_type.unwrap).kind.name
874
894
  when "OBJECT"
875
- name = pick_name(key, taken)
876
- type_ref(field_type) { object_node(core, sub_selections, name) }
877
- when "UNION", "INTERFACE"
878
- conditions = concrete_conditions(core, sub_selections)
879
- shared = abstract_level_fields(core, sub_selections)
880
-
881
- if conditions.empty?
882
- # abstract-level fields only — every member shares them, so one
883
- # struct suffices and no __typename dispatch is needed (for a
884
- # union that selection can only be __typename)
895
+ if (frag = hoistable_spread(core, sub_selections))
896
+ hoisted_ref(field_type, frag)
897
+ else
885
898
  name = pick_name(key, taken)
886
899
  type_ref(field_type) { object_node(core, sub_selections, name) }
887
- elsif conditions.size == 1 && shared.empty? &&
888
- (member = @schema.get_type(conditions.first)).kind.name == "OBJECT"
889
- # a single `... on X` condition: narrow to X's struct — nil
890
- # when the runtime type doesn't match (narrowing filters).
891
- # With `__typename` selected the match is read off the tag;
892
- # without one there is nothing to read but emptiness, and a
893
- # fragment whose every field hides behind @skip/@include would
894
- # make a real match indistinguishable from a miss ({} either
895
- # way) — refuse rather than guess.
896
- tag = member.graphql_name if dispatchable_typename?(core, sub_selections)
897
- unless tag || unconditional_field?(member, sub_selections)
898
- raise GraphWeaver::Error,
899
- "narrowed `... on #{member.graphql_name}` needs at least one field not under " \
900
- "@skip/@include (or a `__typename` to match on) — an all-conditional selection " \
901
- "makes a match indistinguishable from nil"
902
- end
903
-
904
- name = pick_name(key, taken)
905
- nilable_type_ref(field_type) { NarrowedNode.new(object_node(member, sub_selections, name), typename: tag) }
906
- elsif @types_namespace && (frag = lone_shared_spread(sub_selections)) &&
907
- @hoistable_unions.include?(frag)
908
- # a whole-union field spread as a named shared fragment: hoist to
909
- # the shared types module so the same union across queries is one
910
- # Ruby type family (one exhaustive `case ... T.absurd`).
911
- @used_unions << frag unless @used_unions.include?(frag)
912
- ref = UnionRefNode.new(camelize(frag))
913
- type_ref(field_type) { ref }
914
- else
915
- members = union_members(core, sub_selections)
916
- catch_all = catch_all_member(core, sub_selections, members)
917
- # reuse an identical sibling union — the shared type takes the
918
- # first of the sharing keys alphabetically, not in walk order
919
- signature = union_signature(members, catch_all)
920
- union = union_cache[signature]
921
- if union
922
- rename_union(union, key, taken) if camelize(key) < union.class_name
923
- else
924
- union = union_cache[signature] = UnionNode.new(pick_name(key, taken), members, catch_all)
925
- end
926
- type_ref(field_type) { union }
927
900
  end
901
+ when "UNION", "INTERFACE"
902
+ abstract_field(AbstractField.new(
903
+ type: field_type, selections: sub_selections, key:, taken:, union_cache:,
904
+ ))
928
905
  when "ENUM"
929
906
  # one schema enum is one Ruby type: module-level, named for the enum,
930
907
  # shared by every result field and variable that reaches it (and, on
@@ -953,6 +930,114 @@ class GraphWeaver::Codegen
953
930
  node
954
931
  end
955
932
 
933
+ # An abstract-typed (union or interface) field: what was selected through it,
934
+ # and the two ledgers the struct being built keeps — the names already claimed
935
+ # in its scope, and the unions it has already emitted.
936
+ AbstractField = Data.define(:type, :selections, :key, :taken, :union_cache) do
937
+ def core = type.unwrap
938
+ end
939
+ private_constant :AbstractField
940
+
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.
946
+ def abstract_field(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)
955
+ end
956
+ end
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
+
972
+ # Every member carries the abstract-level fields, so one struct answers for
973
+ # all of them and there is nothing to dispatch on — for a union, the only
974
+ # selection that can get here is __typename.
975
+ def abstract_level_struct(field)
976
+ name = pick_name(field.key, field.taken)
977
+ type_ref(field.type) { object_node(field.core, field.selections, name) }
978
+ end
979
+
980
+ # Narrowing to the one member a `... on X` names filters: the field is nil
981
+ # whenever the runtime type doesn't match.
982
+ def narrowed_struct(field, member)
983
+ tag = narrowing_tag!(member, field.core, field.selections)
984
+ name = pick_name(field.key, field.taken)
985
+ nilable_type_ref(field.type) { NarrowedNode.new(object_node(member, field.selections, name), typename: tag) }
986
+ end
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
+
1002
+ # A whole field spread as one named shared fragment points at the type
1003
+ # hoisted into the shared types module, so the same shape across queries is
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.
1008
+ def hoisted_ref(field_type, frag)
1009
+ @used_fragments << frag unless @used_fragments.include?(frag)
1010
+ core = field_type.unwrap
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
1022
+ end
1023
+
1024
+ # One member struct per type the selection names, chosen at runtime off
1025
+ # __typename. Structurally identical sibling unions share one Ruby type,
1026
+ # named for the first of the sharing keys alphabetically so that which one
1027
+ # the walk reached first doesn't decide.
1028
+ def dispatch_union(field)
1029
+ members = union_members(field.core, field.selections)
1030
+ catch_all = catch_all_member(field.core, field.selections, members)
1031
+ signature = union_signature(members, catch_all)
1032
+ union = field.union_cache[signature]
1033
+ if union
1034
+ rename_union(union, field.key, field.taken) if camelize(field.key) < union.class_name
1035
+ else
1036
+ union = field.union_cache[signature] = UnionNode.new(pick_name(field.key, field.taken), members, catch_all)
1037
+ end
1038
+ type_ref(field.type) { union }
1039
+ end
1040
+
956
1041
  # A generated class name is only ever a name; Ruby resolves it lexically. So
957
1042
  # a struct nesting `class Date < T::Struct` (from a result key `date`) turns
958
1043
  # a sibling `Date` scalar prop into that struct, and `Date.iso8601` into a
@@ -1066,10 +1151,26 @@ class GraphWeaver::Codegen
1066
1151
  dispatchable_typename?(core, selections) ? keys - ["__typename"] : keys
1067
1152
  end
1068
1153
 
1154
+ # The name a field's whole selection hoists under: exactly one bare spread of
1155
+ # a fragment this query may hoist, written on the field's own type. Only the
1156
+ # generate! workflow has a shared module to hoist into — dynamic `parse`
1157
+ # inlines.
1158
+ def hoistable_spread(core, selections)
1159
+ return unless @types_namespace
1160
+
1161
+ frag = lone_shared_spread(selections)
1162
+ return unless frag && @hoistable_fragments.include?(frag)
1163
+
1164
+ # the shared type is built from the fragment's own type condition, so it is
1165
+ # this field's type only when the two agree — a fragment on a narrower (or
1166
+ # wider) type stays a locally-emitted struct
1167
+ frag if @fragments.fetch(frag).type.name == core.graphql_name
1168
+ end
1169
+
1069
1170
  # The fragment name when a selection is exactly one bare fragment spread
1070
- # (`{ ...F }`) — the shape a union field must have to hoist into the shared
1071
- # unions module. A spread carrying directives (@skip/@include), or mixed with
1072
- # other fields, stays a locally-emitted union.
1171
+ # (`{ ...F }`) — the shape a field must have to hoist into the shared types
1172
+ # module. A spread carrying directives (@skip/@include), or mixed with other
1173
+ # fields, stays locally emitted.
1073
1174
  def lone_shared_spread(selections)
1074
1175
  return unless selections.size == 1
1075
1176
 
@@ -1172,7 +1273,7 @@ class GraphWeaver::Codegen
1172
1273
  # it. It carries what the abstract type itself guarantees, plus anything a
1173
1274
  # `... on SomeInterface` asked for, since an unnamed member may implement it.
1174
1275
  def catch_all_member(type, selections, members)
1175
- node = object_node(type, selections, catch_all_name(members))
1276
+ node = object_node(type, selections, catch_all_name(members.each_value.map(&:class_name)))
1176
1277
  taken = node.fields.map(&:key)
1177
1278
 
1178
1279
  # These are nilable whatever the schema promises: the member that arrives
@@ -1222,9 +1323,9 @@ class GraphWeaver::Codegen
1222
1323
  sibling_conditions(condition, selections, visiting, out)
1223
1324
  end
1224
1325
 
1225
- # "Other", unless a real member already claims that name.
1226
- def catch_all_name(members)
1227
- taken = members.each_value.map(&:class_name)
1326
+ # "Other", unless a real member already claims that name — one rule for a
1327
+ # union's catch-all struct and a generated enum's fallback member.
1328
+ def catch_all_name(taken)
1228
1329
  name = "Other"
1229
1330
  suffix = 2
1230
1331
  while taken.include?(name)
@@ -1251,13 +1352,13 @@ class GraphWeaver::Codegen
1251
1352
  when List then "[#{signature(node.of)}]"
1252
1353
  when NarrowedNode then "?#{signature(node.nested)}"
1253
1354
  when Scalar then "s:#{node.bare_type}"
1254
- when EnumNode then "e:#{node.values.sort.join("|")}"
1355
+ when EnumNode then "e:#{node.values.sort.join("|")}#{"+" if node.fallback?}"
1255
1356
  when MappedEnum then "m:#{node.graphql_name}"
1256
1357
  when ObjectNode
1257
1358
  inner = node.fields.map { |f| "#{f.prop}=#{signature(f.node)}" }.sort.join(",")
1258
1359
  "o:#{node.graphql_type}(#{inner})"
1259
1360
  when UnionNode then "u:(#{union_signature(node.members, node.catch_all)})"
1260
- when UnionRefNode then "ur:#{node.class_name}" # hoisted — identity is its shared name
1361
+ when HoistedRefNode then "hr:#{node.class_name}" # hoisted — identity is its shared name
1261
1362
  else "x:#{node.object_id}" # unknown node kind — never collapse
1262
1363
  end
1263
1364
  end
@@ -1406,8 +1507,12 @@ class GraphWeaver::Codegen
1406
1507
  "constant — map it onto one of yours: register_enum(#{core.graphql_name.inspect}, YourEnum)"
1407
1508
  end
1408
1509
 
1409
- aliases = @registry.enum_registry[core.graphql_name]&.aliases_for(core.values.keys.sort) || {}
1410
- EnumNode.new(class_name, enum_values(core, aliases), aliases)
1510
+ entry = @registry.enum_registry[core.graphql_name]
1511
+ aliases = entry&.aliases_for(core.values.keys.sort) || {}
1512
+ values = enum_values(core, aliases)
1513
+ # named the way a union's catch-all is: Other, or Other2 past a declared OTHER
1514
+ fallback = catch_all_name(values.map { |value| camelize(value.downcase) }) if entry&.generated_fallback?
1515
+ EnumNode.new(class_name, values, aliases, fallback:)
1411
1516
  end
1412
1517
 
1413
1518
  # A schema enum's constant-bearing wire values, sorted so output is
@@ -1435,7 +1540,7 @@ class GraphWeaver::Codegen
1435
1540
  constant, group = collisions.first
1436
1541
  more = collisions.size - 1
1437
1542
  raise GraphWeaver::Error,
1438
- "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}" \
1439
1544
  "#{" (and #{more} more colliding pair#{"s" if more > 1})" unless more.zero?} — if each pair is one " \
1440
1545
  "value, say which spelling goes on the wire:\n " \
1441
1546
  "#{EnumType.alias_suggestion(core.graphql_name, collisions.values)}\n" \
@@ -1479,6 +1584,61 @@ class GraphWeaver::Codegen
1479
1584
  end
1480
1585
  private :abstract_mixin_members
1481
1586
 
1587
+ # An `abstract!` mixin goes into EVERY struct generated from its type, but
1588
+ # only a struct that selected the members it declares can satisfy them — so a
1589
+ # query selecting a subset generated fine and failed in the app's own
1590
+ # `srb tc`, two tools from the query that fell short. Walked from the root the
1591
+ # way shadowing is, so the refusal can name the struct by its path.
1592
+ def check_abstract_mixins!(node, path = [], hoisted: false)
1593
+ case node
1594
+ when UnionNode
1595
+ inner = path + [node.class_name]
1596
+ (node.members.each_value.to_a + [node.catch_all]).each do |member|
1597
+ check_abstract_mixins!(member, inner, hoisted:)
1598
+ end
1599
+ when ObjectNode
1600
+ inner = path + [node.class_name]
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
1605
+ end
1606
+ end
1607
+
1608
+ def refuse_unsatisfiable_mixin!(node, path, hoisted:)
1609
+ return if node.overrides.empty?
1610
+
1611
+ mixins = @registry.type_registry.dig(node.graphql_type, :mixins) || []
1612
+ provided = (node.fields.map(&:prop) + node.aliases.map(&:name)).to_set
1613
+ # what is still abstract with every registered mixin included: a member one
1614
+ # of them implements for another is already answered
1615
+ probe = Module.new
1616
+ mixins.each { |mixin| probe.include(mixin) }
1617
+ missing = T::AbstractUtils.abstract_methods_for(probe)
1618
+ .map { |method| method.name.to_s }.reject { |member| provided.include?(member) }.sort
1619
+ return if missing.empty?
1620
+
1621
+ mixin = mixins.find { |m|
1622
+ T::AbstractUtils.declared_abstract_methods_for(m).any? { |method| missing.include?(method.name.to_s) }
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
+
1634
+ raise GraphWeaver::Error,
1635
+ "#{[@name, *path].join("::")} includes #{mixin.name}, which declares " \
1636
+ "#{GraphWeaver::Internal::Util.sample(missing.map(&:inspect))} abstract — this selection does " \
1637
+ "not provide #{them}, and every struct generated from " \
1638
+ "#{node.graphql_type} includes the mixin, so `srb tc` fails on this one. #{fix}"
1639
+ end
1640
+ private :check_abstract_mixins!, :refuse_unsatisfiable_mixin!
1641
+
1482
1642
  # The MappedEnum node for a schema enum with a registered app-enum
1483
1643
  # mapping; nil when unregistered — or registered for alias: alone, which
1484
1644
  # says nothing about the Ruby type — falling back to a generated T::Enum.
@@ -255,7 +255,7 @@ module GraphWeaver
255
255
 
256
256
  # the article by the initial, so a registered Ruby class ("an Integer")
257
257
  # reads as well as the scalars ("an Int", "an ID", "a Date")
258
- def expected(scalar) = "expected #{scalar.start_with?(/[AEIOU]/) ? "an" : "a"} #{scalar}"
258
+ def expected(scalar) = "expected #{Internal::Util.article(scalar)} #{scalar}"
259
259
  end
260
260
  end
261
261
  end
@@ -341,15 +341,10 @@ module GraphWeaver
341
341
  *@skipped.sort.map { |name, what| " #{name} (#{evidence(what)})" }]
342
342
  end
343
343
 
344
- # how many coordinates the report names before it says "and N more"
345
- SAMPLE = 5
346
- private_constant :SAMPLE
347
-
348
344
  def evidence(coordinates)
349
345
  return "the supergraph attributes nothing to it alone" if coordinates.empty?
350
- return coordinates.join(", ") if coordinates.size <= SAMPLE
351
346
 
352
- "#{coordinates.first(SAMPLE).join(", ")} and #{coordinates.size - SAMPLE} more"
347
+ Internal::Util.sample(coordinates)
353
348
  end
354
349
 
355
350
  def faked_section
@@ -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 = {}