tapsoob 0.7.17 → 0.8.1

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: 6e0df11d8a5cbaca53aab795175f2addae2e0c08c39f3955e4dad7f1e08ff436
4
+ data.tar.gz: 991a74bb27b76dd013966c70590b953d8854c1982a7070ab05ec200b92ef3d30
5
5
  SHA512:
6
- metadata.gz: ad5f2ffbdd87cda4d8d1e9a14ace5bef12eeed9e53c8fa63514f05904a2b927515997834b7f2eb1fe88fe87506ee63a3d0e7694d83f26e1e59377615b41aecc3
7
- data.tar.gz: 9313cf38034bdb995e022cd52b429f1a60d8c6d4a68ab446f72bd8f3b048521e87a8b7a8ab477603a0102912ad8c0991e2ad45b8d472e987f051afd33ba95fed
6
+ metadata.gz: ee3899b17e99f919977cc2b77af18324c31cb9a7658f4a38d16f97d1cf8e2f2b1bdcd96e39b58e1545e273a22da52b96958b9c4246e1dd84739be18f427d380e
7
+ data.tar.gz: d52a2ba30e681ccb522f5ea8b5c05114b44d09ce1e378afc509436edd51ba49f428262294b0b3207eb5aa4d6f47db138fde6cc1ce3886ede1b0852650ade1f1a
data/.gitlab-ci.yml CHANGED
@@ -7,10 +7,157 @@ 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
+ rules:
23
+ - if: $CI_COMMIT_TAG
24
+ when: never
25
+ - when: on_success
26
+ variables:
27
+ INTEGRATION_TESTS: "1"
28
+ SYSTEM_TESTS: "1"
29
+ before_script:
30
+ - apt-get update -qq && apt-get install -y -qq git
31
+ - bundle install --quiet
32
+ artifacts:
33
+ when: always
34
+ reports:
35
+ junit: rspec.xml
36
+ paths:
37
+ - coverage/
38
+ expire_in: 7 days
39
+
40
+ # ── SQLite (no service container needed) ─────────────────────────────────────
41
+
42
+ test-sqlite:
43
+ extends: .test_base
44
+ image: ruby:3.3
45
+ before_script:
46
+ - apt-get update -qq && apt-get install -y -qq git libsqlite3-dev
47
+ - bundle install --quiet
48
+ variables:
49
+ SRC_DATABASE_URL: "sqlite://tmp/tapsoob_src.db"
50
+ DST_DATABASE_URL: "sqlite://tmp/tapsoob_dst.db"
51
+ INTEGRATION_TESTS: "1"
52
+ SYSTEM_TESTS: "1"
53
+ script:
54
+ - mkdir -p tmp
55
+ - bundle exec rspec spec/unit spec/integration/sqlite_spec.rb spec/system/
56
+ --format progress
57
+ --format RspecJunitFormatter --out rspec.xml
58
+
59
+ # ── MySQL ─────────────────────────────────────────────────────────────────────
60
+
61
+ test-mysql:
62
+ extends: .test_base
63
+ image: ruby:3.3
64
+ services:
65
+ - name: mysql:8.4
66
+ alias: mysql
67
+ variables:
68
+ MYSQL_ROOT_PASSWORD: "rootpassword"
69
+ MYSQL_DATABASE: mysql
70
+ # Prevent job-level MYSQL_USER/MYSQL_PASSWORD from leaking into the
71
+ # service container — MySQL 8.4 rejects MYSQL_USER=root at startup.
72
+ MYSQL_USER: ""
73
+ MYSQL_PASSWORD: ""
74
+ variables:
75
+ MYSQL_HOST: mysql
76
+ MYSQL_PORT: "3306"
77
+ MYSQL_USER: root
78
+ MYSQL_PASSWORD: rootpassword
79
+ SRC_DATABASE_URL: "mysql2://root:rootpassword@mysql/tapsoob_src"
80
+ DST_DATABASE_URL: "mysql2://root:rootpassword@mysql/tapsoob_dst"
81
+ before_script:
82
+ - apt-get update -qq && apt-get install -y -qq git default-libmysqlclient-dev default-mysql-client
83
+ - bundle install --quiet
84
+ # Wait for MySQL to be ready (mysqladmin is available from default-mysql-client)
85
+ - |
86
+ for i in $(seq 1 45); do
87
+ mysqladmin ping -h mysql -u root -prootpassword --skip-ssl --silent 2>/dev/null && break
88
+ echo "Waiting for MySQL ($i/45)..."
89
+ sleep 2
90
+ done
91
+ - mysql -h mysql -u root -prootpassword --skip-ssl -e "CREATE DATABASE IF NOT EXISTS tapsoob_src"
92
+ - mysql -h mysql -u root -prootpassword --skip-ssl -e "CREATE DATABASE IF NOT EXISTS tapsoob_dst"
93
+ script:
94
+ - bundle exec rspec spec/unit spec/integration/mysql_spec.rb
95
+ --format progress
96
+ --format RspecJunitFormatter --out rspec.xml
97
+
98
+ # ── PostgreSQL ────────────────────────────────────────────────────────────────
99
+
100
+ test-postgres:
101
+ extends: .test_base
102
+ image: ruby:3.3
103
+ services:
104
+ - name: postgres:16-alpine
105
+ alias: postgres
106
+ variables:
107
+ POSTGRES_PASSWORD: postgres
108
+ POSTGRES_DB: postgres
109
+ variables:
110
+ POSTGRES_HOST: postgres
111
+ POSTGRES_PORT: "5432"
112
+ POSTGRES_USER: postgres
113
+ POSTGRES_PASSWORD: postgres
114
+ SRC_DATABASE_URL: "postgres://postgres:postgres@postgres/tapsoob_src"
115
+ DST_DATABASE_URL: "postgres://postgres:postgres@postgres/tapsoob_dst"
116
+ PGPASSWORD: postgres
117
+ before_script:
118
+ - apt-get update -qq && apt-get install -y -qq git libpq-dev postgresql-client
119
+ - bundle install --quiet
120
+ # Wait for Postgres to be ready
121
+ - |
122
+ for i in $(seq 1 30); do
123
+ pg_isready -h postgres -U postgres 2>/dev/null && break
124
+ echo "Waiting for PostgreSQL ($i/30)..."
125
+ sleep 2
126
+ done
127
+ - psql -h postgres -U postgres -c "CREATE DATABASE tapsoob_src" || true
128
+ - psql -h postgres -U postgres -c "CREATE DATABASE tapsoob_dst" || true
129
+ script:
130
+ - bundle exec rspec spec/unit spec/integration/postgres_spec.rb
131
+ --format progress
132
+ --format RspecJunitFormatter --out rspec.xml
133
+
134
+ # ── JRuby unit tests (no DB adapters, just unit layer) ───────────────────────
135
+
136
+ test-jruby:
137
+ stage: test
138
+ image: jruby:9.4.14.0
139
+ tags:
140
+ - linux
141
+ - docker
142
+ rules:
143
+ - if: $CI_COMMIT_TAG
144
+ when: never
145
+ - when: on_success
146
+ before_script:
147
+ - apt-get update -qq && apt-get install -y -qq git libsqlite3-dev
148
+ - bundle install --quiet
149
+ script:
150
+ - bundle exec rspec spec/unit/
151
+ --format progress
152
+ --format RspecJunitFormatter --out rspec.xml
153
+ artifacts:
154
+ when: always
155
+ reports:
156
+ junit: rspec.xml
157
+ expire_in: 7 days
158
+
159
+ # ── build ─────────────────────────────────────────────────────────────────────
160
+
14
161
  build-jar:
15
162
  stage: build
16
163
  image: jruby:9.4.14.0
@@ -29,13 +176,13 @@ build-jar:
29
176
  echo "Checking for existing packages with version ${VERSION}..."
30
177
  PACKAGES=$(curl -s --header "JOB-TOKEN: $CI_JOB_TOKEN" \
31
178
  "${GITLAB_INTERNAL_API}/projects/${CI_PROJECT_ID}/packages?package_name=tapsoob&per_page=100")
32
-
179
+
33
180
  # Get package IDs matching this version
34
181
  MATCHING_IDS=$(echo "$PACKAGES" | jq -r --arg v "$VERSION" '.[] | select(.version == $v) | .id')
35
182
  COUNT=$(echo "$MATCHING_IDS" | grep -c . || true)
36
-
183
+
37
184
  echo "Found $COUNT package(s) with version ${VERSION}"
38
-
185
+
39
186
  if [ "$COUNT" -gt 1 ]; then
40
187
  echo "Multiple packages found, cleaning up duplicates..."
41
188
  # Keep the first one, delete the rest
@@ -51,12 +198,14 @@ build-jar:
51
198
  echo "Package tapsoob-${VERSION}.jar already exists, skipping build"
52
199
  exit 0
53
200
  fi
54
-
201
+
55
202
  echo "No existing package found, proceeding with build..."
56
203
  - bundle install
57
204
  - bundle exec rake jar
58
205
  - '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
206
 
207
+ # ── publish ───────────────────────────────────────────────────────────────────
208
+
60
209
  publish-gem:
61
210
  stage: publish
62
211
  image: ruby:3.3
@@ -99,6 +248,8 @@ publish-gem-jruby:
99
248
  rules:
100
249
  - if: $CI_COMMIT_TAG
101
250
 
251
+ # ── cleanup ───────────────────────────────────────────────────────────────────
252
+
102
253
  cleanup-packages:
103
254
  stage: cleanup
104
255
  image: alpine:latest
@@ -115,11 +266,11 @@ cleanup-packages:
115
266
  echo "Fetching packages..."
116
267
  PACKAGES=$(curl -s --header "JOB-TOKEN: $CI_JOB_TOKEN" \
117
268
  "${GITLAB_INTERNAL_API}/projects/${CI_PROJECT_ID}/packages?per_page=100")
118
-
269
+
119
270
  # Get latest nightly (8 hex chars = commit SHA)
120
271
  LATEST_NIGHTLY=$(echo "$PACKAGES" | jq -r '[.[] | select(.version | test("^[0-9a-f]{8}$"))] | sort_by(.created_at) | reverse | .[0].version // empty')
121
272
  echo "Latest nightly to keep: $LATEST_NIGHTLY"
122
-
273
+
123
274
  # Process each package
124
275
  echo "$PACKAGES" | jq -r '.[] | "\(.id) \(.version)"' | while read -r ID VERSION; do
125
276
  if echo "$VERSION" | grep -qE '^[0-9a-f]{8}$'; then
@@ -133,4 +284,4 @@ cleanup-packages:
133
284
  else
134
285
  echo "Keeping tagged release: $VERSION"
135
286
  fi
136
- done
287
+ 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.1".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