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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dcea47fb91d56ea013c431964b391bc378b2a92322bbb0964797b074f54526fd
4
- data.tar.gz: 8e5ffb87171c260906da3188ec9e9bd9ee6d751236b73d5be48048e288a76edf
3
+ metadata.gz: 73391a90a9fdd3a38326bd46c195a69f45bf81c205c571e6c077ccf098b5e642
4
+ data.tar.gz: 58ba24e0716fc604ecb03c1b218c0e43291c9dd852bf158323cf3313734393aa
5
5
  SHA512:
6
- metadata.gz: ad5f2ffbdd87cda4d8d1e9a14ace5bef12eeed9e53c8fa63514f05904a2b927515997834b7f2eb1fe88fe87506ee63a3d0e7694d83f26e1e59377615b41aecc3
7
- data.tar.gz: 9313cf38034bdb995e022cd52b429f1a60d8c6d4a68ab446f72bd8f3b048521e87a8b7a8ab477603a0102912ad8c0991e2ad45b8d472e987f051afd33ba95fed
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', '~> 3.2.0'
45
- gem 'simplecov', '~> 0.9.2'
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.7.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 © 2015 Félix Bellanger <felix.bellanger@gmail.com>
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
- ntables = {}
59
- tables.each do |t, d|
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) }.select { |t| table_filter.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
- schema_data = Tapsoob::Schema.dump_table(db, table_name, @opts.slice(:indexes, :same_db))
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[table].columns
216
+ db.schema(table).map(&:first)
217
217
  end
218
218
  end
219
219
  end
@@ -1,4 +1,4 @@
1
1
  # -*- encoding : utf-8 -*-
2
2
  module Tapsoob
3
- VERSION = "0.7.17".freeze
3
+ VERSION = "0.8.0".freeze
4
4
  end
@@ -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