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