tape_deck 0.1.0.beta1

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.
data/lib/tape_deck.rb ADDED
@@ -0,0 +1,295 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sequel"
4
+ require "bigdecimal"
5
+ require "date"
6
+ require "time"
7
+ require "digest"
8
+ require "yaml"
9
+ require "fileutils"
10
+
11
+ require_relative "tape_deck/version"
12
+ require_relative "tape_deck/errors"
13
+ require_relative "tape_deck/configuration"
14
+ require_relative "tape_deck/tape"
15
+ require_relative "tape_deck/patterns"
16
+ require_relative "tape_deck/filter"
17
+ require_relative "tape_deck/session"
18
+ require_relative "tape_deck/recorder"
19
+
20
+ # Records what a block of code changes in several MySQL databases, and checks later runs against that recording.
21
+ # API, naming and flow inspired by the VCR gem.
22
+ #
23
+ # == Tapes
24
+ #
25
+ # A tape is one recording: the row-level diff that one action caused across all configured databases, stored as a YAML
26
+ # file in {Configuration#tape_library_dir}. It plays the role of VCR's cassette. Unlike a cassette it is never played
27
+ # back. The action always runs for real, and the tape is what its result is checked against.
28
+ #
29
+ # A tape cannot safely be played back, i.e. re-applied to a database. It holds the net effect of the action, not the
30
+ # statements that caused it or their order, so INSERT/UPDATE/DELETE statements generated from it can go wrong in many
31
+ # ways: foreign key constraints, generated columns, triggers.
32
+ #
33
+ # Next to the diff, a tape stores what was recorded: the exclude rules, the watched databases, and the tables and
34
+ # columns the rules left out of each.
35
+ #
36
+ # recorded:
37
+ # exclude_tables: [shop.sessions]
38
+ # exclude_columns: ["*.created_at"]
39
+ # databases:
40
+ # shop:
41
+ # excluded: [orders.created_at, sessions]
42
+ # billing:
43
+ # diff:
44
+ # shop:
45
+ # orders:
46
+ # inserted: [{ "id" => 42, "status" => "new", ... }]
47
+ # updated: [{ "key" => { "id" => 7 }, "changes" => { "status" => ["new", "paid"] } }]
48
+ # deleted: [...]
49
+ #
50
+ # == Lifecycle
51
+ #
52
+ # 1. *Insert* ({TapeDeck.insert_tape}): a snapshot of all watched databases is taken before the action runs. A tape to
53
+ # be verified is read now, so a missing, broken or incompatible tape fails before the action runs.
54
+ # 2. The action runs.
55
+ # 3. *Eject* ({TapeDeck.eject_tape}): a second snapshot is taken and the diff between the two is captured. Then one of:
56
+ # * *record*: the diff is written to the tape file, if the file is missing or the record mode is +:overwrite+
57
+ # * *verify*: the diff is compared with the tape file, and {UnexpectedDiffError} is raised if they differ. See
58
+ # "Excluding and ignoring" for narrowing what is compared.
59
+ # * *discard*: nothing is written or compared, because the action itself failed
60
+ #
61
+ # {TapeDeck.use_tape} does all three steps around a block. See {Tape::RECORD_MODES} for the record modes.
62
+ #
63
+ # == Nested tapes
64
+ #
65
+ # Tapes can be nested. Each tape takes its own snapshots, so an outer tape also contains every change made while an
66
+ # inner tape was inserted. Tapes are ejected innermost first: {TapeDeck.eject_tape} always ejects the most recently
67
+ # inserted one. If an inner tape fails to verify inside an outer {TapeDeck.use_tape} block, the error propagates and
68
+ # the outer tape is discarded. With {TapeDeck.insert_tape} and {TapeDeck.eject_tape}, discarding is up to you.
69
+ #
70
+ # TapeDeck.use_tape("checkout") do # records the changes of both requests
71
+ # client.post("/cart", ...)
72
+ # TapeDeck.use_tape("checkout/payment") do # records only the changes of this request
73
+ # client.post("/pay", ...)
74
+ # end
75
+ # end
76
+ #
77
+ # == Selecting databases
78
+ #
79
+ # When recording, a tape watches all configured databases by default. Pass +databases:+ to watch only some of them.
80
+ # The others are neither read nor compared, so changes there go unnoticed.
81
+ #
82
+ # When verifying, a tape watches the databases it was recorded with by default. +databases:+ may select a subset of
83
+ # those, the rest of the tape is then not compared. Selecting a database the tape has no recording of raises
84
+ # {IncompatibleTapeError}.
85
+ #
86
+ # TapeDeck.use_tape("checkout", databases: %w[shop billing]) { client.post("/checkout", ...) }
87
+ #
88
+ # == Patterns
89
+ #
90
+ # Tables are named +"<database>.<table>"+ and columns +"<database>.<table>.<column>"+, using the keys of
91
+ # {Configuration#databases}. All rules below are glob patterns on these names (<tt>File.fnmatch</tt>, where +*+ also
92
+ # matches dots), so +"*.created_at"+ matches that column in every table of every database, and +"shop.*"+ matches
93
+ # everything in +shop+.
94
+ #
95
+ # == Excluding and ignoring
96
+ #
97
+ # There are two ways to leave data out, and they act at different moments.
98
+ #
99
+ # *Exclude* means never recorded. {Configuration#exclude_tables} and {Configuration#exclude_columns} apply to every
100
+ # tape and are meant for noise no test cares about, like generated timestamps. Excluded data never reaches a tape. The
101
+ # tape stores what was left out, so the rules can change later: data that is excluded now, but is on the tape, is not
102
+ # compared. Data the tape left out, but that is no longer excluded, is missing from the tape, so verifying raises
103
+ # {IncompatibleTapeError} and the tape needs re-recording.
104
+ #
105
+ # The stored rules also reveal schema changes. When verifying, they are applied to the current schema again. If they
106
+ # leave out other tables or columns than when recording, a table or column matching them was added or removed.
107
+ # {Configuration#on_schema_change} decides whether that raises {IncompatibleTapeError}, warns, or is ignored.
108
+ #
109
+ # *Ignore* means recorded, but not compared. A tape always holds everything that is not excluded. When verifying, one
110
+ # use of a tape can narrow what is compared with +only:+ and +ignore:+. Both take one pattern or a list, and a pattern
111
+ # may name a whole database (+"billing.*"+), a table (+"shop.orders"+) or a column (+"shop.orders.status"+). With
112
+ # +only:+, everything else is ignored. +ignore:+ wins over +only:+. Neither has any effect while recording, so these
113
+ # rules can change without re-recording.
114
+ #
115
+ # TapeDeck.use_tape("checkout", only: "shop.orders.status") { client.post("/checkout", ...) }
116
+ # TapeDeck.use_tape("checkout", ignore: ["shop.orders.note", "billing.*"]) { client.post("/checkout", ...) }
117
+ #
118
+ # @example Configure
119
+ # TapeDeck.configure do |c|
120
+ # c.tape_library_dir = "spec/db_tapes"
121
+ # c.databases = { "shop" => SHOP_DB, "billing" => BILLING_DB }
122
+ # c.default_record_mode = :once
123
+ # c.exclude_tables "shop.sessions", "*.schema_migrations"
124
+ # c.exclude_columns "*.created_at", "*.updated_at", "shop.users.last_seen_at"
125
+ # c.on_schema_change = :warn
126
+ # c.configure_rspec_metadata!
127
+ # end
128
+ #
129
+ # @example Record or verify a tape
130
+ # TapeDeck.use_tape("checkout/creates_order") { client.post("/checkout", ...) }
131
+ #
132
+ # @example Watch only some databases
133
+ # TapeDeck.use_tape("checkout/creates_order", databases: %w[shop billing]) { client.post("/checkout", ...) }
134
+ # TapeDeck.use_tape("signup/sends_welcome_mail", databases: "mailer") { client.post("/signup", ...) }
135
+ #
136
+ # @example Compare only part of what is on the tape
137
+ # TapeDeck.use_tape("checkout/creates_order", only: "shop.orders.status") { client.post("/checkout", ...) }
138
+ # TapeDeck.use_tape("checkout/creates_order", ignore: "billing.invoices.number") { client.post("/checkout", ...) }
139
+ #
140
+ # @example RSpec, after +configure_rspec_metadata!+
141
+ # RSpec.describe "Checkout" do
142
+ # # tape "Checkout/creates an order", all databases
143
+ # it "creates an order", :tape_deck do
144
+ # client.post("/checkout", ...)
145
+ # end
146
+ #
147
+ # # only the shop database, custom tape name
148
+ # it "reserves stock", tape_deck: { databases: %w[shop], tape_name: "checkout/stock" } do
149
+ # client.post("/checkout", ...)
150
+ # end
151
+ #
152
+ # # compare only the order status
153
+ # it "marks the order as paid", tape_deck: { only: "shop.orders.status" } do
154
+ # client.post("/checkout", ...)
155
+ # end
156
+ #
157
+ # # re-record this tape on every run
158
+ # it "creates an invoice", tape_deck: { record: :overwrite } do
159
+ # client.post("/checkout", ...)
160
+ # end
161
+ # end
162
+ module TapeDeck
163
+ class << self
164
+ # @return [Configuration]
165
+ def configuration = @configuration ||= Configuration.new
166
+
167
+ # @yieldparam config [Configuration]
168
+ # @return [void]
169
+ def configure
170
+ yield configuration
171
+ end
172
+
173
+ # Records or verifies the database changes made by the block. If the block raises, the tape is neither recorded
174
+ # nor verified and the error propagates.
175
+ #
176
+ # @param name [String] tape name, e.g. +"checkout/creates_order"+
177
+ # @param options [Hash] keyword options, see {.insert_tape}
178
+ # @yield the action whose database changes are recorded
179
+ # @return [Object] the block's return value
180
+ # @raise [UnexpectedDiffError] if the changes do not match the tape
181
+ # @raise [TapeLoadingError] if the tape must be verified but does not exist or cannot be read
182
+ def use_tape(name, **options)
183
+ insert_tape(name, **options)
184
+ completed = false
185
+ begin
186
+ result = yield
187
+ completed = true
188
+ result
189
+ ensure
190
+ eject_tape(discard: !completed)
191
+ end
192
+ end
193
+
194
+ # Starts a tape without a block: creates the tape and takes the "before" snapshot. Pair with {.eject_tape}. Tapes
195
+ # may be nested, see "Nested tapes" in {TapeDeck}.
196
+ #
197
+ # Like +File.open+ without a block, this leaves the rest to you. {.use_tape} and the RSpec tag eject the tape in
198
+ # any case, and discard it if the action fails. Here you have to:
199
+ # * eject the tape with +discard: true+ if the action fails, otherwise the failed action is recorded or verified
200
+ # * eject outer tapes yourself if ejecting an inner one raises. The inner one is ejected anyway, but the outer
201
+ # ones stay inserted, so usually eject them with +discard: true+.
202
+ #
203
+ # @param name [String] tape name, e.g. +"checkout/creates_order"+
204
+ # @param databases [Array<String>, String, nil] names of the configured databases to watch, all of them if nil.
205
+ # See "Selecting databases" in {TapeDeck}.
206
+ # @param only [Array<String>, String, nil] patterns for what to compare when verifying, everything if nil. See
207
+ # "Excluding and ignoring" in {TapeDeck}.
208
+ # @param ignore [Array<String>, String, nil] patterns for what not to compare when verifying
209
+ # @param options [Hash] keyword options, see {Tape#initialize}
210
+ # @return [Tape]
211
+ # @raise [ArgumentError] for a database name that is not configured
212
+ # @raise (see Tape#initialize)
213
+ # @example Insert and eject manually, the way {.use_tape} does
214
+ # TapeDeck.insert_tape("checkout/creates_order")
215
+ # begin
216
+ # client.post("/checkout", ...)
217
+ # rescue
218
+ # TapeDeck.eject_tape(discard: true)
219
+ # raise
220
+ # end
221
+ # TapeDeck.eject_tape
222
+ def insert_tape(name, databases: nil, only: nil, ignore: nil, **options)
223
+ tape = Tape.new(name, **options)
224
+ recorder = Recorder.new(
225
+ select_databases(tape_databases(tape, databases)),
226
+ exclude_tables: configuration.excluded_tables,
227
+ exclude_columns: configuration.excluded_columns
228
+ )
229
+ filter = Filter.new(only: only, ignore: ignore)
230
+ sessions.push(Session.new(tape, recorder, filter, on_schema_change: configuration.on_schema_change))
231
+ tape
232
+ end
233
+
234
+ # Ejects the most recently inserted tape, i.e. the innermost one when tapes are nested: captures the diff, then
235
+ # records it to the tape or verifies it against the tape. The tape is ejected even if this raises, see
236
+ # {.insert_tape} for what that means for outer tapes.
237
+ #
238
+ # @param discard [Boolean] neither record nor verify, e.g. because the block failed
239
+ # @return [Tape, nil] the ejected tape, or nil if none was inserted
240
+ # @raise [UnexpectedDiffError] if verifying and the diff does not match the tape
241
+ # @raise [IncompatibleTapeError] if verifying and the tape lacks data that is no longer excluded, or the schema
242
+ # changed, see {Configuration#on_schema_change}
243
+ # @raise [TapeLoadingError] if verifying and the tape cannot be read, see {Tape#read}
244
+ def eject_tape(discard: false)
245
+ session = sessions.pop
246
+ return unless session
247
+
248
+ session.finish(discard: discard)
249
+ session.tape
250
+ end
251
+
252
+ # @return [Tape, nil] the most recently inserted tape that has not been ejected yet
253
+ def current_tape = sessions.last&.tape
254
+
255
+ private
256
+
257
+ def sessions = @sessions ||= []
258
+
259
+ # When verifying, the tape must hold a recording of every watched database. Without a selection, these are the
260
+ # databases the tape was recorded with. Reads the tape, so a broken one fails before the action runs.
261
+ #
262
+ # @param tape [Tape]
263
+ # @param names [Array<String>, String, nil] the selection passed to {.insert_tape}
264
+ # @return [Array<String>, nil] names of the databases to watch, all configured ones if nil
265
+ # @raise [IncompatibleTapeError] if the tape has no recording of a selected database
266
+ def tape_databases(tape, names)
267
+ return names if tape.recording?
268
+
269
+ recorded = tape.read.fetch("recorded").fetch("databases").keys
270
+ names = Array(names || recorded).map(&:to_s)
271
+ missing = names - recorded
272
+ unless missing.empty?
273
+ raise IncompatibleTapeError,
274
+ "tape #{tape.file} has no recording of #{missing.inspect}, it was recorded with #{recorded.inspect}"
275
+ end
276
+
277
+ names
278
+ end
279
+
280
+ # @param names [Array<String>, String, nil]
281
+ # @return [Hash{String => Sequel::Database}] the configured databases with these names, all of them if nil
282
+ def select_databases(names)
283
+ configured = configuration.databases.transform_keys(&:to_s)
284
+ return configured if names.nil?
285
+
286
+ names = Array(names).map(&:to_s)
287
+ unknown = names - configured.keys
288
+ unless unknown.empty?
289
+ raise ArgumentError, "unknown databases #{unknown.inspect}, configured are #{configured.keys.inspect}"
290
+ end
291
+
292
+ configured.slice(*names)
293
+ end
294
+ end
295
+ end
data/sig/tape_deck.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module TapeDeck
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,95 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tape_deck
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0.beta1
5
+ platform: ruby
6
+ authors:
7
+ - Robert Schulze
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: bigdecimal
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: sequel
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '5.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '5.0'
40
+ description: Snapshots your databases around an action and stores the row-level diff
41
+ as a YAML tape. Later runs of the same action are verified against the tape, so
42
+ you notice when a change or update alters what gets written. VCR-style API, works
43
+ with Sequel and MySQL.
44
+ email:
45
+ - robert@dotless.de
46
+ executables: []
47
+ extensions: []
48
+ extra_rdoc_files: []
49
+ files:
50
+ - ".devcontainer/compose.yml"
51
+ - ".devcontainer/devcontainer.json"
52
+ - ".yardopts"
53
+ - CHANGELOG.md
54
+ - CODE_OF_CONDUCT.md
55
+ - DEVELOPMENT.md
56
+ - LICENSE.txt
57
+ - README.md
58
+ - Rakefile
59
+ - lib/tape_deck.rb
60
+ - lib/tape_deck/configuration.rb
61
+ - lib/tape_deck/errors.rb
62
+ - lib/tape_deck/filter.rb
63
+ - lib/tape_deck/patterns.rb
64
+ - lib/tape_deck/recorder.rb
65
+ - lib/tape_deck/session.rb
66
+ - lib/tape_deck/tape.rb
67
+ - lib/tape_deck/version.rb
68
+ - sig/tape_deck.rbs
69
+ homepage: https://github.com/fnordfish/tape_deck
70
+ licenses:
71
+ - MIT
72
+ metadata:
73
+ homepage_uri: https://github.com/fnordfish/tape_deck
74
+ documentation_uri: https://fnordfish.github.io/tape_deck/docs/v0.1.0.beta1/
75
+ changelog_uri: https://github.com/fnordfish/tape_deck/blob/main/CHANGELOG.md
76
+ rubygems_mfa_required: 'true'
77
+ rdoc_options: []
78
+ require_paths:
79
+ - lib
80
+ required_ruby_version: !ruby/object:Gem::Requirement
81
+ requirements:
82
+ - - ">="
83
+ - !ruby/object:Gem::Version
84
+ version: 3.2.0
85
+ required_rubygems_version: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ requirements: []
91
+ rubygems_version: 4.0.23
92
+ specification_version: 4
93
+ summary: Records what an action changes in your databases, and checks later runs against
94
+ it.
95
+ test_files: []