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

aplus-framework / session / 26317447516

22 May 2026 11:50PM UTC coverage: 93.756% (+0.03%) from 93.726%
26317447516

push

github

natanfelles
Update PHPDocs

991 of 1057 relevant lines covered (93.76%)

43.92 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 Database configs
54
     *     'database' => [],
55
     *     // The name of the table used for sessions
56
     *     'table' => 'Sessions',
57
     *     // The maxlifetime used for locking
58
     *     'maxlifetime' => null, // Null to use the ini value of session.gc_maxlifetime
59
     *     // The custom column names as values
60
     *     'columns' => [
61
     *         'id' => 'id',
62
     *         'data' => 'data',
63
     *         'timestamp' => 'timestamp',
64
     *         'ip' => 'ip',
65
     *         'ua' => 'ua',
66
     *         'user_id' => 'user_id',
67
     *     ],
68
     *     // Match IP?
69
     *     'match_ip' => false,
70
     *     // Match User-Agent?
71
     *     'match_ua' => false,
72
     *     // Independent of match_ip, save the initial IP in the ip column?
73
     *     'save_ip' => false,
74
     *     // Independent of match_ua, save the initial User-Agent in the ua column?
75
     *     'save_ua' => false,
76
     *     // Save the user_id?
77
     *     'save_user_id' => false,
78
     * ];
79
     * ```
80
     *
81
     * NOTE: The Database::connect configs was not shown.
82
     */
83
    protected function prepareConfigs(#[SensitiveParameter] array $configs) : void
84
    {
85
        $this->configs = \array_replace_recursive([
68✔
86
            'database' => [],
68✔
87
            'table' => 'Sessions',
68✔
88
            'maxlifetime' => null,
68✔
89
            'columns' => [
68✔
90
                'id' => 'id',
68✔
91
                'data' => 'data',
68✔
92
                'timestamp' => 'timestamp',
68✔
93
                'ip' => 'ip',
68✔
94
                'ua' => 'ua',
68✔
95
                'user_id' => 'user_id',
68✔
96
            ],
68✔
97
            'match_ip' => false,
68✔
98
            'match_ua' => false,
68✔
99
            'save_ip' => false,
68✔
100
            'save_ua' => false,
68✔
101
            'save_user_id' => false,
68✔
102
        ], $configs);
68✔
103
    }
104

105
    public function setDatabase(Database $database) : static
106
    {
107
        $this->setByExternal = true;
2✔
108
        $this->database = $database;
2✔
109
        return $this;
2✔
110
    }
111

112
    public function getDatabase() : ?Database
113
    {
114
        return $this->database ?? null;
3✔
115
    }
116

117
    /**
118
     * Get the table name based on custom/default configs.
119
     *
120
     * @return string The table name
121
     */
122
    protected function getTable() : string
123
    {
124
        return $this->getConfig('table');
69✔
125
    }
126

127
    /**
128
     * Get a column name based on custom/default configs.
129
     *
130
     * @param string $key The columns config key
131
     *
132
     * @return string The column name
133
     */
134
    protected function getColumn(string $key) : string
135
    {
136
        return $this->getConfig('columns')[$key];
69✔
137
    }
138

139
    /**
140
     * Adds the `WHERE $column = $value` clauses when matching IP or User-Agent.
141
     *
142
     * @param Delete|Select|Update $statement The statement to add the WHERE clause
143
     */
144
    protected function addWhereMatchs(Delete | Select | Update $statement) : void
145
    {
146
        if ($this->getConfig('match_ip')) {
69✔
147
            $statement->whereEqual($this->getColumn('ip'), $this->getIP());
34✔
148
        }
149
        if ($this->getConfig('match_ua')) {
69✔
150
            $statement->whereEqual($this->getColumn('ua'), $this->getUA());
34✔
151
        }
152
    }
153

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

167
    public function open($path, $name) : bool
168
    {
169
        try {
170
            $this->database ??= new Database($this->getConfig('database'));
69✔
171
            return true;
69✔
172
        } catch (\Exception $exception) {
2✔
173
            $this->log(
2✔
174
                'Session (database): Thrown a ' . \get_class($exception)
2✔
175
                . ' while trying to open: ' . $exception->getMessage()
2✔
176
            );
2✔
177
        }
178
        return false;
2✔
179
    }
180

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

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

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

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

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

286
    public function close() : bool
287
    {
288
        $closed = !($this->lockId && !$this->unlock());
68✔
289
        if ($this->setByExternal === false) {
68✔
290
            $this->database = null;
68✔
291
        }
292
        return $closed;
68✔
293
    }
294

295
    public function destroy($id) : bool
296
    {
297
        $statement = $this->database
58✔
298
            ->delete()
58✔
299
            ->from($this->getTable())
58✔
300
            ->whereEqual($this->getColumn('id'), $id);
58✔
301
        $this->addWhereMatchs($statement);
58✔
302
        $result = $statement->run();
58✔
303
        if ($result !== 1) {
58✔
304
            $this->log(
46✔
305
                'Session (database): Expected to delete 1 row, deleted ' . $result,
46✔
306
                LogLevel::DEBUG
46✔
307
            );
46✔
308
        }
309
        return true;
58✔
310
    }
311

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

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

355
    protected function unlock() : bool
356
    {
357
        if ($this->lockId === false) {
68✔
358
            return true;
2✔
359
        }
360
        $row = $this->database
68✔
361
            ->select()
68✔
362
            ->expressions([
68✔
363
                'unlocked' => function (Database $database) : string {
68✔
364
                    $lockId = $database->quote($this->lockId);
68✔
365
                    return "RELEASE_LOCK({$lockId})";
68✔
366
                },
68✔
367
            ])->run()
68✔
368
            ->fetch();
68✔
369
        if ($row && $row->unlocked) { // @phpstan-ignore-line
68✔
370
            $this->lockId = false;
68✔
371
            return true;
68✔
372
        }
373
        $this->log('Session (database): Error while trying to unlock ' . $this->lockId);
2✔
374
        return false;
2✔
375
    }
376
}
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