PNDataProviderSQLite::truncate()   A
last analyzed

Complexity

Conditions 2
Paths 2

Size

Total Lines 8
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 5
c 0
b 0
f 0
nc 2
nop 0
dl 0
loc 8
rs 10
1
<?php
2
declare(strict_types=1);
3
4
namespace SKien\PNServer;
5
6
use Psr\Log\LoggerInterface;
7
use Psr\Log\NullLogger;
8
9
/**
10
 * Dataprovider for SqLite database.
11
 * It uses a given Table in specified SqLite database
12
 *
13
 * if not specified in constructor, default table self::TABLE_NAME in
14
 * databasefile 'pnsub.sqlite' in current working directory is used.
15
 * DB-file and/or table are created if not exist so far.
16
 *
17
 * @package PNServer
18
 * @author Stefanius <[email protected]>
19
 * @copyright MIT License - see the LICENSE file for details
20
*/
21
class PNDataProviderSQLite implements PNDataProvider
22
{
23
    /** @var string tablename                    */
24
    protected string $strTableName;
25
    /** @var string name of the DB file          */
26
    protected string $strDBName;
27
    /** @var \SQLite3 internal SqLite DB         */
28
    protected ?\SQLite3 $db = null;
29
    /** @var \SQLite3Result|false result of DB queries */
30
    protected $dbres = false;
31
    /** @var array<string,mixed>|false last fetched row or false */
32
    protected $row = false;
33
    /** @var string last error                   */
34
    protected string $strLastError;
35
    /** @var bool does table exist               */
36
    protected bool $bTableExist = false;
37
    /** @var LoggerInterface $logger     */
38
    protected LoggerInterface $logger;
39
40
    /**
41
     * @param string $strDir        directory -  if null, current working directory assumed
42
     * @param string $strDBName     name of DB file - if null, file 'pnsub.sqlite' is used and created if not exist
43
     * @param string $strTableName  tablename for the subscriptions - if null, self::TABLE_NAME is used and created if not exist
44
     * @param LoggerInterface $logger
45
     */
46
    public function __construct(?string $strDir = null, ?string $strDBName = null, ?string $strTableName = null, ?LoggerInterface $logger = null)
47
    {
48
        $this->logger = $logger ?? new NullLogger();
49
        $this->strTableName = $strTableName ?? self::TABLE_NAME;
50
        $this->strDBName = $strDBName ?? 'pnsub.sqlite';
51
        $this->strLastError = '';
52
        $strDBName = $this->strDBName;
53
        if (isset($strDir) && strlen($strDir) > 0) {
54
            $strDBName = rtrim($strDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $this->strDBName;
55
        }
56
        try {
57
            if (file_exists($strDBName) && !is_writable($strDBName)) {
58
                $this->strLastError .= 'readonly database file ' . $strDBName . '!';
59
                $this->logger->error(__CLASS__ . ': ' . $this->strLastError);
60
            } else {
61
                $this->db = new \SQLite3($strDBName);
62
                if (!$this->tableExist()) {
63
                    $this->createTable();
64
                }
65
            }
66
        } catch (\Exception $e) {
67
            $this->db = null;
68
            $this->strLastError = $e->getMessage();
69
            if (!file_exists($strDBName)) {
70
                $strDir = pathinfo($strDBName, PATHINFO_DIRNAME) == '' ? __DIR__ : pathinfo($strDBName, PATHINFO_DIRNAME);
71
                if (!is_writable($strDir)) {
0 ignored issues
show
Bug introduced by
It seems like $strDir can also be of type array; however, parameter $filename of is_writable() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

71
                if (!is_writable(/** @scrutinizer ignore-type */ $strDir)) {
Loading history...
72
                    $this->strLastError .= ' (no rights to write on directory ' . $strDir . ')';
0 ignored issues
show
Bug introduced by
Are you sure $strDir of type array|string can be used in concatenation? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

72
                    $this->strLastError .= ' (no rights to write on directory ' . /** @scrutinizer ignore-type */ $strDir . ')';
Loading history...
73
                }
74
            }
75
            $this->logger->error(__CLASS__ . ': ' . $this->strLastError);
76
        }
77
    }
78
79
    /**
80
     * {@inheritDoc}
81
     * @see PNDataProvider::isConnected()
82
     */
83
    public function isConnected() : bool
84
    {
85
        if (!$this->db) {
86
            $this->logger->error(__CLASS__ . ': ' . $this->strLastError);
87
        } else if (!$this->tableExist()) {
88
            // Condition cannot be forced to test
89
            // - can only occur during development using invalid SQL-statement for creation!
90
            // @codeCoverageIgnoreStart
91
            if (strlen($this->strLastError) == 0) {
92
                $this->strLastError = 'database table ' . $this->strTableName . ' not exist!';
93
            }
94
            $this->logger->error(__CLASS__ . ': ' . $this->strLastError);
95
            // @codeCoverageIgnoreEnd
96
        }
97
        return (is_object($this->db) && $this->bTableExist);
98
    }
99
100
    /**
101
     * {@inheritDoc}
102
     * @see PNDataProvider::saveSubscription()
103
     */
104
    public function saveSubscription(string $strJSON) : bool
105
    {
106
        $bSucceeded = false;
107
        if ($this->isConnected()) {
108
            $oSubscription = json_decode($strJSON, true);
109
            if ($oSubscription) {
110
                $iExpires = isset($oSubscription['expirationTime']) ? bcdiv((string) $oSubscription['expirationTime'], '1000') : 0;
111
                $strUserAgent = isset($oSubscription['userAgent']) ? $oSubscription['userAgent'] : 'unknown UserAgent';
112
113
                // insert or update - relevant is the endpoint as unique index
114
                $strSQL  = "REPLACE INTO " . $this->strTableName . " (";
115
                $strSQL .= self::COL_ENDPOINT;
116
                $strSQL .= "," . self::COL_EXPIRES;
117
                $strSQL .= "," . self::COL_SUBSCRIPTION;
118
                $strSQL .= "," . self::COL_USERAGENT;
119
                $strSQL .= "," . self::COL_LASTUPDATED;
120
                $strSQL .= ") VALUES(";
121
                $strSQL .= "'" . $oSubscription['endpoint'] . "'";
122
                $strSQL .= "," . $iExpires;
123
                $strSQL .= ",'" . $strJSON . "'";
124
                $strSQL .= ",'" . $strUserAgent . "'";
125
                $strSQL .= ',' . time();
126
                $strSQL .= ");";
127
128
                $bSucceeded = $this->db->exec($strSQL);
129
                $this->setSQLiteError($bSucceeded);
130
                $this->logger->info(__CLASS__ . ': ' . 'Subscription saved', strlen($this->strLastError) > 0 ? ['error' => $this->strLastError] : []);
131
            } else {
132
                $this->strLastError = 'Error json_decode: ' . json_last_error_msg();
133
                $this->logger->error(__CLASS__ . ': ' . $this->strLastError);
134
            }
135
        }
136
        return $bSucceeded;
137
    }
138
139
    /**
140
     * {@inheritDoc}
141
     * @see PNDataProvider::removeSubscription()
142
     */
143
    public function removeSubscription(string $strEndpoint) : bool
144
    {
145
        $bSucceeded = false;
146
        if ($this->isConnected()) {
147
            $strSQL  = "DELETE FROM " . $this->strTableName . " WHERE " . self::COL_ENDPOINT . " LIKE ";
148
            $strSQL .= "'" . $strEndpoint . "'";
149
150
            $bSucceeded = $this->db->exec($strSQL);
151
            $this->setSQLiteError($bSucceeded);
152
            $this->logger->info(__CLASS__ . ': ' . 'Subscription removed', strlen($this->strLastError) > 0 ? ['error' => $this->strLastError] : []);
153
        }
154
        return $bSucceeded;
155
    }
156
157
    /**
158
     * select all subscriptions not expired so far
159
     * {@inheritDoc}
160
     * @see PNDataProvider::init()
161
     */
162
    public function init(bool $bAutoRemove = true) : bool
163
    {
164
        $bSucceeded = false;
165
        $this->dbres = false;
166
        $this->row = false;
167
        if ($this->isConnected()) {
168
            if ($bAutoRemove) {
169
                // remove expired subscriptions from DB
170
                $strSQL = "DELETE FROM " . $this->strTableName . " WHERE ";
171
                $strSQL .= self::COL_EXPIRES . " != 0 AND ";
172
                $strSQL .= self::COL_EXPIRES . " < " . time();
173
174
                $bSucceeded = $this->db->exec($strSQL);
175
                $this->setSQLiteError($bSucceeded !== false);
176
                $strSQL = "SELECT * FROM " . $this->strTableName;
177
            } else {
178
                // or just exclude them from query
179
                $strSQL = "SELECT * FROM " . $this->strTableName . " WHERE ";
180
                $strSQL .= self::COL_EXPIRES . " = 0 OR ";
181
                $strSQL .= self::COL_EXPIRES . " >= " . time();
182
                $bSucceeded = true;
183
            }
184
            if ($bSucceeded) {
185
                $this->dbres = $this->db->query($strSQL);
186
                $bSucceeded = $this->dbres !== false && $this->dbres->numColumns() > 0;
187
                $this->setSQLiteError($bSucceeded);
188
            }
189
        }
190
        return (bool) $bSucceeded;
191
    }
192
193
    /**
194
     * {@inheritDoc}
195
     * @see PNDataProvider::count()
196
     */
197
    public function count() : int
198
    {
199
        $iCount = 0;
200
        if ($this->isConnected()) {
201
            $iCount = $this->db->querySingle("SELECT count(*) FROM " . $this->strTableName);
202
            $this->setSQLiteError($iCount !== false);
203
        }
204
        return intval($iCount);
205
    }
206
207
    /**
208
     * {@inheritDoc}
209
     * @see PNDataProvider::fetch()
210
     */
211
    public function fetch()
212
    {
213
        $strSubJSON = false;
214
        $this->row = false;
215
        if ($this->dbres !== false) {
216
            $this->row = $this->dbres->fetchArray(SQLITE3_ASSOC);
217
            $this->setSQLiteError(!is_bool($this->row));
218
            if ($this->row !== false) {
219
                $strSubJSON = $this->row[self::COL_SUBSCRIPTION];
220
            }
221
        }
222
        return $strSubJSON;
223
    }
224
225
    /**
226
     * {@inheritDoc}
227
     * @see PNDataProvider::truncate()
228
     */
229
    public function truncate() : bool
230
    {
231
        $bSucceeded = false;
232
        if ($this->isConnected()) {
233
            $bSucceeded = $this->db->exec("DELETE FROM " . $this->strTableName);
234
            $this->logger->info(__CLASS__ . ': ' . 'Subscription table truncated');
235
        }
236
        return $bSucceeded;
237
    }
238
239
    /**
240
     * {@inheritDoc}
241
     * @see PNDataProvider::getColumn()
242
     */
243
    public function getColumn($strName) : ?string
244
    {
245
        $value = null;
246
        if ($this->row !== false && isset($this->row[$strName])) {
247
            $value = $this->row[$strName];
248
        }
249
        return strval($value);
250
    }
251
252
    /**
253
     * @return string
254
     */
255
    public function getError() : string
256
    {
257
        return $this->strLastError;
258
    }
259
260
    /**
261
     * @return bool
262
     */
263
    private function tableExist() : bool
264
    {
265
        if (!$this->bTableExist) {
266
            if ($this->db) {
267
                $this->bTableExist = ($this->db->querySingle("SELECT name FROM sqlite_master WHERE type='table' AND name='" . $this->strTableName . "'") != null);
268
            }
269
        }
270
        return $this->bTableExist;
271
    }
272
273
    /**
274
     * @return bool
275
     */
276
    private function createTable() : bool
277
    {
278
        $bSucceeded = false;
279
        if ($this->db) {
280
            $strSQL  = "CREATE TABLE " . $this->strTableName . " (";
281
            $strSQL .= self::COL_ID . " INTEGER PRIMARY KEY";
282
            $strSQL .= "," . self::COL_ENDPOINT . " TEXT UNIQUE";
283
            $strSQL .= "," . self::COL_EXPIRES . " INTEGER NOT NULL";
284
            $strSQL .= "," . self::COL_SUBSCRIPTION . " TEXT NOT NULL";
285
            $strSQL .= "," . self::COL_USERAGENT . " TEXT NOT NULL";
286
            $strSQL .= "," . self::COL_LASTUPDATED . " INTEGER NOT NULL";
287
            $strSQL .= ");";
288
289
            $bSucceeded = $this->db->exec($strSQL);
290
            $this->setSQLiteError($bSucceeded);
291
            $this->logger->info(__CLASS__ . ': ' . 'Subscription table created', strlen($this->strLastError) > 0 ? ['error' => $this->strLastError] : []);
292
        }
293
        $this->bTableExist = $bSucceeded;
294
        return $bSucceeded;
295
    }
296
297
    /**
298
     * @param bool $bSucceeded  set error, if last opperation not succeeded
299
     */
300
    private function setSQLiteError(bool $bSucceeded) : void
301
    {
302
        // All reasons, with the exception of incorrect SQL statements, are intercepted
303
        // beforehand - so this part of the code is no longer run through in the test
304
        // anphase. This section is therefore excluded from codecoverage.
305
        // @codeCoverageIgnoreStart
306
        if (!$bSucceeded && $this->db) {
307
            $this->strLastError = 'SQLite3: ' . $this->db->lastErrorMsg();
308
        }
309
        // @codeCoverageIgnoreEnd
310
    }
311
312
    /**
313
     * @param LoggerInterface $logger
314
     */
315
    public function setLogger(LoggerInterface $logger) : void
316
    {
317
        $this->logger = $logger;
318
    }
319
}
320