Passed
Push — master ( 6143b8...b69905 )
by Stefan
03:43
created

PNDataProviderSQLite::setLogger()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
c 0
b 0
f 0
nc 1
nop 1
dl 0
loc 3
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
 * uses 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
 * #### History
18
 * - *2020-04-02*   initial version
19
 * - *2020-08-03*   PHP 7.4 type hint
20
 * 
21
 * @package SKien/PNServer
22
 * @version 1.1.0
23
 * @author Stefanius <[email protected]>
24
 * @copyright MIT License - see the LICENSE file for details
25
*/
26
class PNDataProviderSQLite implements PNDataProvider 
27
{
28
    /** @var string tablename                    */
29
    protected string $strTableName;
30
    /** @var string name of the DB file          */
31
    protected string $strDBName; 
32
    /** @var \SQLite3 internal SqLite DB         */
33
    protected ?\SQLite3 $db = null;
34
    /** @var \SQLite3Result|bool result of DB queries (no type hint - \SQLite3::query() returns \SQLite3Result|bool) */
35
    protected $dbres = false;
36
    /** @var array|bool last fetched row or false (no type hint - \SQLite3Result::fetchArray() returns array|bool)    */
37
    protected $row = false;
38
    /** @var string last error                   */
39
    protected string $strLastError;
40
    /** @var bool does table exist               */
41
    protected bool $bTableExist = false;
42
    /** @var LoggerInterface $logger     */
43
    protected LoggerInterface $logger;
44
    
45
    /**
46
     * @param string $strDir        directory -  if null, current working directory assumed
47
     * @param string $strDBName     name of DB file - if null, file 'pnsub.sqlite' is used and created if not exist
48
     * @param string $strTableName  tablename for the subscriptions - if null, self::TABLE_NAME is used and created if not exist
49
     * @param LoggerInterface $logger
50
     */
51
    public function __construct(?string $strDir = null, ?string $strDBName = null, ?string $strTableName = null, ?LoggerInterface $logger = null) 
52
    {
53
        $this->logger = isset($logger) ? $logger : new NullLogger();
54
        $this->strTableName = isset($strTableName) ? $strTableName : self::TABLE_NAME;
55
        $this->strDBName = isset($strDBName) ? $strDBName : 'pnsub.sqlite';
56
        $this->strLastError = ''; 
57
        $strDBName = $this->strDBName;
58
        if (isset($strDir) && strlen($strDir) > 0) {
59
            $strDBName = rtrim($strDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $this->strDBName;
60
        }
61
        try {
62
            if (file_exists($strDBName) && !is_writable($strDBName)) {
63
                $this->strLastError .= 'readonly database file ' . $strDBName . '!';
64
                $this->logger->error(__CLASS__ . ': ' . $this->strLastError);
65
            } else {
66
                $this->db = new \SQLite3($strDBName);
67
                if (!$this->tableExist()) {
68
                    $this->createTable();
69
                }
70
            }
71
        } catch (\Exception $e) {
72
            $this->db = null;
73
            $this->strLastError = $e->getMessage();
74
            if (!file_exists($strDBName)) {
75
                $strDir = pathinfo($strDBName, PATHINFO_DIRNAME) == '' ? __DIR__ : pathinfo($strDBName, PATHINFO_DIRNAME);
76
                if (!is_writable($strDir)) {
77
                    $this->strLastError .= ' (no rights to write on directory ' . $strDir . ')';
78
                }
79
            }
80
            $this->logger->error(__CLASS__ . ': ' . $this->strLastError);
81
        }
82
    }
83
84
    /**
85
     * {@inheritDoc}
86
     * @see PNDataProvider::isConnected()
87
     */
88
    public function isConnected() : bool 
89
    {
90
        if (!$this->db) {
91
            if (strlen($this->strLastError) == 0) {
92
                $this->strLastError = 'no database connected!';
93
            }
94
            $this->logger->error(__CLASS__ . ': ' . $this->strLastError);
95
        } else if (!$this->tableExist()) {
96
            // Condition cannot be forced to test
97
            // - can only occur during development using invalid SQL-statement for creation!
98
            // @codeCoverageIgnoreStart
99
            if (strlen($this->strLastError) == 0) {
100
                $this->strLastError = 'database table ' . $this->strTableName . ' not exist!';
101
            }
102
            $this->logger->error(__CLASS__ . ': ' . $this->strLastError);
103
            // @codeCoverageIgnoreEnd
104
        }
105
        return ($this->db && $this->bTableExist);
106
    }
107
    
108
    /**
109
     * {@inheritDoc}
110
     * @see PNDataProvider::saveSubscription()
111
     */
112
    public function saveSubscription(string $strJSON) : bool 
113
    {
114
        $bSucceeded = false;
115
        if ($this->isConnected()) {
116
            $oSubscription = json_decode($strJSON, true);
117
            if ($oSubscription) {
118
                $iExpires = isset($oSubscription['expirationTime']) ? bcdiv((string) $oSubscription['expirationTime'], '1000') : 0;
119
                $strUserAgent = isset($oSubscription['userAgent']) ? $oSubscription['userAgent'] : 'unknown UserAgent';
120
                
121
                // insert or update - relevant is the endpoint as unique index 
122
                $strSQL  = "REPLACE INTO " . $this->strTableName . " (";
123
                $strSQL .= self::COL_ENDPOINT;
124
                $strSQL .= "," . self::COL_EXPIRES;
125
                $strSQL .= "," . self::COL_SUBSCRIPTION;
126
                $strSQL .= "," . self::COL_USERAGENT;
127
                $strSQL .= "," . self::COL_LASTUPDATED;
128
                $strSQL .= ") VALUES(";
129
                $strSQL .= "'" . $oSubscription['endpoint'] . "'";
130
                $strSQL .= "," . $iExpires;
131
                $strSQL .= ",'" . $strJSON . "'";
132
                $strSQL .= ",'" . $strUserAgent . "'";
133
                $strSQL .= ',' . time();
134
                $strSQL .= ");";
135
136
                $bSucceeded = $this->db->exec($strSQL);
137
                $this->setSQLiteError($bSucceeded);
138
                $this->logger->info(__CLASS__ . ': ' . 'Subscription saved', strlen($this->strLastError) > 0 ? ['error' => $this->strLastError] : []);
139
            } else {
140
                $this->strLastError = 'Error json_decode: ' . json_last_error_msg();
141
                $this->logger->error(__CLASS__ . ': ' . $this->strLastError);
142
            }
143
        }
144
        return $bSucceeded;
145
    }
146
    
147
    /**
148
     * {@inheritDoc}
149
     * @see PNDataProvider::removeSubscription()
150
     */
151
    public function removeSubscription(string $strEndpoint) : bool 
152
    {
153
        $bSucceeded = false;
154
        if ($this->isConnected()) {
155
            $strSQL  = "DELETE FROM " . $this->strTableName . " WHERE " . self::COL_ENDPOINT . " LIKE ";
156
            $strSQL .= "'" . $strEndpoint . "'";
157
        
158
            $bSucceeded = $this->db->exec($strSQL);
159
            $this->setSQLiteError($bSucceeded);
160
            $this->logger->info(__CLASS__ . ': ' . 'Subscription removed', strlen($this->strLastError) > 0 ? ['error' => $this->strLastError] : []);
161
        }
162
        return $bSucceeded;
163
    }
164
    
165
    /**
166
     * select all subscriptions not expired so far
167
     * {@inheritDoc}
168
     * @see PNDataProvider::init()
169
     */
170
    public function init(bool $bAutoRemove = true) : bool 
171
    {
172
        $bSucceeded = false;
173
        $this->dbres = false;
174
        $this->row = false;
175
        if ($this->isConnected()) {
176
            if ($bAutoRemove) {
177
                // remove expired subscriptions from DB
178
                $strSQL = "DELETE FROM " . $this->strTableName . " WHERE ";
179
                $strSQL .= self::COL_EXPIRES . " != 0 AND ";
180
                $strSQL .= self::COL_EXPIRES . " < " . time();
181
                
182
                $bSucceeded = $this->db->exec($strSQL);
183
                $this->setSQLiteError($bSucceeded !== false);
184
                $strSQL = "SELECT * FROM " . $this->strTableName;
185
            } else {
186
                // or just exclude them from query
187
                $strSQL = "SELECT * FROM " . $this->strTableName . " WHERE ";
188
                $strSQL .= self::COL_EXPIRES . " = 0 OR ";
189
                $strSQL .= self::COL_EXPIRES . " >= " . time();
190
                $bSucceeded = true;
191
            }
192
            if ($bSucceeded) {
193
                $this->dbres = $this->db->query($strSQL);
194
                $bSucceeded = $this->dbres !== false && $this->dbres->numColumns() > 0;
195
                $this->setSQLiteError($bSucceeded);
196
            }
197
        }
198
        return (bool) $bSucceeded;
199
    }
200
201
    /**
202
     * {@inheritDoc}
203
     * @see PNDataProvider::count()
204
     */
205
    public function count() : int 
206
    {
207
        $iCount = 0;
208
        if ($this->isConnected()) {
209
            $iCount = $this->db->querySingle("SELECT count(*) FROM " . $this->strTableName);
210
            $this->setSQLiteError($iCount !== false);
211
        }
212
        return intval($iCount);
213
    }
214
    
215
    /**
216
     * {@inheritDoc}
217
     * @see PNDataProvider::fetch()
218
     */
219
    public function fetch()
220
    {
221
        $strSubJSON = false;
222
        $this->row = false;
223
        if ($this->dbres !== false) {
224
            $this->row = $this->dbres->fetchArray(SQLITE3_ASSOC);
225
            $this->setSQLiteError(!is_bool($this->row));
226
            if ($this->row) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->row of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
227
                $strSubJSON = $this->row[self::COL_SUBSCRIPTION];
228
            }
229
        }
230
        return $strSubJSON;
231
    }
232
    
233
    /**
234
     * {@inheritDoc}
235
     * @see PNDataProvider::truncate()
236
     */
237
    public function truncate() : bool
238
    {
239
        $bSucceeded = false;
240
        if ($this->isConnected()) {
241
            $bSucceeded = $this->db->exec("DELETE FROM " . $this->strTableName);
242
            $this->logger->info(__CLASS__ . ': ' . 'Subscription table truncated');
243
        }
244
        return $bSucceeded;
245
    }
246
    
247
    /**
248
     * {@inheritDoc}
249
     * @see PNDataProvider::getColumn()
250
     */
251
    public function getColumn($strName) : ?string 
252
    {
253
        $value = null;
254
        if ($this->row !== false && isset($this->row[$strName])) {
255
            $value = $this->row[$strName];
256
        }
257
        return strval($value);          
258
    }
259
260
    /**
261
     * @return string
262
     */
263
    public function getError() : string
264
    {
265
        return $this->strLastError;
266
    }
267
    
268
    /**
269
     * @return bool
270
     */
271
    private function tableExist() : bool 
272
    {
273
        if (!$this->bTableExist) {
274
            if ($this->db) {
275
                $this->bTableExist = ($this->db->querySingle("SELECT name FROM sqlite_master WHERE type='table' AND name='" . $this->strTableName . "'") != null);
276
            }
277
        }
278
        return $this->bTableExist;
279
    }
280
    
281
    /**
282
     * @return bool
283
     */
284
    private function createTable() : bool 
285
    {
286
        $bSucceeded = false;
287
        if ($this->db) {
288
            $strSQL  = "CREATE TABLE " . $this->strTableName . " (";
289
            $strSQL .= self::COL_ID . " INTEGER PRIMARY KEY";
290
            $strSQL .= "," . self::COL_ENDPOINT . " TEXT UNIQUE"; 
291
            $strSQL .= "," . self::COL_EXPIRES . " INTEGER NOT NULL"; 
292
            $strSQL .= "," . self::COL_SUBSCRIPTION . " TEXT NOT NULL";
293
            $strSQL .= "," . self::COL_USERAGENT . " TEXT NOT NULL";
294
            $strSQL .= "," . self::COL_LASTUPDATED . " INTEGER NOT NULL";
295
            $strSQL .= ");";
296
                
297
            $bSucceeded = $this->db->exec($strSQL);
298
            $this->setSQLiteError($bSucceeded);
299
            $this->logger->info(__CLASS__ . ': ' . 'Subscription table created', strlen($this->strLastError) > 0 ? ['error' => $this->strLastError] : []);
300
        }
301
        $this->bTableExist = $bSucceeded;
302
        return $bSucceeded;
303
    }
304
305
    /**
306
     * @param bool $bSucceeded  set error, if last opperation not succeeded
307
     */
308
    private function setSQLiteError(bool $bSucceeded) : void 
309
    {
310
        // All reasons, with the exception of incorrect SQL statements, are intercepted 
311
        // beforehand - so this part of the code is no longer run through in the test 
312
        // anphase. This section is therefore excluded from codecoverage.
313
        // @codeCoverageIgnoreStart
314
        if (!$bSucceeded && $this->db) {
315
            $this->strLastError = 'SQLite3: ' . $this->db->lastErrorMsg();
316
        }
317
        // @codeCoverageIgnoreEnd
318
    }
319
    
320
    /**
321
     * @param LoggerInterface $logger
322
     */
323
    public function setLogger(LoggerInterface $logger) : void
324
    {
325
        $this->logger = $logger;
326
    }
327
}
328