xref: /dokuwiki/inc/Search/Index/MemoryIndex.php (revision 06053dca2fac9a1da4eb1accf8c2488942da5d2a)
1<?php
2
3namespace dokuwiki\Search\Index;
4
5use dokuwiki\Logger;
6use dokuwiki\Search\Exception\IndexLockException;
7use dokuwiki\Search\Exception\IndexWriteException;
8
9/**
10 * Access to a single index file
11 *
12 * Access using this class always happens by loading the full index into memory.
13 * Changes can be made permanent explicitly via save(), but will also be
14 * auto-saved on destruction to prevent data loss when indexes are used in tandem
15 * (a new RID in one index may already be referenced by another).
16 * Should be used for small indexes that receive many changes at once.
17 */
18class MemoryIndex extends AbstractIndex
19{
20    /** @var string[] the raw data lines of the index, no newlines */
21    protected array $data = [];
22
23    /** @var bool has the index been modified? */
24    protected bool $dirty = false;
25
26    /**
27     * Loads the full contents of the index into memory
28     *
29     * @inheritdoc
30     */
31    public function __construct($idx, $suffix = '', $isWritable = false)
32    {
33        parent::__construct($idx, $suffix, $isWritable);
34        if (!file_exists($this->filename)) {
35            return;
36        }
37        $this->data = file($this->filename, FILE_IGNORE_NEW_LINES);
38    }
39
40    /**
41     * Auto-save dirty data before releasing the lock
42     *
43     * When indexes are used in tandem, a new RID written to one index may already
44     * be referenced by other indexes that were saved. Losing unsaved data here
45     * would leave dangling references, causing silent index corruption.
46     *
47     * The try/catch is necessary because unlock() is called from __destruct()
48     * (in the parent class), and PHP destructors must not throw — a throw
49     * during exception unwinding causes a fatal error.
50     *
51     * @inheritdoc
52     */
53    public function unlock(): void
54    {
55        if ($this->isDirty()) {
56            try {
57                $this->save();
58            } catch (\Exception $e) {
59                Logger::error('MemoryIndex failed to save on unlock: ' . $e->getMessage());
60            }
61        }
62        parent::unlock();
63    }
64
65    /**
66     * @inheritdoc
67     * @throws IndexLockException
68     */
69    public function changeRow(int $rid, string $value): void
70    {
71        if (!$this->isWritable) throw new IndexLockException();
72
73        if ($rid > count($this->data)) {
74            $this->data = array_pad($this->data, $rid, '');
75        }
76        $this->data[$rid] = $value;
77        $this->dirty = true;
78    }
79
80    /** @inheritdoc */
81    public function retrieveRow(int $rid): string
82    {
83        return $this->data[$rid] ?? '';
84    }
85
86    /** @inheritdoc */
87    public function retrieveRows(array $rids): array
88    {
89        $result = [];
90        foreach ($rids as $rid) {
91            if (isset($this->data[$rid])) $result[$rid] = $this->data[$rid];
92        }
93
94        return $result;
95    }
96
97    /** @inheritdoc */
98    public function getRowIDs(array $values): array
99    {
100        $values = array_map(trim(...), $values);
101        $values = array_fill_keys($values, 1); // easier access as associative array
102
103        $result = [];
104        $count = count($this->data);
105        for ($ln = 0; $ln < $count; $ln++) {
106            $line = $this->data[$ln];
107            if (isset($values[$line])) {
108                $result[$line] = $ln;
109                unset($values[$line]);
110            }
111        }
112
113        if (!$this->isWritable) return $result;
114
115        // if there are still values, they have not been found and will be appended
116        foreach (array_keys($values) as $value) {
117            $this->data[] = $value;
118            $result[$value] = $ln++;
119            $this->dirty = true;
120        }
121
122        return $result;
123    }
124
125    /** @inheritdoc */
126    public function search(string $re): array
127    {
128        return preg_grep($re, $this->data);
129    }
130
131    /**
132     * Save the changed index back to its file
133     *
134     * The method will check the internal dirty state and will only write when the index has actually been changed
135     *
136     * @throws IndexWriteException
137     * @throws IndexLockException
138     */
139    public function save(): void
140    {
141        global $conf;
142
143        if (!$this->isDirty()) {
144            return;
145        }
146
147        if (!$this->isWritable) throw new IndexLockException();
148
149        $tempname = $this->filename . '.tmp';
150
151        $fh = @fopen($tempname, 'w');
152        if (!$fh) {
153            throw new IndexWriteException("Failed to write $tempname");
154        }
155        fwrite($fh, implode("\n", $this->data));
156        if ($this->data !== []) {
157            fwrite($fh, "\n");
158        }
159        fclose($fh);
160
161        if ($conf['fperm']) {
162            chmod($tempname, $conf['fperm']);
163        }
164
165        if (!io_rename($tempname, $this->filename)) {
166            throw new IndexWriteException("Failed to write $this->filename");
167        }
168
169        $this->dirty = false;
170    }
171
172    /**
173     * Check if the index has been modified and needs to be saved
174     * @return bool
175     */
176    public function isDirty(): bool
177    {
178        return $this->dirty;
179    }
180
181    /** @inheritdoc */
182    public function count(): int
183    {
184        return count($this->data);
185    }
186
187    /** @inheritdoc */
188    public function getIterator(): \ArrayIterator
189    {
190        return new \ArrayIterator($this->data);
191    }
192}
193