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