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