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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e3e52134a29b1bcec60211e70c2632b5bcf6e948977738bfc3c05419517223a9
4
- data.tar.gz: 377b07a9f6ead12778bda7553b314bf29f800fcdba8d182f284a67289014c2cf
3
+ metadata.gz: 8c9c9d5d8179c5cbf64fcd6538a311c79be5bda549359d7f6a6f7e3673271462
4
+ data.tar.gz: 1fa65375ca49b761250aeb13a81d62acf9b309790d14214656b2abd6c96f265b
5
5
  SHA512:
6
- metadata.gz: b5ae3af854d5d773d37b5cba103ec38721a5e6d7ccc8a77120a24c1e305a0362f945a6e61e86bbbd918fc3ce1fb9a4b8028d72a01a03b8c5dc55a84c657be032
7
- data.tar.gz: 538704dde3a6796d74365ba092156239ee4bb34a1c7478147338dfdd7410f4d6aa3e164223ef8f08c8297d2dcbb571b195c0dd3ad73a0b0cf44da58432c372c2
6
+ metadata.gz: '01794027c3c706220a2c931ff28c820782b6ef20c340347d55edbfc27deef83ec88f006959f6b2ede1a482b6e90dc8e6b18d6b926090a5a29f39c4e71de18737'
7
+ data.tar.gz: e8e54acee961f60c499f0a599952ebd68dd9af4c2a2a06530589a4e514ce6a14610cfd923ca4d8bd41f3ae18dca8109d89394ed997560fba5304ce0986ce6a3d
@@ -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, recorded:)`. Never touches a database |
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 what was recorded**: the exclude patterns (once, they are global), the watched databases, and the resolved names (`<table>`, `<table>.<column>`) the patterns left out of each.
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` runs before and after. A table whose checksum is unchanged reuses the first snapshot object, and `diff` skips it by identity (`equal?`).
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 (`Recorder::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.
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 fake specs, then the MySQL specs once per adapter if `TAPE_DECK_MYSQL_URL` is set
46
- - `bundle exec rake spec:fake` needs no server. In-memory SQLite through Sequel, `spec/support/fake_mysql.rb` answers `CHECKSUM TABLE`, everything else is real Sequel.
47
- - `bundle exec rake spec:mysql2` / `spec:trilogy` run `spec/integration/mysql_spec.rb` (tagged `:mysql`) with that adapter
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
- `TAPE_DECK_MYSQL_URL` is a server URL without a database, e.g. `mysql2://root:tape_deck@mysql`. The adapter tasks replace its scheme with their own. The MySQL specs create and drop `tape_deck_shop` and `tape_deck_billing`.
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
- The devcontainer (`.devcontainer/`) has Ruby and MySQL 8.4, with `TAPE_DECK_MYSQL_URL` already set. Without VS Code:
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
- The server is MySQL 8.4 by default. Set `MYSQL_IMAGE` for another one, e.g. `MYSQL_IMAGE=mariadb:11.4`.
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, plus `spec:mysql2` and `spec:trilogy` in the devcontainer setup, against MySQL 8.4 and MariaDB 11.4.
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 group (`BUNDLE_WITH=mysql`), because `mysql2` needs the MySQL client library to install.
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 push to `main` and every version tag, and publishes `_site` to <https://fnordfish.github.io/tape_deck/docs/> (`main/`, `v0.1.0/`, ...). It needs GitHub Pages enabled with "GitHub Actions" as the source (Settings, Pages).
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 `trilogy` adapters. CI runs all four. Other servers and versions may work, but are untested.
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` scans the whole table. Fine for test databases, worth measuring for large ones.
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 Version](https://img.shields.io/gem/v/tape_deck?include_prereleases)][gem]
5
+ [![Gem Version](https://img.shields.io/gem/v/tape_deck)][gem]
6
6
  [![Build Status](https://github.com/fnordfish/tape_deck/actions/workflows/main.yml/badge.svg)][ci]
7
7
  [![API Docs](https://img.shields.io/badge/docs-API-blue)][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 or MariaDB (it uses `CHECKSUM TABLE` to skip unchanged tables). Tested with MySQL 8.4 and MariaDB 11.4, using the `mysql2` or `trilogy` adapter.
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. While TapeDeck is in beta, the version requirement is needed, because Bundler skips prerelease versions without it:
19
+ Add this line to your application's Gemfile:
23
20
 
24
21
  ```ruby
25
- gem "tape_deck", "~> 0.1.0.beta1", group: :test
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.beta1", group: :test
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
- recorded:
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
- `recorded` holds the exclude rules, the databases the tape watched, and what the rules left out of each.
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 exclude rules, verifying also notices schema changes: when the rules now match tables or columns that were added or removed since recording, it raises `TapeDeck::IncompatibleTapeError`. Set `c.on_schema_change = :warn` to only print a warning, or `:ignore`.
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
- * Your databases need the same starting state on every run. Auto increment ids and existing rows end up on the tape.
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 that fake MySQL's `CHECKSUM TABLE`, so you don't need a MySQL server. For the real thing, open the devcontainer, it comes with MySQL.
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/main/
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
- MYSQL_ADAPTERS = %w[mysql2 trilogy].freeze
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 MySQL server"
10
- RSpec::Core::RakeTask.new(:fake) do |t|
11
- t.rspec_opts = "--tag ~mysql"
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 #{adapter}, needs TAPE_DECK_MYSQL_URL"
16
- task adapter do
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 MySQL ones with every adapter if TAPE_DECK_MYSQL_URL is set"
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:fake"].invoke
35
+ Rake::Task["spec:sqlite"].invoke
27
36
  if ENV["TAPE_DECK_MYSQL_URL"]
28
- MYSQL_ADAPTERS.each { Rake::Task["spec:#{_1}"].invoke }
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
- refs = ["main", *`git tag --list "v*" --sort=-v:refname`.split]
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", "--force", worktree, commit
84
+ sh "git", "worktree", "add", "--detach", worktree, commit
63
85
  begin
64
- Dir.chdir(worktree) { sh "yard", "doc", "--no-save", "--no-stats", "--output-dir", output }
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
- links = refs.map { %(<li><a href="#{_1}/">#{_1}</a></li>) }.join("\n")
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