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