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/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
|
|
|
@@ -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
|
|
@@ -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
|
|
@@ -24,19 +24,28 @@ 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
|
|
31
37
|
# rather than T::Enum.deserialize / the wire table directly: both raise a
|
|
32
38
|
# bare KeyError naming an anonymous module and none of the values they
|
|
33
39
|
# would have taken.
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
+
# fallback is the generated Other member (register_enum fallback: true).
|
|
41
|
+
# It is the one member input refuses: nothing on the wire means it, so a
|
|
42
|
+
# variable carrying it would send a value the server never declared.
|
|
43
|
+
def self.enum(type, value, aliases = nil, fallback: nil)
|
|
44
|
+
member = value.is_a?(type) ? value : type.try_deserialize(aliases ? aliases.fetch(value, value) : value)
|
|
45
|
+
return member if member && !member.equal?(fallback)
|
|
46
|
+
|
|
47
|
+
accepted = type.values.map(&:serialize) - [fallback&.serialize].compact
|
|
48
|
+
member ? unsendable_enum!(member, accepted) : invalid_enum!(type, value, accepted)
|
|
40
49
|
end
|
|
41
50
|
|
|
42
51
|
# A list element's index, prepended when something inside it refused —
|
|
@@ -67,6 +76,25 @@ module GraphWeaver
|
|
|
67
76
|
table.fetch(value) { invalid_enum!(type, value, table.keys) }
|
|
68
77
|
end
|
|
69
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
|
+
|
|
70
98
|
# Names the input field a coercion refused — a scalar's coercer, an
|
|
71
99
|
# enum's, or a nested input's — since the complaint underneath is about
|
|
72
100
|
# the value alone. A nested error that already named a field keeps its
|
|
@@ -80,7 +108,7 @@ module GraphWeaver
|
|
|
80
108
|
raise e.within(field.wire, prop:) if e.field && !redact.filtered?(prop)
|
|
81
109
|
|
|
82
110
|
raise GraphWeaver::InputError.new(
|
|
83
|
-
"#{
|
|
111
|
+
"#{field.label}: #{redact.detail(prop, e.message)}",
|
|
84
112
|
kind: e.kind, path: [field.wire, *e.path], coordinate: e.coordinate || field.coordinate,
|
|
85
113
|
# #value is the value AT #path: this layer owns it only when nothing
|
|
86
114
|
# inner named a field (so a missing one stays valueless, as it is)
|
|
@@ -90,7 +118,7 @@ module GraphWeaver
|
|
|
90
118
|
rescue StandardError => e
|
|
91
119
|
redact = GraphWeaver::Internal::Redact
|
|
92
120
|
raise GraphWeaver::InputError.new(
|
|
93
|
-
"#{
|
|
121
|
+
"#{field.label}: #{redact.detail(prop, e.message)}",
|
|
94
122
|
kind: GraphWeaver::Internal::Refusal.kind_of(e), path: [field.wire],
|
|
95
123
|
coordinate: field.coordinate, value: redact.value(prop, raw),
|
|
96
124
|
details: GraphWeaver::Internal::Refusal.details_of(e), struct:,
|
|
@@ -110,6 +138,18 @@ module GraphWeaver
|
|
|
110
138
|
end
|
|
111
139
|
private_class_method :invalid_enum!
|
|
112
140
|
|
|
141
|
+
# The fallback member is a landing pad for drift, not a value — so it is
|
|
142
|
+
# refused by name rather than listed among the ones you could have meant.
|
|
143
|
+
def self.unsendable_enum!(member, accepted)
|
|
144
|
+
# a T::Enum member inspects as #<Type::Name>
|
|
145
|
+
raise GraphWeaver::Internal::Refusal.brand(
|
|
146
|
+
KeyError.new("#{member.inspect[2..-2]} absorbs values the server added, so " \
|
|
147
|
+
"there is nothing to send for it — expected one of: #{accepted.sort.join(", ")}"),
|
|
148
|
+
:not_a_member, members: accepted.sort,
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
private_class_method :unsendable_enum!
|
|
152
|
+
|
|
113
153
|
def self.included(base)
|
|
114
154
|
base.extend(ClassMethods)
|
|
115
155
|
end
|
|
@@ -269,7 +309,7 @@ module GraphWeaver
|
|
|
269
309
|
field = T.unsafe(self).const_get(:FIELDS).find { |f| f.prop == prop }
|
|
270
310
|
type = field&.type || T::Utils.coerce(info[:type]).to_s
|
|
271
311
|
return GraphWeaver::InputError.new(
|
|
272
|
-
"#{prop}: expected #{type}, got #{GraphWeaver::Internal::Redact.shown(value, prop)}",
|
|
312
|
+
"#{field&.label || prop}: expected #{type}, got #{GraphWeaver::Internal::Redact.shown(value, prop)}",
|
|
273
313
|
kind: :type_mismatch, path: [prop.to_s], coordinate: field&.coordinate,
|
|
274
314
|
value: GraphWeaver::Internal::Redact.value(prop, value),
|
|
275
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
|
|
@@ -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
|
|
@@ -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
|
|
|
@@ -26,10 +26,17 @@ module GraphWeaver
|
|
|
26
26
|
# call that names none of them. Caught where the sink line carries the
|
|
27
27
|
# module's own name, or a local a line above assigned from it.
|
|
28
28
|
SINKS = /\b(?:to_h|to_json|as_json|serialize|deconstruct_keys)\b|render\s+json:/
|
|
29
|
-
# `result = PersonQuery.execute!(...)` — the
|
|
29
|
+
# `result = PersonQuery.execute!(...)` — the name a response lands in.
|
|
30
30
|
# Following one is what lets the sink be on the NEXT line, which is how
|
|
31
|
-
# anyone actually writes a controller.
|
|
32
|
-
|
|
31
|
+
# anyone actually writes a controller. The `@` is part of the capture:
|
|
32
|
+
# it is what says the name outlives the method. Excludes == and =~.
|
|
33
|
+
ASSIGN = /(@?\b[a-z_]\w*)\s*=[^=~]/
|
|
34
|
+
# Where a plain local stops standing for the module it was assigned
|
|
35
|
+
# from: the next method is a new scope, and a block param there that
|
|
36
|
+
# happens to share the name holds someone else's value. An ivar crosses
|
|
37
|
+
# it — `before_action` loading `@result` for the action to render is
|
|
38
|
+
# the shape every Rails controller has.
|
|
39
|
+
SCOPE = /^[ \t]*def\s/
|
|
33
40
|
# A graphql-ruby TYPE class NAMES every field the server offers, as
|
|
34
41
|
# `field :sku` and as a resolver method — which is the server answering,
|
|
35
42
|
# not this app reading a prop back. Without this an app that serves the
|
|
@@ -47,6 +54,11 @@ module GraphWeaver
|
|
|
47
54
|
# .json.erb's sibling JS — is a blind spot, and the footer says so.
|
|
48
55
|
# .rake and .builder are Ruby too.
|
|
49
56
|
EXTENSIONS = %w[.rb .rake .builder .erb .slim .haml .jbuilder].freeze
|
|
57
|
+
# Ruby that carries no extension to recognise it by. In a non-Rails
|
|
58
|
+
# project the entry points live here, so skipping them skipped the
|
|
59
|
+
# files that read the query.
|
|
60
|
+
SCRIPT_DIRS = Set["bin", "exe"].freeze
|
|
61
|
+
RUBY_SHEBANG = /\A#!.*\bruby\b/
|
|
50
62
|
# Directories that hold no app source. "generated" covers both a graph's
|
|
51
63
|
# own output under the convention and a spec/generated fixture dir; a
|
|
52
64
|
# graph that writes somewhere else is pruned by #outputs.
|
|
@@ -60,8 +72,9 @@ module GraphWeaver
|
|
|
60
72
|
# difference between a lint and a number somebody trusts.
|
|
61
73
|
FOOTER = "This is a lint, not a proof — it matches prop names as text, so a common name reads " \
|
|
62
74
|
"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
|
-
"
|
|
75
|
+
"read\nin a file type it doesn't sweep — #{EXTENSIONS.join(", ")},\nplus Ruby with no " \
|
|
76
|
+
"extension (any name under bin/ or exe/, a ruby shebang elsewhere). On\na real app half to " \
|
|
77
|
+
"two thirds of genuinely unread selections go unreported; silence is\nthe safe direction."
|
|
65
78
|
|
|
66
79
|
# query: the .graphql that selected it. struct/prop: where it landed.
|
|
67
80
|
# wire: how the query spells that prop, when it differs.
|
|
@@ -183,6 +196,7 @@ module GraphWeaver
|
|
|
183
196
|
# `result = Q.execute!(...)`, then `render json: result.person`.
|
|
184
197
|
locals = Hash.new { |hash, key| hash[key] = [] }
|
|
185
198
|
body.each_line.with_index(1) do |line, number|
|
|
199
|
+
locals.each_value { |names| names.select! { |n| n.start_with?("@") } } if SCOPE.match?(line)
|
|
186
200
|
candidates.each do |name, base|
|
|
187
201
|
locals[name] << Regexp.last_match(1) if line.include?(base) && ASSIGN.match(line)
|
|
188
202
|
end
|
|
@@ -191,7 +205,7 @@ module GraphWeaver
|
|
|
191
205
|
candidates.each do |name, base|
|
|
192
206
|
# the line naming the module is the better evidence; the local
|
|
193
207
|
# is what it falls back to
|
|
194
|
-
via = locals[name].find { |local| line.match?(
|
|
208
|
+
via = locals[name].find { |local| line.match?(/(?<![\w@])#{Regexp.escape(local)}\b/) } \
|
|
195
209
|
unless line.include?(base)
|
|
196
210
|
next unless via || line.include?(base)
|
|
197
211
|
|
|
@@ -215,20 +229,31 @@ module GraphWeaver
|
|
|
215
229
|
result = Object.const_get(name)
|
|
216
230
|
next [] unless result.const_defined?(:Result, false)
|
|
217
231
|
|
|
218
|
-
|
|
232
|
+
keys = response_keys(GraphQL.parse(source).definitions)
|
|
219
233
|
props(result.const_get(:Result, false))
|
|
220
|
-
.map { |struct, prop| Selection.new(path, name, struct, prop, wire_word(
|
|
234
|
+
.map { |struct, prop| Selection.new(path, name, struct, prop, wire_word(keys, prop)) }
|
|
221
235
|
end
|
|
222
236
|
end
|
|
223
237
|
end
|
|
224
238
|
|
|
225
239
|
# How the query spells a prop, when that isn't the prop's own name — a
|
|
226
240
|
# camelCase field, an alias, a reserved rename. Read back off the query
|
|
227
|
-
#
|
|
228
|
-
#
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
241
|
+
# rather than derived from the prop, since no rule inverts an alias; nil
|
|
242
|
+
# when the query spells it the same way, which is most of the time.
|
|
243
|
+
def wire_word(keys, prop)
|
|
244
|
+
keys.find { |key| key != prop.to_s && GraphWeaver::Codegen.prop_name(key) == prop.to_s }
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Every response key the document asks for. The coordinate is the
|
|
248
|
+
# selection to go and delete, so it can only be one of these: scanning
|
|
249
|
+
# the file's text let `$id: ID!` — or a comment — name a field the query
|
|
250
|
+
# never selected.
|
|
251
|
+
def response_keys(nodes, found = [])
|
|
252
|
+
nodes.each do |node|
|
|
253
|
+
found << (node.alias || node.name) if node.is_a?(GraphQL::Language::Nodes::Field)
|
|
254
|
+
response_keys(node.selections, found) if node.respond_to?(:selections)
|
|
255
|
+
end
|
|
256
|
+
found
|
|
232
257
|
end
|
|
233
258
|
|
|
234
259
|
# Nested structs are nested constants, so the props of a whole response
|
|
@@ -244,19 +269,22 @@ module GraphWeaver
|
|
|
244
269
|
end
|
|
245
270
|
|
|
246
271
|
def files
|
|
247
|
-
@files ||= @roots
|
|
272
|
+
@files ||= @roots
|
|
273
|
+
.flat_map { |root| collect(root, [], SCRIPT_DIRS.include?(File.basename(root))) }
|
|
274
|
+
.uniq.sort
|
|
248
275
|
end
|
|
249
276
|
|
|
250
277
|
# Pruned as it walks rather than globbed and filtered: node_modules is
|
|
251
|
-
# the directory you most want never to descend into.
|
|
252
|
-
|
|
278
|
+
# the directory you most want never to descend into. scripts says we are
|
|
279
|
+
# inside bin/ or exe/, which the walk knows and a path doesn't.
|
|
280
|
+
def collect(dir, found, scripts)
|
|
253
281
|
Dir.children(dir).sort.each do |entry|
|
|
254
282
|
path = File.join(dir, entry)
|
|
255
283
|
# lstat, so a symlinked directory can't loop the walk
|
|
256
284
|
stat = File.lstat(path)
|
|
257
285
|
if stat.directory?
|
|
258
|
-
collect(path, found) unless skip_dir?(entry, path)
|
|
259
|
-
elsif stat.file? &&
|
|
286
|
+
collect(path, found, scripts || SCRIPT_DIRS.include?(entry)) unless skip_dir?(entry, path)
|
|
287
|
+
elsif stat.file? && ruby?(path, entry, scripts)
|
|
260
288
|
found << path
|
|
261
289
|
end
|
|
262
290
|
end
|
|
@@ -265,6 +293,22 @@ module GraphWeaver
|
|
|
265
293
|
found
|
|
266
294
|
end
|
|
267
295
|
|
|
296
|
+
# An extension names most of it. A file with none is Ruby if it sits
|
|
297
|
+
# under bin/ or exe/ — that is what those directories are for — or if
|
|
298
|
+
# its first line says so.
|
|
299
|
+
def ruby?(path, entry, scripts)
|
|
300
|
+
return true if EXTENSIONS.include?(File.extname(entry))
|
|
301
|
+
return false unless File.extname(entry).empty?
|
|
302
|
+
|
|
303
|
+
scripts || shebang?(path)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def shebang?(path)
|
|
307
|
+
File.open(path) { |file| file.gets(chomp: true) }&.match?(RUBY_SHEBANG) || false
|
|
308
|
+
rescue SystemCallError, ArgumentError
|
|
309
|
+
false
|
|
310
|
+
end
|
|
311
|
+
|
|
268
312
|
def skip_dir?(entry, path) = entry.start_with?(".") || SKIP.include?(entry) || outputs.include?(path)
|
|
269
313
|
|
|
270
314
|
# The generated directories a name check can't catch: a graph that sets
|
|
@@ -57,6 +57,11 @@ class GraphWeaver::Internal::Values
|
|
|
57
57
|
# registration says the server writes "12.5".
|
|
58
58
|
WIRE = [NilClass, TrueClass, FalseClass, Integer, Float, String, Symbol, Array, Hash].freeze
|
|
59
59
|
|
|
60
|
+
# Whether a value is already one of those. At a leaf it means the registry's
|
|
61
|
+
# serializer has nothing to do; at a composite position (FakeClient) it means
|
|
62
|
+
# the pin stands as written.
|
|
63
|
+
def self.wire?(value) = WIRE.any? { |klass| value.is_a?(klass) }
|
|
64
|
+
|
|
60
65
|
# The fallback, for a scalar nobody registered: its prop is T.untyped, so
|
|
61
66
|
# anything holds and a plausible shape beats a placeholder.
|
|
62
67
|
NAMED_SHAPES = {
|
|
@@ -83,7 +88,12 @@ class GraphWeaver::Internal::Values
|
|
|
83
88
|
# for the next, and only a caller holding the graph can say which. Left
|
|
84
89
|
# unsaid they are read back off the schema, which is the answer for every
|
|
85
90
|
# app with one graph running a live class.
|
|
86
|
-
|
|
91
|
+
# pin_advice: how to pin, in the words of the door the caller came in by —
|
|
92
|
+
# a callable taking the scalar's name. Left unsaid it is the three doors
|
|
93
|
+
# onto a fake, which is where this is reached from unless something says
|
|
94
|
+
# otherwise (check_scalars! runs outside all three).
|
|
95
|
+
def initialize(seed: nil, values: nil, pins: nil, schema: nil, registry: nil, pin_advice: nil)
|
|
96
|
+
@pin_advice = pin_advice
|
|
87
97
|
@registry = registry || GraphWeaver::Internal::Util.registry_for(schema)
|
|
88
98
|
@rng = Random.new(seed || GraphWeaver::Testing.config.seed || Random.new_seed)
|
|
89
99
|
@pins = (pins || GraphWeaver::Testing.config.overrides).transform_keys(&:to_s)
|
|
@@ -146,10 +156,10 @@ class GraphWeaver::Internal::Values
|
|
|
146
156
|
# as written. Shared with the object-pin door, so both read a pin the same
|
|
147
157
|
# way.
|
|
148
158
|
def wire(type_name, value, coordinate = nil)
|
|
149
|
-
return value if
|
|
159
|
+
return value if self.class.wire?(value)
|
|
150
160
|
|
|
151
161
|
serialized = @registry.scalar(type_name, coordinate).serialize_value(value)
|
|
152
|
-
return serialized if
|
|
162
|
+
return serialized if self.class.wire?(serialized)
|
|
153
163
|
|
|
154
164
|
article = GraphWeaver::Internal::Util.article(value.class.to_s)
|
|
155
165
|
raise GraphWeaver::Error, "the pin for #{type_name.inspect} is #{article} #{value.class}, and a pin " \
|
|
@@ -197,12 +207,19 @@ class GraphWeaver::Internal::Values
|
|
|
197
207
|
# for — `Money.parse` accepts what its author decided it accepts — and
|
|
198
208
|
# guessing hands the generated cast a placeholder, which fails deep inside
|
|
199
209
|
# from_h blaming the codec.
|
|
210
|
+
#
|
|
211
|
+
# Each door onto a fake spells a pin differently and this can't know which
|
|
212
|
+
# you came in by, so the advice names all three — unless the caller said,
|
|
213
|
+
# in which case it is the one that works there.
|
|
200
214
|
def unfakeable!(type_name, field_name, registered, coordinate, at)
|
|
215
|
+
type = type_name.inspect
|
|
216
|
+
advice = @pin_advice&.call(type_name) ||
|
|
217
|
+
"Pin the type (#{type}) or just this field (#{(coordinate || field_name).inspect}), " \
|
|
218
|
+
"wherever the fake is built — graphql_fake(#{type} => ...) in an rspec example, " \
|
|
219
|
+
"FakeClient.new(#{type} => ...) outside one, or GraphWeaver::Testing.config.overrides " \
|
|
220
|
+
"for the whole suite."
|
|
201
221
|
raise GraphWeaver::Error, "can't fabricate a #{type_name} #{at ? "at #{at}" : "for #{field_name.inspect}"}: " \
|
|
202
|
-
"it deserializes into #{registered.type}, and only you know what wire value that accepts. "
|
|
203
|
-
"Pin the type — overrides: { #{type_name.inspect} => ... } — or this one field: " \
|
|
204
|
-
"overrides: { #{(coordinate || field_name).inspect} => ... }. Suite-wide, that's " \
|
|
205
|
-
"GraphWeaver::Testing.config.overrides."
|
|
222
|
+
"it deserializes into #{registered.type}, and only you know what wire value that accepts. #{advice}"
|
|
206
223
|
end
|
|
207
224
|
|
|
208
225
|
# :faker is an explicit ask — fail loudly when the gem is missing; auto
|