tapsoob 0.8.6 → 0.8.7
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/lib/tapsoob/cli/schema.rb +1 -1
- data/lib/tapsoob/version.rb +1 -1
- data/spec/spec_helper.rb +3 -2
- data/spec/support/operation_helpers.rb +43 -0
- data/spec/unit/tapsoob/base_spec.rb +222 -0
- data/spec/unit/tapsoob/cli_pipeline_spec.rb +380 -0
- data/spec/unit/tapsoob/config_spec.rb +54 -0
- data/spec/unit/tapsoob/data_stream_spec.rb +48 -0
- data/spec/unit/tapsoob/file_partition_spec.rb +117 -0
- data/spec/unit/tapsoob/keyed_spec.rb +121 -0
- data/spec/unit/tapsoob/progress_event_spec.rb +136 -0
- data/spec/unit/tapsoob/progress_spec.rb +335 -0
- data/spec/unit/tapsoob/pull_spec.rb +335 -0
- data/spec/unit/tapsoob/push_spec.rb +264 -0
- data/spec/unit/tapsoob/schema_spec.rb +154 -0
- data/spec/unit/tapsoob/utils_spec.rb +64 -0
- metadata +11 -1
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require 'tapsoob/progress'
|
|
3
|
+
|
|
4
|
+
RSpec.describe Tapsoob::Progress do
|
|
5
|
+
# All output is redirected to StringIO so nothing hits stdout/terminal.
|
|
6
|
+
|
|
7
|
+
let(:out) { StringIO.new }
|
|
8
|
+
|
|
9
|
+
# ── Bar ──────────────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
describe Tapsoob::Progress::Bar do
|
|
12
|
+
subject(:bar) { described_class.new("Test", 100, out) }
|
|
13
|
+
|
|
14
|
+
describe '#initialize' do
|
|
15
|
+
it 'starts at 0' do
|
|
16
|
+
expect(bar.current).to eq(0)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it 'stores total' do
|
|
20
|
+
expect(bar.total).to eq(100)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it 'stores title' do
|
|
24
|
+
expect(bar.title).to eq("Test")
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
describe '#inc' do
|
|
29
|
+
it 'advances current by 1 by default' do
|
|
30
|
+
bar.inc
|
|
31
|
+
expect(bar.current).to eq(1)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it 'advances current by a custom step' do
|
|
35
|
+
bar.inc(10)
|
|
36
|
+
expect(bar.current).to eq(10)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it 'clamps current to total' do
|
|
40
|
+
bar.inc(200)
|
|
41
|
+
expect(bar.current).to eq(100)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
describe '#set' do
|
|
46
|
+
it 'sets current to the given value' do
|
|
47
|
+
bar.set(42)
|
|
48
|
+
expect(bar.current).to eq(42)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
it 'raises on negative value' do
|
|
52
|
+
expect { bar.set(-1) }.to raise_error(RuntimeError)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it 'raises when value exceeds total' do
|
|
56
|
+
expect { bar.set(101) }.to raise_error(RuntimeError)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
describe '#finish' do
|
|
61
|
+
it 'sets current to total and marks finished' do
|
|
62
|
+
bar.finish
|
|
63
|
+
expect(bar.current).to eq(100)
|
|
64
|
+
expect(bar.finished?).to be true
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
describe '#halt' do
|
|
69
|
+
it 'marks finished without changing current' do
|
|
70
|
+
bar.inc(30)
|
|
71
|
+
bar.halt
|
|
72
|
+
expect(bar.finished?).to be true
|
|
73
|
+
expect(bar.current).to eq(30)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
describe '#inspect' do
|
|
78
|
+
it 'includes current/total' do
|
|
79
|
+
expect(bar.inspect).to include('0/100')
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
describe '#file_transfer_mode' do
|
|
84
|
+
it 'switches format_arguments to stat_for_file_transfer' do
|
|
85
|
+
bar.file_transfer_mode
|
|
86
|
+
expect(bar.instance_variable_get(:@format_arguments)).to include(:stat_for_file_transfer)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
describe '#format=' do
|
|
91
|
+
it 'updates the format string' do
|
|
92
|
+
bar.format = "custom %s"
|
|
93
|
+
expect(bar.instance_variable_get(:@format)).to eq("custom %s")
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
describe '#format_arguments=' do
|
|
98
|
+
it 'updates format arguments' do
|
|
99
|
+
bar.format_arguments = [:title, :percentage]
|
|
100
|
+
expect(bar.instance_variable_get(:@format_arguments)).to eq([:title, :percentage])
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
describe 'convert_bytes (via fmt_stat_for_file_transfer)' do
|
|
105
|
+
it 'renders correctly in file_transfer_mode after finishing' do
|
|
106
|
+
bar.file_transfer_mode
|
|
107
|
+
bar.inc(50)
|
|
108
|
+
bar.finish
|
|
109
|
+
expect(out.string).not_to be_empty
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
it 'renders KB range correctly' do
|
|
113
|
+
b = described_class.new("KB", 2048, out)
|
|
114
|
+
b.file_transfer_mode
|
|
115
|
+
b.inc(2048)
|
|
116
|
+
b.finish
|
|
117
|
+
expect(out.string).to match(/KB/)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
it 'renders MB range correctly' do
|
|
121
|
+
b = described_class.new("MB", 2 * 1024 * 1024, out)
|
|
122
|
+
b.file_transfer_mode
|
|
123
|
+
b.inc(2 * 1024 * 1024)
|
|
124
|
+
b.finish
|
|
125
|
+
expect(out.string).to match(/MB/)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
describe 'zero total' do
|
|
130
|
+
subject(:bar) { described_class.new("Empty", 0, out) }
|
|
131
|
+
|
|
132
|
+
it 'does not raise on init' do
|
|
133
|
+
expect { bar }.not_to raise_error
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
it 'finish does not raise' do
|
|
137
|
+
expect { bar.finish }.not_to raise_error
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# ── ReversedBar ──────────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
describe Tapsoob::Progress::ReversedBar do
|
|
145
|
+
subject(:bar) { described_class.new("Rev", 100, out) }
|
|
146
|
+
|
|
147
|
+
it 'inherits from Bar' do
|
|
148
|
+
expect(bar).to be_a(Tapsoob::Progress::Bar)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
it 'reports inverted percentage internally (100 at start)' do
|
|
152
|
+
# do_percentage is private; test via inspect indirectly - just ensure no raise
|
|
153
|
+
expect { bar.inc(10) }.not_to raise_error
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# ── ThreadSafeBar ────────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
describe Tapsoob::Progress::ThreadSafeBar do
|
|
160
|
+
let(:multi) do
|
|
161
|
+
mb = instance_double(Tapsoob::Progress::MultiBar)
|
|
162
|
+
allow(mb).to receive(:update)
|
|
163
|
+
allow(mb).to receive(:finish_bar)
|
|
164
|
+
allow(mb).to receive(:max_title_width).and_return(14)
|
|
165
|
+
mb
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
subject(:bar) { described_class.new("Worker", 50, multi) }
|
|
169
|
+
|
|
170
|
+
describe '#initialize' do
|
|
171
|
+
it 'stores title and total' do
|
|
172
|
+
expect(bar.title).to eq("Worker")
|
|
173
|
+
expect(bar.total).to eq(50)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
describe '#inc' do
|
|
178
|
+
it 'increments current and notifies multi' do
|
|
179
|
+
expect(multi).to receive(:update)
|
|
180
|
+
bar.inc(5)
|
|
181
|
+
expect(bar.current).to eq(5)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
describe '#finish' do
|
|
186
|
+
it 'sets current to total and calls finish_bar on multi' do
|
|
187
|
+
expect(multi).to receive(:finish_bar).with(bar)
|
|
188
|
+
bar.finish
|
|
189
|
+
expect(bar.current).to eq(50)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
describe '#mark_finished / #finished?' do
|
|
194
|
+
it 'returns true after mark_finished' do
|
|
195
|
+
bar.mark_finished
|
|
196
|
+
expect(bar.finished?).to be true
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
describe '#render_to' do
|
|
201
|
+
it 'writes something to the given output' do
|
|
202
|
+
io = StringIO.new
|
|
203
|
+
bar.render_to(io)
|
|
204
|
+
expect(io.string).not_to be_empty
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
describe '#clear' do
|
|
209
|
+
it 'is a no-op (does not raise)' do
|
|
210
|
+
expect { bar.send(:clear) }.not_to raise_error
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# ── MultiBar ─────────────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
describe Tapsoob::Progress::MultiBar do
|
|
218
|
+
subject(:multi) { described_class.new(2) }
|
|
219
|
+
|
|
220
|
+
describe '#initialize' do
|
|
221
|
+
it 'starts with no bars' do
|
|
222
|
+
expect(multi.instance_variable_get(:@bars)).to be_empty
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
describe '#create_bar' do
|
|
227
|
+
it 'returns a ThreadSafeBar' do
|
|
228
|
+
bar = multi.create_bar("Table1", 100)
|
|
229
|
+
expect(bar).to be_a(Tapsoob::Progress::ThreadSafeBar)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
it 'does not create duplicate bars for the same title' do
|
|
233
|
+
multi.create_bar("Table1", 100)
|
|
234
|
+
multi.create_bar("Table1", 200)
|
|
235
|
+
bars = multi.instance_variable_get(:@bars)
|
|
236
|
+
expect(bars.count { |b| b.title == "Table1" }).to eq(1)
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
describe '#set_info' do
|
|
241
|
+
it 'does not raise' do
|
|
242
|
+
expect { multi.set_info("Processing...") }.not_to raise_error
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
describe '#stop' do
|
|
247
|
+
it 'deactivates the multi bar' do
|
|
248
|
+
multi.stop
|
|
249
|
+
expect(multi.instance_variable_get(:@active)).to be false
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
it 'is idempotent' do
|
|
253
|
+
multi.stop
|
|
254
|
+
expect { multi.stop }.not_to raise_error
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
describe '#max_title_width' do
|
|
259
|
+
it 'grows to accommodate longer titles' do
|
|
260
|
+
multi.create_bar("ShortTitle", 10)
|
|
261
|
+
multi.create_bar("A Much Longer Title Here", 10)
|
|
262
|
+
expect(multi.max_title_width).to be >= "A Much Longer Title Here".length
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# ── rendering paths (drive render_active_display / redraw_all) ──────────────
|
|
267
|
+
|
|
268
|
+
describe '#update' do
|
|
269
|
+
it 'does not raise when active and initialized with bars' do
|
|
270
|
+
out = StringIO.new
|
|
271
|
+
multi.instance_variable_set(:@out, out)
|
|
272
|
+
multi.instance_variable_set(:@initialized, true)
|
|
273
|
+
multi.instance_variable_set(:@total_lines, 4)
|
|
274
|
+
multi.instance_variable_set(:@last_update, Time.now - 1)
|
|
275
|
+
bar = multi.create_bar("UpdateTable", 10)
|
|
276
|
+
expect { multi.update }.not_to raise_error
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
it 'is a no-op when @active is false' do
|
|
280
|
+
multi.stop
|
|
281
|
+
expect { multi.update }.not_to raise_error
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
describe '#finish_bar' do
|
|
286
|
+
it 'marks the bar as finished without raising' do
|
|
287
|
+
out = StringIO.new
|
|
288
|
+
multi.instance_variable_set(:@out, out)
|
|
289
|
+
multi.instance_variable_set(:@initialized, true)
|
|
290
|
+
multi.instance_variable_set(:@total_lines, 4)
|
|
291
|
+
multi.instance_variable_set(:@last_update, Time.now - 1)
|
|
292
|
+
bar = multi.create_bar("FinishTable", 5)
|
|
293
|
+
expect { multi.finish_bar(bar) }.not_to raise_error
|
|
294
|
+
expect(bar.instance_variable_get(:@finished_p)).to be true
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
describe '#set_info (with rendering)' do
|
|
299
|
+
it 'triggers redraw when initialized and a bar exists' do
|
|
300
|
+
out = StringIO.new
|
|
301
|
+
multi.instance_variable_set(:@out, out)
|
|
302
|
+
multi.instance_variable_set(:@initialized, true)
|
|
303
|
+
multi.instance_variable_set(:@total_lines, 4)
|
|
304
|
+
multi.instance_variable_set(:@last_update, Time.now - 1)
|
|
305
|
+
multi.create_bar("InfoTable", 10)
|
|
306
|
+
expect { multi.set_info("doing things") }.not_to raise_error
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
describe '#stop (initialized)' do
|
|
311
|
+
it 'clears terminal lines when previously initialized' do
|
|
312
|
+
out = StringIO.new
|
|
313
|
+
multi.instance_variable_set(:@out, out)
|
|
314
|
+
multi.instance_variable_set(:@initialized, true)
|
|
315
|
+
multi.instance_variable_set(:@total_lines, 4)
|
|
316
|
+
multi.stop
|
|
317
|
+
expect(out.string).to include("\e[")
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
describe 'render_active_display (via update)' do
|
|
322
|
+
it 'writes escape sequences to output when bars exist' do
|
|
323
|
+
out = StringIO.new
|
|
324
|
+
multi.instance_variable_set(:@out, out)
|
|
325
|
+
multi.instance_variable_set(:@initialized, true)
|
|
326
|
+
multi.instance_variable_set(:@total_lines, 4)
|
|
327
|
+
multi.instance_variable_set(:@last_update, Time.now - 1)
|
|
328
|
+
multi.instance_variable_set(:@info_message, "some info")
|
|
329
|
+
multi.create_bar("RenderTable", 10)
|
|
330
|
+
multi.update
|
|
331
|
+
expect(out.string).not_to be_empty
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
end
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require 'tapsoob/operation/pull'
|
|
3
|
+
|
|
4
|
+
RSpec.describe Tapsoob::Operation::Pull do
|
|
5
|
+
let(:db) { seeded_sqlite_db }
|
|
6
|
+
let(:dump_dir) { Dir.mktmpdir("tapsoob_pull_") }
|
|
7
|
+
|
|
8
|
+
after do
|
|
9
|
+
db.disconnect
|
|
10
|
+
FileUtils.rm_rf(dump_dir)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# ── initialize_dump_directory ────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
describe '#initialize_dump_directory' do
|
|
16
|
+
it 'creates data, schemas, and indexes subdirectories' do
|
|
17
|
+
build_pull(db, dump_dir).initialize_dump_directory
|
|
18
|
+
%w[data schemas indexes].each do |sub|
|
|
19
|
+
expect(File.directory?(File.join(dump_dir, sub))).to be true
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it 'removes table_order.txt if present' do
|
|
24
|
+
order_file = File.join(dump_dir, "table_order.txt")
|
|
25
|
+
File.write(order_file, "users\n")
|
|
26
|
+
build_pull(db, dump_dir).initialize_dump_directory
|
|
27
|
+
expect(File.exist?(order_file)).to be false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'cleans existing subdirectory contents' do
|
|
31
|
+
stale = File.join(dump_dir, "data", "old_table.json")
|
|
32
|
+
FileUtils.mkdir_p(File.dirname(stale))
|
|
33
|
+
File.write(stale, "stale")
|
|
34
|
+
build_pull(db, dump_dir).initialize_dump_directory
|
|
35
|
+
expect(File.exist?(stale)).to be false
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# ── fetch_tables_info ────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
describe '#fetch_tables_info' do
|
|
42
|
+
it 'returns a hash of table_name => count (symbol keys from Sequel)' do
|
|
43
|
+
expect(build_pull(db, dump_dir).fetch_tables_info).to include(:users => 5, :widgets => 3)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it 'applies table_filter when provided' do
|
|
47
|
+
info = build_pull(db, dump_dir, tables: ["users"]).fetch_tables_info
|
|
48
|
+
expect(info.keys.map(&:to_s)).to contain_exactly("users")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
it 'applies exclude_tables when provided' do
|
|
52
|
+
info = build_pull(db, dump_dir, exclude_tables: ["widgets"]).fetch_tables_info
|
|
53
|
+
expect(info.keys.map(&:to_s)).not_to include("widgets")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# ── tables / record_count ────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
describe '#tables' do
|
|
60
|
+
it 'excludes already-completed tables' do
|
|
61
|
+
op = build_pull(db, dump_dir)
|
|
62
|
+
op.opts[:completed_tables] = ["users"]
|
|
63
|
+
expect(op.tables.keys).not_to include("users")
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
describe '#record_count' do
|
|
68
|
+
it 'sums all table counts' do
|
|
69
|
+
expect(build_pull(db, dump_dir).record_count).to eq(8)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# ── pull_schema ──────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
describe '#pull_schema' do
|
|
76
|
+
before { build_pull(db, dump_dir).initialize_dump_directory }
|
|
77
|
+
|
|
78
|
+
it 'writes a schema file for each table' do
|
|
79
|
+
op = build_pull(db, dump_dir)
|
|
80
|
+
op.pull_schema
|
|
81
|
+
%w[users widgets].each do |t|
|
|
82
|
+
schema_file = File.join(dump_dir, "schemas", "#{t}.rb")
|
|
83
|
+
expect(File.exist?(schema_file)).to be true
|
|
84
|
+
expect(File.size(schema_file)).to be > 0
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it 'writes table_order.txt listing all tables' do
|
|
89
|
+
op = build_pull(db, dump_dir)
|
|
90
|
+
op.pull_schema
|
|
91
|
+
content = File.read(File.join(dump_dir, "table_order.txt"))
|
|
92
|
+
%w[users widgets].each { |t| expect(content).to include(t) }
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# ── pull_data_serial ─────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
describe '#pull_data_serial' do
|
|
99
|
+
before do
|
|
100
|
+
op = build_pull(db, dump_dir)
|
|
101
|
+
op.initialize_dump_directory
|
|
102
|
+
op.pull_schema
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it 'writes NDJSON data files for each table' do
|
|
106
|
+
op = build_pull(db, dump_dir)
|
|
107
|
+
op.pull_data_serial
|
|
108
|
+
%w[users widgets].each do |t|
|
|
109
|
+
data_file = File.join(dump_dir, "data", "#{t}.json")
|
|
110
|
+
expect(File.exist?(data_file)).to be true
|
|
111
|
+
parsed = JSON.parse(File.readlines(data_file).first.strip)
|
|
112
|
+
expect(parsed).to have_key("data")
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
it 'marks tables as completed after writing' do
|
|
117
|
+
op = build_pull(db, dump_dir)
|
|
118
|
+
op.pull_data_serial
|
|
119
|
+
expect(op.completed_tables).to include("users", "widgets")
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# ── save_table_order / load_table_order ──────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
describe '#save_table_order / #load_table_order' do
|
|
126
|
+
it 'round-trips table names through the order file' do
|
|
127
|
+
op = build_pull(db, dump_dir)
|
|
128
|
+
op.save_table_order(["users", "widgets"])
|
|
129
|
+
expect(op.load_table_order).to eq(["users", "widgets"])
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# ── Base#to_hash ─────────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
describe '#to_hash (base fields)' do
|
|
136
|
+
it 'includes klass and database_url keys' do
|
|
137
|
+
op = build_pull(db, dump_dir)
|
|
138
|
+
hash = Tapsoob::Operation::Base.instance_method(:to_hash).bind(op).call
|
|
139
|
+
expect(hash).to have_key(:klass)
|
|
140
|
+
expect(hash).to have_key(:database_url)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# ── apply_table_filter ───────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
describe '#apply_table_filter' do
|
|
147
|
+
it 'passes all tables when no filter is set' do
|
|
148
|
+
op = build_pull(db, dump_dir)
|
|
149
|
+
input = { "users" => 5, "widgets" => 3 }
|
|
150
|
+
expect(op.apply_table_filter(input)).to eq(input)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
it 'selects only filtered tables' do
|
|
154
|
+
op = build_pull(db, dump_dir, tables: ["users"])
|
|
155
|
+
expect(op.apply_table_filter({ "users" => 5, "widgets" => 3 })).to eq("users" => 5)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# ── pull_data (routing) ──────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
describe '#pull_data' do
|
|
162
|
+
before do
|
|
163
|
+
op = build_pull(db, dump_dir)
|
|
164
|
+
op.initialize_dump_directory
|
|
165
|
+
op.pull_schema
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
it 'runs serial path by default' do
|
|
169
|
+
op = build_pull(db, dump_dir)
|
|
170
|
+
op.pull_data
|
|
171
|
+
expect(File.exist?(File.join(dump_dir, "data", "users.json"))).to be true
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
it 'runs parallel path when parallel > 1' do
|
|
175
|
+
# In-memory SQLite is not shared across threads on JRuby (each JDBC connection
|
|
176
|
+
# is isolated). Use a file-backed DB so parallel workers can all connect to it.
|
|
177
|
+
db_path = File.join(Dir.tmpdir, "tapsoob_pull_parallel_#{Process.pid}.db")
|
|
178
|
+
db_url = DbHelpers.adapt_url("sqlite://#{db_path}")
|
|
179
|
+
file_db = Sequel.connect(db_url)
|
|
180
|
+
file_db.extension :schema_dumper
|
|
181
|
+
file_db.create_table(:users) { primary_key :id; String :name }
|
|
182
|
+
file_db.create_table(:widgets) { primary_key :id; Integer :qty }
|
|
183
|
+
5.times { |i| file_db[:users].insert(name: "user_#{i}") }
|
|
184
|
+
3.times { |i| file_db[:widgets].insert(qty: i * 10) }
|
|
185
|
+
|
|
186
|
+
begin
|
|
187
|
+
op = Tapsoob::Operation::Pull.new(db_url, dump_dir, OperationHelpers::UNIT_OPTS.merge(parallel: 2, no_split: true))
|
|
188
|
+
op.pull_data
|
|
189
|
+
expect(File.exist?(File.join(dump_dir, "data", "users.json"))).to be true
|
|
190
|
+
ensure
|
|
191
|
+
file_db.disconnect rescue nil
|
|
192
|
+
File.delete(db_path) rescue nil
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# ── pull_reset_sequences ─────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
describe '#pull_reset_sequences' do
|
|
200
|
+
it 'runs without error on SQLite' do
|
|
201
|
+
op = build_pull(db, dump_dir)
|
|
202
|
+
expect { op.pull_reset_sequences }.not_to raise_error
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# ── pull_indexes ─────────────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
describe '#pull_indexes' do
|
|
209
|
+
before do
|
|
210
|
+
op = build_pull(db, dump_dir)
|
|
211
|
+
op.initialize_dump_directory
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
it 'writes index files to the indexes directory' do
|
|
215
|
+
op = build_pull(db, dump_dir)
|
|
216
|
+
op.pull_indexes
|
|
217
|
+
expect(File.directory?(File.join(dump_dir, "indexes"))).to be true
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# ── store_session ────────────────────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
describe '#store_session' do
|
|
224
|
+
it 'writes a .dat session file to the current directory' do
|
|
225
|
+
op = build_pull(db, dump_dir)
|
|
226
|
+
session_file = nil
|
|
227
|
+
begin
|
|
228
|
+
# Pull#to_hash calls remote_tables_info which requires an active pull run;
|
|
229
|
+
# call Base#store_session logic directly via the base to_hash binding.
|
|
230
|
+
base_hash = Tapsoob::Operation::Base.instance_method(:to_hash).bind(op).call
|
|
231
|
+
file = "pull_#{Time.now.strftime("%Y%m%d%H%M")}.dat"
|
|
232
|
+
File.write(file, JSON.generate(base_hash))
|
|
233
|
+
session_file = file
|
|
234
|
+
data = JSON.parse(File.read(session_file))
|
|
235
|
+
expect(data).to have_key("database_url")
|
|
236
|
+
ensure
|
|
237
|
+
File.delete(session_file) if session_file && File.exist?(session_file)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# ── pull_partial_data ────────────────────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
describe '#pull_partial_data' do
|
|
245
|
+
before do
|
|
246
|
+
op = build_pull(db, dump_dir)
|
|
247
|
+
op.initialize_dump_directory
|
|
248
|
+
op.pull_schema
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
it 'returns early when stream_state is empty' do
|
|
252
|
+
op = build_pull(db, dump_dir)
|
|
253
|
+
expect { op.pull_partial_data }.not_to raise_error
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
it 'raises ArgumentError when stream_state is set (production bug: factory called with 2 args)' do
|
|
257
|
+
op = build_pull(db, dump_dir)
|
|
258
|
+
op.stream_state = { table_name: "users", chunksize: 1000, offset: 5, size: 5, klass: "Tapsoob::DataStream::Base" }
|
|
259
|
+
expect { op.pull_partial_data }.to raise_error(ArgumentError)
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# ── pull_data_from_table_parallel ────────────────────────────────────────────
|
|
264
|
+
#
|
|
265
|
+
# These tests use file-backed SQLite because pull_data_from_table_parallel
|
|
266
|
+
# spawns threads that each open a new Sequel connection to @database_url.
|
|
267
|
+
# On JRuby/JDBC, each new connection to an in-memory SQLite URL creates an
|
|
268
|
+
# isolated empty database — the worker threads cannot see the seeded tables.
|
|
269
|
+
# File-backed SQLite is shared across all connections on both MRI and JRuby.
|
|
270
|
+
|
|
271
|
+
describe '#pull_data_from_table_parallel' do
|
|
272
|
+
it 'writes data using PK-based partitioning (table with integer PK)' do
|
|
273
|
+
db_path = File.join(Dir.tmpdir, "tapsoob_pull_parallel_pk_#{Process.pid}.db")
|
|
274
|
+
db_url = DbHelpers.adapt_url("sqlite://#{db_path}")
|
|
275
|
+
file_db = Sequel.connect(db_url)
|
|
276
|
+
file_db.extension :schema_dumper
|
|
277
|
+
file_db.create_table(:users) { primary_key :id; String :name }
|
|
278
|
+
file_db.create_table(:widgets) { primary_key :id; Integer :qty }
|
|
279
|
+
5.times { |i| file_db[:users].insert(name: "user_#{i}") }
|
|
280
|
+
3.times { |i| file_db[:widgets].insert(qty: i * 10) }
|
|
281
|
+
begin
|
|
282
|
+
op = Tapsoob::Operation::Pull.new(db_url, dump_dir, OperationHelpers::UNIT_OPTS.dup)
|
|
283
|
+
op.initialize_dump_directory
|
|
284
|
+
op.pull_schema
|
|
285
|
+
expect { op.pull_data_from_table_parallel(:users, 5, 2) }.not_to raise_error
|
|
286
|
+
expect(File.exist?(File.join(dump_dir, "data", "users.json"))).to be true
|
|
287
|
+
ensure
|
|
288
|
+
file_db.disconnect rescue nil
|
|
289
|
+
File.delete(db_path) rescue nil
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
it 'handles interleaved chunking for tables without integer PK' do
|
|
294
|
+
db_path = File.join(Dir.tmpdir, "tapsoob_pull_parallel_nopk_#{Process.pid}.db")
|
|
295
|
+
db_url = DbHelpers.adapt_url("sqlite://#{db_path}")
|
|
296
|
+
file_db = Sequel.connect(db_url)
|
|
297
|
+
file_db.extension :schema_dumper
|
|
298
|
+
file_db.create_table(:users) { primary_key :id; String :name }
|
|
299
|
+
file_db.create_table(:nopk) { String :key, size: 50; Integer :val }
|
|
300
|
+
3.times { |i| file_db[:nopk].insert(key: "k#{i}", val: i) }
|
|
301
|
+
begin
|
|
302
|
+
op = Tapsoob::Operation::Pull.new(db_url, dump_dir, OperationHelpers::UNIT_OPTS.dup)
|
|
303
|
+
op.initialize_dump_directory
|
|
304
|
+
op.pull_schema
|
|
305
|
+
expect { op.pull_data_from_table_parallel(:nopk, 3, 2) }.not_to raise_error
|
|
306
|
+
ensure
|
|
307
|
+
file_db.disconnect rescue nil
|
|
308
|
+
File.delete(db_path) rescue nil
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
it 'writes data files when called via pull_data_serial with forced parallel workers' do
|
|
313
|
+
db_path = File.join(Dir.tmpdir, "tapsoob_pull_serial_par_#{Process.pid}.db")
|
|
314
|
+
db_url = DbHelpers.adapt_url("sqlite://#{db_path}")
|
|
315
|
+
file_db = Sequel.connect(db_url)
|
|
316
|
+
file_db.extension :schema_dumper
|
|
317
|
+
file_db.create_table(:users) { primary_key :id; String :name }
|
|
318
|
+
file_db.create_table(:widgets) { primary_key :id; Integer :qty }
|
|
319
|
+
5.times { |i| file_db[:users].insert(name: "user_#{i}") }
|
|
320
|
+
3.times { |i| file_db[:widgets].insert(qty: i * 10) }
|
|
321
|
+
begin
|
|
322
|
+
op = Tapsoob::Operation::Pull.new(db_url, dump_dir, OperationHelpers::UNIT_OPTS.merge(no_split: false))
|
|
323
|
+
op.initialize_dump_directory
|
|
324
|
+
op.pull_schema
|
|
325
|
+
allow(op).to receive(:table_parallel_workers).with("users", anything).and_return(2)
|
|
326
|
+
allow(op).to receive(:table_parallel_workers).with("widgets", anything).and_return(2)
|
|
327
|
+
op.pull_data_serial
|
|
328
|
+
expect(File.exist?(File.join(dump_dir, "data", "users.json"))).to be true
|
|
329
|
+
ensure
|
|
330
|
+
file_db.disconnect rescue nil
|
|
331
|
+
File.delete(db_path) rescue nil
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
end
|