• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

aplus-framework / session / 26314044436

22 May 2026 09:53PM UTC coverage: 93.726% (+0.006%) from 93.72%
26314044436

push

github

natanfelles
Update coding standard

986 of 1052 relevant lines covered (93.73%)

42.25 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

99.44
/src/SaveHandlers/DatabaseHandler.php
1
<?php declare(strict_types=1);
2
/*
3
 * This file is part of Aplus Framework Session Library.
4
 *
5
 * (c) Natan Felles <natanfelles@gmail.com>
6
 *
7
 * For the full copyright and license information, please view the LICENSE
8
 * file that was distributed with this source code.
9
 */
10
namespace Framework\Session\SaveHandlers;
11

12
use Closure;
13
use Framework\Database\Database;
14
use Framework\Database\Manipulation\Delete;
15
use Framework\Database\Manipulation\Select;
16
use Framework\Database\Manipulation\Update;
17
use Framework\Log\LogLevel;
18
use Framework\Session\SaveHandler;
19
use SensitiveParameter;
20

21
/**
22
 * Class DatabaseHandler.
23
 *
24
 * ```sql
25
 * CREATE TABLE `Sessions` (
26
 *     `id` char(32) NOT NULL,
27
 *     `timestamp` timestamp NOT NULL,
28
 *     `data` blob NOT NULL,
29
 *     `ip` varchar(45) NOT NULL, -- optional
30
 *     `ua` varchar(255) NOT NULL, -- optional
31
 *     PRIMARY KEY (`id`),
32
 *     KEY `timestamp` (`timestamp`),
33
 *     KEY `ip` (`ip`), -- optional
34
 *     KEY `ua` (`ua`) -- optional
35
 * );
36
 * ```
37
 *
38
 * @package session
39
 */
40
class DatabaseHandler extends SaveHandler
41
{
42
    protected ?Database $database;
43

44
    /**
45
     * Prepare configurations to be used by the DatabaseHandler.
46
     *
47
     * @param array<string,mixed> $configs Custom configs
48
     *
49
     * The custom configs are:
50
     *
51
     * ```php
52
     * $configs = [
53
     *     // The name of the table used for sessions
54
     *     'table' => 'Sessions',
55
     *     // The maxlifetime used for locking
56
     *     'maxlifetime' => null, // Null to use the ini value of session.gc_maxlifetime
57
     *     // The custom column names as values
58
     *     'columns' => [
59
     *         'id' => 'id',
60
     *         'data' => 'data',
61
     *         'timestamp' => 'timestamp',
62
     *         'ip' => 'ip',
63
     *         'ua' => 'ua',
64
     *     ],
65
     *     // Match IP?
66
     *     'match_ip' => false,
67
     *     // Match User-Agent?
68
     *     'match_ua' => false,
69
     *     // Independent of match_ip, save the initial IP in the ip column?
70
     *     'save_ip' => false,
71
     *     // Independent of match_ua, save the initial User-Agent in the ua column?
72
     *     'save_ua' => false,
73
     * ];
74
     * ```
75
     *
76
     * NOTE: The Database::connect configs was not shown.
77
     */
78
    protected function prepareConfigs(#[SensitiveParameter] array $configs) : void
79
    {
80
        $this->configs = \array_replace_recursive([
66✔
81
            'handler' => [],
66✔
82
            'table' => 'Sessions',
66✔
83
            'maxlifetime' => null,
66✔
84
            'columns' => [
66✔
85
                'id' => 'id',
66✔
86
                'data' => 'data',
66✔
87
                'timestamp' => 'timestamp',
66✔
88
                'ip' => 'ip',
66✔
89
                'ua' => 'ua',
66✔
90
                'user_id' => 'user_id',
66✔
91
            ],
66✔
92
            'match_ip' => false,
66✔
93
            'match_ua' => false,
66✔
94
            'save_ip' => false,
66✔
95
            'save_ua' => false,
66✔
96
            'save_user_id' => false,
66✔
97
        ], $configs);
66✔
98
    }
99

100
    public function setDatabase(Database $database) : static
101
    {
102
        $this->setByExternal = true;
2✔
103
        $this->database = $database;
2✔
104
        return $this;
2✔
105
    }
106

107
    public function getDatabase() : ?Database
108
    {
109
        return $this->database ?? null;
3✔
110
    }
111

112
    /**
113
     * Get the table name based on custom/default configs.
114
     *
115
     * @return string The table name
116
     */
117
    protected function getTable() : string
118
    {
119
        return $this->configs['table'];
67✔
120
    }
121

122
    /**
123
     * Get a column name based on custom/default configs.
124
     *
125
     * @param string $key The columns config key
126
     *
127
     * @return string The column name
128
     */
129
    protected function getColumn(string $key) : string
130
    {
131
        return $this->configs['columns'][$key];
67✔
132
    }
133

134
    /**
135
     * Adds the `WHERE $column = $value` clauses when matching IP or User-Agent.
136
     *
137
     * @param Delete|Select|Update $statement The statement to add the WHERE clause
138
     */
139
    protected function addWhereMatchs(Delete | Select | Update $statement) : void
140
    {
141
        if ($this->configs['match_ip']) {
67✔
142
            $statement->whereEqual($this->getColumn('ip'), $this->getIP());
33✔
143
        }
144
        if ($this->configs['match_ua']) {
67✔
145
            $statement->whereEqual($this->getColumn('ua'), $this->getUA());
33✔
146
        }
147
    }
148

149
    /**
150
     * Adds the optional `user_id` column.
151
     *
152
     * @param array<string,Closure|string> $columns The statement columns to insert/update
153
     */
154
    protected function addUserIdColumn(array &$columns) : void
155
    {
156
        if ($this->configs['save_user_id']) {
30✔
157
            $key = $this->getColumn('user_id');
2✔
158
            $columns[$key] = $_SESSION[$key] ?? null;
2✔
159
        }
160
    }
161

162
    public function open($path, $name) : bool
163
    {
164
        try {
165
            $this->database ??= new Database($this->configs);
67✔
166
            return true;
67✔
167
        } catch (\Exception $exception) {
2✔
168
            $this->log(
2✔
169
                'Session (database): Thrown a ' . \get_class($exception)
2✔
170
                . ' while trying to open: ' . $exception->getMessage()
2✔
171
            );
2✔
172
        }
173
        return false;
2✔
174
    }
175

176
    public function read($id) : string
177
    {
178
        if (!isset($this->database) || $this->lock($id) === false) {
67✔
179
            $this->setFingerprint('');
2✔
180
            return '';
2✔
181
        }
182
        if (!isset($this->sessionId)) {
67✔
183
            $this->sessionId = $id;
67✔
184
        }
185
        $statement = $this->database
67✔
186
            ->select()
67✔
187
            ->from($this->getTable())
67✔
188
            ->whereEqual($this->getColumn('id'), $id);
67✔
189
        $this->addWhereMatchs($statement);
67✔
190
        $row = $statement->limit(1)->run()->fetch();
67✔
191
        $this->sessionExists = (bool) $row;
67✔
192
        $column = $this->getColumn('data');
67✔
193
        $data = $row->{$column} ?? '';
67✔
194
        $this->setFingerprint($data);
67✔
195
        return $data;
67✔
196
    }
197

198
    public function write($id, $data) : bool
199
    {
200
        if (!isset($this->database)) {
32✔
201
            return false;
2✔
202
        }
203
        if ($this->lockId === false) {
32✔
204
            return false;
2✔
205
        }
206
        if ($id !== $this->sessionId) {
30✔
207
            $this->sessionExists = false;
2✔
208
            $this->sessionId = $id;
2✔
209
        }
210
        if ($this->sessionExists) {
30✔
211
            return $this->writeUpdate($id, $data);
8✔
212
        }
213
        return $this->writeInsert($id, $data);
30✔
214
    }
215

216
    protected function writeInsert(string $id, string $data) : bool
217
    {
218
        $columns = [
30✔
219
            $this->getColumn('id') => $id,
30✔
220
            $this->getColumn('timestamp') => static function () : string {
30✔
221
                return 'NOW()';
30✔
222
            },
30✔
223
            $this->getColumn('data') => $data,
30✔
224
        ];
30✔
225
        if ($this->configs['match_ip'] || $this->configs['save_ip']) {
30✔
226
            $columns[$this->getColumn('ip')] = $this->getIP();
15✔
227
        }
228
        if ($this->configs['match_ua'] || $this->configs['save_ua']) {
30✔
229
            $columns[$this->getColumn('ua')] = $this->getUA();
15✔
230
        }
231
        $this->addUserIdColumn($columns);
30✔
232
        $inserted = $this->database
30✔
233
            ->insert($this->getTable())
30✔
234
            ->set($columns)
30✔
235
            ->run();
30✔
236
        if ($inserted === 0) {
30✔
237
            return false;
×
238
        }
239
        $this->setFingerprint($data);
30✔
240
        $this->sessionExists = true;
30✔
241
        return true;
30✔
242
    }
243

244
    protected function writeUpdate(string $id, string $data) : bool
245
    {
246
        $columns = [
8✔
247
            $this->getColumn('timestamp') => static function () : string {
8✔
248
                return 'NOW()';
8✔
249
            },
8✔
250
        ];
8✔
251
        if (!$this->hasSameFingerprint($data)) {
8✔
252
            $columns[$this->getColumn('data')] = $data;
8✔
253
        }
254
        $this->addUserIdColumn($columns);
8✔
255
        $statement = $this->database
8✔
256
            ->update()
8✔
257
            ->table($this->getTable())
8✔
258
            ->set($columns)
8✔
259
            ->whereEqual($this->getColumn('id'), $id);
8✔
260
        $this->addWhereMatchs($statement);
8✔
261
        $statement->run();
8✔
262
        return true;
8✔
263
    }
264

265
    public function updateTimestamp($id, $data) : bool
266
    {
267
        $statement = $this->database
6✔
268
            ->update()
6✔
269
            ->table($this->getTable())
6✔
270
            ->set([
6✔
271
                $this->getColumn('timestamp') => static function () : string {
6✔
272
                    return 'NOW()';
6✔
273
                },
6✔
274
            ])
6✔
275
            ->whereEqual($this->getColumn('id'), $id);
6✔
276
        $this->addWhereMatchs($statement);
6✔
277
        $statement->run();
6✔
278
        return true;
6✔
279
    }
280

281
    public function close() : bool
282
    {
283
        $closed = !($this->lockId && !$this->unlock());
66✔
284
        if ($this->setByExternal === false) {
66✔
285
            $this->database = null;
66✔
286
        }
287
        return $closed;
66✔
288
    }
289

290
    public function destroy($id) : bool
291
    {
292
        $statement = $this->database
56✔
293
            ->delete()
56✔
294
            ->from($this->getTable())
56✔
295
            ->whereEqual($this->getColumn('id'), $id);
56✔
296
        $this->addWhereMatchs($statement);
56✔
297
        $result = $statement->run();
56✔
298
        if ($result !== 1) {
56✔
299
            $this->log(
44✔
300
                'Session (database): Expected to delete 1 row, deleted ' . $result,
44✔
301
                LogLevel::DEBUG
44✔
302
            );
44✔
303
        }
304
        return true;
56✔
305
    }
306

307
    public function gc($max_lifetime) : false | int
308
    {
309
        try {
310
            $this->database ??= new Database($this->configs);
4✔
311
        } catch (\Exception $exception) {
2✔
312
            $this->log(
2✔
313
                'Session (database): Thrown a ' . \get_class($exception)
2✔
314
                . ' while trying to gc: ' . $exception->getMessage()
2✔
315
            );
2✔
316
            return false;
2✔
317
        }
318
        // @phpstan-ignore-next-line
319
        return $this->database
2✔
320
            ->delete()
2✔
321
            ->from($this->getTable())
2✔
322
            ->whereLessThan(
2✔
323
                $this->getColumn('timestamp'),
2✔
324
                static function () use ($max_lifetime) : string {
2✔
325
                    return 'NOW() - INTERVAL ' . $max_lifetime . ' second';
2✔
326
                }
2✔
327
            )->run();
2✔
328
    }
329

330
    protected function lock(string $id) : bool
331
    {
332
        $row = $this->database
67✔
333
            ->select()
67✔
334
            ->expressions([
67✔
335
                'locked' => function (Database $database) use ($id) : string {
67✔
336
                    $id = $database->quote($id);
67✔
337
                    $maxlifetime = $database->quote($this->getMaxlifetime());
67✔
338
                    return "GET_LOCK({$id}, {$maxlifetime})";
67✔
339
                },
67✔
340
            ])->run()
67✔
341
            ->fetch();
67✔
342
        if ($row && $row->locked) { // @phpstan-ignore-line
67✔
343
            $this->lockId = $id;
67✔
344
            return true;
67✔
345
        }
346
        $this->log('Session (database): Error while trying to lock ' . $id);
2✔
347
        return false;
2✔
348
    }
349

350
    protected function unlock() : bool
351
    {
352
        if ($this->lockId === false) {
66✔
353
            return true;
2✔
354
        }
355
        $row = $this->database
66✔
356
            ->select()
66✔
357
            ->expressions([
66✔
358
                'unlocked' => function (Database $database) : string {
66✔
359
                    $lockId = $database->quote($this->lockId);
66✔
360
                    return "RELEASE_LOCK({$lockId})";
66✔
361
                },
66✔
362
            ])->run()
66✔
363
            ->fetch();
66✔
364
        if ($row && $row->unlocked) { // @phpstan-ignore-line
66✔
365
            $this->lockId = false;
66✔
366
            return true;
66✔
367
        }
368
        $this->log('Session (database): Error while trying to unlock ' . $this->lockId);
2✔
369
        return false;
2✔
370
    }
371
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc