xref: /dokuwiki/inc/Search/Collection/AbstractCollection.php (revision c66b5ec65fd5aa2f1037d2be542b49297f3aac0e)
1f2bbffb5SAndreas Gohr<?php
2f2bbffb5SAndreas Gohr
3f2bbffb5SAndreas Gohrnamespace dokuwiki\Search\Collection;
4f2bbffb5SAndreas Gohr
5f2bbffb5SAndreas Gohruse dokuwiki\Search\Exception\IndexAccessException;
6f2bbffb5SAndreas Gohruse dokuwiki\Search\Exception\IndexLockException;
7f2bbffb5SAndreas Gohruse dokuwiki\Search\Exception\IndexWriteException;
8f2bbffb5SAndreas Gohruse dokuwiki\Search\Index\FileIndex;
9f2bbffb5SAndreas Gohruse dokuwiki\Search\Index\Lock;
10f2bbffb5SAndreas Gohruse dokuwiki\Search\Index\MemoryIndex;
110a9fafedSAndreas Gohruse dokuwiki\Search\Index\TupleOps;
120a9fafedSAndreas Gohruse dokuwiki\Search\Tokenizer;
13f2bbffb5SAndreas Gohr
14f2bbffb5SAndreas Gohr/**
15f2bbffb5SAndreas Gohr * Abstract base class for index collections
16f2bbffb5SAndreas Gohr *
17f2bbffb5SAndreas Gohr * A collection manages a group of related indexes that together provide a specific search use case.
18f2bbffb5SAndreas Gohr * Every collection works with four index types: entity, token, frequency, and reverse.
19f2bbffb5SAndreas Gohr *
20f2bbffb5SAndreas Gohr * entity - the list of the main entities (eg. pages)
21f2bbffb5SAndreas Gohr * token - the list of tokens (eg. words) assigned to entities (can be split into multiple files)
22f2bbffb5SAndreas Gohr * frequency - how often a token appears on a entity (can be split into multiple files)
23f2bbffb5SAndreas Gohr * reverse - the list of tokens assigned to each entity
24f2bbffb5SAndreas Gohr *
25f2bbffb5SAndreas Gohr * @license GPL 2 (http://www.gnu.org/licenses/gpl.html)
26f2bbffb5SAndreas Gohr * @author Andreas Gohr <andi@splitbrain.org>
27f2bbffb5SAndreas Gohr * @author Tom N Harris <tnharris@whoopdedo.org>
28f2bbffb5SAndreas Gohr */
29f2bbffb5SAndreas Gohrabstract class AbstractCollection
30f2bbffb5SAndreas Gohr{
310a9fafedSAndreas Gohr    /** @var string[] Index names that have been successfully locked */
320a9fafedSAndreas Gohr    protected array $lockedIndexes = [];
330a9fafedSAndreas Gohr
34f2bbffb5SAndreas Gohr    /** @var bool Has a lock been acquired for all used indexes? */
35f2bbffb5SAndreas Gohr    protected bool $isWritable = false;
36f2bbffb5SAndreas Gohr
37f2bbffb5SAndreas Gohr    /**
38f2bbffb5SAndreas Gohr     * Initialize the collection with the names of the indexes it manages
39f2bbffb5SAndreas Gohr     *
40f2bbffb5SAndreas Gohr     * @param string $idxEntity Name of the primary entity index, eg. 'page'
41f2bbffb5SAndreas Gohr     * @param string $idxToken Base name of the secondary entity index, eg. 'w' for words
42f2bbffb5SAndreas Gohr     * @param string $idxFrequency Base name of the frequency index, eg. 'i' for word frequencies
43f2bbffb5SAndreas Gohr     * @param string $idxReverse Name of the reverse index, eg. 'pageword'
44f2bbffb5SAndreas Gohr     * @param bool $splitByLength Whether to split token/frequency indexes by token length
45f2bbffb5SAndreas Gohr     */
46f2bbffb5SAndreas Gohr    public function __construct(
47f2bbffb5SAndreas Gohr        protected string $idxEntity,
48f2bbffb5SAndreas Gohr        protected string $idxToken,
49d92c078cSAndreas Gohr        protected string $idxFrequency = '',
50d92c078cSAndreas Gohr        protected string $idxReverse = '',
51f2bbffb5SAndreas Gohr        protected bool   $splitByLength = false
520a9fafedSAndreas Gohr    )
530a9fafedSAndreas Gohr    {
54f2bbffb5SAndreas Gohr    }
55f2bbffb5SAndreas Gohr
56f2bbffb5SAndreas Gohr    /**
57f2bbffb5SAndreas Gohr     * Destructor
58f2bbffb5SAndreas Gohr     *
59f2bbffb5SAndreas Gohr     * Ensures locks are released when the class is destroyed
60f2bbffb5SAndreas Gohr     */
61f2bbffb5SAndreas Gohr    public function __destruct()
62f2bbffb5SAndreas Gohr    {
63f2bbffb5SAndreas Gohr        $this->unlock();
64f2bbffb5SAndreas Gohr    }
65f2bbffb5SAndreas Gohr
66f2bbffb5SAndreas Gohr    /**
67f2bbffb5SAndreas Gohr     * Lock all indexes for writing
68f2bbffb5SAndreas Gohr     *
69f2bbffb5SAndreas Gohr     * @return $this can be used for chaining
70f2bbffb5SAndreas Gohr     * @throws IndexLockException
71f2bbffb5SAndreas Gohr     */
72f2bbffb5SAndreas Gohr    public function lock(): static
73f2bbffb5SAndreas Gohr    {
740a9fafedSAndreas Gohr        foreach (array_filter([
750a9fafedSAndreas Gohr            $this->idxEntity,
760a9fafedSAndreas Gohr            $this->idxToken,
770a9fafedSAndreas Gohr            $this->idxFrequency,
780a9fafedSAndreas Gohr            $this->idxReverse
790a9fafedSAndreas Gohr        ]) as $idxName) {
80*c66b5ec6SAndreas Gohr            try {
81*c66b5ec6SAndreas Gohr                Lock::acquire($idxName);
820a9fafedSAndreas Gohr                $this->lockedIndexes[] = $idxName;
83*c66b5ec6SAndreas Gohr            } catch (IndexLockException $e) {
84*c66b5ec6SAndreas Gohr                $this->unlock();
85*c66b5ec6SAndreas Gohr                throw $e;
86*c66b5ec6SAndreas Gohr            }
87f2bbffb5SAndreas Gohr        }
88f2bbffb5SAndreas Gohr        $this->isWritable = true;
89f2bbffb5SAndreas Gohr        return $this;
90f2bbffb5SAndreas Gohr    }
91f2bbffb5SAndreas Gohr
92f2bbffb5SAndreas Gohr    /**
930a9fafedSAndreas Gohr     * Unlock all indexes that were successfully locked
94f2bbffb5SAndreas Gohr     *
95f2bbffb5SAndreas Gohr     * @return void
96f2bbffb5SAndreas Gohr     */
97f2bbffb5SAndreas Gohr    public function unlock(): void
98f2bbffb5SAndreas Gohr    {
990a9fafedSAndreas Gohr        foreach ($this->lockedIndexes as $idxName) {
100*c66b5ec6SAndreas Gohr            Lock::release($idxName);
101f2bbffb5SAndreas Gohr        }
1020a9fafedSAndreas Gohr        $this->lockedIndexes = [];
103f2bbffb5SAndreas Gohr        $this->isWritable = false;
104f2bbffb5SAndreas Gohr    }
105f2bbffb5SAndreas Gohr
106f2bbffb5SAndreas Gohr    /**
107f2bbffb5SAndreas Gohr     * @return FileIndex
108*c66b5ec6SAndreas Gohr     * @throws IndexLockException
109f2bbffb5SAndreas Gohr     */
110f2bbffb5SAndreas Gohr    public function getEntityIndex(): FileIndex
111f2bbffb5SAndreas Gohr    {
112f2bbffb5SAndreas Gohr        return new FileIndex($this->idxEntity, '', $this->isWritable);
113f2bbffb5SAndreas Gohr    }
114f2bbffb5SAndreas Gohr
115f2bbffb5SAndreas Gohr    /**
116f2bbffb5SAndreas Gohr     * @param int|string $suffix
117f2bbffb5SAndreas Gohr     * @return MemoryIndex
118*c66b5ec6SAndreas Gohr     * @throws IndexLockException
119f2bbffb5SAndreas Gohr     */
120f2bbffb5SAndreas Gohr    public function getTokenIndex(int|string $suffix): MemoryIndex
121f2bbffb5SAndreas Gohr    {
122f2bbffb5SAndreas Gohr        return new MemoryIndex($this->idxToken, $suffix, $this->isWritable);
123f2bbffb5SAndreas Gohr    }
124f2bbffb5SAndreas Gohr
125f2bbffb5SAndreas Gohr    /**
126f2bbffb5SAndreas Gohr     * @param int|string $suffix
127f2bbffb5SAndreas Gohr     * @return MemoryIndex
128*c66b5ec6SAndreas Gohr     * @throws IndexLockException
129f2bbffb5SAndreas Gohr     */
130f2bbffb5SAndreas Gohr    public function getFrequencyIndex(int|string $suffix): MemoryIndex
131f2bbffb5SAndreas Gohr    {
132f2bbffb5SAndreas Gohr        return new MemoryIndex($this->idxFrequency, $suffix, $this->isWritable);
133f2bbffb5SAndreas Gohr    }
134f2bbffb5SAndreas Gohr
135f2bbffb5SAndreas Gohr    /**
136f2bbffb5SAndreas Gohr     * @return FileIndex
137*c66b5ec6SAndreas Gohr     * @throws IndexLockException
138f2bbffb5SAndreas Gohr     */
139f2bbffb5SAndreas Gohr    public function getReverseIndex(): FileIndex
140f2bbffb5SAndreas Gohr    {
141f2bbffb5SAndreas Gohr        return new FileIndex($this->idxReverse, '', $this->isWritable);
142f2bbffb5SAndreas Gohr    }
143f2bbffb5SAndreas Gohr
144f2bbffb5SAndreas Gohr    /**
145f2bbffb5SAndreas Gohr     * Maximum suffix for the token indexes (eg. max word length currently stored)
146f2bbffb5SAndreas Gohr     *
147f2bbffb5SAndreas Gohr     * @return int
148*c66b5ec6SAndreas Gohr     * @throws IndexLockException
149f2bbffb5SAndreas Gohr     */
150f2bbffb5SAndreas Gohr    public function getTokenIndexMaximum(): int
151f2bbffb5SAndreas Gohr    {
152f2bbffb5SAndreas Gohr        return $this->getTokenIndex('')->max(); // no suffix needed to access the maximum
153f2bbffb5SAndreas Gohr    }
154f2bbffb5SAndreas Gohr
155f2bbffb5SAndreas Gohr    /**
156f2bbffb5SAndreas Gohr     * Add or update the tokens for a given entity
157f2bbffb5SAndreas Gohr     *
158f2bbffb5SAndreas Gohr     * The given list of tokens replaces the previously stored list for that entity. An empty list removes the
159f2bbffb5SAndreas Gohr     * entity from the index.
160f2bbffb5SAndreas Gohr     *
161f2bbffb5SAndreas Gohr     * The update merges old and new token data. getReverseAssignments() returns all previously stored token IDs
162f2bbffb5SAndreas Gohr     * with a value of 0 (see parseReverseRecord). resolveTokens() returns the new token IDs with their values.
163f2bbffb5SAndreas Gohr     * After array_replace_recursive, tokens only in the old map keep value 0 — causing updateIndexes to delete
164f2bbffb5SAndreas Gohr     * them from the frequency index via TupleOps::updateTuple. Tokens in the new map overwrite with their value.
165f2bbffb5SAndreas Gohr     *
166f2bbffb5SAndreas Gohr     * @param string $entity The name of the entity
167f2bbffb5SAndreas Gohr     * @param string[] $tokens The list of tokens for this entity
168f2bbffb5SAndreas Gohr     * @throws IndexAccessException
169f2bbffb5SAndreas Gohr     * @throws IndexWriteException
170f2bbffb5SAndreas Gohr     * @throws IndexLockException
171f2bbffb5SAndreas Gohr     */
172f2bbffb5SAndreas Gohr    public function addEntity(string $entity, array $tokens): void
173f2bbffb5SAndreas Gohr    {
174f2bbffb5SAndreas Gohr        if (!$this->isWritable) {
175f2bbffb5SAndreas Gohr            throw new IndexLockException('Indexes not locked. Forgot to call lock()?');
176f2bbffb5SAndreas Gohr        }
177f2bbffb5SAndreas Gohr
178f2bbffb5SAndreas Gohr        $entityIndex = $this->getEntityIndex();
179f2bbffb5SAndreas Gohr        $entityId = $entityIndex->accessCachedValue($entity);
180f2bbffb5SAndreas Gohr
181f2bbffb5SAndreas Gohr        $old = $this->getReverseAssignments($entity);
182f2bbffb5SAndreas Gohr        $new = $this->resolveTokens($tokens);
183f2bbffb5SAndreas Gohr
184f2bbffb5SAndreas Gohr        $merged = array_replace_recursive($old, $new);
185f2bbffb5SAndreas Gohr
186f2bbffb5SAndreas Gohr        $this->updateIndexes($merged, $entityId);
187f2bbffb5SAndreas Gohr        $this->saveReverseAssignments($entity, $merged);
188f2bbffb5SAndreas Gohr    }
189f2bbffb5SAndreas Gohr
190f2bbffb5SAndreas Gohr    /**
191f2bbffb5SAndreas Gohr     * Resolve raw tokens into the two-level structure [group => [tokenId => frequency]]
192f2bbffb5SAndreas Gohr     *
193f2bbffb5SAndreas Gohr     * Calls countTokens() to get token frequencies (subclass responsibility), then groups
194f2bbffb5SAndreas Gohr     * by token length if splitByLength is enabled, or under '' if not. Finally resolves
195f2bbffb5SAndreas Gohr     * token strings to IDs via the appropriate token index.
196f2bbffb5SAndreas Gohr     *
197f2bbffb5SAndreas Gohr     * @param string[] $tokens The raw token list
198f2bbffb5SAndreas Gohr     * @return array [group => [tokenId => frequency, ...], ...]
199f2bbffb5SAndreas Gohr     * @throws IndexLockException
200f2bbffb5SAndreas Gohr     * @throws IndexWriteException
201f2bbffb5SAndreas Gohr     */
202f2bbffb5SAndreas Gohr    protected function resolveTokens(array $tokens): array
203f2bbffb5SAndreas Gohr    {
204f2bbffb5SAndreas Gohr        $counted = $this->countTokens($tokens);
205f2bbffb5SAndreas Gohr
206f2bbffb5SAndreas Gohr        // group tokens by their index suffix
207f2bbffb5SAndreas Gohr        $groups = [];
208f2bbffb5SAndreas Gohr        foreach ($counted as $token => $freq) {
209f2bbffb5SAndreas Gohr            $group = $this->splitByLength ? (string)Tokenizer::tokenLength($token) : '';
210f2bbffb5SAndreas Gohr            $groups[$group][$token] = $freq;
211f2bbffb5SAndreas Gohr        }
212f2bbffb5SAndreas Gohr
213f2bbffb5SAndreas Gohr        // resolve token strings to IDs
214f2bbffb5SAndreas Gohr        $result = [];
215f2bbffb5SAndreas Gohr        foreach ($groups as $group => $tokenFreqs) {
216f2bbffb5SAndreas Gohr            $tokenIndex = $this->getTokenIndex($group);
217f2bbffb5SAndreas Gohr            $result[$group] = [];
218f2bbffb5SAndreas Gohr            foreach ($tokenFreqs as $token => $freq) {
219f2bbffb5SAndreas Gohr                $tokenId = $tokenIndex->getRowID((string)$token);
220f2bbffb5SAndreas Gohr                $result[$group][$tokenId] = $freq;
221f2bbffb5SAndreas Gohr            }
222f2bbffb5SAndreas Gohr            $tokenIndex->save();
223f2bbffb5SAndreas Gohr        }
224f2bbffb5SAndreas Gohr
225f2bbffb5SAndreas Gohr        return $result;
226f2bbffb5SAndreas Gohr    }
227f2bbffb5SAndreas Gohr
228f2bbffb5SAndreas Gohr    /**
229f2bbffb5SAndreas Gohr     * Count or deduplicate tokens and return their frequencies
230f2bbffb5SAndreas Gohr     *
231f2bbffb5SAndreas Gohr     * FrequencyCollections return actual occurrence counts.
232f2bbffb5SAndreas Gohr     * LookupCollections deduplicate and return 1 for each token.
233f2bbffb5SAndreas Gohr     *
234f2bbffb5SAndreas Gohr     * @param string[] $tokens The raw token list
235f2bbffb5SAndreas Gohr     * @return array [token => frequency, ...]
236f2bbffb5SAndreas Gohr     */
237f2bbffb5SAndreas Gohr    abstract protected function countTokens(array $tokens): array;
238f2bbffb5SAndreas Gohr
239f2bbffb5SAndreas Gohr    /**
240f2bbffb5SAndreas Gohr     * Get the token assignments for a given entity from the reverse index
241f2bbffb5SAndreas Gohr     *
242f2bbffb5SAndreas Gohr     * Returns the parsed reverse index record. The exact structure depends on the collection type.
243f2bbffb5SAndreas Gohr     *
244f2bbffb5SAndreas Gohr     * @param string $entity
245f2bbffb5SAndreas Gohr     * @return array
246f2bbffb5SAndreas Gohr     * @throws IndexAccessException
247f2bbffb5SAndreas Gohr     * @throws IndexWriteException
248*c66b5ec6SAndreas Gohr     * @throws IndexLockException
249f2bbffb5SAndreas Gohr     */
250f2bbffb5SAndreas Gohr    public function getReverseAssignments(string $entity): array
251f2bbffb5SAndreas Gohr    {
252f2bbffb5SAndreas Gohr        $entityIndex = $this->getEntityIndex();
253f2bbffb5SAndreas Gohr        $entityId = $entityIndex->accessCachedValue($entity);
254f2bbffb5SAndreas Gohr
255f2bbffb5SAndreas Gohr        $reverseIndex = $this->getReverseIndex();
256f2bbffb5SAndreas Gohr        $record = $reverseIndex->retrieveRow($entityId);
257f2bbffb5SAndreas Gohr
258f2bbffb5SAndreas Gohr        if ($record === '') {
259f2bbffb5SAndreas Gohr            return [];
260f2bbffb5SAndreas Gohr        }
261f2bbffb5SAndreas Gohr
262f2bbffb5SAndreas Gohr        return $this->parseReverseRecord($record);
263f2bbffb5SAndreas Gohr    }
264f2bbffb5SAndreas Gohr
265f2bbffb5SAndreas Gohr    /**
266f2bbffb5SAndreas Gohr     * Store the reverse index info about what tokens are assigned to the entity
267f2bbffb5SAndreas Gohr     *
268f2bbffb5SAndreas Gohr     * @param string $entity
269f2bbffb5SAndreas Gohr     * @param array $data The assignment data to store
270f2bbffb5SAndreas Gohr     * @return void
271f2bbffb5SAndreas Gohr     * @throws IndexAccessException
272f2bbffb5SAndreas Gohr     * @throws IndexWriteException
273f2bbffb5SAndreas Gohr     * @throws IndexLockException
274f2bbffb5SAndreas Gohr     */
275f2bbffb5SAndreas Gohr    protected function saveReverseAssignments(string $entity, array $data): void
276f2bbffb5SAndreas Gohr    {
277f2bbffb5SAndreas Gohr        // remove tokens with frequency 0 (no longer assigned), then remove empty groups
278f2bbffb5SAndreas Gohr        $data = array_map('array_filter', $data);
279f2bbffb5SAndreas Gohr        $data = array_filter($data);
280f2bbffb5SAndreas Gohr
281f2bbffb5SAndreas Gohr        $record = $this->formatReverseRecord($data);
282f2bbffb5SAndreas Gohr
283f2bbffb5SAndreas Gohr        $entityIndex = $this->getEntityIndex();
284f2bbffb5SAndreas Gohr        $entityId = $entityIndex->accessCachedValue($entity);
285f2bbffb5SAndreas Gohr
286f2bbffb5SAndreas Gohr        $reverseIndex = $this->getReverseIndex();
287f2bbffb5SAndreas Gohr        $reverseIndex->changeRow($entityId, $record);
288f2bbffb5SAndreas Gohr    }
289f2bbffb5SAndreas Gohr
290f2bbffb5SAndreas Gohr    /**
291f2bbffb5SAndreas Gohr     * Parse a reverse index record into a two-level array
292f2bbffb5SAndreas Gohr     *
293f2bbffb5SAndreas Gohr     * The reverse index only stores which token IDs belong to an entity, not their frequencies. All values
294f2bbffb5SAndreas Gohr     * in the returned array are set to 0. This is intentional: when merged with new data in addEntity(),
295f2bbffb5SAndreas Gohr     * tokens absent from the new data retain 0, signaling deletion from the frequency index.
296f2bbffb5SAndreas Gohr     *
297f2bbffb5SAndreas Gohr     * For split collections the format is "group*tokenId:group*tokenId:..." where group is the token length.
298f2bbffb5SAndreas Gohr     * For non-split collections the group prefix is omitted: "tokenId:tokenId:..."
299f2bbffb5SAndreas Gohr     * This mirrors how TupleOps omits *1 for frequency 1.
300f2bbffb5SAndreas Gohr     *
301f2bbffb5SAndreas Gohr     * @param string $record The raw reverse index record
302f2bbffb5SAndreas Gohr     * @return array [group => [tokenId => 0, ...], ...]
303f2bbffb5SAndreas Gohr     */
304f2bbffb5SAndreas Gohr    protected function parseReverseRecord(string $record): array
305f2bbffb5SAndreas Gohr    {
306f2bbffb5SAndreas Gohr        $result = [];
307f2bbffb5SAndreas Gohr        foreach (explode(':', $record) as $entry) {
308f2bbffb5SAndreas Gohr            $parts = explode('*', $entry, 2);
309f2bbffb5SAndreas Gohr            $tokenId = array_pop($parts);
310f2bbffb5SAndreas Gohr            $group = array_pop($parts) ?? '';
311f2bbffb5SAndreas Gohr            $result[$group][$tokenId] = 0;
312f2bbffb5SAndreas Gohr        }
313f2bbffb5SAndreas Gohr        return $result;
314f2bbffb5SAndreas Gohr    }
315f2bbffb5SAndreas Gohr
316f2bbffb5SAndreas Gohr    /**
317f2bbffb5SAndreas Gohr     * Format a two-level array into a reverse index record string
318f2bbffb5SAndreas Gohr     *
319f2bbffb5SAndreas Gohr     * @param array $data [group => [tokenId => freq, ...], ...]
320f2bbffb5SAndreas Gohr     * @return string The formatted record
321f2bbffb5SAndreas Gohr     */
322f2bbffb5SAndreas Gohr    protected function formatReverseRecord(array $data): string
323f2bbffb5SAndreas Gohr    {
324f2bbffb5SAndreas Gohr        $parts = [];
325f2bbffb5SAndreas Gohr        foreach ($data as $group => $tokens) {
326f2bbffb5SAndreas Gohr            $prefix = $group === '' ? '' : "$group*";
327f2bbffb5SAndreas Gohr            foreach (array_keys($tokens) as $tokenId) {
328f2bbffb5SAndreas Gohr                $parts[] = $prefix . $tokenId;
329f2bbffb5SAndreas Gohr            }
330f2bbffb5SAndreas Gohr        }
331f2bbffb5SAndreas Gohr        return implode(':', $parts);
332f2bbffb5SAndreas Gohr    }
333f2bbffb5SAndreas Gohr
334f2bbffb5SAndreas Gohr    /**
335f2bbffb5SAndreas Gohr     * Update frequency indexes with the given data
336f2bbffb5SAndreas Gohr     *
337f2bbffb5SAndreas Gohr     * Iterates over the two-level structure [group => [tokenId => freq]] and updates the
338f2bbffb5SAndreas Gohr     * corresponding frequency index for each group. A frequency of 0 removes the entity
339f2bbffb5SAndreas Gohr     * from that token's frequency record.
340f2bbffb5SAndreas Gohr     *
341f2bbffb5SAndreas Gohr     * @param array $data [group => [tokenId => frequency, ...], ...]
342f2bbffb5SAndreas Gohr     * @param int $entityId The entity ID
343f2bbffb5SAndreas Gohr     * @throws IndexLockException
344f2bbffb5SAndreas Gohr     * @throws IndexWriteException
345f2bbffb5SAndreas Gohr     */
346f2bbffb5SAndreas Gohr    protected function updateIndexes(array $data, int $entityId): void
347f2bbffb5SAndreas Gohr    {
348f2bbffb5SAndreas Gohr        foreach ($data as $group => $tokens) {
349f2bbffb5SAndreas Gohr            $freqIndex = $this->getFrequencyIndex($group);
350f2bbffb5SAndreas Gohr            foreach ($tokens as $tokenId => $freq) {
351f2bbffb5SAndreas Gohr                $record = $freqIndex->retrieveRow($tokenId);
352f2bbffb5SAndreas Gohr                $record = TupleOps::updateTuple($record, $entityId, $freq);
353f2bbffb5SAndreas Gohr                $freqIndex->changeRow($tokenId, $record);
354f2bbffb5SAndreas Gohr            }
355f2bbffb5SAndreas Gohr            $freqIndex->save();
356f2bbffb5SAndreas Gohr        }
357f2bbffb5SAndreas Gohr    }
358f2bbffb5SAndreas Gohr}
359