graph_weaver 0.7.5 → 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/docs/errors.md +7 -3
- data/docs/generated_modules.md +10 -6
- data/docs/getting_started.md +34 -17
- data/docs/logging.md +79 -35
- data/docs/migrating.md +11 -4
- data/docs/scalars.md +12 -3
- data/docs/testing.md +75 -13
- data/docs/upgrading.md +25 -3
- 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 +20 -2
- data/lib/graph_weaver/client.rb +0 -23
- data/lib/graph_weaver/codegen/aliases.rb +15 -3
- data/lib/graph_weaver/codegen/emit.rb +7 -6
- data/lib/graph_weaver/codegen/enum_type.rb +26 -2
- data/lib/graph_weaver/codegen/nodes.rb +21 -2
- data/lib/graph_weaver/codegen.rb +106 -54
- data/lib/graph_weaver/graph.rb +55 -5
- data/lib/graph_weaver/in_process.rb +1 -1
- data/lib/graph_weaver/input_struct.rb +29 -4
- data/lib/graph_weaver/internal/overrides.rb +126 -14
- data/lib/graph_weaver/internal/test_clients.rb +29 -7
- data/lib/graph_weaver/internal/unused.rb +30 -11
- data/lib/graph_weaver/internal/values.rb +17 -5
- data/lib/graph_weaver/internal.rb +6 -6
- data/lib/graph_weaver/log_subscriber.rb +27 -17
- data/lib/graph_weaver/logging.rb +89 -53
- data/lib/graph_weaver/parsing.rb +32 -3
- data/lib/graph_weaver/query_module.rb +48 -8
- data/lib/graph_weaver/rspec.rb +41 -17
- data/lib/graph_weaver/schema_diff.rb +24 -5
- data/lib/graph_weaver/schema_loader.rb +22 -9
- data/lib/graph_weaver/tasks.rb +71 -44
- data/lib/graph_weaver/testing/fake_client.rb +24 -21
- data/lib/graph_weaver/testing.rb +27 -7
- data/lib/graph_weaver/transport.rb +1 -1
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +59 -35
- metadata +2 -2
data/lib/graph_weaver/codegen.rb
CHANGED
|
@@ -191,7 +191,7 @@ class GraphWeaver::Codegen
|
|
|
191
191
|
check_shared_collisions!(hoisted)
|
|
192
192
|
nodes.each do |node|
|
|
193
193
|
check_shadowing!(node)
|
|
194
|
-
check_abstract_mixins!(node)
|
|
194
|
+
check_abstract_mixins!(node, hoisted: true)
|
|
195
195
|
end
|
|
196
196
|
|
|
197
197
|
emit_types_files(nodes).tap { report_untyped_scalars }
|
|
@@ -216,30 +216,44 @@ class GraphWeaver::Codegen
|
|
|
216
216
|
|
|
217
217
|
fragment = fragments.fetch(name)
|
|
218
218
|
type = @schema.get_type(fragment.type.name)
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
|
223
233
|
end
|
|
224
234
|
private :hoisted_fragment
|
|
225
235
|
|
|
226
236
|
# Schema type names are unique, so an input and an enum can never land on the
|
|
227
237
|
# same name — but a hoisted type is named for its FRAGMENT, which the schema
|
|
228
|
-
# knows nothing about
|
|
229
|
-
#
|
|
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.
|
|
230
242
|
def check_shared_collisions!(names)
|
|
231
243
|
taken = {}
|
|
232
244
|
@enums.each { |graphql_name, node| taken[node.class_name] = "the schema enum #{graphql_name}" }
|
|
233
245
|
@mapped_enums.each_key { |graphql_name| taken[camelize(graphql_name)] = "the schema enum #{graphql_name}" }
|
|
234
246
|
@variable_inputs.each { |graphql_name, node| taken[node.class_name] = "the input type #{graphql_name}" }
|
|
235
247
|
|
|
236
|
-
names.each do |name|
|
|
248
|
+
names.uniq.sort.each do |name|
|
|
237
249
|
class_name = camelize(name)
|
|
238
|
-
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
|
|
239
255
|
|
|
240
|
-
|
|
241
|
-
"shared fragment #{name.inspect} hoists to #{@name}::#{class_name}, " \
|
|
242
|
-
"where #{claim} already generates — rename the fragment"
|
|
256
|
+
taken[class_name] = "the shared fragment #{name.inspect}"
|
|
243
257
|
end
|
|
244
258
|
end
|
|
245
259
|
private :check_shared_collisions!
|
|
@@ -924,24 +938,37 @@ class GraphWeaver::Codegen
|
|
|
924
938
|
end
|
|
925
939
|
private_constant :AbstractField
|
|
926
940
|
|
|
927
|
-
#
|
|
928
|
-
#
|
|
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.
|
|
929
946
|
def abstract_field(field)
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
elsif (frag = hoistable_spread(field.core, field.selections))
|
|
939
|
-
hoisted_ref(field.type, frag)
|
|
940
|
-
else
|
|
941
|
-
dispatch_union(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)
|
|
942
955
|
end
|
|
943
956
|
end
|
|
944
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
|
+
|
|
945
972
|
# Every member carries the abstract-level fields, so one struct answers for
|
|
946
973
|
# all of them and there is nothing to dispatch on — for a union, the only
|
|
947
974
|
# selection that can get here is __typename.
|
|
@@ -951,34 +978,47 @@ class GraphWeaver::Codegen
|
|
|
951
978
|
end
|
|
952
979
|
|
|
953
980
|
# Narrowing to the one member a `... on X` names filters: the field is nil
|
|
954
|
-
# whenever the runtime type doesn't match.
|
|
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.
|
|
981
|
+
# whenever the runtime type doesn't match.
|
|
959
982
|
def narrowed_struct(field, member)
|
|
960
|
-
tag = member
|
|
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
|
-
|
|
983
|
+
tag = narrowing_tag!(member, field.core, field.selections)
|
|
968
984
|
name = pick_name(field.key, field.taken)
|
|
969
985
|
nilable_type_ref(field.type) { NarrowedNode.new(object_node(member, field.selections, name), typename: tag) }
|
|
970
986
|
end
|
|
971
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
|
+
|
|
972
1002
|
# A whole field spread as one named shared fragment points at the type
|
|
973
1003
|
# hoisted into the shared types module, so the same shape across queries is
|
|
974
|
-
# one Ruby type
|
|
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.
|
|
975
1008
|
def hoisted_ref(field_type, frag)
|
|
976
1009
|
@used_fragments << frag unless @used_fragments.include?(frag)
|
|
977
|
-
# an abstract type hoists to a dispatch module, an object type to a struct
|
|
978
1010
|
core = field_type.unwrap
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
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
|
|
982
1022
|
end
|
|
983
1023
|
|
|
984
1024
|
# One member struct per type the selection names, chosen at runtime off
|
|
@@ -1500,7 +1540,7 @@ class GraphWeaver::Codegen
|
|
|
1500
1540
|
constant, group = collisions.first
|
|
1501
1541
|
more = collisions.size - 1
|
|
1502
1542
|
raise GraphWeaver::Error,
|
|
1503
|
-
"enum #{core.graphql_name} values #{group.join(" and ")}
|
|
1543
|
+
"enum #{core.graphql_name} values #{group.join(" and ")} all become the constant #{constant}" \
|
|
1504
1544
|
"#{" (and #{more} more colliding pair#{"s" if more > 1})" unless more.zero?} — if each pair is one " \
|
|
1505
1545
|
"value, say which spelling goes on the wire:\n " \
|
|
1506
1546
|
"#{EnumType.alias_suggestion(core.graphql_name, collisions.values)}\n" \
|
|
@@ -1549,19 +1589,23 @@ class GraphWeaver::Codegen
|
|
|
1549
1589
|
# query selecting a subset generated fine and failed in the app's own
|
|
1550
1590
|
# `srb tc`, two tools from the query that fell short. Walked from the root the
|
|
1551
1591
|
# way shadowing is, so the refusal can name the struct by its path.
|
|
1552
|
-
def check_abstract_mixins!(node, path = [])
|
|
1592
|
+
def check_abstract_mixins!(node, path = [], hoisted: false)
|
|
1553
1593
|
case node
|
|
1554
1594
|
when UnionNode
|
|
1555
1595
|
inner = path + [node.class_name]
|
|
1556
|
-
(node.members.each_value.to_a + [node.catch_all]).each
|
|
1596
|
+
(node.members.each_value.to_a + [node.catch_all]).each do |member|
|
|
1597
|
+
check_abstract_mixins!(member, inner, hoisted:)
|
|
1598
|
+
end
|
|
1557
1599
|
when ObjectNode
|
|
1558
1600
|
inner = path + [node.class_name]
|
|
1559
|
-
refuse_unsatisfiable_mixin!(node, inner)
|
|
1560
|
-
node.fields.each
|
|
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
|
|
1561
1605
|
end
|
|
1562
1606
|
end
|
|
1563
1607
|
|
|
1564
|
-
def refuse_unsatisfiable_mixin!(node, path)
|
|
1608
|
+
def refuse_unsatisfiable_mixin!(node, path, hoisted:)
|
|
1565
1609
|
return if node.overrides.empty?
|
|
1566
1610
|
|
|
1567
1611
|
mixins = @registry.type_registry.dig(node.graphql_type, :mixins) || []
|
|
@@ -1577,13 +1621,21 @@ class GraphWeaver::Codegen
|
|
|
1577
1621
|
mixin = mixins.find { |m|
|
|
1578
1622
|
T::AbstractUtils.declared_abstract_methods_for(m).any? { |method| missing.include?(method.name.to_s) }
|
|
1579
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
|
+
|
|
1580
1634
|
raise GraphWeaver::Error,
|
|
1581
1635
|
"#{[@name, *path].join("::")} includes #{mixin.name}, which declares " \
|
|
1582
1636
|
"#{GraphWeaver::Internal::Util.sample(missing.map(&:inspect))} abstract — this selection does " \
|
|
1583
|
-
"not provide #{
|
|
1584
|
-
"#{node.graphql_type} includes the mixin, so `srb tc` fails on this one.
|
|
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."
|
|
1637
|
+
"not provide #{them}, and every struct generated from " \
|
|
1638
|
+
"#{node.graphql_type} includes the mixin, so `srb tc` fails on this one. #{fix}"
|
|
1587
1639
|
end
|
|
1588
1640
|
private :check_abstract_mixins!, :refuse_unsatisfiable_mixin!
|
|
1589
1641
|
|
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 = {}
|
|
@@ -55,7 +55,7 @@ class GraphWeaver::InProcess
|
|
|
55
55
|
payload = { url: nil, schema: schema_label, operation: operation_name, client: self.class,
|
|
56
56
|
kind: GraphWeaver::Internal::Wire.kind(query) }
|
|
57
57
|
|
|
58
|
-
GraphWeaver::Internal::Log.
|
|
58
|
+
GraphWeaver::Internal::Log.instrument_request(payload) do
|
|
59
59
|
perform(query, variables, operation_name)
|
|
60
60
|
end
|
|
61
61
|
end
|
|
@@ -24,7 +24,13 @@ module GraphWeaver
|
|
|
24
24
|
# is the schema's name for the slot ("PetFilter.species") and type its
|
|
25
25
|
# spelling of what goes there ("[Float!]!"), so a refusal can say where
|
|
26
26
|
# it happened, and in whose vocabulary, without reflecting at runtime.
|
|
27
|
-
Field = Data.define(:prop, :wire, :required, :serializer, :coercer, :coordinate, :type)
|
|
27
|
+
Field = Data.define(:prop, :wire, :required, :serializer, :coercer, :coordinate, :type) do
|
|
28
|
+
# How a message names this field. The prop is what you type in Ruby, so
|
|
29
|
+
# it leads; the wire name is what you grep the .graphql for, so it comes
|
|
30
|
+
# along where the two differ. Nothing structured reads this — #path,
|
|
31
|
+
# #field and #coordinate are the schema's spelling either way.
|
|
32
|
+
def label = (wire == prop.to_s) ? prop.to_s : "#{prop} (#{wire})"
|
|
33
|
+
end
|
|
28
34
|
|
|
29
35
|
# An enum reaching the library as input — an execute kwarg or an input
|
|
30
36
|
# field — as the member or its wire value. Generated code calls these
|
|
@@ -70,6 +76,25 @@ module GraphWeaver
|
|
|
70
76
|
table.fetch(value) { invalid_enum!(type, value, table.keys) }
|
|
71
77
|
end
|
|
72
78
|
|
|
79
|
+
# The way back out: a member => the wire value it sends as. Generation
|
|
80
|
+
# makes that table total, so a miss is a T::Enum that grew a member since
|
|
81
|
+
# — and Hash#fetch's KeyError named the anonymous table rather than the
|
|
82
|
+
# member or anything it could have sent. Also the result side's `as_json`,
|
|
83
|
+
# where nothing wraps a raised KeyError into a GraphWeaver error at all.
|
|
84
|
+
def self.enum_wire(graphql_name, table, member)
|
|
85
|
+
table.fetch(member) do
|
|
86
|
+
raise GraphWeaver::Internal::Refusal.brand(
|
|
87
|
+
GraphWeaver::Error.new(
|
|
88
|
+
# a T::Enum member inspects as #<Type::Name>
|
|
89
|
+
"#{member.inspect[2..-2]} maps onto no #{graphql_name} value, so there is nothing to " \
|
|
90
|
+
"send for it — expected one of: #{table.values.sort.join(", ")}; a member added since " \
|
|
91
|
+
"you generated needs a regenerate",
|
|
92
|
+
),
|
|
93
|
+
:not_a_member, members: table.values.sort,
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
73
98
|
# Names the input field a coercion refused — a scalar's coercer, an
|
|
74
99
|
# enum's, or a nested input's — since the complaint underneath is about
|
|
75
100
|
# the value alone. A nested error that already named a field keeps its
|
|
@@ -83,7 +108,7 @@ module GraphWeaver
|
|
|
83
108
|
raise e.within(field.wire, prop:) if e.field && !redact.filtered?(prop)
|
|
84
109
|
|
|
85
110
|
raise GraphWeaver::InputError.new(
|
|
86
|
-
"#{
|
|
111
|
+
"#{field.label}: #{redact.detail(prop, e.message)}",
|
|
87
112
|
kind: e.kind, path: [field.wire, *e.path], coordinate: e.coordinate || field.coordinate,
|
|
88
113
|
# #value is the value AT #path: this layer owns it only when nothing
|
|
89
114
|
# inner named a field (so a missing one stays valueless, as it is)
|
|
@@ -93,7 +118,7 @@ module GraphWeaver
|
|
|
93
118
|
rescue StandardError => e
|
|
94
119
|
redact = GraphWeaver::Internal::Redact
|
|
95
120
|
raise GraphWeaver::InputError.new(
|
|
96
|
-
"#{
|
|
121
|
+
"#{field.label}: #{redact.detail(prop, e.message)}",
|
|
97
122
|
kind: GraphWeaver::Internal::Refusal.kind_of(e), path: [field.wire],
|
|
98
123
|
coordinate: field.coordinate, value: redact.value(prop, raw),
|
|
99
124
|
details: GraphWeaver::Internal::Refusal.details_of(e), struct:,
|
|
@@ -284,7 +309,7 @@ module GraphWeaver
|
|
|
284
309
|
field = T.unsafe(self).const_get(:FIELDS).find { |f| f.prop == prop }
|
|
285
310
|
type = field&.type || T::Utils.coerce(info[:type]).to_s
|
|
286
311
|
return GraphWeaver::InputError.new(
|
|
287
|
-
"#{prop}: expected #{type}, got #{GraphWeaver::Internal::Redact.shown(value, prop)}",
|
|
312
|
+
"#{field&.label || prop}: expected #{type}, got #{GraphWeaver::Internal::Redact.shown(value, prop)}",
|
|
288
313
|
kind: :type_mismatch, path: [prop.to_s], coordinate: field&.coordinate,
|
|
289
314
|
value: GraphWeaver::Internal::Redact.value(prop, value),
|
|
290
315
|
details: { type: }, struct: self,
|
|
@@ -10,9 +10,9 @@ module GraphWeaver
|
|
|
10
10
|
# suite-wide ones while the block that set them is still on the stack,
|
|
11
11
|
# and a fake built with its own checks them then.
|
|
12
12
|
module Overrides
|
|
13
|
-
# The key a Hash `list_size:` says its
|
|
14
|
-
# doesn't name.
|
|
15
|
-
|
|
13
|
+
# The key a per-field Hash (`list_size:`, `null_chance:`) says its
|
|
14
|
+
# fallback under — everything it doesn't name.
|
|
15
|
+
DEFAULT_KEY = "default"
|
|
16
16
|
|
|
17
17
|
class << self
|
|
18
18
|
# A pin key names something in the schema: a type ("Money",
|
|
@@ -51,16 +51,22 @@ module GraphWeaver
|
|
|
51
51
|
# minus the bare type name: a type says nothing about how long any one
|
|
52
52
|
# of its fields is.
|
|
53
53
|
def validate_list_size!(schema, list_size)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
next if key.to_s == LIST_SIZE_DEFAULT
|
|
54
|
+
reaches = ->(type) { type.list? }
|
|
55
|
+
validate_per_field!(schema, list_size, "list_size",
|
|
56
|
+
reaches:, unreached: "is not a list and has no length to set — name a list field") do |value|
|
|
57
|
+
"an Integer or a Range of them, neither negative — how long an unbounded list is" unless
|
|
58
|
+
length?(value)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
62
61
|
|
|
63
|
-
|
|
62
|
+
# A Hash `null_chance:` is keyed the same way, one nullable field at a
|
|
63
|
+
# time.
|
|
64
|
+
def validate_null_chance!(schema, null_chance)
|
|
65
|
+
reaches = method(:nullable_anywhere?)
|
|
66
|
+
validate_per_field!(schema, null_chance, "null_chance",
|
|
67
|
+
reaches:, unreached: "can never come back null — name a nullable field") do |value|
|
|
68
|
+
"a number from 0 to 1 — how often a nullable field comes back null" unless
|
|
69
|
+
value.is_a?(Numeric) && (0..1).cover?(value)
|
|
64
70
|
end
|
|
65
71
|
end
|
|
66
72
|
|
|
@@ -75,6 +81,83 @@ module GraphWeaver
|
|
|
75
81
|
|
|
76
82
|
private
|
|
77
83
|
|
|
84
|
+
# The two shapes both per-field options take: one value for every
|
|
85
|
+
# field, or a Hash keyed by field — a "Type.field" coordinate or a
|
|
86
|
+
# bare field name — with DEFAULT_KEY for the rest. The block says
|
|
87
|
+
# what a value has to be, in the words the refusal uses, and says it
|
|
88
|
+
# of both shapes: a plain `null_chance: 7` used to sail through and
|
|
89
|
+
# null everything, a plain `list_size: "3"` to die inside the
|
|
90
|
+
# fabricator.
|
|
91
|
+
def validate_per_field!(schema, option, name, reaches:, unreached:)
|
|
92
|
+
unless option.is_a?(Hash)
|
|
93
|
+
refuse_value!(name, nil, option, yield(option))
|
|
94
|
+
return
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
option.each do |key, value|
|
|
98
|
+
refuse_value!(name, key, value, yield(value))
|
|
99
|
+
next if key.to_s == DEFAULT_KEY
|
|
100
|
+
|
|
101
|
+
label = "#{name}: key"
|
|
102
|
+
validate_field_key!(schema, key.to_s, label)
|
|
103
|
+
reaches!(schema, key.to_s, label, reaches, unreached)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# A key naming a field the option can never reach is inert, which is
|
|
108
|
+
# the silent green every other key check exists to stop: the
|
|
109
|
+
# fabricator asks `null_chance` at nullable positions only and
|
|
110
|
+
# `list_size` at lists only, so it never looks this key up.
|
|
111
|
+
def reaches!(schema, key, label, reaches, unreached)
|
|
112
|
+
types = field_types(schema, key)
|
|
113
|
+
return if types.empty? || types.any? { |type| reaches.call(type) }
|
|
114
|
+
|
|
115
|
+
spelled = types.map(&:to_type_signature).uniq.sort.join(", ")
|
|
116
|
+
raise GraphWeaver::Error, "#{label} #{key.inspect} (#{spelled}) #{unreached}, " \
|
|
117
|
+
"or drop the key"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Every field a per-field key names: the one a coordinate points at,
|
|
121
|
+
# or every field of that name in the schema for a bare one.
|
|
122
|
+
def field_types(schema, key)
|
|
123
|
+
type_name, field_name = key.split(".", 2)
|
|
124
|
+
# introspection fields (__typename) are real but absent from #fields
|
|
125
|
+
return [] if (field_name || type_name).start_with?("__")
|
|
126
|
+
return [schema.get_type(type_name).fields.fetch(field_name).type] if field_name
|
|
127
|
+
|
|
128
|
+
schema.types.each_value.filter_map do |type|
|
|
129
|
+
type.fields[type_name]&.type if type.respond_to?(:fields)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Whether null_chance has a position in this type to reach. The outer
|
|
134
|
+
# wrapper isn't the whole answer: `[Pet]!` is non-null and its ELEMENTS
|
|
135
|
+
# are nullable, which is where the fabricator puts the nulls.
|
|
136
|
+
def nullable_anywhere?(type)
|
|
137
|
+
return true unless type.non_null?
|
|
138
|
+
|
|
139
|
+
inner = type.of_type
|
|
140
|
+
inner.list? && nullable_anywhere?(inner.of_type)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def refuse_value!(name, key, value, wanted)
|
|
144
|
+
return unless wanted
|
|
145
|
+
|
|
146
|
+
raise GraphWeaver::Error, "#{name}:#{" #{key.to_s.inspect}" if key} must be #{wanted} — " \
|
|
147
|
+
"got #{value.inspect}"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# A length is a count the fabricator can build an Array of: Array.new(-1)
|
|
151
|
+
# is "negative array size" out of its guts, and a Range the seeded rng
|
|
152
|
+
# can't sample (endless, or beginless) is worse.
|
|
153
|
+
def length?(value)
|
|
154
|
+
case value
|
|
155
|
+
when Integer then !value.negative?
|
|
156
|
+
when Range then [value.begin, value.end].all? { |edge| edge.is_a?(Integer) && !edge.negative? }
|
|
157
|
+
else false
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
78
161
|
# A proc taking anything else can't be called at fabrication time,
|
|
79
162
|
# and the ArgumentError it would raise there names no pin.
|
|
80
163
|
def validate_arity!(key, value)
|
|
@@ -113,20 +196,49 @@ module GraphWeaver
|
|
|
113
196
|
known = field_names(schema)
|
|
114
197
|
return if known.include?(type_name)
|
|
115
198
|
|
|
199
|
+
# a type name reads like a reasonable key here — `null_chance: {
|
|
200
|
+
# "Person" => 1.0 }` looks like "null the whole subtree" — and
|
|
201
|
+
# did_you_mean sent it to the nearest FIELD ('person') instead of
|
|
202
|
+
# saying these options are keyed by field
|
|
203
|
+
named = schema.get_type(type_name)
|
|
204
|
+
type_key!(label, key, named) if named
|
|
116
205
|
bad!(label, key, "matches no field in this schema", known, type_name)
|
|
117
206
|
end
|
|
118
207
|
|
|
119
208
|
coordinate!(schema, label, key, type_name, field_name)
|
|
120
209
|
end
|
|
121
210
|
|
|
211
|
+
def type_key!(label, key, type)
|
|
212
|
+
reach = type.respond_to?(:fields) ? "#{type.graphql_name}.<field>".inspect : "a \"Type.field\" coordinate"
|
|
213
|
+
raise GraphWeaver::Error, "#{label} #{key.inspect} names " \
|
|
214
|
+
"#{type.kind.name.downcase.tr("_", " ")} #{type.graphql_name}, and a key here names " \
|
|
215
|
+
"one field — #{reach}, or a bare field name"
|
|
216
|
+
end
|
|
217
|
+
|
|
122
218
|
def coordinate!(schema, label, key, type_name, field_name)
|
|
123
219
|
type = schema.get_type(type_name)
|
|
124
220
|
unless type.respond_to?(:fields)
|
|
125
221
|
bad!(label, key, "names no object type in this schema", schema.types.keys, type_name)
|
|
126
222
|
end
|
|
127
|
-
|
|
223
|
+
unless type.fields.key?(field_name)
|
|
224
|
+
bad!(label, key, "is not a field of #{type_name}", type.fields.keys, field_name)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
concrete!(schema, label, key, type, field_name)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# The abstract-type refusal, in the coordinate form. An interface
|
|
231
|
+
# declares the field, so "Named.name" reads as a key that must work
|
|
232
|
+
# — and it matches nothing: the walk picks a member before it builds
|
|
233
|
+
# a coordinate, so every key it looks up is "Person.name".
|
|
234
|
+
def concrete!(schema, label, key, type, field_name)
|
|
235
|
+
return unless type.kind.abstract?
|
|
128
236
|
|
|
129
|
-
|
|
237
|
+
members = schema.possible_types(type)
|
|
238
|
+
.map { |member| "#{member.graphql_name}.#{field_name}".inspect }.sort
|
|
239
|
+
raise GraphWeaver::Error, "#{label} #{key.inspect} names #{type.kind.name.downcase} " \
|
|
240
|
+
"#{type.graphql_name}, and a fake only ever holds a concrete type: name the " \
|
|
241
|
+
"concrete type — #{members.join(", ")}"
|
|
130
242
|
end
|
|
131
243
|
|
|
132
244
|
# A type pin says what every value of that type is, and the fake only
|
|
@@ -97,15 +97,20 @@ module GraphWeaver
|
|
|
97
97
|
# :wire takes no client slot: it serves the resolvers at the
|
|
98
98
|
# endpoint each client already posts to — the transport you ship,
|
|
99
99
|
# running unchanged, is the whole point
|
|
100
|
-
return if @mode == :wire
|
|
100
|
+
return if @mode == :wire && GraphWeaver.graphs.all?(&:client_url)
|
|
101
101
|
# :live is the app's own clients, untouched — so with nothing
|
|
102
102
|
# standing in there is nothing to look up
|
|
103
103
|
return if @mode == :live && !built?
|
|
104
104
|
|
|
105
|
+
graph = graph_for!(mod)
|
|
106
|
+
# a graph whose client posts to no url has no wire to be served at,
|
|
107
|
+
# so :wire serves it here instead — the same pick, one hop shorter
|
|
108
|
+
return if @mode == :wire && graph&.client_url
|
|
109
|
+
|
|
105
110
|
# a helper's entry wins whatever the example's mode is, and :live
|
|
106
111
|
# builds nothing of its own, so an untagged example's other graphs
|
|
107
112
|
# still resolve their own clients
|
|
108
|
-
standin(
|
|
113
|
+
standin(graph)
|
|
109
114
|
end
|
|
110
115
|
|
|
111
116
|
# The stand-in `graph`'s modules run against under the installed mode,
|
|
@@ -165,12 +170,29 @@ module GraphWeaver
|
|
|
165
170
|
return :in_process if config.schema_class?(graph)
|
|
166
171
|
return :fake if config.schema || graph&.named_schema?
|
|
167
172
|
|
|
168
|
-
raise GraphWeaver::Error,
|
|
169
|
-
|
|
170
|
-
|
|
173
|
+
raise GraphWeaver::Error, nothing_to_serve(graph)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Two graphs are in different states here, and the served one's
|
|
177
|
+
# sentences are all false for the other: a graph whose client posts
|
|
178
|
+
# nowhere has no endpoint to stub, no stub of ours to introspect, and
|
|
179
|
+
# no url to refresh a dump from — what it is missing is a schema.
|
|
180
|
+
def nothing_to_serve(graph)
|
|
181
|
+
missing = "no live GraphQL::Schema class, no composed supergraph, and no type " \
|
|
171
182
|
"information (nothing at #{GraphWeaver.schema_path}, and " \
|
|
172
|
-
"GraphWeaver::Testing.config.schema is unset)
|
|
173
|
-
|
|
183
|
+
"GraphWeaver::Testing.config.schema is unset)"
|
|
184
|
+
if graph&.name && !graph.client_url
|
|
185
|
+
return ":wire has no endpoint for graph #{graph.name.inspect} — its client posts to " \
|
|
186
|
+
"none, so its modules run above the wire, against the most faithful stand-in the " \
|
|
187
|
+
"graph has. It has #{missing}. Name one where the graph is declared: " \
|
|
188
|
+
"GraphWeaver.graph(#{graph.name.inspect}) { schema -> { MySchema } }, or a dump " \
|
|
189
|
+
"(schema \"schema.graphql\"). Or tag the example graphql: :live."
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
":wire serves your schema at the endpoint your client posts to, and " \
|
|
193
|
+
"#{graph&.name ? "graph #{graph.name.inspect}" : "this app"} has none to serve — " \
|
|
194
|
+
"#{missing}. Your client's own schema can't stand in here: reading it introspects " \
|
|
195
|
+
"the endpoint :wire has stubbed. Commit a dump " \
|
|
174
196
|
"(rake graph_weaver:schema:refresh URL=…), or tag the example graphql: :live."
|
|
175
197
|
end
|
|
176
198
|
|