tape_deck 0.1.0.beta1 → 0.1.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/.devcontainer/compose.yml +18 -2
- data/CHANGELOG.md +18 -0
- data/DEVELOPMENT.md +29 -18
- data/README.md +79 -14
- data/Rakefile +50 -15
- data/lib/tape_deck/adapters/base.rb +96 -0
- data/lib/tape_deck/adapters/mysql.rb +20 -0
- data/lib/tape_deck/adapters/postgres.rb +29 -0
- data/lib/tape_deck/adapters/sqlite.rb +10 -0
- data/lib/tape_deck/adapters.rb +22 -0
- data/lib/tape_deck/configuration.rb +116 -3
- data/lib/tape_deck/filter.rb +1 -2
- data/lib/tape_deck/new_rows.rb +168 -0
- data/lib/tape_deck/recorder.rb +97 -90
- data/lib/tape_deck/rows.rb +16 -0
- data/lib/tape_deck/session.rb +73 -30
- data/lib/tape_deck/tape.rb +47 -34
- data/lib/tape_deck/version.rb +1 -1
- data/lib/tape_deck.rb +65 -8
- metadata +10 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8c9c9d5d8179c5cbf64fcd6538a311c79be5bda549359d7f6a6f7e3673271462
|
|
4
|
+
data.tar.gz: 1fa65375ca49b761250aeb13a81d62acf9b309790d14214656b2abd6c96f265b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '01794027c3c706220a2c931ff28c820782b6ef20c340347d55edbfc27deef83ec88f006959f6b2ede1a482b6e90dc8e6b18d6b926090a5a29f39c4e71de18737'
|
|
7
|
+
data.tar.gz: e8e54acee961f60c499f0a599952ebd68dd9af4c2a2a06530589a4e514ce6a14610cfd923ca4d8bd41f3ae18dca8109d89394ed997560fba5304ce0986ce6a3d
|
data/.devcontainer/compose.yml
CHANGED
|
@@ -2,19 +2,22 @@ name: tape_deck
|
|
|
2
2
|
|
|
3
3
|
services:
|
|
4
4
|
app:
|
|
5
|
-
image: ruby:4.0
|
|
5
|
+
image: ${RUBY_IMAGE:-ruby:4.0}
|
|
6
6
|
command: sleep infinity
|
|
7
7
|
working_dir: /workspaces/tape_deck
|
|
8
8
|
volumes:
|
|
9
9
|
- ..:/workspaces/tape_deck
|
|
10
10
|
- bundle:/usr/local/bundle
|
|
11
11
|
environment:
|
|
12
|
-
BUNDLE_WITH: mysql
|
|
12
|
+
BUNDLE_WITH: mysql:postgres
|
|
13
13
|
TZ: UTC
|
|
14
14
|
TAPE_DECK_MYSQL_URL: ${TAPE_DECK_MYSQL_URL:-mysql2://root:tape_deck@mysql}
|
|
15
|
+
TAPE_DECK_POSTGRES_URL: ${TAPE_DECK_POSTGRES_URL:-postgres://postgres:tape_deck@postgres}
|
|
15
16
|
depends_on:
|
|
16
17
|
mysql:
|
|
17
18
|
condition: service_healthy
|
|
19
|
+
postgres:
|
|
20
|
+
condition: service_healthy
|
|
18
21
|
|
|
19
22
|
mysql:
|
|
20
23
|
image: ${MYSQL_IMAGE:-mysql:8.4}
|
|
@@ -29,5 +32,18 @@ services:
|
|
|
29
32
|
timeout: 5s
|
|
30
33
|
retries: 30
|
|
31
34
|
|
|
35
|
+
postgres:
|
|
36
|
+
image: ${POSTGRES_IMAGE:-postgres:18}
|
|
37
|
+
environment:
|
|
38
|
+
POSTGRES_PASSWORD: tape_deck
|
|
39
|
+
tmpfs:
|
|
40
|
+
# Covers the data directory of all image versions (18 moved it into a version subdirectory)
|
|
41
|
+
- /var/lib/postgresql
|
|
42
|
+
healthcheck:
|
|
43
|
+
test: ["CMD", "pg_isready", "-U", "postgres"]
|
|
44
|
+
interval: 2s
|
|
45
|
+
timeout: 5s
|
|
46
|
+
retries: 30
|
|
47
|
+
|
|
32
48
|
volumes:
|
|
33
49
|
bundle:
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changes
|
|
2
2
|
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
- Breaking: tapes recorded with 0.1.0.beta1 need re-recording. Their information section is now called `tape_deck`, and ids of new rows are stored as placeholders.
|
|
6
|
+
- Breaking: generated ids of inserted rows are stored as placeholders like `new shop.orders 1`, numbered per table in creation order, and so are declared foreign keys to them. Tapes no longer depend on where the database's counters stand. `keep_new_ids` keeps real ids.
|
|
7
|
+
- `number_by_content` numbers a table's new rows by content, when creation order doesn't matter, like for an import
|
|
8
|
+
- `references_to` adds references across databases and ones without a foreign key
|
|
9
|
+
- `generated_keys` adds generated keys that aren't auto increment, like UUIDs
|
|
10
|
+
- Add: PostgreSQL support, and SQLite as a supported database. Database specific code lives in adapters, picked by Sequel's database type. Other databases Sequel supports read every table on every snapshot.
|
|
11
|
+
- Add: opt-in views with `include_views`
|
|
12
|
+
- Add: tapes store the TapeDeck version and the new-row numbering rules. Verifying uses the tape's numbering rules, not the current configuration.
|
|
13
|
+
- Add: the RSpec hook fails the suite when tagged examples would share a tape, like the same description in a loop
|
|
14
|
+
- Fix: the order of rows on a tape no longer depends on Ruby's `inspect` format, which changes between Ruby versions. Verifying also ignores the row order on the tape.
|
|
15
|
+
- Change: values of unknown types, like from Sequel's `pg_json` or `pg_array` extensions, become plain Hashes, Arrays or Strings
|
|
16
|
+
- Internal housekeeping
|
|
17
|
+
- API docs on GitHub Pages, for `main` and every release
|
|
18
|
+
- CI tests Ruby 3.2 to 4.0 and head, MySQL 8.4 and MariaDB 11.4 with `mysql2`, `trilogy` and `ruby-mysql`, and PostgreSQL 15 and 18
|
|
19
|
+
- Gemfile.lock is no longer tracked
|
|
20
|
+
|
|
3
21
|
## 0.1.0.beta1
|
|
4
22
|
|
|
5
23
|
- First beta. The API and the tape format may still change.
|
data/DEVELOPMENT.md
CHANGED
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
| Piece | Job |
|
|
9
9
|
| --- | --- |
|
|
10
10
|
| `Configuration` | Plain settings: `tape_library_dir`, `databases`, `default_record_mode`, `on_schema_change`, exclude patterns, RSpec hook |
|
|
11
|
-
| `Tape` | File-like. Knows its name, path and mode (`recording?` / `verifying?`), can `read` and `write(diff,
|
|
11
|
+
| `Tape` | File-like. Knows its name, path and mode (`recording?` / `verifying?`), can `read` and `write(diff, tape_deck:)`. Never touches a database |
|
|
12
12
|
| `Recorder` | Observes databases: `snapshot` and `diff`. Knows nothing about tapes or configuration |
|
|
13
|
+
| `Adapters` | The database specific parts of a snapshot: checksums to skip unchanged tables, and turning column values into plain YAML values. Picked by `db.database_type`, `Adapters::Base` for other databases |
|
|
13
14
|
| `Filter` | Narrows a diff to what one use of a tape wants compared (`only:` / `ignore:`). `@api private` |
|
|
14
15
|
| `Session` | One run of one tape: "before" snapshot on creation, `finish` captures the diff and records or verifies. `@api private` |
|
|
15
16
|
|
|
@@ -21,16 +22,24 @@ Decisions made on purpose. Please don't undo them without discussing first:
|
|
|
21
22
|
- **Record modes** are `:once`, `:verify`, `:overwrite`. Renamed from VCR's `:once`, `:none`, `:all`.
|
|
22
23
|
- **Vocabulary.** "record", "verify", "discard", "capture". Not "playback", not "compare" as a mode name.
|
|
23
24
|
- **Errors** live directly under `TapeDeck` (`Error`, `TapeLoadingError`, `IncompatibleTapeError`, `UnexpectedDiffError`), not in an `Errors` module.
|
|
25
|
+
- **Tape names come from example descriptions, never from line numbers or example ids.** Those change when examples are added above, which would silently orphan tapes. Instead, the RSpec hook fails the suite in `before(:suite)` when tagged examples share a tape file (`Configuration.check_rspec_tape_names`), using `RSpec.world.all_examples`, which is internal but long stable.
|
|
24
26
|
- **Manual `insert_tape` / `eject_tape` work like `File.open` without a block**: no automatic tear down. The caller discards a tape whose action failed, and ejects outer tapes when an inner eject raises. `use_tape` and the RSpec tag do both.
|
|
25
27
|
- **Options are keywords.** Unknown keywords raise.
|
|
26
|
-
- **A tape stores
|
|
28
|
+
- **A tape stores everything that shapes its content**, in its `tape_deck` section: the TapeDeck version that recorded it, every recording rule (exclude, include, numbering), the watched databases, and the resolved names the exclude and include rules matched in each. A tape must be understandable and verifiable on its own. Per-use options (`only:`, `ignore:`) and behavior settings (record mode, `on_schema_change`) are not stored, they don't shape the content.
|
|
27
29
|
- A tape must cover the run that verifies it: the watched databases are a subset of the recorded ones, and everything the tape left out is still excluded under the current rules. What is excluded now but on the tape is dropped from both sides before comparing.
|
|
28
30
|
- Schema changes: the recorded patterns, applied to the current schema, must resolve to the recorded names. A difference means a matching table or column was added or removed. `on_schema_change` decides between error, warning and nothing.
|
|
31
|
+
- **New rows get placeholders**, `"new <database>.<table> <n>"`, numbered per table in creation order (sorted auto increment ids). Only single, auto increment primary keys count as generated, so natural keys like a SKU stay visible. Declared single column foreign keys get the same placeholder, in inserted rows and in updated values. Ids of rows that existed before stay literal. `NewRows` runs on both sides, after the diff is captured and before it is recorded or verified. Verifying uses the numbering rules stored on the tape, not the configuration, because numbering only changes how ids are written. (Exclude and include rules are different: they decide which data is on the tape, so verifying uses the current ones and checks they fit the tape.)
|
|
32
|
+
- `references_to` adds references by column pattern, across databases too, and wins over a declared foreign key. `generated_keys` adds primary keys that aren't auto increment. Keys that aren't integers have no creation order, so their tables are always numbered by content.
|
|
33
|
+
- `number_by_content` tables are sorted by their JSON without the key, after the tables they reference, so references are placeholders already. A reference to a row without a placeholder yet (self references, cycles) counts as `"new <database>.<table>"` only, because its id isn't stable. Ties are broken by creation order.
|
|
29
34
|
- **Primary key columns are never excluded**, even if a pattern matches, because rows are matched by them.
|
|
30
|
-
- **Snapshots reuse unchanged tables.** `CHECKSUM TABLE`
|
|
35
|
+
- **Snapshots reuse unchanged tables.** The adapter computes a checksum per table before and after: `CHECKSUM TABLE` on MySQL, the row count plus a sum of row hashes on PostgreSQL. A table whose checksum is unchanged reuses the first snapshot object, and `diff` skips it by identity (`equal?`). Without a checksum (SQLite, other databases), a table is read on every snapshot.
|
|
31
36
|
- **Tables without a primary key** are compared as a multiset of rows (inserted / deleted only).
|
|
37
|
+
- **Row order never depends on `inspect`**, whose format changes between Ruby versions (3.4 changed `Hash#inspect`). Rows are sorted by their JSON (`TapeDeck::Rows`), and verifying sorts both sides before comparing, so the order on a tape is for reading only.
|
|
38
|
+
- **Unknown value types become plain values.** Values from Sequel extensions like `pg_json` or `pg_array` are hash-like or array-like objects, which `YAML.safe_load` can't read back. `Adapters::Base#serialize` turns them into plain Hashes and Arrays, and any other unknown type into a String.
|
|
39
|
+
- **PostgreSQL sees the search path only**, like `db.tables`, so names stay `"<database>.<table>"` for every database.
|
|
40
|
+
- **Views are opt-in** (`include_views`), because table diffs already cover their data. Included views are always read in full (no checksum tells whether a view changed) and compared as a multiset. The tape stores the include patterns and the resolved views per database. A view no longer included is not compared, one included by changed rules raises `IncompatibleTapeError`, one added by a schema change is reported like any schema change and not compared.
|
|
32
41
|
- **Binary values**: valid UTF-8 becomes a plain UTF-8 string, anything else is hex up to 64 bytes and a `sha256:` digest above that. Always a plain `String`, never a subclass, so `YAML.safe_load` can read the tape.
|
|
33
|
-
- **TIME columns** are stored as a time of day, recognized by their database type (`
|
|
42
|
+
- **TIME columns** are stored as a time of day, recognized by their database type (`Adapters::Base::TIME_DB_TYPE`), because Sequel types `time` as `:time` but not `time(6)`. `mysql2` returns them as a `Time` on 2000-01-01, which would otherwise be stored as a timestamp.
|
|
34
43
|
|
|
35
44
|
## Code style
|
|
36
45
|
|
|
@@ -42,28 +51,31 @@ Decisions made on purpose. Please don't undo them without discussing first:
|
|
|
42
51
|
|
|
43
52
|
## Testing
|
|
44
53
|
|
|
45
|
-
- `bundle exec rake spec` runs everything: the
|
|
46
|
-
- `bundle exec rake spec:
|
|
47
|
-
- `bundle exec rake spec:mysql2` / `spec:trilogy` run `spec/integration/mysql_spec.rb` (tagged `:mysql`) with that
|
|
54
|
+
- `bundle exec rake spec` runs everything: the SQLite specs, then the MySQL specs once per adapter if `TAPE_DECK_MYSQL_URL` is set, and the PostgreSQL specs if `TAPE_DECK_POSTGRES_URL` is set
|
|
55
|
+
- `bundle exec rake spec:sqlite` needs no server. In-memory SQLite through Sequel, using the real SQLite adapter.
|
|
56
|
+
- `bundle exec rake spec:mysql2` / `spec:trilogy` / `spec:ruby-mysql` run `spec/integration/mysql_spec.rb` (tagged `:mysql`) with that gem, through Sequel's `mysql2`, `trilogy` and `mysql` adapters
|
|
57
|
+
- `bundle exec rake spec:postgres` runs `spec/integration/postgres_spec.rb` (tagged `:postgres`)
|
|
48
58
|
|
|
49
|
-
|
|
59
|
+
Both server specs include the shared examples in `spec/support/server_examples.rb`, and add their own value types. The adapters' checksum queries are also covered by the SQLite run, through Sequel's mock databases (`spec/tape_deck/adapters_spec.rb`).
|
|
50
60
|
|
|
51
|
-
|
|
61
|
+
`TAPE_DECK_MYSQL_URL` and `TAPE_DECK_POSTGRES_URL` are server URLs without a database, e.g. `mysql2://root:tape_deck@mysql` and `postgres://postgres:tape_deck@postgres`. The MySQL tasks replace the scheme with their Sequel adapter's (`mysql://` for ruby-mysql). The server specs create and drop `tape_deck_shop` and `tape_deck_billing`.
|
|
62
|
+
|
|
63
|
+
The devcontainer (`.devcontainer/`) has Ruby, MySQL 8.4 and PostgreSQL 18, with both URLs already set. Without VS Code:
|
|
52
64
|
|
|
53
65
|
docker compose -f .devcontainer/compose.yml run --rm app sh -c "bundle install && bundle exec rake spec"
|
|
54
66
|
|
|
55
|
-
|
|
67
|
+
Set `MYSQL_IMAGE` for another MySQL server, e.g. `MYSQL_IMAGE=mariadb:11.4`, `POSTGRES_IMAGE` for another PostgreSQL, e.g. `POSTGRES_IMAGE=postgres:15`, and `RUBY_IMAGE` for another Ruby, e.g. `RUBY_IMAGE=ruby:3.2`.
|
|
56
68
|
|
|
57
|
-
CI runs the default task,
|
|
69
|
+
CI runs the default task on every supported Ruby and on Ruby head. In the devcontainer setup, on the oldest and newest supported Ruby, it runs `spec:mysql2`, `spec:trilogy` and `spec:ruby-mysql` against MySQL 8.4 and MariaDB 11.4, and `spec:postgres` against PostgreSQL 15 and 18.
|
|
58
70
|
|
|
59
|
-
The adapters live in the optional `mysql` Gemfile
|
|
71
|
+
The database adapters live in the optional `mysql` and `postgres` Gemfile groups (`BUNDLE_WITH=mysql:postgres`), because `mysql2` and `pg` may need client libraries to install.
|
|
60
72
|
|
|
61
73
|
## Docs
|
|
62
74
|
|
|
63
75
|
- `bundle exec yard doc` builds the docs into `doc/`, `bundle exec yard server -r` serves them while you edit
|
|
64
|
-
- `bundle exec rake docs:pages` builds `main` and every `v*` tag into `_site/docs/<ref>`, from their commits, not the working tree
|
|
76
|
+
- `bundle exec rake docs:pages` builds `main` and every `v*` tag into `_site/docs/<ref>`, from their commits, not the working tree. It empties `_site` first.
|
|
65
77
|
|
|
66
|
-
The Docs workflow runs `docs:pages` on every
|
|
78
|
+
The Docs workflow runs `docs:pages` on every version tag, and on pushes to `main` that change a file the docs are built from (the `paths` in `.github/workflows/pages.yml`, keep it in sync with `.yardopts`), and force-pushes `_site` as a single commit to the `gh-pages` branch, published at <https://fnordfish.github.io/tape_deck/docs/> (`main/`, `v0.1.0/`, ...). `latest/` redirects to the newest release, or the newest prerelease until there is one. GitHub Pages must serve that branch (Settings, Pages, Source: "Deploy from a branch", `gh-pages`, `/ (root)`). The branch is not deployed with `actions/deploy-pages`, because that deploys a commit only once, and a release pushes `main` and the tag for the same commit.
|
|
67
79
|
|
|
68
80
|
## Releasing
|
|
69
81
|
|
|
@@ -76,11 +88,10 @@ Releases go to rubygems.org and to the `@fnordfish` namespace on [gem.coop](http
|
|
|
76
88
|
|
|
77
89
|
## Tested against
|
|
78
90
|
|
|
79
|
-
MySQL 8.4 and MariaDB 11.4, each with the `mysql2` and `
|
|
91
|
+
Ruby 3.2 to 4.0. On Ruby 3.2 and 4.0: MySQL 8.4 and MariaDB 11.4, each with the `mysql2`, `trilogy` and `ruby-mysql` gems, and PostgreSQL 15 and 18. SQLite with every supported Ruby. Other servers and versions may work, but are untested.
|
|
80
92
|
|
|
81
93
|
## Roadmap and open questions
|
|
82
94
|
|
|
83
|
-
- Replace new auto increment ids with stable placeholders, so tapes don't depend on the starting state
|
|
84
|
-
- Views are not snapshotted (`db.tables` skips them)
|
|
85
95
|
- Binary data that happens to be valid UTF-8 (short hashes, UUIDs) is shown as text
|
|
86
|
-
- `CHECKSUM TABLE
|
|
96
|
+
- Checksums scan the whole table (`CHECKSUM TABLE`, the PostgreSQL checksum query). Fine for test databases, worth measuring for large ones.
|
|
97
|
+
- PostgreSQL schemas other than the search path
|
data/README.md
CHANGED
|
@@ -2,34 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
Records what an action changes in your databases, and checks later runs against that recording.
|
|
4
4
|
|
|
5
|
-
[][gem]
|
|
6
6
|
[][ci]
|
|
7
7
|
[][docs]
|
|
8
8
|
|
|
9
9
|
**[API documentation][docs]**
|
|
10
10
|
|
|
11
|
-
> [!NOTE]
|
|
12
|
-
> TapeDeck is in beta. The API and the tape format may still change, which can mean re-recording your tapes.
|
|
13
|
-
|
|
14
11
|
You run a command or request against a black-box project. TapeDeck snapshots all configured databases before and after, and stores the row-level diff as a YAML file, a "tape". On later runs the same action must cause the same diff, so you notice when a project changes its behaviour after an update.
|
|
15
12
|
|
|
16
13
|
API, naming and flow are modelled on [VCR](https://github.com/vcr/vcr), with one big difference: a tape is never played back. The action always runs for real, and the tape is only what the result is compared against.
|
|
17
14
|
|
|
18
|
-
Works with [Sequel](https://sequel.jeremyevans.net) and MySQL
|
|
15
|
+
Works with [Sequel](https://sequel.jeremyevans.net) and MySQL, MariaDB, PostgreSQL or SQLite. Needs Ruby 3.2 or newer.
|
|
19
16
|
|
|
20
17
|
## Installation
|
|
21
18
|
|
|
22
|
-
Add this line to your application's Gemfile
|
|
19
|
+
Add this line to your application's Gemfile:
|
|
23
20
|
|
|
24
21
|
```ruby
|
|
25
|
-
gem "tape_deck", "~> 0.1.0
|
|
22
|
+
gem "tape_deck", "~> 0.1.0", group: :test
|
|
26
23
|
```
|
|
27
24
|
|
|
28
25
|
Or get it from [gem.coop](https://gem.coop):
|
|
29
26
|
|
|
30
27
|
```ruby
|
|
31
28
|
source "https://gem.coop/@fnordfish" do
|
|
32
|
-
gem "tape_deck", "~> 0.1.0
|
|
29
|
+
gem "tape_deck", "~> 0.1.0", group: :test
|
|
33
30
|
end
|
|
34
31
|
```
|
|
35
32
|
|
|
@@ -59,7 +56,8 @@ end
|
|
|
59
56
|
The first run writes `spec/db_tapes/checkout/creates_order.yml`:
|
|
60
57
|
|
|
61
58
|
```yaml
|
|
62
|
-
|
|
59
|
+
tape_deck:
|
|
60
|
+
version: 0.1.0
|
|
63
61
|
exclude_tables: [shop.sessions, "*.schema_migrations"]
|
|
64
62
|
exclude_columns: ["*.created_at", "*.updated_at"]
|
|
65
63
|
databases:
|
|
@@ -88,7 +86,7 @@ diff:
|
|
|
88
86
|
- 9
|
|
89
87
|
```
|
|
90
88
|
|
|
91
|
-
`
|
|
89
|
+
`tape_deck` holds everything that shaped the tape: the TapeDeck version, the recording rules, the databases the tape watched, and what the rules left out of each.
|
|
92
90
|
|
|
93
91
|
Every later run verifies against it and raises `TapeDeck::UnexpectedDiffError` with a diff when the changes differ.
|
|
94
92
|
|
|
@@ -102,6 +100,15 @@ Every later run verifies against it and raises `TapeDeck::UnexpectedDiffError` w
|
|
|
102
100
|
|
|
103
101
|
After `configure_rspec_metadata!`, tag examples with `:tape_deck`. The tape is named after the example's description, unless you pass `tape_name:`. A failing example neither records nor verifies its tape.
|
|
104
102
|
|
|
103
|
+
Every example needs its own tape name. Before the first example runs, TapeDeck stops the suite if two tagged examples would share a tape, which easily happens in a loop:
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
%w[paid shipped].each do |status|
|
|
107
|
+
it "creates an order", :tape_deck do ... end # both would use "Checkout/creates an order"
|
|
108
|
+
it "creates an order (#{status})", :tape_deck do ... end # each gets its own tape
|
|
109
|
+
end
|
|
110
|
+
```
|
|
111
|
+
|
|
105
112
|
```ruby
|
|
106
113
|
RSpec.describe "Checkout" do
|
|
107
114
|
it "creates an order", :tape_deck do
|
|
@@ -125,15 +132,73 @@ Names are `"<database>.<table>"` and `"<database>.<table>.<column>"`, matched as
|
|
|
125
132
|
* `exclude_tables` / `exclude_columns` (configuration): never recorded. Meant for noise like timestamps. Excluding more later is fine, existing tapes just stop comparing it. Excluding less means re-recording, because the tapes lack that data (`TapeDeck::IncompatibleTapeError`).
|
|
126
133
|
* `only:` / `ignore:` (per use of a tape): recorded, but not compared. Can change without re-recording.
|
|
127
134
|
|
|
128
|
-
Since a tape stores its
|
|
135
|
+
Since a tape stores its rules, verifying also notices schema changes: when the rules now match tables, columns or views that were added or removed since recording, it raises `TapeDeck::IncompatibleTapeError`. Set `c.on_schema_change = :warn` to only print a warning, or `:ignore`.
|
|
129
136
|
|
|
130
137
|
Primary key columns are never excluded, because rows are matched by them. Tables without a primary key are compared as a multiset of rows.
|
|
131
138
|
|
|
139
|
+
### New rows
|
|
140
|
+
|
|
141
|
+
Generated ids depend on where the database's counters stand, and those move with every insert, also with ones a test rolled back. So tapes store placeholders instead, numbered per table in the order the rows were created:
|
|
142
|
+
|
|
143
|
+
```yaml
|
|
144
|
+
orders:
|
|
145
|
+
inserted:
|
|
146
|
+
- id: new shop.orders 1
|
|
147
|
+
items:
|
|
148
|
+
inserted:
|
|
149
|
+
- id: new shop.items 1
|
|
150
|
+
order_id: new shop.orders 1
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Declared foreign keys to new rows get the same placeholder. Ids of rows that existed before stay as they are. To keep the real ids of a table, use `c.keep_new_ids "shop.audit_log"`.
|
|
154
|
+
|
|
155
|
+
Numbering in creation order means that creating the same rows in a different order fails verification. That's what you want if the order matters, for example when a CSV import must keep the file's order. If it doesn't, number those tables by content instead:
|
|
156
|
+
|
|
157
|
+
```ruby
|
|
158
|
+
c.number_by_content "shop.imported_products", "shop.imported_prices"
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Rows are then sorted by their content, and referenced tables are numbered first, so a row's references count as part of its content. Rows with identical content are numbered in creation order.
|
|
162
|
+
|
|
163
|
+
References across databases, and columns without a foreign key, need to be named. Keys that are generated, but not auto increment, like UUIDs, too:
|
|
164
|
+
|
|
165
|
+
```ruby
|
|
166
|
+
c.references_to "shop.orders", "billing.invoices.order_id", "*.last_order_id"
|
|
167
|
+
c.generated_keys "shop.tokens.id"
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
UUIDs have no creation order, so those rows are always numbered by content.
|
|
171
|
+
|
|
172
|
+
These numbering rules are stored on each tape, and verifying uses the tape's rules, not your current configuration. Changing them only affects tapes you record afterwards.
|
|
173
|
+
|
|
174
|
+
### Views
|
|
175
|
+
|
|
176
|
+
Views are left out, because the changes of the tables behind them are on the tape anyway. Include them to also notice a changed view definition:
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
c.include_views "shop.order_totals", "billing.*"
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Included views are read in full on every snapshot, and compared as a multiset of rows, because they have no primary key. `exclude_columns` applies to their columns.
|
|
183
|
+
|
|
184
|
+
### Databases
|
|
185
|
+
|
|
186
|
+
| Database | Tested with | Unchanged tables |
|
|
187
|
+
| --- | --- | --- |
|
|
188
|
+
| MySQL, MariaDB | MySQL 8.4 and MariaDB 11.4, `mysql2`, `trilogy` and `ruby-mysql` gems | skipped, using `CHECKSUM TABLE` |
|
|
189
|
+
| PostgreSQL | PostgreSQL 15 and 18, `pg` adapter | skipped, using a checksum computed on the server |
|
|
190
|
+
| SQLite | `sqlite3` adapter | read again on every snapshot |
|
|
191
|
+
|
|
192
|
+
Telling unchanged tables apart still scans them on the server, but saves reading their rows. Other databases Sequel supports should work too, and read every table on every snapshot.
|
|
193
|
+
|
|
194
|
+
PostgreSQL tables are seen through the search path, like Sequel's `db.tables` does, usually the `public` schema. Tables in other schemas are not recorded.
|
|
195
|
+
|
|
132
196
|
### Things to know
|
|
133
197
|
|
|
134
|
-
*
|
|
198
|
+
* Rows that exist before the action end up on the tape when it changes them, so your databases need the same starting rows on every run. Where the id counters stand doesn't matter.
|
|
135
199
|
* Anything the action does asynchronously must finish inside the block, or only part of it is captured.
|
|
136
200
|
* When verifying, a tape watches the databases it was recorded with. `databases:` can narrow that down, but can't add a database the tape has no recording of.
|
|
201
|
+
* Until 1.0, a minor version (0.2, 0.3, ...) may change the tape format. The CHANGELOG says when tapes need re-recording.
|
|
137
202
|
|
|
138
203
|
The [API docs][docs] have the full story.
|
|
139
204
|
|
|
@@ -141,7 +206,7 @@ The [API docs][docs] have the full story.
|
|
|
141
206
|
|
|
142
207
|
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
143
208
|
|
|
144
|
-
The default specs run against in-memory SQLite databases
|
|
209
|
+
The default specs run against in-memory SQLite databases, so you don't need a database server. For MySQL, MariaDB and PostgreSQL, open the devcontainer, it comes with both servers.
|
|
145
210
|
|
|
146
211
|
Please also see [DEVELOPMENT.md](DEVELOPMENT.md) for design decisions and open questions.
|
|
147
212
|
|
|
@@ -155,4 +220,4 @@ The gem is available as open source under the terms of the [MIT License](https:/
|
|
|
155
220
|
|
|
156
221
|
[gem]: https://rubygems.org/gems/tape_deck
|
|
157
222
|
[ci]: https://github.com/fnordfish/tape_deck/actions/workflows/main.yml
|
|
158
|
-
[docs]: https://fnordfish.github.io/tape_deck/docs/
|
|
223
|
+
[docs]: https://fnordfish.github.io/tape_deck/docs/latest/
|
data/Rakefile
CHANGED
|
@@ -3,32 +3,46 @@
|
|
|
3
3
|
require "bundler/gem_tasks"
|
|
4
4
|
require "rspec/core/rake_task"
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
# Gem -> the Sequel adapter that uses it, which is also the scheme of its database URLs
|
|
7
|
+
MYSQL_ADAPTERS = {"mysql2" => "mysql2", "trilogy" => "trilogy", "ruby-mysql" => "mysql"}.freeze
|
|
7
8
|
|
|
8
9
|
namespace :spec do
|
|
9
|
-
desc "Run the specs that need no
|
|
10
|
-
RSpec::Core::RakeTask.new(:
|
|
11
|
-
|
|
10
|
+
desc "Run the specs that need no database server, on SQLite"
|
|
11
|
+
RSpec::Core::RakeTask.new(:sqlite) do |t|
|
|
12
|
+
# Quoted, because the command runs through a shell, and where a "mysql" user exists (GitHub's runners), ~mysql
|
|
13
|
+
# expands to its home directory
|
|
14
|
+
t.rspec_opts = "--tag '~mysql' --tag '~postgres'"
|
|
12
15
|
end
|
|
13
16
|
|
|
14
|
-
MYSQL_ADAPTERS.each do |adapter|
|
|
15
|
-
desc "Run the MySQL specs with #{
|
|
16
|
-
task
|
|
17
|
+
MYSQL_ADAPTERS.each do |gem_name, adapter|
|
|
18
|
+
desc "Run the MySQL specs with #{gem_name}, needs TAPE_DECK_MYSQL_URL"
|
|
19
|
+
task gem_name do
|
|
17
20
|
server_url = ENV.fetch("TAPE_DECK_MYSQL_URL") { abort "Set TAPE_DECK_MYSQL_URL, see DEVELOPMENT.md" }
|
|
18
21
|
adapter_url = server_url.sub(%r{\A\w+://}, "#{adapter}://")
|
|
19
22
|
sh({"TAPE_DECK_MYSQL_URL" => adapter_url}, "rspec", "--tag", "mysql")
|
|
20
23
|
end
|
|
21
24
|
end
|
|
25
|
+
|
|
26
|
+
desc "Run the PostgreSQL specs, needs TAPE_DECK_POSTGRES_URL"
|
|
27
|
+
task :postgres do
|
|
28
|
+
ENV.fetch("TAPE_DECK_POSTGRES_URL") { abort "Set TAPE_DECK_POSTGRES_URL, see DEVELOPMENT.md" }
|
|
29
|
+
sh "rspec", "--tag", "postgres"
|
|
30
|
+
end
|
|
22
31
|
end
|
|
23
32
|
|
|
24
|
-
desc "Run all specs, the
|
|
33
|
+
desc "Run all specs, the database server ones if their URL is set (MySQL with every adapter)"
|
|
25
34
|
task :spec do
|
|
26
|
-
Rake::Task["spec:
|
|
35
|
+
Rake::Task["spec:sqlite"].invoke
|
|
27
36
|
if ENV["TAPE_DECK_MYSQL_URL"]
|
|
28
|
-
MYSQL_ADAPTERS.
|
|
37
|
+
MYSQL_ADAPTERS.each_key { Rake::Task["spec:#{_1}"].invoke }
|
|
29
38
|
else
|
|
30
39
|
warn "Skipping the MySQL specs, TAPE_DECK_MYSQL_URL is not set"
|
|
31
40
|
end
|
|
41
|
+
if ENV["TAPE_DECK_POSTGRES_URL"]
|
|
42
|
+
Rake::Task["spec:postgres"].invoke
|
|
43
|
+
else
|
|
44
|
+
warn "Skipping the PostgreSQL specs, TAPE_DECK_POSTGRES_URL is not set"
|
|
45
|
+
end
|
|
32
46
|
end
|
|
33
47
|
|
|
34
48
|
require "standard/rake"
|
|
@@ -51,7 +65,15 @@ end
|
|
|
51
65
|
namespace :docs do
|
|
52
66
|
desc "Build the YARD docs of main and every version tag into _site, like GitHub Pages publishes them"
|
|
53
67
|
task :pages do
|
|
54
|
-
|
|
68
|
+
# Start from scratch, also after an interrupted run left worktrees behind
|
|
69
|
+
rm_rf %w[_site tmp/pages]
|
|
70
|
+
sh "git", "worktree", "prune"
|
|
71
|
+
|
|
72
|
+
version = ->(tag) { Gem::Version.new(tag.delete_prefix("v")) }
|
|
73
|
+
tags = `git tag --list "v*"`.split.select { Gem::Version.correct?(_1.delete_prefix("v")) }
|
|
74
|
+
tags = tags.sort_by(&version).reverse
|
|
75
|
+
refs = ["main", *tags]
|
|
76
|
+
|
|
55
77
|
refs.each do |ref|
|
|
56
78
|
# Without a local main branch (e.g. CI building a tag), use the remote one
|
|
57
79
|
commit = ref
|
|
@@ -59,22 +81,35 @@ namespace :docs do
|
|
|
59
81
|
worktree = File.expand_path("tmp/pages/#{ref}")
|
|
60
82
|
output = File.expand_path("_site/docs/#{ref}")
|
|
61
83
|
|
|
62
|
-
sh "git", "worktree", "add", "--detach",
|
|
84
|
+
sh "git", "worktree", "add", "--detach", worktree, commit
|
|
63
85
|
begin
|
|
64
|
-
Dir.chdir(worktree)
|
|
86
|
+
Dir.chdir(worktree) do
|
|
87
|
+
sh "yard", "doc", "--no-save", "--no-stats", "--title", "TapeDeck #{ref}", "--output-dir", output
|
|
88
|
+
end
|
|
65
89
|
ensure
|
|
66
90
|
sh "git", "worktree", "remove", "--force", worktree
|
|
67
91
|
end
|
|
68
92
|
end
|
|
69
93
|
|
|
70
|
-
|
|
94
|
+
# The newest release, or the newest prerelease until there is one
|
|
95
|
+
latest = tags.find { !version.call(_1).prerelease? } || tags.first || "main"
|
|
96
|
+
links = refs.map do |ref|
|
|
97
|
+
link = %(<a href="#{ref}/">#{ref}</a>)
|
|
98
|
+
link += " (latest)" if ref == latest
|
|
99
|
+
"<li>#{link}</li>"
|
|
100
|
+
end
|
|
71
101
|
File.write("_site/docs/index.html", <<~HTML)
|
|
72
102
|
<!DOCTYPE html>
|
|
73
103
|
<html><head><meta charset="utf-8"><title>TapeDeck docs</title></head>
|
|
74
104
|
<body><h1>TapeDeck docs</h1><ul>
|
|
75
|
-
#{links}
|
|
105
|
+
#{links.join("\n")}
|
|
76
106
|
</ul></body></html>
|
|
77
107
|
HTML
|
|
108
|
+
mkdir_p "_site/docs/latest"
|
|
109
|
+
File.write("_site/docs/latest/index.html", <<~HTML)
|
|
110
|
+
<!DOCTYPE html>
|
|
111
|
+
<html><head><meta charset="utf-8"><meta http-equiv="refresh" content="0; url=../#{latest}/"></head></html>
|
|
112
|
+
HTML
|
|
78
113
|
File.write("_site/index.html", <<~HTML)
|
|
79
114
|
<!DOCTYPE html>
|
|
80
115
|
<html><head><meta charset="utf-8"><meta http-equiv="refresh" content="0; url=docs/"></head></html>
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TapeDeck
|
|
4
|
+
module Adapters
|
|
5
|
+
# Works with any database Sequel supports. Subclasses add what their database does better.
|
|
6
|
+
class Base
|
|
7
|
+
# Database types of TIME columns, with or without fractional seconds. Sequel only types +time+ as +:time+, not
|
|
8
|
+
# +time(6)+.
|
|
9
|
+
TIME_DB_TYPE = /\Atime\b/i
|
|
10
|
+
|
|
11
|
+
# @return [Sequel::Database]
|
|
12
|
+
attr_reader :db
|
|
13
|
+
|
|
14
|
+
# @param db [Sequel::Database]
|
|
15
|
+
def initialize(db)
|
|
16
|
+
@db = db
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Checksums tell which tables changed, so unchanged ones are not read again. A table without a checksum is read
|
|
20
|
+
# on every snapshot, which is what this default does for all of them.
|
|
21
|
+
#
|
|
22
|
+
# @param tables [Array<String>]
|
|
23
|
+
# @return [Hash{String => Object, nil}] table -> checksum, nil if unknown
|
|
24
|
+
def checksums(tables)
|
|
25
|
+
tables.to_h { [_1, nil] }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Turns a column value into one that +YAML.safe_load+ reads back unchanged.
|
|
29
|
+
#
|
|
30
|
+
# @param value [Object] as returned by Sequel
|
|
31
|
+
# @param column [Hash] column info from +db.schema+
|
|
32
|
+
# @return [String, Integer, Float, Boolean, nil, Array, Hash]
|
|
33
|
+
def serialize(value, column = {})
|
|
34
|
+
case value
|
|
35
|
+
when String then maybe_utf8(value)
|
|
36
|
+
when Integer, Float, true, false, nil then value
|
|
37
|
+
when BigDecimal then value.to_s("F")
|
|
38
|
+
when Sequel::SQLTime then time_of_day(value)
|
|
39
|
+
when Time then timestamp(value, column)
|
|
40
|
+
when Date then value.iso8601
|
|
41
|
+
else structured(value)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# Values of types not handled above, e.g. from Sequel extensions like +pg_json+ or +pg_array+. Hash-like and
|
|
48
|
+
# array-like ones become plain Hashes and Arrays, everything else a String.
|
|
49
|
+
def structured(value)
|
|
50
|
+
if value.respond_to?(:to_hash)
|
|
51
|
+
value.to_hash.to_h { |key, v| [key.to_s, serialize(v)] }
|
|
52
|
+
elsif value.respond_to?(:to_ary)
|
|
53
|
+
value.to_ary.map { serialize(_1) }
|
|
54
|
+
else
|
|
55
|
+
value.to_s
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# mysql2 returns TIME columns as a +Time+ on 2000-01-01, not as +Sequel::SQLTime+. Those keep their local time
|
|
60
|
+
# of day, because converting them to UTC would shift the hour.
|
|
61
|
+
def timestamp(value, column)
|
|
62
|
+
if TIME_DB_TYPE.match?(column[:db_type].to_s)
|
|
63
|
+
time_of_day(value)
|
|
64
|
+
else
|
|
65
|
+
value.utc.iso8601(6)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def time_of_day(value) = value.strftime("%H:%M:%S.%6N")
|
|
70
|
+
|
|
71
|
+
# Small binaries encode as hex. Large ones are shown as a digest so the YAML stays small.
|
|
72
|
+
def bin(value)
|
|
73
|
+
if value.bytesize <= 64
|
|
74
|
+
"0x#{value.unpack1("H*")}"
|
|
75
|
+
else
|
|
76
|
+
"sha256:#{Digest::SHA256.hexdigest(value)}"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Binary columns arrive as ASCII-8BIT strings or +Sequel::SQL::Blob+, depending on the adapter. Ones that hold
|
|
81
|
+
# valid UTF-8 become plain UTF-8 strings, because YAML would otherwise store them as base64 (+!binary+).
|
|
82
|
+
# Everything else goes through {#bin}. Always returns a plain +String+, never a subclass, so YAML can read it.
|
|
83
|
+
def maybe_utf8(value)
|
|
84
|
+
binary = value.encoding == Encoding::BINARY || value.is_a?(Sequel::SQL::Blob)
|
|
85
|
+
return String.new(value) unless binary
|
|
86
|
+
|
|
87
|
+
utf8 = String.new(value, encoding: Encoding::UTF_8)
|
|
88
|
+
if utf8.valid_encoding?
|
|
89
|
+
utf8
|
|
90
|
+
else
|
|
91
|
+
bin(value)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TapeDeck
|
|
4
|
+
module Adapters
|
|
5
|
+
# MySQL and MariaDB. Uses +CHECKSUM TABLE+, so unchanged tables are not read again.
|
|
6
|
+
class MySQL < Base
|
|
7
|
+
# @param tables [Array<String>]
|
|
8
|
+
# @return [Hash{String => Integer, nil}] table -> checksum
|
|
9
|
+
def checksums(tables)
|
|
10
|
+
return {} if tables.empty?
|
|
11
|
+
|
|
12
|
+
list = tables.map { db.literal(Sequel.identifier(_1)) }.join(", ")
|
|
13
|
+
db.fetch("CHECKSUM TABLE #{list}").to_h do |r|
|
|
14
|
+
_db_name, table = r[:Table].split(".", 2) # MySQL reports "<db>.<table>"
|
|
15
|
+
[table, r[:Checksum]]
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TapeDeck
|
|
4
|
+
module Adapters
|
|
5
|
+
# PostgreSQL. Sees the tables and views in the search path, like +Sequel::Database#tables+.
|
|
6
|
+
#
|
|
7
|
+
# PostgreSQL has no +CHECKSUM TABLE+. This computes one on the server: the row count, and the sum of 64 bits of
|
|
8
|
+
# each row's MD5. Like +CHECKSUM TABLE+ it scans the table, but no rows are sent. The order of rows doesn't matter,
|
|
9
|
+
# and the sum can't overflow, because it is a +numeric+.
|
|
10
|
+
class Postgres < Base
|
|
11
|
+
# Table alias in the checksum query. A short alias would mean a column instead, if the table had one with that
|
|
12
|
+
# name.
|
|
13
|
+
ROW = "tape_deck_row"
|
|
14
|
+
|
|
15
|
+
# @param tables [Array<String>]
|
|
16
|
+
# @return [Hash{String => String}] table -> checksum
|
|
17
|
+
def checksums(tables)
|
|
18
|
+
return {} if tables.empty?
|
|
19
|
+
|
|
20
|
+
queries = tables.map do |table|
|
|
21
|
+
"SELECT #{db.literal(table)} AS name, " \
|
|
22
|
+
"count(*) || ':' || coalesce(sum(('x' || substr(md5(#{ROW}::text), 1, 16))::bit(64)::bigint), 0) AS sum " \
|
|
23
|
+
"FROM #{db.literal(Sequel.identifier(table))} AS #{ROW}"
|
|
24
|
+
end
|
|
25
|
+
db.fetch(queries.join(" UNION ALL ")).to_h { |r| [r[:name], r[:sum]] }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TapeDeck
|
|
4
|
+
module Adapters
|
|
5
|
+
# SQLite. Has no cheap way to tell whether a table changed, so every table is read on every snapshot. For a local
|
|
6
|
+
# database file that is usually fast enough.
|
|
7
|
+
class SQLite < Base
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|