xref: /dokuwiki/_test/tests/Search/Collection/LookupCollectionTest.php (revision 6a8e48eda246f872402bf5b85763f276cd4c319d)
1<?php
2
3namespace dokuwiki\test\Search\Collection;
4
5use dokuwiki\Search\Collection\PageMetaCollection;
6use dokuwiki\Search\Exception\IndexIntegrityException;
7use dokuwiki\Search\Exception\IndexLockException;
8use dokuwiki\Search\Index\FileIndex;
9use dokuwiki\Search\Index\Lock;
10use dokuwiki\Search\Index\MemoryIndex;
11
12class LookupCollectionTest extends \DokuWikiTest
13{
14    protected function tearDown(): void
15    {
16        Lock::releaseAll();
17        parent::tearDown();
18    }
19
20    /**
21     * A shared, caller-owned index lock must survive a collection lock()/unlock() cycle
22     *
23     * Indexer::addPage() locks the page index once, then hands it to a series of
24     * collections that each lock() and unlock() around their work. The collection must
25     * treat that shared, already-locked index as owned by the caller and leave its lock
26     * untouched - previously the collection's unlock() released it, dropping the caller's
27     * lock (and the filesystem lock) mid-operation.
28     */
29    public function testSharedIndexLockSurvivesCollectionCycle()
30    {
31        global $conf;
32        // a dedicated shared index name so this mutating test cannot disturb the RIDs
33        // the other tests rely on in this class's shared data dir
34        $lockDir = $conf['lockdir'] . '/sharedlockentity.index';
35
36        // caller acquires the shared entity index lock, as Indexer::addPage() does
37        // with the page index
38        $sharedIndex = new FileIndex('sharedlockentity', '', true);
39        $this->assertTrue($sharedIndex->isWritable());
40        $this->assertDirectoryExists($lockDir);
41
42        // several collections work on the shared index in turn
43        foreach (['sharedlock_one', 'sharedlock_two'] as $key) {
44            (new PageMetaCollection($key, $sharedIndex))->lock()
45                ->addEntity('sharedlock:start', ['wiki:a', 'wiki:b'])->unlock();
46
47            // the caller's lock on the shared index must still be held
48            $this->assertTrue($sharedIndex->isWritable(), "shared lock dropped after $key");
49            $this->assertDirectoryExists($lockDir, "filesystem lock dropped after $key");
50        }
51
52        // releasing the caller's own lock finally drops it
53        $sharedIndex->unlock();
54        $this->assertFalse($sharedIndex->isWritable());
55        $this->assertDirectoryDoesNotExist($lockDir);
56    }
57    /**
58     * Add data and directly check the underlying indexes for correctness
59     */
60    public function testAddEntity()
61    {
62        $index = new MockLookupCollection('a_entity', 'a_token', 'a_freq', 'a_reverse');
63        $index->lock();
64        $index->addEntity('wiki:start', ['wiki:logo.png', 'wiki:banner.jpg', 'wiki:icon.svg']);
65        $index->unlock();
66
67        // check entity index
68        $idxEntity = new MemoryIndex('a_entity');
69        $this->assertEquals('wiki:start', $idxEntity->retrieveRow(0));
70
71        // check token index (single file, no suffix)
72        $idxToken = new MemoryIndex('a_token');
73        $this->assertEquals('wiki:logo.png', $idxToken->retrieveRow(0));
74        $this->assertEquals('wiki:banner.jpg', $idxToken->retrieveRow(1));
75        $this->assertEquals('wiki:icon.svg', $idxToken->retrieveRow(2));
76
77        // check frequency index — all frequencies are 1 (written without *1)
78        $idxFreq = new MemoryIndex('a_freq');
79        $this->assertEquals('0', $idxFreq->retrieveRow(0)); // entity 0 with implicit freq 1
80        $this->assertEquals('0', $idxFreq->retrieveRow(1));
81        $this->assertEquals('0', $idxFreq->retrieveRow(2));
82
83        // check reverse index
84        $idxRev = new MemoryIndex('a_reverse');
85        $this->assertEquals('0:1:2', $idxRev->retrieveRow(0));
86    }
87
88    /**
89     * Duplicate tokens should be deduplicated
90     */
91    public function testAddEntityDedup()
92    {
93        $index = new MockLookupCollection('b_entity', 'b_token', 'b_freq', 'b_reverse');
94        $index->lock();
95        $index->addEntity('wiki:start', ['wiki:logo.png', 'wiki:logo.png', 'wiki:banner.jpg']);
96        $index->unlock();
97
98        $idxToken = new MemoryIndex('b_token');
99        $this->assertEquals('wiki:logo.png', $idxToken->retrieveRow(0));
100        $this->assertEquals('wiki:banner.jpg', $idxToken->retrieveRow(1));
101
102        $idxRev = new MemoryIndex('b_reverse');
103        $this->assertEquals('0:1', $idxRev->retrieveRow(0));
104    }
105
106    /**
107     * Updating an entity should remove old tokens and add new ones
108     */
109    public function testUpdateEntity()
110    {
111        $index = new MockLookupCollection('c_entity', 'c_token', 'c_freq', 'c_reverse');
112
113        // initial add
114        $index->lock();
115        $index->addEntity('wiki:start', ['wiki:logo.png', 'wiki:banner.jpg']);
116        $index->unlock();
117
118        // update: remove logo, keep banner, add icon
119        $index->lock();
120        $index->addEntity('wiki:start', ['wiki:banner.jpg', 'wiki:icon.svg']);
121        $index->unlock();
122
123        // logo should be removed from frequency index
124        $idxFreq = new MemoryIndex('c_freq');
125        $this->assertEquals('', $idxFreq->retrieveRow(0)); // logo removed
126        $this->assertEquals('0', $idxFreq->retrieveRow(1)); // banner still on entity 0
127        $this->assertEquals('0', $idxFreq->retrieveRow(2)); // icon added on entity 0
128
129        // reverse index should only have banner and icon
130        $idxRev = new MemoryIndex('c_reverse');
131        $this->assertEquals('1:2', $idxRev->retrieveRow(0));
132    }
133
134    /**
135     * Test reverse assignments returns two-level structure with empty group key
136     */
137    public function testReverseAssignments()
138    {
139        $index = new MockLookupCollection('d_entity', 'd_token', 'd_freq', 'd_reverse');
140        $index->lock();
141        $index->addEntity('wiki:start', ['wiki:logo.png', 'wiki:banner.jpg']);
142        $index->unlock();
143
144        $result = $index->getReverseAssignments('wiki:start');
145        $this->assertEquals([0 => [0 => 0, 1 => 0]], $result);
146    }
147
148    /**
149     * Adding entity without lock should throw exception
150     */
151    public function testAddEntityWithoutLock()
152    {
153        $this->expectException(IndexLockException::class);
154
155        $index = new MockLookupCollection();
156        $index->addEntity('wiki:start', ['wiki:logo.png']);
157    }
158
159    /**
160     * Adding empty token list should clear entity from indexes
161     */
162    public function testEmptyTokens()
163    {
164        $index = new MockLookupCollection('f_entity', 'f_token', 'f_freq', 'f_reverse');
165
166        // add some tokens first
167        $index->lock();
168        $index->addEntity('wiki:start', ['wiki:logo.png']);
169        $index->unlock();
170
171        // now clear
172        $index->lock();
173        $index->addEntity('wiki:start', []);
174        $index->unlock();
175
176        // frequency index should be empty for this token
177        $idxFreq = new MemoryIndex('f_freq');
178        $this->assertEquals('', $idxFreq->retrieveRow(0));
179
180        // reverse index should be empty
181        $idxRev = new MemoryIndex('f_reverse');
182        $this->assertEquals('', $idxRev->retrieveRow(0));
183    }
184
185    /**
186     * Test that PageMetaCollection('relation_media') uses correct index names
187     */
188    public function testMediaCollection()
189    {
190        $index = new PageMetaCollection('relation_media');
191        $index->lock();
192        $index->addEntity('wiki:start', ['wiki:logo.png', 'wiki:banner.jpg']);
193        $index->unlock();
194
195        $idxToken = new MemoryIndex('relation_media_w');
196        $this->assertEquals('wiki:logo.png', $idxToken->retrieveRow(0));
197        $this->assertEquals('wiki:banner.jpg', $idxToken->retrieveRow(1));
198
199        $idxRev = new MemoryIndex('relation_media_p');
200        $this->assertEquals('0:1', $idxRev->retrieveRow(0));
201    }
202
203    /**
204     * Test that PageMetaCollection('relation_references') uses correct index names
205     */
206    public function testReferencesCollection()
207    {
208        $index = new PageMetaCollection('relation_references');
209        $index->lock();
210        $index->addEntity('wiki:start', ['wiki:syntax', 'wiki:welcome']);
211        $index->unlock();
212
213        $idxToken = new MemoryIndex('relation_references_w');
214        $this->assertEquals('wiki:syntax', $idxToken->retrieveRow(0));
215        $this->assertEquals('wiki:welcome', $idxToken->retrieveRow(1));
216
217        $idxRev = new MemoryIndex('relation_references_p');
218        $this->assertEquals('0:1', $idxRev->retrieveRow(0));
219
220        $result = $index->getReverseAssignments('wiki:start');
221        $this->assertEquals([0 => [0 => 0, 1 => 0]], $result);
222    }
223
224    /**
225     * resolveTokens should deduplicate and assign frequency 1 under group 0
226     */
227    public function testResolveTokens()
228    {
229        $index = new MockLookupCollection('rt_entity', 'rt_token', 'rt_freq', 'rt_reverse');
230        $index->lock();
231
232        $result = $this->callInaccessibleMethod($index, 'resolveTokens', [
233            ['wiki:logo.png', 'wiki:banner.jpg', 'wiki:logo.png'],
234        ]);
235
236        // all tokens under group 0 (non-split)
237        $this->assertArrayHasKey(0, $result);
238        $this->assertCount(2, $result[0]); // deduplicated
239
240        // token IDs are sequential: logo=0, banner=1
241        $this->assertEquals(1, $result[0][0]); // logo freq=1
242        $this->assertEquals(1, $result[0][1]); // banner freq=1
243    }
244
245    /**
246     * resolveTokens with empty input should return empty array
247     */
248    public function testResolveTokensEmpty()
249    {
250        $index = new MockLookupCollection('rte_entity', 'rte_token', 'rte_freq', 'rte_reverse');
251        $index->lock();
252
253        $result = $this->callInaccessibleMethod($index, 'resolveTokens', [[]]);
254
255        $this->assertEmpty($result);
256    }
257
258    /**
259     * countTokens should deduplicate and assign frequency 1
260     */
261    public function testCountTokens()
262    {
263        $index = new MockLookupCollection();
264
265        $result = $this->callInaccessibleMethod($index, 'countTokens', [
266            ['wiki:logo.png', 'wiki:banner.jpg', 'wiki:logo.png'],
267        ]);
268
269        $this->assertEquals([
270            'wiki:logo.png' => 1,
271            'wiki:banner.jpg' => 1,
272        ], $result);
273    }
274
275    /**
276     * getEntitiesWithData returns entities that have frequency data
277     */
278    public function testGetEntitiesWithData()
279    {
280        $index = new MockLookupCollection('ewd_entity', 'ewd_token', 'ewd_freq', 'ewd_reverse');
281        $index->lock();
282        $index->addEntity('wiki:start', ['wiki:syntax', 'wiki:welcome']);
283        $index->addEntity('wiki:other', ['wiki:syntax']);
284        $index->addEntity('wiki:empty', []);
285        $index->unlock();
286
287        $result = $index->getEntitiesWithData();
288        sort($result);
289        $this->assertEquals(['wiki:other', 'wiki:start'], $result);
290    }
291
292    /**
293     * histogram() returns each token with the number of entities carrying it,
294     * ordered by frequency and filtered by min/max/minlen
295     */
296    public function testHistogram()
297    {
298        $index = new MockLookupCollection('hist_entity', 'hist_token', 'hist_freq', 'hist_reverse');
299        $index->lock();
300        $index->addEntity('p:a', ['news', 'wiki', 'ab']);
301        $index->addEntity('p:b', ['news']);
302        $index->addEntity('p:c', ['news', 'tech']);
303        $index->unlock();
304
305        $this->assertSame(['news' => 3, 'wiki' => 1, 'tech' => 1], $index->histogram(1, 0, 3));
306        $this->assertSame(['news' => 3], $index->histogram(2, 0, 3), 'min filter');
307        $this->assertSame(['wiki' => 1, 'tech' => 1], $index->histogram(1, 2, 3), 'max filter');
308        $this->assertArrayHasKey('ab', $index->histogram(1, 0, 2), 'minlen 2 keeps short token');
309
310        $empty = new MockLookupCollection('histe_entity', 'histe_token', 'histe_freq', 'histe_reverse');
311        $this->assertSame([], $empty->histogram());
312    }
313
314    /**
315     * resolveTokenFrequencies returns entity frequencies for given token IDs
316     */
317    public function testResolveTokenFrequencies()
318    {
319        $index = new MockLookupCollection('rtf_entity', 'rtf_token', 'rtf_freq', 'rtf_reverse');
320        $index->lock();
321        $index->addEntity('wiki:start', ['wiki:syntax', 'wiki:welcome']);
322        $index->addEntity('wiki:other', ['wiki:syntax']);
323        $index->unlock();
324
325        // token ID 0 = wiki:syntax, referenced by both entities
326        $result = $index->resolveTokenFrequencies(0, [0]);
327        $this->assertArrayHasKey(0, $result);
328        $this->assertCount(2, $result[0]); // two entities have this token
329    }
330
331    /**
332     * checkIntegrity passes on a healthy non-split collection
333     */
334    public function testCheckIntegrityHealthy()
335    {
336        $index = new MockLookupCollection('cih_entity', 'cih_token', 'cih_freq', 'cih_reverse');
337        $index->lock();
338        $index->addEntity('wiki:start', ['wiki:syntax']);
339        $index->unlock();
340
341        $index->checkIntegrity(); // should not throw
342        $this->assertTrue(true);
343    }
344
345    /**
346     * checkIntegrity passes on an empty non-split collection
347     */
348    public function testCheckIntegrityEmpty()
349    {
350        $index = new MockLookupCollection('cie_entity', 'cie_token', 'cie_freq', 'cie_reverse');
351        $index->checkIntegrity(); // should not throw
352        $this->assertTrue(true);
353    }
354
355    /**
356     * checkIntegrity detects token/frequency mismatch on non-split collection
357     */
358    public function testCheckIntegrityTokenFreqMismatch()
359    {
360        global $conf;
361        $index = new MockLookupCollection('cim_entity', 'cim_token', 'cim_freq', 'cim_reverse');
362        $index->lock();
363        $index->addEntity('wiki:start', ['wiki:syntax']);
364        $index->unlock();
365
366        // corrupt: add extra line to token index
367        file_put_contents($conf['indexdir'] . '/cim_token.idx', "extra\n", FILE_APPEND);
368
369        $this->expectException(IndexIntegrityException::class);
370        (new MockLookupCollection('cim_entity', 'cim_token', 'cim_freq', 'cim_reverse'))->checkIntegrity();
371    }
372
373    /**
374     * checkIntegrity detects entity/reverse mismatch on non-split collection
375     */
376    public function testCheckIntegrityEntityReverseMismatch()
377    {
378        global $conf;
379        $index = new MockLookupCollection('cir_entity', 'cir_token', 'cir_freq', 'cir_reverse');
380        $index->lock();
381        $index->addEntity('wiki:start', ['wiki:syntax']);
382        $index->unlock();
383
384        // corrupt: add extra line to reverse index
385        file_put_contents($conf['indexdir'] . '/cir_reverse.idx', "0\n", FILE_APPEND);
386
387        $this->expectException(IndexIntegrityException::class);
388        (new MockLookupCollection('cir_entity', 'cir_token', 'cir_freq', 'cir_reverse'))->checkIntegrity();
389    }
390
391    /**
392     * checkIntegrity detects missing frequency index when token index exists
393     */
394    public function testCheckIntegrityMissingFreqIndex()
395    {
396        global $conf;
397        $index = new MockLookupCollection('cimf_entity', 'cimf_token', 'cimf_freq', 'cimf_reverse');
398        $index->lock();
399        $index->addEntity('wiki:start', ['wiki:syntax']);
400        $index->unlock();
401
402        // corrupt: delete frequency index
403        @unlink($conf['indexdir'] . '/cimf_freq.idx');
404
405        $this->expectException(IndexIntegrityException::class);
406        (new MockLookupCollection('cimf_entity', 'cimf_token', 'cimf_freq', 'cimf_reverse'))->checkIntegrity();
407    }
408
409    /**
410     * groupToSuffix throws on non-0 group for non-split collection
411     */
412    public function testGroupToSuffixValidation()
413    {
414        $this->expectException(\dokuwiki\Search\Exception\IndexUsageException::class);
415
416        $index = new MockLookupCollection('gs_entity', 'gs_token', 'gs_freq', 'gs_reverse');
417        // non-split collection should reject group 5
418        $index->getTokenIndex(5);
419    }
420}
421