graph_weaver 0.7.2 → 0.7.4

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.
@@ -1,7 +1,7 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
- # Generated by GraphWeaver 0.7.2 — 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.2 — 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:))
@@ -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,
@@ -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
@@ -340,9 +340,17 @@ class GraphWeaver::Codegen
340
340
  end
341
341
  out << "#{pad} end"
342
342
  out << "#{pad}end"
343
+ return unless node.aliased?
344
+
345
+ out << "#{pad}# wire spellings read as another value of #{node.class_name} (register_enum alias:)"
346
+ out << "#{pad}#{node.alias_const} = T.let({"
347
+ node.aliases.each { |from, to| out << "#{pad} #{from.inspect} => #{to.inspect}," }
348
+ out << "#{pad}}.freeze, T::Hash[String, String])"
343
349
  end
344
350
 
345
- # module-level wire translation tables for an app-mapped enum
351
+ # module-level wire translation tables for an app-mapped enum. TO_WIRE is
352
+ # written out rather than inverted from FROM_WIRE: an alias puts two
353
+ # spellings on one member, and only one of them goes back out.
346
354
  def emit_mapped_enum(node, out, indent)
347
355
  pad = " " * indent
348
356
  type = node.bare_type
@@ -354,7 +362,11 @@ class GraphWeaver::Codegen
354
362
  out << "#{pad} #{wire.inspect} => #{type}.deserialize(#{member.serialize.to_s.inspect}),"
355
363
  end
356
364
  out << "#{pad}}.freeze, T::Hash[String, #{type}])"
357
- out << "#{pad}#{prefix}_TO_WIRE = T.let(#{prefix}_FROM_WIRE.invert.freeze, T::Hash[#{type}, String])"
365
+ out << "#{pad}#{prefix}_TO_WIRE = T.let({"
366
+ node.to_wire.each do |member, wire|
367
+ out << "#{pad} #{type}.deserialize(#{member.serialize.to_s.inspect}) => #{wire.inspect},"
368
+ end
369
+ out << "#{pad}}.freeze, T::Hash[#{type}, String])"
358
370
  end
359
371
 
360
372
  def emit_object(node, out, indent)
@@ -383,7 +395,10 @@ class GraphWeaver::Codegen
383
395
  node.fields.each do |field|
384
396
  note = renamed_note(field.prop, field.key)
385
397
  out << "#{pad} #{note}" if note
386
- out << "#{pad} const :#{field.prop}, #{field.node.prop_type}"
398
+ # a prop satisfying a mixin's abstract sig has to declare the override,
399
+ # and a `const` says it as a rule — there is no sig to put it in
400
+ override = node.overrides.include?(field.prop) ? ", override: true" : ""
401
+ out << "#{pad} const :#{field.prop}, #{field.node.prop_type}#{override}"
387
402
  end
388
403
 
389
404
  out << ""
@@ -418,7 +433,7 @@ class GraphWeaver::Codegen
418
433
  # selected field onto the struct, next to the honest wire data
419
434
  node.aliases.each do |a|
420
435
  out << ""
421
- out << "#{pad} sig { returns(#{a.type}) }"
436
+ out << "#{pad} sig { #{"override." if node.overrides.include?(a.name)}returns(#{a.type}) }"
422
437
  out << "#{pad} def #{a.name} = #{a.expr}"
423
438
  end
424
439
  out << "#{pad}end"
@@ -677,13 +692,13 @@ class GraphWeaver::Codegen
677
692
  out << "#{pad} const :#{field.prop}, #{type}#{default}"
678
693
  end
679
694
  out << ""
680
- out << "#{pad} # (prop, wire, required, serializer, coercer, coordinate) per field"
695
+ out << "#{pad} # (prop, wire, required, serializer, coercer, coordinate, type) per field"
681
696
  out << "#{pad} FIELDS = T.let(["
682
697
  node.fields.each do |field|
683
698
  serializer = field.node.serialize_identity? ? "nil" : "->(v) { #{field.node.serialize("v", 1)} }"
684
699
  coercer = field.node.hash_coerce_identity? ? "nil" : "->(v) { #{field.node.hash_coerce("v", 1)} }"
685
700
  coordinate = "#{node.graphql_name}.#{field.wire}"
686
- out << "#{pad} GraphWeaver::InputStruct::Field.new(:#{field.prop}, #{field.wire.inspect}, #{field.required}, #{serializer}, #{coercer}, #{coordinate.inspect}),"
701
+ out << "#{pad} GraphWeaver::InputStruct::Field.new(:#{field.prop}, #{field.wire.inspect}, #{field.required}, #{serializer}, #{coercer}, #{coordinate.inspect}, #{field.type.inspect}),"
687
702
  end
688
703
  out << "#{pad} ].freeze, T::Array[GraphWeaver::InputStruct::Field])"
689
704
  # InputStruct reads it with const_get, which privacy doesn't block
@@ -2,8 +2,8 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  class GraphWeaver::Codegen
5
- # How one GraphQL enum maps onto an app-owned T::Enum, so generated
6
- # code speaks YOUR enum instead of generating one per module:
5
+ # What a register_enum said about one GraphQL enum: the app-owned T::Enum
6
+ # its values map onto, and which wire spellings are the same value.
7
7
  #
8
8
  # class PetKind < T::Enum
9
9
  # enums { Cat = new("cat"); Dog = new("dog") }
@@ -17,11 +17,23 @@ class GraphWeaver::Codegen
17
17
  # value the schema declares must resolve — generation fails naming the
18
18
  # gaps — unless fallback: names a member to absorb unknown values
19
19
  # (forward-compat for servers that add members; inputs stay strict).
20
+ #
21
+ # alias: { "legacy_mode" => "LEGACY_MODE" } says two wire values are one
22
+ # value — both spellings cast, the target is what serializes. It is the
23
+ # whole registration when there is no T::Enum to map onto, and then the
24
+ # generated enum gets one constant for the target and none for the alias.
20
25
  class EnumType
21
26
  attr_reader :graphql_name, :type, :fallback, :requires
22
27
 
23
- def initialize(graphql_name, type, map: nil, fallback: nil, requires: nil)
28
+ def initialize(graphql_name, type, map: nil, fallback: nil, requires: nil, aliases: nil)
24
29
  @graphql_name = graphql_name.to_s
30
+ @aliases = normalize_aliases!(aliases)
31
+ @type = type
32
+ @map = map || {}
33
+ @fallback = fallback
34
+
35
+ return alias_only!(map, fallback, requires) if type.nil?
36
+
25
37
  # A name, not the class, is what you write when the constant won't
26
38
  # resolve yet — which in Rails means a config/initializers file, since
27
39
  # autoloading is set up after those run. Say where it does resolve.
@@ -36,9 +48,6 @@ class GraphWeaver::Codegen
36
48
  raise ArgumentError, "type: must be a named constant (anonymous classes can't appear in generated source)"
37
49
  end
38
50
 
39
- @type = type
40
- @map = map || {}
41
- @fallback = fallback
42
51
  @requires = GraphWeaver::Codegen.normalize_requires!(requires, load: true)
43
52
 
44
53
  if fallback && !type.values.include?(fallback)
@@ -46,28 +55,128 @@ class GraphWeaver::Codegen
46
55
  end
47
56
  end
48
57
 
49
- # wire value => member for every value the schema declares; raises
50
- # naming the unmappable ones (unless fallback: absorbs them)
51
- def mapping_for(wire_values)
52
- mapping = {}
58
+ # The wire tables for a mapped enum: [wire value => member, member => the
59
+ # wire value that goes out]. Every spelling casts; an alias's target is the
60
+ # one that serializes.
61
+ def tables_for(wire_values)
62
+ aliases = aliases_for(wire_values)
63
+ from_wire = {}
53
64
  missing = []
54
65
 
55
66
  wire_values.each do |wire|
56
- member = @map[wire] || infer(wire)
57
- member ? mapping[wire] = member : missing << wire
67
+ canonical = aliases.fetch(wire, wire)
68
+ member = @map[canonical] || infer(canonical)
69
+ member ? from_wire[wire] = member : missing << canonical
58
70
  end
59
71
 
60
72
  if missing.any? && !fallback
61
73
  raise GraphWeaver::Error,
62
- "#{type} has no member for #{graphql_name} value(s) #{missing.join(", ")} — " \
74
+ "#{type} has no member for #{graphql_name} value(s) #{missing.uniq.join(", ")} — " \
63
75
  "add them, pin with map:, or absorb with fallback:"
64
76
  end
65
77
 
66
- mapping
78
+ [from_wire, to_wire(from_wire, aliases)]
79
+ end
80
+
81
+ # alias spelling => the value it is read as, checked against what the
82
+ # schema declares — an alias for or onto a value that isn't there is a
83
+ # registration this schema disproves.
84
+ def aliases_for(wire_values)
85
+ declared = wire_values.sort.join(", ")
86
+
87
+ @aliases.each do |from, to|
88
+ unless wire_values.include?(from)
89
+ raise GraphWeaver::Error,
90
+ "enum #{graphql_name} has no value #{from.inspect} to alias — its values are #{declared}; " \
91
+ "fix the spelling or drop the alias"
92
+ end
93
+ unless wire_values.include?(to)
94
+ raise GraphWeaver::Error,
95
+ "enum #{graphql_name}: alias #{from.inspect} => #{to.inspect} names no value of #{graphql_name} — " \
96
+ "its values are #{declared}; the target is the spelling that goes on the wire"
97
+ end
98
+ end
99
+
100
+ @aliases
101
+ end
102
+
103
+ # The register_enum a set of indistinguishable wire values needs, ready to
104
+ # paste. SCREAMING_CASE is the GraphQL convention, so the odd spelling is
105
+ # guessed as the alias — every caller's sentence says to check the direction.
106
+ def self.alias_suggestion(graphql_name, groups, type = nil)
107
+ pairs = groups.flat_map { |group|
108
+ target = group.find { |value| value == value.upcase } || group.first
109
+ (group - [target]).map { |value| "#{value.inspect} => #{target.inspect}" }
110
+ }
111
+
112
+ "GraphWeaver.register_enum(#{graphql_name.inspect}#{type ? ", #{type}" : ""}, " \
113
+ "alias: { #{pairs.join(", ")} })"
67
114
  end
68
115
 
69
116
  private
70
117
 
118
+ # Without a T::Enum there is nothing for map:/fallback:/requires: to
119
+ # describe, so alias: is the whole registration.
120
+ def alias_only!(map, fallback, requires)
121
+ if @aliases.empty?
122
+ raise ArgumentError, "register_enum(#{graphql_name.inspect}) says nothing about #{graphql_name} — " \
123
+ "pass the T::Enum to map it onto, or alias: { \"old\" => \"NEW\" } to read two wire values as one"
124
+ end
125
+
126
+ extra = { map:, fallback:, requires: }.compact.keys.first
127
+ if extra
128
+ raise ArgumentError,
129
+ "register_enum(#{graphql_name.inspect}, alias: {...}) takes no #{extra}: — that describes a T::Enum " \
130
+ "of your own, so pass one: register_enum(#{graphql_name.inspect}, YourEnum, alias: {...})"
131
+ end
132
+
133
+ @requires = []
134
+ end
135
+
136
+ # Sorted so generated source is stable across registration order.
137
+ def normalize_aliases!(aliases)
138
+ return {} if aliases.nil?
139
+
140
+ unless aliases.is_a?(Hash)
141
+ raise ArgumentError, "alias: is a hash of wire value => wire value, got #{aliases.inspect}"
142
+ end
143
+
144
+ pairs = aliases.to_h { |from, to| [from.to_s, to.to_s] }
145
+
146
+ self_alias = pairs.find { |from, to| from == to }
147
+ if self_alias
148
+ raise ArgumentError, "register_enum(#{graphql_name.inspect}): alias #{self_alias.first.inspect} => " \
149
+ "#{self_alias.last.inspect} reads a value as itself — drop it"
150
+ end
151
+
152
+ chained = pairs.keys.find { |from| pairs.value?(from) }
153
+ if chained
154
+ raise ArgumentError, "register_enum(#{graphql_name.inspect}): #{chained.inspect} is both an alias and " \
155
+ "the value an alias points at — an alias can't chain; point every spelling at the one that goes on the wire"
156
+ end
157
+
158
+ pairs.sort.to_h
159
+ end
160
+
161
+ # member => the one wire value it serializes to. Two spellings on one
162
+ # member with no alias saying which goes out is ambiguous — invert used to
163
+ # pick whichever came last, which for a deprecation pair was the dead one.
164
+ def to_wire(from_wire, aliases)
165
+ canonical = from_wire.except(*aliases.keys)
166
+ ambiguous = canonical.group_by { |_, member| member }.select { |_, pairs| pairs.size > 1 }
167
+ if ambiguous.any?
168
+ member, = ambiguous.first
169
+ groups = ambiguous.values.map { |pairs| pairs.map(&:first) }
170
+ more = ambiguous.size - 1
171
+ raise GraphWeaver::Error,
172
+ "enum #{graphql_name}: #{groups.first.join(" and ")} both map onto the #{type} member " \
173
+ "#{member.serialize.to_s.inspect}#{" (and #{more} more)" unless more.zero?} — say which spelling " \
174
+ "goes on the wire:\n #{EnumType.alias_suggestion(graphql_name, groups, type)}"
175
+ end
176
+
177
+ canonical.invert
178
+ end
179
+
71
180
  # "CAT" matches serialize "cat"; "NOT_FOUND" matches "not_found"
72
181
  def infer(wire)
73
182
  @type.values.find { |member| normalize(member.serialize.to_s) == normalize(wire) }
@@ -80,19 +189,23 @@ class GraphWeaver::Codegen
80
189
 
81
190
  # The enum half of one graph's registrations — see Codegen::Registry.
82
191
  class Registry
83
- # Map a GraphQL enum onto an app-owned T::Enum (see EnumType). The one
84
- # implementation GraphWeaver.register_enum is a delegate, so the same
85
- # call reaches it whichever door you came in by.
192
+ # Map a GraphQL enum onto an app-owned T::Enum, or fold two of its wire
193
+ # spellings into one value (see EnumType). The one implementation —
194
+ # GraphWeaver.register_enum is a delegate, so the same call reaches it
195
+ # whichever door you came in by.
86
196
  #
87
197
  # A value map is a natural third *positional* guess, and Ruby's arity
88
198
  # complaint ("given 3, expected 2") never mentions the keyword.
89
- def register_enum(graphql_name, type, positional_map = nil, map: nil, fallback: nil, requires: nil)
199
+ def register_enum(graphql_name, type = nil, positional_map = nil, map: nil, fallback: nil, requires: nil,
200
+ alias: nil)
90
201
  if positional_map
91
202
  raise GraphWeaver::Error, "register_enum: the value map is a keyword — " \
92
203
  "register_enum(#{graphql_name.inspect}, #{type}, map: {...})"
93
204
  end
94
205
 
95
- enum_registry[graphql_name.to_s] = EnumType.new(graphql_name, type, map:, fallback:, requires:)
206
+ # `alias` is a Ruby keyword, so the parameter is only readable through binding
207
+ aliases = binding.local_variable_get(:alias)
208
+ enum_registry[graphql_name.to_s] = EnumType.new(graphql_name, type, map:, fallback:, requires:, aliases:)
96
209
  end
97
210
 
98
211
  def enum_registry
@@ -178,14 +178,16 @@ class GraphWeaver::Codegen
178
178
 
179
179
  attr_reader :class_name, :fields
180
180
  # the GraphQL type this struct was generated from, any registered helper
181
- # modules to include, and any resolved alias delegators (see extend_type)
182
- attr_accessor :graphql_type, :mixins, :aliases
181
+ # modules to include, any resolved alias delegators, and the member names a
182
+ # mixin declares abstract — Sorbet demands the override on those (extend_type)
183
+ attr_accessor :graphql_type, :mixins, :aliases, :overrides
183
184
 
184
185
  def initialize(class_name)
185
186
  @class_name = class_name
186
187
  @fields = []
187
188
  @mixins = []
188
189
  @aliases = []
190
+ @overrides = []
189
191
  end
190
192
 
191
193
  def bare_type = class_name
@@ -203,17 +205,23 @@ class GraphWeaver::Codegen
203
205
  end
204
206
 
205
207
  class EnumNode < Node
206
- attr_reader :class_name, :values
208
+ attr_reader :class_name, :values, :aliases
207
209
 
208
- def initialize(class_name, values)
210
+ def initialize(class_name, values, aliases = {})
209
211
  @class_name = class_name
210
212
  @values = values
213
+ @aliases = aliases
211
214
  end
212
215
 
213
216
  def bare_type = class_name
214
217
 
218
+ # wire spellings this enum reads as another of its values (register_enum
219
+ # alias:) — a module-level table, emitted beside the class
220
+ def aliased? = !@aliases.empty?
221
+ def alias_const = "#{GraphWeaver::Inflect.underscore(class_name).upcase}_ALIASES"
222
+
215
223
  def cast(expr, _depth)
216
- "GraphWeaver::Hints.enum(#{class_name}, #{expr})"
224
+ "GraphWeaver::Hints.enum(#{class_name}, #{expr}#{", #{alias_const}" if aliased?})"
217
225
  end
218
226
 
219
227
  def serialize(expr, _depth)
@@ -227,7 +235,7 @@ class GraphWeaver::Codegen
227
235
  def coerce? = true
228
236
 
229
237
  def coerce(expr)
230
- "GraphWeaver::InputStruct.enum(#{class_name}, #{expr})"
238
+ "GraphWeaver::InputStruct.enum(#{class_name}, #{expr}#{", #{alias_const}" if aliased?})"
231
239
  end
232
240
 
233
241
  def input_type = "T.any(#{class_name}, String)"
@@ -240,13 +248,13 @@ class GraphWeaver::Codegen
240
248
  # <NAME>_TO_WIRE constants translate at the boundary. fallback: makes
241
249
  # casting absorb unknown wire values (inputs stay strict).
242
250
  class MappedEnum < Node
243
- attr_reader :graphql_name, :mapping
251
+ attr_reader :graphql_name, :mapping, :to_wire
244
252
 
245
253
  def initialize(enum_type, wire_values)
246
254
  @graphql_name = enum_type.graphql_name
247
255
  @type_name = enum_type.type.name
248
256
  @fallback = enum_type.fallback
249
- @mapping = enum_type.mapping_for(wire_values)
257
+ @mapping, @to_wire = enum_type.tables_for(wire_values)
250
258
  end
251
259
 
252
260
  def const_prefix = GraphWeaver::Inflect.underscore(@graphql_name).upcase
@@ -379,7 +387,10 @@ class GraphWeaver::Codegen
379
387
  # Joins the coerce protocol so execute kwargs accept plain hashes,
380
388
  # normalized (and type-checked) through the generated .coerce.
381
389
  class InputNode < Node
382
- Field = Struct.new(:prop, :wire, :node, :required)
390
+ # type: the schema's spelling of the field's type ("[Float!]!"), which a
391
+ # refusal reports — the Sorbet prop type is the library's vocabulary, not
392
+ # the caller's.
393
+ Field = Struct.new(:prop, :wire, :node, :required, :type)
383
394
 
384
395
  # graphql_name as well as class_name: a schema coordinate is spelled the
385
396
  # schema's way (pokemon_bool_exp.name), which camelize has already lost.