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