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
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require 'tapsoob/operation/base'
|
|
3
|
+
require 'tapsoob/operation/pull'
|
|
4
|
+
require 'tapsoob/operation/push'
|
|
5
|
+
|
|
6
|
+
RSpec.describe Tapsoob::Operation::Base do
|
|
7
|
+
let(:url) { sqlite_memory_url }
|
|
8
|
+
let(:dir) { Dir.mktmpdir }
|
|
9
|
+
let(:base_opts) { { data: true, schema: true, progress: false, default_chunksize: 1000 } }
|
|
10
|
+
|
|
11
|
+
after { FileUtils.rm_rf(dir) }
|
|
12
|
+
|
|
13
|
+
subject(:op) { described_class.new(url, dir, base_opts) }
|
|
14
|
+
|
|
15
|
+
# ── table_filter / exclude_tables ────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
describe '#apply_table_filter' do
|
|
18
|
+
context 'when no filter is set' do
|
|
19
|
+
it 'returns all tables unchanged' do
|
|
20
|
+
tables = ['users', 'orders', 'products']
|
|
21
|
+
expect(op.apply_table_filter(tables)).to eq(tables)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
context 'with :tables filter' do
|
|
26
|
+
subject(:op) { described_class.new(url, dir, base_opts.merge(tables: ['users'])) }
|
|
27
|
+
|
|
28
|
+
it 'keeps only whitelisted tables' do
|
|
29
|
+
tables = ['users', 'orders']
|
|
30
|
+
expect(op.apply_table_filter(tables)).to eq(['users'])
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
context 'with :exclude_tables' do
|
|
35
|
+
subject(:op) { described_class.new(url, dir, base_opts.merge(exclude_tables: ['orders'])) }
|
|
36
|
+
|
|
37
|
+
it 'excludes the specified tables' do
|
|
38
|
+
tables = ['users', 'orders', 'products']
|
|
39
|
+
expect(op.apply_table_filter(tables)).to eq(['users', 'products'])
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
context 'with a Hash argument' do
|
|
44
|
+
subject(:op) { described_class.new(url, dir, base_opts.merge(tables: ['users'])) }
|
|
45
|
+
|
|
46
|
+
it 'filters the hash by whitelisted keys' do
|
|
47
|
+
tables = { 'users' => 10, 'orders' => 5 }
|
|
48
|
+
expect(op.apply_table_filter(tables)).to eq({ 'users' => 10 })
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# ── parallelism ──────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
describe '#parallel_workers' do
|
|
56
|
+
it 'defaults to 1 when no --parallel option given' do
|
|
57
|
+
expect(op.parallel_workers).to eq(1)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it 'respects the :parallel option' do
|
|
61
|
+
o = described_class.new(url, dir, base_opts.merge(parallel: 4))
|
|
62
|
+
expect(o.parallel_workers).to eq(4)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it 'is at least 1 for invalid values' do
|
|
66
|
+
o = described_class.new(url, dir, base_opts.merge(parallel: 0))
|
|
67
|
+
expect(o.parallel_workers).to be >= 1
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
describe '#table_parallel_workers' do
|
|
72
|
+
it 'returns 1 when dump_path is nil (piped mode)' do
|
|
73
|
+
o = described_class.new(url, nil, base_opts)
|
|
74
|
+
expect(o.table_parallel_workers(:big_table, 500_000)).to eq(1)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'returns 1 for tables under 100K rows' do
|
|
78
|
+
expect(op.table_parallel_workers(:small_table, 99_999)).to eq(1)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it 'returns >= 2 for tables at the 100K threshold' do
|
|
82
|
+
expect(op.table_parallel_workers(:big_table, 100_000)).to be >= 2
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it 'returns 1 when --no-split is set' do
|
|
86
|
+
o = described_class.new(url, dir, base_opts.merge(no_split: true))
|
|
87
|
+
expect(o.table_parallel_workers(:big_table, 500_000)).to eq(1)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# ── completed_tables (thread safety) ─────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
describe '#add_completed_table' do
|
|
94
|
+
it 'adds the table name as a string' do
|
|
95
|
+
op.add_completed_table(:users)
|
|
96
|
+
expect(op.completed_tables).to include('users')
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it 'is thread-safe under concurrent adds' do
|
|
100
|
+
threads = 20.times.map { |i| Thread.new { op.add_completed_table("table_#{i}") } }
|
|
101
|
+
threads.each(&:join)
|
|
102
|
+
expect(op.completed_tables.uniq.size).to eq(20)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# ── factory ──────────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
describe '.factory' do
|
|
109
|
+
it 'returns a Pull operation for :pull' do
|
|
110
|
+
result = described_class.factory(:pull, url, dir, base_opts)
|
|
111
|
+
expect(result).to be_a(Tapsoob::Operation::Pull)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
it 'returns a Push operation for :push' do
|
|
115
|
+
result = described_class.factory(:push, url, dir, base_opts)
|
|
116
|
+
expect(result).to be_a(Tapsoob::Operation::Push)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
it 'raises on unknown type' do
|
|
120
|
+
expect {
|
|
121
|
+
described_class.factory(:unknown, url, dir, base_opts)
|
|
122
|
+
}.to raise_error(RuntimeError, /Unknown Operation Type/)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# ── session persistence ───────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
describe '#to_hash' do
|
|
129
|
+
it 'includes klass, database_url, stream_state, completed_tables' do
|
|
130
|
+
h = op.to_hash
|
|
131
|
+
expect(h).to include(:klass, :database_url, :stream_state, :completed_tables)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require 'tapsoob/schema'
|
|
3
|
+
|
|
4
|
+
RSpec.describe Tapsoob::Schema do
|
|
5
|
+
let(:db) do
|
|
6
|
+
d = connect_sqlite
|
|
7
|
+
d.extension :schema_dumper
|
|
8
|
+
d.create_table(:articles) do
|
|
9
|
+
primary_key :id
|
|
10
|
+
String :title, null: false, size: 255
|
|
11
|
+
String :body, text: true
|
|
12
|
+
DateTime :published_at
|
|
13
|
+
end
|
|
14
|
+
d
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
after { db.disconnect }
|
|
18
|
+
|
|
19
|
+
# ── dump_table ───────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
describe '.dump_table' do
|
|
22
|
+
it 'returns a Sequel migration string' do
|
|
23
|
+
result = described_class.dump_table(db, :articles, {})
|
|
24
|
+
expect(result).to include('Sequel::Migration')
|
|
25
|
+
expect(result).to include('articles')
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it 'accepts a URL string' do
|
|
29
|
+
# sqlite::memory: opens a fresh empty DB on every connect, so dump_table
|
|
30
|
+
# (which opens its own connection) would see no tables. Use a temp file instead.
|
|
31
|
+
require 'tmpdir'
|
|
32
|
+
tmp_path = File.join(Dir.tmpdir, "tapsoob_schema_test_#{Process.pid}.db")
|
|
33
|
+
url = DbHelpers.adapt_url("sqlite://#{tmp_path}")
|
|
34
|
+
begin
|
|
35
|
+
tmp = DbHelpers.connect(url)
|
|
36
|
+
tmp.create_table(:t) { primary_key :id; String :v }
|
|
37
|
+
result = described_class.dump_table(url, :t, {})
|
|
38
|
+
expect(result).to include('Sequel::Migration')
|
|
39
|
+
ensure
|
|
40
|
+
DbHelpers.disconnect_all
|
|
41
|
+
File.delete(tmp_path) rescue nil
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# ── load / round-trip ────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
describe '.load' do
|
|
49
|
+
it 'creates the table in the destination DB' do
|
|
50
|
+
schema_str = described_class.dump_table(db, :articles, {})
|
|
51
|
+
|
|
52
|
+
dest = connect_sqlite
|
|
53
|
+
dest.extension :schema_dumper
|
|
54
|
+
described_class.load(dest, schema_str)
|
|
55
|
+
expect(dest.table_exists?(:articles)).to be true
|
|
56
|
+
dest.disconnect
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it 'drops then recreates table when drop: true' do
|
|
60
|
+
schema_str = described_class.dump_table(db, :articles, {})
|
|
61
|
+
dest = connect_sqlite
|
|
62
|
+
dest.extension :schema_dumper
|
|
63
|
+
described_class.load(dest, schema_str)
|
|
64
|
+
described_class.load(dest, schema_str, drop: true)
|
|
65
|
+
expect(dest.table_exists?(:articles)).to be true
|
|
66
|
+
dest.disconnect
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# ── indexes_individual ───────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
describe '.indexes_individual' do
|
|
73
|
+
let(:indexed_db) do
|
|
74
|
+
d = connect_sqlite
|
|
75
|
+
d.create_table(:idx_test) { primary_key :id; String :email, size: 100 }
|
|
76
|
+
d.add_index(:idx_test, :email)
|
|
77
|
+
d
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
after { indexed_db.disconnect }
|
|
81
|
+
|
|
82
|
+
it 'returns a JSON string' do
|
|
83
|
+
url = sqlite_memory_url
|
|
84
|
+
Sequel.connect(url) do |tmp|
|
|
85
|
+
tmp.create_table(:t) { primary_key :id; String :v }
|
|
86
|
+
tmp.add_index(:t, :v)
|
|
87
|
+
result = described_class.indexes_individual(url)
|
|
88
|
+
expect { JSON.parse(result) }.not_to raise_error
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# ── reset_db_sequences ───────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
describe '.reset_db_sequences' do
|
|
96
|
+
it 'runs without error on SQLite (which has no sequences)' do
|
|
97
|
+
expect {
|
|
98
|
+
described_class.reset_db_sequences(sqlite_memory_url)
|
|
99
|
+
}.not_to raise_error
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require 'tapsoob/utils'
|
|
3
|
+
|
|
4
|
+
RSpec.describe Tapsoob::Utils do
|
|
5
|
+
# ── checksum / valid_data ────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
describe '.checksum' do
|
|
8
|
+
it 'returns a Zlib CRC32 integer' do
|
|
9
|
+
expect(described_class.checksum('hello')).to be_a(Integer)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
it 'is deterministic for the same input' do
|
|
13
|
+
expect(described_class.checksum('test')).to eq(described_class.checksum('test'))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it 'differs for different inputs' do
|
|
17
|
+
expect(described_class.checksum('a')).not_to eq(described_class.checksum('b'))
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
describe '.valid_data?' do
|
|
22
|
+
let(:data) { 'some payload' }
|
|
23
|
+
let(:crc) { described_class.checksum(data) }
|
|
24
|
+
|
|
25
|
+
it 'returns true when checksum matches' do
|
|
26
|
+
expect(described_class.valid_data?(data, crc)).to be true
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it 'returns false when checksum does not match' do
|
|
30
|
+
expect(described_class.valid_data?(data, crc + 1)).to be false
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it 'accepts crc as a string (mirrors production code path)' do
|
|
34
|
+
expect(described_class.valid_data?(data, crc.to_s)).to be true
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# ── base64 round-trip ────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
describe '.base64encode / .base64decode' do
|
|
41
|
+
it 'round-trips plain text' do
|
|
42
|
+
text = 'Hello, Tapsoob!'
|
|
43
|
+
expect(described_class.base64decode(described_class.base64encode(text))).to eq(text)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it 'round-trips binary data faithfully' do
|
|
47
|
+
binary = Random.bytes(512)
|
|
48
|
+
decoded = described_class.base64decode(described_class.base64encode(binary))
|
|
49
|
+
expect(decoded.bytes).to eq(binary.bytes)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it 'round-trips empty string' do
|
|
53
|
+
expect(described_class.base64decode(described_class.base64encode(''))).to eq('')
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# ── format_data ──────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
describe '.format_data' do
|
|
60
|
+
let(:db) do
|
|
61
|
+
d = connect_sqlite
|
|
62
|
+
d.create_table(:fmt_test) do
|
|
63
|
+
primary_key :id
|
|
64
|
+
String :name, size: 50
|
|
65
|
+
Integer :score
|
|
66
|
+
File :payload
|
|
67
|
+
end
|
|
68
|
+
d
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Evaluate schema in the same let-chain so it always uses the same db instance.
|
|
72
|
+
let(:schema) { db.schema(:fmt_test) }
|
|
73
|
+
|
|
74
|
+
after { db.disconnect }
|
|
75
|
+
|
|
76
|
+
it 'returns {} for empty data' do
|
|
77
|
+
expect(described_class.format_data(db, [], schema: schema, table: :fmt_test)).to eq({})
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
it 'builds header, data, types for normal rows' do
|
|
81
|
+
data = [{ id: 1, name: 'Alice', score: 42, payload: nil }]
|
|
82
|
+
result = described_class.format_data(db, data, schema: schema, table: :fmt_test)
|
|
83
|
+
expect(result[:header]).to include(:id, :name, :score, :payload)
|
|
84
|
+
expect(result[:data].first).to include(1, 'Alice', 42)
|
|
85
|
+
expect(result[:types]).to include('integer', 'string')
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it 'marks File columns as "blob" type' do
|
|
89
|
+
data = [{ id: 1, name: 'x', score: 0, payload: Sequel::SQL::Blob.new('bin') }]
|
|
90
|
+
result = described_class.format_data(db, data, schema: schema, table: :fmt_test)
|
|
91
|
+
payload_idx = result[:header].index(:payload)
|
|
92
|
+
expect(result[:types][payload_idx]).to eq('blob')
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
it 'converts Time objects to ISO-8601 strings' do
|
|
96
|
+
db2 = connect_sqlite
|
|
97
|
+
db2.extension :schema_dumper
|
|
98
|
+
db2.create_table(:ts_test) { primary_key :id; DateTime :ts }
|
|
99
|
+
t = Time.utc(2024, 6, 15, 12, 0, 0)
|
|
100
|
+
data = [{ id: 1, ts: t }]
|
|
101
|
+
result = described_class.format_data(db2, data,
|
|
102
|
+
schema: db2.schema(:ts_test), table: :ts_test)
|
|
103
|
+
ts_idx = result[:header].index(:ts)
|
|
104
|
+
expect(result[:data].first[ts_idx]).to eq('2024-06-15 12:00:00')
|
|
105
|
+
db2.disconnect
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# ── encode_blobs ─────────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
describe '.encode_blobs' do
|
|
112
|
+
it 'base64-encodes Sequel::SQL::Blob values' do
|
|
113
|
+
blob = Sequel::SQL::Blob.new('binary data')
|
|
114
|
+
row = { payload: blob }
|
|
115
|
+
described_class.encode_blobs(row, [:payload])
|
|
116
|
+
expect(row[:payload]).to eq(described_class.base64encode('binary data'))
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
it 'leaves non-blob string columns unchanged' do
|
|
120
|
+
row = { name: 'Alice' }
|
|
121
|
+
described_class.encode_blobs(row, [])
|
|
122
|
+
expect(row[:name]).to eq('Alice')
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
it 'encodes Blob values not in the columns list (auto-detect)' do
|
|
126
|
+
blob = Sequel::SQL::Blob.new('surprise')
|
|
127
|
+
row = { payload: blob }
|
|
128
|
+
described_class.encode_blobs(row, [])
|
|
129
|
+
expect(row[:payload]).to eq(described_class.base64encode('surprise'))
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
it 'skips nil blob values' do
|
|
133
|
+
row = { payload: nil }
|
|
134
|
+
described_class.encode_blobs(row, [:payload])
|
|
135
|
+
expect(row[:payload]).to be_nil
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# ── calculate_chunksize ──────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
describe '.calculate_chunksize' do
|
|
142
|
+
it 'returns an Integer' do
|
|
143
|
+
result = described_class.calculate_chunksize(1000) { |_c| 0.5 }
|
|
144
|
+
expect(result).to be_a(Integer)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
it 'increases chunksize when block returns fast time (< 0.8s)' do
|
|
148
|
+
result = described_class.calculate_chunksize(1000) { |_c| 0.1 }
|
|
149
|
+
expect(result).to be > 1000
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
it 'decreases chunksize when block returns slow time (> 3s)' do
|
|
153
|
+
# time_in_db must be near-zero so the real wall-clock diff drives the decision.
|
|
154
|
+
# We fake it by setting start/end times directly on the Chunksize object.
|
|
155
|
+
result = described_class.calculate_chunksize(900) do |c|
|
|
156
|
+
c.start_time = Time.now - 4.5 # pretend we started 4.5s ago
|
|
157
|
+
0.0 # time_in_db ≈ 0 → diff = 4.5 > 3.0 → halve
|
|
158
|
+
end
|
|
159
|
+
expect(result).to be < 900
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
it 'retries up to 2 times on EPIPE and then re-raises' do
|
|
163
|
+
calls = 0
|
|
164
|
+
expect {
|
|
165
|
+
described_class.calculate_chunksize(1000) do |c|
|
|
166
|
+
calls += 1
|
|
167
|
+
raise Errno::EPIPE
|
|
168
|
+
end
|
|
169
|
+
}.to raise_error(Errno::EPIPE)
|
|
170
|
+
expect(calls).to eq(3) # initial + 2 retries
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# ── primary_key / single_integer_primary_key / order_by ─────────────────────
|
|
175
|
+
|
|
176
|
+
describe 'primary key helpers' do
|
|
177
|
+
let(:db) do
|
|
178
|
+
d = connect_sqlite
|
|
179
|
+
d.create_table(:pk_test) { primary_key :id; String :name }
|
|
180
|
+
d.create_table(:cpk_test) { String :a; String :b; primary_key [:a, :b] }
|
|
181
|
+
d.create_table(:nopk) { String :x; String :y }
|
|
182
|
+
d
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
after { db.disconnect }
|
|
186
|
+
|
|
187
|
+
describe '.primary_key' do
|
|
188
|
+
it 'returns [:id] for a single-column integer PK' do
|
|
189
|
+
expect(described_class.primary_key(db, :pk_test)).to eq([:id])
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
it 'returns both columns for a composite PK' do
|
|
193
|
+
expect(described_class.primary_key(db, :cpk_test)).to match_array([:a, :b])
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
describe '.single_integer_primary_key' do
|
|
198
|
+
it 'is true for a single integer PK' do
|
|
199
|
+
expect(described_class.single_integer_primary_key(db, :pk_test)).to be true
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
it 'is false for composite PK' do
|
|
203
|
+
expect(described_class.single_integer_primary_key(db, :cpk_test)).to be false
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
it 'is false for a table with no PK' do
|
|
207
|
+
expect(described_class.single_integer_primary_key(db, :nopk)).to be false
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
describe '.order_by' do
|
|
212
|
+
it 'returns the PK column as an array for keyed tables' do
|
|
213
|
+
expect(described_class.order_by(db, :pk_test)).to eq([:id])
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
it 'returns all columns for tables without a single integer PK' do
|
|
217
|
+
result = described_class.order_by(db, :nopk)
|
|
218
|
+
expect(result).to match_array([:x, :y])
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# ── export_rows / export_schema / export_indexes (filesystem) ────────────────
|
|
224
|
+
|
|
225
|
+
describe 'filesystem export helpers' do
|
|
226
|
+
let(:dir) { Dir.mktmpdir }
|
|
227
|
+
after { FileUtils.rm_rf(dir) }
|
|
228
|
+
|
|
229
|
+
describe '.export_rows' do
|
|
230
|
+
it 'appends NDJSON lines to the data file' do
|
|
231
|
+
FileUtils.mkdir_p(File.join(dir, 'data'))
|
|
232
|
+
rows = { table_name: :users, header: [:id, :name], data: [[1, 'Alice']] }
|
|
233
|
+
described_class.export_rows(dir, :users, rows)
|
|
234
|
+
described_class.export_rows(dir, :users, rows)
|
|
235
|
+
lines = File.readlines(File.join(dir, 'data', 'users.json'))
|
|
236
|
+
expect(lines.size).to eq(2)
|
|
237
|
+
expect(JSON.parse(lines.first)).to have_key('table_name')
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
describe '.export_schema' do
|
|
242
|
+
it 'writes the schema string to schemas/<table>.rb' do
|
|
243
|
+
FileUtils.mkdir_p(File.join(dir, 'schemas'))
|
|
244
|
+
described_class.export_schema(dir, :users, 'Class.new(Sequel::Migration) { def up; end }')
|
|
245
|
+
content = File.read(File.join(dir, 'schemas', 'users.rb'))
|
|
246
|
+
expect(content).to include('Sequel::Migration')
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
describe '.export_indexes' do
|
|
251
|
+
it 'appends index migration strings as NDJSON' do
|
|
252
|
+
FileUtils.mkdir_p(File.join(dir, 'indexes'))
|
|
253
|
+
described_class.export_indexes(dir, :users, 'add_index :users, :email')
|
|
254
|
+
described_class.export_indexes(dir, :users, 'add_index :users, :name')
|
|
255
|
+
lines = File.readlines(File.join(dir, 'indexes', 'users.json'))
|
|
256
|
+
expect(lines.size).to eq(2)
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
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.
|
|
4
|
+
version: 0.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Félix Bellanger
|
|
@@ -125,9 +125,21 @@ files:
|
|
|
125
125
|
- lib/tapsoob/utils.rb
|
|
126
126
|
- lib/tapsoob/version.rb
|
|
127
127
|
- lib/tasks/tapsoob.rake
|
|
128
|
-
- spec/
|
|
129
|
-
- spec/
|
|
128
|
+
- spec/integration/mysql_spec.rb
|
|
129
|
+
- spec/integration/postgres_spec.rb
|
|
130
|
+
- spec/integration/sqlite_spec.rb
|
|
130
131
|
- spec/spec_helper.rb
|
|
132
|
+
- spec/support/db_helpers.rb
|
|
133
|
+
- spec/support/fixtures.rb
|
|
134
|
+
- spec/support/round_trip_helper.rb
|
|
135
|
+
- spec/support/shared_examples/round_trip.rb
|
|
136
|
+
- spec/system/large_dataset_spec.rb
|
|
137
|
+
- spec/unit/tapsoob/chunksize_spec.rb
|
|
138
|
+
- spec/unit/tapsoob/data_stream_spec.rb
|
|
139
|
+
- spec/unit/tapsoob/operation_base_spec.rb
|
|
140
|
+
- spec/unit/tapsoob/schema_spec.rb
|
|
141
|
+
- spec/unit/tapsoob/utils_spec.rb
|
|
142
|
+
- spec/unit/tapsoob/version_spec.rb
|
|
131
143
|
- tapsoob.gemspec
|
|
132
144
|
homepage: https://github.com/Keeguon/tapsoob
|
|
133
145
|
licenses:
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
require 'spec_helper'
|
|
2
|
-
require 'tapsoob/chunksize'
|
|
3
|
-
|
|
4
|
-
describe Tapsoob::Chunksize do
|
|
5
|
-
subject(:tapsoob) { Tapsoob::Chunksize.new(1) }
|
|
6
|
-
let(:chunksize) { double('chunksize') }
|
|
7
|
-
chunksize = Tapsoob::Chunksize.new(chunksize)
|
|
8
|
-
describe '#new' do
|
|
9
|
-
it 'works' do
|
|
10
|
-
result = Tapsoob::Chunksize.new(chunksize)
|
|
11
|
-
expect(result).not_to be_nil
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
describe '#initialize' do
|
|
15
|
-
it { should respond_to :chunksize }
|
|
16
|
-
it { should respond_to :idle_secs }
|
|
17
|
-
it { should respond_to :retries }
|
|
18
|
-
end
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
describe '#to_i' do
|
|
22
|
-
|
|
23
|
-
it { expect(tapsoob.to_i).to eq(1) }
|
|
24
|
-
it { expect(tapsoob.to_i).to be_a(Integer) }
|
|
25
|
-
it 'works' do
|
|
26
|
-
chunksize = Tapsoob::Chunksize.new(chunksize)
|
|
27
|
-
result = chunksize.to_i
|
|
28
|
-
expect(result).not_to be_nil
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
context 'converts to type integer' do
|
|
32
|
-
it { expect(tapsoob.to_i).to eq(1) }
|
|
33
|
-
it { expect(tapsoob.to_i).to be_an(Integer) }
|
|
34
|
-
end
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
describe '#reset_chunksize' do
|
|
38
|
-
|
|
39
|
-
context 'retries <= 1' do
|
|
40
|
-
it { expect(tapsoob.retries).to eq(0) }
|
|
41
|
-
it { expect(tapsoob.reset_chunksize).to eq(10) }
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
it 'works' do
|
|
45
|
-
chunksize = Tapsoob::Chunksize.new(chunksize)
|
|
46
|
-
result = chunksize.reset_chunksize
|
|
47
|
-
expect(result).not_to be_nil
|
|
48
|
-
end
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
describe '#diff' do
|
|
53
|
-
it 'works' do
|
|
54
|
-
chunksize = Tapsoob::Chunksize.new(chunksize)
|
|
55
|
-
chunksize.start_time = 1
|
|
56
|
-
chunksize.end_time = 10
|
|
57
|
-
chunksize.time_in_db = 2
|
|
58
|
-
chunksize.idle_secs = 3
|
|
59
|
-
result = chunksize.diff
|
|
60
|
-
expect(result).not_to be_nil
|
|
61
|
-
end
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
describe '#time_in_db=' do
|
|
65
|
-
it 'works' do
|
|
66
|
-
chunksize = Tapsoob::Chunksize.new(chunksize)
|
|
67
|
-
result = chunksize.time_in_db = (1)
|
|
68
|
-
expect(result).not_to be_nil
|
|
69
|
-
end
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
describe '#time_delta' do
|
|
73
|
-
it 'works' do
|
|
74
|
-
chunksize = double('chunksize')
|
|
75
|
-
chunksize = Tapsoob::Chunksize.new(chunksize)
|
|
76
|
-
result = chunksize.time_delta
|
|
77
|
-
expect(result).not_to be_nil
|
|
78
|
-
end
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
describe '#calc_new_chunksize' do
|
|
82
|
-
it 'works' do
|
|
83
|
-
chunksize = Tapsoob::Chunksize.new(1)
|
|
84
|
-
chunksize.start_time = 1
|
|
85
|
-
chunksize.end_time = 10
|
|
86
|
-
chunksize.time_in_db = 2
|
|
87
|
-
chunksize.idle_secs = 3
|
|
88
|
-
result = chunksize.calc_new_chunksize
|
|
89
|
-
expect(result).not_to be_nil
|
|
90
|
-
end
|
|
91
|
-
end
|
|
92
|
-
end
|