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