graph_weaver 0.7.3 → 0.7.5

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +2 -2
  3. data/README.md +1 -0
  4. data/docs/errors.md +5 -2
  5. data/docs/federation.md +3 -2
  6. data/docs/generated_modules.md +176 -22
  7. data/docs/getting_started.md +174 -14
  8. data/docs/i18n.md +4 -4
  9. data/docs/migrating.md +119 -0
  10. data/docs/scalars.md +161 -35
  11. data/docs/testing.md +24 -3
  12. data/docs/upgrading.md +51 -5
  13. data/examples/github/generated/star_mutation.rb +24 -2
  14. data/examples/github/generated/stargazers_query.rb +61 -5
  15. data/examples/github/generated/starred_query.rb +33 -3
  16. data/lib/generators/graph_weaver/install_generator.rb +32 -3
  17. data/lib/graph_weaver/client.rb +23 -0
  18. data/lib/graph_weaver/codegen/aliases.rb +23 -2
  19. data/lib/graph_weaver/codegen/emit.rb +35 -16
  20. data/lib/graph_weaver/codegen/enum_type.rb +149 -19
  21. data/lib/graph_weaver/codegen/nodes.rb +72 -37
  22. data/lib/graph_weaver/codegen/scalar_type.rb +72 -18
  23. data/lib/graph_weaver/codegen/type_helpers.rb +71 -13
  24. data/lib/graph_weaver/codegen.rb +259 -106
  25. data/lib/graph_weaver/coerce.rb +25 -6
  26. data/lib/graph_weaver/federation.rb +1 -6
  27. data/lib/graph_weaver/graph.rb +4 -1
  28. data/lib/graph_weaver/hints.rb +23 -5
  29. data/lib/graph_weaver/in_process.rb +1 -3
  30. data/lib/graph_weaver/input_struct.rb +31 -10
  31. data/lib/graph_weaver/internal/subgraphs.rb +1 -10
  32. data/lib/graph_weaver/internal/unused.rb +32 -7
  33. data/lib/graph_weaver/internal/values.rb +12 -4
  34. data/lib/graph_weaver/internal.rb +84 -0
  35. data/lib/graph_weaver/logging.rb +26 -29
  36. data/lib/graph_weaver/query_module.rb +20 -5
  37. data/lib/graph_weaver/railtie.rb +7 -2
  38. data/lib/graph_weaver/rspec.rb +0 -1
  39. data/lib/graph_weaver/schema_loader.rb +7 -8
  40. data/lib/graph_weaver/tasks.rb +60 -5
  41. data/lib/graph_weaver/testing/fake_client.rb +4 -10
  42. data/lib/graph_weaver/testing/router.rb +26 -25
  43. data/lib/graph_weaver/testing.rb +101 -1
  44. data/lib/graph_weaver/version.rb +1 -1
  45. data/lib/graph_weaver.rb +80 -74
  46. metadata +3 -2
@@ -45,10 +45,21 @@ class GraphWeaver::Codegen
45
45
  # fit the path just omits the accessor instead of failing generation. Use it
46
46
  # for a root-type accessor (a Query alias every query would otherwise have to
47
47
  # satisfy) or one that only fits some selections.
48
+ #
49
+ # Inside the block, `alias_field` is the same keyword said next to the
50
+ # methods that use it — one alias per line, always strict:
51
+ #
52
+ # GraphWeaver.extend_type("Widget") do
53
+ # alias_field :tag, "meta.tag"
54
+ # def shout = tag&.upcase
55
+ # end
48
56
  def extend_type(graphql_name, *mixins, requires: nil, **kw, &block)
49
- aliases = take_aliases(kw)
57
+ optional = !!kw.delete(:optional)
58
+ aliases = normalize_aliases(kw.delete(:alias), optional:)
59
+ raise ArgumentError, "unknown keyword: #{kw.keys.first}" unless kw.empty?
60
+
50
61
  mixins = mixins.dup
51
- mixins << helper_module(graphql_name, block) if block
62
+ mixins << helper_module(graphql_name, block, aliases) if block
52
63
 
53
64
  raise ArgumentError, "pass one or more helper modules, a block, or alias:" if mixins.empty? && aliases.empty?
54
65
  mixins.each do |mixin|
@@ -70,15 +81,6 @@ class GraphWeaver::Codegen
70
81
  entry
71
82
  end
72
83
 
73
- # Pull alias:/optional: out of the keyword rest and normalize; any other
74
- # keyword is a typo worth flagging rather than silently dropping.
75
- def take_aliases(kw)
76
- aliases = normalize_aliases(kw.delete(:alias), optional: !!kw.delete(:optional))
77
- raise ArgumentError, "unknown keyword: #{kw.keys.first}" unless kw.empty?
78
- aliases
79
- end
80
- private :take_aliases
81
-
82
84
  # accessor names and path segments are interpolated verbatim into generated
83
85
  # source, so — like module_name — they must be plain identifiers, never
84
86
  # arbitrary text that could inject code
@@ -138,7 +140,7 @@ class GraphWeaver::Codegen
138
140
  # naming it after whichever constants happened to exist made it a function
139
141
  # of how many times THIS process had read the registry, and `generate`
140
142
  # wrote a name a plain boot never creates.
141
- def helper_module(graphql_name, block)
143
+ def helper_module(graphql_name, block, aliases)
142
144
  namespace = helper_namespace
143
145
  type = GraphWeaver::Inflect.camelize(graphql_name.to_s)
144
146
  index = (helper_counts[[namespace.name, type]] += 1)
@@ -146,11 +148,67 @@ class GraphWeaver::Codegen
146
148
  # reused rather than replaced, so re-declaring the same source (a Rails
147
149
  # to_prepare reload) keeps the module already-loaded structs include
148
150
  mod = const_under(namespace, name) { Module.new }
149
- mod.module_eval(&block)
151
+ aliases.merge!(collect_aliases(graphql_name, mod, aliases, &block))
150
152
  mod
151
153
  end
152
154
  private :helper_module
153
155
 
156
+ # Run the block with `alias_field` available — the alias: keyword said one
157
+ # line at a time — and hand back what it collected.
158
+ #
159
+ # It lives on the module's singleton for the length of the block and is
160
+ # removed after: a generated struct includes this module, and an
161
+ # `alias_field` left behind would be an instance method the wire never named.
162
+ def collect_aliases(graphql_name, mod, keyword_aliases, &block)
163
+ registry, collected = self, {}
164
+ mod.define_singleton_method(:alias_field) do |name, path = nil, **kw|
165
+ collected.merge!(registry.send(:one_alias, graphql_name, name, path, kw))
166
+ end
167
+ mod.module_eval(&block)
168
+
169
+ twice = keyword_aliases.keys & collected.keys
170
+ unless twice.empty?
171
+ raise ArgumentError, "extend_type(#{graphql_name.to_s.inspect}) declares alias " \
172
+ "#{twice.first.inspect} twice — once as alias:, once as alias_field; keep one"
173
+ end
174
+ collected
175
+ ensure
176
+ mod.singleton_class.send(:remove_method, :alias_field)
177
+ end
178
+ private :collect_aliases
179
+
180
+ ALIAS_FIELD_FORMS = %(one alias per line — alias_field "meta.tag", or alias_field :tag, "meta.tag"; ) +
181
+ %(a Hash or Array of paths goes on the alias: keyword)
182
+ private_constant :ALIAS_FIELD_FORMS
183
+
184
+ # One `alias_field` line, normalized the way the keyword's own paths are.
185
+ # The block takes no optional: — leniency has one spelling, on the keyword,
186
+ # because it is a property of the registration and not of one accessor.
187
+ def one_alias(graphql_name, name, path, kw)
188
+ if kw.key?(:optional)
189
+ keyword = path ? "alias: { #{name}: #{path.inspect} }" : "alias: #{name.inspect}"
190
+ raise ArgumentError, "alias_field is always strict — for a lenient alias use the keyword: " \
191
+ "extend_type(#{graphql_name.to_s.inspect}, #{keyword}, optional: true)"
192
+ end
193
+ raise ArgumentError, "unknown keyword: #{kw.keys.first}" unless kw.empty?
194
+
195
+ input = if path.nil? && name.is_a?(String)
196
+ name
197
+ elsif path.is_a?(String) && (name.is_a?(String) || name.is_a?(Symbol))
198
+ { name => path }
199
+ end
200
+ if input.nil?
201
+ # spelled as a caller writes it — Hash#inspect changed between Ruby 3.3 and 3.4
202
+ given = [name, path].compact.map do |arg|
203
+ arg.is_a?(Hash) ? "{#{arg.map { |k, v| "#{k}: #{v.inspect}" }.join(", ")}}" : arg.inspect
204
+ end.join(", ")
205
+ raise ArgumentError, "alias_field #{given}: #{ALIAS_FIELD_FORMS}"
206
+ end
207
+
208
+ normalize_aliases(input, optional: false)
209
+ end
210
+ private :one_alias
211
+
154
212
  # Where this registry's block-built helpers live: under a module named for
155
213
  # the graph, so two graphs extending the same type get two constants and
156
214
  # neither has to know the other exists.
@@ -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,16 +144,22 @@ 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
154
154
  # queries so the build says them once, for GraphWeaver.untyped_scalars.
155
155
  def untyped_scalars = @untyped_scalars.uniq.sort
156
156
 
157
+ # The block-built type helpers this walk included, by constant name. They are
158
+ # minted at registration, so no source file declares them and an app's
159
+ # `srb tc` can't resolve the include this emitted — the generate! workflow
160
+ # unions these across queries and declares them in an .rbi.
161
+ def block_helpers = @block_helpers.uniq.sort
162
+
157
163
  # The shared types artifact: every type a schema shares across query modules,
158
164
  # emitted once as a manifest (types.rb) plus one file per type under types/,
159
165
  # so a schema migration diffs only the types it touched. Returns
@@ -166,26 +172,29 @@ class GraphWeaver::Codegen
166
172
  # T::Enum, or the wire tables for one mapped onto an app enum
167
173
  # (register_enum) — so a value read out of one query's result hands
168
174
  # straight back into another's variable;
169
- # - unions: each named shared fragment a query spread as a whole union field,
170
- # so the same union across queries is one Ruby type family. `fragments` is
171
- # 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).
172
178
  #
173
- # Unions are built first: a hoisted fragment's own selections are the one
174
- # place a query walk never reaches, so the enums they touch are only known
175
- # once the fragments are built.
176
- 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:)
177
183
  validate_module_name!("types module name")
178
184
  reset_walk_state!
179
185
  # nested spreads inside a shared fragment resolve through the whole table
180
186
  @fragments = fragments
181
187
 
182
- union_nodes = unions.uniq.sort.map { |name| hoisted_union(fragments, name) }
188
+ nodes = hoisted.uniq.sort.map { |name| hoisted_fragment(fragments, name) }
183
189
  inputs.sort.each { |name| input_node(@schema.get_type(name)) }
184
190
  enums.uniq.sort.each { |name| variable_core(@schema.get_type(name)) }
185
- check_shared_collisions!(unions)
186
- 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)
195
+ end
187
196
 
188
- emit_types_files(union_nodes).tap { report_untyped_scalars }
197
+ emit_types_files(nodes).tap { report_untyped_scalars }
189
198
  end
190
199
 
191
200
  # module-level constants every generated query module defines — a shared
@@ -195,7 +204,7 @@ class GraphWeaver::Codegen
195
204
 
196
205
  # One hoisted shared fragment, built against the schema and named for the
197
206
  # fragment rather than the field that spread it.
198
- def hoisted_union(fragments, name)
207
+ def hoisted_fragment(fragments, name)
199
208
  class_name = camelize(name)
200
209
  # the query module aliases <class_name> = <shared module>::<class_name>; a
201
210
  # name that camelizes to a generated module-level constant (the Result
@@ -207,13 +216,15 @@ class GraphWeaver::Codegen
207
216
 
208
217
  fragment = fragments.fetch(name)
209
218
  type = @schema.get_type(fragment.type.name)
219
+ return object_node(type, fragment.selections, class_name) if type.kind.object?
220
+
210
221
  members = union_members(type, fragment.selections)
211
222
  UnionNode.new(class_name, members, catch_all_member(type, fragment.selections, members))
212
223
  end
213
- private :hoisted_union
224
+ private :hoisted_fragment
214
225
 
215
226
  # Schema type names are unique, so an input and an enum can never land on the
216
- # same name — but a hoisted union is named for its FRAGMENT, which the schema
227
+ # same name — but a hoisted type is named for its FRAGMENT, which the schema
217
228
  # knows nothing about. One shared module means one namespace, so a fragment
218
229
  # named after a type it doesn't describe has to refuse rather than overwrite.
219
230
  def check_shared_collisions!(names)
@@ -244,7 +255,9 @@ class GraphWeaver::Codegen
244
255
  @input_list = false
245
256
  @input_hops = []
246
257
  @mapped_enums = {}
247
- @used_unions = []
258
+ @used_fragments = []
259
+ # block-built type helpers this walk included — see #block_helpers
260
+ @block_helpers = []
248
261
  # requires the generated file needs (custom scalars, enum mappings,
249
262
  # type helpers all contribute)
250
263
  @requires = []
@@ -400,6 +413,7 @@ class GraphWeaver::Codegen
400
413
  variables = build_variables(operation)
401
414
  root = object_node(root_type, operation.selections, "Result")
402
415
  check_shadowing!(root)
416
+ check_abstract_mixins!(root)
403
417
 
404
418
  # An anonymous operation takes the module's name — declared in the document
405
419
  # AND sent as operationName, which have to agree (a server rejects an
@@ -711,7 +725,7 @@ class GraphWeaver::Codegen
711
725
  # The shared fragments a query spreads (transitively), excluding any it
712
726
  # shadows with a local definition of the same name — the names
713
727
  # inline_fragments appends, and the set the generate! workflow may hoist
714
- # when they sit on a whole-union field.
728
+ # when they are a field's whole selection.
715
729
  def self.shared_fragment_spreads(query, shared, path = nil)
716
730
  # parsed even with nothing to spread: this is the first look at the document
717
731
  # on the generate! path, so it's where a syntax error gets branded and
@@ -840,6 +854,7 @@ class GraphWeaver::Codegen
840
854
  node = ObjectNode.new(class_name)
841
855
  node.graphql_type = type.graphql_name
842
856
  node.mixins = type_mixins(type.graphql_name)
857
+ node.overrides = abstract_mixin_members(type.graphql_name)
843
858
  # class name => the result key that claimed it; the struct itself first,
844
859
  # claimed by nothing (see pick_name)
845
860
  taken = { class_name => nil }
@@ -863,59 +878,16 @@ class GraphWeaver::Codegen
863
878
 
864
879
  case (core = field_type.unwrap).kind.name
865
880
  when "OBJECT"
866
- name = pick_name(key, taken)
867
- type_ref(field_type) { object_node(core, sub_selections, name) }
868
- when "UNION", "INTERFACE"
869
- conditions = concrete_conditions(core, sub_selections)
870
- shared = abstract_level_fields(core, sub_selections)
871
-
872
- if conditions.empty?
873
- # abstract-level fields only — every member shares them, so one
874
- # struct suffices and no __typename dispatch is needed (for a
875
- # union that selection can only be __typename)
881
+ if (frag = hoistable_spread(core, sub_selections))
882
+ hoisted_ref(field_type, frag)
883
+ else
876
884
  name = pick_name(key, taken)
877
885
  type_ref(field_type) { object_node(core, sub_selections, name) }
878
- elsif conditions.size == 1 && shared.empty? &&
879
- (member = @schema.get_type(conditions.first)).kind.name == "OBJECT"
880
- # a single `... on X` condition: narrow to X's struct — nil
881
- # when the runtime type doesn't match (narrowing filters).
882
- # With `__typename` selected the match is read off the tag;
883
- # without one there is nothing to read but emptiness, and a
884
- # fragment whose every field hides behind @skip/@include would
885
- # make a real match indistinguishable from a miss ({} either
886
- # way) — refuse rather than guess.
887
- tag = member.graphql_name if dispatchable_typename?(core, sub_selections)
888
- unless tag || unconditional_field?(member, sub_selections)
889
- raise GraphWeaver::Error,
890
- "narrowed `... on #{member.graphql_name}` needs at least one field not under " \
891
- "@skip/@include (or a `__typename` to match on) — an all-conditional selection " \
892
- "makes a match indistinguishable from nil"
893
- end
894
-
895
- name = pick_name(key, taken)
896
- nilable_type_ref(field_type) { NarrowedNode.new(object_node(member, sub_selections, name), typename: tag) }
897
- elsif @types_namespace && (frag = lone_shared_spread(sub_selections)) &&
898
- @hoistable_unions.include?(frag)
899
- # a whole-union field spread as a named shared fragment: hoist to
900
- # the shared types module so the same union across queries is one
901
- # Ruby type family (one exhaustive `case ... T.absurd`).
902
- @used_unions << frag unless @used_unions.include?(frag)
903
- ref = UnionRefNode.new(camelize(frag))
904
- type_ref(field_type) { ref }
905
- else
906
- members = union_members(core, sub_selections)
907
- catch_all = catch_all_member(core, sub_selections, members)
908
- # reuse an identical sibling union — the shared type takes the
909
- # first of the sharing keys alphabetically, not in walk order
910
- signature = union_signature(members, catch_all)
911
- union = union_cache[signature]
912
- if union
913
- rename_union(union, key, taken) if camelize(key) < union.class_name
914
- else
915
- union = union_cache[signature] = UnionNode.new(pick_name(key, taken), members, catch_all)
916
- end
917
- type_ref(field_type) { union }
918
886
  end
887
+ when "UNION", "INTERFACE"
888
+ abstract_field(AbstractField.new(
889
+ type: field_type, selections: sub_selections, key:, taken:, union_cache:,
890
+ ))
919
891
  when "ENUM"
920
892
  # one schema enum is one Ruby type: module-level, named for the enum,
921
893
  # shared by every result field and variable that reaches it (and, on
@@ -944,6 +916,88 @@ class GraphWeaver::Codegen
944
916
  node
945
917
  end
946
918
 
919
+ # An abstract-typed (union or interface) field: what was selected through it,
920
+ # and the two ledgers the struct being built keeps — the names already claimed
921
+ # in its scope, and the unions it has already emitted.
922
+ AbstractField = Data.define(:type, :selections, :key, :taken, :union_cache) do
923
+ def core = type.unwrap
924
+ end
925
+ private_constant :AbstractField
926
+
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.
929
+ 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)
942
+ end
943
+ end
944
+
945
+ # Every member carries the abstract-level fields, so one struct answers for
946
+ # all of them and there is nothing to dispatch on — for a union, the only
947
+ # selection that can get here is __typename.
948
+ def abstract_level_struct(field)
949
+ name = pick_name(field.key, field.taken)
950
+ type_ref(field.type) { object_node(field.core, field.selections, name) }
951
+ end
952
+
953
+ # 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.
959
+ 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
+
968
+ name = pick_name(field.key, field.taken)
969
+ nilable_type_ref(field.type) { NarrowedNode.new(object_node(member, field.selections, name), typename: tag) }
970
+ end
971
+
972
+ # A whole field spread as one named shared fragment points at the type
973
+ # 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`.
975
+ def hoisted_ref(field_type, frag)
976
+ @used_fragments << frag unless @used_fragments.include?(frag)
977
+ # an abstract type hoists to a dispatch module, an object type to a struct
978
+ 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 }
982
+ end
983
+
984
+ # One member struct per type the selection names, chosen at runtime off
985
+ # __typename. Structurally identical sibling unions share one Ruby type,
986
+ # named for the first of the sharing keys alphabetically so that which one
987
+ # the walk reached first doesn't decide.
988
+ def dispatch_union(field)
989
+ members = union_members(field.core, field.selections)
990
+ catch_all = catch_all_member(field.core, field.selections, members)
991
+ signature = union_signature(members, catch_all)
992
+ union = field.union_cache[signature]
993
+ if union
994
+ rename_union(union, field.key, field.taken) if camelize(field.key) < union.class_name
995
+ else
996
+ union = field.union_cache[signature] = UnionNode.new(pick_name(field.key, field.taken), members, catch_all)
997
+ end
998
+ type_ref(field.type) { union }
999
+ end
1000
+
947
1001
  # A generated class name is only ever a name; Ruby resolves it lexically. So
948
1002
  # a struct nesting `class Date < T::Struct` (from a result key `date`) turns
949
1003
  # a sibling `Date` scalar prop into that struct, and `Date.iso8601` into a
@@ -1057,10 +1111,26 @@ class GraphWeaver::Codegen
1057
1111
  dispatchable_typename?(core, selections) ? keys - ["__typename"] : keys
1058
1112
  end
1059
1113
 
1114
+ # The name a field's whole selection hoists under: exactly one bare spread of
1115
+ # a fragment this query may hoist, written on the field's own type. Only the
1116
+ # generate! workflow has a shared module to hoist into — dynamic `parse`
1117
+ # inlines.
1118
+ def hoistable_spread(core, selections)
1119
+ return unless @types_namespace
1120
+
1121
+ frag = lone_shared_spread(selections)
1122
+ return unless frag && @hoistable_fragments.include?(frag)
1123
+
1124
+ # the shared type is built from the fragment's own type condition, so it is
1125
+ # this field's type only when the two agree — a fragment on a narrower (or
1126
+ # wider) type stays a locally-emitted struct
1127
+ frag if @fragments.fetch(frag).type.name == core.graphql_name
1128
+ end
1129
+
1060
1130
  # The fragment name when a selection is exactly one bare fragment spread
1061
- # (`{ ...F }`) — the shape a union field must have to hoist into the shared
1062
- # unions module. A spread carrying directives (@skip/@include), or mixed with
1063
- # other fields, stays a locally-emitted union.
1131
+ # (`{ ...F }`) — the shape a field must have to hoist into the shared types
1132
+ # module. A spread carrying directives (@skip/@include), or mixed with other
1133
+ # fields, stays locally emitted.
1064
1134
  def lone_shared_spread(selections)
1065
1135
  return unless selections.size == 1
1066
1136
 
@@ -1163,7 +1233,7 @@ class GraphWeaver::Codegen
1163
1233
  # it. It carries what the abstract type itself guarantees, plus anything a
1164
1234
  # `... on SomeInterface` asked for, since an unnamed member may implement it.
1165
1235
  def catch_all_member(type, selections, members)
1166
- node = object_node(type, selections, catch_all_name(members))
1236
+ node = object_node(type, selections, catch_all_name(members.each_value.map(&:class_name)))
1167
1237
  taken = node.fields.map(&:key)
1168
1238
 
1169
1239
  # These are nilable whatever the schema promises: the member that arrives
@@ -1213,9 +1283,9 @@ class GraphWeaver::Codegen
1213
1283
  sibling_conditions(condition, selections, visiting, out)
1214
1284
  end
1215
1285
 
1216
- # "Other", unless a real member already claims that name.
1217
- def catch_all_name(members)
1218
- taken = members.each_value.map(&:class_name)
1286
+ # "Other", unless a real member already claims that name — one rule for a
1287
+ # union's catch-all struct and a generated enum's fallback member.
1288
+ def catch_all_name(taken)
1219
1289
  name = "Other"
1220
1290
  suffix = 2
1221
1291
  while taken.include?(name)
@@ -1242,13 +1312,13 @@ class GraphWeaver::Codegen
1242
1312
  when List then "[#{signature(node.of)}]"
1243
1313
  when NarrowedNode then "?#{signature(node.nested)}"
1244
1314
  when Scalar then "s:#{node.bare_type}"
1245
- when EnumNode then "e:#{node.values.sort.join("|")}"
1315
+ when EnumNode then "e:#{node.values.sort.join("|")}#{"+" if node.fallback?}"
1246
1316
  when MappedEnum then "m:#{node.graphql_name}"
1247
1317
  when ObjectNode
1248
1318
  inner = node.fields.map { |f| "#{f.prop}=#{signature(f.node)}" }.sort.join(",")
1249
1319
  "o:#{node.graphql_type}(#{inner})"
1250
1320
  when UnionNode then "u:(#{union_signature(node.members, node.catch_all)})"
1251
- when UnionRefNode then "ur:#{node.class_name}" # hoisted — identity is its shared name
1321
+ when HoistedRefNode then "hr:#{node.class_name}" # hoisted — identity is its shared name
1252
1322
  else "x:#{node.object_id}" # unknown node kind — never collapse
1253
1323
  end
1254
1324
  end
@@ -1315,7 +1385,8 @@ class GraphWeaver::Codegen
1315
1385
  child = type_ref(argument.type) { variable_core(argument.type.unwrap) }
1316
1386
  @input_hops.pop
1317
1387
  required = child.non_null? && !argument.default_value?
1318
- node.fields << InputNode::Field.new(prop, argument.graphql_name, child, required)
1388
+ node.fields << InputNode::Field.new(prop, argument.graphql_name, child, required,
1389
+ argument.type.to_type_signature)
1319
1390
  end
1320
1391
  check_input_props!(core, node)
1321
1392
  node
@@ -1396,15 +1467,22 @@ class GraphWeaver::Codegen
1396
1467
  "constant — map it onto one of yours: register_enum(#{core.graphql_name.inspect}, YourEnum)"
1397
1468
  end
1398
1469
 
1399
- EnumNode.new(class_name, enum_values(core))
1400
- end
1401
-
1402
- # A schema enum's wire values, sorted so output is deterministic across schema
1403
- # sources (SDL round-trips reorder values alphabetically). Values that differ
1404
- # only in case name the same T::Enum constant, which raises at LOAD time
1405
- # ("Enum values must be assigned to constants") — catch it here instead.
1406
- def enum_values(core)
1407
- values = core.values.keys.sort
1470
+ entry = @registry.enum_registry[core.graphql_name]
1471
+ aliases = entry&.aliases_for(core.values.keys.sort) || {}
1472
+ values = enum_values(core, aliases)
1473
+ # named the way a union's catch-all is: Other, or Other2 past a declared OTHER
1474
+ fallback = catch_all_name(values.map { |value| camelize(value.downcase) }) if entry&.generated_fallback?
1475
+ EnumNode.new(class_name, values, aliases, fallback:)
1476
+ end
1477
+
1478
+ # A schema enum's constant-bearing wire values, sorted so output is
1479
+ # deterministic across schema sources (SDL round-trips reorder values
1480
+ # alphabetically). An aliased spelling is read as another of the values, so
1481
+ # it gets no constant. Values that differ only in case name the same T::Enum
1482
+ # constant, which raises at LOAD time ("Enum values must be assigned to
1483
+ # constants") — catch it here instead.
1484
+ def enum_values(core, aliases = {})
1485
+ values = core.values.keys.sort - aliases.keys
1408
1486
  # `_` and `__` are legal GraphQL enum values and camelize to nothing, so
1409
1487
  # the emitted `= new("_")` isn't even parseable — the file fails at load
1410
1488
  # with a syntax error pointing into generated source
@@ -1415,12 +1493,18 @@ class GraphWeaver::Codegen
1415
1493
  "one of yours: register_enum(#{core.graphql_name.inspect}, YourEnum)"
1416
1494
  end
1417
1495
 
1418
- collision = values.group_by { |value| camelize(value.downcase) }.find { |_, group| group.size > 1 }
1419
- if collision
1496
+ # A schema mid-rename declares every value twice, so every pair collides:
1497
+ # name one, count the rest, and print the registration that fixes them all.
1498
+ collisions = values.group_by { |value| camelize(value.downcase) }.select { |_, group| group.size > 1 }
1499
+ if collisions.any?
1500
+ constant, group = collisions.first
1501
+ more = collisions.size - 1
1420
1502
  raise GraphWeaver::Error,
1421
- "enum #{core.graphql_name} values #{collision.last.join(" and ")} both become the constant " \
1422
- "#{collision.first} — map the enum onto one of yours: " \
1423
- "register_enum(#{core.graphql_name.inspect}, YourEnum)"
1503
+ "enum #{core.graphql_name} values #{group.join(" and ")} both become the constant #{constant}" \
1504
+ "#{" (and #{more} more colliding pair#{"s" if more > 1})" unless more.zero?} — if each pair is one " \
1505
+ "value, say which spelling goes on the wire:\n " \
1506
+ "#{EnumType.alias_suggestion(core.graphql_name, collisions.values)}\n" \
1507
+ "or map the enum onto one of yours: register_enum(#{core.graphql_name.inspect}, YourEnum)"
1424
1508
  end
1425
1509
 
1426
1510
  values
@@ -1432,14 +1516,83 @@ class GraphWeaver::Codegen
1432
1516
  return [] unless entry
1433
1517
 
1434
1518
  @requires.concat(entry[:requires])
1435
- entry[:mixins].map(&:name)
1519
+ names = entry[:mixins].map(&:name)
1520
+ # GraphWeaver::TypeHelpers is where extend_type mints a block's mixin, and
1521
+ # a module there exists in no source file — so generation has to declare it
1522
+ @block_helpers.concat(names.grep(BLOCK_HELPER))
1523
+ names
1524
+ end
1525
+
1526
+ # A block-built mixin's constant, by the namespace extend_type mints it under.
1527
+ BLOCK_HELPER = /\AGraphWeaver::TypeHelpers::/
1528
+ private_constant :BLOCK_HELPER
1529
+
1530
+ # Struct members a registered mixin declares abstract (docs/generated_modules.md
1531
+ # recommends that shape so a named helper carries sigs srb tc can check).
1532
+ # Sorbet demands the override be declared on whatever satisfies an abstract
1533
+ # sig, and nothing generation writes can be corrected by hand — a `const` has
1534
+ # no sig to insert `override.` into — so codegen derives it, the same way it
1535
+ # derives an alias's type. Mixin ancestors count: an inherited abstract sig is
1536
+ # one Sorbet demands the override for just the same.
1537
+ def abstract_mixin_members(graphql_name)
1538
+ entry = @registry.type_registry[graphql_name]
1539
+ return [] unless entry
1540
+
1541
+ entry[:mixins].flat_map { |mixin|
1542
+ T::AbstractUtils.declared_abstract_methods_for(mixin).map { |method| method.name.to_s }
1543
+ }.uniq
1544
+ end
1545
+ private :abstract_mixin_members
1546
+
1547
+ # An `abstract!` mixin goes into EVERY struct generated from its type, but
1548
+ # only a struct that selected the members it declares can satisfy them — so a
1549
+ # query selecting a subset generated fine and failed in the app's own
1550
+ # `srb tc`, two tools from the query that fell short. Walked from the root the
1551
+ # way shadowing is, so the refusal can name the struct by its path.
1552
+ def check_abstract_mixins!(node, path = [])
1553
+ case node
1554
+ when UnionNode
1555
+ inner = path + [node.class_name]
1556
+ (node.members.each_value.to_a + [node.catch_all]).each { |member| check_abstract_mixins!(member, inner) }
1557
+ when ObjectNode
1558
+ 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 }
1561
+ end
1562
+ end
1563
+
1564
+ def refuse_unsatisfiable_mixin!(node, path)
1565
+ return if node.overrides.empty?
1566
+
1567
+ mixins = @registry.type_registry.dig(node.graphql_type, :mixins) || []
1568
+ provided = (node.fields.map(&:prop) + node.aliases.map(&:name)).to_set
1569
+ # what is still abstract with every registered mixin included: a member one
1570
+ # of them implements for another is already answered
1571
+ probe = Module.new
1572
+ mixins.each { |mixin| probe.include(mixin) }
1573
+ missing = T::AbstractUtils.abstract_methods_for(probe)
1574
+ .map { |method| method.name.to_s }.reject { |member| provided.include?(member) }.sort
1575
+ return if missing.empty?
1576
+
1577
+ mixin = mixins.find { |m|
1578
+ T::AbstractUtils.declared_abstract_methods_for(m).any? { |method| missing.include?(method.name.to_s) }
1579
+ }
1580
+ raise GraphWeaver::Error,
1581
+ "#{[@name, *path].join("::")} includes #{mixin.name}, which declares " \
1582
+ "#{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."
1436
1587
  end
1588
+ private :check_abstract_mixins!, :refuse_unsatisfiable_mixin!
1437
1589
 
1438
1590
  # The MappedEnum node for a schema enum with a registered app-enum
1439
- # mapping; nil when unregistered, falling back to a generated T::Enum.
1591
+ # mapping; nil when unregistered or registered for alias: alone, which
1592
+ # says nothing about the Ruby type — falling back to a generated T::Enum.
1440
1593
  def mapped_enum_node(core)
1441
1594
  enum_type = @registry.enum_registry[core.graphql_name]
1442
- return unless enum_type
1595
+ return unless enum_type&.type
1443
1596
 
1444
1597
  @requires.concat(enum_type.requires)
1445
1598
  @mapped_enums[core.graphql_name] ||= MappedEnum.new(enum_type, core.values.keys.sort)