1<?php 2 3namespace dokuwiki\Search\Collection; 4 5use dokuwiki\Search\Exception\IndexAccessException; 6use dokuwiki\Search\Exception\IndexIntegrityException; 7use dokuwiki\Search\Exception\IndexLockException; 8use dokuwiki\Search\Exception\IndexUsageException; 9use dokuwiki\Search\Exception\IndexWriteException; 10use dokuwiki\Search\Index\AbstractIndex; 11use dokuwiki\Search\Index\FileIndex; 12use dokuwiki\Search\Index\Lock; 13use dokuwiki\Search\Index\MemoryIndex; 14use dokuwiki\Search\Index\TupleOps; 15use dokuwiki\Search\Tokenizer; 16 17/** 18 * Abstract base class for index collections 19 * 20 * A collection manages a group of related indexes that together provide a specific search use case. 21 * Every collection works with four index types: entity, token, frequency, and reverse. 22 * 23 * entity - the list of the main entities (eg. pages) 24 * token - the list of tokens (eg. words) assigned to entities (can be split into multiple files) 25 * frequency - how often a token appears on a entity (can be split into multiple files) 26 * reverse - the list of tokens assigned to each entity 27 * 28 * @license GPL 2 (http://www.gnu.org/licenses/gpl.html) 29 * @author Andreas Gohr <andi@splitbrain.org> 30 * @author Tom N Harris <tnharris@whoopdedo.org> 31 */ 32abstract class AbstractCollection 33{ 34 /** @var array<string|AbstractIndex> Index names or objects that have been successfully locked */ 35 protected array $lockedIndexes = []; 36 37 /** @var bool Has a lock been acquired for all used indexes? */ 38 protected bool $isWritable = false; 39 40 /** 41 * Initialize the collection with the names of the indexes it manages 42 * 43 * Entity and token indexes can be passed as already instantiated AbstractIndex objects 44 * for sharing between collections. When $idxToken is an object, $splitByLength must be false. 45 * 46 * @param string|AbstractIndex $idxEntity Name or instance of the primary entity index, eg. 'page' 47 * @param string|AbstractIndex $idxToken Name or instance of the secondary entity index, eg. 'w' for words 48 * @param string $idxFrequency Base name of the frequency index, eg. 'i' for word frequencies 49 * @param string $idxReverse Name of the reverse index, eg. 'pageword' 50 * @param bool $splitByLength Whether to split token/frequency indexes by token length 51 * @throws IndexUsageException 52 */ 53 public function __construct( 54 protected string|AbstractIndex $idxEntity, 55 protected string|AbstractIndex $idxToken, 56 protected string $idxFrequency = '', 57 protected string $idxReverse = '', 58 protected bool $splitByLength = false 59 ) { 60 if ($idxToken instanceof AbstractIndex && $splitByLength) { 61 throw new IndexUsageException('Cannot split by length when using a pre-instantiated token index'); 62 } 63 } 64 65 /** 66 * Destructor 67 * 68 * Ensures locks are released when the class is destroyed 69 */ 70 public function __destruct() 71 { 72 $this->unlock(); 73 } 74 75 /** 76 * Lock all indexes for writing 77 * 78 * @return $this can be used for chaining 79 * @throws IndexLockException 80 */ 81 public function lock(): static 82 { 83 foreach ( 84 [ 85 $this->idxEntity, 86 $this->idxToken, 87 $this->idxFrequency, 88 $this->idxReverse 89 ] as $idx 90 ) { 91 if ($idx === '') continue; 92 try { 93 if ($idx instanceof AbstractIndex) { 94 // A shared index that is already writable was locked by whoever 95 // created it (eg. the page index in Indexer::addPage()). Its lock 96 // belongs to that owner, so we must not lock it here - otherwise our 97 // unlock() would release a lock still needed by the caller. 98 if ($idx->isWritable()) continue; 99 $idx->lock(); 100 } else { 101 Lock::acquire($idx); 102 } 103 $this->lockedIndexes[] = $idx; 104 } catch (IndexLockException $e) { 105 $this->unlock(); 106 throw $e; 107 } 108 } 109 $this->isWritable = true; 110 return $this; 111 } 112 113 /** 114 * Unlock all indexes that were successfully locked 115 * 116 * @return static 117 */ 118 public function unlock(): static 119 { 120 foreach ($this->lockedIndexes as $idx) { 121 if ($idx instanceof AbstractIndex) { 122 $idx->unlock(); 123 } else { 124 Lock::release($idx); 125 } 126 } 127 $this->lockedIndexes = []; 128 $this->isWritable = false; 129 return $this; 130 } 131 132 /** 133 * @return AbstractIndex 134 * @throws IndexLockException 135 */ 136 public function getEntityIndex(): AbstractIndex 137 { 138 if ($this->idxEntity instanceof AbstractIndex) { 139 return $this->idxEntity; 140 } 141 return new FileIndex($this->idxEntity, '', $this->isWritable); 142 } 143 144 /** 145 * @param int $group Index group (0 for non-split, token length for split) 146 * @return AbstractIndex 147 * @throws IndexLockException 148 */ 149 public function getTokenIndex(int $group = 0): AbstractIndex 150 { 151 if ($this->idxToken instanceof AbstractIndex) { 152 return $this->idxToken; 153 } 154 return new MemoryIndex($this->idxToken, $this->groupToSuffix($group), $this->isWritable); 155 } 156 157 /** 158 * @param int $group Index group (0 for non-split, token length for split) 159 * @return AbstractIndex 160 * @throws IndexLockException 161 */ 162 public function getFrequencyIndex(int $group = 0): AbstractIndex 163 { 164 return new MemoryIndex($this->idxFrequency, $this->groupToSuffix($group), $this->isWritable); 165 } 166 167 /** 168 * @return AbstractIndex 169 * @throws IndexLockException 170 */ 171 public function getReverseIndex(): AbstractIndex 172 { 173 return new FileIndex($this->idxReverse, '', $this->isWritable); 174 } 175 176 /** 177 * Whether this collection splits token/frequency indexes by token length 178 * 179 * @return bool 180 */ 181 public function isSplitByLength(): bool 182 { 183 return $this->splitByLength; 184 } 185 186 /** 187 * Convert a logical group number to the index file suffix 188 * 189 * Group 0 represents non-split indexes (suffix '') while positive integers 190 * represent split-by-length indexes (suffix = the length). 191 * 192 * @param int $group 193 * @return string The file suffix ('' for group 0, the group number as string otherwise) 194 * @throws IndexUsageException when group does not match the collection's split mode 195 */ 196 protected function groupToSuffix(int $group): string 197 { 198 if ($group === 0 && $this->splitByLength) { 199 throw new IndexUsageException('Group 0 is not valid for split-by-length collections'); 200 } 201 if ($group !== 0 && !$this->splitByLength) { 202 throw new IndexUsageException("Group $group is not valid for non-split collections"); 203 } 204 return $group === 0 ? '' : (string)$group; 205 } 206 207 /** 208 * Resolve token IDs to entity frequencies 209 * 210 * Given a set of token IDs from a specific index group, returns the entities 211 * that have those tokens and their frequencies. This encapsulates the frequency 212 * index access so that subclasses (e.g. DirectCollection) can provide alternative 213 * mappings. 214 * 215 * @param int $group Index group (0 for non-split, token length for split) 216 * @param int[] $tokenIds The token IDs to resolve 217 * @return array [tokenId => [entityId => frequency, ...], ...] 218 */ 219 public function resolveTokenFrequencies(int $group, array $tokenIds): array 220 { 221 $freqIndex = $this->getFrequencyIndex($group); 222 if (!$freqIndex->exists()) return []; 223 return array_map(TupleOps::parseTuples(...), $freqIndex->retrieveRows($tokenIds)); 224 } 225 226 /** 227 * Return all entity names that have data in this collection 228 * 229 * @return string[] entity names 230 */ 231 public function getEntitiesWithData(): array 232 { 233 $entityIndex = $this->getEntityIndex(); 234 235 // collect entity IDs from all frequency index groups 236 $max = $this->splitByLength ? $this->getTokenIndexMaximum() : 0; 237 $groups = $this->splitByLength ? ($max > 0 ? range(1, $max) : []) : [0]; 238 239 $entityIds = []; 240 foreach ($groups as $group) { 241 $freqIndex = $this->getFrequencyIndex($group); 242 if (!$freqIndex->exists()) continue; 243 foreach ($freqIndex as $line) { 244 foreach (array_keys(TupleOps::parseTuples($line)) as $entityId) { 245 $entityIds[$entityId] = true; 246 } 247 } 248 } 249 250 $names = $entityIndex->retrieveRows(array_keys($entityIds)); 251 return array_values(array_filter($names, static fn($v) => $v !== '')); 252 } 253 254 /** 255 * Maximum suffix for the token indexes (eg. max word length currently stored) 256 * 257 * @return int 258 * @throws IndexLockException 259 */ 260 public function getTokenIndexMaximum(): int 261 { 262 if ($this->idxToken instanceof AbstractIndex) { 263 return $this->idxToken->max(); 264 } 265 return (new MemoryIndex($this->idxToken, ''))->max(); 266 } 267 268 /** 269 * Check the structural integrity of this collection's indexes 270 * 271 * Verifies that paired indexes have matching line counts: 272 * - token == frequency (per group, both keyed by token RID) 273 * - entity == reverse (both keyed by entity RID) 274 * 275 * @throws IndexIntegrityException when a structural inconsistency is found 276 */ 277 public function checkIntegrity(): void 278 { 279 // Check token/frequency pairs 280 $max = $this->splitByLength ? $this->getTokenIndexMaximum() : 0; 281 $groups = $this->splitByLength ? ($max > 0 ? range(1, $max) : []) : [0]; 282 283 foreach ($groups as $group) { 284 $tokenIndex = $this->getTokenIndex($group); 285 $freqIndex = $this->getFrequencyIndex($group); 286 287 if (!$tokenIndex->exists() && !$freqIndex->exists()) continue; 288 289 if ($tokenIndex->exists() !== $freqIndex->exists()) { 290 throw new IndexIntegrityException( 291 "Group $group: missing " . 292 ($tokenIndex->exists() ? 'frequency' : 'token') . ' index' 293 ); 294 } 295 296 $tc = count($tokenIndex); 297 $fc = count($freqIndex); 298 if ($tc !== $fc) { 299 throw new IndexIntegrityException( 300 "Group $group: token count ($tc) != frequency count ($fc)" 301 ); 302 } 303 } 304 305 // Check entity/reverse pair 306 $entityIndex = $this->getEntityIndex(); 307 $reverseIndex = $this->getReverseIndex(); 308 if ($entityIndex->exists() && $reverseIndex->exists()) { 309 $ec = count($entityIndex); 310 $rc = count($reverseIndex); 311 if ($ec !== $rc) { 312 throw new IndexIntegrityException( 313 "Entity count ($ec) != reverse count ($rc)" 314 ); 315 } 316 } 317 } 318 319 /** 320 * Add or update the tokens for a given entity 321 * 322 * The given list of tokens replaces the previously stored list for that entity. An empty list removes the 323 * entity from the index. 324 * 325 * The update merges old and new token data. getReverseAssignments() returns all previously stored token IDs 326 * with a value of 0 (see parseReverseRecord). resolveTokens() returns the new token IDs with their values. 327 * After array_replace_recursive, tokens only in the old map keep value 0 — causing updateIndexes to delete 328 * them from the frequency index via TupleOps::updateTuple. Tokens in the new map overwrite with their value. 329 * 330 * @param string $entity The name of the entity 331 * @param string[] $tokens The list of tokens for this entity 332 * @return static 333 * @throws IndexAccessException 334 * @throws IndexWriteException 335 * @throws IndexLockException 336 */ 337 public function addEntity(string $entity, array $tokens): static 338 { 339 if (!$this->isWritable) { 340 throw new IndexLockException('Indexes not locked. Forgot to call lock()?'); 341 } 342 343 $entityIndex = $this->getEntityIndex(); 344 $entityId = $entityIndex->accessCachedValue($entity); 345 346 $old = $this->getReverseAssignments($entity); 347 $new = $this->resolveTokens($tokens); 348 349 $merged = array_replace_recursive($old, $new); 350 351 $this->updateIndexes($merged, $entityId); 352 $this->saveReverseAssignments($entity, $merged); 353 354 return $this; 355 } 356 357 /** 358 * Resolve raw tokens into the two-level structure [group => [tokenId => frequency]] 359 * 360 * Calls countTokens() to get token frequencies (subclass responsibility), then groups 361 * by token length if splitByLength is enabled, or under '' if not. Finally resolves 362 * token strings to IDs via the appropriate token index. 363 * 364 * @param string[] $tokens The raw token list 365 * @return array [group => [tokenId => frequency, ...], ...] 366 * @throws IndexLockException 367 */ 368 protected function resolveTokens(array $tokens): array 369 { 370 $counted = $this->countTokens($tokens); 371 372 // group tokens by their index suffix 373 $groups = []; 374 foreach ($counted as $token => $freq) { 375 $group = $this->splitByLength ? Tokenizer::tokenLength($token) : 0; 376 $groups[$group][$token] = $freq; 377 } 378 379 // resolve token strings to IDs 380 $result = []; 381 foreach ($groups as $group => $tokenFreqs) { 382 $tokenIndex = $this->getTokenIndex($group); 383 $result[$group] = []; 384 foreach ($tokenFreqs as $token => $freq) { 385 $tokenId = $tokenIndex->getRowID((string)$token); 386 $result[$group][$tokenId] = $freq; 387 } 388 $tokenIndex->save(); 389 } 390 391 return $result; 392 } 393 394 /** 395 * Count or deduplicate tokens and return their frequencies 396 * 397 * FrequencyCollections return actual occurrence counts. 398 * LookupCollections deduplicate and return 1 for each token. 399 * 400 * @param string[] $tokens The raw token list 401 * @return array [token => frequency, ...] 402 */ 403 abstract protected function countTokens(array $tokens): array; 404 405 /** 406 * Get the token assignments for a given entity from the reverse index 407 * 408 * Returns the parsed reverse index record. The exact structure depends on the collection type. 409 * 410 * @param string $entity 411 * @return array 412 * @throws IndexAccessException 413 * @throws IndexWriteException 414 * @throws IndexLockException 415 */ 416 public function getReverseAssignments(string $entity): array 417 { 418 $entityIndex = $this->getEntityIndex(); 419 $entityId = $entityIndex->accessCachedValue($entity); 420 421 $reverseIndex = $this->getReverseIndex(); 422 $record = $reverseIndex->retrieveRow($entityId); 423 424 if ($record === '') { 425 return []; 426 } 427 428 return $this->parseReverseRecord($record); 429 } 430 431 /** 432 * Store the reverse index info about what tokens are assigned to the entity 433 * 434 * @param string $entity 435 * @param array $data The assignment data to store 436 * @return void 437 * @throws IndexAccessException 438 * @throws IndexWriteException 439 * @throws IndexLockException 440 */ 441 protected function saveReverseAssignments(string $entity, array $data): void 442 { 443 // remove tokens with frequency 0 (no longer assigned), then remove empty groups 444 $data = array_map(array_filter(...), $data); 445 $data = array_filter($data); 446 447 $record = $this->formatReverseRecord($data); 448 449 $entityIndex = $this->getEntityIndex(); 450 $entityId = $entityIndex->accessCachedValue($entity); 451 452 $reverseIndex = $this->getReverseIndex(); 453 $reverseIndex->changeRow($entityId, $record); 454 } 455 456 /** 457 * Parse a reverse index record into a two-level array 458 * 459 * The reverse index only stores which token IDs belong to an entity, not their frequencies. All values 460 * in the returned array are set to 0. This is intentional: when merged with new data in addEntity(), 461 * tokens absent from the new data retain 0, signaling deletion from the frequency index. 462 * 463 * For split collections the format is "group*tokenId:group*tokenId:..." where group is the token length. 464 * For non-split collections the group prefix is omitted: "tokenId:tokenId:..." 465 * This mirrors how TupleOps omits *1 for frequency 1. 466 * 467 * @param string $record The raw reverse index record 468 * @return array [group => [tokenId => 0, ...], ...] 469 */ 470 protected function parseReverseRecord(string $record): array 471 { 472 $result = []; 473 foreach (explode(':', $record) as $entry) { 474 $parts = explode('*', $entry, 2); 475 $tokenId = array_pop($parts); 476 $group = (int)(array_pop($parts) ?? 0); 477 $result[$group][$tokenId] = 0; 478 } 479 return $result; 480 } 481 482 /** 483 * Format a two-level array into a reverse index record string 484 * 485 * @param array $data [group => [tokenId => freq, ...], ...] 486 * @return string The formatted record 487 */ 488 protected function formatReverseRecord(array $data): string 489 { 490 $parts = []; 491 foreach ($data as $group => $tokens) { 492 $prefix = $group === 0 ? '' : "$group*"; 493 foreach (array_keys($tokens) as $tokenId) { 494 $parts[] = $prefix . $tokenId; 495 } 496 } 497 return implode(':', $parts); 498 } 499 500 /** 501 * Update frequency indexes with the given data 502 * 503 * Iterates over the two-level structure [group => [tokenId => freq]] and updates the 504 * corresponding frequency index for each group. A frequency of 0 removes the entity 505 * from that token's frequency record. 506 * 507 * @param array $data [group => [tokenId => frequency, ...], ...] 508 * @param int $entityId The entity ID 509 * @throws IndexLockException 510 */ 511 protected function updateIndexes(array $data, int $entityId): void 512 { 513 foreach ($data as $group => $tokens) { 514 $freqIndex = $this->getFrequencyIndex($group); 515 foreach ($tokens as $tokenId => $freq) { 516 $record = $freqIndex->retrieveRow($tokenId); 517 $record = TupleOps::updateTuple($record, $entityId, $freq); 518 $freqIndex->changeRow($tokenId, $record); 519 } 520 $freqIndex->save(); 521 } 522 } 523} 524