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