wp2txt 2.2.0 → 2.3.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/.gitignore +3 -0
- data/CHANGELOG.md +10 -0
- data/Dockerfile +3 -0
- data/README.md +36 -56
- data/Rakefile +4 -1
- data/bin/wp2txt +1 -0
- data/bin/wp2txt-mcp +18 -19
- data/docs/RESEARCH.md +207 -0
- data/lib/wp2txt/cli.rb +43 -0
- data/lib/wp2txt/corpus.rb +358 -23
- data/lib/wp2txt/index_commands.rb +83 -0
- data/lib/wp2txt/langlinks_importer.rb +273 -0
- data/lib/wp2txt/metadata_index.rb +68 -2
- data/lib/wp2txt/multistream.rb +29 -0
- data/lib/wp2txt/output_path.rb +27 -0
- data/lib/wp2txt/version.rb +1 -1
- data/spec/auto_download_spec.rb +77 -0
- data/spec/langlinks_importer_spec.rb +308 -0
- data/spec/multi_dump_attach_spec.rb +174 -0
- data/spec/support/meta_db_fixture.rb +53 -0
- data/spec/titles_output_path_spec.rb +338 -0
- metadata +12 -1
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
require "zlib"
|
|
6
|
+
require_relative "support/multistream_fixture"
|
|
7
|
+
require_relative "support/meta_db_fixture"
|
|
8
|
+
require_relative "../lib/wp2txt/langlinks_importer"
|
|
9
|
+
require_relative "../lib/wp2txt/multistream"
|
|
10
|
+
|
|
11
|
+
RSpec.describe Wp2txt::LanglinksImporter do
|
|
12
|
+
include MultistreamFixture
|
|
13
|
+
include MetaDbFixture
|
|
14
|
+
|
|
15
|
+
around do |example|
|
|
16
|
+
Dir.mktmpdir do |dir|
|
|
17
|
+
@dir = dir
|
|
18
|
+
@multistream_path, @index_path = create_fixture(dir)
|
|
19
|
+
|
|
20
|
+
ms_index = Wp2txt::MultistreamIndex.new(@index_path, use_cache: false, show_progress: false)
|
|
21
|
+
@db_path = Wp2txt::MetadataIndex.path_for(@multistream_path, cache_dir: dir)
|
|
22
|
+
Wp2txt::MetadataIndexBuilder.new(
|
|
23
|
+
@multistream_path, ms_index.stream_offsets,
|
|
24
|
+
db_path: @db_path, num_processes: 0
|
|
25
|
+
).build
|
|
26
|
+
example.run
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# MySQL dump content covering the tricky cases: escaped quotes/backslashes,
|
|
31
|
+
# commas and parens inside titles, underscores, multiple INSERT statements,
|
|
32
|
+
# and an INSERT for a different table (must be ignored)
|
|
33
|
+
LANGLINKS_SQL = <<~SQL
|
|
34
|
+
-- MySQL dump fixture
|
|
35
|
+
CREATE TABLE `langlinks` (
|
|
36
|
+
`ll_from` int unsigned NOT NULL DEFAULT 0,
|
|
37
|
+
`ll_lang` varbinary(20) NOT NULL DEFAULT '',
|
|
38
|
+
`ll_title` varbinary(255) NOT NULL DEFAULT ''
|
|
39
|
+
) ENGINE=InnoDB DEFAULT CHARSET=binary;
|
|
40
|
+
|
|
41
|
+
INSERT INTO `langlinks` VALUES (1,'en','Film A'),(1,'de','Film A (Film)'),(2,'en','It\\'s a Film, Really (1984)');
|
|
42
|
+
INSERT INTO `langlinks` VALUES (3,'en','Back\\\\slash Title'),(3,'fr','Film B'),(4,'en','Underscore_title');
|
|
43
|
+
INSERT INTO `pagelinks` VALUES (1,'Foo',0);
|
|
44
|
+
UNLOCK TABLES;
|
|
45
|
+
SQL
|
|
46
|
+
|
|
47
|
+
def write_langlinks(name, content = LANGLINKS_SQL, gzip: false)
|
|
48
|
+
path = File.join(@dir, name)
|
|
49
|
+
if gzip
|
|
50
|
+
Zlib::GzipWriter.open(path) { |gz| gz.write(content) }
|
|
51
|
+
else
|
|
52
|
+
File.write(path, content)
|
|
53
|
+
end
|
|
54
|
+
path
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def import(path, **opts)
|
|
58
|
+
described_class.new(@db_path, cache_dir: @dir).import!(path, **opts)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def langlinks_rows
|
|
62
|
+
db = SQLite3::Database.new(@db_path, readonly: true)
|
|
63
|
+
rows = db.execute("SELECT ll_from, ll_lang, ll_title FROM langlinks ORDER BY ll_from, ll_lang")
|
|
64
|
+
db.close
|
|
65
|
+
rows
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
describe "parsing and normalization" do
|
|
69
|
+
it "imports tuples with escapes, commas, parens, and multiple INSERT statements" do
|
|
70
|
+
path = write_langlinks("testwiki-20260101-langlinks.sql")
|
|
71
|
+
result = import(path)
|
|
72
|
+
|
|
73
|
+
expect(result[:status]).to eq(:imported)
|
|
74
|
+
expect(result[:row_count]).to eq(6)
|
|
75
|
+
expect(langlinks_rows).to contain_exactly(
|
|
76
|
+
[1, "en", "Film A"],
|
|
77
|
+
[1, "de", "Film A (Film)"],
|
|
78
|
+
[2, "en", "It's a Film, Really (1984)"],
|
|
79
|
+
[3, "en", 'Back\slash Title'],
|
|
80
|
+
[3, "fr", "Film B"],
|
|
81
|
+
[4, "en", "Underscore title"]
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it "reads .sql.gz files" do
|
|
86
|
+
path = write_langlinks("testwiki-20260101-langlinks.sql.gz", gzip: true)
|
|
87
|
+
expect(import(path)[:row_count]).to eq(6)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
it "creates both indexes after the load" do
|
|
91
|
+
path = write_langlinks("testwiki-20260101-langlinks.sql")
|
|
92
|
+
import(path)
|
|
93
|
+
db = SQLite3::Database.new(@db_path, readonly: true)
|
|
94
|
+
indexes = db.execute("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'langlinks'").flatten
|
|
95
|
+
db.close
|
|
96
|
+
expect(indexes).to contain_exactly("idx_langlinks_from", "idx_langlinks_lang_title")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it "keeps all rows across batch boundaries" do
|
|
100
|
+
stub_const("Wp2txt::LanglinksImporter::BATCH_SIZE", 2)
|
|
101
|
+
path = write_langlinks("testwiki-20260101-langlinks.sql")
|
|
102
|
+
expect(import(path)[:row_count]).to eq(6)
|
|
103
|
+
expect(langlinks_rows.size).to eq(6)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
it "imports multibyte (ja/zh/ko) titles, including escapes and underscores" do
|
|
107
|
+
sql = <<~SQL
|
|
108
|
+
INSERT INTO `langlinks` VALUES (1,'ja','宇宙戦艦ヤマト'),(2,'zh','粵語標題(電影)'),(3,'ko','한국어_제목'),(4,'ja','It\\'s 映画, Really(1984)');
|
|
109
|
+
SQL
|
|
110
|
+
path = write_langlinks("testwiki-20260101-langlinks.sql", sql)
|
|
111
|
+
result = import(path)
|
|
112
|
+
|
|
113
|
+
expect(result[:row_count]).to eq(4)
|
|
114
|
+
expect(langlinks_rows).to contain_exactly(
|
|
115
|
+
[1, "ja", "宇宙戦艦ヤマト"],
|
|
116
|
+
[2, "zh", "粵語標題(電影)"],
|
|
117
|
+
[3, "ko", "한국어 제목"],
|
|
118
|
+
[4, "ja", "It's 映画, Really(1984)"]
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it "parses large multibyte INSERT lines fast enough (performance regression)" do
|
|
123
|
+
# A single ~200KB+ extended INSERT line full of multibyte titles: the
|
|
124
|
+
# old character-index parser was O(n²) here (minutes), regex scan is O(n)
|
|
125
|
+
titles = ["宇宙戦艦ヤマト(映画)", "粵語標題(電影, 1984)", "한국어 제목", "時間の旅, それから"]
|
|
126
|
+
tuples = Array.new(6_000) { |i| "(#{i + 1},'ja','#{titles[i % titles.size]}')" }
|
|
127
|
+
sql = +"INSERT INTO `langlinks` VALUES " << tuples.join(",") << ";\n"
|
|
128
|
+
expect(sql.bytesize).to be > 200_000
|
|
129
|
+
|
|
130
|
+
path = write_langlinks("testwiki-20260101-langlinks.sql", sql)
|
|
131
|
+
importer = described_class.new(@db_path, cache_dir: @dir)
|
|
132
|
+
count = 0
|
|
133
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
134
|
+
importer.send(:each_source_row, path) { count += 1 }
|
|
135
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
|
136
|
+
|
|
137
|
+
expect(count).to eq(6_000)
|
|
138
|
+
expect(elapsed).to be < 0.5
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
it "skips and counts rows whose title contains invalid UTF-8 bytes" do
|
|
142
|
+
# Real dumps contain historically corrupted bytes in ll_title (VARBINARY)
|
|
143
|
+
sql = +"INSERT INTO `langlinks` VALUES (1,'en','Film A'),(2,'en','Bad\xFFTitle'),(3,'fr','Film B');\n"
|
|
144
|
+
path = write_langlinks("testwiki-20260101-langlinks.sql", sql.b)
|
|
145
|
+
result = import(path)
|
|
146
|
+
|
|
147
|
+
expect(result[:status]).to eq(:imported)
|
|
148
|
+
expect(result[:row_count]).to eq(2)
|
|
149
|
+
expect(result[:skipped_invalid]).to eq(1)
|
|
150
|
+
expect(langlinks_rows).to contain_exactly([1, "en", "Film A"], [3, "fr", "Film B"])
|
|
151
|
+
expect(result[:provenance][:skipped_invalid]).to eq(1)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
it "skips and counts rows whose language code contains invalid UTF-8 bytes" do
|
|
155
|
+
sql = +"INSERT INTO `langlinks` VALUES (1,'e\xFFn','Film A'),(2,'en','Film B');\n"
|
|
156
|
+
path = write_langlinks("testwiki-20260101-langlinks.sql", sql.b)
|
|
157
|
+
result = import(path)
|
|
158
|
+
|
|
159
|
+
expect(result[:row_count]).to eq(1)
|
|
160
|
+
expect(result[:skipped_invalid]).to eq(1)
|
|
161
|
+
expect(langlinks_rows).to eq([[2, "en", "Film B"]])
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
it "handles invalid bytes in .sql.gz input as well" do
|
|
165
|
+
sql = +"INSERT INTO `langlinks` VALUES (1,'en','Bad\xFFTitle'),(2,'en','Film B');\n"
|
|
166
|
+
path = write_langlinks("testwiki-20260101-langlinks.sql.gz", sql.b, gzip: true)
|
|
167
|
+
result = import(path)
|
|
168
|
+
|
|
169
|
+
expect(result[:row_count]).to eq(1)
|
|
170
|
+
expect(result[:skipped_invalid]).to eq(1)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
it "filters target languages with the langs option" do
|
|
174
|
+
path = write_langlinks("testwiki-20260101-langlinks.sql")
|
|
175
|
+
result = import(path, langs: %w[en fr])
|
|
176
|
+
|
|
177
|
+
expect(result[:row_count]).to eq(5)
|
|
178
|
+
expect(langlinks_rows.map { |r| r[1] }.uniq).to contain_exactly("en", "fr")
|
|
179
|
+
expect(result[:provenance][:lang_filter]).to eq("en,fr")
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
describe "version pinning" do
|
|
184
|
+
it "rejects a langlinks file whose dump name differs from the metadata DB" do
|
|
185
|
+
path = write_langlinks("testwiki-20260102-langlinks.sql")
|
|
186
|
+
expect { import(path) }.to raise_error(ArgumentError, /version mismatch/)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
it "rejects a mismatched file even with force (no override)" do
|
|
190
|
+
path = write_langlinks("testwiki-20260102-langlinks.sql")
|
|
191
|
+
expect { import(path, force: true) }.to raise_error(ArgumentError, /version mismatch/)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
it "rejects files whose name carries no dump name" do
|
|
195
|
+
path = write_langlinks("langlinks.sql")
|
|
196
|
+
expect { import(path) }.to raise_error(ArgumentError, /version mismatch/)
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
describe "re-import" do
|
|
201
|
+
it "is a no-op when already imported (reports imported_at)" do
|
|
202
|
+
path = write_langlinks("testwiki-20260101-langlinks.sql")
|
|
203
|
+
first = import(path)
|
|
204
|
+
second = import(path)
|
|
205
|
+
|
|
206
|
+
expect(second[:status]).to eq(:already_imported)
|
|
207
|
+
expect(second[:imported_at]).to eq(first[:provenance][:imported_at])
|
|
208
|
+
expect(second[:row_count]).to eq(6)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
it "drops and re-imports with force, refreshing the provenance" do
|
|
212
|
+
path = write_langlinks("testwiki-20260101-langlinks.sql")
|
|
213
|
+
import(path)
|
|
214
|
+
|
|
215
|
+
smaller = <<~SQL
|
|
216
|
+
INSERT INTO `langlinks` VALUES (1,'en','Film A');
|
|
217
|
+
SQL
|
|
218
|
+
path2 = write_langlinks("testwiki-20260101-langlinks.sql", smaller)
|
|
219
|
+
result = import(path2, force: true)
|
|
220
|
+
|
|
221
|
+
expect(result[:status]).to eq(:imported)
|
|
222
|
+
expect(result[:row_count]).to eq(1)
|
|
223
|
+
expect(langlinks_rows).to eq([[1, "en", "Film A"]])
|
|
224
|
+
expect(result[:provenance][:row_count]).to eq(1)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
it "clears stale provenance when a forced re-import fails midway" do
|
|
228
|
+
path = write_langlinks("testwiki-20260101-langlinks.sql")
|
|
229
|
+
expect(import(path)[:status]).to eq(:imported)
|
|
230
|
+
|
|
231
|
+
# Simulate a failure during the load (corrupt bytes, disk full, ...),
|
|
232
|
+
# after the first batch has already been committed
|
|
233
|
+
stub_const("Wp2txt::LanglinksImporter::BATCH_SIZE", 1)
|
|
234
|
+
failing = described_class.new(@db_path, cache_dir: @dir)
|
|
235
|
+
allow(failing).to receive(:each_source_row) do |_source, &block|
|
|
236
|
+
block.call(1, "en", "Film A")
|
|
237
|
+
raise IOError, "simulated read failure"
|
|
238
|
+
end
|
|
239
|
+
expect { failing.import!(path, force: true) }.to raise_error(IOError)
|
|
240
|
+
|
|
241
|
+
# Partial table, but provenance is gone: judged as "not imported"
|
|
242
|
+
expect(langlinks_rows).to eq([[1, "en", "Film A"]])
|
|
243
|
+
meta = Wp2txt::MetadataIndex.new(@db_path)
|
|
244
|
+
expect(meta.langlinks_provenance).to be_nil
|
|
245
|
+
meta.close
|
|
246
|
+
|
|
247
|
+
# A non-force retry re-imports instead of reporting a stale success
|
|
248
|
+
retry_result = import(path)
|
|
249
|
+
expect(retry_result[:status]).to eq(:imported)
|
|
250
|
+
expect(retry_result[:row_count]).to eq(6)
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
describe "provenance" do
|
|
255
|
+
it "stamps source, size, time, tool version, filter, and row count" do
|
|
256
|
+
path = write_langlinks("testwiki-20260101-langlinks.sql")
|
|
257
|
+
prov = import(path)[:provenance]
|
|
258
|
+
|
|
259
|
+
expect(prov[:source]).to eq("testwiki-20260101-langlinks.sql")
|
|
260
|
+
expect(prov[:source_size]).to eq(File.size(path))
|
|
261
|
+
expect(prov[:imported_at]).to match(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
|
|
262
|
+
expect(prov[:imported_with]).to eq(Wp2txt::VERSION)
|
|
263
|
+
expect(prov[:lang_filter]).to eq("all")
|
|
264
|
+
expect(prov[:row_count]).to eq(6)
|
|
265
|
+
|
|
266
|
+
meta = Wp2txt::MetadataIndex.new(@db_path)
|
|
267
|
+
expect(meta.langlinks_provenance[:row_count]).to eq(6)
|
|
268
|
+
meta.close
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
describe "sanity check" do
|
|
273
|
+
it "is skipped when the target language has no local meta DB" do
|
|
274
|
+
path = write_langlinks("testwiki-20260101-langlinks.sql")
|
|
275
|
+
expect(import(path)[:sanity]).to eq([])
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
it "reports the join rate against an installed target meta DB" do
|
|
279
|
+
create_meta_db(@dir, lang: "en", date: "20260101",
|
|
280
|
+
pages: [[101, "Film A", 0, nil, 10], [102, "Film B", 0, nil, 10]])
|
|
281
|
+
sql = <<~SQL
|
|
282
|
+
INSERT INTO `langlinks` VALUES (1,'en','Film A'),(2,'en','Film B');
|
|
283
|
+
SQL
|
|
284
|
+
path = write_langlinks("testwiki-20260101-langlinks.sql", sql)
|
|
285
|
+
result = import(path)
|
|
286
|
+
|
|
287
|
+
expect(result[:sanity].size).to eq(1)
|
|
288
|
+
check = result[:sanity].first
|
|
289
|
+
expect(check[:lang]).to eq("en")
|
|
290
|
+
expect(check[:match_rate]).to eq(1.0)
|
|
291
|
+
expect(check[:warning]).to be false
|
|
292
|
+
expect(check[:against]).to eq("enwiki-20260101")
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
it "warns when the join rate falls below 90%" do
|
|
296
|
+
create_meta_db(@dir, lang: "en", date: "20260101",
|
|
297
|
+
pages: [[101, "Something Else", 0, nil, 10]])
|
|
298
|
+
sql = <<~SQL
|
|
299
|
+
INSERT INTO `langlinks` VALUES (1,'en','Film A'),(2,'en','Film B');
|
|
300
|
+
SQL
|
|
301
|
+
path = write_langlinks("testwiki-20260101-langlinks.sql", sql)
|
|
302
|
+
check = import(path)[:sanity].first
|
|
303
|
+
|
|
304
|
+
expect(check[:match_rate]).to eq(0.0)
|
|
305
|
+
expect(check[:warning]).to be true
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
require_relative "support/multistream_fixture"
|
|
6
|
+
require_relative "support/meta_db_fixture"
|
|
7
|
+
require_relative "../lib/wp2txt/corpus"
|
|
8
|
+
require_relative "../lib/wp2txt/langlinks_importer"
|
|
9
|
+
|
|
10
|
+
# query_sql's multi-dump ATTACH (design doc §2) and the langlinks join demo (§3)
|
|
11
|
+
RSpec.describe "multi-dump ATTACH" do
|
|
12
|
+
include MultistreamFixture
|
|
13
|
+
include MetaDbFixture
|
|
14
|
+
|
|
15
|
+
EN_PAGES = [
|
|
16
|
+
[101, "Film A", 0, nil, 100],
|
|
17
|
+
[102, "Film B", 0, nil, 100],
|
|
18
|
+
[103, "Person X (actor)", 0, nil, 100]
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
EN_SECTIONS = [
|
|
22
|
+
[101, "Plot", 2, 1],
|
|
23
|
+
[101, "Reception", 2, 2],
|
|
24
|
+
[101, "Production", 2, 3],
|
|
25
|
+
[102, "Synopsis", 2, 1]
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
around do |example|
|
|
29
|
+
Dir.mktmpdir do |dir|
|
|
30
|
+
@dir = dir
|
|
31
|
+
@multistream_path, @index_path = create_fixture(dir)
|
|
32
|
+
|
|
33
|
+
ms_index = Wp2txt::MultistreamIndex.new(@index_path, use_cache: false, show_progress: false)
|
|
34
|
+
@db_path = Wp2txt::MetadataIndex.path_for(@multistream_path, cache_dir: dir)
|
|
35
|
+
Wp2txt::MetadataIndexBuilder.new(
|
|
36
|
+
@multistream_path, ms_index.stream_offsets,
|
|
37
|
+
db_path: @db_path, num_processes: 0
|
|
38
|
+
).build
|
|
39
|
+
|
|
40
|
+
# Same-date "en" dump (attachable), different-date "fr" dump (mismatch)
|
|
41
|
+
@en_meta = create_meta_db(dir, lang: "en", date: "20260101",
|
|
42
|
+
pages: EN_PAGES, sections: EN_SECTIONS)
|
|
43
|
+
@fr_meta = create_meta_db(dir, lang: "fr", date: "20260202",
|
|
44
|
+
pages: [[201, "Film A (fr)", 0, nil, 50]])
|
|
45
|
+
|
|
46
|
+
@corpus = Wp2txt::Corpus.for_input(@multistream_path, cache_dir: dir)
|
|
47
|
+
example.run
|
|
48
|
+
@corpus.close
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
describe "attach argument validation" do
|
|
53
|
+
it "rejects malformed language codes (paths, traversal, injection)" do
|
|
54
|
+
["../x", "en/x", "en;DROP", "EN", "e", "x" * 20, ""].each do |bad|
|
|
55
|
+
expect { @corpus.query_sql("SELECT 1", attach: [bad]) }
|
|
56
|
+
.to raise_error(ArgumentError, /invalid language code/), "expected #{bad.inspect} to be rejected"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it "rejects languages with no installed index" do
|
|
61
|
+
expect { @corpus.query_sql("SELECT 1", attach: ["ko"]) }
|
|
62
|
+
.to raise_error(ArgumentError, /no installed index/)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it "rejects attaching the main database's own language" do
|
|
66
|
+
expect { @corpus.query_sql("SELECT 1", attach: ["test"]) }
|
|
67
|
+
.to raise_error(ArgumentError, /cannot attach 'test'/)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
describe "read-only cross-dump queries" do
|
|
72
|
+
it "attaches another language's meta DB as {lang}_meta" do
|
|
73
|
+
result = @corpus.query_sql("SELECT title FROM en_meta.pages ORDER BY page_id", attach: ["en"])
|
|
74
|
+
expect(result[:rows].flatten).to eq(["Film A", "Film B", "Person X (actor)"])
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it "attaches the FTS DB as {lang}_fts when built" do
|
|
78
|
+
create_fts_db(@en_meta)
|
|
79
|
+
result = @corpus.query_sql("SELECT name FROM en_fts.sqlite_master", attach: ["en"])
|
|
80
|
+
expect(result[:rows].flatten).to include("fts_map")
|
|
81
|
+
expect(result[:attached].first[:fts]).to be true
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it "reports attached metadata (lang, dump_name, built_with, fts) in the response" do
|
|
85
|
+
result = @corpus.query_sql("SELECT 1", attach: ["en"])
|
|
86
|
+
expect(result[:attached]).to eq([
|
|
87
|
+
{ lang: "en", dump_name: "enwiki-20260101", built_with: Wp2txt::VERSION, fts: false }
|
|
88
|
+
])
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
it "flags dump_mismatch when only a different-date dump is installed" do
|
|
92
|
+
result = @corpus.query_sql("SELECT 1", attach: ["fr"])
|
|
93
|
+
entry = result[:attached].first
|
|
94
|
+
expect(entry[:dump_name]).to eq("frwiki-20260202")
|
|
95
|
+
expect(entry[:dump_mismatch]).to be true
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it "makes attached databases read-only at the driver level" do
|
|
99
|
+
attachments = @corpus.send(:resolve_attachments, ["en"])
|
|
100
|
+
db = @corpus.send(:build_readonly_connection, attach_fts: false, attachments: attachments)
|
|
101
|
+
expect { db.execute("INSERT INTO en_meta.pages VALUES (999, 'x', 0, NULL, 0)") }
|
|
102
|
+
.to raise_error(SQLite3::Exception, /readonly/i)
|
|
103
|
+
db.close
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
it "maps hyphenated language codes to underscored aliases (zh-yue → zh_yue_meta)" do
|
|
107
|
+
create_meta_db(@dir, lang: "zh-yue", date: "20260101",
|
|
108
|
+
pages: [[301, "Cantonese Page", 0, nil, 10]])
|
|
109
|
+
result = @corpus.query_sql("SELECT title FROM zh_yue_meta.pages", attach: ["zh-yue"])
|
|
110
|
+
expect(result[:rows].flatten).to eq(["Cantonese Page"])
|
|
111
|
+
expect(result[:attached].first[:lang]).to eq("zh-yue")
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
describe "user SQL ATTACH/DETACH stays forbidden (regression)" do
|
|
116
|
+
it "rejects ATTACH in user SQL even when the attach argument is used" do
|
|
117
|
+
expect { @corpus.query_sql("SELECT 1; ATTACH DATABASE 'x' AS y", attach: ["en"]) }
|
|
118
|
+
.to raise_error(ArgumentError, /forbidden/)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
it "rejects DETACH in user SQL" do
|
|
122
|
+
expect { @corpus.query_sql("SELECT 1; DETACH DATABASE en_meta", attach: ["en"]) }
|
|
123
|
+
.to raise_error(ArgumentError, /forbidden/)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
it "still allows 'attach' inside string literals" do
|
|
127
|
+
result = @corpus.query_sql("SELECT 'please attach the file' AS note")
|
|
128
|
+
expect(result[:rows].first.first).to eq("please attach the file")
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
describe "langlinks join demo (design doc §3)" do
|
|
133
|
+
before do
|
|
134
|
+
path = File.join(@dir, "testwiki-20260101-langlinks.sql")
|
|
135
|
+
File.write(path, <<~SQL)
|
|
136
|
+
INSERT INTO `langlinks` VALUES (1,'en','Film A'),(2,'en','Film B'),(3,'en','Person X (actor)');
|
|
137
|
+
SQL
|
|
138
|
+
Wp2txt::LanglinksImporter.new(@db_path, cache_dir: @dir).import!(path)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
it "compares section structures of article pairs across languages in one query" do
|
|
142
|
+
result = @corpus.query_sql(<<~SQL, attach: ["en"])
|
|
143
|
+
SELECT p.title AS ja_title, ll.ll_title AS en_title,
|
|
144
|
+
(SELECT COUNT(*) FROM page_sections s WHERE s.page_id = p.page_id) AS ja_sections,
|
|
145
|
+
(SELECT COUNT(*) FROM en_meta.page_sections s2
|
|
146
|
+
JOIN en_meta.pages p2 ON p2.page_id = s2.page_id
|
|
147
|
+
WHERE p2.title = ll.ll_title) AS en_sections
|
|
148
|
+
FROM pages p
|
|
149
|
+
JOIN langlinks ll ON ll.ll_from = p.page_id AND ll.ll_lang = 'en'
|
|
150
|
+
WHERE p.namespace = 0 AND p.redirect_to IS NULL
|
|
151
|
+
LIMIT 20
|
|
152
|
+
SQL
|
|
153
|
+
|
|
154
|
+
expect(result[:columns]).to eq(%w[ja_title en_title ja_sections en_sections])
|
|
155
|
+
expect(result[:rows]).to contain_exactly(
|
|
156
|
+
["Film A", "Film A", 2, 3],
|
|
157
|
+
["Film B", "Film B", 1, 1],
|
|
158
|
+
["Person X", "Person X (actor)", 2, 0]
|
|
159
|
+
)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
it "exposes langlinks provenance via dump_info" do
|
|
163
|
+
info = @corpus.dump_info
|
|
164
|
+
expect(info[:langlinks][:source]).to eq("testwiki-20260101-langlinks.sql")
|
|
165
|
+
expect(info[:langlinks][:row_count]).to eq(3)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
it "includes the langlinks table in describe_schema (introspection-based)" do
|
|
169
|
+
schemas = @corpus.describe_schema[:meta].join("\n")
|
|
170
|
+
expect(schemas).to include("CREATE TABLE langlinks")
|
|
171
|
+
expect(schemas).to include("idx_langlinks_from")
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sqlite3"
|
|
4
|
+
require_relative "../../lib/wp2txt/metadata_index"
|
|
5
|
+
require_relative "../../lib/wp2txt/fts_index"
|
|
6
|
+
require_relative "../../lib/wp2txt/version"
|
|
7
|
+
|
|
8
|
+
# Synthetic Tier 1 / Tier 2 index DBs for cross-dump (ATTACH) and langlinks
|
|
9
|
+
# specs: builds the SQLite files directly, without a real multistream dump
|
|
10
|
+
module MetaDbFixture
|
|
11
|
+
# Create a synthetic built metadata DB for LANG in DIR, mimicking what
|
|
12
|
+
# MetadataIndex.path_for + MetadataIndexBuilder produce.
|
|
13
|
+
# @param pages [Array] rows of [page_id, title, namespace, redirect_to, text_length]
|
|
14
|
+
# @param sections [Array] rows of [page_id, heading, level, ord]
|
|
15
|
+
# @return [String] db path
|
|
16
|
+
def create_meta_db(dir, lang:, date:, pages: [], sections: [],
|
|
17
|
+
built_with: Wp2txt::VERSION, built_at: "2026-01-02T00:00:00Z")
|
|
18
|
+
path = File.join(dir, "#{lang}wiki-#{date}-pages-articles-multistream_deadbeef#{Wp2txt::MetadataIndex::CACHE_SUFFIX}")
|
|
19
|
+
db = SQLite3::Database.new(path)
|
|
20
|
+
db.execute("CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT)")
|
|
21
|
+
db.execute("CREATE TABLE pages (page_id INTEGER PRIMARY KEY, title TEXT, namespace INTEGER, redirect_to TEXT, text_length INTEGER)")
|
|
22
|
+
db.execute("CREATE TABLE page_categories (page_id INTEGER, category TEXT)")
|
|
23
|
+
db.execute("CREATE TABLE page_sections (page_id INTEGER, heading TEXT, level INTEGER, ord INTEGER)")
|
|
24
|
+
db.execute("CREATE TABLE category_hierarchy (child TEXT, parent TEXT)")
|
|
25
|
+
pages.each { |r| db.execute("INSERT INTO pages VALUES (?, ?, ?, ?, ?)", r) }
|
|
26
|
+
sections.each { |r| db.execute("INSERT INTO page_sections VALUES (?, ?, ?, ?)", r) }
|
|
27
|
+
{
|
|
28
|
+
"schema_version" => Wp2txt::MetadataIndex::SCHEMA_VERSION.to_s,
|
|
29
|
+
"wp2txt_version" => built_with,
|
|
30
|
+
"dump_name" => "#{lang}wiki-#{date}",
|
|
31
|
+
"built_at" => built_at
|
|
32
|
+
}.each { |k, v| db.execute("INSERT INTO metadata VALUES (?, ?)", [k, v]) }
|
|
33
|
+
db.close
|
|
34
|
+
path
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Create the synthetic FTS DB companion of a meta DB created above
|
|
38
|
+
# (same basename, _fts suffix)
|
|
39
|
+
# @return [String] db path
|
|
40
|
+
def create_fts_db(meta_db_path, built_with: Wp2txt::VERSION, built_at: "2026-01-02T00:00:00Z")
|
|
41
|
+
path = meta_db_path.sub(/#{Wp2txt::MetadataIndex::CACHE_SUFFIX}\z/, Wp2txt::FtsIndex::CACHE_SUFFIX)
|
|
42
|
+
db = SQLite3::Database.new(path)
|
|
43
|
+
db.execute("CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT)")
|
|
44
|
+
db.execute("CREATE TABLE fts_map (rowid INTEGER PRIMARY KEY, page_id INTEGER, heading TEXT, ord INTEGER)")
|
|
45
|
+
{
|
|
46
|
+
"schema_version" => Wp2txt::FtsIndex::SCHEMA_VERSION.to_s,
|
|
47
|
+
"wp2txt_version" => built_with,
|
|
48
|
+
"built_at" => built_at
|
|
49
|
+
}.each { |k, v| db.execute("INSERT INTO metadata VALUES (?, ?)", [k, v]) }
|
|
50
|
+
db.close
|
|
51
|
+
path
|
|
52
|
+
end
|
|
53
|
+
end
|