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
@@ -1,7 +1,7 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
- # Generated by GraphWeaver 0.7.3 — do not edit.
4
+ # Generated by GraphWeaver 0.7.4 — do not edit.
5
5
 
6
6
  require "time"
7
7
 
@@ -85,6 +85,14 @@ module StargazersQuery
85
85
  rescue StandardError => e
86
86
  raise GraphWeaver::CastError.new(struct: self, message: GraphWeaver::Hints.cast_message(self, data, e))
87
87
  end
88
+
89
+ sig { params(_options: T.untyped).returns(T::Hash[String, T.untyped]) }
90
+ def as_json(*_options)
91
+ {
92
+ "nameWithOwner" => name_with_owner,
93
+ "stargazerCount" => stargazer_count,
94
+ }
95
+ end
88
96
  end
89
97
 
90
98
  const :nodes, T.nilable(T::Array[T.nilable(Nodes)])
@@ -99,6 +107,13 @@ module StargazersQuery
99
107
  rescue StandardError => e
100
108
  raise GraphWeaver::CastError.new(struct: self, message: GraphWeaver::Hints.cast_message(self, data, e))
101
109
  end
110
+
111
+ sig { params(_options: T.untyped).returns(T::Hash[String, T.untyped]) }
112
+ def as_json(*_options)
113
+ {
114
+ "nodes" => nodes&.then { |v1| v1.map { |v2| v2&.then { |v3| v3.as_json } } },
115
+ }
116
+ end
102
117
  end
103
118
 
104
119
  const :login, String
@@ -117,6 +132,15 @@ module StargazersQuery
117
132
  rescue StandardError => e
118
133
  raise GraphWeaver::CastError.new(struct: self, message: GraphWeaver::Hints.cast_message(self, data, e))
119
134
  end
135
+
136
+ sig { params(_options: T.untyped).returns(T::Hash[String, T.untyped]) }
137
+ def as_json(*_options)
138
+ {
139
+ "login" => login,
140
+ "name" => name,
141
+ "repositories" => repositories.as_json,
142
+ }
143
+ end
120
144
  end
121
145
 
122
146
  const :starred_at, Time
@@ -125,7 +149,7 @@ module StargazersQuery
125
149
  sig { params(data: T::Hash[String, T.untyped]).returns(Edges) }
126
150
  def self.from_h(data)
127
151
  new(
128
- starred_at: GraphWeaver::Hints.field(self, "starredAt") { Time.parse(data.fetch("starredAt")) },
152
+ starred_at: GraphWeaver::Hints.field(self, "starredAt") { Time.iso8601(data.fetch("starredAt")) },
129
153
  node: Node.from_h(data.fetch("node")),
130
154
  )
131
155
  rescue GraphWeaver::Error
@@ -133,6 +157,14 @@ module StargazersQuery
133
157
  rescue StandardError => e
134
158
  raise GraphWeaver::CastError.new(struct: self, message: GraphWeaver::Hints.cast_message(self, data, e))
135
159
  end
160
+
161
+ sig { params(_options: T.untyped).returns(T::Hash[String, T.untyped]) }
162
+ def as_json(*_options)
163
+ {
164
+ "starredAt" => GraphWeaver::Coerce.timestamp(starred_at),
165
+ "node" => node.as_json,
166
+ }
167
+ end
136
168
  end
137
169
 
138
170
  const :edges, T.nilable(T::Array[T.nilable(Edges)])
@@ -147,6 +179,13 @@ module StargazersQuery
147
179
  rescue StandardError => e
148
180
  raise GraphWeaver::CastError.new(struct: self, message: GraphWeaver::Hints.cast_message(self, data, e))
149
181
  end
182
+
183
+ sig { params(_options: T.untyped).returns(T::Hash[String, T.untyped]) }
184
+ def as_json(*_options)
185
+ {
186
+ "edges" => edges&.then { |v1| v1.map { |v2| v2&.then { |v3| v3.as_json } } },
187
+ }
188
+ end
150
189
  end
151
190
 
152
191
  const :id, String
@@ -167,6 +206,16 @@ module StargazersQuery
167
206
  rescue StandardError => e
168
207
  raise GraphWeaver::CastError.new(struct: self, message: GraphWeaver::Hints.cast_message(self, data, e))
169
208
  end
209
+
210
+ sig { params(_options: T.untyped).returns(T::Hash[String, T.untyped]) }
211
+ def as_json(*_options)
212
+ {
213
+ "id" => id,
214
+ "nameWithOwner" => name_with_owner,
215
+ "stargazerCount" => stargazer_count,
216
+ "stargazers" => stargazers.as_json,
217
+ }
218
+ end
170
219
  end
171
220
 
172
221
  const :repository, T.nilable(Repository)
@@ -181,6 +230,13 @@ module StargazersQuery
181
230
  rescue StandardError => e
182
231
  raise GraphWeaver::CastError.new(struct: self, message: GraphWeaver::Hints.cast_message(self, data, e))
183
232
  end
233
+
234
+ sig { params(_options: T.untyped).returns(T::Hash[String, T.untyped]) }
235
+ def as_json(*_options)
236
+ {
237
+ "repository" => repository&.then { |v1| v1.as_json },
238
+ }
239
+ end
184
240
  end
185
241
 
186
242
  # client — see GraphWeaver::QueryModule
@@ -196,9 +252,9 @@ module StargazersQuery
196
252
  sig { params(owner: String, name: String, first: Integer, client: T.untyped).returns(GraphWeaver::Response[Result]).checked(:never) }
197
253
  def self.execute(owner:, name:, first:, client: nil)
198
254
  variables = {
199
- "owner" => GraphWeaver::Coerce.variable("owner", OPERATION_NAME, owner) { |v| GraphWeaver::Coerce.string(v) },
200
- "name" => GraphWeaver::Coerce.variable("name", OPERATION_NAME, name) { |v| GraphWeaver::Coerce.string(v) },
201
- "first" => GraphWeaver::Coerce.variable("first", OPERATION_NAME, first) { |v| GraphWeaver::Coerce.integer(v) },
255
+ "owner" => GraphWeaver::Coerce.variable("owner", OPERATION_NAME, owner) { |v| GraphWeaver::Coerce.string(v, "String") },
256
+ "name" => GraphWeaver::Coerce.variable("name", OPERATION_NAME, name) { |v| GraphWeaver::Coerce.string(v, "String") },
257
+ "first" => GraphWeaver::Coerce.variable("first", OPERATION_NAME, first) { |v| GraphWeaver::Coerce.integer(v, "Int") },
202
258
  }
203
259
 
204
260
  from_response(dispatch(variables, client:))
@@ -1,7 +1,7 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
- # Generated by GraphWeaver 0.7.3 — do not edit.
4
+ # Generated by GraphWeaver 0.7.4 — do not edit.
5
5
 
6
6
  module StarredQuery
7
7
  extend T::Sig
@@ -57,6 +57,14 @@ module StarredQuery
57
57
  rescue StandardError => e
58
58
  raise GraphWeaver::CastError.new(struct: self, message: GraphWeaver::Hints.cast_message(self, data, e))
59
59
  end
60
+
61
+ sig { params(_options: T.untyped).returns(T::Hash[String, T.untyped]) }
62
+ def as_json(*_options)
63
+ {
64
+ "nameWithOwner" => name_with_owner,
65
+ "stargazerCount" => stargazer_count,
66
+ }
67
+ end
60
68
  end
61
69
 
62
70
  const :total_count, Integer
@@ -73,6 +81,14 @@ module StarredQuery
73
81
  rescue StandardError => e
74
82
  raise GraphWeaver::CastError.new(struct: self, message: GraphWeaver::Hints.cast_message(self, data, e))
75
83
  end
84
+
85
+ sig { params(_options: T.untyped).returns(T::Hash[String, T.untyped]) }
86
+ def as_json(*_options)
87
+ {
88
+ "totalCount" => total_count,
89
+ "nodes" => nodes&.then { |v1| v1.map { |v2| v2&.then { |v3| v3.as_json } } },
90
+ }
91
+ end
76
92
  end
77
93
 
78
94
  const :starred_repositories, StarredRepositories
@@ -87,6 +103,13 @@ module StarredQuery
87
103
  rescue StandardError => e
88
104
  raise GraphWeaver::CastError.new(struct: self, message: GraphWeaver::Hints.cast_message(self, data, e))
89
105
  end
106
+
107
+ sig { params(_options: T.untyped).returns(T::Hash[String, T.untyped]) }
108
+ def as_json(*_options)
109
+ {
110
+ "starredRepositories" => starred_repositories.as_json,
111
+ }
112
+ end
90
113
  end
91
114
 
92
115
  const :user, T.nilable(User)
@@ -101,6 +124,13 @@ module StarredQuery
101
124
  rescue StandardError => e
102
125
  raise GraphWeaver::CastError.new(struct: self, message: GraphWeaver::Hints.cast_message(self, data, e))
103
126
  end
127
+
128
+ sig { params(_options: T.untyped).returns(T::Hash[String, T.untyped]) }
129
+ def as_json(*_options)
130
+ {
131
+ "user" => user&.then { |v1| v1.as_json },
132
+ }
133
+ end
104
134
  end
105
135
 
106
136
  # client — see GraphWeaver::QueryModule
@@ -116,8 +146,8 @@ module StarredQuery
116
146
  sig { params(login: String, first: Integer, client: T.untyped).returns(GraphWeaver::Response[Result]).checked(:never) }
117
147
  def self.execute(login:, first:, client: nil)
118
148
  variables = {
119
- "login" => GraphWeaver::Coerce.variable("login", OPERATION_NAME, login) { |v| GraphWeaver::Coerce.string(v) },
120
- "first" => GraphWeaver::Coerce.variable("first", OPERATION_NAME, first) { |v| GraphWeaver::Coerce.integer(v) },
149
+ "login" => GraphWeaver::Coerce.variable("login", OPERATION_NAME, login) { |v| GraphWeaver::Coerce.string(v, "String") },
150
+ "first" => GraphWeaver::Coerce.variable("first", OPERATION_NAME, first) { |v| GraphWeaver::Coerce.integer(v, "Int") },
121
151
  }
122
152
 
123
153
  from_response(dispatch(variables, client:))
@@ -119,6 +119,28 @@ module GraphWeaver
119
119
  end
120
120
  end
121
121
 
122
+ # The same thing the header and the Exclude above say, told to GitHub:
123
+ # linguist-generated collapses these files in a diff ("Load diff") and
124
+ # drops them from the language breakdown. Display only — they stay
125
+ # versioned, expandable, and `git diff` here is untouched.
126
+ #
127
+ # Written even when there is no .gitattributes, unlike .rubocop.yml: a
128
+ # .gitattributes turns no tooling on, so there is no app this surprises.
129
+ def mark_generated_for_github
130
+ body = File.read(gitattributes) if File.exist?(gitattributes)
131
+ # already marked — a re-run, or done by hand
132
+ globs = linguist_globs.reject { |glob| body&.include?(glob) }
133
+ return if globs.empty?
134
+
135
+ lines = globs.map { |glob| "#{glob} linguist-generated" }.join("\n")
136
+ marks = <<~TEXT + lines + "\n"
137
+ # Machine-written by `rake graph_weaver:generate` — GitHub collapses it in
138
+ # diffs and leaves it out of the language breakdown.
139
+ TEXT
140
+
141
+ body ? append_to_file(GITATTRIBUTES, "\n#{marks}") : create_file(GITATTRIBUTES, marks)
142
+ end
143
+
122
144
  # The `graphql:` tags need this require, and it has to be somewhere
123
145
  # rspec actually loads. A spec/support file is not: rspec-rails ships
124
146
  # the spec/support glob commented out, so the require sat there doing
@@ -191,6 +213,7 @@ module GraphWeaver
191
213
  private
192
214
 
193
215
  RUBOCOP_CONFIG = ".rubocop.yml"
216
+ GITATTRIBUTES = ".gitattributes"
194
217
 
195
218
  # rails_helper first: rspec-rails writes both, and only rails_helper
196
219
  # has Rails booted by the time the require runs.
@@ -203,6 +226,8 @@ module GraphWeaver
203
226
 
204
227
  def rubocop_config = File.join(GraphWeaver.root, RUBOCOP_CONFIG)
205
228
 
229
+ def gitattributes = File.join(GraphWeaver.root, GITATTRIBUTES)
230
+
206
231
  # Parsed, not counted: a `---` can also be a line inside a block scalar.
207
232
  # A file rubocop itself can't read is left to rubocop to complain about.
208
233
  def yaml_documents(body)
@@ -213,9 +238,13 @@ module GraphWeaver
213
238
 
214
239
  # Every graph's output directory, so a multi-schema app is covered by
215
240
  # the same run — read off the graphs rather than restated here.
216
- def generated_globs
217
- GraphWeaver.graphs.map { |graph| File.join(graph.output, "**/*") }.uniq
218
- end
241
+ def generated_outputs = GraphWeaver.graphs.map(&:output).uniq
242
+
243
+ def generated_globs = generated_outputs.map { |dir| File.join(dir, "**/*") }
244
+
245
+ # gitattributes patterns are gitignore-style, where `dir/**` is
246
+ # everything beneath dir — spelled as git reads it, not as rubocop does.
247
+ def linguist_globs = generated_outputs.map { |dir| File.join(dir, "**") }
219
248
 
220
249
  # This install run is the one moment the user is guaranteed to be
221
250
  # reading, and a composed supergraph changes what the next steps are:
@@ -190,6 +190,29 @@ class GraphWeaver::Client
190
190
  run(query, **variables).data!
191
191
  end
192
192
 
193
+ # Does this query validate? The string form of GraphWeaver.check_queries,
194
+ # answering with the same JSON-ready hashes — `message`, `line`, `column`,
195
+ # plus `subgraphs` where a supergraph brands them — so an empty array means
196
+ # it validates:
197
+ #
198
+ # client.check_query("query { viewer { login } }") # => []
199
+ # client.check_query("query { viewer { lgoin } }")
200
+ # # => [{ "message" => "Field 'lgoin' doesn't exist on type 'User'",
201
+ # # "line" => 1, "column" => 17 }]
202
+ #
203
+ # Checked against this client's own schema — what `execute` would run
204
+ # against — so a url client introspects on first use as it always does, and
205
+ # nothing re-introspects the way check_queries defaults to. An unparseable
206
+ # source is an entry like any other; nothing here raises for a bad query.
207
+ # Shared fragments are inlined from fragments: the same way every other door
208
+ # inlines them.
209
+ def check_query(source, fragments: GraphWeaver.fragments_paths)
210
+ GraphWeaver::Internal::QueryCheck.errors(
211
+ schema, source, GraphWeaver::Codegen.load_fragments(fragments),
212
+ GraphWeaver::Internal::QueryCheck.routing_table_for(schema_source),
213
+ )
214
+ end
215
+
193
216
  private
194
217
 
195
218
  # Anything already speaking the client contract — another Client,
@@ -119,6 +119,7 @@ class GraphWeaver::Codegen
119
119
  nilable = true
120
120
  else
121
121
  obj = object_of(cur)
122
+ refuse_hoisted!(node, name, hoisted_of(cur), seg)
122
123
  unless obj
123
124
  hint = if list_of(cur)
124
125
  " — use .first or .last to pick an element"
@@ -155,6 +156,19 @@ class GraphWeaver::Codegen
155
156
  ObjectNode::Alias.new(name, expr, type)
156
157
  end
157
158
 
159
+ # A hop landing on a hoisted shared fragment: the struct is another
160
+ # module's, so the path stops here. Which query hoists is a property of how
161
+ # that query spreads the fragment, so optional: still skips it.
162
+ def refuse_hoisted!(node, name, ref, seg)
163
+ return unless ref
164
+
165
+ raise GraphWeaver::Error,
166
+ "alias #{name.inspect} on #{node.graphql_type}: '#{seg}' is inside the shared fragment " \
167
+ "#{ref.class_name}, which hoists to #{@types_namespace}::#{ref.class_name} — a path can't " \
168
+ "read into it. Register the alias on #{ref.graphql_type}, or select a field beside the " \
169
+ "spread to keep the struct local"
170
+ end
171
+
158
172
  # Separate "this query didn't select it" from "no query could": a segment
159
173
  # the schema doesn't declare on the type is a mistake in the registration,
160
174
  # so it raises even for an optional alias — which otherwise turns a typo
@@ -185,7 +199,7 @@ class GraphWeaver::Codegen
185
199
  # The leaf's Sorbet type as referenced from the aliased struct. Generated
186
200
  # nested constants (structs, enums, unions) must carry the container path,
187
201
  # since the delegator's `sig` is emitted in an outer struct where a bare
188
- # `Sub` wouldn't resolve; scalars, mapped enums, and hoisted union refs are
202
+ # `Sub` wouldn't resolve; scalars, mapped enums, and hoisted refs are
189
203
  # already top-level. `containers` is the class-name chain to the leaf.
190
204
  def qualified_alias_type(node, containers)
191
205
  node = node.of if node.is_a?(NonNull)
@@ -203,10 +217,17 @@ class GraphWeaver::Codegen
203
217
  # there from the shared enums module — either way, no container prefix
204
218
  when EnumNode then node.class_name
205
219
  when UnionNode then "#{prefix}#{node.bare_type}"
206
- else node.bare_type # Scalar, MappedEnum, UnionRefNode — already top-level
220
+ else node.bare_type # Scalar, MappedEnum, a hoisted ref — already top-level
207
221
  end
208
222
  end
209
223
 
224
+ # the hoisted-fragment reference a node resolves to (through NON_NULL), or nil
225
+ def hoisted_of(node)
226
+ node = T.let(node, T.untyped)
227
+ node = node.of while node.is_a?(NonNull)
228
+ node if node.is_a?(HoistedRefNode)
229
+ end
230
+
210
231
  # the List a node wraps (through NON_NULL), or nil
211
232
  def list_of(node)
212
233
  node = T.let(node, T.untyped)
@@ -97,7 +97,7 @@ class GraphWeaver::Codegen
97
97
  # both reference it by that name.
98
98
  def shared_enum_names
99
99
  @mapped_enums.each_value.flat_map { |m| ["#{m.const_prefix}_FROM_WIRE", "#{m.const_prefix}_TO_WIRE"] } +
100
- @enums.each_value.map(&:class_name)
100
+ @enums.each_value.flat_map { |e| e.aliased? ? [e.class_name, e.alias_const] : [e.class_name] }
101
101
  end
102
102
 
103
103
  # Inputs: only the variable root types — the names this module's own
@@ -130,8 +130,8 @@ class GraphWeaver::Codegen
130
130
  # The shared types artifact as files: one file per type under types/, plus
131
131
  # types.rb — the manifest that requires them in the order the runtime needs
132
132
  # (see below). One rule for all three kinds, so a schema migration diffs
133
- # exactly the types it touched whether they're inputs, enums or unions.
134
- def emit_types_files(unions)
133
+ # exactly the types it touched whether they're inputs, enums or fragments.
134
+ def emit_types_files(fragments)
135
135
  files = {}
136
136
  # a mapped enum's constants are its wire tables, but the file is still
137
137
  # named for the GraphQL enum — one type, one file, whichever it is
@@ -142,15 +142,15 @@ class GraphWeaver::Codegen
142
142
  }
143
143
  inputs, = ordered_inputs
144
144
  structs = inputs.map { |input| type_file(files, input.class_name) { |out| emit_input(input, out, 1) } }
145
- hoisted = unions.map { |union| type_file(files, union.class_name) { |out| emit_union(union, out, 1) } }
145
+ hoisted = fragments.map { |node| type_file(files, node.class_name) { |out| emit_nested(node, out, 1) } }
146
146
 
147
147
  out = []
148
148
  out << "# typed: strict"
149
149
  out << "# frozen_string_literal: true"
150
150
  out << ""
151
151
  out << "# Generated by GraphWeaver #{GraphWeaver::VERSION} — do not edit. Shared types for this schema —"
152
- out << "# input types, enums, and unions hoisted from shared fragments — one file"
153
- out << "# per type; query modules alias what they use."
152
+ out << "# input types, enums, and the types hoisted out of shared fragments — one"
153
+ out << "# file per type; query modules alias what they use."
154
154
  out << ""
155
155
  requires = @requires.uniq.sort
156
156
  if requires.any?
@@ -175,7 +175,7 @@ class GraphWeaver::Codegen
175
175
  # both spell them bare, and a T::Enum can't be forward-declared the way
176
176
  # an input struct can
177
177
  if enums.any? && (structs.any? || hoisted.any?)
178
- out << "# enums first — input structs and union members spell them bare"
178
+ out << "# enums first — input structs and hoisted fragments spell them bare"
179
179
  end
180
180
  (enums.sort + structs.sort + hoisted.sort).each do |file|
181
181
  out << "require_relative #{file.delete_suffix(".rb").inspect}"
@@ -210,11 +210,11 @@ class GraphWeaver::Codegen
210
210
  # assembled from the generator's walked state.
211
211
  def emit_module(root, variables, representations = [], operation_name = nil)
212
212
  # Every shared type this module names: its variable root inputs, the
213
- # enums it reached, and the unions it hoisted (aliased so <Name>::Type
214
- # and <Name>.from_h resolve to the shared module).
213
+ # enums it reached, and the fragments it hoisted (aliased so <Name> and
214
+ # <Name>.from_h resolve to the shared module).
215
215
  aliases = if @types_namespace
216
216
  (shared_input_names(variables) + shared_enum_names +
217
- @used_unions.map { |name| camelize(name) }).uniq.sort
217
+ @used_fragments.map { |name| camelize(name) }).uniq.sort
218
218
  else
219
219
  []
220
220
  end
@@ -338,11 +338,23 @@ class GraphWeaver::Codegen
338
338
  node.values.each do |value|
339
339
  out << "#{pad} #{camelize(value.downcase)} = new(#{value.inspect})"
340
340
  end
341
+ if node.fallback?
342
+ out << "#{pad} # every other wire value casts here (register_enum fallback: true)"
343
+ out << "#{pad} #{node.fallback} = new(#{GraphWeaver::Internal::ENUM_FALLBACK_WIRE.inspect})"
344
+ end
341
345
  out << "#{pad} end"
342
346
  out << "#{pad}end"
347
+ return unless node.aliased?
348
+
349
+ out << "#{pad}# wire spellings read as another value of #{node.class_name} (register_enum alias:)"
350
+ out << "#{pad}#{node.alias_const} = T.let({"
351
+ node.aliases.each { |from, to| out << "#{pad} #{from.inspect} => #{to.inspect}," }
352
+ out << "#{pad}}.freeze, T::Hash[String, String])"
343
353
  end
344
354
 
345
- # module-level wire translation tables for an app-mapped enum
355
+ # module-level wire translation tables for an app-mapped enum. TO_WIRE is
356
+ # written out rather than inverted from FROM_WIRE: an alias puts two
357
+ # spellings on one member, and only one of them goes back out.
346
358
  def emit_mapped_enum(node, out, indent)
347
359
  pad = " " * indent
348
360
  type = node.bare_type
@@ -354,7 +366,11 @@ class GraphWeaver::Codegen
354
366
  out << "#{pad} #{wire.inspect} => #{type}.deserialize(#{member.serialize.to_s.inspect}),"
355
367
  end
356
368
  out << "#{pad}}.freeze, T::Hash[String, #{type}])"
357
- out << "#{pad}#{prefix}_TO_WIRE = T.let(#{prefix}_FROM_WIRE.invert.freeze, T::Hash[#{type}, String])"
369
+ out << "#{pad}#{prefix}_TO_WIRE = T.let({"
370
+ node.to_wire.each do |member, wire|
371
+ out << "#{pad} #{type}.deserialize(#{member.serialize.to_s.inspect}) => #{wire.inspect},"
372
+ end
373
+ out << "#{pad}}.freeze, T::Hash[#{type}, String])"
358
374
  end
359
375
 
360
376
  def emit_object(node, out, indent)
@@ -383,7 +399,10 @@ class GraphWeaver::Codegen
383
399
  node.fields.each do |field|
384
400
  note = renamed_note(field.prop, field.key)
385
401
  out << "#{pad} #{note}" if note
386
- out << "#{pad} const :#{field.prop}, #{field.node.prop_type}"
402
+ # a prop satisfying a mixin's abstract sig has to declare the override,
403
+ # and a `const` says it as a rule — there is no sig to put it in
404
+ override = node.overrides.include?(field.prop) ? ", override: true" : ""
405
+ out << "#{pad} const :#{field.prop}, #{field.node.prop_type}#{override}"
387
406
  end
388
407
 
389
408
  out << ""
@@ -418,7 +437,7 @@ class GraphWeaver::Codegen
418
437
  # selected field onto the struct, next to the honest wire data
419
438
  node.aliases.each do |a|
420
439
  out << ""
421
- out << "#{pad} sig { returns(#{a.type}) }"
440
+ out << "#{pad} sig { #{"override." if node.overrides.include?(a.name)}returns(#{a.type}) }"
422
441
  out << "#{pad} def #{a.name} = #{a.expr}"
423
442
  end
424
443
  out << "#{pad}end"
@@ -677,13 +696,13 @@ class GraphWeaver::Codegen
677
696
  out << "#{pad} const :#{field.prop}, #{type}#{default}"
678
697
  end
679
698
  out << ""
680
- out << "#{pad} # (prop, wire, required, serializer, coercer, coordinate) per field"
699
+ out << "#{pad} # (prop, wire, required, serializer, coercer, coordinate, type) per field"
681
700
  out << "#{pad} FIELDS = T.let(["
682
701
  node.fields.each do |field|
683
702
  serializer = field.node.serialize_identity? ? "nil" : "->(v) { #{field.node.serialize("v", 1)} }"
684
703
  coercer = field.node.hash_coerce_identity? ? "nil" : "->(v) { #{field.node.hash_coerce("v", 1)} }"
685
704
  coordinate = "#{node.graphql_name}.#{field.wire}"
686
- out << "#{pad} GraphWeaver::InputStruct::Field.new(:#{field.prop}, #{field.wire.inspect}, #{field.required}, #{serializer}, #{coercer}, #{coordinate.inspect}),"
705
+ out << "#{pad} GraphWeaver::InputStruct::Field.new(:#{field.prop}, #{field.wire.inspect}, #{field.required}, #{serializer}, #{coercer}, #{coordinate.inspect}, #{field.type.inspect}),"
687
706
  end
688
707
  out << "#{pad} ].freeze, T::Array[GraphWeaver::InputStruct::Field])"
689
708
  # InputStruct reads it with const_get, which privacy doesn't block