Completed
Push — master ( 193986...cdf399 )
by Andrey
11:47
created

PdoSessionHandler::destroy()   A

Complexity

Conditions 2
Paths 4

Size

Total Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
nc 4
nop 1
dl 0
loc 17
rs 9.7
c 0
b 0
f 0
1
<?php
2
3
/*
4
 * This file is part of the Symfony package.
5
 *
6
 * (c) Fabien Potencier <[email protected]>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11
12
namespace Symfony\Component\HttpFoundation\Session\Storage\Handler;
13
14
/**
15
 * Session handler using a PDO connection to read and write data.
16
 *
17
 * It works with MySQL, PostgreSQL, Oracle, SQL Server and SQLite and implements
18
 * different locking strategies to handle concurrent access to the same session.
19
 * Locking is necessary to prevent loss of data due to race conditions and to keep
20
 * the session data consistent between read() and write(). With locking, requests
21
 * for the same session will wait until the other one finished writing. For this
22
 * reason it's best practice to close a session as early as possible to improve
23
 * concurrency. PHPs internal files session handler also implements locking.
24
 *
25
 * Attention: Since SQLite does not support row level locks but locks the whole database,
26
 * it means only one session can be accessed at a time. Even different sessions would wait
27
 * for another to finish. So saving session in SQLite should only be considered for
28
 * development or prototypes.
29
 *
30
 * Session data is a binary string that can contain non-printable characters like the null byte.
31
 * For this reason it must be saved in a binary column in the database like BLOB in MySQL.
32
 * Saving it in a character column could corrupt the data. You can use createTable()
33
 * to initialize a correctly defined table.
34
 *
35
 * @see http://php.net/sessionhandlerinterface
36
 *
37
 * @author Fabien Potencier <[email protected]>
38
 * @author Michael Williams <[email protected]>
39
 * @author Tobias Schultze <http://tobion.de>
40
 */
41
class PdoSessionHandler implements \SessionHandlerInterface
42
{
43
    /**
44
     * No locking is done. This means sessions are prone to loss of data due to
45
     * race conditions of concurrent requests to the same session. The last session
46
     * write will win in this case. It might be useful when you implement your own
47
     * logic to deal with this like an optimistic approach.
48
     */
49
    const LOCK_NONE = 0;
50
51
    /**
52
     * Creates an application-level lock on a session. The disadvantage is that the
53
     * lock is not enforced by the database and thus other, unaware parts of the
54
     * application could still concurrently modify the session. The advantage is it
55
     * does not require a transaction.
56
     * This mode is not available for SQLite and not yet implemented for oci and sqlsrv.
57
     */
58
    const LOCK_ADVISORY = 1;
59
60
    /**
61
     * Issues a real row lock. Since it uses a transaction between opening and
62
     * closing a session, you have to be careful when you use same database connection
63
     * that you also use for your application logic. This mode is the default because
64
     * it's the only reliable solution across DBMSs.
65
     */
66
    const LOCK_TRANSACTIONAL = 2;
67
68
    /**
69
     * @var \PDO|null PDO instance or null when not connected yet
70
     */
71
    private $pdo;
72
73
    /**
74
     * @var string|null|false DSN string or null for session.save_path or false when lazy connection disabled
75
     */
76
    private $dsn = false;
77
78
    /**
79
     * @var string Database driver
80
     */
81
    private $driver;
82
83
    /**
84
     * @var string Table name
85
     */
86
    private $table = 'sessions';
87
88
    /**
89
     * @var string Column for session id
90
     */
91
    private $idCol = 'sess_id';
92
93
    /**
94
     * @var string Column for session data
95
     */
96
    private $dataCol = 'sess_data';
97
98
    /**
99
     * @var string Column for lifetime
100
     */
101
    private $lifetimeCol = 'sess_lifetime';
102
103
    /**
104
     * @var string Column for timestamp
105
     */
106
    private $timeCol = 'sess_time';
107
108
    /**
109
     * @var string Username when lazy-connect
110
     */
111
    private $username = '';
112
113
    /**
114
     * @var string Password when lazy-connect
115
     */
116
    private $password = '';
117
118
    /**
119
     * @var array Connection options when lazy-connect
120
     */
121
    private $connectionOptions = array();
122
123
    /**
124
     * @var int The strategy for locking, see constants
125
     */
126
    private $lockMode = self::LOCK_TRANSACTIONAL;
127
128
    /**
129
     * It's an array to support multiple reads before closing which is manual, non-standard usage.
130
     *
131
     * @var \PDOStatement[] An array of statements to release advisory locks
132
     */
133
    private $unlockStatements = array();
134
135
    /**
136
     * @var bool True when the current session exists but expired according to session.gc_maxlifetime
137
     */
138
    private $sessionExpired = false;
139
140
    /**
141
     * @var bool Whether a transaction is active
142
     */
143
    private $inTransaction = false;
144
145
    /**
146
     * @var bool Whether gc() has been called
147
     */
148
    private $gcCalled = false;
149
150
    /**
151
     * Constructor.
152
     *
153
     * You can either pass an existing database connection as PDO instance or
154
     * pass a DSN string that will be used to lazy-connect to the database
155
     * when the session is actually used. Furthermore it's possible to pass null
156
     * which will then use the session.save_path ini setting as PDO DSN parameter.
157
     *
158
     * List of available options:
159
     *  * db_table: The name of the table [default: sessions]
160
     *  * db_id_col: The column where to store the session id [default: sess_id]
161
     *  * db_data_col: The column where to store the session data [default: sess_data]
162
     *  * db_lifetime_col: The column where to store the lifetime [default: sess_lifetime]
163
     *  * db_time_col: The column where to store the timestamp [default: sess_time]
164
     *  * db_username: The username when lazy-connect [default: '']
165
     *  * db_password: The password when lazy-connect [default: '']
166
     *  * db_connection_options: An array of driver-specific connection options [default: array()]
167
     *  * lock_mode: The strategy for locking, see constants [default: LOCK_TRANSACTIONAL]
168
     *
169
     * @param \PDO|string|null $pdoOrDsn A \PDO instance or DSN string or null
170
     * @param array            $options  An associative array of options
171
     *
172
     * @throws \InvalidArgumentException When PDO error mode is not PDO::ERRMODE_EXCEPTION
173
     */
174
    public function __construct($pdoOrDsn = null, array $options = array())
175
    {
176
        if ($pdoOrDsn instanceof \PDO) {
177
            if (\PDO::ERRMODE_EXCEPTION !== $pdoOrDsn->getAttribute(\PDO::ATTR_ERRMODE)) {
178
                throw new \InvalidArgumentException(sprintf('"%s" requires PDO error mode attribute be set to throw Exceptions (i.e. $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION))', __CLASS__));
179
            }
180
181
            $this->pdo = $pdoOrDsn;
182
            $this->driver = $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME);
183
        } else {
184
            $this->dsn = $pdoOrDsn;
185
        }
186
187
        $this->table = isset($options['db_table']) ? $options['db_table'] : $this->table;
188
        $this->idCol = isset($options['db_id_col']) ? $options['db_id_col'] : $this->idCol;
189
        $this->dataCol = isset($options['db_data_col']) ? $options['db_data_col'] : $this->dataCol;
190
        $this->lifetimeCol = isset($options['db_lifetime_col']) ? $options['db_lifetime_col'] : $this->lifetimeCol;
191
        $this->timeCol = isset($options['db_time_col']) ? $options['db_time_col'] : $this->timeCol;
192
        $this->username = isset($options['db_username']) ? $options['db_username'] : $this->username;
193
        $this->password = isset($options['db_password']) ? $options['db_password'] : $this->password;
194
        $this->connectionOptions = isset($options['db_connection_options']) ? $options['db_connection_options'] : $this->connectionOptions;
195
        $this->lockMode = isset($options['lock_mode']) ? $options['lock_mode'] : $this->lockMode;
196
    }
197
198
    /**
199
     * Creates the table to store sessions which can be called once for setup.
200
     *
201
     * Session ID is saved in a column of maximum length 128 because that is enough even
202
     * for a 512 bit configured session.hash_function like Whirlpool. Session data is
203
     * saved in a BLOB. One could also use a shorter inlined varbinary column
204
     * if one was sure the data fits into it.
205
     *
206
     * @throws \PDOException    When the table already exists
207
     * @throws \DomainException When an unsupported PDO driver is used
208
     */
209
    public function createTable()
210
    {
211
        // connect if we are not yet
212
        $this->getConnection();
213
214
        switch ($this->driver) {
215 View Code Duplication
            case 'mysql':
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
216
                // We use varbinary for the ID column because it prevents unwanted conversions:
217
                // - character set conversions between server and client
218
                // - trailing space removal
219
                // - case-insensitivity
220
                // - language processing like é == e
221
                $sql = "CREATE TABLE $this->table ($this->idCol VARBINARY(128) NOT NULL PRIMARY KEY, $this->dataCol BLOB NOT NULL, $this->lifetimeCol MEDIUMINT NOT NULL, $this->timeCol INTEGER UNSIGNED NOT NULL) COLLATE utf8_bin, ENGINE = InnoDB";
222
                break;
223 View Code Duplication
            case 'sqlite':
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
224
                $sql = "CREATE TABLE $this->table ($this->idCol TEXT NOT NULL PRIMARY KEY, $this->dataCol BLOB NOT NULL, $this->lifetimeCol INTEGER NOT NULL, $this->timeCol INTEGER NOT NULL)";
225
                break;
226 View Code Duplication
            case 'pgsql':
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
227
                $sql = "CREATE TABLE $this->table ($this->idCol VARCHAR(128) NOT NULL PRIMARY KEY, $this->dataCol BYTEA NOT NULL, $this->lifetimeCol INTEGER NOT NULL, $this->timeCol INTEGER NOT NULL)";
228
                break;
229 View Code Duplication
            case 'oci':
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
230
                $sql = "CREATE TABLE $this->table ($this->idCol VARCHAR2(128) NOT NULL PRIMARY KEY, $this->dataCol BLOB NOT NULL, $this->lifetimeCol INTEGER NOT NULL, $this->timeCol INTEGER NOT NULL)";
231
                break;
232 View Code Duplication
            case 'sqlsrv':
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
233
                $sql = "CREATE TABLE $this->table ($this->idCol VARCHAR(128) NOT NULL PRIMARY KEY, $this->dataCol VARBINARY(MAX) NOT NULL, $this->lifetimeCol INTEGER NOT NULL, $this->timeCol INTEGER NOT NULL)";
234
                break;
235
            default:
236
                throw new \DomainException(sprintf('Creating the session table is currently not implemented for PDO driver "%s".', $this->driver));
237
        }
238
239
        try {
240
            $this->pdo->exec($sql);
241
        } catch (\PDOException $e) {
242
            $this->rollback();
243
244
            throw $e;
245
        }
246
    }
247
248
    /**
249
     * Returns true when the current session exists but expired according to session.gc_maxlifetime.
250
     *
251
     * Can be used to distinguish between a new session and one that expired due to inactivity.
252
     *
253
     * @return bool Whether current session expired
254
     */
255
    public function isSessionExpired()
256
    {
257
        return $this->sessionExpired;
258
    }
259
260
    /**
261
     * {@inheritdoc}
262
     */
263
    public function open($savePath, $sessionName)
264
    {
265
        if (null === $this->pdo) {
266
            $this->connect($this->dsn ?: $savePath);
267
        }
268
269
        return true;
270
    }
271
272
    /**
273
     * {@inheritdoc}
274
     */
275
    public function read($sessionId)
276
    {
277
        try {
278
            return $this->doRead($sessionId);
279
        } catch (\PDOException $e) {
280
            $this->rollback();
281
282
            throw $e;
283
        }
284
    }
285
286
    /**
287
     * {@inheritdoc}
288
     */
289
    public function gc($maxlifetime)
290
    {
291
        // We delay gc() to close() so that it is executed outside the transactional and blocking read-write process.
292
        // This way, pruning expired sessions does not block them from being started while the current session is used.
293
        $this->gcCalled = true;
294
295
        return true;
296
    }
297
298
    /**
299
     * {@inheritdoc}
300
     */
301
    public function destroy($sessionId)
302
    {
303
        // delete the record associated with this id
304
        $sql = "DELETE FROM $this->table WHERE $this->idCol = :id";
305
306
        try {
307
            $stmt = $this->pdo->prepare($sql);
308
            $stmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
309
            $stmt->execute();
310
        } catch (\PDOException $e) {
311
            $this->rollback();
312
313
            throw $e;
314
        }
315
316
        return true;
317
    }
318
319
    /**
320
     * {@inheritdoc}
321
     */
322
    public function write($sessionId, $data)
323
    {
324
        $maxlifetime = (int) ini_get('session.gc_maxlifetime');
325
326
        try {
327
            // We use a single MERGE SQL query when supported by the database.
328
            $mergeStmt = $this->getMergeStatement($sessionId, $data, $maxlifetime);
329
            if (null !== $mergeStmt) {
330
                $mergeStmt->execute();
331
332
                return true;
333
            }
334
335
            $updateStmt = $this->pdo->prepare(
336
                "UPDATE $this->table SET $this->dataCol = :data, $this->lifetimeCol = :lifetime, $this->timeCol = :time WHERE $this->idCol = :id"
337
            );
338
            $updateStmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
339
            $updateStmt->bindParam(':data', $data, \PDO::PARAM_LOB);
340
            $updateStmt->bindParam(':lifetime', $maxlifetime, \PDO::PARAM_INT);
341
            $updateStmt->bindValue(':time', time(), \PDO::PARAM_INT);
342
            $updateStmt->execute();
343
344
            // When MERGE is not supported, like in Postgres < 9.5, we have to use this approach that can result in
345
            // duplicate key errors when the same session is written simultaneously (given the LOCK_NONE behavior).
346
            // We can just catch such an error and re-execute the update. This is similar to a serializable
347
            // transaction with retry logic on serialization failures but without the overhead and without possible
348
            // false positives due to longer gap locking.
349
            if (!$updateStmt->rowCount()) {
350
                try {
351
                    $insertStmt = $this->pdo->prepare(
352
                        "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time)"
353
                    );
354
                    $insertStmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
355
                    $insertStmt->bindParam(':data', $data, \PDO::PARAM_LOB);
356
                    $insertStmt->bindParam(':lifetime', $maxlifetime, \PDO::PARAM_INT);
357
                    $insertStmt->bindValue(':time', time(), \PDO::PARAM_INT);
358
                    $insertStmt->execute();
359
                } catch (\PDOException $e) {
360
                    // Handle integrity violation SQLSTATE 23000 (or a subclass like 23505 in Postgres) for duplicate keys
361
                    if (0 === strpos($e->getCode(), '23')) {
362
                        $updateStmt->execute();
363
                    } else {
364
                        throw $e;
365
                    }
366
                }
367
            }
368
        } catch (\PDOException $e) {
369
            $this->rollback();
370
371
            throw $e;
372
        }
373
374
        return true;
375
    }
376
377
    /**
378
     * {@inheritdoc}
379
     */
380
    public function close()
381
    {
382
        $this->commit();
383
384
        while ($unlockStmt = array_shift($this->unlockStatements)) {
385
            $unlockStmt->execute();
386
        }
387
388
        if ($this->gcCalled) {
389
            $this->gcCalled = false;
390
391
            // delete the session records that have expired
392
            $sql = "DELETE FROM $this->table WHERE $this->lifetimeCol + $this->timeCol < :time";
393
394
            $stmt = $this->pdo->prepare($sql);
395
            $stmt->bindValue(':time', time(), \PDO::PARAM_INT);
396
            $stmt->execute();
397
        }
398
399
        if (false !== $this->dsn) {
400
            $this->pdo = null; // only close lazy-connection
401
        }
402
403
        return true;
404
    }
405
406
    /**
407
     * Lazy-connects to the database.
408
     *
409
     * @param string $dsn DSN string
410
     */
411
    private function connect($dsn)
412
    {
413
        $this->pdo = new \PDO($dsn, $this->username, $this->password, $this->connectionOptions);
414
        $this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
415
        $this->driver = $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME);
416
    }
417
418
    /**
419
     * Helper method to begin a transaction.
420
     *
421
     * Since SQLite does not support row level locks, we have to acquire a reserved lock
422
     * on the database immediately. Because of https://bugs.php.net/42766 we have to create
423
     * such a transaction manually which also means we cannot use PDO::commit or
424
     * PDO::rollback or PDO::inTransaction for SQLite.
425
     *
426
     * Also MySQLs default isolation, REPEATABLE READ, causes deadlock for different sessions
427
     * due to http://www.mysqlperformanceblog.com/2013/12/12/one-more-innodb-gap-lock-to-avoid/ .
428
     * So we change it to READ COMMITTED.
429
     */
430
    private function beginTransaction()
431
    {
432
        if (!$this->inTransaction) {
433
            if ('sqlite' === $this->driver) {
434
                $this->pdo->exec('BEGIN IMMEDIATE TRANSACTION');
435
            } else {
436
                if ('mysql' === $this->driver) {
437
                    $this->pdo->exec('SET TRANSACTION ISOLATION LEVEL READ COMMITTED');
438
                }
439
                $this->pdo->beginTransaction();
440
            }
441
            $this->inTransaction = true;
442
        }
443
    }
444
445
    /**
446
     * Helper method to commit a transaction.
447
     */
448 View Code Duplication
    private function commit()
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
449
    {
450
        if ($this->inTransaction) {
451
            try {
452
                // commit read-write transaction which also releases the lock
453
                if ('sqlite' === $this->driver) {
454
                    $this->pdo->exec('COMMIT');
455
                } else {
456
                    $this->pdo->commit();
457
                }
458
                $this->inTransaction = false;
459
            } catch (\PDOException $e) {
460
                $this->rollback();
461
462
                throw $e;
463
            }
464
        }
465
    }
466
467
    /**
468
     * Helper method to rollback a transaction.
469
     */
470 View Code Duplication
    private function rollback()
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
471
    {
472
        // We only need to rollback if we are in a transaction. Otherwise the resulting
473
        // error would hide the real problem why rollback was called. We might not be
474
        // in a transaction when not using the transactional locking behavior or when
475
        // two callbacks (e.g. destroy and write) are invoked that both fail.
476
        if ($this->inTransaction) {
477
            if ('sqlite' === $this->driver) {
478
                $this->pdo->exec('ROLLBACK');
479
            } else {
480
                $this->pdo->rollBack();
481
            }
482
            $this->inTransaction = false;
483
        }
484
    }
485
486
    /**
487
     * Reads the session data in respect to the different locking strategies.
488
     *
489
     * We need to make sure we do not return session data that is already considered garbage according
490
     * to the session.gc_maxlifetime setting because gc() is called after read() and only sometimes.
491
     *
492
     * @param string $sessionId Session ID
493
     *
494
     * @return string The session data
495
     */
496
    private function doRead($sessionId)
497
    {
498
        $this->sessionExpired = false;
499
500
        if (self::LOCK_ADVISORY === $this->lockMode) {
501
            $this->unlockStatements[] = $this->doAdvisoryLock($sessionId);
502
        }
503
504
        $selectSql = $this->getSelectSql();
505
        $selectStmt = $this->pdo->prepare($selectSql);
506
        $selectStmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
507
508
        do {
509
            $selectStmt->execute();
510
            $sessionRows = $selectStmt->fetchAll(\PDO::FETCH_NUM);
511
512
            if ($sessionRows) {
513
                if ($sessionRows[0][1] + $sessionRows[0][2] < time()) {
514
                    $this->sessionExpired = true;
515
516
                    return '';
517
                }
518
519
                return is_resource($sessionRows[0][0]) ? stream_get_contents($sessionRows[0][0]) : $sessionRows[0][0];
520
            }
521
522
            if (self::LOCK_TRANSACTIONAL === $this->lockMode && 'sqlite' !== $this->driver) {
523
                // Exclusive-reading of non-existent rows does not block, so we need to do an insert to block
524
                // until other connections to the session are committed.
525
                try {
526
                    $insertStmt = $this->pdo->prepare(
527
                        "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time)"
528
                    );
529
                    $insertStmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
530
                    $insertStmt->bindValue(':data', '', \PDO::PARAM_LOB);
531
                    $insertStmt->bindValue(':lifetime', 0, \PDO::PARAM_INT);
532
                    $insertStmt->bindValue(':time', time(), \PDO::PARAM_INT);
533
                    $insertStmt->execute();
534
                } catch (\PDOException $e) {
535
                    // Catch duplicate key error because other connection created the session already.
536
                    // It would only not be the case when the other connection destroyed the session.
537
                    if (0 === strpos($e->getCode(), '23')) {
538
                        // Retrieve finished session data written by concurrent connection by restarting the loop.
539
                        // We have to start a new transaction as a failed query will mark the current transaction as
540
                        // aborted in PostgreSQL and disallow further queries within it.
541
                        $this->rollback();
542
                        $this->beginTransaction();
543
                        continue;
544
                    }
545
546
                    throw $e;
547
                }
548
            }
549
550
            return '';
551
        } while (true);
552
    }
553
554
    /**
555
     * Executes an application-level lock on the database.
556
     *
557
     * @param string $sessionId Session ID
558
     *
559
     * @return \PDOStatement The statement that needs to be executed later to release the lock
560
     *
561
     * @throws \DomainException When an unsupported PDO driver is used
562
     *
563
     * @todo implement missing advisory locks
564
     *       - for oci using DBMS_LOCK.REQUEST
565
     *       - for sqlsrv using sp_getapplock with LockOwner = Session
566
     */
567
    private function doAdvisoryLock($sessionId)
568
    {
569
        switch ($this->driver) {
570
            case 'mysql':
571
                // should we handle the return value? 0 on timeout, null on error
572
                // we use a timeout of 50 seconds which is also the default for innodb_lock_wait_timeout
573
                $stmt = $this->pdo->prepare('SELECT GET_LOCK(:key, 50)');
574
                $stmt->bindValue(':key', $sessionId, \PDO::PARAM_STR);
575
                $stmt->execute();
576
577
                $releaseStmt = $this->pdo->prepare('DO RELEASE_LOCK(:key)');
578
                $releaseStmt->bindValue(':key', $sessionId, \PDO::PARAM_STR);
579
580
                return $releaseStmt;
581
            case 'pgsql':
582
                // Obtaining an exclusive session level advisory lock requires an integer key.
583
                // So we convert the HEX representation of the session id to an integer.
584
                // Since integers are signed, we have to skip one hex char to fit in the range.
585
                if (4 === PHP_INT_SIZE) {
586
                    $sessionInt1 = hexdec(substr($sessionId, 0, 7));
587
                    $sessionInt2 = hexdec(substr($sessionId, 7, 7));
588
589
                    $stmt = $this->pdo->prepare('SELECT pg_advisory_lock(:key1, :key2)');
590
                    $stmt->bindValue(':key1', $sessionInt1, \PDO::PARAM_INT);
591
                    $stmt->bindValue(':key2', $sessionInt2, \PDO::PARAM_INT);
592
                    $stmt->execute();
593
594
                    $releaseStmt = $this->pdo->prepare('SELECT pg_advisory_unlock(:key1, :key2)');
595
                    $releaseStmt->bindValue(':key1', $sessionInt1, \PDO::PARAM_INT);
596
                    $releaseStmt->bindValue(':key2', $sessionInt2, \PDO::PARAM_INT);
597
                } else {
598
                    $sessionBigInt = hexdec(substr($sessionId, 0, 15));
599
600
                    $stmt = $this->pdo->prepare('SELECT pg_advisory_lock(:key)');
601
                    $stmt->bindValue(':key', $sessionBigInt, \PDO::PARAM_INT);
602
                    $stmt->execute();
603
604
                    $releaseStmt = $this->pdo->prepare('SELECT pg_advisory_unlock(:key)');
605
                    $releaseStmt->bindValue(':key', $sessionBigInt, \PDO::PARAM_INT);
606
                }
607
608
                return $releaseStmt;
609
            case 'sqlite':
610
                throw new \DomainException('SQLite does not support advisory locks.');
611
            default:
612
                throw new \DomainException(sprintf('Advisory locks are currently not implemented for PDO driver "%s".', $this->driver));
613
        }
614
    }
615
616
    /**
617
     * Return a locking or nonlocking SQL query to read session information.
618
     *
619
     * @return string The SQL string
620
     *
621
     * @throws \DomainException When an unsupported PDO driver is used
622
     */
623
    private function getSelectSql()
624
    {
625
        if (self::LOCK_TRANSACTIONAL === $this->lockMode) {
626
            $this->beginTransaction();
627
628
            switch ($this->driver) {
629
                case 'mysql':
630
                case 'oci':
631
                case 'pgsql':
632
                    return "SELECT $this->dataCol, $this->lifetimeCol, $this->timeCol FROM $this->table WHERE $this->idCol = :id FOR UPDATE";
633
                case 'sqlsrv':
634
                    return "SELECT $this->dataCol, $this->lifetimeCol, $this->timeCol FROM $this->table WITH (UPDLOCK, ROWLOCK) WHERE $this->idCol = :id";
635
                case 'sqlite':
636
                    // we already locked when starting transaction
637
                    break;
638
                default:
639
                    throw new \DomainException(sprintf('Transactional locks are currently not implemented for PDO driver "%s".', $this->driver));
640
            }
641
        }
642
643
        return "SELECT $this->dataCol, $this->lifetimeCol, $this->timeCol FROM $this->table WHERE $this->idCol = :id";
644
    }
645
646
    /**
647
     * Returns a merge/upsert (i.e. insert or update) statement when supported by the database for writing session data.
648
     *
649
     * @param string $sessionId   Session ID
650
     * @param string $data        Encoded session data
651
     * @param int    $maxlifetime session.gc_maxlifetime
652
     *
653
     * @return \PDOStatement|null The merge statement or null when not supported
654
     */
655
    private function getMergeStatement($sessionId, $data, $maxlifetime)
656
    {
657
        $mergeSql = null;
658
        switch (true) {
659
            case 'mysql' === $this->driver:
660
                $mergeSql = "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time) ".
661
                    "ON DUPLICATE KEY UPDATE $this->dataCol = VALUES($this->dataCol), $this->lifetimeCol = VALUES($this->lifetimeCol), $this->timeCol = VALUES($this->timeCol)";
662
                break;
663
            case 'oci' === $this->driver:
664
                // DUAL is Oracle specific dummy table
665
                $mergeSql = "MERGE INTO $this->table USING DUAL ON ($this->idCol = ?) ".
666
                    "WHEN NOT MATCHED THEN INSERT ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (?, ?, ?, ?) ".
667
                    "WHEN MATCHED THEN UPDATE SET $this->dataCol = ?, $this->lifetimeCol = ?, $this->timeCol = ?";
668
                break;
669
            case 'sqlsrv' === $this->driver && version_compare($this->pdo->getAttribute(\PDO::ATTR_SERVER_VERSION), '10', '>='):
670
                // MERGE is only available since SQL Server 2008 and must be terminated by semicolon
671
                // It also requires HOLDLOCK according to http://weblogs.sqlteam.com/dang/archive/2009/01/31/UPSERT-Race-Condition-With-MERGE.aspx
672
                $mergeSql = "MERGE INTO $this->table WITH (HOLDLOCK) USING (SELECT 1 AS dummy) AS src ON ($this->idCol = ?) ".
673
                    "WHEN NOT MATCHED THEN INSERT ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (?, ?, ?, ?) ".
674
                    "WHEN MATCHED THEN UPDATE SET $this->dataCol = ?, $this->lifetimeCol = ?, $this->timeCol = ?;";
675
                break;
676 View Code Duplication
            case 'sqlite' === $this->driver:
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
677
                $mergeSql = "INSERT OR REPLACE INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time)";
678
                break;
679
            case 'pgsql' === $this->driver && version_compare($this->pdo->getAttribute(\PDO::ATTR_SERVER_VERSION), '9.5', '>='):
680
                $mergeSql = "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time) ".
681
                    "ON CONFLICT ($this->idCol) DO UPDATE SET ($this->dataCol, $this->lifetimeCol, $this->timeCol) = (EXCLUDED.$this->dataCol, EXCLUDED.$this->lifetimeCol, EXCLUDED.$this->timeCol)";
682
                break;
683
        }
684
685
        if (null !== $mergeSql) {
686
            $mergeStmt = $this->pdo->prepare($mergeSql);
687
688
            if ('sqlsrv' === $this->driver || 'oci' === $this->driver) {
689
                $mergeStmt->bindParam(1, $sessionId, \PDO::PARAM_STR);
690
                $mergeStmt->bindParam(2, $sessionId, \PDO::PARAM_STR);
691
                $mergeStmt->bindParam(3, $data, \PDO::PARAM_LOB);
692
                $mergeStmt->bindParam(4, $maxlifetime, \PDO::PARAM_INT);
693
                $mergeStmt->bindValue(5, time(), \PDO::PARAM_INT);
694
                $mergeStmt->bindParam(6, $data, \PDO::PARAM_LOB);
695
                $mergeStmt->bindParam(7, $maxlifetime, \PDO::PARAM_INT);
696
                $mergeStmt->bindValue(8, time(), \PDO::PARAM_INT);
697
            } else {
698
                $mergeStmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
699
                $mergeStmt->bindParam(':data', $data, \PDO::PARAM_LOB);
700
                $mergeStmt->bindParam(':lifetime', $maxlifetime, \PDO::PARAM_INT);
701
                $mergeStmt->bindValue(':time', time(), \PDO::PARAM_INT);
702
            }
703
704
            return $mergeStmt;
705
        }
706
    }
707
708
    /**
709
     * Return a PDO instance.
710
     *
711
     * @return \PDO
712
     */
713
    protected function getConnection()
714
    {
715
        if (null === $this->pdo) {
716
            $this->connect($this->dsn ?: ini_get('session.save_path'));
717
        }
718
719
        return $this->pdo;
720
    }
721
}
722