graph_weaver 0.5.0 → 0.6.0
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/CHANGELOG.md +537 -0
- data/Gemfile.lock +19 -19
- data/README.md +74 -53
- data/docs/cassettes.md +29 -4
- data/docs/editors.md +3 -1
- data/docs/errors.md +75 -16
- data/docs/federation.md +206 -155
- data/docs/generated_modules.md +223 -166
- data/docs/getting_started.md +106 -82
- data/docs/logging.md +35 -5
- data/docs/scalars.md +119 -24
- data/docs/testing.md +196 -155
- data/docs/transports.md +47 -19
- data/docs/upgrading.md +243 -22
- data/graph_weaver.gemspec +16 -2
- data/lib/generators/graph_weaver/install_generator.rb +31 -16
- data/lib/graph_weaver/client.rb +52 -15
- data/lib/graph_weaver/codegen/aliases.rb +15 -8
- data/lib/graph_weaver/codegen/emit.rb +107 -42
- data/lib/graph_weaver/codegen/enum_type.rb +4 -3
- data/lib/graph_weaver/codegen/nodes.rb +42 -21
- data/lib/graph_weaver/codegen/scalar_type.rb +87 -83
- data/lib/graph_weaver/codegen/type_helpers.rb +2 -3
- data/lib/graph_weaver/codegen.rb +382 -105
- data/lib/graph_weaver/coerce.rb +113 -0
- data/lib/graph_weaver/errors.rb +57 -13
- data/lib/graph_weaver/federation.rb +10 -22
- data/lib/graph_weaver/hints.rb +76 -2
- data/lib/graph_weaver/in_process.rb +11 -8
- data/lib/graph_weaver/inflect.rb +2 -0
- data/lib/graph_weaver/input_struct.rb +115 -12
- data/lib/graph_weaver/internal/overrides.rb +101 -0
- data/lib/graph_weaver/internal/planner.rb +868 -0
- data/lib/graph_weaver/internal/schemas.rb +50 -0
- data/lib/graph_weaver/internal/selection.rb +127 -0
- data/lib/graph_weaver/{testing → internal}/subgraphs.rb +45 -43
- data/lib/graph_weaver/internal/values.rb +181 -0
- data/lib/graph_weaver/internal.rb +206 -0
- data/lib/graph_weaver/logging.rb +108 -20
- data/lib/graph_weaver/parsing.rb +6 -13
- data/lib/graph_weaver/query_module.rb +2 -0
- data/lib/graph_weaver/railtie.rb +113 -14
- data/lib/graph_weaver/representation.rb +30 -2
- data/lib/graph_weaver/response.rb +15 -0
- data/lib/graph_weaver/retry.rb +54 -22
- data/lib/graph_weaver/rspec.rb +63 -18
- data/lib/graph_weaver/schema_diff.rb +293 -0
- data/lib/graph_weaver/schema_loader.rb +126 -35
- data/lib/graph_weaver/tasks.rb +88 -36
- data/lib/graph_weaver/testing/cassette.rb +131 -78
- data/lib/graph_weaver/testing/coverage.rb +11 -15
- data/lib/graph_weaver/testing/failure.rb +14 -8
- data/lib/graph_weaver/testing/fake_client.rb +253 -60
- data/lib/graph_weaver/testing/fake_subgraph.rb +19 -8
- data/lib/graph_weaver/testing/router.rb +147 -840
- data/lib/graph_weaver/testing.rb +40 -83
- data/lib/graph_weaver/transport/faraday.rb +1 -1
- data/lib/graph_weaver/transport/http.rb +29 -12
- data/lib/graph_weaver/transport.rb +11 -34
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +221 -118
- metadata +17 -13
- data/CLAUDE.md +0 -161
- data/DECISIONS.md +0 -309
- data/Makefile +0 -23
- data/NOTES.md +0 -182
- data/PLAN.md +0 -115
- data/REVIEW.md +0 -946
- data/lib/graph_weaver/schemas.rb +0 -46
- data/lib/graph_weaver/selection.rb +0 -120
- data/lib/graph_weaver/testing/values.rb +0 -98
data/lib/graph_weaver/retry.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require_relative "errors"
|
|
5
|
+
require_relative "transport"
|
|
5
6
|
|
|
6
7
|
# Wraps any client/transport with configurable retries — it satisfies
|
|
7
8
|
# the same execute contract, so it layers over HTTP, Faraday, or
|
|
@@ -9,16 +10,27 @@ require_relative "errors"
|
|
|
9
10
|
#
|
|
10
11
|
# client = GraphWeaver::Retry.new(
|
|
11
12
|
# GraphWeaver::Transport::HTTP.new(url),
|
|
12
|
-
#
|
|
13
|
-
#
|
|
13
|
+
# retries: 5, # attempts after the first
|
|
14
|
+
# retry_on: [GraphWeaver::TransportError, GraphWeaver::ServerError],
|
|
14
15
|
# backoff: :exponential, # or :linear, or ->(attempt) { seconds }
|
|
15
|
-
#
|
|
16
|
+
# base_delay: 0.5, max_delay: 30, # seconds; delays clamp at max_delay:
|
|
16
17
|
# jitter: true, # randomize each delay by 50-100%
|
|
17
18
|
# retry_codes: ["THROTTLED"], # also retry GraphQL errors by code
|
|
19
|
+
# retry_mutations: true, # off by default — see below
|
|
18
20
|
# )
|
|
19
21
|
#
|
|
22
|
+
# `retries:` is how many attempts follow the first; the other retry options
|
|
23
|
+
# sit beside it — the same spelling on the client, which passes them
|
|
24
|
+
# straight through (`GraphWeaver.new(url, retries: 5, backoff: :linear)`).
|
|
25
|
+
# So `retries: 0` is one attempt and no retry.
|
|
26
|
+
#
|
|
27
|
+
# **A mutation gets one attempt.** A failure with no answer — a read
|
|
28
|
+
# timeout, a 502, a reset socket — does not say whether the server applied
|
|
29
|
+
# it, and a second `charge` is worse than a failed one. retry_mutations:
|
|
30
|
+
# true opts an idempotent API back in.
|
|
31
|
+
#
|
|
20
32
|
# What retries, by default:
|
|
21
|
-
# - TransportError: always
|
|
33
|
+
# - TransportError: always
|
|
22
34
|
# - ServerError: 5xx, plus 408 and 429 — the rest of 4xx is a bug in
|
|
23
35
|
# the request, retrying won't fix it. Override with
|
|
24
36
|
# retry_if: ->(error) { ... }
|
|
@@ -26,9 +38,9 @@ require_relative "errors"
|
|
|
26
38
|
# default — pass the codes your API uses for transient failures)
|
|
27
39
|
#
|
|
28
40
|
# A server that answers with Retry-After sets the delay itself (clamped
|
|
29
|
-
# to
|
|
41
|
+
# to max_delay:); otherwise the configured backoff decides.
|
|
30
42
|
#
|
|
31
|
-
# Exhausting
|
|
43
|
+
# Exhausting the retries re-raises the last error (or returns the last
|
|
32
44
|
# code-matched response).
|
|
33
45
|
class GraphWeaver::Retry
|
|
34
46
|
BACKOFFS = {
|
|
@@ -41,20 +53,26 @@ class GraphWeaver::Retry
|
|
|
41
53
|
RETRIABLE_CLIENT_STATUSES = [408, 429].freeze
|
|
42
54
|
|
|
43
55
|
# retry 5xx (and 408/429), not the rest of 4xx; everything else listed
|
|
44
|
-
# in
|
|
56
|
+
# in retry_on: retries
|
|
45
57
|
DEFAULT_RETRY_IF = lambda do |error|
|
|
46
58
|
!error.is_a?(GraphWeaver::ServerError) ||
|
|
47
59
|
error.status >= 500 || RETRIABLE_CLIENT_STATUSES.include?(error.status)
|
|
48
60
|
end
|
|
49
61
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
62
|
+
# said once, where the decision is made and where it is explained
|
|
63
|
+
MUTATION_HINT = "not retrying a mutation — a request that failed without an answer " \
|
|
64
|
+
"may still have been applied; pass retry_mutations: true if yours are idempotent"
|
|
65
|
+
private_constant :DEFAULT_RETRY_IF, :MUTATION_HINT
|
|
66
|
+
|
|
67
|
+
def initialize(client, retries: 2, retry_on: [GraphWeaver::TransportError, GraphWeaver::ServerError],
|
|
68
|
+
backoff: :exponential, base_delay: 0.5, max_delay: 30, jitter: true, retry_if: DEFAULT_RETRY_IF,
|
|
69
|
+
retry_codes: [], retry_mutations: false, sleeper: nil)
|
|
70
|
+
raise ArgumentError, "retries: must be >= 0" unless retries.is_a?(Integer) && retries >= 0
|
|
54
71
|
|
|
55
72
|
@client = client
|
|
56
|
-
@
|
|
57
|
-
@
|
|
73
|
+
@retries = retries
|
|
74
|
+
@retry_mutations = retry_mutations
|
|
75
|
+
@retry_on = retry_on
|
|
58
76
|
@backoff = if backoff.is_a?(Proc)
|
|
59
77
|
->(_base, attempt) { backoff.call(attempt) } # custom: ->(attempt) { seconds }
|
|
60
78
|
else
|
|
@@ -62,8 +80,8 @@ class GraphWeaver::Retry
|
|
|
62
80
|
raise ArgumentError, "backoff: must be :exponential, :linear, or a Proc, got #{backoff.inspect}"
|
|
63
81
|
}
|
|
64
82
|
end
|
|
65
|
-
@
|
|
66
|
-
@
|
|
83
|
+
@base_delay = base_delay
|
|
84
|
+
@max_delay = max_delay
|
|
67
85
|
@jitter = jitter
|
|
68
86
|
@retry_if = retry_if
|
|
69
87
|
@retry_codes = retry_codes
|
|
@@ -76,6 +94,7 @@ class GraphWeaver::Retry
|
|
|
76
94
|
end
|
|
77
95
|
|
|
78
96
|
def execute(query, variables: {}, operation_name: nil)
|
|
97
|
+
attempts = mutation?(query) ? 1 : @retries + 1
|
|
79
98
|
attempt = 0
|
|
80
99
|
failure = T.let(nil, T.nilable(Exception))
|
|
81
100
|
|
|
@@ -83,20 +102,33 @@ class GraphWeaver::Retry
|
|
|
83
102
|
attempt += 1
|
|
84
103
|
begin
|
|
85
104
|
response = @client.execute(query, variables:, operation_name:)
|
|
86
|
-
return response unless attempt <
|
|
87
|
-
rescue *@
|
|
88
|
-
|
|
105
|
+
return response unless attempt < attempts && retryable_response?(response)
|
|
106
|
+
rescue *@retry_on => e
|
|
107
|
+
if attempt >= attempts || !@retry_if.call(e)
|
|
108
|
+
GraphWeaver::Internal::Log.log(:warn) { MUTATION_HINT } if attempts == 1 && @retries.positive?
|
|
109
|
+
raise
|
|
110
|
+
end
|
|
89
111
|
|
|
90
112
|
failure = e
|
|
91
113
|
end
|
|
92
114
|
|
|
93
|
-
|
|
115
|
+
seconds = delay(attempt, failure)
|
|
116
|
+
# a retry is invisible otherwise: the caller sees one slow call, and the
|
|
117
|
+
# log shows an error that apparently didn't stop anything
|
|
118
|
+
GraphWeaver::Internal::Log.log(:info) do
|
|
119
|
+
"retrying #{operation_name || "query"} in #{seconds.round(2)}s (attempt #{attempt + 1} of #{attempts})"
|
|
120
|
+
end
|
|
121
|
+
@sleeper.call(seconds)
|
|
94
122
|
failure = nil
|
|
95
123
|
end
|
|
96
124
|
end
|
|
97
125
|
|
|
98
126
|
private
|
|
99
127
|
|
|
128
|
+
def mutation?(query)
|
|
129
|
+
!@retry_mutations && @retries.positive? && GraphWeaver::Internal::Wire.mutation?(query)
|
|
130
|
+
end
|
|
131
|
+
|
|
100
132
|
def retryable_response?(response)
|
|
101
133
|
return false if @retry_codes.empty?
|
|
102
134
|
|
|
@@ -107,12 +139,12 @@ class GraphWeaver::Retry
|
|
|
107
139
|
def delay(attempt, failure)
|
|
108
140
|
# A Retry-After wins over our backoff: the server is the only party
|
|
109
141
|
# that knows when its window reopens, and it isn't guessing. Still
|
|
110
|
-
# clamped to
|
|
142
|
+
# clamped to max_delay:, so "come back in an hour" can't park a thread for
|
|
111
143
|
# an hour — and not jittered, since it's an instruction, not a guess.
|
|
112
144
|
after = failure.retry_after if failure.is_a?(GraphWeaver::ServerError)
|
|
113
|
-
return [after, @
|
|
145
|
+
return [after, @max_delay].min.to_f if after
|
|
114
146
|
|
|
115
|
-
seconds = [@backoff.call(@
|
|
147
|
+
seconds = [@backoff.call(@base_delay, attempt), @max_delay].min.to_f
|
|
116
148
|
@jitter ? seconds * (0.5 + rand * 0.5) : seconds
|
|
117
149
|
end
|
|
118
150
|
end
|
data/lib/graph_weaver/rspec.rb
CHANGED
|
@@ -43,9 +43,8 @@ require_relative "testing"
|
|
|
43
43
|
# neither is there it says so rather than hunting for one.
|
|
44
44
|
# - :router plans against the composed supergraph: the dump, when that's
|
|
45
45
|
# what it is, else config.router = { supergraph: … }. Subgraphs are
|
|
46
|
-
# derived from what each loaded schema defines
|
|
47
|
-
#
|
|
48
|
-
# fields is refused.
|
|
46
|
+
# derived from what each loaded schema defines; one nothing here serves
|
|
47
|
+
# is absent, and only a query that reaches its fields is refused.
|
|
49
48
|
#
|
|
50
49
|
# What it wires up:
|
|
51
50
|
# - seed: defaults to rspec's --seed, so `rspec --seed 1234` reproduces
|
|
@@ -83,9 +82,12 @@ module GraphWeaver
|
|
|
83
82
|
# so there is no idiom to discover and no way to leak a client forward
|
|
84
83
|
rspec_config.before(:each) do
|
|
85
84
|
@__graph_weaver_prior_client = GraphWeaver.client
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
85
|
+
metadata = RSpec.current_example&.metadata || {}
|
|
86
|
+
# what this example said, apart from what config.default_mode says
|
|
87
|
+
# for the ones that said nothing — a helper may only contradict the
|
|
88
|
+
# former
|
|
89
|
+
@__graph_weaver_tag = metadata[TAG] if metadata.key?(TAG)
|
|
90
|
+
@__graph_weaver_mode = GraphWeaver::Testing::RSpecIntegration.mode_for(metadata)
|
|
89
91
|
if @__graph_weaver_mode
|
|
90
92
|
GraphWeaver.client = GraphWeaver::Testing::RSpecIntegration.client_for(@__graph_weaver_mode)
|
|
91
93
|
end
|
|
@@ -96,7 +98,10 @@ module GraphWeaver
|
|
|
96
98
|
|
|
97
99
|
GraphWeaver.client = @__graph_weaver_prior_client
|
|
98
100
|
remove_instance_variable(:@__graph_weaver_prior_client)
|
|
99
|
-
|
|
101
|
+
# a refused tag raises before the mode is ever set, and its message
|
|
102
|
+
# is the one thing that says what to fix — a NameError out of the
|
|
103
|
+
# cleanup would report a second failure on top of it
|
|
104
|
+
remove_instance_variable(:@__graph_weaver_mode) if defined?(@__graph_weaver_mode)
|
|
100
105
|
end
|
|
101
106
|
end
|
|
102
107
|
|
|
@@ -125,9 +130,9 @@ module GraphWeaver
|
|
|
125
130
|
when :router
|
|
126
131
|
router = config.built_router
|
|
127
132
|
router.context = config.context
|
|
128
|
-
# built once for the suite, so
|
|
129
|
-
#
|
|
130
|
-
router.
|
|
133
|
+
# built once for the suite, so it has to be told where this example
|
|
134
|
+
# starts — the trace, and any faked subgraph's fabricated data
|
|
135
|
+
router.reset!
|
|
131
136
|
end
|
|
132
137
|
end
|
|
133
138
|
|
|
@@ -135,15 +140,19 @@ module GraphWeaver
|
|
|
135
140
|
# whether or not this example took a client from the hook
|
|
136
141
|
module Helpers
|
|
137
142
|
# The fake this example runs against, built here rather than by the
|
|
138
|
-
# tag — which is how it takes options. `graphql: :fake` is
|
|
139
|
-
# this call with none:
|
|
143
|
+
# tag — which is how it takes pins and options. `graphql: :fake` is
|
|
144
|
+
# exactly this call with none:
|
|
140
145
|
#
|
|
141
146
|
# it "shows the two paid orders" do
|
|
142
|
-
# graphql_fake(
|
|
143
|
-
#
|
|
147
|
+
# graphql_fake("Reader.name" => "Ada",
|
|
148
|
+
# "Reader.orders" => [{ "status" => "PAID" }, {}])
|
|
144
149
|
# expect(DashboardQuery.execute!.reader.orders.size).to eq 2
|
|
145
150
|
# end
|
|
146
151
|
#
|
|
152
|
+
# Pins lead, options follow: `graphql_fake("Money" => "12.00",
|
|
153
|
+
# values: :literal)`. Options are lowercase words, so a key with a
|
|
154
|
+
# dot or a leading capital is a pin wherever it is written.
|
|
155
|
+
#
|
|
147
156
|
# Returns the client, for the assertions that are about the request:
|
|
148
157
|
#
|
|
149
158
|
# fake = graphql_fake
|
|
@@ -152,10 +161,11 @@ module GraphWeaver
|
|
|
152
161
|
#
|
|
153
162
|
# Installed as GraphWeaver.client and restored after the example,
|
|
154
163
|
# like a tagged one — so the tag is optional here, not required.
|
|
155
|
-
def graphql_fake(**options)
|
|
164
|
+
def graphql_fake(pins = {}, **options)
|
|
156
165
|
claim_mode!(:fake)
|
|
166
|
+
refuse_seed!(options)
|
|
157
167
|
options[:schema] ||= GraphWeaver::Testing.config.reference_schema!
|
|
158
|
-
GraphWeaver.client = GraphWeaver::Testing::FakeClient.new(**options)
|
|
168
|
+
GraphWeaver.client = GraphWeaver::Testing::FakeClient.new(pins, **options)
|
|
159
169
|
end
|
|
160
170
|
|
|
161
171
|
# Run this example against one schema class's real resolvers.
|
|
@@ -181,13 +191,37 @@ module GraphWeaver
|
|
|
181
191
|
GraphWeaver.client = GraphWeaver::InProcess.new(schema, **options)
|
|
182
192
|
end
|
|
183
193
|
|
|
194
|
+
# Run this example against the whole federated graph. `graphql:
|
|
195
|
+
# :router` is exactly this call with no argument; `fake:` is how the
|
|
196
|
+
# subgraphs it fakes fabricate — the pins and options graphql_fake
|
|
197
|
+
# takes, in one hash:
|
|
198
|
+
#
|
|
199
|
+
# it "shows the carrier" do
|
|
200
|
+
# graphql_router(fake: { "Shipment.carrier" => "UPS" })
|
|
201
|
+
# …
|
|
202
|
+
# end
|
|
203
|
+
#
|
|
204
|
+
# The router itself is built once for the suite — parsing a
|
|
205
|
+
# supergraph per example is real time — so this installs that one and
|
|
206
|
+
# tells it where this example starts.
|
|
207
|
+
def graphql_router(fake: nil)
|
|
208
|
+
claim_mode!(:router)
|
|
209
|
+
refuse_seed!(fake) if fake
|
|
210
|
+
router = GraphWeaver::Testing::RSpecIntegration.client_for(:router)
|
|
211
|
+
router.fake = fake if fake
|
|
212
|
+
GraphWeaver.client = router
|
|
213
|
+
end
|
|
214
|
+
|
|
184
215
|
# A tag and a helper are two spellings of one choice, so they can
|
|
185
216
|
# agree (`graphql: :fake` plus `graphql_fake(overrides:)` is the
|
|
186
217
|
# documented way to pass options) but must not contradict: one of the
|
|
187
218
|
# two is then a mistake, and silently letting the later one win hides
|
|
188
219
|
# which.
|
|
189
|
-
def claim_mode!(mode)
|
|
190
|
-
|
|
220
|
+
private def claim_mode!(mode)
|
|
221
|
+
# only an explicit tag can contradict a helper. config.default_mode
|
|
222
|
+
# is a fallback for examples that said nothing, so a helper is the
|
|
223
|
+
# example finally saying something — not a disagreement.
|
|
224
|
+
tagged = defined?(@__graph_weaver_tag) ? @__graph_weaver_tag : nil
|
|
191
225
|
if tagged && tagged != mode
|
|
192
226
|
# Kernel.raise: this module is mixed into every example group, so
|
|
193
227
|
# it doesn't include Kernel for sorbet to find
|
|
@@ -199,6 +233,17 @@ module GraphWeaver
|
|
|
199
233
|
@__graph_weaver_mode = mode
|
|
200
234
|
end
|
|
201
235
|
|
|
236
|
+
# rspec's own --seed already drives the fake (config.seed takes it
|
|
237
|
+
# at suite start), so a per-example seed: is a second answer to one
|
|
238
|
+
# question — and the one that stops `rspec --seed` reproducing the run.
|
|
239
|
+
private def refuse_seed!(options)
|
|
240
|
+
return unless options.to_h.key?(:seed) || options.to_h.key?("seed")
|
|
241
|
+
|
|
242
|
+
Kernel.raise GraphWeaver::Error, "seed: isn't a per-example option — `rspec --seed 1234` " \
|
|
243
|
+
"reproduces the fabricated data along with the test order. For a suite that isn't " \
|
|
244
|
+
"rspec, set GraphWeaver::Testing.config.seed."
|
|
245
|
+
end
|
|
246
|
+
|
|
202
247
|
# The GraphQL context this example's resolvers see — merged onto
|
|
203
248
|
# config.context, and reset before the next example runs:
|
|
204
249
|
#
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "graphql"
|
|
5
|
+
|
|
6
|
+
module GraphWeaver
|
|
7
|
+
# What moved between two schemas — the dump you committed and the one the
|
|
8
|
+
# server serves now.
|
|
9
|
+
#
|
|
10
|
+
# rake graph_weaver:schema:diff
|
|
11
|
+
#
|
|
12
|
+
# Compares the two *schemas*, not their SDL: graphql-ruby reorders and
|
|
13
|
+
# reformats what it prints, so a text diff reports churn no client can
|
|
14
|
+
# break on, and buries the one line that matters in a 3 MB file.
|
|
15
|
+
#
|
|
16
|
+
# Breaking is judged from the client's side — a query that validates
|
|
17
|
+
# against the dump and stops validating (or stops casting) against the
|
|
18
|
+
# server. That makes nullability directional: an *output* going non-null
|
|
19
|
+
# to nullable breaks a generated struct that expects a value, while an
|
|
20
|
+
# *input* going nullable to non-null breaks a query that omits it. The
|
|
21
|
+
# same signature change is breaking in one position and free in the other.
|
|
22
|
+
#
|
|
23
|
+
# This names what changed; it doesn't decide what to do about it.
|
|
24
|
+
# `queries:check` answers the narrower question — which of *your* queries
|
|
25
|
+
# a change actually reaches.
|
|
26
|
+
class SchemaDiff
|
|
27
|
+
# One difference. `coordinate` is the schema coordinate it happened at
|
|
28
|
+
# ("User", "User.email", "Query.search(first:)"), so a CI log stays
|
|
29
|
+
# greppable and a line can be pasted somewhere useful on its own.
|
|
30
|
+
Change = Data.define(:coordinate, :description, :breaking) do
|
|
31
|
+
def to_h = { "coordinate" => coordinate, "change" => description, "breaking" => breaking }
|
|
32
|
+
|
|
33
|
+
def to_s = "#{coordinate} #{description}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Every schema carries these whether or not anything uses them, so a
|
|
37
|
+
# field newly typed Float would otherwise read as "type Float added".
|
|
38
|
+
BUILT_IN = GraphQL::Schema::BUILT_IN_TYPES.keys.to_set.freeze
|
|
39
|
+
private_constant :BUILT_IN
|
|
40
|
+
|
|
41
|
+
# every change found, breaking ones first, then by coordinate
|
|
42
|
+
attr_reader :changes
|
|
43
|
+
|
|
44
|
+
# before/after: two GraphQL::Schema classes — the dump and the server.
|
|
45
|
+
# source/target: what to call them in the headline (a path, a url).
|
|
46
|
+
def initialize(before, after, source: nil, target: nil)
|
|
47
|
+
@source = source
|
|
48
|
+
@target = target
|
|
49
|
+
@changes = []
|
|
50
|
+
compare(before, after)
|
|
51
|
+
@changes.sort_by! { |change| [change.breaking ? 0 : 1, change.coordinate] }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# the schemas agree — what CI gates on
|
|
55
|
+
def empty? = @changes.empty?
|
|
56
|
+
|
|
57
|
+
# the changes a query written against the dump can break on
|
|
58
|
+
def breaking = @changes.select(&:breaking)
|
|
59
|
+
|
|
60
|
+
# the rest: additions, deprecations, widenings a client absorbs
|
|
61
|
+
def compatible = @changes.reject(&:breaking)
|
|
62
|
+
|
|
63
|
+
# JSON-ready. One list, each entry saying whether it breaks — the
|
|
64
|
+
# counts are derivable, and two places to read "breaking" from is one
|
|
65
|
+
# too many.
|
|
66
|
+
def to_h = { "changes" => @changes.map(&:to_h) }
|
|
67
|
+
|
|
68
|
+
def report
|
|
69
|
+
return "#{subject}no changes" if empty?
|
|
70
|
+
|
|
71
|
+
[headline, *section("breaking:", breaking), *section("other:", compatible)].join("\n")
|
|
72
|
+
end
|
|
73
|
+
alias to_s report
|
|
74
|
+
|
|
75
|
+
def inspect = "#<#{self.class.name} #{@changes.size} changes, #{breaking.size} breaking>"
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def subject
|
|
80
|
+
return "" unless @source
|
|
81
|
+
|
|
82
|
+
@target ? "#{@source} vs #{@target}: " : "#{@source}: "
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def headline
|
|
86
|
+
count = "#{@changes.size} #{(@changes.size == 1) ? "change" : "changes"}"
|
|
87
|
+
"#{subject}#{count}, #{breaking.any? ? "#{breaking.size} breaking" : "none breaking"}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def section(title, changes)
|
|
91
|
+
return [] if changes.empty?
|
|
92
|
+
|
|
93
|
+
width = changes.map { |change| change.coordinate.length }.max
|
|
94
|
+
["", title, *changes.map { |change| " #{change.coordinate.ljust(width)} #{change.description}" }]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def change(coordinate, description, breaking: false)
|
|
98
|
+
@changes << Change.new(coordinate:, description:, breaking:)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def compare(before, after)
|
|
102
|
+
old_types = comparable_types(before)
|
|
103
|
+
new_types = comparable_types(after)
|
|
104
|
+
|
|
105
|
+
(old_types.keys - new_types.keys).each { |name| change(name, "removed", breaking: true) }
|
|
106
|
+
(new_types.keys - old_types.keys).each { |name| change(name, "added #{kind(new_types[name])}") }
|
|
107
|
+
(old_types.keys & new_types.keys).each { |name| compare_type(name, old_types[name], new_types[name]) }
|
|
108
|
+
|
|
109
|
+
note_unnamed_drift(before, after)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def comparable_types(schema)
|
|
113
|
+
schema.types.reject { |name, _| name.start_with?("__") || BUILT_IN.include?(name) }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def kind(type) = type.kind.name.downcase.tr("_", " ")
|
|
117
|
+
|
|
118
|
+
def compare_type(name, old, new)
|
|
119
|
+
# nothing below is comparable across kinds, and the kind change is
|
|
120
|
+
# the only thing worth saying about it
|
|
121
|
+
if old.kind.name != new.kind.name
|
|
122
|
+
return change(name, "#{kind(old)} -> #{kind(new)}", breaking: true)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
case new.kind.name
|
|
126
|
+
when "OBJECT", "INTERFACE"
|
|
127
|
+
compare_fields(name, old, new)
|
|
128
|
+
compare_interfaces(name, old, new)
|
|
129
|
+
when "INPUT_OBJECT" then compare_input_fields(name, old, new)
|
|
130
|
+
when "ENUM" then compare_enum(name, old, new)
|
|
131
|
+
when "UNION" then compare_union(name, old, new)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def compare_fields(name, old, new)
|
|
136
|
+
before = old.fields
|
|
137
|
+
after = new.fields
|
|
138
|
+
|
|
139
|
+
(before.keys - after.keys).each { |field| change("#{name}.#{field}", "removed", breaking: true) }
|
|
140
|
+
(after.keys - before.keys).each do |field|
|
|
141
|
+
change("#{name}.#{field}", "added: #{signature(after[field])}")
|
|
142
|
+
end
|
|
143
|
+
(before.keys & after.keys).each do |field|
|
|
144
|
+
compare_field("#{name}.#{field}", before[field], after[field])
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def compare_field(coordinate, old, new)
|
|
149
|
+
if signature(old) != signature(new)
|
|
150
|
+
change(coordinate, "#{signature(old)} -> #{signature(new)}",
|
|
151
|
+
breaking: breaks_output?(old.type, new.type))
|
|
152
|
+
end
|
|
153
|
+
compare_deprecation(coordinate, old, new)
|
|
154
|
+
compare_arguments(coordinate, old.arguments, new.arguments)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def compare_arguments(coordinate, before, after)
|
|
158
|
+
(before.keys - after.keys).each do |arg|
|
|
159
|
+
change("#{coordinate}(#{arg}:)", "argument removed", breaking: true)
|
|
160
|
+
end
|
|
161
|
+
(after.keys - before.keys).each do |arg|
|
|
162
|
+
change("#{coordinate}(#{arg}:)", "argument added: #{describe(after[arg])}",
|
|
163
|
+
breaking: required?(after[arg]))
|
|
164
|
+
end
|
|
165
|
+
(before.keys & after.keys).each do |arg|
|
|
166
|
+
compare_input(coordinate: "#{coordinate}(#{arg}:)", prefix: "argument ",
|
|
167
|
+
old: before[arg], new: after[arg])
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# An input object's members are arguments, and break the same way — a
|
|
172
|
+
# removed one fails a query that sends it, a newly required one fails a
|
|
173
|
+
# query that doesn't.
|
|
174
|
+
def compare_input_fields(name, old, new)
|
|
175
|
+
before = old.arguments
|
|
176
|
+
after = new.arguments
|
|
177
|
+
|
|
178
|
+
(before.keys - after.keys).each { |field| change("#{name}.#{field}", "removed", breaking: true) }
|
|
179
|
+
(after.keys - before.keys).each do |field|
|
|
180
|
+
change("#{name}.#{field}", "added: #{describe(after[field])}", breaking: required?(after[field]))
|
|
181
|
+
end
|
|
182
|
+
(before.keys & after.keys).each do |field|
|
|
183
|
+
compare_input(coordinate: "#{name}.#{field}", prefix: "", old: before[field], new: after[field])
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def compare_input(coordinate:, prefix:, old:, new:)
|
|
188
|
+
if signature(old) != signature(new)
|
|
189
|
+
# a default satisfies the new non-null, so the tightening reaches
|
|
190
|
+
# no query
|
|
191
|
+
breaking = breaks_input?(old.type, new.type) && !new.default_value?
|
|
192
|
+
change(coordinate, "#{prefix}#{signature(old)} -> #{signature(new)}", breaking:)
|
|
193
|
+
end
|
|
194
|
+
compare_deprecation(coordinate, old, new)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def compare_enum(name, old, new)
|
|
198
|
+
before = old.values
|
|
199
|
+
after = new.values
|
|
200
|
+
|
|
201
|
+
(before.keys - after.keys).each { |value| change("#{name}.#{value}", "enum value removed", breaking: true) }
|
|
202
|
+
(after.keys - before.keys).each { |value| change("#{name}.#{value}", "enum value added") }
|
|
203
|
+
(before.keys & after.keys).each { |value| compare_deprecation("#{name}.#{value}", before[value], after[value]) }
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# A dropped member silently stops matching a `... on X` fragment, which
|
|
207
|
+
# is the quiet half of this: the query still validates.
|
|
208
|
+
def compare_union(name, old, new)
|
|
209
|
+
before = old.possible_types.map(&:graphql_name)
|
|
210
|
+
after = new.possible_types.map(&:graphql_name)
|
|
211
|
+
|
|
212
|
+
(before - after).each { |member| change("#{name}.#{member}", "union member removed", breaking: true) }
|
|
213
|
+
(after - before).each { |member| change("#{name}.#{member}", "union member added") }
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def compare_interfaces(name, old, new)
|
|
217
|
+
return unless old.respond_to?(:interfaces) && new.respond_to?(:interfaces)
|
|
218
|
+
|
|
219
|
+
before = old.interfaces.map(&:graphql_name)
|
|
220
|
+
after = new.interfaces.map(&:graphql_name)
|
|
221
|
+
|
|
222
|
+
(before - after).each { |iface| change(name, "no longer implements #{iface}", breaking: true) }
|
|
223
|
+
(after - before).each { |iface| change(name, "now implements #{iface}") }
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def compare_deprecation(coordinate, old, new)
|
|
227
|
+
return unless old.respond_to?(:deprecation_reason)
|
|
228
|
+
|
|
229
|
+
was = old.deprecation_reason
|
|
230
|
+
now = new.deprecation_reason
|
|
231
|
+
return if was == now
|
|
232
|
+
|
|
233
|
+
change(coordinate, now ? "deprecated: #{now}" : "no longer deprecated")
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# The walk names what a client breaks on. A description, a directive
|
|
237
|
+
# definition, an argument default moves the SDL without appearing
|
|
238
|
+
# above — still drift, and a gate that went green on it would be worse
|
|
239
|
+
# than one that admits it can't name it.
|
|
240
|
+
def note_unnamed_drift(before, after)
|
|
241
|
+
return unless @changes.empty?
|
|
242
|
+
return if before.to_definition == after.to_definition
|
|
243
|
+
|
|
244
|
+
change("(schema)", "changed in ways this summary doesn't name — compare the dumps")
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def signature(member) = member.type.to_type_signature
|
|
248
|
+
|
|
249
|
+
def describe(argument)
|
|
250
|
+
"#{signature(argument)}#{" — required" if required?(argument)}"
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def required?(argument) = argument.type.non_null? && !argument.default_value?
|
|
254
|
+
|
|
255
|
+
# A signature split into its shape and where the `!`s sit: "[User!]!"
|
|
256
|
+
# => ["[User]", [.., true(r), true(])]]. Comparing the two separately
|
|
257
|
+
# is what lets nullability be read directionally.
|
|
258
|
+
def shape(signature)
|
|
259
|
+
bare = +""
|
|
260
|
+
nullability = []
|
|
261
|
+
signature.each_char do |char|
|
|
262
|
+
if char == "!"
|
|
263
|
+
nullability[-1] = true
|
|
264
|
+
else
|
|
265
|
+
bare << char
|
|
266
|
+
nullability << false
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
[bare, nullability]
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# An output the client can no longer trust: a different type or list
|
|
273
|
+
# depth, or a guarantee withdrawn — `String!` to `String` hands a
|
|
274
|
+
# generated struct the nil it declared it wouldn't get.
|
|
275
|
+
def breaks_output?(old, new)
|
|
276
|
+
was, was_null = shape(old.to_type_signature)
|
|
277
|
+
now, now_null = shape(new.to_type_signature)
|
|
278
|
+
return true if was != now
|
|
279
|
+
|
|
280
|
+
was_null.each_index.any? { |i| was_null[i] && !now_null[i] }
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# An input the client can no longer satisfy: a different type, or a
|
|
284
|
+
# guarantee demanded that wasn't demanded before.
|
|
285
|
+
def breaks_input?(old, new)
|
|
286
|
+
was, was_null = shape(old.to_type_signature)
|
|
287
|
+
now, now_null = shape(new.to_type_signature)
|
|
288
|
+
return true if was != now
|
|
289
|
+
|
|
290
|
+
now_null.each_index.any? { |i| now_null[i] && !was_null[i] }
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|