tapsoob 0.8.4 → 0.8.6
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 +5 -3
- data/lib/tapsoob/schema.rb +46 -19
- data/lib/tapsoob/version.rb +1 -1
- data/spec/integration/postgres_spec.rb +27 -4
- data/spec/spec_helper.rb +2 -5
- data/spec/support/fixtures.rb +1 -1
- data/spec/support/shared_examples/round_trip.rb +35 -21
- metadata +1 -2
- data/spec/system/large_dataset_spec.rb +0 -163
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dd37614b588e363ef7ae5fefdbc98188a194045f72906d496bef4ef67df04bf7
|
|
4
|
+
data.tar.gz: 9270a820fdd7600f943a6343c208d8752618f40fe29d6a81c3af32051f4475ee
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1ac36335eca4bc18bff412ab892b05a6e13322f34debdf6a1decf40e95128066d86b9b598edc43e150e1414291aa883d5f93f8ba10695b1b1d7fa02480d620ad
|
|
7
|
+
data.tar.gz: 63bbb2832e9768287cc3e9189e7cbd919cf73b9707968ed8de4f9442ea2ab833216bcedcc043ebbd761f79a2f0ccab5fc60706efabfb9fa3f57abc8fb7422738
|
data/.gitlab-ci.yml
CHANGED
|
@@ -22,10 +22,11 @@ stages:
|
|
|
22
22
|
rules:
|
|
23
23
|
- if: $CI_COMMIT_TAG
|
|
24
24
|
when: never
|
|
25
|
+
- if: $CI_COMMIT_MESSAGE =~ /Bumped version/
|
|
26
|
+
when: never
|
|
25
27
|
- when: on_success
|
|
26
28
|
variables:
|
|
27
29
|
INTEGRATION_TESTS: "1"
|
|
28
|
-
SYSTEM_TESTS: "1"
|
|
29
30
|
before_script:
|
|
30
31
|
- apt-get update -qq && apt-get install -y -qq git
|
|
31
32
|
- bundle install --quiet
|
|
@@ -49,10 +50,9 @@ test-sqlite:
|
|
|
49
50
|
SRC_DATABASE_URL: "sqlite://tmp/tapsoob_src.db"
|
|
50
51
|
DST_DATABASE_URL: "sqlite://tmp/tapsoob_dst.db"
|
|
51
52
|
INTEGRATION_TESTS: "1"
|
|
52
|
-
SYSTEM_TESTS: "1"
|
|
53
53
|
script:
|
|
54
54
|
- mkdir -p tmp
|
|
55
|
-
- bundle exec rspec spec/unit spec/integration/sqlite_spec.rb
|
|
55
|
+
- bundle exec rspec spec/unit spec/integration/sqlite_spec.rb
|
|
56
56
|
--format progress
|
|
57
57
|
--format RspecJunitFormatter --out rspec.xml
|
|
58
58
|
|
|
@@ -142,6 +142,8 @@ test-jruby:
|
|
|
142
142
|
rules:
|
|
143
143
|
- if: $CI_COMMIT_TAG
|
|
144
144
|
when: never
|
|
145
|
+
- if: $CI_COMMIT_MESSAGE =~ /Bumped version/
|
|
146
|
+
when: never
|
|
145
147
|
- when: on_success
|
|
146
148
|
before_script:
|
|
147
149
|
- apt-get update -qq && apt-get install -y -qq git libsqlite3-dev
|
data/lib/tapsoob/schema.rb
CHANGED
|
@@ -4,15 +4,16 @@ require 'sequel/extensions/schema_dumper'
|
|
|
4
4
|
require 'sequel/extensions/migration'
|
|
5
5
|
require 'erb'
|
|
6
6
|
require 'json'
|
|
7
|
+
require 'tapsoob/log'
|
|
7
8
|
|
|
8
9
|
module Tapsoob
|
|
9
10
|
module Schema
|
|
10
11
|
extend self
|
|
11
12
|
|
|
12
13
|
def dump(database_url, options = {})
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
Sequel.connect(database_url) do |db|
|
|
15
|
+
db.extension :schema_dumper
|
|
16
|
+
template = ERB.new <<-END_MIG
|
|
16
17
|
Class.new(Sequel::Migration) do
|
|
17
18
|
def up
|
|
18
19
|
<% db.send(:sort_dumped_tables, db.tables, {}).each do |table| %>
|
|
@@ -28,7 +29,8 @@ Class.new(Sequel::Migration) do
|
|
|
28
29
|
end
|
|
29
30
|
END_MIG
|
|
30
31
|
|
|
31
|
-
|
|
32
|
+
template.result(binding)
|
|
33
|
+
end
|
|
32
34
|
end
|
|
33
35
|
|
|
34
36
|
def dump_table(database_url_or_db, table, options)
|
|
@@ -67,15 +69,17 @@ END_MIG
|
|
|
67
69
|
end
|
|
68
70
|
|
|
69
71
|
def foreign_keys(database_url)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
72
|
+
Sequel.connect(database_url) do |db|
|
|
73
|
+
db.extension :schema_dumper
|
|
74
|
+
db.dump_foreign_key_migration
|
|
75
|
+
end
|
|
73
76
|
end
|
|
74
77
|
|
|
75
78
|
def indexes(database_url)
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
+
Sequel.connect(database_url) do |db|
|
|
80
|
+
db.extension :schema_dumper
|
|
81
|
+
db.dump_indexes_migration
|
|
82
|
+
end
|
|
79
83
|
end
|
|
80
84
|
|
|
81
85
|
def indexes_individual(database_url)
|
|
@@ -104,6 +108,7 @@ END_MIG
|
|
|
104
108
|
end
|
|
105
109
|
|
|
106
110
|
def load(database_url_or_db, schema, options = { drop: false })
|
|
111
|
+
schema = rewrite_non_integer_primary_keys(schema)
|
|
107
112
|
# Accept either a database URL or an existing connection object
|
|
108
113
|
if database_url_or_db.is_a?(Sequel::Database)
|
|
109
114
|
db = database_url_or_db
|
|
@@ -157,16 +162,38 @@ END_MIG
|
|
|
157
162
|
end
|
|
158
163
|
end
|
|
159
164
|
|
|
165
|
+
NON_INTEGER_PK_PATTERN = /^(\s*)primary_key\s+(:?\w+),\s*:type=>"([^"]+)"(.*)$/
|
|
166
|
+
INTEGER_DB_TYPES = /\A(?:int(?:eger|\d+)?|bigint|smallint|serial|bigserial|smallserial)/i
|
|
167
|
+
|
|
168
|
+
# On PG 10+, Sequel's CreateTableGenerator injects `identity: true` into
|
|
169
|
+
# every primary_key call via serial_primary_key_options. PG rejects IDENTITY
|
|
170
|
+
# on non-integer types. Rewrite `primary_key :col, :type=>"varchar..."` to
|
|
171
|
+
# `column :col, "varchar...", primary_key: true, null: false` which bypasses
|
|
172
|
+
# that code path entirely.
|
|
173
|
+
def rewrite_non_integer_primary_keys(schema_str)
|
|
174
|
+
schema_str.gsub(NON_INTEGER_PK_PATTERN) do
|
|
175
|
+
indent, col, db_type, rest = $1, $2, $3, $4
|
|
176
|
+
if db_type =~ INTEGER_DB_TYPES
|
|
177
|
+
"#{indent}primary_key #{col}, :type=>\"#{db_type}\"#{rest}"
|
|
178
|
+
else
|
|
179
|
+
"#{indent}column #{col}, \"#{db_type}\", primary_key: true, null: false#{rest}"
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
160
184
|
def reset_db_sequences(database_url)
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
185
|
+
Sequel.connect(database_url) do |db|
|
|
186
|
+
db.extension :schema_dumper
|
|
187
|
+
next unless db.respond_to?(:reset_primary_key_sequence)
|
|
188
|
+
db.tables.each do |table|
|
|
189
|
+
pk = db.primary_key(table)
|
|
190
|
+
next unless pk
|
|
191
|
+
pk_type = db.schema(table).find { |col, _| col.to_s == pk.to_s }&.last&.dig(:db_type)
|
|
192
|
+
next unless pk_type&.match?(/int|serial/i)
|
|
193
|
+
db.reset_primary_key_sequence(table)
|
|
194
|
+
rescue Sequel::DatabaseError => e
|
|
195
|
+
Tapsoob.log.warn "Could not reset sequence for table '#{table}': #{e.message.lines.first.chomp}"
|
|
196
|
+
end
|
|
170
197
|
end
|
|
171
198
|
end
|
|
172
199
|
end
|
data/lib/tapsoob/version.rb
CHANGED
|
@@ -105,12 +105,35 @@ RSpec.describe 'PostgreSQL round-trip', :integration do
|
|
|
105
105
|
|
|
106
106
|
after(:each) do
|
|
107
107
|
@src_db.run("DROP TABLE IF EXISTS varchar_pk_table")
|
|
108
|
+
@dst_db.run("DROP TABLE IF EXISTS varchar_pk_table")
|
|
108
109
|
end
|
|
109
110
|
|
|
110
|
-
it '
|
|
111
|
-
expect
|
|
112
|
-
|
|
113
|
-
|
|
111
|
+
it 'skips reset without logging a warning (no sequence attached to varchar PK)' do
|
|
112
|
+
expect(Tapsoob.log).not_to receive(:warn)
|
|
113
|
+
Tapsoob::Schema.reset_db_sequences(@src_url)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
it 'round-trips the table without identity column errors' do
|
|
117
|
+
dump_dir = Dir.mktmpdir
|
|
118
|
+
begin
|
|
119
|
+
pull(src_url, dump_dir)
|
|
120
|
+
expect { push(dst_url, dump_dir) }.not_to raise_error
|
|
121
|
+
expect(dst_db[:varchar_pk_table].where(id: 'abc-123').count).to eq(1)
|
|
122
|
+
ensure
|
|
123
|
+
FileUtils.rm_rf(dump_dir)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
context 'when reset_primary_key_sequence raises a DatabaseError' do
|
|
129
|
+
it 'logs a warning per failing table and does not re-raise' do
|
|
130
|
+
allow(Sequel).to receive(:connect).and_yield(@src_db)
|
|
131
|
+
allow(@src_db).to receive(:reset_primary_key_sequence).and_raise(
|
|
132
|
+
Sequel::DatabaseError, 'ERROR: identity column type must be smallint, integer, or bigint'
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
expect(Tapsoob.log).to receive(:warn).at_least(:once)
|
|
136
|
+
expect { Tapsoob::Schema.reset_db_sequences(@src_url) }.not_to raise_error
|
|
114
137
|
end
|
|
115
138
|
end
|
|
116
139
|
end
|
data/spec/spec_helper.rb
CHANGED
|
@@ -42,12 +42,9 @@ RSpec.configure do |config|
|
|
|
42
42
|
config.order = :random
|
|
43
43
|
Kernel.srand config.seed
|
|
44
44
|
|
|
45
|
-
# Integration
|
|
45
|
+
# Integration tests require a real DB — skip unless env vars are set.
|
|
46
46
|
config.filter_run_excluding :integration unless ENV['INTEGRATION_TESTS'] || ENV['SRC_DATABASE_URL']
|
|
47
|
-
config.filter_run_excluding :system unless ENV['SYSTEM_TESTS'] || ENV['SRC_DATABASE_URL']
|
|
48
47
|
|
|
49
|
-
config.include DbHelpers,
|
|
50
|
-
config.include DbHelpers, :system
|
|
48
|
+
config.include DbHelpers, :integration
|
|
51
49
|
config.include RoundTripHelper, :integration
|
|
52
|
-
config.include RoundTripHelper, :system
|
|
53
50
|
end
|
data/spec/support/fixtures.rb
CHANGED
|
@@ -6,49 +6,53 @@
|
|
|
6
6
|
# defined in DbHelpers (which delegate to the ivars set in before(:all)).
|
|
7
7
|
|
|
8
8
|
RSpec.shared_examples 'a complete round-trip' do
|
|
9
|
+
# Pull once into a shared dir and reuse across all examples in this group.
|
|
10
|
+
before(:all) do
|
|
11
|
+
@shared_dump_dir = Dir.mktmpdir('tapsoob_shared_')
|
|
12
|
+
pull(src_url, @shared_dump_dir)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
after(:all) do
|
|
16
|
+
FileUtils.rm_rf(@shared_dump_dir)
|
|
17
|
+
end
|
|
18
|
+
|
|
9
19
|
it 'pulls without error' do
|
|
10
|
-
expect
|
|
20
|
+
expect(File).to exist(File.join(@shared_dump_dir, 'schemas'))
|
|
11
21
|
end
|
|
12
22
|
|
|
13
23
|
it 'creates schema dump files for every table' do
|
|
14
|
-
pull(src_url, dump_dir)
|
|
15
24
|
src_db.tables.each do |table|
|
|
16
|
-
expect(File).to exist(File.join(
|
|
25
|
+
expect(File).to exist(File.join(@shared_dump_dir, 'schemas', "#{table}.rb"))
|
|
17
26
|
end
|
|
18
27
|
end
|
|
19
28
|
|
|
20
29
|
it 'creates data dump files for every seeded table' do
|
|
21
|
-
pull(src_url, dump_dir)
|
|
22
30
|
%i[users orders products documents attachments events large_table null_heavy].each do |table|
|
|
23
|
-
expect(File).to exist(File.join(
|
|
31
|
+
expect(File).to exist(File.join(@shared_dump_dir, 'data', "#{table}.json"))
|
|
24
32
|
end
|
|
25
33
|
end
|
|
26
34
|
|
|
27
35
|
it 'pushes without error' do
|
|
28
|
-
|
|
29
|
-
expect { push(dst_url, dump_dir) }.not_to raise_error
|
|
36
|
+
expect { push(dst_url, @shared_dump_dir) }.not_to raise_error
|
|
30
37
|
end
|
|
31
38
|
|
|
32
39
|
it 'preserves row counts for all tables' do
|
|
33
|
-
|
|
40
|
+
push(dst_url, @shared_dump_dir)
|
|
34
41
|
expect_same_counts(src_db, dst_db)
|
|
35
42
|
end
|
|
36
43
|
|
|
37
44
|
it 'preserves NULL values in null_heavy' do
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
expect(null_rows).to be > 0
|
|
45
|
+
push(dst_url, @shared_dump_dir)
|
|
46
|
+
expect(dst_db[:null_heavy].where(maybe_name: nil).count).to be > 0
|
|
41
47
|
end
|
|
42
48
|
|
|
43
49
|
it 'preserves string content in users.email' do
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
dst_emails = dst_db[:users].select_map(:email).sort
|
|
47
|
-
expect(dst_emails).to eq(src_emails)
|
|
50
|
+
push(dst_url, @shared_dump_dir)
|
|
51
|
+
expect(dst_db[:users].select_map(:email).sort).to eq(src_db[:users].select_map(:email).sort)
|
|
48
52
|
end
|
|
49
53
|
|
|
50
54
|
it 'preserves BLOB payloads in attachments' do
|
|
51
|
-
|
|
55
|
+
push(dst_url, @shared_dump_dir)
|
|
52
56
|
src_db[:attachments].order(:id).each do |src_row|
|
|
53
57
|
dst_row = dst_db[:attachments][id: src_row[:id]]
|
|
54
58
|
expect(dst_row).not_to be_nil
|
|
@@ -57,7 +61,7 @@ RSpec.shared_examples 'a complete round-trip' do
|
|
|
57
61
|
end
|
|
58
62
|
|
|
59
63
|
it 'preserves large TEXT bodies in documents' do
|
|
60
|
-
|
|
64
|
+
push(dst_url, @shared_dump_dir)
|
|
61
65
|
src_db[:documents].order(:id).each do |src_row|
|
|
62
66
|
dst_row = dst_db[:documents][id: src_row[:id]]
|
|
63
67
|
expect(dst_row[:body]).to eq(src_row[:body])
|
|
@@ -65,19 +69,29 @@ RSpec.shared_examples 'a complete round-trip' do
|
|
|
65
69
|
end
|
|
66
70
|
|
|
67
71
|
it 'handles the no-PK events table' do
|
|
68
|
-
|
|
72
|
+
push(dst_url, @shared_dump_dir)
|
|
69
73
|
expect(dst_db[:events].count).to eq(src_db[:events].count)
|
|
70
74
|
end
|
|
71
75
|
end
|
|
72
76
|
|
|
73
77
|
RSpec.shared_examples 'a parallel round-trip' do |workers:|
|
|
78
|
+
# Pull once, push with parallel workers.
|
|
79
|
+
before(:all) do
|
|
80
|
+
@parallel_dump_dir = Dir.mktmpdir('tapsoob_parallel_')
|
|
81
|
+
pull(src_url, @parallel_dump_dir, parallel: workers)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
after(:all) do
|
|
85
|
+
FileUtils.rm_rf(@parallel_dump_dir)
|
|
86
|
+
end
|
|
87
|
+
|
|
74
88
|
it "preserves row counts with #{workers} parallel workers" do
|
|
75
|
-
|
|
89
|
+
push(dst_url, @parallel_dump_dir, parallel: workers)
|
|
76
90
|
expect_same_counts(src_db, dst_db)
|
|
77
91
|
end
|
|
78
92
|
|
|
79
|
-
it "handles the large_table
|
|
80
|
-
|
|
93
|
+
it "handles the large_table with #{workers} workers" do
|
|
94
|
+
push(dst_url, @parallel_dump_dir, parallel: workers)
|
|
81
95
|
expect(dst_db[:large_table].count).to eq(Fixtures::LARGE_TABLE_ROWS)
|
|
82
96
|
end
|
|
83
97
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: tapsoob
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.8.
|
|
4
|
+
version: 0.8.6
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Félix Bellanger
|
|
@@ -133,7 +133,6 @@ files:
|
|
|
133
133
|
- spec/support/fixtures.rb
|
|
134
134
|
- spec/support/round_trip_helper.rb
|
|
135
135
|
- spec/support/shared_examples/round_trip.rb
|
|
136
|
-
- spec/system/large_dataset_spec.rb
|
|
137
136
|
- spec/unit/tapsoob/chunksize_spec.rb
|
|
138
137
|
- spec/unit/tapsoob/data_stream_spec.rb
|
|
139
138
|
- spec/unit/tapsoob/operation_base_spec.rb
|
|
@@ -1,163 +0,0 @@
|
|
|
1
|
-
require 'spec_helper'
|
|
2
|
-
|
|
3
|
-
RSpec.describe 'Large dataset system tests', :system do
|
|
4
|
-
before(:all) do
|
|
5
|
-
@src_url = DbHelpers.adapt_url(ENV.fetch('SRC_DATABASE_URL', 'sqlite://tmp/tapsoob_system_src.db'))
|
|
6
|
-
@dst_url = DbHelpers.adapt_url(ENV.fetch('DST_DATABASE_URL', 'sqlite://tmp/tapsoob_system_dst.db'))
|
|
7
|
-
|
|
8
|
-
FileUtils.mkdir_p('tmp')
|
|
9
|
-
File.delete('tmp/tapsoob_system_src.db') rescue nil
|
|
10
|
-
File.delete('tmp/tapsoob_system_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_system_src.db') rescue nil
|
|
28
|
-
File.delete('tmp/tapsoob_system_dst.db') rescue nil
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
# ── large_table: intra-table parallelization threshold ───────────────────────
|
|
32
|
-
|
|
33
|
-
describe 'large_table (150K rows)' do
|
|
34
|
-
it 'transfers all rows in serial mode' do
|
|
35
|
-
round_trip(src_url, dst_url, dump_dir)
|
|
36
|
-
expect(dst_db[:large_table].count).to eq(Fixtures::LARGE_TABLE_ROWS)
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
it 'transfers all rows with parallel: 2' do
|
|
40
|
-
round_trip(src_url, dst_url, dump_dir, parallel: 2)
|
|
41
|
-
expect(dst_db[:large_table].count).to eq(Fixtures::LARGE_TABLE_ROWS)
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
it 'transfers all rows with parallel: 4' do
|
|
45
|
-
round_trip(src_url, dst_url, dump_dir, parallel: 4)
|
|
46
|
-
expect(dst_db[:large_table].count).to eq(Fixtures::LARGE_TABLE_ROWS)
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
it 'has no duplicate rows after parallel pull' do
|
|
50
|
-
round_trip(src_url, dst_url, dump_dir, parallel: 4)
|
|
51
|
-
total = dst_db[:large_table].count
|
|
52
|
-
distinct = dst_db[:large_table].select(:id).distinct.count
|
|
53
|
-
expect(distinct).to eq(total)
|
|
54
|
-
end
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
# ── documents: large TEXT columns ────────────────────────────────────────────
|
|
58
|
-
|
|
59
|
-
describe 'documents table (large TEXT)' do
|
|
60
|
-
it 'preserves body content exactly' do
|
|
61
|
-
round_trip(src_url, dst_url, dump_dir)
|
|
62
|
-
src_db[:documents].order(:id).each do |src_row|
|
|
63
|
-
dst_row = dst_db[:documents][id: src_row[:id]]
|
|
64
|
-
expect(dst_row[:body]).to eq(src_row[:body]),
|
|
65
|
-
"body mismatch for document #{src_row[:id]}: " \
|
|
66
|
-
"src=#{src_row[:body]&.length} bytes dst=#{dst_row[:body]&.length} bytes"
|
|
67
|
-
end
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
it 'handles documents with nil body' do
|
|
71
|
-
round_trip(src_url, dst_url, dump_dir)
|
|
72
|
-
expect(dst_db[:documents].where(body: nil).count).to eq(src_db[:documents].where(body: nil).count)
|
|
73
|
-
end
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
# ── attachments: BLOB encoding/decoding ──────────────────────────────────────
|
|
77
|
-
|
|
78
|
-
describe 'attachments table (binary BLOBs up to 256 KB)' do
|
|
79
|
-
it 'preserves every byte of every payload' do
|
|
80
|
-
round_trip(src_url, dst_url, dump_dir)
|
|
81
|
-
mismatch_count = 0
|
|
82
|
-
src_db[:attachments].order(:id).each do |src_row|
|
|
83
|
-
dst_row = dst_db[:attachments][id: src_row[:id]]
|
|
84
|
-
mismatch_count += 1 unless dst_row[:payload].to_s.bytes == src_row[:payload].to_s.bytes
|
|
85
|
-
end
|
|
86
|
-
expect(mismatch_count).to eq(0)
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
it 'preserves size_bytes metadata' do
|
|
90
|
-
round_trip(src_url, dst_url, dump_dir)
|
|
91
|
-
src_db[:attachments].order(:id).each do |src_row|
|
|
92
|
-
dst_row = dst_db[:attachments][id: src_row[:id]]
|
|
93
|
-
expect(dst_row[:size_bytes]).to eq(src_row[:size_bytes])
|
|
94
|
-
end
|
|
95
|
-
end
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
# ── null_heavy: NULL preservation ────────────────────────────────────────────
|
|
99
|
-
|
|
100
|
-
describe 'null_heavy table' do
|
|
101
|
-
it 'preserves NULLs in every nullable column' do
|
|
102
|
-
round_trip(src_url, dst_url, dump_dir)
|
|
103
|
-
%i[maybe_name maybe_number maybe_score maybe_date maybe_text].each do |col|
|
|
104
|
-
src_nulls = src_db[:null_heavy].where(col => nil).count
|
|
105
|
-
dst_nulls = dst_db[:null_heavy].where(col => nil).count
|
|
106
|
-
expect(dst_nulls).to eq(src_nulls),
|
|
107
|
-
"NULL count mismatch for null_heavy.#{col}: src=#{src_nulls} dst=#{dst_nulls}"
|
|
108
|
-
end
|
|
109
|
-
end
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
# ── events: table without primary key ────────────────────────────────────────
|
|
113
|
-
|
|
114
|
-
describe 'events table (no primary key)' do
|
|
115
|
-
it 'uses the Base (non-keyed) stream' do
|
|
116
|
-
round_trip(src_url, dst_url, dump_dir)
|
|
117
|
-
expect(dst_db[:events].count).to eq(src_db[:events].count)
|
|
118
|
-
end
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
# ── adaptive chunksize: very small chunks ────────────────────────────────────
|
|
122
|
-
|
|
123
|
-
describe 'adaptive chunksize under load' do
|
|
124
|
-
it 'completes with chunksize=1 (extreme case)' do
|
|
125
|
-
small_src = DbHelpers.adapt_url('sqlite://tmp/tapsoob_small_src.db')
|
|
126
|
-
small_dst = DbHelpers.adapt_url('sqlite://tmp/tapsoob_small_dst.db')
|
|
127
|
-
small_dir = Dir.mktmpdir
|
|
128
|
-
|
|
129
|
-
begin
|
|
130
|
-
sdb = DbHelpers.connect(small_src)
|
|
131
|
-
sdb.create_table!(:small_test) { primary_key :id; String :v, size: 50 }
|
|
132
|
-
100.times { |i| sdb[:small_test].insert(v: "row_#{i}") }
|
|
133
|
-
|
|
134
|
-
round_trip(small_src, small_dst, small_dir, default_chunksize: 1)
|
|
135
|
-
expect(DbHelpers.connect(small_dst)[:small_test].count).to eq(100)
|
|
136
|
-
ensure
|
|
137
|
-
FileUtils.rm_rf(small_dir)
|
|
138
|
-
File.delete('tmp/tapsoob_small_src.db') rescue nil
|
|
139
|
-
File.delete('tmp/tapsoob_small_dst.db') rescue nil
|
|
140
|
-
# Reconnect suite DBs after disconnect_all clears the pool
|
|
141
|
-
DbHelpers.disconnect_all
|
|
142
|
-
@src_db = DbHelpers.connect(@src_url)
|
|
143
|
-
@dst_db = DbHelpers.connect(@dst_url)
|
|
144
|
-
end
|
|
145
|
-
end
|
|
146
|
-
end
|
|
147
|
-
|
|
148
|
-
# ── FK order: orders depends on users ────────────────────────────────────────
|
|
149
|
-
|
|
150
|
-
describe 'foreign key dependency ordering' do
|
|
151
|
-
it 'pushes users before orders (table_order.txt respected)' do
|
|
152
|
-
pull(src_url, dump_dir)
|
|
153
|
-
order_file = File.join(dump_dir, 'table_order.txt')
|
|
154
|
-
if File.exist?(order_file)
|
|
155
|
-
order = File.readlines(order_file).map(&:strip)
|
|
156
|
-
users_idx = order.index('users')
|
|
157
|
-
orders_idx = order.index('orders')
|
|
158
|
-
expect(users_idx).to be < orders_idx if users_idx && orders_idx
|
|
159
|
-
end
|
|
160
|
-
expect { push(dst_url, dump_dir) }.not_to raise_error
|
|
161
|
-
end
|
|
162
|
-
end
|
|
163
|
-
end
|