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