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