graph_weaver 0.7.4 → 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/generated_modules.md +127 -14
- data/docs/getting_started.md +159 -14
- data/docs/migrating.md +119 -0
- data/docs/scalars.md +39 -4
- data/docs/testing.md +3 -1
- data/docs/upgrading.md +21 -1
- data/lib/generators/graph_weaver/install_generator.rb +32 -3
- data/lib/graph_weaver/codegen/aliases.rb +23 -2
- data/lib/graph_weaver/codegen/emit.rb +13 -9
- data/lib/graph_weaver/codegen/enum_type.rb +26 -9
- data/lib/graph_weaver/codegen/nodes.rb +55 -31
- data/lib/graph_weaver/codegen.rb +198 -90
- data/lib/graph_weaver/coerce.rb +1 -1
- data/lib/graph_weaver/federation.rb +1 -6
- data/lib/graph_weaver/hints.rb +20 -5
- data/lib/graph_weaver/in_process.rb +1 -3
- data/lib/graph_weaver/input_struct.rb +21 -6
- 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 +7 -2
- data/lib/graph_weaver/internal.rb +17 -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 +1 -2
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +13 -7
- 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)
|
|
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,13 +216,15 @@ class GraphWeaver::Codegen
|
|
|
213
216
|
|
|
214
217
|
fragment = fragments.fetch(name)
|
|
215
218
|
type = @schema.get_type(fragment.type.name)
|
|
219
|
+
return object_node(type, fragment.selections, class_name) if type.kind.object?
|
|
220
|
+
|
|
216
221
|
members = union_members(type, fragment.selections)
|
|
217
222
|
UnionNode.new(class_name, members, catch_all_member(type, fragment.selections, members))
|
|
218
223
|
end
|
|
219
|
-
private :
|
|
224
|
+
private :hoisted_fragment
|
|
220
225
|
|
|
221
226
|
# Schema type names are unique, so an input and an enum can never land on the
|
|
222
|
-
# same name — but a hoisted
|
|
227
|
+
# same name — but a hoisted type is named for its FRAGMENT, which the schema
|
|
223
228
|
# knows nothing about. One shared module means one namespace, so a fragment
|
|
224
229
|
# named after a type it doesn't describe has to refuse rather than overwrite.
|
|
225
230
|
def check_shared_collisions!(names)
|
|
@@ -250,7 +255,7 @@ class GraphWeaver::Codegen
|
|
|
250
255
|
@input_list = false
|
|
251
256
|
@input_hops = []
|
|
252
257
|
@mapped_enums = {}
|
|
253
|
-
@
|
|
258
|
+
@used_fragments = []
|
|
254
259
|
# block-built type helpers this walk included — see #block_helpers
|
|
255
260
|
@block_helpers = []
|
|
256
261
|
# requires the generated file needs (custom scalars, enum mappings,
|
|
@@ -408,6 +413,7 @@ class GraphWeaver::Codegen
|
|
|
408
413
|
variables = build_variables(operation)
|
|
409
414
|
root = object_node(root_type, operation.selections, "Result")
|
|
410
415
|
check_shadowing!(root)
|
|
416
|
+
check_abstract_mixins!(root)
|
|
411
417
|
|
|
412
418
|
# An anonymous operation takes the module's name — declared in the document
|
|
413
419
|
# AND sent as operationName, which have to agree (a server rejects an
|
|
@@ -719,7 +725,7 @@ class GraphWeaver::Codegen
|
|
|
719
725
|
# The shared fragments a query spreads (transitively), excluding any it
|
|
720
726
|
# shadows with a local definition of the same name — the names
|
|
721
727
|
# inline_fragments appends, and the set the generate! workflow may hoist
|
|
722
|
-
# when they
|
|
728
|
+
# when they are a field's whole selection.
|
|
723
729
|
def self.shared_fragment_spreads(query, shared, path = nil)
|
|
724
730
|
# parsed even with nothing to spread: this is the first look at the document
|
|
725
731
|
# on the generate! path, so it's where a syntax error gets branded and
|
|
@@ -872,59 +878,16 @@ class GraphWeaver::Codegen
|
|
|
872
878
|
|
|
873
879
|
case (core = field_type.unwrap).kind.name
|
|
874
880
|
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)
|
|
881
|
+
if (frag = hoistable_spread(core, sub_selections))
|
|
882
|
+
hoisted_ref(field_type, frag)
|
|
883
|
+
else
|
|
885
884
|
name = pick_name(key, taken)
|
|
886
885
|
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
886
|
end
|
|
887
|
+
when "UNION", "INTERFACE"
|
|
888
|
+
abstract_field(AbstractField.new(
|
|
889
|
+
type: field_type, selections: sub_selections, key:, taken:, union_cache:,
|
|
890
|
+
))
|
|
928
891
|
when "ENUM"
|
|
929
892
|
# one schema enum is one Ruby type: module-level, named for the enum,
|
|
930
893
|
# shared by every result field and variable that reaches it (and, on
|
|
@@ -953,6 +916,88 @@ class GraphWeaver::Codegen
|
|
|
953
916
|
node
|
|
954
917
|
end
|
|
955
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
|
+
|
|
956
1001
|
# A generated class name is only ever a name; Ruby resolves it lexically. So
|
|
957
1002
|
# a struct nesting `class Date < T::Struct` (from a result key `date`) turns
|
|
958
1003
|
# a sibling `Date` scalar prop into that struct, and `Date.iso8601` into a
|
|
@@ -1066,10 +1111,26 @@ class GraphWeaver::Codegen
|
|
|
1066
1111
|
dispatchable_typename?(core, selections) ? keys - ["__typename"] : keys
|
|
1067
1112
|
end
|
|
1068
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
|
+
|
|
1069
1130
|
# The fragment name when a selection is exactly one bare fragment spread
|
|
1070
|
-
# (`{ ...F }`) — the shape a
|
|
1071
|
-
#
|
|
1072
|
-
#
|
|
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.
|
|
1073
1134
|
def lone_shared_spread(selections)
|
|
1074
1135
|
return unless selections.size == 1
|
|
1075
1136
|
|
|
@@ -1172,7 +1233,7 @@ class GraphWeaver::Codegen
|
|
|
1172
1233
|
# it. It carries what the abstract type itself guarantees, plus anything a
|
|
1173
1234
|
# `... on SomeInterface` asked for, since an unnamed member may implement it.
|
|
1174
1235
|
def catch_all_member(type, selections, members)
|
|
1175
|
-
node = object_node(type, selections, catch_all_name(members))
|
|
1236
|
+
node = object_node(type, selections, catch_all_name(members.each_value.map(&:class_name)))
|
|
1176
1237
|
taken = node.fields.map(&:key)
|
|
1177
1238
|
|
|
1178
1239
|
# These are nilable whatever the schema promises: the member that arrives
|
|
@@ -1222,9 +1283,9 @@ class GraphWeaver::Codegen
|
|
|
1222
1283
|
sibling_conditions(condition, selections, visiting, out)
|
|
1223
1284
|
end
|
|
1224
1285
|
|
|
1225
|
-
# "Other", unless a real member already claims that name
|
|
1226
|
-
|
|
1227
|
-
|
|
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)
|
|
1228
1289
|
name = "Other"
|
|
1229
1290
|
suffix = 2
|
|
1230
1291
|
while taken.include?(name)
|
|
@@ -1251,13 +1312,13 @@ class GraphWeaver::Codegen
|
|
|
1251
1312
|
when List then "[#{signature(node.of)}]"
|
|
1252
1313
|
when NarrowedNode then "?#{signature(node.nested)}"
|
|
1253
1314
|
when Scalar then "s:#{node.bare_type}"
|
|
1254
|
-
when EnumNode then "e:#{node.values.sort.join("|")}"
|
|
1315
|
+
when EnumNode then "e:#{node.values.sort.join("|")}#{"+" if node.fallback?}"
|
|
1255
1316
|
when MappedEnum then "m:#{node.graphql_name}"
|
|
1256
1317
|
when ObjectNode
|
|
1257
1318
|
inner = node.fields.map { |f| "#{f.prop}=#{signature(f.node)}" }.sort.join(",")
|
|
1258
1319
|
"o:#{node.graphql_type}(#{inner})"
|
|
1259
1320
|
when UnionNode then "u:(#{union_signature(node.members, node.catch_all)})"
|
|
1260
|
-
when
|
|
1321
|
+
when HoistedRefNode then "hr:#{node.class_name}" # hoisted — identity is its shared name
|
|
1261
1322
|
else "x:#{node.object_id}" # unknown node kind — never collapse
|
|
1262
1323
|
end
|
|
1263
1324
|
end
|
|
@@ -1406,8 +1467,12 @@ class GraphWeaver::Codegen
|
|
|
1406
1467
|
"constant — map it onto one of yours: register_enum(#{core.graphql_name.inspect}, YourEnum)"
|
|
1407
1468
|
end
|
|
1408
1469
|
|
|
1409
|
-
|
|
1410
|
-
|
|
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:)
|
|
1411
1476
|
end
|
|
1412
1477
|
|
|
1413
1478
|
# A schema enum's constant-bearing wire values, sorted so output is
|
|
@@ -1479,6 +1544,49 @@ class GraphWeaver::Codegen
|
|
|
1479
1544
|
end
|
|
1480
1545
|
private :abstract_mixin_members
|
|
1481
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."
|
|
1587
|
+
end
|
|
1588
|
+
private :check_abstract_mixins!, :refuse_unsatisfiable_mixin!
|
|
1589
|
+
|
|
1482
1590
|
# The MappedEnum node for a schema enum with a registered app-enum
|
|
1483
1591
|
# mapping; nil when unregistered — or registered for alias: alone, which
|
|
1484
1592
|
# 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/hints.rb
CHANGED
|
@@ -69,22 +69,37 @@ module GraphWeaver
|
|
|
69
69
|
# neither that nor which values exist. Raised bare so the enclosing
|
|
70
70
|
# Hints.field brands it with the field.
|
|
71
71
|
# aliases (register_enum alias:) is wire spelling => the value it reads as.
|
|
72
|
-
|
|
72
|
+
# fallback is the Other member, when the registration asked for one.
|
|
73
|
+
def self.enum(type, value, aliases = nil, fallback: nil)
|
|
73
74
|
value = aliases.fetch(value, value) if aliases
|
|
75
|
+
member = type.try_deserialize(value)
|
|
76
|
+
return member if member
|
|
77
|
+
return absorbed(type, value, fallback) if fallback
|
|
74
78
|
|
|
75
|
-
|
|
79
|
+
drifted!(type, value, type.values.map(&:serialize), "register_enum fallback: true")
|
|
76
80
|
end
|
|
77
81
|
|
|
78
82
|
# the same, for an enum mapped onto an app-owned T::Enum, where the wire
|
|
79
83
|
# table rather than the type knows the accepted values
|
|
80
84
|
def self.mapped_enum(type, table, value)
|
|
81
|
-
table.fetch(value) { drifted!(type, value, table.keys) }
|
|
85
|
+
table.fetch(value) { drifted!(type, value, table.keys, "register_enum fallback:") }
|
|
82
86
|
end
|
|
83
87
|
|
|
84
|
-
|
|
88
|
+
# A T::Enum member is a singleton, so Other can't carry the value it
|
|
89
|
+
# swallowed — this line is the only record that anything drifted.
|
|
90
|
+
def self.absorbed(type, value, fallback)
|
|
91
|
+
GraphWeaver::Internal::Log.log(:debug) do
|
|
92
|
+
# the member's bare constant name — a T::Enum member inspects as #<Type::Name>
|
|
93
|
+
"#{type} absorbed #{GraphWeaver::Internal::Redact.shown(value)} into #{fallback.inspect[/::(\w+)>\z/, 1]}"
|
|
94
|
+
end
|
|
95
|
+
fallback
|
|
96
|
+
end
|
|
97
|
+
private_class_method :absorbed
|
|
98
|
+
|
|
99
|
+
def self.drifted!(type, value, values, suggestion)
|
|
85
100
|
raise KeyError, "#{GraphWeaver::Internal::Redact.shown(value)} is not a #{type} — expected one of: " \
|
|
86
101
|
"#{values.sort.join(", ")}; a value the server added since you generated " \
|
|
87
|
-
"needs a regenerate, or
|
|
102
|
+
"needs a regenerate, or #{suggestion} to absorb them"
|
|
88
103
|
end
|
|
89
104
|
private_class_method :drifted!
|
|
90
105
|
|
|
@@ -72,14 +72,12 @@ class GraphWeaver::InProcess
|
|
|
72
72
|
"#{GraphWeaver::Internal::Wire.truncate_for_log(query)}"
|
|
73
73
|
end
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
GraphWeaver::Internal::Log.log_timed(:debug, "in-process #{schema_label} #{tag} completed") do
|
|
76
76
|
# a copy per query: graphql-ruby writes a resolver's `context[...] =`
|
|
77
77
|
# into the hash it is handed, and one client serves every request
|
|
78
78
|
@schema.execute(query, variables:, operation_name:,
|
|
79
79
|
context: GraphWeaver::Internal::Util.context!(@context).dup)
|
|
80
80
|
end
|
|
81
|
-
|
|
82
|
-
result
|
|
83
81
|
rescue GraphWeaver::Error
|
|
84
82
|
raise
|
|
85
83
|
rescue => e
|
|
@@ -31,12 +31,15 @@ module GraphWeaver
|
|
|
31
31
|
# rather than T::Enum.deserialize / the wire table directly: both raise a
|
|
32
32
|
# bare KeyError naming an anonymous module and none of the values they
|
|
33
33
|
# would have taken.
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
34
|
+
# fallback is the generated Other member (register_enum fallback: true).
|
|
35
|
+
# It is the one member input refuses: nothing on the wire means it, so a
|
|
36
|
+
# variable carrying it would send a value the server never declared.
|
|
37
|
+
def self.enum(type, value, aliases = nil, fallback: nil)
|
|
38
|
+
member = value.is_a?(type) ? value : type.try_deserialize(aliases ? aliases.fetch(value, value) : value)
|
|
39
|
+
return member if member && !member.equal?(fallback)
|
|
40
|
+
|
|
41
|
+
accepted = type.values.map(&:serialize) - [fallback&.serialize].compact
|
|
42
|
+
member ? unsendable_enum!(member, accepted) : invalid_enum!(type, value, accepted)
|
|
40
43
|
end
|
|
41
44
|
|
|
42
45
|
# A list element's index, prepended when something inside it refused —
|
|
@@ -110,6 +113,18 @@ module GraphWeaver
|
|
|
110
113
|
end
|
|
111
114
|
private_class_method :invalid_enum!
|
|
112
115
|
|
|
116
|
+
# The fallback member is a landing pad for drift, not a value — so it is
|
|
117
|
+
# refused by name rather than listed among the ones you could have meant.
|
|
118
|
+
def self.unsendable_enum!(member, accepted)
|
|
119
|
+
# a T::Enum member inspects as #<Type::Name>
|
|
120
|
+
raise GraphWeaver::Internal::Refusal.brand(
|
|
121
|
+
KeyError.new("#{member.inspect[2..-2]} absorbs values the server added, so " \
|
|
122
|
+
"there is nothing to send for it — expected one of: #{accepted.sort.join(", ")}"),
|
|
123
|
+
:not_a_member, members: accepted.sort,
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
private_class_method :unsendable_enum!
|
|
127
|
+
|
|
113
128
|
def self.included(base)
|
|
114
129
|
base.extend(ClassMethods)
|
|
115
130
|
end
|
|
@@ -34,9 +34,6 @@ module GraphWeaver
|
|
|
34
34
|
# goes through the same check *and refuses at construction* — a swapped
|
|
35
35
|
# pair fails there rather than as a mystery three fetches later.
|
|
36
36
|
module Subgraphs
|
|
37
|
-
# how many coordinates a message names before it says "and N more"
|
|
38
|
-
SAMPLE = 5
|
|
39
|
-
|
|
40
37
|
# answer this subgraph with fabricated data rather than refusing
|
|
41
38
|
FAKE = :fake
|
|
42
39
|
|
|
@@ -117,15 +114,9 @@ module GraphWeaver
|
|
|
117
114
|
return schema if gaps.empty?
|
|
118
115
|
|
|
119
116
|
raise GraphWeaver::ConfigurationError, "subgraphs[#{name.inspect}] is " \
|
|
120
|
-
"#{schema.name || schema.inspect}, which doesn't define #{sample(gaps)} — the supergraph " \
|
|
117
|
+
"#{schema.name || schema.inspect}, which doesn't define #{Util.sample(gaps)} — the supergraph " \
|
|
121
118
|
"says #{name} resolves them. Did two entries get swapped?"
|
|
122
119
|
end
|
|
123
|
-
|
|
124
|
-
def sample(list)
|
|
125
|
-
return list.join(", ") if list.size <= SAMPLE
|
|
126
|
-
|
|
127
|
-
"#{list.first(SAMPLE).join(", ")} and #{list.size - SAMPLE} more"
|
|
128
|
-
end
|
|
129
120
|
end
|
|
130
121
|
end
|
|
131
122
|
end
|
|
@@ -47,6 +47,11 @@ module GraphWeaver
|
|
|
47
47
|
# .json.erb's sibling JS — is a blind spot, and the footer says so.
|
|
48
48
|
# .rake and .builder are Ruby too.
|
|
49
49
|
EXTENSIONS = %w[.rb .rake .builder .erb .slim .haml .jbuilder].freeze
|
|
50
|
+
# Ruby that carries no extension to recognise it by. In a non-Rails
|
|
51
|
+
# project the entry points live here, so skipping them skipped the
|
|
52
|
+
# files that read the query.
|
|
53
|
+
SCRIPT_DIRS = Set["bin", "exe"].freeze
|
|
54
|
+
RUBY_SHEBANG = /\A#!.*\bruby\b/
|
|
50
55
|
# Directories that hold no app source. "generated" covers both a graph's
|
|
51
56
|
# own output under the convention and a spec/generated fixture dir; a
|
|
52
57
|
# graph that writes somewhere else is pruned by #outputs.
|
|
@@ -60,8 +65,9 @@ module GraphWeaver
|
|
|
60
65
|
# difference between a lint and a number somebody trusts.
|
|
61
66
|
FOOTER = "This is a lint, not a proof — it matches prop names as text, so a common name reads " \
|
|
62
67
|
"as\nused the moment anything says it. It can't see a prop reached by public_send, or a " \
|
|
63
|
-
"read\nin a file type it doesn't sweep
|
|
64
|
-
"
|
|
68
|
+
"read\nin a file type it doesn't sweep — #{EXTENSIONS.join(", ")},\nplus Ruby with no " \
|
|
69
|
+
"extension (any name under bin/ or exe/, a ruby shebang elsewhere). On\na real app half to " \
|
|
70
|
+
"two thirds of genuinely unread selections go unreported; silence is\nthe safe direction."
|
|
65
71
|
|
|
66
72
|
# query: the .graphql that selected it. struct/prop: where it landed.
|
|
67
73
|
# wire: how the query spells that prop, when it differs.
|
|
@@ -244,19 +250,22 @@ module GraphWeaver
|
|
|
244
250
|
end
|
|
245
251
|
|
|
246
252
|
def files
|
|
247
|
-
@files ||= @roots
|
|
253
|
+
@files ||= @roots
|
|
254
|
+
.flat_map { |root| collect(root, [], SCRIPT_DIRS.include?(File.basename(root))) }
|
|
255
|
+
.uniq.sort
|
|
248
256
|
end
|
|
249
257
|
|
|
250
258
|
# Pruned as it walks rather than globbed and filtered: node_modules is
|
|
251
|
-
# the directory you most want never to descend into.
|
|
252
|
-
|
|
259
|
+
# the directory you most want never to descend into. scripts says we are
|
|
260
|
+
# inside bin/ or exe/, which the walk knows and a path doesn't.
|
|
261
|
+
def collect(dir, found, scripts)
|
|
253
262
|
Dir.children(dir).sort.each do |entry|
|
|
254
263
|
path = File.join(dir, entry)
|
|
255
264
|
# lstat, so a symlinked directory can't loop the walk
|
|
256
265
|
stat = File.lstat(path)
|
|
257
266
|
if stat.directory?
|
|
258
|
-
collect(path, found) unless skip_dir?(entry, path)
|
|
259
|
-
elsif stat.file? &&
|
|
267
|
+
collect(path, found, scripts || SCRIPT_DIRS.include?(entry)) unless skip_dir?(entry, path)
|
|
268
|
+
elsif stat.file? && ruby?(path, entry, scripts)
|
|
260
269
|
found << path
|
|
261
270
|
end
|
|
262
271
|
end
|
|
@@ -265,6 +274,22 @@ module GraphWeaver
|
|
|
265
274
|
found
|
|
266
275
|
end
|
|
267
276
|
|
|
277
|
+
# An extension names most of it. A file with none is Ruby if it sits
|
|
278
|
+
# under bin/ or exe/ — that is what those directories are for — or if
|
|
279
|
+
# its first line says so.
|
|
280
|
+
def ruby?(path, entry, scripts)
|
|
281
|
+
return true if EXTENSIONS.include?(File.extname(entry))
|
|
282
|
+
return false unless File.extname(entry).empty?
|
|
283
|
+
|
|
284
|
+
scripts || shebang?(path)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def shebang?(path)
|
|
288
|
+
File.open(path) { |file| file.gets(chomp: true) }&.match?(RUBY_SHEBANG) || false
|
|
289
|
+
rescue SystemCallError, ArgumentError
|
|
290
|
+
false
|
|
291
|
+
end
|
|
292
|
+
|
|
268
293
|
def skip_dir?(entry, path) = entry.start_with?(".") || SKIP.include?(entry) || outputs.include?(path)
|
|
269
294
|
|
|
270
295
|
# The generated directories a name check can't catch: a graph that sets
|