1<?php /** @noinspection SpellCheckingInspection */ 2 3/** 4 * Copyright (c) 2021. ComboStrap, Inc. and its affiliates. All Rights Reserved. 5 * 6 * This source code is licensed under the GPL license found in the 7 * COPYING file in the root directory of this source tree. 8 * 9 * @license GPL 3 (https://www.gnu.org/licenses/gpl-3.0.en.html) 10 * @author ComboStrap <support@combostrap.com> 11 * 12 */ 13 14namespace ComboStrap; 15 16 17use helper_plugin_sqlite; 18use RuntimeException; 19 20class Sqlite 21{ 22 23 24 /** 25 * Principal database 26 * (Backup) 27 */ 28 private const MAIN_DATABASE_NAME = "combo"; 29 /** 30 * Backend Databse 31 * (Log, Pub/Sub,...) 32 */ 33 private const SECONDARY_DB = "combo-secondary"; 34 35 private static $sqliteVersion; 36 37 38 private helper_plugin_sqlite $sqlitePlugin; 39 40 /** 41 * @var SqliteRequest the actual request. If not closed, it will be close. 42 * Otherwise, it's not possible to delete the database file. See {@link self::deleteDatabasesFile()} 43 */ 44 private SqliteRequest $actualRequest; 45 46 47 /** 48 * Sqlite constructor. 49 * @var helper_plugin_sqlite $sqlitePlugin 50 */ 51 public function __construct(helper_plugin_sqlite $sqlitePlugin) 52 { 53 $this->sqlitePlugin = $sqlitePlugin; 54 } 55 56 57 /** 58 * 59 * @return Sqlite $sqlite 60 * @throws ExceptionSqliteNotAvailable 61 */ 62 public static function createOrGetSqlite($databaseName = self::MAIN_DATABASE_NAME): Sqlite 63 { 64 65 $sqliteExecutionObjectIdentifier = Sqlite::class . "-$databaseName"; 66 $executionContext = ExecutionContext::getActualOrCreateFromEnv(); 67 68 try { 69 /** 70 * @var Sqlite $sqlite 71 * 72 * 73 * sqlite is stored globally 74 * because when we create a new instance, it will open the 75 * sqlite file. 76 * 77 * In a {@link cli_plugin_combo} run, you will run in the error: 78 * `` 79 * failed to open stream: Too many open files 80 * `` 81 * As there is by default a limit of 1024 open files 82 * which means that if there is more than 1024 pages 83 * that you replicate using a new sqlite instance each time, 84 * you fail. 85 * 86 */ 87 $sqlite = $executionContext->getRuntimeObject($sqliteExecutionObjectIdentifier); 88 } catch (ExceptionNotFound $e) { 89 $sqlite = null; 90 } 91 92 if ($sqlite !== null) { 93 $res = $sqlite->doWeNeedToCreateNewInstance(); 94 if ($res === false) { 95 return $sqlite; 96 } 97 } 98 99 /** 100 * Init 101 * @var helper_plugin_sqlite $sqlitePlugin 102 */ 103 $sqlitePlugin = plugin_load('helper', 'sqlite'); 104 /** 105 * Not enabled / loaded 106 */ 107 if ($sqlitePlugin === null) { 108 109 $sqliteMandatoryMessage = "The Sqlite Plugin is mandatory. Some functionalities of the ComboStrap Plugin may not work."; 110 throw new ExceptionSqliteNotAvailable($sqliteMandatoryMessage); 111 } 112 113 $adapter = $sqlitePlugin->getAdapter(); 114 if ($adapter == null) { 115 self::sendMessageAsNotAvailable(); 116 } 117 118 $adapter->setUseNativeAlter(true); 119 120 list($databaseName, $databaseDefinitionDir) = self::getDatabaseNameAndDefinitionDirectory($databaseName); 121 $init = $sqlitePlugin->init($databaseName, $databaseDefinitionDir); 122 if (!$init) { 123 $message = "Unable to initialize Sqlite"; 124 throw new ExceptionSqliteNotAvailable($message); 125 } 126 // regexp implementation 127 // https://stackoverflow.com/questions/5071601/how-do-i-use-regex-in-a-sqlite-query/18484596#18484596 128 $adapter = $sqlitePlugin->getAdapter(); 129 $adapter->create_function('regexp', 130 function ($pattern, $data, $delimiter = '~', $modifiers = 'isuS') { 131 if (isset($pattern, $data) === true) { 132 return (preg_match(sprintf('%1$s%2$s%1$s%3$s', $delimiter, $pattern, $modifiers), $data) > 0); 133 } 134 return null; 135 }, 136 4 137 ); 138 139 $sqlite = new Sqlite($sqlitePlugin); 140 $executionContext->setRuntimeObject($sqliteExecutionObjectIdentifier, $sqlite); 141 return $sqlite; 142 143 } 144 145 /** 146 * @throws ExceptionSqliteNotAvailable 147 */ 148 public static function createOrGetBackendSqlite(): ?Sqlite 149 { 150 return self::createOrGetSqlite(self::SECONDARY_DB); 151 } 152 153 154 public static function createSelectFromTableAndColumns(string $tableName, array $columns = null): string 155 { 156 if ($columns === null) { 157 $columnStatement = "*"; 158 } else { 159 $columnsStatement = []; 160 foreach ($columns as $columnName) { 161 $columnsStatement[] = "$columnName as \"$columnName\""; 162 } 163 $columnStatement = implode(", ", $columnsStatement); 164 } 165 /** 166 * TODO: We had added the `rowid` on all query 167 * but the underlining code was not supporting it, 168 * adding it in the next release to be able to locate the row 169 */ 170 return "select $columnStatement from $tableName"; 171 172 } 173 174 /** 175 * Used in test to delete the database file 176 * @return void 177 * @throws ExceptionFileSystem - if we can delete the databases 178 */ 179 public static function deleteDatabasesFile() 180 { 181 /** 182 * The plugin does not give you the option to 183 * where to create the database file 184 * See {@link \helper_plugin_sqlite_adapter::initdb()} 185 * $this->dbfile = $conf['metadir'].'/'.$dbname.$this->fileextension; 186 * 187 * If error on delete, see {@link self::close()} 188 */ 189 $metadatDirectory = ExecutionContext::getActualOrCreateFromEnv() 190 ->getConfig() 191 ->getMetaDataDirectory(); 192 $fileChildren = FileSystems::getChildrenLeaf($metadatDirectory); 193 foreach ($fileChildren as $child) { 194 try { 195 $extension = $child->getExtension(); 196 } catch (ExceptionNotFound $e) { 197 // ok no extension 198 continue; 199 } 200 if (in_array($extension, ["sqlite", "sqlite3"])) { 201 FileSystems::delete($child); 202 } 203 204 } 205 } 206 207 private static function getDatabaseNameAndDefinitionDirectory($databaseName): array 208 { 209 global $conf; 210 211 if ($databaseName === self::MAIN_DATABASE_NAME) { 212 $oldDbName = '404manager'; 213 $oldDbFile = $conf['metadir'] . "/{$oldDbName}.sqlite"; 214 $oldDbFileSqlite3 = $conf['metadir'] . "/{$oldDbName}.sqlite3"; 215 if (file_exists($oldDbFile) || file_exists($oldDbFileSqlite3)) { 216 $databaseName = $oldDbName; 217 } 218 } 219 220 $databaseDir = DOKU_PLUGIN . PluginUtility::PLUGIN_BASE_NAME . "/db/$databaseName"; 221 return [$databaseName, $databaseDir]; 222 223 } 224 225 /** 226 * Print debug info to the console in order to resolve 227 * RuntimeException: HY000 8 attempt to write a readonly database 228 * https://phpunit.readthedocs.io/en/latest/writing-tests-for-phpunit.html#error-output 229 */ 230 public function printDbInfoAtConsole() 231 { 232 $dbFile = $this->sqlitePlugin->getAdapter()->getDbFile(); 233 fwrite(STDERR, "Stderr DbFile: " . $dbFile . "\n"); 234 if (file_exists($dbFile)) { 235 fwrite(STDERR, "File does exists\n"); 236 fwrite(STDERR, "Permission " . substr(sprintf('%o', fileperms($dbFile)), -4) . "\n"); 237 } else { 238 fwrite(STDERR, "File does not exist\n"); 239 } 240 241 global $conf; 242 $metadir = $conf['metadir']; 243 fwrite(STDERR, "MetaDir: " . $metadir . "\n"); 244 $subdir = strpos($dbFile, $metadir) === 0; 245 if ($subdir) { 246 fwrite(STDERR, "Meta is a subdirectory of the db \n"); 247 } else { 248 fwrite(STDERR, "Meta is a not subdirectory of the db \n"); 249 } 250 251 } 252 253 /** 254 * Json support 255 */ 256 public function supportJson(): bool 257 { 258 259 260 $res = $this->sqlitePlugin->query("PRAGMA compile_options"); 261 $isJsonEnabled = false; 262 foreach ($this->sqlitePlugin->res2arr($res) as $row) { 263 if ($row["compile_option"] === "ENABLE_JSON1") { 264 $isJsonEnabled = true; 265 break; 266 } 267 }; 268 $this->sqlitePlugin->res_close($res); 269 return $isJsonEnabled; 270 } 271 272 273 /** 274 * @throws ExceptionSqliteNotAvailable 275 */ 276 public 277 static function sendMessageAsNotAvailable(): void 278 { 279 $sqliteMandatoryMessage = "The Sqlite Php Extension is mandatory. It seems that it's not available on this installation."; 280 throw new ExceptionSqliteNotAvailable($sqliteMandatoryMessage); 281 } 282 283 /** 284 * 285 * Old check when there was no {@link ExecutionContext} 286 * to reset the Sqlite variable 287 * TODO: delete ? 288 * 289 * 290 */ 291 private function doWeNeedToCreateNewInstance(): bool 292 { 293 294 global $conf; 295 $metaDir = $conf['metadir']; 296 297 /** 298 * Adapter may be null 299 * when the SQLite & PDO SQLite 300 * are not installed 301 * ie: SQLite & PDO SQLite support missing 302 */ 303 $adapter = $this->sqlitePlugin->getAdapter(); 304 if ($adapter === null) { 305 return true; 306 } 307 308 /** 309 * When the database is {@link \helper_plugin_sqlite_adapter::closedb()} 310 */ 311 if ($adapter->getDb() === null) { 312 /** 313 * We may also open it again 314 * {@link \helper_plugin_sqlite_adapter::opendb()} 315 * for now, reinit 316 */ 317 return true; 318 } 319 /** 320 * In test, we are running in different context (ie different root 321 * directory for DokuWiki and therefore different $conf 322 * and therefore different metadir where sqlite is stored) 323 * Because a sql file may be deleted, we may get: 324 * ``` 325 * RuntimeException: HY000 8 attempt to write a readonly database: 326 * ``` 327 * To avoid this error, we check that we are still in the same metadir 328 * where the sqlite database is stored. If not, we create a new instance 329 */ 330 $dbFile = $adapter->getDbFile(); 331 if (!file_exists($dbFile)) { 332 $this->close(); 333 return true; 334 } 335 // the file is in the meta directory 336 if (strpos($dbFile, $metaDir) === 0) { 337 // we are still in a class run 338 return false; 339 } 340 $this->close(); 341 return true; 342 } 343 344 public function close() 345 { 346 347 /** 348 * https://www.php.net/manual/en/pdo.connections.php#114822 349 * You put the variable connection on null 350 * the {@link \helper_plugin_sqlite_adapter::closedb() function} do that 351 * 352 * If we don't do that, the file is still locked 353 * by the sqlite process and the clean up process 354 * of dokuwiki test cannot delete it 355 * 356 * ie to avoid 357 * RuntimeException: Unable to delete the file 358 * (C:/Users/GERARD~1/AppData/Local/Temp/dwtests-1676813655.6773/data/meta/combo-secondary.sqlite3) in D:\dokuwiki\_test\core\TestUtils.php on line 58 359 * {@link TestUtils::rdelete} 360 * 361 * Windows sort of handling/ bug explained here 362 * https://bugs.php.net/bug.php?id=78930&edit=3 363 * 364 * Null to close the db explanation and bug 365 * https://bugs.php.net/bug.php?id=62065 366 * 367 */ 368 369 $this->closeActualRequestIfNotClosed(); 370 371 $adapter = $this->sqlitePlugin->getAdapter(); 372 if ($adapter !== null) { 373 374 $adapter->closedb(); 375 376 unset($adapter); 377 378 gc_collect_cycles(); 379 380 } 381 382 } 383 384 public function getDbName(): string 385 { 386 return $this->sqlitePlugin->getAdapter()->getName(); 387 } 388 389 390 public function getSqlitePlugin(): helper_plugin_sqlite 391 { 392 return $this->sqlitePlugin; 393 } 394 395 public function createRequest(): SqliteRequest 396 { 397 $this->closeActualRequestIfNotClosed(); 398 $this->actualRequest = new SqliteRequest($this); 399 return $this->actualRequest; 400 } 401 402 public function getVersion() 403 { 404 if (self::$sqliteVersion === null) { 405 $request = $this->createRequest() 406 ->setQuery("select sqlite_version()"); 407 try { 408 self::$sqliteVersion = $request 409 ->execute() 410 ->getFirstCellValue(); 411 } catch (ExceptionCompile $e) { 412 self::$sqliteVersion = "unknown"; 413 } finally { 414 $request->close(); 415 } 416 } 417 return self::$sqliteVersion; 418 } 419 420 /** 421 * @param string $option 422 * @return bool - true if the option is available 423 */ 424 public function hasOption(string $option): bool 425 { 426 try { 427 $present = $this->createRequest() 428 ->setQueryParametrized("select count(1) from pragma_compile_options() where compile_options = ?", [$option]) 429 ->execute() 430 ->getFirstCellValueAsInt(); 431 return $present === 1; 432 } catch (ExceptionCompile $e) { 433 LogUtility::msg("Error while trying to see if the sqlite option is available"); 434 return false; 435 } 436 437 } 438 439 /** 440 * Internal function that closes the actual request 441 * This is to be able to close all resources even if the developer 442 * forget. 443 * 444 * This is needed to be able to delete the database file. 445 * See {@link self::close()} for more information 446 * 447 * @return void 448 */ 449 private function closeActualRequestIfNotClosed() 450 { 451 if(isset($this->actualRequest)){ 452 $this->actualRequest->close(); 453 unset($this->actualRequest); 454 } 455 } 456} 457