tapsoob 0.7.17 → 0.8.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/.gitlab-ci.yml +150 -7
- data/Gemfile +5 -2
- data/README.md +11 -7
- data/lib/tapsoob/operation/base.rb +4 -8
- data/lib/tapsoob/operation/pull.rb +4 -1
- data/lib/tapsoob/utils.rb +3 -3
- data/lib/tapsoob/version.rb +1 -1
- data/spec/integration/mysql_spec.rb +89 -0
- data/spec/integration/postgres_spec.rb +97 -0
- data/spec/integration/sqlite_spec.rb +119 -0
- data/spec/spec_helper.rb +40 -78
- data/spec/support/db_helpers.rb +115 -0
- data/spec/support/fixtures.rb +304 -0
- data/spec/support/round_trip_helper.rb +70 -0
- data/spec/support/shared_examples/round_trip.rb +83 -0
- data/spec/system/large_dataset_spec.rb +163 -0
- data/spec/unit/tapsoob/chunksize_spec.rb +105 -0
- data/spec/unit/tapsoob/data_stream_spec.rb +220 -0
- data/spec/unit/tapsoob/operation_base_spec.rb +134 -0
- data/spec/unit/tapsoob/schema_spec.rb +102 -0
- data/spec/unit/tapsoob/utils_spec.rb +260 -0
- data/spec/unit/tapsoob/version_spec.rb +8 -0
- metadata +15 -3
- data/spec/lib/tapsoob/chunksize_spec.rb +0 -92
- data/spec/lib/tapsoob/version_spec.rb +0 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 73391a90a9fdd3a38326bd46c195a69f45bf81c205c571e6c077ccf098b5e642
|
|
4
|
+
data.tar.gz: 58ba24e0716fc604ecb03c1b218c0e43291c9dd852bf158323cf3313734393aa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: aba22a0a89f784891afcb6e3511cd264870b4668ba834a9b3e869f7019258c8cf467aa44bdc806e56fc06d70c97c216449fb9109933e5be1767a9d48e232b697
|
|
7
|
+
data.tar.gz: a27be0a57e4e931ff1d76f1f3ad77e0fb034053ca2abf88ab880e834b1c9cb33c09e97a9b42939d131409d29eb9c100027c4fde7a4f3161e54648b1893576bb5
|
data/.gitlab-ci.yml
CHANGED
|
@@ -7,10 +7,149 @@ default:
|
|
|
7
7
|
- script_failure
|
|
8
8
|
|
|
9
9
|
stages:
|
|
10
|
+
- test
|
|
10
11
|
- build
|
|
11
12
|
- publish
|
|
12
13
|
- cleanup
|
|
13
14
|
|
|
15
|
+
# ── shared test base ──────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
.test_base:
|
|
18
|
+
stage: test
|
|
19
|
+
tags:
|
|
20
|
+
- linux
|
|
21
|
+
- docker
|
|
22
|
+
variables:
|
|
23
|
+
INTEGRATION_TESTS: "1"
|
|
24
|
+
SYSTEM_TESTS: "1"
|
|
25
|
+
before_script:
|
|
26
|
+
- apt-get update -qq && apt-get install -y -qq git
|
|
27
|
+
- bundle install --quiet
|
|
28
|
+
artifacts:
|
|
29
|
+
when: always
|
|
30
|
+
reports:
|
|
31
|
+
junit: rspec.xml
|
|
32
|
+
paths:
|
|
33
|
+
- coverage/
|
|
34
|
+
expire_in: 7 days
|
|
35
|
+
|
|
36
|
+
# ── SQLite (no service container needed) ─────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
test-sqlite:
|
|
39
|
+
extends: .test_base
|
|
40
|
+
image: ruby:3.3
|
|
41
|
+
before_script:
|
|
42
|
+
- apt-get update -qq && apt-get install -y -qq git libsqlite3-dev
|
|
43
|
+
- bundle install --quiet
|
|
44
|
+
variables:
|
|
45
|
+
SRC_DATABASE_URL: "sqlite://tmp/tapsoob_src.db"
|
|
46
|
+
DST_DATABASE_URL: "sqlite://tmp/tapsoob_dst.db"
|
|
47
|
+
INTEGRATION_TESTS: "1"
|
|
48
|
+
SYSTEM_TESTS: "1"
|
|
49
|
+
script:
|
|
50
|
+
- mkdir -p tmp
|
|
51
|
+
- bundle exec rspec spec/unit spec/integration/sqlite_spec.rb spec/system/
|
|
52
|
+
--format progress
|
|
53
|
+
--format RspecJunitFormatter --out rspec.xml
|
|
54
|
+
|
|
55
|
+
# ── MySQL ─────────────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
test-mysql:
|
|
58
|
+
extends: .test_base
|
|
59
|
+
image: ruby:3.3
|
|
60
|
+
services:
|
|
61
|
+
- name: mysql:8.4
|
|
62
|
+
alias: mysql
|
|
63
|
+
variables:
|
|
64
|
+
MYSQL_ROOT_PASSWORD: "rootpassword"
|
|
65
|
+
MYSQL_DATABASE: mysql
|
|
66
|
+
# Prevent job-level MYSQL_USER/MYSQL_PASSWORD from leaking into the
|
|
67
|
+
# service container — MySQL 8.4 rejects MYSQL_USER=root at startup.
|
|
68
|
+
MYSQL_USER: ""
|
|
69
|
+
MYSQL_PASSWORD: ""
|
|
70
|
+
variables:
|
|
71
|
+
MYSQL_HOST: mysql
|
|
72
|
+
MYSQL_PORT: "3306"
|
|
73
|
+
MYSQL_USER: root
|
|
74
|
+
MYSQL_PASSWORD: rootpassword
|
|
75
|
+
SRC_DATABASE_URL: "mysql2://root:rootpassword@mysql/tapsoob_src"
|
|
76
|
+
DST_DATABASE_URL: "mysql2://root:rootpassword@mysql/tapsoob_dst"
|
|
77
|
+
before_script:
|
|
78
|
+
- apt-get update -qq && apt-get install -y -qq git default-libmysqlclient-dev default-mysql-client
|
|
79
|
+
- bundle install --quiet
|
|
80
|
+
# Wait for MySQL to be ready (mysqladmin is available from default-mysql-client)
|
|
81
|
+
- |
|
|
82
|
+
for i in $(seq 1 45); do
|
|
83
|
+
mysqladmin ping -h mysql -u root -prootpassword --skip-ssl --silent 2>/dev/null && break
|
|
84
|
+
echo "Waiting for MySQL ($i/45)..."
|
|
85
|
+
sleep 2
|
|
86
|
+
done
|
|
87
|
+
- mysql -h mysql -u root -prootpassword --skip-ssl -e "CREATE DATABASE IF NOT EXISTS tapsoob_src"
|
|
88
|
+
- mysql -h mysql -u root -prootpassword --skip-ssl -e "CREATE DATABASE IF NOT EXISTS tapsoob_dst"
|
|
89
|
+
script:
|
|
90
|
+
- bundle exec rspec spec/unit spec/integration/mysql_spec.rb
|
|
91
|
+
--format progress
|
|
92
|
+
--format RspecJunitFormatter --out rspec.xml
|
|
93
|
+
|
|
94
|
+
# ── PostgreSQL ────────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
test-postgres:
|
|
97
|
+
extends: .test_base
|
|
98
|
+
image: ruby:3.3
|
|
99
|
+
services:
|
|
100
|
+
- name: postgres:16-alpine
|
|
101
|
+
alias: postgres
|
|
102
|
+
variables:
|
|
103
|
+
POSTGRES_PASSWORD: postgres
|
|
104
|
+
POSTGRES_DB: postgres
|
|
105
|
+
variables:
|
|
106
|
+
POSTGRES_HOST: postgres
|
|
107
|
+
POSTGRES_PORT: "5432"
|
|
108
|
+
POSTGRES_USER: postgres
|
|
109
|
+
POSTGRES_PASSWORD: postgres
|
|
110
|
+
SRC_DATABASE_URL: "postgres://postgres:postgres@postgres/tapsoob_src"
|
|
111
|
+
DST_DATABASE_URL: "postgres://postgres:postgres@postgres/tapsoob_dst"
|
|
112
|
+
PGPASSWORD: postgres
|
|
113
|
+
before_script:
|
|
114
|
+
- apt-get update -qq && apt-get install -y -qq git libpq-dev postgresql-client
|
|
115
|
+
- bundle install --quiet
|
|
116
|
+
# Wait for Postgres to be ready
|
|
117
|
+
- |
|
|
118
|
+
for i in $(seq 1 30); do
|
|
119
|
+
pg_isready -h postgres -U postgres 2>/dev/null && break
|
|
120
|
+
echo "Waiting for PostgreSQL ($i/30)..."
|
|
121
|
+
sleep 2
|
|
122
|
+
done
|
|
123
|
+
- psql -h postgres -U postgres -c "CREATE DATABASE tapsoob_src" || true
|
|
124
|
+
- psql -h postgres -U postgres -c "CREATE DATABASE tapsoob_dst" || true
|
|
125
|
+
script:
|
|
126
|
+
- bundle exec rspec spec/unit spec/integration/postgres_spec.rb
|
|
127
|
+
--format progress
|
|
128
|
+
--format RspecJunitFormatter --out rspec.xml
|
|
129
|
+
|
|
130
|
+
# ── JRuby unit tests (no DB adapters, just unit layer) ───────────────────────
|
|
131
|
+
|
|
132
|
+
test-jruby:
|
|
133
|
+
stage: test
|
|
134
|
+
image: jruby:9.4.14.0
|
|
135
|
+
tags:
|
|
136
|
+
- linux
|
|
137
|
+
- docker
|
|
138
|
+
before_script:
|
|
139
|
+
- apt-get update -qq && apt-get install -y -qq git libsqlite3-dev
|
|
140
|
+
- bundle install --quiet
|
|
141
|
+
script:
|
|
142
|
+
- bundle exec rspec spec/unit/
|
|
143
|
+
--format progress
|
|
144
|
+
--format RspecJunitFormatter --out rspec.xml
|
|
145
|
+
artifacts:
|
|
146
|
+
when: always
|
|
147
|
+
reports:
|
|
148
|
+
junit: rspec.xml
|
|
149
|
+
expire_in: 7 days
|
|
150
|
+
|
|
151
|
+
# ── build ─────────────────────────────────────────────────────────────────────
|
|
152
|
+
|
|
14
153
|
build-jar:
|
|
15
154
|
stage: build
|
|
16
155
|
image: jruby:9.4.14.0
|
|
@@ -29,13 +168,13 @@ build-jar:
|
|
|
29
168
|
echo "Checking for existing packages with version ${VERSION}..."
|
|
30
169
|
PACKAGES=$(curl -s --header "JOB-TOKEN: $CI_JOB_TOKEN" \
|
|
31
170
|
"${GITLAB_INTERNAL_API}/projects/${CI_PROJECT_ID}/packages?package_name=tapsoob&per_page=100")
|
|
32
|
-
|
|
171
|
+
|
|
33
172
|
# Get package IDs matching this version
|
|
34
173
|
MATCHING_IDS=$(echo "$PACKAGES" | jq -r --arg v "$VERSION" '.[] | select(.version == $v) | .id')
|
|
35
174
|
COUNT=$(echo "$MATCHING_IDS" | grep -c . || true)
|
|
36
|
-
|
|
175
|
+
|
|
37
176
|
echo "Found $COUNT package(s) with version ${VERSION}"
|
|
38
|
-
|
|
177
|
+
|
|
39
178
|
if [ "$COUNT" -gt 1 ]; then
|
|
40
179
|
echo "Multiple packages found, cleaning up duplicates..."
|
|
41
180
|
# Keep the first one, delete the rest
|
|
@@ -51,12 +190,14 @@ build-jar:
|
|
|
51
190
|
echo "Package tapsoob-${VERSION}.jar already exists, skipping build"
|
|
52
191
|
exit 0
|
|
53
192
|
fi
|
|
54
|
-
|
|
193
|
+
|
|
55
194
|
echo "No existing package found, proceeding with build..."
|
|
56
195
|
- bundle install
|
|
57
196
|
- bundle exec rake jar
|
|
58
197
|
- 'curl --fail --header "JOB-TOKEN: $CI_JOB_TOKEN" --upload-file dist/tapsoob.jar "${GITLAB_INTERNAL_API}/projects/${CI_PROJECT_ID}/packages/generic/tapsoob/${VERSION}/tapsoob-${VERSION}.jar"'
|
|
59
198
|
|
|
199
|
+
# ── publish ───────────────────────────────────────────────────────────────────
|
|
200
|
+
|
|
60
201
|
publish-gem:
|
|
61
202
|
stage: publish
|
|
62
203
|
image: ruby:3.3
|
|
@@ -99,6 +240,8 @@ publish-gem-jruby:
|
|
|
99
240
|
rules:
|
|
100
241
|
- if: $CI_COMMIT_TAG
|
|
101
242
|
|
|
243
|
+
# ── cleanup ───────────────────────────────────────────────────────────────────
|
|
244
|
+
|
|
102
245
|
cleanup-packages:
|
|
103
246
|
stage: cleanup
|
|
104
247
|
image: alpine:latest
|
|
@@ -115,11 +258,11 @@ cleanup-packages:
|
|
|
115
258
|
echo "Fetching packages..."
|
|
116
259
|
PACKAGES=$(curl -s --header "JOB-TOKEN: $CI_JOB_TOKEN" \
|
|
117
260
|
"${GITLAB_INTERNAL_API}/projects/${CI_PROJECT_ID}/packages?per_page=100")
|
|
118
|
-
|
|
261
|
+
|
|
119
262
|
# Get latest nightly (8 hex chars = commit SHA)
|
|
120
263
|
LATEST_NIGHTLY=$(echo "$PACKAGES" | jq -r '[.[] | select(.version | test("^[0-9a-f]{8}$"))] | sort_by(.created_at) | reverse | .[0].version // empty')
|
|
121
264
|
echo "Latest nightly to keep: $LATEST_NIGHTLY"
|
|
122
|
-
|
|
265
|
+
|
|
123
266
|
# Process each package
|
|
124
267
|
echo "$PACKAGES" | jq -r '.[] | "\(.id) \(.version)"' | while read -r ID VERSION; do
|
|
125
268
|
if echo "$VERSION" | grep -qE '^[0-9a-f]{8}$'; then
|
|
@@ -133,4 +276,4 @@ cleanup-packages:
|
|
|
133
276
|
else
|
|
134
277
|
echo "Keeping tagged release: $VERSION"
|
|
135
278
|
fi
|
|
136
|
-
done
|
|
279
|
+
done
|
data/Gemfile
CHANGED
|
@@ -41,6 +41,9 @@ group :development do
|
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
group :test do
|
|
44
|
-
gem 'rspec',
|
|
45
|
-
gem 'simplecov',
|
|
44
|
+
gem 'rspec', '~> 3.13'
|
|
45
|
+
gem 'simplecov', '~> 0.22'
|
|
46
|
+
gem 'rspec_junit_formatter', '~> 0.6'
|
|
47
|
+
gem 'database_cleaner-sequel', '~> 2.0'
|
|
48
|
+
gem 'faker', '~> 3.4'
|
|
46
49
|
end
|
data/README.md
CHANGED
|
@@ -13,10 +13,19 @@ Tapsoob currently rely on the Sequel ORM (<http://sequel.rubyforge.org/>) so we
|
|
|
13
13
|
|
|
14
14
|
If you're using either Oracle or Oracle XE you will need some extra requirements. If you're using Ruby you'll need to have your ORACLE_HOME environnement variable set properly and the `ruby-oci8` gem installed. However if you're using jRuby you'll need to have the official Oracle JDBC driver (see here for more informations: <http://www.oracle.com/technetwork/articles/dsl/jruby-oracle11g-330825.html>) and it should be loaded prior to using Tapsoob otherwise you won't be able to connect the database.
|
|
15
15
|
|
|
16
|
+
## Recent fixes
|
|
17
|
+
|
|
18
|
+
* Fixed intra-table parallelization where sometimes size/offset where lazily instantiated leading to infinite data dumping.
|
|
19
|
+
* Fixed connection handling when piping to release and reconnect DB when loading to avoid RDBMS closing the connection early (mainly in MySQL/MariaDB).
|
|
20
|
+
|
|
16
21
|
|
|
17
22
|
## Recent changes
|
|
18
23
|
|
|
19
|
-
### 0.
|
|
24
|
+
### 0.8.x
|
|
25
|
+
|
|
26
|
+
* Introduced an extensive test suite to consolidate recent changes and fixes.
|
|
27
|
+
|
|
28
|
+
### 0.7.x
|
|
20
29
|
|
|
21
30
|
### Features
|
|
22
31
|
|
|
@@ -93,11 +102,6 @@ It defaults to a single thread as per pre 0.6.1, it is also appliable to `tapsoo
|
|
|
93
102
|
Your exports can be moved from one machine to another for backups or replication, you can also use Tapsoob to switch your RDBMS from one of the supported system to another.
|
|
94
103
|
|
|
95
104
|
|
|
96
|
-
## ToDo
|
|
97
|
-
|
|
98
|
-
* Tests (in progress)
|
|
99
|
-
|
|
100
|
-
|
|
101
105
|
## Contributors
|
|
102
106
|
|
|
103
107
|
* Félix Bellanger <felix.bellanger@gmail.com>
|
|
@@ -107,7 +111,7 @@ Your exports can be moved from one machine to another for backups or replication
|
|
|
107
111
|
## License
|
|
108
112
|
|
|
109
113
|
The MIT License (MIT)
|
|
110
|
-
Copyright ©
|
|
114
|
+
Copyright © 2026 Félix Bellanger <felix.bellanger@gmail.com>
|
|
111
115
|
|
|
112
116
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
113
117
|
|
|
@@ -55,15 +55,11 @@ module Tapsoob
|
|
|
55
55
|
return tables if table_filter.empty? && exclude_tables.empty?
|
|
56
56
|
|
|
57
57
|
if tables.kind_of?(Hash)
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if !exclude_tables.include?(t.to_s) && (!table_filter.empty? && table_filter.include?(t.to_s))
|
|
61
|
-
ntables[t] = d
|
|
62
|
-
end
|
|
63
|
-
end
|
|
64
|
-
ntables
|
|
58
|
+
tables.reject { |t, _| exclude_tables.include?(t.to_s) }
|
|
59
|
+
.select { |t, _| table_filter.empty? || table_filter.include?(t.to_s) }
|
|
65
60
|
else
|
|
66
|
-
tables.reject { |t| exclude_tables.include?(t.to_s) }
|
|
61
|
+
tables.reject { |t| exclude_tables.include?(t.to_s) }
|
|
62
|
+
.select { |t| table_filter.empty? || table_filter.include?(t.to_s) }
|
|
67
63
|
end
|
|
68
64
|
end
|
|
69
65
|
|
|
@@ -44,9 +44,12 @@ module Tapsoob
|
|
|
44
44
|
Tapsoob::ProgressEvent.schema_start(tables.size)
|
|
45
45
|
|
|
46
46
|
progress = opts[:progress] ? Tapsoob::Progress::Bar.new('Schema', tables.size) : nil
|
|
47
|
+
mysql_db = [:mysql, :mysql2].include?(db.database_type)
|
|
47
48
|
tables.each do |table_name, count|
|
|
48
49
|
# Reuse existing db connection for better performance
|
|
49
|
-
|
|
50
|
+
dump_opts = @opts.slice(:indexes, :same_db)
|
|
51
|
+
dump_opts[:same_db] = true if mysql_db && !dump_opts.key?(:same_db)
|
|
52
|
+
schema_data = Tapsoob::Schema.dump_table(db, table_name, dump_opts)
|
|
50
53
|
log.debug "Table: #{table_name}\n#{schema_data}\n"
|
|
51
54
|
output = Tapsoob::Utils.export_schema(dump_path, table_name, schema_data)
|
|
52
55
|
puts output if dump_path.nil? && output
|
data/lib/tapsoob/utils.rb
CHANGED
|
@@ -39,7 +39,7 @@ module Tapsoob
|
|
|
39
39
|
|
|
40
40
|
def format_data(db, data, opts = {})
|
|
41
41
|
return {} if data.size == 0
|
|
42
|
-
string_columns = opts[:string_columns] ||
|
|
42
|
+
string_columns = opts[:string_columns] || []
|
|
43
43
|
schema = opts[:schema] || []
|
|
44
44
|
table = opts[:table]
|
|
45
45
|
|
|
@@ -209,11 +209,11 @@ Data : #{data}
|
|
|
209
209
|
|
|
210
210
|
def order_by(db, table)
|
|
211
211
|
pkey = primary_key(db, table)
|
|
212
|
-
if pkey
|
|
212
|
+
if pkey && !pkey.empty?
|
|
213
213
|
pkey.kind_of?(Array) ? pkey : [pkey.to_sym]
|
|
214
214
|
else
|
|
215
215
|
table = table.to_sym unless table.kind_of?(Sequel::SQL::Identifier)
|
|
216
|
-
db
|
|
216
|
+
db.schema(table).map(&:first)
|
|
217
217
|
end
|
|
218
218
|
end
|
|
219
219
|
end
|
data/lib/tapsoob/version.rb
CHANGED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
# MySQL integration suite.
|
|
4
|
+
# In CI: supplied by the mysql service container via MYSQL_* env vars.
|
|
5
|
+
# Locally:
|
|
6
|
+
# SRC_DATABASE_URL=mysql2://root:root@127.0.0.1/tapsoob_src \
|
|
7
|
+
# DST_DATABASE_URL=mysql2://root:root@127.0.0.1/tapsoob_dst \
|
|
8
|
+
# bundle exec rspec spec/integration/mysql_spec.rb
|
|
9
|
+
|
|
10
|
+
def mysql_available?
|
|
11
|
+
host = ENV.fetch('MYSQL_HOST', 'mysql')
|
|
12
|
+
port = ENV.fetch('MYSQL_PORT', '3306')
|
|
13
|
+
user = ENV.fetch('MYSQL_USER', 'root')
|
|
14
|
+
password = ENV.fetch('MYSQL_PASSWORD', 'root')
|
|
15
|
+
Sequel.connect("mysql2://#{user}:#{password}@#{host}:#{port}/mysql").disconnect
|
|
16
|
+
true
|
|
17
|
+
rescue StandardError
|
|
18
|
+
false
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
RSpec.describe 'MySQL round-trip', :integration do
|
|
22
|
+
before(:all) do
|
|
23
|
+
skip 'MySQL not available' unless mysql_available?
|
|
24
|
+
|
|
25
|
+
host = ENV.fetch('MYSQL_HOST', 'mysql')
|
|
26
|
+
port = ENV.fetch('MYSQL_PORT', '3306')
|
|
27
|
+
user = ENV.fetch('MYSQL_USER', 'root')
|
|
28
|
+
password = ENV.fetch('MYSQL_PASSWORD', 'root')
|
|
29
|
+
|
|
30
|
+
Sequel.connect("mysql2://#{user}:#{password}@#{host}:#{port}/mysql") do |db|
|
|
31
|
+
db.run("CREATE DATABASE IF NOT EXISTS tapsoob_src")
|
|
32
|
+
db.run("CREATE DATABASE IF NOT EXISTS tapsoob_dst")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
@src_url = DbHelpers.adapt_url(
|
|
36
|
+
ENV.fetch('SRC_DATABASE_URL', "mysql2://#{user}:#{password}@#{host}:#{port}/tapsoob_src"))
|
|
37
|
+
@dst_url = DbHelpers.adapt_url(
|
|
38
|
+
ENV.fetch('DST_DATABASE_URL', "mysql2://#{user}:#{password}@#{host}:#{port}/tapsoob_dst"))
|
|
39
|
+
|
|
40
|
+
@src_db = DbHelpers.connect(@src_url)
|
|
41
|
+
@dst_db = DbHelpers.connect(@dst_url)
|
|
42
|
+
|
|
43
|
+
Fixtures.create_tables(@src_db)
|
|
44
|
+
Fixtures.seed(@src_db)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
before(:each) do
|
|
48
|
+
Fixtures.drop_tables(@dst_db) if @dst_db
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
after(:all) do
|
|
52
|
+
Fixtures.drop_tables(@src_db) if @src_db
|
|
53
|
+
Fixtures.drop_tables(@dst_db) if @dst_db
|
|
54
|
+
DbHelpers.disconnect_all
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
include_examples 'a complete round-trip'
|
|
58
|
+
include_examples 'a parallel round-trip', workers: 2
|
|
59
|
+
include_examples 'a parallel round-trip', workers: 4
|
|
60
|
+
|
|
61
|
+
context 'with SET foreign_key_checks behavior' do
|
|
62
|
+
it 'loads schema with FK relationships intact' do
|
|
63
|
+
pull(src_url, dump_dir)
|
|
64
|
+
push(dst_url, dump_dir)
|
|
65
|
+
expect(dst_db.table_exists?(:orders)).to be true
|
|
66
|
+
expect(dst_db.table_exists?(:users)).to be true
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
context 'with large BLOB payloads' do
|
|
71
|
+
let(:blob_dir) { Dir.mktmpdir }
|
|
72
|
+
after { FileUtils.rm_rf(blob_dir) }
|
|
73
|
+
|
|
74
|
+
it 'transfers all attachment blobs correctly' do
|
|
75
|
+
pull(src_url, blob_dir)
|
|
76
|
+
push(dst_url, blob_dir)
|
|
77
|
+
src_db[:attachments].order(:id).each do |src_row|
|
|
78
|
+
dst_row = dst_db[:attachments][id: src_row[:id]]
|
|
79
|
+
expect(dst_row[:payload].to_s.bytes).to eq(src_row[:payload].to_s.bytes)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
context 'with invalid date handling' do
|
|
85
|
+
it 'tolerates 0000-00-00 MySQL dates without raising' do
|
|
86
|
+
expect { round_trip(src_url, dst_url, dump_dir) }.not_to raise_error
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
# PostgreSQL integration suite.
|
|
4
|
+
# In CI: supplied by the postgres service container via POSTGRES_* env vars.
|
|
5
|
+
# Locally:
|
|
6
|
+
# SRC_DATABASE_URL=postgres://postgres:postgres@127.0.0.1/tapsoob_src \
|
|
7
|
+
# DST_DATABASE_URL=postgres://postgres:postgres@127.0.0.1/tapsoob_dst \
|
|
8
|
+
# bundle exec rspec spec/integration/postgres_spec.rb
|
|
9
|
+
|
|
10
|
+
def postgres_available?
|
|
11
|
+
host = ENV.fetch('POSTGRES_HOST', 'postgres')
|
|
12
|
+
port = ENV.fetch('POSTGRES_PORT', '5432')
|
|
13
|
+
user = ENV.fetch('POSTGRES_USER', 'postgres')
|
|
14
|
+
password = ENV.fetch('POSTGRES_PASSWORD', 'postgres')
|
|
15
|
+
Sequel.connect("postgres://#{user}:#{password}@#{host}:#{port}/postgres").disconnect
|
|
16
|
+
true
|
|
17
|
+
rescue StandardError
|
|
18
|
+
false
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
RSpec.describe 'PostgreSQL round-trip', :integration do
|
|
22
|
+
before(:all) do
|
|
23
|
+
skip 'PostgreSQL not available' unless postgres_available?
|
|
24
|
+
|
|
25
|
+
host = ENV.fetch('POSTGRES_HOST', 'postgres')
|
|
26
|
+
port = ENV.fetch('POSTGRES_PORT', '5432')
|
|
27
|
+
user = ENV.fetch('POSTGRES_USER', 'postgres')
|
|
28
|
+
password = ENV.fetch('POSTGRES_PASSWORD', 'postgres')
|
|
29
|
+
|
|
30
|
+
Sequel.connect("postgres://#{user}:#{password}@#{host}:#{port}/postgres") do |db|
|
|
31
|
+
['tapsoob_src', 'tapsoob_dst'].each do |dbname|
|
|
32
|
+
db.run("DROP DATABASE IF EXISTS #{dbname}")
|
|
33
|
+
db.run("CREATE DATABASE #{dbname}")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
@src_url = DbHelpers.adapt_url(
|
|
38
|
+
ENV.fetch('SRC_DATABASE_URL', "postgres://#{user}:#{password}@#{host}:#{port}/tapsoob_src"))
|
|
39
|
+
@dst_url = DbHelpers.adapt_url(
|
|
40
|
+
ENV.fetch('DST_DATABASE_URL', "postgres://#{user}:#{password}@#{host}:#{port}/tapsoob_dst"))
|
|
41
|
+
|
|
42
|
+
@src_db = DbHelpers.connect(@src_url)
|
|
43
|
+
@dst_db = DbHelpers.connect(@dst_url)
|
|
44
|
+
|
|
45
|
+
Fixtures.create_tables(@src_db)
|
|
46
|
+
Fixtures.seed(@src_db)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
before(:each) do
|
|
50
|
+
Fixtures.drop_tables(@dst_db) if @dst_db
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
after(:all) do
|
|
54
|
+
Fixtures.drop_tables(@src_db) if @src_db
|
|
55
|
+
Fixtures.drop_tables(@dst_db) if @dst_db
|
|
56
|
+
DbHelpers.disconnect_all
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
include_examples 'a complete round-trip'
|
|
60
|
+
include_examples 'a parallel round-trip', workers: 2
|
|
61
|
+
include_examples 'a parallel round-trip', workers: 4
|
|
62
|
+
|
|
63
|
+
context 'with sequence reset' do
|
|
64
|
+
it 'sequences are reset after push so inserts work' do
|
|
65
|
+
round_trip(src_url, dst_url, dump_dir)
|
|
66
|
+
expect {
|
|
67
|
+
dst_db[:users].insert(
|
|
68
|
+
name: 'PostPush User',
|
|
69
|
+
email: "postpush_#{rand(9999)}@example.com",
|
|
70
|
+
created_at: Time.now.strftime('%Y-%m-%d %H:%M:%S'),
|
|
71
|
+
updated_at: Time.now.strftime('%Y-%m-%d %H:%M:%S')
|
|
72
|
+
)
|
|
73
|
+
}.not_to raise_error
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
context 'with bytea BLOB columns' do
|
|
78
|
+
it 'transfers bytea payloads with correct byte content' do
|
|
79
|
+
round_trip(src_url, dst_url, dump_dir)
|
|
80
|
+
src_db[:attachments].order(:id).each do |src_row|
|
|
81
|
+
dst_row = dst_db[:attachments][id: src_row[:id]]
|
|
82
|
+
expect(dst_row[:payload].to_s.bytes).to eq(src_row[:payload].to_s.bytes)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
context 'with --indexes-first' do
|
|
88
|
+
let(:idx_dir) { Dir.mktmpdir }
|
|
89
|
+
after { FileUtils.rm_rf(idx_dir) }
|
|
90
|
+
|
|
91
|
+
it 'creates indexes before loading data without error' do
|
|
92
|
+
pull(src_url, idx_dir, indexes_first: true)
|
|
93
|
+
expect { push(dst_url, idx_dir, indexes_first: true) }.not_to raise_error
|
|
94
|
+
expect_same_counts(src_db, dst_db)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
RSpec.describe 'SQLite round-trip', :integration do
|
|
4
|
+
before(:all) do
|
|
5
|
+
@src_url = DbHelpers.adapt_url('sqlite://tmp/tapsoob_sqlite_src.db')
|
|
6
|
+
@dst_url = DbHelpers.adapt_url('sqlite://tmp/tapsoob_sqlite_dst.db')
|
|
7
|
+
|
|
8
|
+
FileUtils.mkdir_p('tmp')
|
|
9
|
+
File.delete('tmp/tapsoob_sqlite_src.db') rescue nil
|
|
10
|
+
File.delete('tmp/tapsoob_sqlite_dst.db') rescue nil
|
|
11
|
+
|
|
12
|
+
@src_db = DbHelpers.connect(@src_url)
|
|
13
|
+
@dst_db = DbHelpers.connect(@dst_url)
|
|
14
|
+
|
|
15
|
+
Fixtures.create_tables(@src_db)
|
|
16
|
+
Fixtures.seed(@src_db)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
before(:each) do
|
|
20
|
+
Fixtures.drop_tables(@dst_db)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
after(:all) do
|
|
24
|
+
Fixtures.drop_tables(@src_db)
|
|
25
|
+
Fixtures.drop_tables(@dst_db)
|
|
26
|
+
DbHelpers.disconnect_all
|
|
27
|
+
File.delete('tmp/tapsoob_sqlite_src.db') rescue nil
|
|
28
|
+
File.delete('tmp/tapsoob_sqlite_dst.db') rescue nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
include_examples 'a complete round-trip'
|
|
32
|
+
|
|
33
|
+
# ── SQLite-specific edge cases ───────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
context 'with --discard-identity' do
|
|
36
|
+
let(:discard_dir) { Dir.mktmpdir }
|
|
37
|
+
after { FileUtils.rm_rf(discard_dir) }
|
|
38
|
+
|
|
39
|
+
it 'inserts rows without the id column' do
|
|
40
|
+
pull(src_url, discard_dir, tables: ['users'])
|
|
41
|
+
|
|
42
|
+
dst_db.drop_table(:orders, if_exists: true)
|
|
43
|
+
dst_db.drop_table(:users, if_exists: true)
|
|
44
|
+
dst_db.create_table(:users) do
|
|
45
|
+
primary_key :id
|
|
46
|
+
String :name, size: 100
|
|
47
|
+
String :email, size: 255
|
|
48
|
+
String :locale, size: 10
|
|
49
|
+
Integer :age
|
|
50
|
+
Date :birthday
|
|
51
|
+
DateTime :created_at
|
|
52
|
+
DateTime :updated_at
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
push(dst_url, discard_dir, :"discard-identity" => true, schema: false)
|
|
56
|
+
expect(dst_db[:users].count).to eq(src_db[:users].count)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
context 'with --tables filter' do
|
|
61
|
+
let(:filtered_dir) { Dir.mktmpdir }
|
|
62
|
+
after { FileUtils.rm_rf(filtered_dir) }
|
|
63
|
+
|
|
64
|
+
it 'only pulls the specified tables' do
|
|
65
|
+
pull(src_url, filtered_dir, tables: ['users', 'orders'])
|
|
66
|
+
schema_files = Dir.glob(File.join(filtered_dir, 'schemas', '*.rb'))
|
|
67
|
+
.map { |f| File.basename(f, '.rb') }
|
|
68
|
+
expect(schema_files).to match_array(%w[users orders])
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
context 'with --exclude-tables' do
|
|
73
|
+
let(:excl_dir) { Dir.mktmpdir }
|
|
74
|
+
after { FileUtils.rm_rf(excl_dir) }
|
|
75
|
+
|
|
76
|
+
it 'excludes specified tables from the pull' do
|
|
77
|
+
pull(src_url, excl_dir, exclude_tables: ['large_table'])
|
|
78
|
+
expect(File).not_to exist(File.join(excl_dir, 'schemas', 'large_table.rb'))
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
context 'with custom chunksize' do
|
|
83
|
+
let(:chunk_dir) { Dir.mktmpdir }
|
|
84
|
+
after { FileUtils.rm_rf(chunk_dir) }
|
|
85
|
+
|
|
86
|
+
[10, 50, 500, 5000].each do |cs|
|
|
87
|
+
it "round-trips with chunksize #{cs}" do
|
|
88
|
+
round_trip(src_url, dst_url, chunk_dir, default_chunksize: cs)
|
|
89
|
+
expect(dst_db[:users].count).to eq(src_db[:users].count)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
context 'with empty tables' do
|
|
95
|
+
let(:empty_dir) { Dir.mktmpdir }
|
|
96
|
+
after { FileUtils.rm_rf(empty_dir) }
|
|
97
|
+
|
|
98
|
+
it 'handles a completely empty table gracefully' do
|
|
99
|
+
empty_src_url = DbHelpers.adapt_url('sqlite://tmp/tapsoob_sqlite_empty.db')
|
|
100
|
+
empty_dst_url = DbHelpers.adapt_url('sqlite://tmp/tapsoob_sqlite_empty_dst.db')
|
|
101
|
+
empty_src_db = DbHelpers.connect(empty_src_url)
|
|
102
|
+
empty_dst_db = DbHelpers.connect(empty_dst_url)
|
|
103
|
+
|
|
104
|
+
empty_src_db.create_table!(:empty_table) { primary_key :id; String :name, size: 50 }
|
|
105
|
+
|
|
106
|
+
round_trip(empty_src_url, empty_dst_url, empty_dir)
|
|
107
|
+
|
|
108
|
+
expect(empty_dst_db.table_exists?(:empty_table)).to be true
|
|
109
|
+
expect(empty_dst_db[:empty_table].count).to eq(0)
|
|
110
|
+
ensure
|
|
111
|
+
DbHelpers.disconnect_all
|
|
112
|
+
File.delete('tmp/tapsoob_sqlite_empty.db') rescue nil
|
|
113
|
+
File.delete('tmp/tapsoob_sqlite_empty_dst.db') rescue nil
|
|
114
|
+
# Reconnect suite DBs after disconnect_all
|
|
115
|
+
@src_db = DbHelpers.connect(@src_url)
|
|
116
|
+
@dst_db = DbHelpers.connect(@dst_url)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|