Passed
Branch master (c73d10)
by Stefan
02:51 queued 55s
created

PNDataProviderSQLite   B

Complexity

Total Complexity 49

Size/Duplication

Total Lines 277
Duplicated Lines 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 129
dl 0
loc 277
rs 8.48
c 1
b 0
f 0
wmc 49

13 Methods

Rating   Name   Duplication   Size   Complexity  
A truncate() 0 7 2
A saveSubscription() 0 31 5
A init() 0 29 5
A setSQLiteError() 0 8 3
A fetch() 0 12 3
A getError() 0 3 1
A tableExist() 0 9 3
C __construct() 0 25 12
A isConnected() 0 16 6
A count() 0 8 2
A createTable() 0 17 2
A getColumn() 0 7 3
A removeSubscription() 0 11 2

How to fix   Complexity   

Complex Class

Complex classes like PNDataProviderSQLite often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use PNDataProviderSQLite, and based on these observations, apply Extract Interface, too.

1
<?php
2
declare(strict_types = 1);
3
4
namespace SKien\PNServer;
5
6
/**
7
 * dataprovider for SqLite database
8
 * uses given Table in specified SqLite database
9
 * 
10
 * if not specified in constructor, default table self::TABLE_NAME in 
11
 * databasefile 'pnsub.sqlite' in current working directory is used. 
12
 * DB-file and/or table are created if not exist so far.
13
 * 
14
 * #### History
15
 * - *2020-04-02*   initial version
16
 * - *2020-08-03*   PHP 7.4 type hint
17
 * 
18
 * @package SKien/PNServer
19
 * @version 1.1.0
20
 * @author Stefanius <[email protected]>
21
 * @copyright MIT License - see the LICENSE file for details
22
*/
23
class PNDataProviderSQLite implements PNDataProvider 
24
{
25
    /** @var string tablename                    */
26
    protected   string $strTableName;
27
    /** @var string name of the DB file          */
28
    protected   string $strDBName; 
29
    /** @var \SQLite3 internal SqLite DB         */
30
    protected   ?\SQLite3 $db = null;
31
    /** @var \SQLite3Result|bool result of DB queries (no type hint - \SQLite3::query() returns \SQLite3Result|bool) */
32
    protected   $dbres = false;
33
    /** @var array|bool last fetched row or false (no type hint - \SQLite3Result::fetchArray() returns array|bool)    */
34
    protected   $row = false;
35
    /** @var string last error                   */
36
    protected   string $strLastError;
37
    /** @var bool does table exist               */
38
    protected   ?bool $bTableExist = null;
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
     */
45
    public function __construct(?string $strDir=null, ?string $strDBName=null, ?string $strTableName=null) 
46
    {
47
        $this->strTableName = isset($strTableName) ? $strTableName : self::TABLE_NAME;
48
        $this->strDBName = isset($strDBName) ? $strDBName : 'pnsub.sqlite';
49
        $this->strLastError = ''; 
50
        $strDBName = $this->strDBName;
51
        if (isset($strDir) && strlen($strDir) > 0) {
52
            $strDBName = rtrim($strDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $this->strDBName;
53
        }
54
        try {
55
            if (file_exists($strDBName) && !is_writable($strDBName)) {
56
                $this->strLastError .= 'readonly database file ' . $strDBName . '!';
57
            } else {
58
                $this->db = new \SQLite3($strDBName);
59
                if (!$this->tableExist()) {
60
                    $this->createTable();
61
                }
62
            }
63
        } catch (\Exception $e) {
64
            $this->db = null;
65
            $this->strLastError = $e->getMessage();
66
            if (!file_exists($strDBName)) {
67
                $strDir = pathinfo($strDBName, PATHINFO_DIRNAME) == '' ?  __DIR__ : pathinfo($strDBName, PATHINFO_DIRNAME);
68
                if (!is_writable($strDir)) {
69
                   $this->strLastError .= ' (no rights to write on directory ' . $strDir . ')';
70
                }
71
            }
72
        }
73
    }
74
75
    /**
76
     * {@inheritDoc}
77
     * @see PNDataProvider::isConnected()
78
     */
79
    public function isConnected() : bool 
80
    {
81
        if (!$this->db) {
82
            if (strlen($this->strLastError) == 0) {
83
                $this->strLastError = 'no database connected!';
84
            }
85
        } else if (!$this->tableExist()) {
86
            // Condition cannot be forced to test
87
            // - can only occur during development using invalid SQL-statement for creation!
88
            // @codeCoverageIgnoreStart
89
            if (strlen($this->strLastError) == 0) {
90
               $this->strLastError = 'database table ' . $this->strTableName . ' not exist!';
91
            }
92
            // @codeCoverageIgnoreEnd
93
        }
94
        return ($this->db && $this->bTableExist);
95
    }
96
    
97
    /**
98
     * {@inheritDoc}
99
     * @see PNDataProvider::saveSubscription()
100
     */
101
    public function saveSubscription(string $strJSON) : bool 
102
    {
103
        $bSucceeded = false;
104
        if ($this->isConnected()) {
105
            $oSubscription = json_decode($strJSON, true);
106
            if ($oSubscription) {
107
                $iExpires = isset($oSubscription['expirationTime']) ? bcdiv((string)$oSubscription['expirationTime'], '1000') : 0;
108
                $strUserAgent = isset($oSubscription['userAgent']) ? $oSubscription['userAgent'] : 'unknown UserAgent';
109
                
110
                // insert or update - relevant is the endpoint as unique index 
111
                $strSQL  = "REPLACE INTO " . $this->strTableName . " (";
112
                $strSQL .=       self::COL_ENDPOINT;
113
                $strSQL .= "," . self::COL_EXPIRES;
114
                $strSQL .= "," . self::COL_SUBSCRIPTION;
115
                $strSQL .= "," . self::COL_USERAGENT;
116
                $strSQL .= "," . self::COL_LASTUPDATED;
117
                $strSQL .= ") VALUES(";
118
                $strSQL .= "'" . $oSubscription['endpoint'] . "'";
119
                $strSQL .= "," . $iExpires;
120
                $strSQL .= ",'" . $strJSON . "'";
121
                $strSQL .= ",'" . $strUserAgent . "'";
122
                $strSQL .= ',' . time();
123
                $strSQL .= ");";
124
125
                $bSucceeded = $this->db->exec($strSQL);
126
                $this->setSQLiteError($bSucceeded);
127
            } else {
128
                $this->strLastError = 'Error json_decode: ' . json_last_error_msg();
129
            }
130
        }
131
        return $bSucceeded;
132
    }
133
    
134
    /**
135
     * {@inheritDoc}
136
     * @see PNDataProvider::removeSubscription()
137
     */
138
    public function removeSubscription(string $strEndpoint) : bool 
139
    {
140
        $bSucceeded = false;
141
        if ($this->isConnected()) {
142
            $strSQL  = "DELETE FROM " . $this->strTableName . " WHERE " . self::COL_ENDPOINT . " LIKE ";
143
            $strSQL .= "'" . $strEndpoint . "'";
144
        
145
            $bSucceeded = $this->db->exec($strSQL);
146
            $this->setSQLiteError($bSucceeded);
147
        }
148
        return $bSucceeded;
149
    }
150
    
151
    /**
152
     * select all subscriptions not expired so far
153
     * {@inheritDoc}
154
     * @see PNDataProvider::init()
155
     */
156
    public function init(bool $bAutoRemove=true) : bool 
157
    {
158
        $bSucceeded = false;
159
        $this->dbres = false;
160
        $this->row = false;
161
        if ($this->isConnected()) {
162
            if ($bAutoRemove) {
163
                // remove expired subscriptions from DB
164
                $strSQL = "DELETE FROM " . $this->strTableName . " WHERE ";
165
                $strSQL .= self::COL_EXPIRES . " != 0 AND ";
166
                $strSQL .= self::COL_EXPIRES . " < " . time();
167
                
168
                $bSucceeded = $this->db->exec($strSQL);
169
                $this->setSQLiteError($bSucceeded !== false);
170
                $strSQL = "SELECT * FROM " . $this->strTableName;
171
            } else {
172
                // or just exclude them from query
173
                $strSQL = "SELECT * FROM " . $this->strTableName . " WHERE ";
174
                $strSQL .= self::COL_EXPIRES . " = 0 OR ";
175
                $strSQL .= self::COL_EXPIRES . " >= " . time();
176
                $bSucceeded = true;
177
            }
178
            if ($bSucceeded) {
179
                $this->dbres = $this->db->query($strSQL);
180
                $bSucceeded = $this->dbres !== false && $this->dbres->numColumns() > 0;
181
                $this->setSQLiteError($bSucceeded);
182
            }
183
        }
184
        return (bool)$bSucceeded;
185
    }
186
187
    /**
188
     * {@inheritDoc}
189
     * @see PNDataProvider::count()
190
     */
191
    public function count() : int 
192
    {
193
        $iCount = 0;
194
        if ($this->isConnected()) {
195
            $iCount = $this->db->querySingle("SELECT count(*) FROM " . $this->strTableName);
196
            $this->setSQLiteError($iCount !== false);
197
        }
198
        return intval($iCount);
199
    }
200
    
201
    /**
202
     * {@inheritDoc}
203
     * @see PNDataProvider::fetch()
204
     */
205
    public function fetch()
206
    {
207
        $strSubJSON = false;
208
        $this->row = false;
209
        if ($this->dbres !== false) {
210
            $this->row = $this->dbres->fetchArray(SQLITE3_ASSOC);
211
            $this->setSQLiteError($this->row !== false);
212
            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...
213
                $strSubJSON = $this->row[self::COL_SUBSCRIPTION];
214
            }
215
        }
216
        return $strSubJSON;
217
    }
218
    
219
    /**
220
     * {@inheritDoc}
221
     * @see PNDataProvider::truncate()
222
     */
223
    public function truncate() : bool
224
    {
225
        $bSucceeded = false;
226
        if ($this->isConnected()) {
227
            $bSucceeded = $this->db->exec("DELETE FROM " . $this->strTableName);
228
        }
229
        return $bSucceeded;
230
    }
231
    
232
    /**
233
     * {@inheritDoc}
234
     * @see PNDataProvider::getColumn()
235
     */
236
    public function getColumn($strName) : ?string 
237
    {
238
        $value = null;
239
        if ($this->row !== false && isset($this->row[$strName])) {
240
            $value = $this->row[$strName];
241
        }
242
        return strval($value);          
243
    }
244
245
    /**
246
     * @return string
247
     */
248
    public function getError() : string
249
    {
250
        return $this->strLastError;
251
    }
252
    
253
    /**
254
     * @return bool
255
     */
256
    private function tableExist() : bool 
257
    {
258
        if ($this->bTableExist === null) {
259
            $this->bTableExist = false;
260
            if ($this->db) {
261
                $this->bTableExist = ($this->db->querySingle("SELECT name FROM sqlite_master WHERE type='table' AND name='" . $this->strTableName . "'") != null);
262
            }
263
        }
264
        return $this->bTableExist;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->bTableExist could return the type null which is incompatible with the type-hinted return boolean. Consider adding an additional type-check to rule them out.
Loading history...
265
    }
266
    
267
    /**
268
     * @return bool
269
     */
270
    private function createTable() : bool 
271
    {
272
        $bSucceeded = false;
273
        if ($this->db) {
274
            $strSQL  = "CREATE TABLE " . $this->strTableName . " (";
275
            $strSQL .= self::COL_ID . " INTEGER PRIMARY KEY";
276
            $strSQL .= "," . self::COL_ENDPOINT . " TEXT UNIQUE"; 
277
            $strSQL .= "," . self::COL_EXPIRES . " INTEGER NOT NULL"; 
278
            $strSQL .= "," . self::COL_SUBSCRIPTION . " TEXT NOT NULL";
279
            $strSQL .= "," . self::COL_USERAGENT . " TEXT NOT NULL";
280
            $strSQL .= "," . self::COL_LASTUPDATED . " INTEGER NOT NULL";
281
            $strSQL .= ");";
282
                
283
            $bSucceeded = $this->db->exec($strSQL);
284
        }
285
        $this->bTableExist = $bSucceeded;
286
        return $bSucceeded;
287
    }
288
289
    /**
290
     * @param bool $bSucceeded  set error, if last opperation not succeeded
291
     */
292
    private function setSQLiteError(bool $bSucceeded) : void 
293
    {
294
        // All reasons, with the exception of incorrect SQL statements, are intercepted 
295
        // beforehand - so this part of the code is no longer run through in the test 
296
        // anphase. This section is therefore excluded from codecoverage.
297
        // @codeCoverageIgnoreStart
298
        if (!$bSucceeded && $this->db) {
299
            $this->strLastError = 'SQLite3: ' . $this->db->lastErrorMsg();
300
        }
301
        // @codeCoverageIgnoreEnd
302
    }
303
}
304