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 * Frequency histogram of the tokens in this collection 256 * 257 * Sums, for every token, how often it occurs across all entities and 258 * returns the tokens together with those totals, ordered by frequency 259 * (highest first). This is the primitive used to build tag clouds and 260 * word-frequency lists. 261 * 262 * @param int $min minimum frequency a token must reach to be included 263 * @param int $max maximum frequency to include, 0 for no upper limit 264 * @param int $minlen minimum token length to include 265 * @return array<string, int> token => frequency, ordered by frequency descending 266 * @throws IndexLockException 267 */ 268 public function histogram(int $min = 1, int $max = 0, int $minlen = 3): array 269 { 270 if ($min < 1) $min = 1; 271 if ($max < $min) $max = 0; 272 if ($minlen < 1) $minlen = 1; 273 274 // For length-split collections the group number is the token length, so 275 // only groups of at least $minlen can contribute; build the range to 276 // start there. Non-split collections keep everything in group 0 and 277 // enforce $minlen through the token-length filter below. 278 if ($this->splitByLength) { 279 $maxGroup = $this->getTokenIndexMaximum(); 280 $groups = $maxGroup >= $minlen ? range($minlen, $maxGroup) : []; 281 } else { 282 $groups = [0]; 283 } 284 285 $result = []; 286 foreach ($groups as $group) { 287 $freqIndex = $this->getFrequencyIndex($group); 288 if (!$freqIndex->exists()) continue; 289 290 // collect token RIDs whose summed frequency falls within [min, max] 291 $freqs = []; 292 foreach ($freqIndex as $rid => $line) { 293 if ($line === '') continue; 294 $freq = TupleOps::aggregateTupleCounts($line); 295 if ($freq >= $min && (!$max || $freq <= $max)) { 296 $freqs[$rid] = $freq; 297 } 298 } 299 if ($freqs === []) continue; 300 301 // resolve the RIDs to token strings in one batch and apply the 302 // length filter (this is where $minlen is enforced when not split) 303 $tokens = $this->getTokenIndex($group)->retrieveRows(array_keys($freqs)); 304 foreach ($freqs as $rid => $freq) { 305 $token = $tokens[$rid] ?? ''; 306 if (strlen($token) >= $minlen) { 307 $result[$token] = $freq; 308 } 309 } 310 } 311 312 arsort($result); 313 return $result; 314 } 315 316 /** 317 * Maximum suffix for the token indexes (eg. max word length currently stored) 318 * 319 * @return int 320 * @throws IndexLockException 321 */ 322 public function getTokenIndexMaximum(): int 323 { 324 if ($this->idxToken instanceof AbstractIndex) { 325 return $this->idxToken->max(); 326 } 327 return (new MemoryIndex($this->idxToken, ''))->max(); 328 } 329 330 /** 331 * Check the structural integrity of this collection's indexes 332 * 333 * Verifies that paired indexes have matching line counts: 334 * - token == frequency (per group, both keyed by token RID) 335 * - entity == reverse (both keyed by entity RID) 336 * 337 * @throws IndexIntegrityException when a structural inconsistency is found 338 */ 339 public function checkIntegrity(): void 340 { 341 // Check token/frequency pairs 342 $max = $this->splitByLength ? $this->getTokenIndexMaximum() : 0; 343 $groups = $this->splitByLength ? ($max > 0 ? range(1, $max) : []) : [0]; 344 345 foreach ($groups as $group) { 346 $tokenIndex = $this->getTokenIndex($group); 347 $freqIndex = $this->getFrequencyIndex($group); 348 349 if (!$tokenIndex->exists() && !$freqIndex->exists()) continue; 350 351 if ($tokenIndex->exists() !== $freqIndex->exists()) { 352 throw new IndexIntegrityException( 353 "Group $group: missing " . 354 ($tokenIndex->exists() ? 'frequency' : 'token') . ' index' 355 ); 356 } 357 358 $tc = count($tokenIndex); 359 $fc = count($freqIndex); 360 if ($tc !== $fc) { 361 throw new IndexIntegrityException( 362 "Group $group: token count ($tc) != frequency count ($fc)" 363 ); 364 } 365 } 366 367 // Check entity/reverse pair 368 $entityIndex = $this->getEntityIndex(); 369 $reverseIndex = $this->getReverseIndex(); 370 if ($entityIndex->exists() && $reverseIndex->exists()) { 371 $ec = count($entityIndex); 372 $rc = count($reverseIndex); 373 if ($ec !== $rc) { 374 throw new IndexIntegrityException( 375 "Entity count ($ec) != reverse count ($rc)" 376 ); 377 } 378 } 379 } 380 381 /** 382 * Add or update the tokens for a given entity 383 * 384 * The given list of tokens replaces the previously stored list for that entity. An empty list removes the 385 * entity from the index. 386 * 387 * The update merges old and new token data. getReverseAssignments() returns all previously stored token IDs 388 * with a value of 0 (see parseReverseRecord). resolveTokens() returns the new token IDs with their values. 389 * After array_replace_recursive, tokens only in the old map keep value 0 — causing updateIndexes to delete 390 * them from the frequency index via TupleOps::updateTuple. Tokens in the new map overwrite with their value. 391 * 392 * @param string $entity The name of the entity 393 * @param string[] $tokens The list of tokens for this entity 394 * @return static 395 * @throws IndexAccessException 396 * @throws IndexWriteException 397 * @throws IndexLockException 398 */ 399 public function addEntity(string $entity, array $tokens): static 400 { 401 if (!$this->isWritable) { 402 throw new IndexLockException('Indexes not locked. Forgot to call lock()?'); 403 } 404 405 $entityIndex = $this->getEntityIndex(); 406 $entityId = $entityIndex->accessCachedValue($entity); 407 408 $old = $this->getReverseAssignments($entity); 409 $new = $this->resolveTokens($tokens); 410 411 $merged = array_replace_recursive($old, $new); 412 413 $this->updateIndexes($merged, $entityId); 414 $this->saveReverseAssignments($entity, $merged); 415 416 return $this; 417 } 418 419 /** 420 * Resolve raw tokens into the two-level structure [group => [tokenId => frequency]] 421 * 422 * Calls countTokens() to get token frequencies (subclass responsibility), then groups 423 * by token length if splitByLength is enabled, or under '' if not. Finally resolves 424 * token strings to IDs via the appropriate token index. 425 * 426 * @param string[] $tokens The raw token list 427 * @return array [group => [tokenId => frequency, ...], ...] 428 * @throws IndexLockException 429 */ 430 protected function resolveTokens(array $tokens): array 431 { 432 $counted = $this->countTokens($tokens); 433 434 // group tokens by their index suffix 435 $groups = []; 436 foreach ($counted as $token => $freq) { 437 $group = $this->splitByLength ? Tokenizer::tokenLength($token) : 0; 438 $groups[$group][$token] = $freq; 439 } 440 441 // resolve token strings to IDs 442 $result = []; 443 foreach ($groups as $group => $tokenFreqs) { 444 $tokenIndex = $this->getTokenIndex($group); 445 $result[$group] = []; 446 foreach ($tokenFreqs as $token => $freq) { 447 $tokenId = $tokenIndex->getRowID((string)$token); 448 $result[$group][$tokenId] = $freq; 449 } 450 $tokenIndex->save(); 451 } 452 453 return $result; 454 } 455 456 /** 457 * Count or deduplicate tokens and return their frequencies 458 * 459 * FrequencyCollections return actual occurrence counts. 460 * LookupCollections deduplicate and return 1 for each token. 461 * 462 * @param string[] $tokens The raw token list 463 * @return array [token => frequency, ...] 464 */ 465 abstract protected function countTokens(array $tokens): array; 466 467 /** 468 * Get the token assignments for a given entity from the reverse index 469 * 470 * Returns the parsed reverse index record. The exact structure depends on the collection type. 471 * 472 * @param string $entity 473 * @return array 474 * @throws IndexAccessException 475 * @throws IndexWriteException 476 * @throws IndexLockException 477 */ 478 public function getReverseAssignments(string $entity): array 479 { 480 $entityIndex = $this->getEntityIndex(); 481 $entityId = $entityIndex->accessCachedValue($entity); 482 483 $reverseIndex = $this->getReverseIndex(); 484 $record = $reverseIndex->retrieveRow($entityId); 485 486 if ($record === '') { 487 return []; 488 } 489 490 return $this->parseReverseRecord($record); 491 } 492 493 /** 494 * Store the reverse index info about what tokens are assigned to the entity 495 * 496 * @param string $entity 497 * @param array $data The assignment data to store 498 * @return void 499 * @throws IndexAccessException 500 * @throws IndexWriteException 501 * @throws IndexLockException 502 */ 503 protected function saveReverseAssignments(string $entity, array $data): void 504 { 505 // remove tokens with frequency 0 (no longer assigned), then remove empty groups 506 $data = array_map(array_filter(...), $data); 507 $data = array_filter($data); 508 509 $record = $this->formatReverseRecord($data); 510 511 $entityIndex = $this->getEntityIndex(); 512 $entityId = $entityIndex->accessCachedValue($entity); 513 514 $reverseIndex = $this->getReverseIndex(); 515 $reverseIndex->changeRow($entityId, $record); 516 } 517 518 /** 519 * Parse a reverse index record into a two-level array 520 * 521 * The reverse index only stores which token IDs belong to an entity, not their frequencies. All values 522 * in the returned array are set to 0. This is intentional: when merged with new data in addEntity(), 523 * tokens absent from the new data retain 0, signaling deletion from the frequency index. 524 * 525 * For split collections the format is "group*tokenId:group*tokenId:..." where group is the token length. 526 * For non-split collections the group prefix is omitted: "tokenId:tokenId:..." 527 * This mirrors how TupleOps omits *1 for frequency 1. 528 * 529 * @param string $record The raw reverse index record 530 * @return array [group => [tokenId => 0, ...], ...] 531 */ 532 protected function parseReverseRecord(string $record): array 533 { 534 $result = []; 535 foreach (explode(':', $record) as $entry) { 536 $parts = explode('*', $entry, 2); 537 $tokenId = array_pop($parts); 538 $group = (int)(array_pop($parts) ?? 0); 539 $result[$group][$tokenId] = 0; 540 } 541 return $result; 542 } 543 544 /** 545 * Format a two-level array into a reverse index record string 546 * 547 * @param array $data [group => [tokenId => freq, ...], ...] 548 * @return string The formatted record 549 */ 550 protected function formatReverseRecord(array $data): string 551 { 552 $parts = []; 553 foreach ($data as $group => $tokens) { 554 $prefix = $group === 0 ? '' : "$group*"; 555 foreach (array_keys($tokens) as $tokenId) { 556 $parts[] = $prefix . $tokenId; 557 } 558 } 559 return implode(':', $parts); 560 } 561 562 /** 563 * Update frequency indexes with the given data 564 * 565 * Iterates over the two-level structure [group => [tokenId => freq]] and updates the 566 * corresponding frequency index for each group. A frequency of 0 removes the entity 567 * from that token's frequency record. 568 * 569 * @param array $data [group => [tokenId => frequency, ...], ...] 570 * @param int $entityId The entity ID 571 * @throws IndexLockException 572 */ 573 protected function updateIndexes(array $data, int $entityId): void 574 { 575 foreach ($data as $group => $tokens) { 576 $freqIndex = $this->getFrequencyIndex($group); 577 foreach ($tokens as $tokenId => $freq) { 578 $record = $freqIndex->retrieveRow($tokenId); 579 $record = TupleOps::updateTuple($record, $entityId, $freq); 580 $freqIndex->changeRow($tokenId, $record); 581 } 582 $freqIndex->save(); 583 } 584 } 585} 586