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.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +537 -0
  3. data/Gemfile.lock +19 -19
  4. data/README.md +74 -53
  5. data/docs/cassettes.md +29 -4
  6. data/docs/editors.md +3 -1
  7. data/docs/errors.md +75 -16
  8. data/docs/federation.md +206 -155
  9. data/docs/generated_modules.md +223 -166
  10. data/docs/getting_started.md +106 -82
  11. data/docs/logging.md +35 -5
  12. data/docs/scalars.md +119 -24
  13. data/docs/testing.md +196 -155
  14. data/docs/transports.md +47 -19
  15. data/docs/upgrading.md +243 -22
  16. data/graph_weaver.gemspec +16 -2
  17. data/lib/generators/graph_weaver/install_generator.rb +31 -16
  18. data/lib/graph_weaver/client.rb +52 -15
  19. data/lib/graph_weaver/codegen/aliases.rb +15 -8
  20. data/lib/graph_weaver/codegen/emit.rb +107 -42
  21. data/lib/graph_weaver/codegen/enum_type.rb +4 -3
  22. data/lib/graph_weaver/codegen/nodes.rb +42 -21
  23. data/lib/graph_weaver/codegen/scalar_type.rb +87 -83
  24. data/lib/graph_weaver/codegen/type_helpers.rb +2 -3
  25. data/lib/graph_weaver/codegen.rb +382 -105
  26. data/lib/graph_weaver/coerce.rb +113 -0
  27. data/lib/graph_weaver/errors.rb +57 -13
  28. data/lib/graph_weaver/federation.rb +10 -22
  29. data/lib/graph_weaver/hints.rb +76 -2
  30. data/lib/graph_weaver/in_process.rb +11 -8
  31. data/lib/graph_weaver/inflect.rb +2 -0
  32. data/lib/graph_weaver/input_struct.rb +115 -12
  33. data/lib/graph_weaver/internal/overrides.rb +101 -0
  34. data/lib/graph_weaver/internal/planner.rb +868 -0
  35. data/lib/graph_weaver/internal/schemas.rb +50 -0
  36. data/lib/graph_weaver/internal/selection.rb +127 -0
  37. data/lib/graph_weaver/{testing → internal}/subgraphs.rb +45 -43
  38. data/lib/graph_weaver/internal/values.rb +181 -0
  39. data/lib/graph_weaver/internal.rb +206 -0
  40. data/lib/graph_weaver/logging.rb +108 -20
  41. data/lib/graph_weaver/parsing.rb +6 -13
  42. data/lib/graph_weaver/query_module.rb +2 -0
  43. data/lib/graph_weaver/railtie.rb +113 -14
  44. data/lib/graph_weaver/representation.rb +30 -2
  45. data/lib/graph_weaver/response.rb +15 -0
  46. data/lib/graph_weaver/retry.rb +54 -22
  47. data/lib/graph_weaver/rspec.rb +63 -18
  48. data/lib/graph_weaver/schema_diff.rb +293 -0
  49. data/lib/graph_weaver/schema_loader.rb +126 -35
  50. data/lib/graph_weaver/tasks.rb +88 -36
  51. data/lib/graph_weaver/testing/cassette.rb +131 -78
  52. data/lib/graph_weaver/testing/coverage.rb +11 -15
  53. data/lib/graph_weaver/testing/failure.rb +14 -8
  54. data/lib/graph_weaver/testing/fake_client.rb +253 -60
  55. data/lib/graph_weaver/testing/fake_subgraph.rb +19 -8
  56. data/lib/graph_weaver/testing/router.rb +147 -840
  57. data/lib/graph_weaver/testing.rb +40 -83
  58. data/lib/graph_weaver/transport/faraday.rb +1 -1
  59. data/lib/graph_weaver/transport/http.rb +29 -12
  60. data/lib/graph_weaver/transport.rb +11 -34
  61. data/lib/graph_weaver/version.rb +1 -1
  62. data/lib/graph_weaver.rb +221 -118
  63. metadata +17 -13
  64. data/CLAUDE.md +0 -161
  65. data/DECISIONS.md +0 -309
  66. data/Makefile +0 -23
  67. data/NOTES.md +0 -182
  68. data/PLAN.md +0 -115
  69. data/REVIEW.md +0 -946
  70. data/lib/graph_weaver/schemas.rb +0 -46
  71. data/lib/graph_weaver/selection.rb +0 -120
  72. data/lib/graph_weaver/testing/values.rb +0 -98
@@ -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
- # tries: 5, # total attempts, first included
13
- # on: [GraphWeaver::TransportError, GraphWeaver::ServerError],
13
+ # retries: 5, # attempts after the first
14
+ # retry_on: [GraphWeaver::TransportError, GraphWeaver::ServerError],
14
15
  # backoff: :exponential, # or :linear, or ->(attempt) { seconds }
15
- # base: 0.5, max: 30, # seconds; delays clamp at max:
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 (the request never arrived)
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 max:); otherwise the configured backoff decides.
41
+ # to max_delay:); otherwise the configured backoff decides.
30
42
  #
31
- # Exhausting tries re-raises the last error (or returns the last
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 on: retries
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
- def initialize(client, tries: 3, on: [GraphWeaver::TransportError, GraphWeaver::ServerError],
51
- backoff: :exponential, base: 0.5, max: 30, jitter: true, retry_if: DEFAULT_RETRY_IF,
52
- retry_codes: [], sleeper: nil)
53
- raise ArgumentError, "tries: must be >= 1" unless tries >= 1
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
- @tries = tries
57
- @on = on
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
- @base = base
66
- @max = max
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 < @tries && retryable_response?(response)
87
- rescue *@on => e
88
- raise if attempt >= @tries || !@retry_if.call(e)
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
- @sleeper.call(delay(attempt, failure))
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 max:, so "come back in an hour" can't park a thread for
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, @max].min.to_f if after
145
+ return [after, @max_delay].min.to_f if after
114
146
 
115
- seconds = [@backoff.call(@base, attempt), @max].min.to_f
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
@@ -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 (Testing::Subgraphs);
47
- # one nothing here serves is absent, and only a query that reaches its
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
- @__graph_weaver_mode = GraphWeaver::Testing::RSpecIntegration.mode_for(
87
- RSpec.current_example&.metadata || {},
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
- remove_instance_variable(:@__graph_weaver_mode)
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 the trace has to be told where this
129
- # example starts — otherwise have_fetched reads the last one's
130
- router.reset_trace
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 exactly
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(overrides: { "Reader.name" => "Ada",
143
- # "Reader.orders" => [{ "status" => "PAID" }, {}] })
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
- tagged = defined?(@__graph_weaver_mode) ? @__graph_weaver_mode : nil
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