Mercurial > hg > isophonics-drupal-site
comparison vendor/symfony/http-foundation/Session/Storage/Handler/PdoSessionHandler.php @ 0:4c8ae668cc8c
Initial import (non-working)
author | Chris Cannam |
---|---|
date | Wed, 29 Nov 2017 16:09:58 +0000 |
parents | |
children | 1fec387a4317 |
comparison
equal
deleted
inserted
replaced
-1:000000000000 | 0:4c8ae668cc8c |
---|---|
1 <?php | |
2 | |
3 /* | |
4 * This file is part of the Symfony package. | |
5 * | |
6 * (c) Fabien Potencier <fabien@symfony.com> | |
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 <fabien@symfony.com> | |
38 * @author Michael Williams <michael.williams@funsational.com> | |
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 case 'mysql': | |
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 case 'sqlite': | |
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 case 'pgsql': | |
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 case 'oci': | |
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 case 'sqlsrv': | |
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 private function commit() | |
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 private function rollback() | |
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 case 'sqlite' === $this->driver: | |
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 } |