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.
- checksums.yaml +4 -4
- data/Gemfile.lock +2 -2
- data/README.md +1 -0
- data/docs/errors.md +5 -2
- data/docs/federation.md +3 -2
- data/docs/generated_modules.md +176 -22
- data/docs/getting_started.md +174 -14
- data/docs/i18n.md +4 -4
- data/docs/migrating.md +119 -0
- data/docs/scalars.md +161 -35
- data/docs/testing.md +24 -3
- data/docs/upgrading.md +51 -5
- data/examples/github/generated/star_mutation.rb +24 -2
- data/examples/github/generated/stargazers_query.rb +61 -5
- data/examples/github/generated/starred_query.rb +33 -3
- data/lib/generators/graph_weaver/install_generator.rb +32 -3
- data/lib/graph_weaver/client.rb +23 -0
- data/lib/graph_weaver/codegen/aliases.rb +23 -2
- data/lib/graph_weaver/codegen/emit.rb +35 -16
- data/lib/graph_weaver/codegen/enum_type.rb +149 -19
- data/lib/graph_weaver/codegen/nodes.rb +72 -37
- data/lib/graph_weaver/codegen/scalar_type.rb +72 -18
- data/lib/graph_weaver/codegen/type_helpers.rb +71 -13
- data/lib/graph_weaver/codegen.rb +259 -106
- data/lib/graph_weaver/coerce.rb +25 -6
- data/lib/graph_weaver/federation.rb +1 -6
- data/lib/graph_weaver/graph.rb +4 -1
- data/lib/graph_weaver/hints.rb +23 -5
- data/lib/graph_weaver/in_process.rb +1 -3
- data/lib/graph_weaver/input_struct.rb +31 -10
- data/lib/graph_weaver/internal/subgraphs.rb +1 -10
- data/lib/graph_weaver/internal/unused.rb +32 -7
- data/lib/graph_weaver/internal/values.rb +12 -4
- data/lib/graph_weaver/internal.rb +84 -0
- data/lib/graph_weaver/logging.rb +26 -29
- data/lib/graph_weaver/query_module.rb +20 -5
- data/lib/graph_weaver/railtie.rb +7 -2
- data/lib/graph_weaver/rspec.rb +0 -1
- data/lib/graph_weaver/schema_loader.rb +7 -8
- data/lib/graph_weaver/tasks.rb +60 -5
- data/lib/graph_weaver/testing/fake_client.rb +4 -10
- data/lib/graph_weaver/testing/router.rb +26 -25
- data/lib/graph_weaver/testing.rb +101 -1
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +80 -74
- 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
|
-
|
|
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
|
-
|
|
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.
|
data/lib/graph_weaver/codegen.rb
CHANGED
|
@@ -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
|
|
70
|
-
# shared fragments live once in that module and the query module aliases
|
|
71
|
-
# it uses.
|
|
72
|
-
# may hoist (spreads it inlined, minus any it shadows locally) — a
|
|
73
|
-
#
|
|
74
|
-
#
|
|
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,
|
|
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
|
-
@
|
|
91
|
-
@
|
|
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
|
|
148
|
-
#
|
|
149
|
-
#
|
|
150
|
-
def
|
|
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
|
-
# -
|
|
170
|
-
#
|
|
171
|
-
#
|
|
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
|
-
#
|
|
174
|
-
#
|
|
175
|
-
#
|
|
176
|
-
def generate_types(inputs:, enums:,
|
|
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
|
-
|
|
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!(
|
|
186
|
-
|
|
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(
|
|
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
|
|
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 :
|
|
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
|
|
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
|
-
@
|
|
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
|
|
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
|
-
|
|
867
|
-
|
|
868
|
-
|
|
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
|
|
1062
|
-
#
|
|
1063
|
-
#
|
|
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
|
-
|
|
1218
|
-
|
|
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
|
|
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
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
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
|
-
|
|
1419
|
-
|
|
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 #{
|
|
1422
|
-
"#{
|
|
1423
|
-
"
|
|
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
|
|
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)
|