graph_weaver 0.7.4 → 0.7.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Gemfile.lock +2 -2
- data/README.md +1 -0
- data/docs/errors.md +12 -5
- data/docs/generated_modules.md +134 -17
- data/docs/getting_started.md +182 -20
- data/docs/logging.md +79 -35
- data/docs/migrating.md +126 -0
- data/docs/scalars.md +50 -6
- data/docs/testing.md +78 -14
- data/docs/upgrading.md +44 -2
- data/examples/README.md +4 -2
- data/examples/github/generate.rb +22 -8
- data/examples/github/generated/star_mutation.rb +2 -2
- data/examples/github/generated/stargazers_query.rb +2 -2
- data/examples/github/generated/starred_query.rb +2 -2
- data/examples/github/run.rb +1 -0
- data/examples/github/setup.rb +16 -8
- data/graph_weaver.gemspec +15 -6
- data/lib/generators/graph_weaver/install_generator.rb +49 -2
- data/lib/graph_weaver/client.rb +0 -23
- data/lib/graph_weaver/codegen/aliases.rb +36 -3
- data/lib/graph_weaver/codegen/emit.rb +20 -15
- data/lib/graph_weaver/codegen/enum_type.rb +52 -11
- data/lib/graph_weaver/codegen/nodes.rb +75 -32
- data/lib/graph_weaver/codegen.rb +260 -100
- data/lib/graph_weaver/coerce.rb +1 -1
- data/lib/graph_weaver/federation.rb +1 -6
- data/lib/graph_weaver/graph.rb +55 -5
- data/lib/graph_weaver/hints.rb +20 -5
- data/lib/graph_weaver/in_process.rb +2 -4
- data/lib/graph_weaver/input_struct.rb +50 -10
- data/lib/graph_weaver/internal/overrides.rb +126 -14
- data/lib/graph_weaver/internal/subgraphs.rb +1 -10
- data/lib/graph_weaver/internal/test_clients.rb +29 -7
- data/lib/graph_weaver/internal/unused.rb +62 -18
- data/lib/graph_weaver/internal/values.rb +24 -7
- data/lib/graph_weaver/internal.rb +23 -6
- data/lib/graph_weaver/log_subscriber.rb +27 -17
- data/lib/graph_weaver/logging.rb +115 -82
- data/lib/graph_weaver/parsing.rb +32 -3
- data/lib/graph_weaver/query_module.rb +67 -12
- data/lib/graph_weaver/railtie.rb +7 -2
- data/lib/graph_weaver/rspec.rb +41 -18
- data/lib/graph_weaver/schema_diff.rb +24 -5
- data/lib/graph_weaver/schema_loader.rb +29 -17
- data/lib/graph_weaver/tasks.rb +98 -16
- data/lib/graph_weaver/testing/fake_client.rb +28 -31
- data/lib/graph_weaver/testing/router.rb +26 -25
- data/lib/graph_weaver/testing.rb +27 -8
- data/lib/graph_weaver/transport.rb +1 -1
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +72 -42
- metadata +3 -2
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,10 +144,10 @@ class GraphWeaver::Codegen
|
|
|
144
144
|
{ inputs: @variable_inputs.keys, enums: @enums.keys, mapped: @mapped_enums.keys }
|
|
145
145
|
end
|
|
146
146
|
|
|
147
|
-
# The shared
|
|
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
|
|
@@ -172,26 +172,29 @@ class GraphWeaver::Codegen
|
|
|
172
172
|
# T::Enum, or the wire tables for one mapped onto an app enum
|
|
173
173
|
# (register_enum) — so a value read out of one query's result hands
|
|
174
174
|
# straight back into another's variable;
|
|
175
|
-
# -
|
|
176
|
-
#
|
|
177
|
-
#
|
|
175
|
+
# - hoisted: each named shared fragment a query spread as a whole field, so
|
|
176
|
+
# the same shape across queries is one Ruby type. `fragments` is the loaded
|
|
177
|
+
# shared-fragment table (nested spreads resolve through it).
|
|
178
178
|
#
|
|
179
|
-
#
|
|
180
|
-
#
|
|
181
|
-
#
|
|
182
|
-
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:)
|
|
183
183
|
validate_module_name!("types module name")
|
|
184
184
|
reset_walk_state!
|
|
185
185
|
# nested spreads inside a shared fragment resolve through the whole table
|
|
186
186
|
@fragments = fragments
|
|
187
187
|
|
|
188
|
-
|
|
188
|
+
nodes = hoisted.uniq.sort.map { |name| hoisted_fragment(fragments, name) }
|
|
189
189
|
inputs.sort.each { |name| input_node(@schema.get_type(name)) }
|
|
190
190
|
enums.uniq.sort.each { |name| variable_core(@schema.get_type(name)) }
|
|
191
|
-
check_shared_collisions!(
|
|
192
|
-
|
|
191
|
+
check_shared_collisions!(hoisted)
|
|
192
|
+
nodes.each do |node|
|
|
193
|
+
check_shadowing!(node)
|
|
194
|
+
check_abstract_mixins!(node, hoisted: true)
|
|
195
|
+
end
|
|
193
196
|
|
|
194
|
-
emit_types_files(
|
|
197
|
+
emit_types_files(nodes).tap { report_untyped_scalars }
|
|
195
198
|
end
|
|
196
199
|
|
|
197
200
|
# module-level constants every generated query module defines — a shared
|
|
@@ -201,7 +204,7 @@ class GraphWeaver::Codegen
|
|
|
201
204
|
|
|
202
205
|
# One hoisted shared fragment, built against the schema and named for the
|
|
203
206
|
# fragment rather than the field that spread it.
|
|
204
|
-
def
|
|
207
|
+
def hoisted_fragment(fragments, name)
|
|
205
208
|
class_name = camelize(name)
|
|
206
209
|
# the query module aliases <class_name> = <shared module>::<class_name>; a
|
|
207
210
|
# name that camelizes to a generated module-level constant (the Result
|
|
@@ -213,28 +216,44 @@ class GraphWeaver::Codegen
|
|
|
213
216
|
|
|
214
217
|
fragment = fragments.fetch(name)
|
|
215
218
|
type = @schema.get_type(fragment.type.name)
|
|
216
|
-
|
|
217
|
-
|
|
219
|
+
selections = fragment.selections
|
|
220
|
+
return object_node(type, selections, class_name) if type.kind.object?
|
|
221
|
+
|
|
222
|
+
# the abstract shape is the fragment's own selections' to decide, exactly
|
|
223
|
+
# as it would be at a field spelling them inline — see abstract_shape
|
|
224
|
+
case (shape = abstract_shape(type, selections))
|
|
225
|
+
when :abstract_level then object_node(type, selections, class_name)
|
|
226
|
+
when :dispatch
|
|
227
|
+
members = union_members(type, selections)
|
|
228
|
+
UnionNode.new(class_name, members, catch_all_member(type, selections, members))
|
|
229
|
+
else
|
|
230
|
+
narrowing_tag!(shape, type, selections)
|
|
231
|
+
object_node(shape, selections, class_name)
|
|
232
|
+
end
|
|
218
233
|
end
|
|
219
|
-
private :
|
|
234
|
+
private :hoisted_fragment
|
|
220
235
|
|
|
221
236
|
# Schema type names are unique, so an input and an enum can never land on the
|
|
222
|
-
# same name — but a hoisted
|
|
223
|
-
# knows nothing about
|
|
224
|
-
#
|
|
237
|
+
# same name — but a hoisted type is named for its FRAGMENT, which the schema
|
|
238
|
+
# knows nothing about: two fragments camelize onto one name as readily as one
|
|
239
|
+
# lands on a type's. One shared module means one namespace, so either
|
|
240
|
+
# collision refuses rather than overwrites. Sorted, so which query file was
|
|
241
|
+
# walked first doesn't decide which fragment is named as the incumbent.
|
|
225
242
|
def check_shared_collisions!(names)
|
|
226
243
|
taken = {}
|
|
227
244
|
@enums.each { |graphql_name, node| taken[node.class_name] = "the schema enum #{graphql_name}" }
|
|
228
245
|
@mapped_enums.each_key { |graphql_name| taken[camelize(graphql_name)] = "the schema enum #{graphql_name}" }
|
|
229
246
|
@variable_inputs.each { |graphql_name, node| taken[node.class_name] = "the input type #{graphql_name}" }
|
|
230
247
|
|
|
231
|
-
names.each do |name|
|
|
248
|
+
names.uniq.sort.each do |name|
|
|
232
249
|
class_name = camelize(name)
|
|
233
|
-
claim = taken[class_name]
|
|
250
|
+
if (claim = taken[class_name])
|
|
251
|
+
raise GraphWeaver::Error,
|
|
252
|
+
"shared fragment #{name.inspect} hoists to #{@name}::#{class_name}, " \
|
|
253
|
+
"where #{claim} already generates — rename the fragment"
|
|
254
|
+
end
|
|
234
255
|
|
|
235
|
-
|
|
236
|
-
"shared fragment #{name.inspect} hoists to #{@name}::#{class_name}, " \
|
|
237
|
-
"where #{claim} already generates — rename the fragment"
|
|
256
|
+
taken[class_name] = "the shared fragment #{name.inspect}"
|
|
238
257
|
end
|
|
239
258
|
end
|
|
240
259
|
private :check_shared_collisions!
|
|
@@ -250,7 +269,7 @@ class GraphWeaver::Codegen
|
|
|
250
269
|
@input_list = false
|
|
251
270
|
@input_hops = []
|
|
252
271
|
@mapped_enums = {}
|
|
253
|
-
@
|
|
272
|
+
@used_fragments = []
|
|
254
273
|
# block-built type helpers this walk included — see #block_helpers
|
|
255
274
|
@block_helpers = []
|
|
256
275
|
# requires the generated file needs (custom scalars, enum mappings,
|
|
@@ -408,6 +427,7 @@ class GraphWeaver::Codegen
|
|
|
408
427
|
variables = build_variables(operation)
|
|
409
428
|
root = object_node(root_type, operation.selections, "Result")
|
|
410
429
|
check_shadowing!(root)
|
|
430
|
+
check_abstract_mixins!(root)
|
|
411
431
|
|
|
412
432
|
# An anonymous operation takes the module's name — declared in the document
|
|
413
433
|
# AND sent as operationName, which have to agree (a server rejects an
|
|
@@ -719,7 +739,7 @@ class GraphWeaver::Codegen
|
|
|
719
739
|
# The shared fragments a query spreads (transitively), excluding any it
|
|
720
740
|
# shadows with a local definition of the same name — the names
|
|
721
741
|
# inline_fragments appends, and the set the generate! workflow may hoist
|
|
722
|
-
# when they
|
|
742
|
+
# when they are a field's whole selection.
|
|
723
743
|
def self.shared_fragment_spreads(query, shared, path = nil)
|
|
724
744
|
# parsed even with nothing to spread: this is the first look at the document
|
|
725
745
|
# on the generate! path, so it's where a syntax error gets branded and
|
|
@@ -872,59 +892,16 @@ class GraphWeaver::Codegen
|
|
|
872
892
|
|
|
873
893
|
case (core = field_type.unwrap).kind.name
|
|
874
894
|
when "OBJECT"
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
conditions = concrete_conditions(core, sub_selections)
|
|
879
|
-
shared = abstract_level_fields(core, sub_selections)
|
|
880
|
-
|
|
881
|
-
if conditions.empty?
|
|
882
|
-
# abstract-level fields only — every member shares them, so one
|
|
883
|
-
# struct suffices and no __typename dispatch is needed (for a
|
|
884
|
-
# union that selection can only be __typename)
|
|
895
|
+
if (frag = hoistable_spread(core, sub_selections))
|
|
896
|
+
hoisted_ref(field_type, frag)
|
|
897
|
+
else
|
|
885
898
|
name = pick_name(key, taken)
|
|
886
899
|
type_ref(field_type) { object_node(core, sub_selections, name) }
|
|
887
|
-
elsif conditions.size == 1 && shared.empty? &&
|
|
888
|
-
(member = @schema.get_type(conditions.first)).kind.name == "OBJECT"
|
|
889
|
-
# a single `... on X` condition: narrow to X's struct — nil
|
|
890
|
-
# when the runtime type doesn't match (narrowing filters).
|
|
891
|
-
# With `__typename` selected the match is read off the tag;
|
|
892
|
-
# without one there is nothing to read but emptiness, and a
|
|
893
|
-
# fragment whose every field hides behind @skip/@include would
|
|
894
|
-
# make a real match indistinguishable from a miss ({} either
|
|
895
|
-
# way) — refuse rather than guess.
|
|
896
|
-
tag = member.graphql_name if dispatchable_typename?(core, sub_selections)
|
|
897
|
-
unless tag || unconditional_field?(member, sub_selections)
|
|
898
|
-
raise GraphWeaver::Error,
|
|
899
|
-
"narrowed `... on #{member.graphql_name}` needs at least one field not under " \
|
|
900
|
-
"@skip/@include (or a `__typename` to match on) — an all-conditional selection " \
|
|
901
|
-
"makes a match indistinguishable from nil"
|
|
902
|
-
end
|
|
903
|
-
|
|
904
|
-
name = pick_name(key, taken)
|
|
905
|
-
nilable_type_ref(field_type) { NarrowedNode.new(object_node(member, sub_selections, name), typename: tag) }
|
|
906
|
-
elsif @types_namespace && (frag = lone_shared_spread(sub_selections)) &&
|
|
907
|
-
@hoistable_unions.include?(frag)
|
|
908
|
-
# a whole-union field spread as a named shared fragment: hoist to
|
|
909
|
-
# the shared types module so the same union across queries is one
|
|
910
|
-
# Ruby type family (one exhaustive `case ... T.absurd`).
|
|
911
|
-
@used_unions << frag unless @used_unions.include?(frag)
|
|
912
|
-
ref = UnionRefNode.new(camelize(frag))
|
|
913
|
-
type_ref(field_type) { ref }
|
|
914
|
-
else
|
|
915
|
-
members = union_members(core, sub_selections)
|
|
916
|
-
catch_all = catch_all_member(core, sub_selections, members)
|
|
917
|
-
# reuse an identical sibling union — the shared type takes the
|
|
918
|
-
# first of the sharing keys alphabetically, not in walk order
|
|
919
|
-
signature = union_signature(members, catch_all)
|
|
920
|
-
union = union_cache[signature]
|
|
921
|
-
if union
|
|
922
|
-
rename_union(union, key, taken) if camelize(key) < union.class_name
|
|
923
|
-
else
|
|
924
|
-
union = union_cache[signature] = UnionNode.new(pick_name(key, taken), members, catch_all)
|
|
925
|
-
end
|
|
926
|
-
type_ref(field_type) { union }
|
|
927
900
|
end
|
|
901
|
+
when "UNION", "INTERFACE"
|
|
902
|
+
abstract_field(AbstractField.new(
|
|
903
|
+
type: field_type, selections: sub_selections, key:, taken:, union_cache:,
|
|
904
|
+
))
|
|
928
905
|
when "ENUM"
|
|
929
906
|
# one schema enum is one Ruby type: module-level, named for the enum,
|
|
930
907
|
# shared by every result field and variable that reaches it (and, on
|
|
@@ -953,6 +930,114 @@ class GraphWeaver::Codegen
|
|
|
953
930
|
node
|
|
954
931
|
end
|
|
955
932
|
|
|
933
|
+
# An abstract-typed (union or interface) field: what was selected through it,
|
|
934
|
+
# and the two ledgers the struct being built keeps — the names already claimed
|
|
935
|
+
# in its scope, and the unions it has already emitted.
|
|
936
|
+
AbstractField = Data.define(:type, :selections, :key, :taken, :union_cache) do
|
|
937
|
+
def core = type.unwrap
|
|
938
|
+
end
|
|
939
|
+
private_constant :AbstractField
|
|
940
|
+
|
|
941
|
+
# An abstract-typed field. Hoisting is asked FIRST, as it is for an object
|
|
942
|
+
# field: a whole field spread as one shared fragment is one shared type
|
|
943
|
+
# whatever shape that fragment has, and which shape it has must not be what
|
|
944
|
+
# decides — adding a second `... on` to a fragment would otherwise move every
|
|
945
|
+
# consumer's constant.
|
|
946
|
+
def abstract_field(field)
|
|
947
|
+
if (frag = hoistable_spread(field.core, field.selections))
|
|
948
|
+
return hoisted_ref(field.type, frag)
|
|
949
|
+
end
|
|
950
|
+
|
|
951
|
+
case (shape = abstract_shape(field.core, field.selections))
|
|
952
|
+
when :abstract_level then abstract_level_struct(field)
|
|
953
|
+
when :dispatch then dispatch_union(field)
|
|
954
|
+
else narrowed_struct(field, shape)
|
|
955
|
+
end
|
|
956
|
+
end
|
|
957
|
+
|
|
958
|
+
# Which of three shapes an abstract selection generates, decided by the
|
|
959
|
+
# selection rather than the schema: :abstract_level when it names no concrete
|
|
960
|
+
# type, the one object member when it narrows to exactly that and nothing
|
|
961
|
+
# else, :dispatch otherwise. Both the hoisted type and every reference to it
|
|
962
|
+
# ask this, so the two can't disagree about what was built.
|
|
963
|
+
def abstract_shape(core, selections)
|
|
964
|
+
conditions = concrete_conditions(core, selections)
|
|
965
|
+
return :abstract_level if conditions.empty?
|
|
966
|
+
return :dispatch unless conditions.size == 1 && abstract_level_fields(core, selections).empty?
|
|
967
|
+
|
|
968
|
+
member = @schema.get_type(conditions.first)
|
|
969
|
+
member.kind.name == "OBJECT" ? member : :dispatch
|
|
970
|
+
end
|
|
971
|
+
|
|
972
|
+
# Every member carries the abstract-level fields, so one struct answers for
|
|
973
|
+
# all of them and there is nothing to dispatch on — for a union, the only
|
|
974
|
+
# selection that can get here is __typename.
|
|
975
|
+
def abstract_level_struct(field)
|
|
976
|
+
name = pick_name(field.key, field.taken)
|
|
977
|
+
type_ref(field.type) { object_node(field.core, field.selections, name) }
|
|
978
|
+
end
|
|
979
|
+
|
|
980
|
+
# Narrowing to the one member a `... on X` names filters: the field is nil
|
|
981
|
+
# whenever the runtime type doesn't match.
|
|
982
|
+
def narrowed_struct(field, member)
|
|
983
|
+
tag = narrowing_tag!(member, field.core, field.selections)
|
|
984
|
+
name = pick_name(field.key, field.taken)
|
|
985
|
+
nilable_type_ref(field.type) { NarrowedNode.new(object_node(member, field.selections, name), typename: tag) }
|
|
986
|
+
end
|
|
987
|
+
|
|
988
|
+
# What a narrowed selection matches on: the member's name when `__typename`
|
|
989
|
+
# is selected, else nil for "the object came back empty". A fragment whose
|
|
990
|
+
# every field hides behind @skip/@include leaves nothing to read either way
|
|
991
|
+
# ({} on a match and on a miss alike) — refuse rather than guess.
|
|
992
|
+
def narrowing_tag!(member, core, selections)
|
|
993
|
+
return member.graphql_name if dispatchable_typename?(core, selections)
|
|
994
|
+
return if unconditional_field?(member, selections)
|
|
995
|
+
|
|
996
|
+
raise GraphWeaver::Error,
|
|
997
|
+
"narrowed `... on #{member.graphql_name}` needs at least one field not under " \
|
|
998
|
+
"@skip/@include (or a `__typename` to match on) — an all-conditional selection " \
|
|
999
|
+
"makes a match indistinguishable from nil"
|
|
1000
|
+
end
|
|
1001
|
+
|
|
1002
|
+
# A whole field spread as one named shared fragment points at the type
|
|
1003
|
+
# hoisted into the shared types module, so the same shape across queries is
|
|
1004
|
+
# one Ruby type. Which node stands in for it follows the shape hoisted_fragment
|
|
1005
|
+
# built: a dispatch module for a union, a struct otherwise — and a narrowed
|
|
1006
|
+
# one still filters, so the match is read here rather than inside a struct
|
|
1007
|
+
# whose from_h cannot answer nil.
|
|
1008
|
+
def hoisted_ref(field_type, frag)
|
|
1009
|
+
@used_fragments << frag unless @used_fragments.include?(frag)
|
|
1010
|
+
core = field_type.unwrap
|
|
1011
|
+
name = camelize(frag)
|
|
1012
|
+
return type_ref(field_type) { HoistedRefNode.new(name, core.graphql_name) } if core.kind.object?
|
|
1013
|
+
|
|
1014
|
+
selections = @fragments.fetch(frag).selections
|
|
1015
|
+
case (shape = abstract_shape(core, selections))
|
|
1016
|
+
when :abstract_level then type_ref(field_type) { HoistedRefNode.new(name, core.graphql_name) }
|
|
1017
|
+
when :dispatch then type_ref(field_type) { UnionRefNode.new(name, core.graphql_name) }
|
|
1018
|
+
else
|
|
1019
|
+
tag = narrowing_tag!(shape, core, selections)
|
|
1020
|
+
nilable_type_ref(field_type) { NarrowedRefNode.new(name, core.graphql_name, typename: tag) }
|
|
1021
|
+
end
|
|
1022
|
+
end
|
|
1023
|
+
|
|
1024
|
+
# One member struct per type the selection names, chosen at runtime off
|
|
1025
|
+
# __typename. Structurally identical sibling unions share one Ruby type,
|
|
1026
|
+
# named for the first of the sharing keys alphabetically so that which one
|
|
1027
|
+
# the walk reached first doesn't decide.
|
|
1028
|
+
def dispatch_union(field)
|
|
1029
|
+
members = union_members(field.core, field.selections)
|
|
1030
|
+
catch_all = catch_all_member(field.core, field.selections, members)
|
|
1031
|
+
signature = union_signature(members, catch_all)
|
|
1032
|
+
union = field.union_cache[signature]
|
|
1033
|
+
if union
|
|
1034
|
+
rename_union(union, field.key, field.taken) if camelize(field.key) < union.class_name
|
|
1035
|
+
else
|
|
1036
|
+
union = field.union_cache[signature] = UnionNode.new(pick_name(field.key, field.taken), members, catch_all)
|
|
1037
|
+
end
|
|
1038
|
+
type_ref(field.type) { union }
|
|
1039
|
+
end
|
|
1040
|
+
|
|
956
1041
|
# A generated class name is only ever a name; Ruby resolves it lexically. So
|
|
957
1042
|
# a struct nesting `class Date < T::Struct` (from a result key `date`) turns
|
|
958
1043
|
# a sibling `Date` scalar prop into that struct, and `Date.iso8601` into a
|
|
@@ -1066,10 +1151,26 @@ class GraphWeaver::Codegen
|
|
|
1066
1151
|
dispatchable_typename?(core, selections) ? keys - ["__typename"] : keys
|
|
1067
1152
|
end
|
|
1068
1153
|
|
|
1154
|
+
# The name a field's whole selection hoists under: exactly one bare spread of
|
|
1155
|
+
# a fragment this query may hoist, written on the field's own type. Only the
|
|
1156
|
+
# generate! workflow has a shared module to hoist into — dynamic `parse`
|
|
1157
|
+
# inlines.
|
|
1158
|
+
def hoistable_spread(core, selections)
|
|
1159
|
+
return unless @types_namespace
|
|
1160
|
+
|
|
1161
|
+
frag = lone_shared_spread(selections)
|
|
1162
|
+
return unless frag && @hoistable_fragments.include?(frag)
|
|
1163
|
+
|
|
1164
|
+
# the shared type is built from the fragment's own type condition, so it is
|
|
1165
|
+
# this field's type only when the two agree — a fragment on a narrower (or
|
|
1166
|
+
# wider) type stays a locally-emitted struct
|
|
1167
|
+
frag if @fragments.fetch(frag).type.name == core.graphql_name
|
|
1168
|
+
end
|
|
1169
|
+
|
|
1069
1170
|
# The fragment name when a selection is exactly one bare fragment spread
|
|
1070
|
-
# (`{ ...F }`) — the shape a
|
|
1071
|
-
#
|
|
1072
|
-
#
|
|
1171
|
+
# (`{ ...F }`) — the shape a field must have to hoist into the shared types
|
|
1172
|
+
# module. A spread carrying directives (@skip/@include), or mixed with other
|
|
1173
|
+
# fields, stays locally emitted.
|
|
1073
1174
|
def lone_shared_spread(selections)
|
|
1074
1175
|
return unless selections.size == 1
|
|
1075
1176
|
|
|
@@ -1172,7 +1273,7 @@ class GraphWeaver::Codegen
|
|
|
1172
1273
|
# it. It carries what the abstract type itself guarantees, plus anything a
|
|
1173
1274
|
# `... on SomeInterface` asked for, since an unnamed member may implement it.
|
|
1174
1275
|
def catch_all_member(type, selections, members)
|
|
1175
|
-
node = object_node(type, selections, catch_all_name(members))
|
|
1276
|
+
node = object_node(type, selections, catch_all_name(members.each_value.map(&:class_name)))
|
|
1176
1277
|
taken = node.fields.map(&:key)
|
|
1177
1278
|
|
|
1178
1279
|
# These are nilable whatever the schema promises: the member that arrives
|
|
@@ -1222,9 +1323,9 @@ class GraphWeaver::Codegen
|
|
|
1222
1323
|
sibling_conditions(condition, selections, visiting, out)
|
|
1223
1324
|
end
|
|
1224
1325
|
|
|
1225
|
-
# "Other", unless a real member already claims that name
|
|
1226
|
-
|
|
1227
|
-
|
|
1326
|
+
# "Other", unless a real member already claims that name — one rule for a
|
|
1327
|
+
# union's catch-all struct and a generated enum's fallback member.
|
|
1328
|
+
def catch_all_name(taken)
|
|
1228
1329
|
name = "Other"
|
|
1229
1330
|
suffix = 2
|
|
1230
1331
|
while taken.include?(name)
|
|
@@ -1251,13 +1352,13 @@ class GraphWeaver::Codegen
|
|
|
1251
1352
|
when List then "[#{signature(node.of)}]"
|
|
1252
1353
|
when NarrowedNode then "?#{signature(node.nested)}"
|
|
1253
1354
|
when Scalar then "s:#{node.bare_type}"
|
|
1254
|
-
when EnumNode then "e:#{node.values.sort.join("|")}"
|
|
1355
|
+
when EnumNode then "e:#{node.values.sort.join("|")}#{"+" if node.fallback?}"
|
|
1255
1356
|
when MappedEnum then "m:#{node.graphql_name}"
|
|
1256
1357
|
when ObjectNode
|
|
1257
1358
|
inner = node.fields.map { |f| "#{f.prop}=#{signature(f.node)}" }.sort.join(",")
|
|
1258
1359
|
"o:#{node.graphql_type}(#{inner})"
|
|
1259
1360
|
when UnionNode then "u:(#{union_signature(node.members, node.catch_all)})"
|
|
1260
|
-
when
|
|
1361
|
+
when HoistedRefNode then "hr:#{node.class_name}" # hoisted — identity is its shared name
|
|
1261
1362
|
else "x:#{node.object_id}" # unknown node kind — never collapse
|
|
1262
1363
|
end
|
|
1263
1364
|
end
|
|
@@ -1406,8 +1507,12 @@ class GraphWeaver::Codegen
|
|
|
1406
1507
|
"constant — map it onto one of yours: register_enum(#{core.graphql_name.inspect}, YourEnum)"
|
|
1407
1508
|
end
|
|
1408
1509
|
|
|
1409
|
-
|
|
1410
|
-
|
|
1510
|
+
entry = @registry.enum_registry[core.graphql_name]
|
|
1511
|
+
aliases = entry&.aliases_for(core.values.keys.sort) || {}
|
|
1512
|
+
values = enum_values(core, aliases)
|
|
1513
|
+
# named the way a union's catch-all is: Other, or Other2 past a declared OTHER
|
|
1514
|
+
fallback = catch_all_name(values.map { |value| camelize(value.downcase) }) if entry&.generated_fallback?
|
|
1515
|
+
EnumNode.new(class_name, values, aliases, fallback:)
|
|
1411
1516
|
end
|
|
1412
1517
|
|
|
1413
1518
|
# A schema enum's constant-bearing wire values, sorted so output is
|
|
@@ -1435,7 +1540,7 @@ class GraphWeaver::Codegen
|
|
|
1435
1540
|
constant, group = collisions.first
|
|
1436
1541
|
more = collisions.size - 1
|
|
1437
1542
|
raise GraphWeaver::Error,
|
|
1438
|
-
"enum #{core.graphql_name} values #{group.join(" and ")}
|
|
1543
|
+
"enum #{core.graphql_name} values #{group.join(" and ")} all become the constant #{constant}" \
|
|
1439
1544
|
"#{" (and #{more} more colliding pair#{"s" if more > 1})" unless more.zero?} — if each pair is one " \
|
|
1440
1545
|
"value, say which spelling goes on the wire:\n " \
|
|
1441
1546
|
"#{EnumType.alias_suggestion(core.graphql_name, collisions.values)}\n" \
|
|
@@ -1479,6 +1584,61 @@ class GraphWeaver::Codegen
|
|
|
1479
1584
|
end
|
|
1480
1585
|
private :abstract_mixin_members
|
|
1481
1586
|
|
|
1587
|
+
# An `abstract!` mixin goes into EVERY struct generated from its type, but
|
|
1588
|
+
# only a struct that selected the members it declares can satisfy them — so a
|
|
1589
|
+
# query selecting a subset generated fine and failed in the app's own
|
|
1590
|
+
# `srb tc`, two tools from the query that fell short. Walked from the root the
|
|
1591
|
+
# way shadowing is, so the refusal can name the struct by its path.
|
|
1592
|
+
def check_abstract_mixins!(node, path = [], hoisted: false)
|
|
1593
|
+
case node
|
|
1594
|
+
when UnionNode
|
|
1595
|
+
inner = path + [node.class_name]
|
|
1596
|
+
(node.members.each_value.to_a + [node.catch_all]).each do |member|
|
|
1597
|
+
check_abstract_mixins!(member, inner, hoisted:)
|
|
1598
|
+
end
|
|
1599
|
+
when ObjectNode
|
|
1600
|
+
inner = path + [node.class_name]
|
|
1601
|
+
refuse_unsatisfiable_mixin!(node, inner, hoisted:)
|
|
1602
|
+
node.fields.each do |field|
|
|
1603
|
+
check_abstract_mixins!(field.node.nested, inner, hoisted:) if field.node.nested
|
|
1604
|
+
end
|
|
1605
|
+
end
|
|
1606
|
+
end
|
|
1607
|
+
|
|
1608
|
+
def refuse_unsatisfiable_mixin!(node, path, hoisted:)
|
|
1609
|
+
return if node.overrides.empty?
|
|
1610
|
+
|
|
1611
|
+
mixins = @registry.type_registry.dig(node.graphql_type, :mixins) || []
|
|
1612
|
+
provided = (node.fields.map(&:prop) + node.aliases.map(&:name)).to_set
|
|
1613
|
+
# what is still abstract with every registered mixin included: a member one
|
|
1614
|
+
# of them implements for another is already answered
|
|
1615
|
+
probe = Module.new
|
|
1616
|
+
mixins.each { |mixin| probe.include(mixin) }
|
|
1617
|
+
missing = T::AbstractUtils.abstract_methods_for(probe)
|
|
1618
|
+
.map { |method| method.name.to_s }.reject { |member| provided.include?(member) }.sort
|
|
1619
|
+
return if missing.empty?
|
|
1620
|
+
|
|
1621
|
+
mixin = mixins.find { |m|
|
|
1622
|
+
T::AbstractUtils.declared_abstract_methods_for(m).any? { |method| missing.include?(method.name.to_s) }
|
|
1623
|
+
}
|
|
1624
|
+
them = missing.one? ? "it" : "them"
|
|
1625
|
+
# the second door is "hoist it", which a struct built inside the shared
|
|
1626
|
+
# types module has already been through
|
|
1627
|
+
fix = if hoisted
|
|
1628
|
+
"Select #{them} in the fragment."
|
|
1629
|
+
else
|
|
1630
|
+
"Select #{them} here, or select #{node.graphql_type} through one shared " \
|
|
1631
|
+
"fragment (`{ ...Frag }`), which hoists one struct for every query to share."
|
|
1632
|
+
end
|
|
1633
|
+
|
|
1634
|
+
raise GraphWeaver::Error,
|
|
1635
|
+
"#{[@name, *path].join("::")} includes #{mixin.name}, which declares " \
|
|
1636
|
+
"#{GraphWeaver::Internal::Util.sample(missing.map(&:inspect))} abstract — this selection does " \
|
|
1637
|
+
"not provide #{them}, and every struct generated from " \
|
|
1638
|
+
"#{node.graphql_type} includes the mixin, so `srb tc` fails on this one. #{fix}"
|
|
1639
|
+
end
|
|
1640
|
+
private :check_abstract_mixins!, :refuse_unsatisfiable_mixin!
|
|
1641
|
+
|
|
1482
1642
|
# The MappedEnum node for a schema enum with a registered app-enum
|
|
1483
1643
|
# mapping; nil when unregistered — or registered for alias: alone, which
|
|
1484
1644
|
# says nothing about the Ruby type — falling back to a generated T::Enum.
|
data/lib/graph_weaver/coerce.rb
CHANGED
|
@@ -255,7 +255,7 @@ module GraphWeaver
|
|
|
255
255
|
|
|
256
256
|
# the article by the initial, so a registered Ruby class ("an Integer")
|
|
257
257
|
# reads as well as the scalars ("an Int", "an ID", "a Date")
|
|
258
|
-
def expected(scalar) = "expected #{
|
|
258
|
+
def expected(scalar) = "expected #{Internal::Util.article(scalar)} #{scalar}"
|
|
259
259
|
end
|
|
260
260
|
end
|
|
261
261
|
end
|
|
@@ -341,15 +341,10 @@ module GraphWeaver
|
|
|
341
341
|
*@skipped.sort.map { |name, what| " #{name} (#{evidence(what)})" }]
|
|
342
342
|
end
|
|
343
343
|
|
|
344
|
-
# how many coordinates the report names before it says "and N more"
|
|
345
|
-
SAMPLE = 5
|
|
346
|
-
private_constant :SAMPLE
|
|
347
|
-
|
|
348
344
|
def evidence(coordinates)
|
|
349
345
|
return "the supergraph attributes nothing to it alone" if coordinates.empty?
|
|
350
|
-
return coordinates.join(", ") if coordinates.size <= SAMPLE
|
|
351
346
|
|
|
352
|
-
|
|
347
|
+
Internal::Util.sample(coordinates)
|
|
353
348
|
end
|
|
354
349
|
|
|
355
350
|
def faked_section
|
data/lib/graph_weaver/graph.rb
CHANGED
|
@@ -152,15 +152,48 @@ module GraphWeaver
|
|
|
152
152
|
# read a source off, so the source is where its modules already post.
|
|
153
153
|
# That is what bootstraps a second graph's dump: URL= names one
|
|
154
154
|
# endpoint, and each graph has its own.
|
|
155
|
-
|
|
156
|
-
|
|
155
|
+
source = path ? GraphWeaver::SchemaLoader.provenance(path)&.dig("url") : (client_source if named_dump_path)
|
|
156
|
+
source || live_schema
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# The server behind this graph, however it is named: the url the dump
|
|
160
|
+
# recorded, else the graphql-ruby class this process runs, else whatever
|
|
161
|
+
# this graph's client reaches. nil when there is nothing to ask — a dump
|
|
162
|
+
# with no provenance, and no client with a server behind it.
|
|
163
|
+
#
|
|
164
|
+
# The one source rule the three schema tasks share: `schema:refresh`
|
|
165
|
+
# rewrites the dump from it, `schema:diff` says how far it has moved, and
|
|
166
|
+
# `queries:check` validates the queries against it.
|
|
167
|
+
def source = dump_source || client_source
|
|
168
|
+
|
|
169
|
+
# The server this graph's client reaches, as something to ask: the url it
|
|
170
|
+
# posts to, else the graphql-ruby class it runs in-process. A client that
|
|
171
|
+
# runs one names a server as surely as a client that posts to one — and
|
|
172
|
+
# reading only the url is what let `source_transport` reach a schema class
|
|
173
|
+
# that `source` said wasn't there.
|
|
174
|
+
def client_source
|
|
175
|
+
client_url || GraphWeaver::Internal::Util.live_schema(client || GraphWeaver.client)
|
|
176
|
+
end
|
|
177
|
+
private :client_source
|
|
178
|
+
|
|
179
|
+
# How to reach that source. A schema class answers introspection itself. A
|
|
180
|
+
# url the dump recorded goes through SchemaLoader, which authenticates from
|
|
181
|
+
# the auth_env the dump named. A url that came from the graph's client is
|
|
182
|
+
# that client's own transport — headers, auth and all.
|
|
183
|
+
def source_transport
|
|
184
|
+
found = source
|
|
185
|
+
return found if found.is_a?(Module)
|
|
186
|
+
return GraphWeaver::SchemaLoader.source_transport(dump_path) if dump_source
|
|
187
|
+
|
|
188
|
+
target = client || GraphWeaver.client
|
|
189
|
+
target.respond_to?(:transport) ? target.transport : target
|
|
157
190
|
end
|
|
158
191
|
|
|
159
192
|
# The url this graph's modules post to, or nil — the graph's own client,
|
|
160
|
-
# else the app default, which is where its modules go too. What
|
|
161
|
-
# `schema:refresh` bootstraps a missing dump from, and what `rake
|
|
193
|
+
# else the app default, which is where its modules go too. What `rake
|
|
162
194
|
# graph_weaver:graphs` reports; a client with no url (a schema class
|
|
163
|
-
# running in-process) has none to report
|
|
195
|
+
# running in-process) has none to report, and `client_source` is the
|
|
196
|
+
# question that covers both.
|
|
164
197
|
def client_url
|
|
165
198
|
target = client || GraphWeaver.client
|
|
166
199
|
target = (target.transport if target.respond_to?(:transport)) || target
|
|
@@ -248,6 +281,7 @@ module GraphWeaver
|
|
|
248
281
|
def self.build(name, &block)
|
|
249
282
|
builder = new(name)
|
|
250
283
|
builder.instance_eval(&block)
|
|
284
|
+
refuse_callable_client!(name, builder.settings[:client])
|
|
251
285
|
graph = GraphWeaver::Graph.new(name:, registrations: builder.registrations, **builder.settings)
|
|
252
286
|
# the block's registrations are applied at generation; run them once
|
|
253
287
|
# here so a bad one is a mistake in the block, said where it is written
|
|
@@ -265,6 +299,22 @@ module GraphWeaver
|
|
|
265
299
|
"Declare graph #{name.inspect} from one.", e.backtrace
|
|
266
300
|
end
|
|
267
301
|
|
|
302
|
+
# `schema` takes a callable — that is how an initializer names a class
|
|
303
|
+
# Zeitwerk hasn't loaded — and the same page says so, which makes one
|
|
304
|
+
# here an easy mistake. It was accepted, and surfaced much later as
|
|
305
|
+
# `undefined method 'execute' for an instance of Proc` under a rake
|
|
306
|
+
# backtrace. A client needs no lambda: the string form names the
|
|
307
|
+
# constant and is resolved when a module calls it.
|
|
308
|
+
def self.refuse_callable_client!(name, client)
|
|
309
|
+
return unless client.respond_to?(:call) && !client.respond_to?(:execute)
|
|
310
|
+
|
|
311
|
+
raise ArgumentError, "client in graph #{name.inspect} takes the object your modules " \
|
|
312
|
+
"call, and a callable doesn't answer #execute — name the object (client " \
|
|
313
|
+
"GraphWeaver.new(\"https://api.example.com/graphql\")), or its constant (client " \
|
|
314
|
+
"\"Billing::Schema\"), which is resolved when a module calls it and so needs no lambda"
|
|
315
|
+
end
|
|
316
|
+
private_class_method :refuse_callable_client!
|
|
317
|
+
|
|
268
318
|
def initialize(name)
|
|
269
319
|
@name = name
|
|
270
320
|
@settings = {}
|