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
16require_once(__DIR__ . '/PluginUtility.php');
17
18use helper_plugin_sqlite;
19use RuntimeException;
20
21class Sqlite
22{
23
24    /** @var Sqlite[] $sqlite */
25    private static $sqlites;
26
27    /**
28     * Principal database
29     * (Backup)
30     */
31    private const  MAIN_DATABASE_NAME = "combo";
32    /**
33     * Backend Databse
34     * (Log, Pub/Sub,...)
35     */
36    private const  SECONDARY_DB = "combo-secondary";
37
38    private static $sqliteVersion;
39
40    /**
41     * @var helper_plugin_sqlite
42     */
43    private $sqlitePlugin;
44
45    /**
46     * Sqlite constructor.
47     * @var helper_plugin_sqlite $sqlitePlugin
48     */
49    public function __construct(helper_plugin_sqlite $sqlitePlugin)
50    {
51        $this->sqlitePlugin = $sqlitePlugin;
52    }
53
54
55    /**
56     *
57     * @return Sqlite $sqlite
58     */
59    public static function createOrGetSqlite($databaseName = self::MAIN_DATABASE_NAME): ?Sqlite
60    {
61
62        $sqlite = self::$sqlites[$databaseName];
63        if ($sqlite !== null) {
64            $res = $sqlite->doWeNeedToCreateNewInstance();
65            if ($res === false) {
66                return $sqlite;
67            }
68        }
69
70
71        /**
72         * Init
73         * @var helper_plugin_sqlite $sqlitePlugin
74         */
75        $sqlitePlugin = plugin_load('helper', 'sqlite');
76        /**
77         * Not enabled / loaded
78         */
79        if ($sqlitePlugin === null) {
80
81            $sqliteMandatoryMessage = "The Sqlite Plugin is mandatory. Some functionalities of the ComboStrap Plugin may not work.";
82            LogUtility::log2FrontEnd($sqliteMandatoryMessage, LogUtility::LVL_MSG_ERROR);
83            return null;
84        }
85
86        $adapter = $sqlitePlugin->getAdapter();
87        if ($adapter == null) {
88            self::sendMessageAsNotAvailable();
89            return null;
90        }
91
92        $adapter->setUseNativeAlter(true);
93
94        global $conf;
95
96        if ($databaseName === self::MAIN_DATABASE_NAME) {
97            $oldDbName = '404manager';
98            $oldDbFile = $conf['metadir'] . "/{$oldDbName}.sqlite";
99            $oldDbFileSqlite3 = $conf['metadir'] . "/{$oldDbName}.sqlite3";
100            if (file_exists($oldDbFile) || file_exists($oldDbFileSqlite3)) {
101                $databaseName = $oldDbName;
102            }
103        }
104
105        $updatedir = DOKU_PLUGIN . PluginUtility::PLUGIN_BASE_NAME . "/db/$databaseName";
106        $init = $sqlitePlugin->init($databaseName, $updatedir);
107        if (!$init) {
108            # TODO: Message 'SqliteUnableToInitialize'
109            $message = "Unable to initialize Sqlite";
110            LogUtility::msg($message, MSG_MANAGERS_ONLY);
111            return null;
112        }
113        // regexp implementation
114        // https://stackoverflow.com/questions/5071601/how-do-i-use-regex-in-a-sqlite-query/18484596#18484596
115        $adapter = $sqlitePlugin->getAdapter();
116        $adapter->create_function('regexp',
117            function ($pattern, $data, $delimiter = '~', $modifiers = 'isuS') {
118                if (isset($pattern, $data) === true) {
119                    return (preg_match(sprintf('%1$s%2$s%1$s%3$s', $delimiter, $pattern, $modifiers), $data) > 0);
120                }
121                return null;
122            },
123            4
124        );
125
126        $sqlite = new Sqlite($sqlitePlugin);
127        self::$sqlites[$databaseName] = $sqlite;
128        return $sqlite;
129
130    }
131
132    public static function createOrGetBackendSqlite(): ?Sqlite
133    {
134        return self::createOrGetSqlite(self::SECONDARY_DB);
135    }
136
137    public static function createSelectFromTableAndColumns(string $tableName, array $columns = null): string
138    {
139        if ($columns === null) {
140            $columnStatement = "*";
141        } else {
142            $columnsStatement = [];
143            foreach ($columns as $columnName) {
144                $columnsStatement[] = "$columnName as \"$columnName\"";
145            }
146            $columnStatement = implode(", ", $columnsStatement);
147        }
148        return "select $columnStatement from $tableName";
149
150    }
151
152    /**
153     * Print debug info to the console in order to resolve
154     * RuntimeException: HY000 8 attempt to write a readonly database
155     * https://phpunit.readthedocs.io/en/latest/writing-tests-for-phpunit.html#error-output
156     */
157    public function printDbInfoAtConsole()
158    {
159        $dbFile = $this->sqlitePlugin->getAdapter()->getDbFile();
160        fwrite(STDERR, "Stderr DbFile: " . $dbFile . "\n");
161        if (file_exists($dbFile)) {
162            fwrite(STDERR, "File does exists\n");
163            fwrite(STDERR, "Permission " . substr(sprintf('%o', fileperms($dbFile)), -4) . "\n");
164        } else {
165            fwrite(STDERR, "File does not exist\n");
166        }
167
168        global $conf;
169        $metadir = $conf['metadir'];
170        fwrite(STDERR, "MetaDir: " . $metadir . "\n");
171        $subdir = strpos($dbFile, $metadir) === 0;
172        if ($subdir) {
173            fwrite(STDERR, "Meta is a subdirectory of the db \n");
174        } else {
175            fwrite(STDERR, "Meta is a not subdirectory of the db \n");
176        }
177
178    }
179
180    /**
181     * Json support
182     */
183    public function supportJson(): bool
184    {
185
186
187        $res = $this->sqlitePlugin->query("PRAGMA compile_options");
188        $isJsonEnabled = false;
189        foreach ($this->sqlitePlugin->res2arr($res) as $row) {
190            if ($row["compile_option"] === "ENABLE_JSON1") {
191                $isJsonEnabled = true;
192                break;
193            }
194        };
195        $this->sqlitePlugin->res_close($res);
196        return $isJsonEnabled;
197    }
198
199
200    public
201    static function sendMessageAsNotAvailable(): void
202    {
203        $sqliteMandatoryMessage = "The Sqlite Php Extension is mandatory. It seems that it's not available on this installation.";
204        LogUtility::log2FrontEnd($sqliteMandatoryMessage, LogUtility::LVL_MSG_ERROR);
205    }
206
207    /**
208     * sqlite is stored in a static variable
209     * because when we run the {@link cli_plugin_combo},
210     * we will run in the error:
211     * ``
212     * failed to open stream: Too many open files
213     * ``
214     * There is by default a limit of 1024 open files
215     * which means that if there is more than 1024 pages, you fail.
216     *
217     *
218     */
219    private function doWeNeedToCreateNewInstance(): bool
220    {
221
222        global $conf;
223        $metaDir = $conf['metadir'];
224
225
226        /**
227         * Adapter may be null
228         * when the SQLite & PDO SQLite
229         * are not installed
230         * ie: SQLite & PDO SQLite support missing
231         */
232        $adapter = $this->sqlitePlugin->getAdapter();
233        if ($adapter === null) {
234            return true;
235        }
236
237        /**
238         * When the database is {@link \helper_plugin_sqlite_adapter::closedb()}
239         */
240        if ($adapter->getDb() === null) {
241            /**
242             * We may also open it again
243             * {@link \helper_plugin_sqlite_adapter::opendb()}
244             * for now, reinit
245             */
246            return true;
247        }
248        /**
249         * In test, we are running in different context (ie different root
250         * directory for DokuWiki and therefore different $conf
251         * and therefore different metadir where sqlite is stored)
252         * Because a sql file may be deleted, we may get:
253         * ```
254         * RuntimeException: HY000 8 attempt to write a readonly database:
255         * ```
256         * To avoid this error, we check that we are still in the same metadir
257         * where the sqlite database is stored. If not, we create a new instance
258         */
259        $dbFile = $adapter->getDbFile();
260        if (!file_exists($dbFile)) {
261            $this->close();
262            return true;
263        }
264        // the file is in the meta directory
265        if (strpos($dbFile, $metaDir) === 0) {
266            // we are still in a class run
267            return false;
268        }
269        $this->close();
270        return true;
271    }
272
273    public
274    function close()
275    {
276
277        $adapter = $this->sqlitePlugin->getAdapter();
278        if ($adapter !== null) {
279            /**
280             * https://www.php.net/manual/en/pdo.connections.php#114822
281             * You put the connection on null
282             * CloseDb do that
283             */
284            $adapter->closedb();
285
286            /**
287             * Delete the file If we can't delete the file
288             * there is a resource still open
289             */
290            $sqliteFile = $adapter->getDbFile();
291            if (file_exists($sqliteFile)) {
292                $result = unlink($sqliteFile);
293                if ($result === false) {
294                    throw new RuntimeException("Unable to delete the file ($sqliteFile). Did you close all resources ?");
295                }
296            }
297
298        }
299        /**
300         * Forwhatever reason, closing in php
301         * is putting the variable to null
302         * We do it also in the static variable to be sure
303         */
304        self::$sqlites[$this->getDbName()] == null;
305
306
307    }
308
309    public function getDbName(): string
310    {
311        return $this->sqlitePlugin->getAdapter()->getName();
312    }
313
314    public static function closeAll()
315    {
316
317        $sqlites = self::$sqlites;
318        if ($sqlites !== null) {
319            foreach ($sqlites as $sqlite) {
320                $sqlite->close();
321            }
322            /**
323             * Set it to null
324             */
325            Sqlite::$sqlites = null;
326        }
327    }
328
329
330    public function getSqlitePlugin(): helper_plugin_sqlite
331    {
332        return $this->sqlitePlugin;
333    }
334
335    public function createRequest(): SqliteRequest
336    {
337        return new SqliteRequest($this);
338    }
339
340    public function getVersion()
341    {
342        if (self::$sqliteVersion === null) {
343            $request = $this->createRequest()
344                ->setQuery("select sqlite_version()");
345            try {
346                self::$sqliteVersion = $request
347                    ->execute()
348                    ->getFirstCellValue();
349            } catch (ExceptionCombo $e) {
350                self::$sqliteVersion = "unknown";
351            } finally {
352                $request->close();
353            }
354        }
355        return self::$sqliteVersion;
356    }
357
358    /**
359     * @param string $option
360     * @return bool - true if the option is available
361     */
362    public function hasOption(string $option): bool
363    {
364        try {
365            $present = $this->createRequest()
366                ->setQueryParametrized("select count(1) from pragma_compile_options() where compile_options = ?", $option)
367                ->execute()
368                ->getFirstCellValueAsInt();
369            return $present === 1;
370        } catch (ExceptionCombo $e) {
371            LogUtility::msg("Error while trying to see if the sqlite option is available");
372            return false;
373        }
374
375    }
376}
377