xref: /dokuwiki/inc/Search/Collection/AbstractCollection.php (revision c3a14f677ee1ae0cdba278e7ba21602e6cabba18)
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