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 public static function createSelectFromTableAndColumns(string $tableName, array $columns = null): string 154 { 155 if ($columns === null) { 156 $columnStatement = "*"; 157 } else { 158 $columnsStatement = []; 159 foreach ($columns as $columnName) { 160 $columnsStatement[] = "$columnName as \"$columnName\""; 161 } 162 $columnStatement = implode(", ", $columnsStatement); 163 } 164 return "select $columnStatement from $tableName"; 165 166 } 167 168 /** 169 * Used in test to delete the database file 170 * @return void 171 * @throws ExceptionFileSystem - if we can delete the databases 172 */ 173 public static function deleteDatabasesFile() 174 { 175 /** 176 * The plugin does not give you the option to 177 * where to create the database file 178 * See {@link \helper_plugin_sqlite_adapter::initdb()} 179 * $this->dbfile = $conf['metadir'].'/'.$dbname.$this->fileextension; 180 * 181 * If error on delete, see {@link self::close()} 182 */ 183 $metadatDirectory = ExecutionContext::getActualOrCreateFromEnv() 184 ->getConfig() 185 ->getMetaDataDirectory(); 186 $fileChildren = FileSystems::getChildrenLeaf($metadatDirectory); 187 foreach ($fileChildren as $child) { 188 try { 189 $extension = $child->getExtension(); 190 } catch (ExceptionNotFound $e) { 191 // ok no extension 192 continue; 193 } 194 if (in_array($extension, ["sqlite", "sqlite3"])) { 195 FileSystems::delete($child); 196 } 197 198 } 199 } 200 201 private static function getDatabaseNameAndDefinitionDirectory($databaseName): array 202 { 203 global $conf; 204 205 if ($databaseName === self::MAIN_DATABASE_NAME) { 206 $oldDbName = '404manager'; 207 $oldDbFile = $conf['metadir'] . "/{$oldDbName}.sqlite"; 208 $oldDbFileSqlite3 = $conf['metadir'] . "/{$oldDbName}.sqlite3"; 209 if (file_exists($oldDbFile) || file_exists($oldDbFileSqlite3)) { 210 $databaseName = $oldDbName; 211 } 212 } 213 214 $databaseDir = DOKU_PLUGIN . PluginUtility::PLUGIN_BASE_NAME . "/db/$databaseName"; 215 return [$databaseName, $databaseDir]; 216 217 } 218 219 /** 220 * Print debug info to the console in order to resolve 221 * RuntimeException: HY000 8 attempt to write a readonly database 222 * https://phpunit.readthedocs.io/en/latest/writing-tests-for-phpunit.html#error-output 223 */ 224 public function printDbInfoAtConsole() 225 { 226 $dbFile = $this->sqlitePlugin->getAdapter()->getDbFile(); 227 fwrite(STDERR, "Stderr DbFile: " . $dbFile . "\n"); 228 if (file_exists($dbFile)) { 229 fwrite(STDERR, "File does exists\n"); 230 fwrite(STDERR, "Permission " . substr(sprintf('%o', fileperms($dbFile)), -4) . "\n"); 231 } else { 232 fwrite(STDERR, "File does not exist\n"); 233 } 234 235 global $conf; 236 $metadir = $conf['metadir']; 237 fwrite(STDERR, "MetaDir: " . $metadir . "\n"); 238 $subdir = strpos($dbFile, $metadir) === 0; 239 if ($subdir) { 240 fwrite(STDERR, "Meta is a subdirectory of the db \n"); 241 } else { 242 fwrite(STDERR, "Meta is a not subdirectory of the db \n"); 243 } 244 245 } 246 247 /** 248 * Json support 249 */ 250 public function supportJson(): bool 251 { 252 253 254 $res = $this->sqlitePlugin->query("PRAGMA compile_options"); 255 $isJsonEnabled = false; 256 foreach ($this->sqlitePlugin->res2arr($res) as $row) { 257 if ($row["compile_option"] === "ENABLE_JSON1") { 258 $isJsonEnabled = true; 259 break; 260 } 261 }; 262 $this->sqlitePlugin->res_close($res); 263 return $isJsonEnabled; 264 } 265 266 267 /** 268 * @throws ExceptionSqliteNotAvailable 269 */ 270 public 271 static function sendMessageAsNotAvailable(): void 272 { 273 $sqliteMandatoryMessage = "The Sqlite Php Extension is mandatory. It seems that it's not available on this installation."; 274 throw new ExceptionSqliteNotAvailable($sqliteMandatoryMessage); 275 } 276 277 /** 278 * 279 * Old check when there was no {@link ExecutionContext} 280 * to reset the Sqlite variable 281 * TODO: delete ? 282 * 283 * 284 */ 285 private function doWeNeedToCreateNewInstance(): bool 286 { 287 288 global $conf; 289 $metaDir = $conf['metadir']; 290 291 /** 292 * Adapter may be null 293 * when the SQLite & PDO SQLite 294 * are not installed 295 * ie: SQLite & PDO SQLite support missing 296 */ 297 $adapter = $this->sqlitePlugin->getAdapter(); 298 if ($adapter === null) { 299 return true; 300 } 301 302 /** 303 * When the database is {@link \helper_plugin_sqlite_adapter::closedb()} 304 */ 305 if ($adapter->getDb() === null) { 306 /** 307 * We may also open it again 308 * {@link \helper_plugin_sqlite_adapter::opendb()} 309 * for now, reinit 310 */ 311 return true; 312 } 313 /** 314 * In test, we are running in different context (ie different root 315 * directory for DokuWiki and therefore different $conf 316 * and therefore different metadir where sqlite is stored) 317 * Because a sql file may be deleted, we may get: 318 * ``` 319 * RuntimeException: HY000 8 attempt to write a readonly database: 320 * ``` 321 * To avoid this error, we check that we are still in the same metadir 322 * where the sqlite database is stored. If not, we create a new instance 323 */ 324 $dbFile = $adapter->getDbFile(); 325 if (!file_exists($dbFile)) { 326 $this->close(); 327 return true; 328 } 329 // the file is in the meta directory 330 if (strpos($dbFile, $metaDir) === 0) { 331 // we are still in a class run 332 return false; 333 } 334 $this->close(); 335 return true; 336 } 337 338 public function close() 339 { 340 341 /** 342 * https://www.php.net/manual/en/pdo.connections.php#114822 343 * You put the variable connection on null 344 * the {@link \helper_plugin_sqlite_adapter::closedb() function} do that 345 * 346 * If we don't do that, the file is still locked 347 * by the sqlite process and the clean up process 348 * of dokuwiki test cannot delete it 349 * 350 * ie to avoid 351 * RuntimeException: Unable to delete the file 352 * (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 353 * {@link TestUtils::rdelete} 354 * 355 * Windows sort of handling/ bug explained here 356 * https://bugs.php.net/bug.php?id=78930&edit=3 357 * 358 * Null to close the db explanation and bug 359 * https://bugs.php.net/bug.php?id=62065 360 * 361 */ 362 363 $this->closeActualRequestIfNotClosed(); 364 365 $adapter = $this->sqlitePlugin->getAdapter(); 366 if ($adapter !== null) { 367 368 $adapter->closedb(); 369 370 unset($adapter); 371 372 gc_collect_cycles(); 373 374 } 375 376 } 377 378 public function getDbName(): string 379 { 380 return $this->sqlitePlugin->getAdapter()->getName(); 381 } 382 383 384 public function getSqlitePlugin(): helper_plugin_sqlite 385 { 386 return $this->sqlitePlugin; 387 } 388 389 public function createRequest(): SqliteRequest 390 { 391 $this->closeActualRequestIfNotClosed(); 392 $this->actualRequest = new SqliteRequest($this); 393 return $this->actualRequest; 394 } 395 396 public function getVersion() 397 { 398 if (self::$sqliteVersion === null) { 399 $request = $this->createRequest() 400 ->setQuery("select sqlite_version()"); 401 try { 402 self::$sqliteVersion = $request 403 ->execute() 404 ->getFirstCellValue(); 405 } catch (ExceptionCompile $e) { 406 self::$sqliteVersion = "unknown"; 407 } finally { 408 $request->close(); 409 } 410 } 411 return self::$sqliteVersion; 412 } 413 414 /** 415 * @param string $option 416 * @return bool - true if the option is available 417 */ 418 public function hasOption(string $option): bool 419 { 420 try { 421 $present = $this->createRequest() 422 ->setQueryParametrized("select count(1) from pragma_compile_options() where compile_options = ?", [$option]) 423 ->execute() 424 ->getFirstCellValueAsInt(); 425 return $present === 1; 426 } catch (ExceptionCompile $e) { 427 LogUtility::msg("Error while trying to see if the sqlite option is available"); 428 return false; 429 } 430 431 } 432 433 /** 434 * Internal function that closes the actual request 435 * This is to be able to close all resources even if the developer 436 * forget. 437 * 438 * This is needed to be able to delete the database file. 439 * See {@link self::close()} for more information 440 * 441 * @return void 442 */ 443 private function closeActualRequestIfNotClosed() 444 { 445 if(isset($this->actualRequest)){ 446 $this->actualRequest->close(); 447 unset($this->actualRequest); 448 } 449 } 450} 451