xref: /plugin/sqlite/SQLiteDB.php (revision 3a56750b42e05745f84fd97217f7133805e622eb)
18da7d805SAndreas Gohr<?php
28da7d805SAndreas Gohr
38da7d805SAndreas Gohr/**
48da7d805SAndreas Gohr * @noinspection SqlNoDataSourceInspection
58da7d805SAndreas Gohr * @noinspection SqlDialectInspection
68da7d805SAndreas Gohr * @noinspection PhpComposerExtensionStubsInspection
78da7d805SAndreas Gohr */
88da7d805SAndreas Gohr
98da7d805SAndreas Gohrnamespace dokuwiki\plugin\sqlite;
108da7d805SAndreas Gohr
118da7d805SAndreas Gohr
128da7d805SAndreas Gohr/**
138da7d805SAndreas Gohr * Helpers to access a SQLite Database with automatic schema migration
148da7d805SAndreas Gohr */
158da7d805SAndreas Gohrclass SQLiteDB
168da7d805SAndreas Gohr{
178da7d805SAndreas Gohr    const FILE_EXTENSION = '.sqlite3';
188da7d805SAndreas Gohr
198da7d805SAndreas Gohr    /** @var \PDO */
208da7d805SAndreas Gohr    protected $pdo;
218da7d805SAndreas Gohr
228da7d805SAndreas Gohr    /** @var string */
238da7d805SAndreas Gohr    protected $schemadir;
248da7d805SAndreas Gohr
258da7d805SAndreas Gohr    /** @var string */
268da7d805SAndreas Gohr    protected $dbname;
278da7d805SAndreas Gohr
28*3a56750bSAndreas Gohr    /** @var \helper_plugin_sqlite */
298da7d805SAndreas Gohr    protected $helper;
308da7d805SAndreas Gohr
318da7d805SAndreas Gohr    /**
328da7d805SAndreas Gohr     * Constructor
338da7d805SAndreas Gohr     *
348da7d805SAndreas Gohr     * @param string $dbname Database name
358da7d805SAndreas Gohr     * @param string $schemadir directory with schema migration files
368da7d805SAndreas Gohr     * @param \helper_plugin_sqlite $sqlitehelper for backwards compatibility
378da7d805SAndreas Gohr     * @throws \Exception
388da7d805SAndreas Gohr     */
398da7d805SAndreas Gohr    public function __construct($dbname, $schemadir, $sqlitehelper = null)
408da7d805SAndreas Gohr    {
418da7d805SAndreas Gohr        if (!class_exists('pdo') || !in_array('sqlite', \PDO::getAvailableDrivers())) {
428da7d805SAndreas Gohr            throw new \Exception('SQLite PDO driver not available');
438da7d805SAndreas Gohr        }
448da7d805SAndreas Gohr
458da7d805SAndreas Gohr        // backwards compatibility, circular dependency
468da7d805SAndreas Gohr        $this->helper = $sqlitehelper;
47*3a56750bSAndreas Gohr        if(!$this->helper) {
48*3a56750bSAndreas Gohr            $this->helper = new \helper_plugin_sqlite();
49*3a56750bSAndreas Gohr        }
50*3a56750bSAndreas Gohr        $this->helper->setAdapter($this);
518da7d805SAndreas Gohr
528da7d805SAndreas Gohr        $this->schemadir = $schemadir;
538da7d805SAndreas Gohr        $this->dbname = $dbname;
548da7d805SAndreas Gohr        $file = $this->getDbFile();
558da7d805SAndreas Gohr
568da7d805SAndreas Gohr        $this->pdo = new \PDO(
578da7d805SAndreas Gohr            'sqlite:' . $file,
588da7d805SAndreas Gohr            null,
598da7d805SAndreas Gohr            null,
608da7d805SAndreas Gohr            [
618da7d805SAndreas Gohr                \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION
628da7d805SAndreas Gohr            ]
638da7d805SAndreas Gohr        );
648da7d805SAndreas Gohr
658da7d805SAndreas Gohr        if($schemadir !== '') {
668da7d805SAndreas Gohr            // schema dir is empty, when accessing the DB from Admin interface instead of plugin context
678da7d805SAndreas Gohr            $this->applyMigrations();
688da7d805SAndreas Gohr        }
698da7d805SAndreas Gohr        Functions::register($this->pdo);
708da7d805SAndreas Gohr    }
718da7d805SAndreas Gohr
728da7d805SAndreas Gohr
738da7d805SAndreas Gohr    // region public API
748da7d805SAndreas Gohr
758da7d805SAndreas Gohr    /**
768da7d805SAndreas Gohr     * Direct access to the PDO object
778da7d805SAndreas Gohr     * @return \PDO
788da7d805SAndreas Gohr     */
798da7d805SAndreas Gohr    public function pdo()
808da7d805SAndreas Gohr    {
818da7d805SAndreas Gohr        return $this->pdo;
828da7d805SAndreas Gohr    }
838da7d805SAndreas Gohr
848da7d805SAndreas Gohr    /**
858da7d805SAndreas Gohr     * Execute a statement and return it
868da7d805SAndreas Gohr     *
878da7d805SAndreas Gohr     * @param string $sql
888da7d805SAndreas Gohr     * @param array $parameters
898da7d805SAndreas Gohr     * @return \PDOStatement Be sure to close the cursor yourself
908da7d805SAndreas Gohr     * @throws \PDOException
918da7d805SAndreas Gohr     */
928da7d805SAndreas Gohr    public function query($sql, $parameters = [])
938da7d805SAndreas Gohr    {
948da7d805SAndreas Gohr        $stmt = $this->pdo->prepare($sql);
958da7d805SAndreas Gohr        $stmt->execute($parameters);
968da7d805SAndreas Gohr        return $stmt;
978da7d805SAndreas Gohr    }
988da7d805SAndreas Gohr
998da7d805SAndreas Gohr    /**
1008da7d805SAndreas Gohr     * Execute a statement and return metadata
1018da7d805SAndreas Gohr     *
1028da7d805SAndreas Gohr     * Returns the last insert ID on INSERTs or the number of affected rows
1038da7d805SAndreas Gohr     *
1048da7d805SAndreas Gohr     * @param string $sql
1058da7d805SAndreas Gohr     * @param array $parameters
1068da7d805SAndreas Gohr     * @return int
1078da7d805SAndreas Gohr     * @throws \PDOException
1088da7d805SAndreas Gohr     */
1098da7d805SAndreas Gohr    public function exec($sql, $parameters = [])
1108da7d805SAndreas Gohr    {
1118da7d805SAndreas Gohr        $stmt = $this->pdo->prepare($sql);
1128da7d805SAndreas Gohr        $stmt->execute($parameters);
1138da7d805SAndreas Gohr
1148da7d805SAndreas Gohr        $count = $stmt->rowCount();
1158da7d805SAndreas Gohr        $stmt->closeCursor();
1168da7d805SAndreas Gohr        if ($count && preg_match('/^INSERT /i', $sql)) {
1178da7d805SAndreas Gohr            return $this->queryValue('SELECT last_insert_rowid()');
1188da7d805SAndreas Gohr        }
1198da7d805SAndreas Gohr
1208da7d805SAndreas Gohr        return $count;
1218da7d805SAndreas Gohr    }
1228da7d805SAndreas Gohr
1238da7d805SAndreas Gohr    /**
1248da7d805SAndreas Gohr     * Simple query abstraction
1258da7d805SAndreas Gohr     *
1268da7d805SAndreas Gohr     * Returns all data
1278da7d805SAndreas Gohr     *
1288da7d805SAndreas Gohr     * @param string $sql
1298da7d805SAndreas Gohr     * @param array $params
1308da7d805SAndreas Gohr     * @return array
1318da7d805SAndreas Gohr     * @throws \PDOException
1328da7d805SAndreas Gohr     */
1338da7d805SAndreas Gohr    public function queryAll($sql, $params = [])
1348da7d805SAndreas Gohr    {
1358da7d805SAndreas Gohr        $stmt = $this->query($sql, $params);
1368da7d805SAndreas Gohr        $data = $stmt->fetchAll(\PDO::FETCH_ASSOC);
1378da7d805SAndreas Gohr        $stmt->closeCursor();
1388da7d805SAndreas Gohr        return $data;
1398da7d805SAndreas Gohr    }
1408da7d805SAndreas Gohr
1418da7d805SAndreas Gohr    /**
1428da7d805SAndreas Gohr     * Query one single row
1438da7d805SAndreas Gohr     *
1448da7d805SAndreas Gohr     * @param string $sql
1458da7d805SAndreas Gohr     * @param array $params
1468da7d805SAndreas Gohr     * @return array|null
1478da7d805SAndreas Gohr     * @throws \PDOException
1488da7d805SAndreas Gohr     */
1498da7d805SAndreas Gohr    public function queryRecord($sql, $params = [])
1508da7d805SAndreas Gohr    {
1518da7d805SAndreas Gohr        $stmt = $this->query($sql, $params);
1528da7d805SAndreas Gohr        $row = $stmt->fetch();
1538da7d805SAndreas Gohr        $stmt->closeCursor();
1548da7d805SAndreas Gohr        if (is_array($row) && count($row)) return $row;
1558da7d805SAndreas Gohr        return null;
1568da7d805SAndreas Gohr    }
1578da7d805SAndreas Gohr
1588da7d805SAndreas Gohr    /**
1598da7d805SAndreas Gohr     * Insert or replace the given data into the table
1608da7d805SAndreas Gohr     *
1618da7d805SAndreas Gohr     * @param string $table
1628da7d805SAndreas Gohr     * @param array $data
1638da7d805SAndreas Gohr     * @param bool $replace Conflict resolution, replace or ignore
1648da7d805SAndreas Gohr     * @throws \PDOException
1658da7d805SAndreas Gohr     */
1668da7d805SAndreas Gohr    public function saveRecord($table, $data, $replace = true)
1678da7d805SAndreas Gohr    {
1688da7d805SAndreas Gohr        $columns = array_map(function ($column) {
1698da7d805SAndreas Gohr            return '"' . $column . '"';
1708da7d805SAndreas Gohr        }, array_keys($data));
1718da7d805SAndreas Gohr        $values = array_values($data);
1728da7d805SAndreas Gohr        $placeholders = array_pad([], count($columns), '?');
1738da7d805SAndreas Gohr
1748da7d805SAndreas Gohr        if ($replace) {
1758da7d805SAndreas Gohr            $command = 'REPLACE';
1768da7d805SAndreas Gohr        } else {
1778da7d805SAndreas Gohr            $command = 'INSERT OR IGNORE';
1788da7d805SAndreas Gohr        }
1798da7d805SAndreas Gohr
1808da7d805SAndreas Gohr        /** @noinspection SqlResolve */
1818da7d805SAndreas Gohr        $sql = $command . ' INTO "' . $table . '" (' . join(',', $columns) . ') VALUES (' . join(',', $placeholders) . ')';
1828da7d805SAndreas Gohr        $stm = $this->pdo->prepare($sql);
1838da7d805SAndreas Gohr        $stm->execute($values);
1848da7d805SAndreas Gohr        $stm->closeCursor();
1858da7d805SAndreas Gohr    }
1868da7d805SAndreas Gohr
1878da7d805SAndreas Gohr    /**
1888da7d805SAndreas Gohr     * Execute a query that returns a single value
1898da7d805SAndreas Gohr     *
1908da7d805SAndreas Gohr     * @param string $sql
1918da7d805SAndreas Gohr     * @param array $params
1928da7d805SAndreas Gohr     * @return mixed|null
1938da7d805SAndreas Gohr     * @throws \PDOException
1948da7d805SAndreas Gohr     */
1958da7d805SAndreas Gohr    public function queryValue($sql, $params = [])
1968da7d805SAndreas Gohr    {
1978da7d805SAndreas Gohr        $result = $this->queryAll($sql, $params);
1988da7d805SAndreas Gohr        if (is_array($result) && count($result)) return array_values($result[0])[0];
1998da7d805SAndreas Gohr        return null;
2008da7d805SAndreas Gohr    }
2018da7d805SAndreas Gohr
2028da7d805SAndreas Gohr    // endregion
2038da7d805SAndreas Gohr
2048da7d805SAndreas Gohr    // region meta handling
2058da7d805SAndreas Gohr
2068da7d805SAndreas Gohr    /**
2078da7d805SAndreas Gohr     * Get a config value from the opt table
2088da7d805SAndreas Gohr     *
2098da7d805SAndreas Gohr     * @param string $opt Config name
2108da7d805SAndreas Gohr     * @param mixed $default What to return if the value isn't set
2118da7d805SAndreas Gohr     * @return mixed
2128da7d805SAndreas Gohr     * @throws \PDOException
2138da7d805SAndreas Gohr     */
2148da7d805SAndreas Gohr    public function getOpt($opt, $default = null)
2158da7d805SAndreas Gohr    {
2168da7d805SAndreas Gohr        $value = $this->queryValue("SELECT val FROM opts WHERE opt = ?", [$opt]);
2178da7d805SAndreas Gohr        if ($value === null) return $default;
2188da7d805SAndreas Gohr        return $value;
2198da7d805SAndreas Gohr    }
2208da7d805SAndreas Gohr
2218da7d805SAndreas Gohr    /**
2228da7d805SAndreas Gohr     * Set a config value in the opt table
2238da7d805SAndreas Gohr     *
2248da7d805SAndreas Gohr     * @param $opt
2258da7d805SAndreas Gohr     * @param $value
2268da7d805SAndreas Gohr     * @throws \PDOException
2278da7d805SAndreas Gohr     */
2288da7d805SAndreas Gohr    public function setOpt($opt, $value)
2298da7d805SAndreas Gohr    {
2308da7d805SAndreas Gohr        $this->exec('REPLACE INTO opts (opt,val) VALUES (?,?)', [$opt, $value]);
2318da7d805SAndreas Gohr    }
2328da7d805SAndreas Gohr
2338da7d805SAndreas Gohr    /**
2348da7d805SAndreas Gohr     * @return string
2358da7d805SAndreas Gohr     */
2368da7d805SAndreas Gohr    public function getDbName()
2378da7d805SAndreas Gohr    {
2388da7d805SAndreas Gohr        return $this->dbname;
2398da7d805SAndreas Gohr    }
2408da7d805SAndreas Gohr
2418da7d805SAndreas Gohr    /**
2428da7d805SAndreas Gohr     * @return string
2438da7d805SAndreas Gohr     */
2448da7d805SAndreas Gohr    public function getDbFile()
2458da7d805SAndreas Gohr    {
2468da7d805SAndreas Gohr        global $conf;
2478da7d805SAndreas Gohr        return $conf['metadir'] . '/' . $this->dbname . self::FILE_EXTENSION;
2488da7d805SAndreas Gohr    }
2498da7d805SAndreas Gohr
2508da7d805SAndreas Gohr    /**
2518da7d805SAndreas Gohr     * Create a dump of the database and its contents
2528da7d805SAndreas Gohr     *
2538da7d805SAndreas Gohr     * @return string
2548da7d805SAndreas Gohr     * @throws \Exception
2558da7d805SAndreas Gohr     */
2568da7d805SAndreas Gohr    public function dumpToFile($filename)
2578da7d805SAndreas Gohr    {
2588da7d805SAndreas Gohr        $fp = fopen($filename, 'w');
2598da7d805SAndreas Gohr        if (!$fp) {
2608da7d805SAndreas Gohr            throw new \Exception('Could not open file ' . $filename . ' for writing');
2618da7d805SAndreas Gohr        }
2628da7d805SAndreas Gohr
2638da7d805SAndreas Gohr
2648da7d805SAndreas Gohr        $tables = $this->queryAll('SELECT name,sql FROM sqlite_master WHERE type="table"');
2658da7d805SAndreas Gohr        fwrite($fp, 'BEGIN TRANSACTION;' . "\n");
2668da7d805SAndreas Gohr
2678da7d805SAndreas Gohr        foreach ($tables as $table) {
2688da7d805SAndreas Gohr            fwrite($fp, $table['sql'] . ";\n"); // table definition
2698da7d805SAndreas Gohr
2708da7d805SAndreas Gohr            // data as INSERT statements
2718da7d805SAndreas Gohr            $sql = "SELECT * FROM " . $table['name'];
2728da7d805SAndreas Gohr            $res = $this->query($sql);
2738da7d805SAndreas Gohr            while ($row = $res->fetch(\PDO::FETCH_ASSOC)) {
2748da7d805SAndreas Gohr                $line = 'INSERT INTO ' . $table['name'] . ' VALUES(';
2758da7d805SAndreas Gohr                foreach ($row as $no_entry => $entry) {
2768da7d805SAndreas Gohr                    if ($no_entry !== 0) {
2778da7d805SAndreas Gohr                        $line .= ',';
2788da7d805SAndreas Gohr                    }
2798da7d805SAndreas Gohr
2808da7d805SAndreas Gohr                    if (is_null($entry)) {
2818da7d805SAndreas Gohr                        $line .= 'NULL';
2828da7d805SAndreas Gohr                    } elseif (!is_numeric($entry)) {
2838da7d805SAndreas Gohr                        $line .= $this->pdo->quote($entry);
2848da7d805SAndreas Gohr                    } else {
2858da7d805SAndreas Gohr                        // TODO depending on locale extra leading zeros
2868da7d805SAndreas Gohr                        // are truncated e.g 1.300 (thousand three hunderd)-> 1.3
2878da7d805SAndreas Gohr                        $line .= $entry;
2888da7d805SAndreas Gohr                    }
2898da7d805SAndreas Gohr                }
2908da7d805SAndreas Gohr                $line .= ');' . "\n";
2918da7d805SAndreas Gohr                fwrite($fp, $line);
2928da7d805SAndreas Gohr            }
2938da7d805SAndreas Gohr            $res->closeCursor();
2948da7d805SAndreas Gohr        }
2958da7d805SAndreas Gohr
2968da7d805SAndreas Gohr        // indexes
2978da7d805SAndreas Gohr        $indexes = $this->queryAll("SELECT name,sql FROM sqlite_master WHERE type='index'");
2988da7d805SAndreas Gohr        foreach ($indexes as $index) {
2998da7d805SAndreas Gohr            fwrite($fp, $index['sql'] . ";\n");
3008da7d805SAndreas Gohr        }
3018da7d805SAndreas Gohr        fwrite($fp, 'COMMIT;' . "\n");
3028da7d805SAndreas Gohr        fclose($fp);
3038da7d805SAndreas Gohr        return $filename;
3048da7d805SAndreas Gohr    }
3058da7d805SAndreas Gohr
3068da7d805SAndreas Gohr    // endregion
3078da7d805SAndreas Gohr
3088da7d805SAndreas Gohr    // region migration handling
3098da7d805SAndreas Gohr
3108da7d805SAndreas Gohr    /**
3118da7d805SAndreas Gohr     * Apply all pending migrations
3128da7d805SAndreas Gohr     *
3138da7d805SAndreas Gohr     * Each migration is executed in a transaction which is rolled back on failure
3148da7d805SAndreas Gohr     * Migrations can be files in the schema directory or event handlers
3158da7d805SAndreas Gohr     *
3168da7d805SAndreas Gohr     * @throws \Exception
3178da7d805SAndreas Gohr     */
3188da7d805SAndreas Gohr    protected function applyMigrations()
3198da7d805SAndreas Gohr    {
3208da7d805SAndreas Gohr        $currentVersion = $this->currentDbVersion();
3218da7d805SAndreas Gohr        $latestVersion = $this->latestDbVersion();
3228da7d805SAndreas Gohr
3238da7d805SAndreas Gohr        for ($newVersion = $currentVersion + 1; $newVersion <= $latestVersion; $newVersion++) {
3248da7d805SAndreas Gohr            $data = [
3258da7d805SAndreas Gohr                'dbname' => $this->dbname,
3268da7d805SAndreas Gohr                'from' => $currentVersion,
3278da7d805SAndreas Gohr                'to' => $newVersion,
3288da7d805SAndreas Gohr                'file' => $this->getMigrationFile($newVersion),
3298da7d805SAndreas Gohr                'sqlite' => $this->helper,
3308da7d805SAndreas Gohr                'adapter' => $this,
3318da7d805SAndreas Gohr            ];
3328da7d805SAndreas Gohr            $event = new \Doku_Event('PLUGIN_SQLITE_DATABASE_UPGRADE', $data);
3338da7d805SAndreas Gohr
3348da7d805SAndreas Gohr            $this->pdo->beginTransaction();
3358da7d805SAndreas Gohr            try {
3368da7d805SAndreas Gohr                if ($event->advise_before()) {
3378da7d805SAndreas Gohr                    // standard migration file
3388da7d805SAndreas Gohr                    $sql = file_get_contents($data['file']);
3398da7d805SAndreas Gohr                    $this->exec($sql);
3408da7d805SAndreas Gohr                } else if (!$event->result) {
3418da7d805SAndreas Gohr                    // advise before returned false, but the result was false
3428da7d805SAndreas Gohr                    throw new \PDOException('Plugin event did not signal success');
3438da7d805SAndreas Gohr                }
3448da7d805SAndreas Gohr                $this->setOpt('dbversion', $newVersion);
3458da7d805SAndreas Gohr                $this->pdo->commit();
3468da7d805SAndreas Gohr                $event->advise_after();
3478da7d805SAndreas Gohr            } catch (\Exception $e) {
3488da7d805SAndreas Gohr                // something went wrong, rollback
3498da7d805SAndreas Gohr                $this->pdo->rollBack();
3508da7d805SAndreas Gohr                throw $e;
3518da7d805SAndreas Gohr            }
3528da7d805SAndreas Gohr        }
3538da7d805SAndreas Gohr
3548da7d805SAndreas Gohr        // vacuum the database to free up unused space
3558da7d805SAndreas Gohr        $this->pdo->exec('VACUUM');
3568da7d805SAndreas Gohr    }
3578da7d805SAndreas Gohr
3588da7d805SAndreas Gohr    /**
3598da7d805SAndreas Gohr     * Read the current version from the opt table
3608da7d805SAndreas Gohr     *
3618da7d805SAndreas Gohr     * The opt table is created here if not found
3628da7d805SAndreas Gohr     *
3638da7d805SAndreas Gohr     * @return int
3648da7d805SAndreas Gohr     * @throws \PDOException
3658da7d805SAndreas Gohr     */
3668da7d805SAndreas Gohr    protected function currentDbVersion()
3678da7d805SAndreas Gohr    {
3688da7d805SAndreas Gohr        try {
3698da7d805SAndreas Gohr            $version = $this->getOpt('dbversion', 0);
3708da7d805SAndreas Gohr            return (int)$version;
3718da7d805SAndreas Gohr        } catch (\PDOException $ignored) {
3728da7d805SAndreas Gohr            // add the opt table - if this fails too, let the exception bubble up
3738da7d805SAndreas Gohr            $sql = "CREATE TABLE IF NOT EXISTS opts (opt TEXT NOT NULL PRIMARY KEY, val NOT NULL DEFAULT '')";
3748da7d805SAndreas Gohr            $this->exec($sql);
3758da7d805SAndreas Gohr            $this->setOpt('dbversion', 0);
3768da7d805SAndreas Gohr            return 0;
3778da7d805SAndreas Gohr        }
3788da7d805SAndreas Gohr    }
3798da7d805SAndreas Gohr
3808da7d805SAndreas Gohr    /**
3818da7d805SAndreas Gohr     * Get the version this db should have
3828da7d805SAndreas Gohr     *
3838da7d805SAndreas Gohr     * @return int
3848da7d805SAndreas Gohr     * @throws \PDOException
3858da7d805SAndreas Gohr     */
3868da7d805SAndreas Gohr    protected function latestDbVersion()
3878da7d805SAndreas Gohr    {
3888da7d805SAndreas Gohr        if (!file_exists($this->schemadir . '/latest.version')) {
3898da7d805SAndreas Gohr            throw new \PDOException('No latest.version in schema dir');
3908da7d805SAndreas Gohr        }
3918da7d805SAndreas Gohr        return (int)trim(file_get_contents($this->schemadir . '/latest.version'));
3928da7d805SAndreas Gohr    }
3938da7d805SAndreas Gohr
3948da7d805SAndreas Gohr    /**
3958da7d805SAndreas Gohr     * Get the migrartion file for the given version
3968da7d805SAndreas Gohr     *
3978da7d805SAndreas Gohr     * @param int $version
3988da7d805SAndreas Gohr     * @return string
3998da7d805SAndreas Gohr     */
4008da7d805SAndreas Gohr    protected function getMigrationFile($version)
4018da7d805SAndreas Gohr    {
4028da7d805SAndreas Gohr        return sprintf($this->schemadir . '/update%04d.sql', $version);
4038da7d805SAndreas Gohr    }
4048da7d805SAndreas Gohr    // endregion
4058da7d805SAndreas Gohr}
406