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