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.
@@ -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