xref: /plugin/sqlite/SQLiteDB.php (revision c9d29deffdcdc31804d0062041a96cfc62beadcb)
1<?php
2
3/**
4 * @noinspection SqlNoDataSourceInspection
5 * @noinspection SqlDialectInspection
6 * @noinspection PhpComposerExtensionStubsInspection
7 */
8
9namespace dokuwiki\plugin\sqlite;
10
11use dokuwiki\Extension\Event;
12
13/**
14 * Helpers to access a SQLite Database with automatic schema migration
15 */
16class SQLiteDB
17{
18    const FILE_EXTENSION = '.sqlite3';
19
20    /** @var \PDO */
21    protected $pdo;
22
23    /** @var string */
24    protected $schemadir;
25
26    /** @var string */
27    protected $dbname;
28
29    /** @var \helper_plugin_sqlite */
30    protected $helper;
31
32    /**
33     * Constructor
34     *
35     * @param string $dbname Database name
36     * @param string $schemadir directory with schema migration files
37     * @param \helper_plugin_sqlite $sqlitehelper for backwards compatibility
38     * @throws \Exception
39     */
40    public function __construct($dbname, $schemadir, $sqlitehelper = null)
41    {
42        if (!class_exists('pdo') || !in_array('sqlite', \PDO::getAvailableDrivers())) {
43            throw new \Exception('SQLite PDO driver not available');
44        }
45
46        // backwards compatibility, circular dependency
47        $this->helper = $sqlitehelper;
48        if (!$this->helper) {
49            $this->helper = new \helper_plugin_sqlite();
50        }
51        $this->helper->setAdapter($this);
52
53        $this->schemadir = $schemadir;
54        $this->dbname = $dbname;
55        $file = $this->getDbFile();
56
57        $this->pdo = new \PDO(
58            'sqlite:' . $file,
59            null,
60            null,
61            [
62                \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION
63            ]
64        );
65
66        if ($schemadir !== '') {
67            // schema dir is empty, when accessing the DB from Admin interface instead of plugin context
68            $this->applyMigrations();
69        }
70        Functions::register($this->pdo);
71    }
72
73    /**
74     * Do not serialize the DB connection
75     *
76     * @return array
77     */
78    public function __sleep()
79    {
80        $this->pdo = null;
81        return array_keys(get_object_vars($this));
82    }
83
84    /**
85     * On deserialization, reinit database connection
86     */
87    public function __wakeup()
88    {
89        $this->__construct($this->dbname, $this->schemadir, $this->helper);
90    }
91
92    // region public API
93
94    /**
95     * Direct access to the PDO object
96     * @return \PDO
97     */
98    public function pdo()
99    {
100        return $this->pdo;
101    }
102
103    /**
104     * Execute a statement and return it
105     *
106     * @param string $sql
107     * @param array $parameters
108     * @return \PDOStatement Be sure to close the cursor yourself
109     * @throws \PDOException
110     */
111    public function query($sql, $parameters = [])
112    {
113        $stmt = $this->pdo->prepare($sql);
114        $eventData = [
115            'sql' => &$sql,
116            'parameters' => &$parameters,
117            'stmt' => $stmt
118        ];
119        $event = new Event('PLUGIN_SQLITE_QUERY_EXECUTE', $eventData);
120        if ($event->advise_before()) {
121            $stmt->execute($parameters);
122        }
123        $event->advise_after();
124        return $stmt;
125    }
126
127    /**
128     * Execute a statement and return metadata
129     *
130     * Returns the last insert ID on INSERTs or the number of affected rows
131     *
132     * @param string $sql
133     * @param array $parameters
134     * @return int
135     * @throws \PDOException
136     */
137    public function exec($sql, $parameters = [])
138    {
139        $stmt = $this->query($sql, $parameters);
140
141        $count = $stmt->rowCount();
142        $stmt->closeCursor();
143        if ($count && preg_match('/^INSERT /i', $sql)) {
144            return $this->queryValue('SELECT last_insert_rowid()');
145        }
146
147        return $count;
148    }
149
150    /**
151     * Simple query abstraction
152     *
153     * Returns all data
154     *
155     * @param string $sql
156     * @param array $params
157     * @return array
158     * @throws \PDOException
159     */
160    public function queryAll($sql, $params = [])
161    {
162        $stmt = $this->query($sql, $params);
163        $data = $stmt->fetchAll(\PDO::FETCH_ASSOC);
164        $stmt->closeCursor();
165        return $data;
166    }
167
168    /**
169     * Query one single row
170     *
171     * @param string $sql
172     * @param array $params
173     * @return array|null
174     * @throws \PDOException
175     */
176    public function queryRecord($sql, $params = [])
177    {
178        $stmt = $this->query($sql, $params);
179        $row = $stmt->fetch(\PDO::FETCH_ASSOC);
180        $stmt->closeCursor();
181        if (is_array($row) && count($row)) {
182            return $row;
183        }
184        return null;
185    }
186
187    /**
188     * Insert or replace the given data into the table
189     *
190     * @param string $table
191     * @param array $data
192     * @param bool $replace Conflict resolution, replace or ignore
193     * @return array|null Either the inserted row or null if nothing was inserted
194     * @throws \PDOException
195     */
196    public function saveRecord($table, $data, $replace = true)
197    {
198        $columns = array_map(function ($column) {
199            return '"' . $column . '"';
200        }, array_keys($data));
201        $values = array_values($data);
202        $placeholders = array_pad([], count($columns), '?');
203
204        if ($replace) {
205            $command = 'REPLACE';
206        } else {
207            $command = 'INSERT OR IGNORE';
208        }
209
210        /** @noinspection SqlResolve */
211        $sql = $command . ' INTO "' . $table . '" (' . join(',', $columns) . ') VALUES (' . join(',',
212                $placeholders) . ')';
213        $stm = $this->query($sql, $values);
214        $success = $stm->rowCount();
215        $stm->closeCursor();
216
217        if ($success) {
218            $sql = 'SELECT * FROM "' . $table . '" WHERE rowid = last_insert_rowid()';
219            return $this->queryRecord($sql);
220        }
221        return null;
222    }
223
224    /**
225     * Execute a query that returns a single value
226     *
227     * @param string $sql
228     * @param array $params
229     * @return mixed|null
230     * @throws \PDOException
231     */
232    public function queryValue($sql, $params = [])
233    {
234        $result = $this->queryAll($sql, $params);
235        if (is_array($result) && count($result)) {
236            return array_values($result[0])[0];
237        }
238        return null;
239    }
240
241    // endregion
242
243    // region meta handling
244
245    /**
246     * Get a config value from the opt table
247     *
248     * @param string $opt Config name
249     * @param mixed $default What to return if the value isn't set
250     * @return mixed
251     * @throws \PDOException
252     */
253    public function getOpt($opt, $default = null)
254    {
255        $value = $this->queryValue("SELECT val FROM opts WHERE opt = ?", [$opt]);
256        if ($value === null) {
257            return $default;
258        }
259        return $value;
260    }
261
262    /**
263     * Set a config value in the opt table
264     *
265     * @param $opt
266     * @param $value
267     * @throws \PDOException
268     */
269    public function setOpt($opt, $value)
270    {
271        $this->exec('REPLACE INTO opts (opt,val) VALUES (?,?)', [$opt, $value]);
272    }
273
274    /**
275     * @return string
276     */
277    public function getDbName()
278    {
279        return $this->dbname;
280    }
281
282    /**
283     * @return string
284     */
285    public function getDbFile()
286    {
287        global $conf;
288        return $conf['metadir'] . '/' . $this->dbname . self::FILE_EXTENSION;
289    }
290
291    /**
292     * Create a dump of the database and its contents
293     *
294     * @return string
295     * @throws \Exception
296     */
297    public function dumpToFile($filename)
298    {
299        $fp = fopen($filename, 'w');
300        if (!$fp) {
301            throw new \Exception('Could not open file ' . $filename . ' for writing');
302        }
303
304        $tables = $this->queryAll('SELECT name,sql FROM sqlite_master WHERE type="table"');
305        fwrite($fp, 'BEGIN TRANSACTION;' . "\n");
306
307        foreach ($tables as $table) {
308            fwrite($fp, $table['sql'] . ";\n"); // table definition
309
310            // data as INSERT statements
311            $sql = "SELECT * FROM " . $table['name'];
312            $res = $this->query($sql);
313            while ($row = $res->fetch(\PDO::FETCH_ASSOC)) {
314                $line = 'INSERT INTO ' . $table['name'] . ' VALUES(';
315                foreach ($row as $no_entry => $entry) {
316                    if ($no_entry !== 0) {
317                        $line .= ',';
318                    }
319
320                    if (is_null($entry)) {
321                        $line .= 'NULL';
322                    } elseif (!is_numeric($entry)) {
323                        $line .= $this->pdo->quote($entry);
324                    } else {
325                        // TODO depending on locale extra leading zeros
326                        // are truncated e.g 1.300 (thousand three hunderd)-> 1.3
327                        $line .= $entry;
328                    }
329                }
330                $line .= ');' . "\n";
331                fwrite($fp, $line);
332            }
333            $res->closeCursor();
334        }
335
336        // indexes
337        $indexes = $this->queryAll("SELECT name,sql FROM sqlite_master WHERE type='index'");
338        foreach ($indexes as $index) {
339            fwrite($fp, $index['sql'] . ";\n");
340        }
341        fwrite($fp, 'COMMIT;' . "\n");
342        fclose($fp);
343        return $filename;
344    }
345
346    // endregion
347
348    // region migration handling
349
350    /**
351     * Apply all pending migrations
352     *
353     * Each migration is executed in a transaction which is rolled back on failure
354     * Migrations can be files in the schema directory or event handlers
355     *
356     * @throws \Exception
357     */
358    protected function applyMigrations()
359    {
360        $currentVersion = $this->currentDbVersion();
361        $latestVersion = $this->latestDbVersion();
362
363        for ($newVersion = $currentVersion + 1; $newVersion <= $latestVersion; $newVersion++) {
364            $data = [
365                'dbname' => $this->dbname,
366                'from' => $currentVersion,
367                'to' => $newVersion,
368                'file' => $this->getMigrationFile($newVersion),
369                'sqlite' => $this->helper,
370                'adapter' => $this,
371            ];
372            $event = new \Doku_Event('PLUGIN_SQLITE_DATABASE_UPGRADE', $data);
373
374            $this->pdo->beginTransaction();
375            try {
376                if ($event->advise_before()) {
377                    // standard migration file
378                    $sql = file_get_contents($data['file']);
379                    $this->pdo->exec($sql);
380                } else {
381                    if (!$event->result) {
382                        // advise before returned false, but the result was false
383                        throw new \PDOException('Plugin event did not signal success');
384                    }
385                }
386                $this->setOpt('dbversion', $newVersion);
387                $this->pdo->commit();
388                $event->advise_after();
389            } catch(\Exception $e) {
390                // something went wrong, rollback
391                $this->pdo->rollBack();
392                throw $e;
393            }
394        }
395
396        // vacuum the database to free up unused space
397        $this->pdo->exec('VACUUM');
398    }
399
400    /**
401     * Read the current version from the opt table
402     *
403     * The opt table is created here if not found
404     *
405     * @return int
406     * @throws \PDOException
407     */
408    protected function currentDbVersion()
409    {
410        try {
411            $version = $this->getOpt('dbversion', 0);
412            return (int) $version;
413        } catch(\PDOException $ignored) {
414            // add the opt table - if this fails too, let the exception bubble up
415            $sql = "CREATE TABLE IF NOT EXISTS opts (opt TEXT NOT NULL PRIMARY KEY, val NOT NULL DEFAULT '')";
416            $this->exec($sql);
417            $this->setOpt('dbversion', 0);
418            return 0;
419        }
420    }
421
422    /**
423     * Get the version this db should have
424     *
425     * @return int
426     * @throws \PDOException
427     */
428    protected function latestDbVersion()
429    {
430        if (!file_exists($this->schemadir . '/latest.version')) {
431            throw new \PDOException('No latest.version in schema dir');
432        }
433        return (int) trim(file_get_contents($this->schemadir . '/latest.version'));
434    }
435
436    /**
437     * Get the migrartion file for the given version
438     *
439     * @param int $version
440     * @return string
441     */
442    protected function getMigrationFile($version)
443    {
444        return sprintf($this->schemadir . '/update%04d.sql', $version);
445    }
446    // endregion
447}
448