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