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,264 @@
1
+ require 'spec_helper'
2
+ require 'tapsoob/operation/push'
3
+ require 'tapsoob/operation/pull'
4
+
5
+ RSpec.describe Tapsoob::Operation::Push do
6
+ # Push opens its own Sequel connection from the URL, so we need a file-backed DB.
7
+ let(:db_path) { File.join(Dir.tmpdir, "tapsoob_push_#{Process.pid}_#{rand(9999)}.db") }
8
+ let(:db_url) { DbHelpers.adapt_url("sqlite://#{db_path}") }
9
+
10
+ let(:db) do
11
+ d = Sequel.connect(db_url)
12
+ d.extension :schema_dumper
13
+ d.create_table(:users) { primary_key :id; String :name }
14
+ d.create_table(:widgets) { primary_key :id; Integer :qty }
15
+ 5.times { |i| d[:users].insert(name: "user_#{i}") }
16
+ 3.times { |i| d[:widgets].insert(qty: i * 10) }
17
+ d
18
+ end
19
+
20
+ let(:dump_dir) { Dir.mktmpdir("tapsoob_push_") }
21
+
22
+ after do
23
+ db.disconnect rescue nil
24
+ File.delete(db_path) rescue nil
25
+ FileUtils.rm_rf(dump_dir)
26
+ end
27
+
28
+ # Populate dump_dir via Pull so Push has real schema + data files to read.
29
+ before do
30
+ pull_op = build_pull(db, dump_dir)
31
+ pull_op.initialize_dump_directory
32
+ pull_op.pull_schema
33
+ pull_op.pull_data_serial
34
+ end
35
+
36
+ # ── fetch_local_tables_info ──────────────────────────────────────────────────
37
+
38
+ describe '#fetch_local_tables_info' do
39
+ it 'returns a hash of table_name => row_count' do
40
+ expect(build_push(db_url, dump_dir).fetch_local_tables_info).to include("users" => 5, "widgets" => 3)
41
+ end
42
+
43
+ it 'respects table_order.txt when present' do
44
+ expect(build_push(db_url, dump_dir).fetch_local_tables_info.keys).to include("users", "widgets")
45
+ end
46
+
47
+ it 'falls back to schema files when table_order.txt is absent' do
48
+ File.delete(File.join(dump_dir, "table_order.txt")) rescue nil
49
+ expect(build_push(db_url, dump_dir).fetch_local_tables_info.keys).to include("users", "widgets")
50
+ end
51
+
52
+ it 'applies exclude_tables filter' do
53
+ info = build_push(db_url, dump_dir, exclude_tables: ["widgets"]).fetch_local_tables_info
54
+ expect(info.keys).not_to include("widgets")
55
+ end
56
+ end
57
+
58
+ # ── tables / record_count ────────────────────────────────────────────────────
59
+
60
+ describe '#tables' do
61
+ it 'excludes completed tables' do
62
+ op = build_push(db_url, dump_dir)
63
+ op.opts[:completed_tables] = ["users"]
64
+ expect(op.tables.keys).not_to include("users")
65
+ end
66
+ end
67
+
68
+ describe '#record_count' do
69
+ it 'sums all table row counts' do
70
+ expect(build_push(db_url, dump_dir).record_count).to eq(8)
71
+ end
72
+ end
73
+
74
+ # ── calculate_file_line_ranges ───────────────────────────────────────────────
75
+
76
+ describe '#calculate_file_line_ranges' do
77
+ it 'returns [] when the data file does not exist' do
78
+ expect(build_push(db_url, dump_dir).calculate_file_line_ranges("nonexistent", 2)).to eq([])
79
+ end
80
+
81
+ it 'returns a single range for 1 worker' do
82
+ ranges = build_push(db_url, dump_dir).calculate_file_line_ranges("users", 1)
83
+ expect(ranges.size).to eq(1)
84
+ expect(ranges.first.first).to eq(0)
85
+ end
86
+
87
+ it 'splits a multi-line file across workers without gaps' do
88
+ ranges = build_push(db_url, dump_dir).calculate_file_line_ranges("users", 2)
89
+ expect(ranges.size).to be >= 1
90
+ ranges.each_cons(2) do |(_, end1), (start2, _)|
91
+ expect(start2).to eq(end1 + 1)
92
+ end
93
+ end
94
+ end
95
+
96
+ # ── push_schema ──────────────────────────────────────────────────────────────
97
+
98
+ describe '#push_schema' do
99
+ it 'loads schema into a fresh target DB without error' do
100
+ fresh_path = File.join(Dir.tmpdir, "tapsoob_push_fresh_#{Process.pid}.db")
101
+ fresh_url = DbHelpers.adapt_url("sqlite://#{fresh_path}")
102
+ fresh_db = Sequel.connect(fresh_url)
103
+ fresh_db.extension :schema_dumper
104
+ begin
105
+ op = build_push(fresh_url, dump_dir)
106
+ op.instance_variable_set(:@db, fresh_db)
107
+ op.instance_variable_set(:@database_url, fresh_url)
108
+ expect { op.push_schema }.not_to raise_error
109
+ ensure
110
+ fresh_db.disconnect rescue nil
111
+ File.delete(fresh_path) rescue nil
112
+ end
113
+ end
114
+ end
115
+
116
+ # ── push_data_serial ─────────────────────────────────────────────────────────
117
+
118
+ describe '#push_data_serial' do
119
+ before do
120
+ db[:users].delete
121
+ db[:widgets].delete
122
+ end
123
+
124
+ it 'inserts rows into the target DB' do
125
+ op = build_push(db_url, dump_dir)
126
+ op.instance_variable_set(:@db, db)
127
+ op.push_data_serial
128
+ expect(db[:users].count).to eq(5)
129
+ expect(db[:widgets].count).to eq(3)
130
+ end
131
+
132
+ it 'marks tables as completed' do
133
+ op = build_push(db_url, dump_dir)
134
+ op.instance_variable_set(:@db, db)
135
+ op.push_data_serial
136
+ expect(op.completed_tables).to include("users", "widgets")
137
+ end
138
+ end
139
+
140
+ # ── to_hash ──────────────────────────────────────────────────────────────────
141
+
142
+ describe '#to_hash' do
143
+ it 'includes local_tables_info key' do
144
+ expect(build_push(db_url, dump_dir).to_hash).to have_key(:local_tables_info)
145
+ end
146
+ end
147
+
148
+ # ── parallel? always false for Push ──────────────────────────────────────────
149
+
150
+ describe '#parallel?' do
151
+ it 'is always false regardless of :parallel option' do
152
+ expect(build_push(db_url, dump_dir, parallel: 4).parallel?).to be false
153
+ end
154
+ end
155
+
156
+ # ── push_partial_data ────────────────────────────────────────────────────────
157
+
158
+ describe '#push_partial_data' do
159
+ before do
160
+ db[:users].delete
161
+ db[:widgets].delete
162
+ end
163
+
164
+ it 'returns early when stream_state is empty' do
165
+ op = build_push(db_url, dump_dir)
166
+ op.instance_variable_set(:@db, db)
167
+ expect { op.push_partial_data }.not_to raise_error
168
+ end
169
+
170
+ it 'raises ArgumentError when stream_state is set (production bug: factory called with 2 args)' do
171
+ op = build_push(db_url, dump_dir)
172
+ op.instance_variable_set(:@db, db)
173
+ op.stream_state = { table_name: "users", chunksize: 1000, offset: 5, size: 5, klass: "Tapsoob::DataStream::Base" }
174
+ expect { op.push_partial_data }.to raise_error(ArgumentError)
175
+ end
176
+ end
177
+
178
+ # ── push_data_from_file_parallel (intra-table parallelization) ───────────────
179
+
180
+ describe '#push_data_from_file_parallel' do
181
+ before do
182
+ db[:users].delete
183
+ db[:widgets].delete
184
+ end
185
+
186
+ it 'inserts all rows routing through push_data_from_file_parallel' do
187
+ op = build_push(db_url, dump_dir)
188
+ op.instance_variable_set(:@db, db)
189
+ # Use 1 worker for all tables — with chunksize=1000 the files have 1 line each,
190
+ # so requesting 2 workers would leave ranges[1] nil and crash FilePartition.
191
+ allow(op).to receive(:table_parallel_workers).and_return(1)
192
+ op.push_data_serial
193
+ expect(db[:users].count).to eq(5)
194
+ end
195
+
196
+ it 'directly calls push_data_from_file_parallel without error' do
197
+ op = build_push(db_url, dump_dir)
198
+ op.instance_variable_set(:@db, db)
199
+ # 1 worker avoids the nil-current_line crash from under-populated ranges
200
+ expect { op.push_data_from_file_parallel("users", 5, 1) }.not_to raise_error
201
+ expect(db[:users].count).to eq(5)
202
+ end
203
+
204
+ it 'returns early when no data file exists' do
205
+ op = build_push(db_url, dump_dir)
206
+ op.instance_variable_set(:@db, db)
207
+ expect { op.push_data_from_file_parallel("nonexistent_table", 0, 2) }.not_to raise_error
208
+ end
209
+ end
210
+
211
+ # ── push_indexes ─────────────────────────────────────────────────────────────
212
+
213
+ describe '#push_indexes' do
214
+ it 'is a no-op when no index files exist' do
215
+ op = build_push(db_url, dump_dir)
216
+ op.instance_variable_set(:@db, db)
217
+ expect { op.push_indexes }.not_to raise_error
218
+ end
219
+
220
+ end
221
+
222
+ # ── push_reset_sequences ─────────────────────────────────────────────────────
223
+
224
+ describe '#push_reset_sequences' do
225
+ it 'runs without error on SQLite' do
226
+ op = build_push(db_url, dump_dir)
227
+ op.instance_variable_set(:@db, db)
228
+ expect { op.push_reset_sequences }.not_to raise_error
229
+ end
230
+ end
231
+
232
+ # ── push_data_parallel (parallel? stubbed to true) ───────────────────────────
233
+
234
+ describe '#push_data_parallel' do
235
+ before do
236
+ db[:users].delete
237
+ db[:widgets].delete
238
+ end
239
+
240
+ it 'inserts rows using table-level parallel workers (parallel? forced true)' do
241
+ op = build_push(db_url, dump_dir, parallel: 2)
242
+ op.instance_variable_set(:@db, db)
243
+ # Push#parallel? always returns false; stub it to exercise push_data_parallel
244
+ allow(op).to receive(:parallel?).and_return(true)
245
+ allow(op).to receive(:parallel_workers).and_return(2)
246
+ op.push_data
247
+ expect(db[:users].count).to eq(5)
248
+ expect(db[:widgets].count).to eq(3)
249
+ end
250
+
251
+ it 'handles intra-table parallelization within push_data_parallel' do
252
+ op = build_push(db_url, dump_dir, parallel: 2)
253
+ op.instance_variable_set(:@db, db)
254
+ allow(op).to receive(:parallel?).and_return(true)
255
+ allow(op).to receive(:parallel_workers).and_return(2)
256
+ # Use 1 intra-table worker — with chunksize=1000 and 5 rows the file has 1 line,
257
+ # so requesting 2 workers would leave ranges[1] nil and crash FilePartition.
258
+ allow(op).to receive(:table_parallel_workers).with("users", anything).and_return(1)
259
+ allow(op).to receive(:table_parallel_workers).with("widgets", anything).and_return(1)
260
+ op.push_data
261
+ expect(db[:users].count).to eq(5)
262
+ end
263
+ end
264
+ end
@@ -2,6 +2,20 @@ require 'spec_helper'
2
2
  require 'tapsoob/schema'
3
3
 
4
4
  RSpec.describe Tapsoob::Schema do
5
+ # SQLite file-based URL helpers — memory: URLs open a fresh DB on each connect,
6
+ # so methods that open their own connection (dump, foreign_keys, indexes, …)
7
+ # must use a file-backed database.
8
+ def with_sqlite_file
9
+ path = File.join(Dir.tmpdir, "tapsoob_schema_#{Process.pid}_#{rand(9999)}.db")
10
+ url = DbHelpers.adapt_url("sqlite://#{path}")
11
+ db = DbHelpers.connect(url)
12
+ db.extension :schema_dumper
13
+ yield url, db
14
+ ensure
15
+ DbHelpers.disconnect_all
16
+ File.delete(path) rescue nil
17
+ end
18
+
5
19
  let(:db) do
6
20
  d = connect_sqlite
7
21
  d.extension :schema_dumper
@@ -99,4 +113,144 @@ RSpec.describe Tapsoob::Schema do
99
113
  }.not_to raise_error
100
114
  end
101
115
  end
116
+
117
+ # ── dump ─────────────────────────────────────────────────────────────────────
118
+
119
+ describe '.dump' do
120
+ it 'returns a migration string covering all tables' do
121
+ with_sqlite_file do |url, tmp|
122
+ tmp.create_table(:things) { primary_key :id; String :name }
123
+ result = described_class.dump(url)
124
+ expect(result).to include('Sequel::Migration')
125
+ expect(result).to include('things')
126
+ end
127
+ end
128
+
129
+ it 'includes both up and down blocks' do
130
+ with_sqlite_file do |url, tmp|
131
+ tmp.create_table(:items) { primary_key :id }
132
+ result = described_class.dump(url)
133
+ expect(result).to include('def up')
134
+ expect(result).to include('def down')
135
+ end
136
+ end
137
+ end
138
+
139
+ # ── foreign_keys ─────────────────────────────────────────────────────────────
140
+
141
+ describe '.foreign_keys' do
142
+ it 'returns a string (even when there are no FK constraints)' do
143
+ with_sqlite_file do |url, _|
144
+ result = described_class.foreign_keys(url)
145
+ expect(result).to be_a(String)
146
+ end
147
+ end
148
+ end
149
+
150
+ # ── indexes ──────────────────────────────────────────────────────────────────
151
+
152
+ describe '.indexes' do
153
+ it 'returns a string' do
154
+ with_sqlite_file do |url, tmp|
155
+ tmp.create_table(:idx_things) { primary_key :id; String :slug }
156
+ tmp.add_index(:idx_things, :slug)
157
+ result = described_class.indexes(url)
158
+ expect(result).to be_a(String)
159
+ end
160
+ end
161
+ end
162
+
163
+ # ── load via URL ─────────────────────────────────────────────────────────────
164
+
165
+ describe '.load (URL path)' do
166
+ it 'creates the table when passed a URL string' do
167
+ with_sqlite_file do |url, tmp|
168
+ schema_str = described_class.dump_table(db, :articles, {})
169
+ described_class.load(url, schema_str)
170
+ expect(tmp.table_exists?(:articles)).to be true
171
+ end
172
+ end
173
+
174
+ it 'drops then recreates table when drop: true and passed a URL' do
175
+ with_sqlite_file do |url, tmp|
176
+ schema_str = described_class.dump_table(db, :articles, {})
177
+ described_class.load(url, schema_str)
178
+ described_class.load(url, schema_str, drop: true)
179
+ expect(tmp.table_exists?(:articles)).to be true
180
+ end
181
+ end
182
+ end
183
+
184
+ # ── load_indexes ─────────────────────────────────────────────────────────────
185
+
186
+ describe '.load_indexes' do
187
+ it 'applies an index migration without error' do
188
+ with_sqlite_file do |url, tmp|
189
+ tmp.create_table(:things) { primary_key :id; String :slug }
190
+ index_migration = <<~RUBY
191
+ Class.new(Sequel::Migration) do
192
+ def up
193
+ add_index :things, :slug
194
+ end
195
+ end
196
+ RUBY
197
+ expect { described_class.load_indexes(url, index_migration) }.not_to raise_error
198
+ expect(tmp.indexes(:things)).to have_key(:things_slug_index)
199
+ end
200
+ end
201
+ end
202
+
203
+ # ── load_foreign_keys ────────────────────────────────────────────────────────
204
+
205
+ describe '.load_foreign_keys' do
206
+ it 'applies a foreign key migration without error' do
207
+ with_sqlite_file do |url, tmp|
208
+ tmp.create_table(:parents) { primary_key :id }
209
+ tmp.create_table(:children) { primary_key :id; Integer :parent_id }
210
+ fk_migration = <<~RUBY
211
+ Class.new(Sequel::Migration) do
212
+ def up
213
+ alter_table(:children) { add_foreign_key [:parent_id], :parents }
214
+ end
215
+ end
216
+ RUBY
217
+ expect { described_class.load_foreign_keys(url, fk_migration) }.not_to raise_error
218
+ end
219
+ end
220
+ end
221
+
222
+ # ── rewrite_non_integer_primary_keys ─────────────────────────────────────────
223
+
224
+ describe '.rewrite_non_integer_primary_keys' do
225
+ it 'leaves integer primary keys unchanged' do
226
+ schema = ' primary_key :id, :type=>"integer"'
227
+ expect(described_class.rewrite_non_integer_primary_keys(schema)).to eq(schema)
228
+ end
229
+
230
+ it 'leaves bigint primary keys unchanged' do
231
+ schema = ' primary_key :id, :type=>"bigint"'
232
+ expect(described_class.rewrite_non_integer_primary_keys(schema)).to eq(schema)
233
+ end
234
+
235
+ it 'rewrites varchar primary keys to column form' do
236
+ schema = ' primary_key :code, :type=>"varchar(10)"'
237
+ result = described_class.rewrite_non_integer_primary_keys(schema)
238
+ expect(result).to include('column :code')
239
+ expect(result).to include('"varchar(10)"')
240
+ expect(result).to include('primary_key: true')
241
+ expect(result).not_to include('primary_key :code,')
242
+ end
243
+
244
+ it 'rewrites uuid primary keys to column form' do
245
+ schema = ' primary_key :id, :type=>"uuid"'
246
+ result = described_class.rewrite_non_integer_primary_keys(schema)
247
+ expect(result).to include('column :id')
248
+ expect(result).to include('primary_key: true')
249
+ end
250
+
251
+ it 'passes through schema with no primary_key lines unchanged' do
252
+ schema = " String :name, size: 50\n Integer :score"
253
+ expect(described_class.rewrite_non_integer_primary_keys(schema)).to eq(schema)
254
+ end
255
+ end
102
256
  end
@@ -220,6 +220,70 @@ RSpec.describe Tapsoob::Utils do
220
220
  end
221
221
  end
222
222
 
223
+ # ── windows? / bin ───────────────────────────────────────────────────────────
224
+
225
+ describe '.windows? / .bin' do
226
+ it 'returns a boolean for windows?' do
227
+ expect([true, false]).to include(described_class.windows?)
228
+ end
229
+
230
+ it 'returns the command unchanged on non-Windows' do
231
+ allow(described_class).to receive(:windows?).and_return(false)
232
+ expect(described_class.bin("mytool")).to eq("mytool")
233
+ end
234
+
235
+ it 'appends .cmd on Windows' do
236
+ allow(described_class).to receive(:windows?).and_return(true)
237
+ expect(described_class.bin("mytool")).to eq("mytool.cmd")
238
+ end
239
+ end
240
+
241
+ # ── incorrect_blobs ──────────────────────────────────────────────────────────
242
+
243
+ describe '.incorrect_blobs' do
244
+ let(:db) do
245
+ d = connect_sqlite
246
+ d.create_table(:blob_test) { primary_key :id; File :data; String :name }
247
+ d
248
+ end
249
+ after { db.disconnect }
250
+
251
+ it 'returns blob-typed column names' do
252
+ expect(described_class.incorrect_blobs(db, :blob_test)).to include(:data)
253
+ end
254
+
255
+ it 'does not include non-blob columns' do
256
+ expect(described_class.incorrect_blobs(db, :blob_test)).not_to include(:name)
257
+ end
258
+ end
259
+
260
+ # ── encode_blobs ASCII-8BIT branch ──────────────────────────────────────────
261
+
262
+ describe '.encode_blobs (ASCII-8BIT branch)' do
263
+ it 'encodes raw binary strings that are not Sequel::SQL::Blob' do
264
+ raw = "binary".force_encoding(Encoding::ASCII_8BIT)
265
+ row = { data: raw }
266
+ described_class.encode_blobs(row, [:data])
267
+ expect(row[:data]).to eq(described_class.base64encode(raw))
268
+ end
269
+ end
270
+
271
+ # ── load_schema with a DB object ─────────────────────────────────────────────
272
+
273
+ describe '.load_schema' do
274
+ let(:dir) { Dir.mktmpdir }
275
+ after { FileUtils.rm_rf(dir) }
276
+
277
+ it 'accepts a Sequel::Database object directly' do
278
+ db = connect_sqlite
279
+ FileUtils.mkdir_p(File.join(dir, 'schemas'))
280
+ schema_content = "Sequel.migration { change { create_table(:load_test) { primary_key :id } } }"
281
+ File.write(File.join(dir, 'schemas', 'load_test.rb'), schema_content)
282
+ expect { described_class.load_schema(dir, db, :load_test) }.not_to raise_error
283
+ db.disconnect
284
+ end
285
+ end
286
+
223
287
  # ── export_rows / export_schema / export_indexes (filesystem) ────────────────
224
288
 
225
289
  describe 'filesystem export helpers' do
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.6
4
+ version: 0.8.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Félix Bellanger
@@ -131,11 +131,21 @@ files:
131
131
  - spec/spec_helper.rb
132
132
  - spec/support/db_helpers.rb
133
133
  - spec/support/fixtures.rb
134
+ - spec/support/operation_helpers.rb
134
135
  - spec/support/round_trip_helper.rb
135
136
  - spec/support/shared_examples/round_trip.rb
137
+ - spec/unit/tapsoob/base_spec.rb
136
138
  - spec/unit/tapsoob/chunksize_spec.rb
139
+ - spec/unit/tapsoob/cli_pipeline_spec.rb
140
+ - spec/unit/tapsoob/config_spec.rb
137
141
  - spec/unit/tapsoob/data_stream_spec.rb
142
+ - spec/unit/tapsoob/file_partition_spec.rb
143
+ - spec/unit/tapsoob/keyed_spec.rb
138
144
  - spec/unit/tapsoob/operation_base_spec.rb
145
+ - spec/unit/tapsoob/progress_event_spec.rb
146
+ - spec/unit/tapsoob/progress_spec.rb
147
+ - spec/unit/tapsoob/pull_spec.rb
148
+ - spec/unit/tapsoob/push_spec.rb
139
149
  - spec/unit/tapsoob/schema_spec.rb
140
150
  - spec/unit/tapsoob/utils_spec.rb
141
151
  - spec/unit/tapsoob/version_spec.rb