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: $mergeSql = $this->getMergeSql();
329:
330: if (null !== $mergeSql) {
331: $mergeStmt = $this->pdo->prepare($mergeSql);
332: $mergeStmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
333: $mergeStmt->bindParam(':data', $data, \PDO::PARAM_LOB);
334: $mergeStmt->bindParam(':lifetime', $maxlifetime, \PDO::PARAM_INT);
335: $mergeStmt->bindValue(':time', time(), \PDO::PARAM_INT);
336: $mergeStmt->execute();
337:
338: return true;
339: }
340:
341: $updateStmt = $this->pdo->prepare(
342: "UPDATE $this->table SET $this->dataCol = :data, $this->lifetimeCol = :lifetime, $this->timeCol = :time WHERE $this->idCol = :id"
343: );
344: $updateStmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
345: $updateStmt->bindParam(':data', $data, \PDO::PARAM_LOB);
346: $updateStmt->bindParam(':lifetime', $maxlifetime, \PDO::PARAM_INT);
347: $updateStmt->bindValue(':time', time(), \PDO::PARAM_INT);
348: $updateStmt->execute();
349:
350: // When MERGE is not supported, like in Postgres, we have to use this approach that can result in
351: // duplicate key errors when the same session is written simultaneously (given the LOCK_NONE behavior).
352: // We can just catch such an error and re-execute the update. This is similar to a serializable
353: // transaction with retry logic on serialization failures but without the overhead and without possible
354: // false positives due to longer gap locking.
355: if (!$updateStmt->rowCount()) {
356: try {
357: $insertStmt = $this->pdo->prepare(
358: "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time)"
359: );
360: $insertStmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
361: $insertStmt->bindParam(':data', $data, \PDO::PARAM_LOB);
362: $insertStmt->bindParam(':lifetime', $maxlifetime, \PDO::PARAM_INT);
363: $insertStmt->bindValue(':time', time(), \PDO::PARAM_INT);
364: $insertStmt->execute();
365: } catch (\PDOException $e) {
366: // Handle integrity violation SQLSTATE 23000 (or a subclass like 23505 in Postgres) for duplicate keys
367: if (0 === strpos($e->getCode(), '23')) {
368: $updateStmt->execute();
369: } else {
370: throw $e;
371: }
372: }
373: }
374: } catch (\PDOException $e) {
375: $this->rollback();
376:
377: throw $e;
378: }
379:
380: return true;
381: }
382:
383: /**
384: * {@inheritdoc}
385: */
386: public function close()
387: {
388: $this->commit();
389:
390: while ($unlockStmt = array_shift($this->unlockStatements)) {
391: $unlockStmt->execute();
392: }
393:
394: if ($this->gcCalled) {
395: $this->gcCalled = false;
396:
397: // delete the session records that have expired
398: $sql = "DELETE FROM $this->table WHERE $this->lifetimeCol + $this->timeCol < :time";
399:
400: $stmt = $this->pdo->prepare($sql);
401: $stmt->bindValue(':time', time(), \PDO::PARAM_INT);
402: $stmt->execute();
403: }
404:
405: if (false !== $this->dsn) {
406: $this->pdo = null; // only close lazy-connection
407: }
408:
409: return true;
410: }
411:
412: /**
413: * Lazy-connects to the database.
414: *
415: * @param string $dsn DSN string
416: */
417: private function connect($dsn)
418: {
419: $this->pdo = new \PDO($dsn, $this->username, $this->password, $this->connectionOptions);
420: $this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
421: $this->driver = $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME);
422: }
423:
424: /**
425: * Helper method to begin a transaction.
426: *
427: * Since SQLite does not support row level locks, we have to acquire a reserved lock
428: * on the database immediately. Because of https://bugs.php.net/42766 we have to create
429: * such a transaction manually which also means we cannot use PDO::commit or
430: * PDO::rollback or PDO::inTransaction for SQLite.
431: *
432: * Also MySQLs default isolation, REPEATABLE READ, causes deadlock for different sessions
433: * due to http://www.mysqlperformanceblog.com/2013/12/12/one-more-innodb-gap-lock-to-avoid/ .
434: * So we change it to READ COMMITTED.
435: */
436: private function beginTransaction()
437: {
438: if (!$this->inTransaction) {
439: if ('sqlite' === $this->driver) {
440: $this->pdo->exec('BEGIN IMMEDIATE TRANSACTION');
441: } else {
442: if ('mysql' === $this->driver) {
443: $this->pdo->exec('SET TRANSACTION ISOLATION LEVEL READ COMMITTED');
444: }
445: $this->pdo->beginTransaction();
446: }
447: $this->inTransaction = true;
448: }
449: }
450:
451: /**
452: * Helper method to commit a transaction.
453: */
454: private function commit()
455: {
456: if ($this->inTransaction) {
457: try {
458: // commit read-write transaction which also releases the lock
459: if ('sqlite' === $this->driver) {
460: $this->pdo->exec('COMMIT');
461: } else {
462: $this->pdo->commit();
463: }
464: $this->inTransaction = false;
465: } catch (\PDOException $e) {
466: $this->rollback();
467:
468: throw $e;
469: }
470: }
471: }
472:
473: /**
474: * Helper method to rollback a transaction.
475: */
476: private function rollback()
477: {
478: // We only need to rollback if we are in a transaction. Otherwise the resulting
479: // error would hide the real problem why rollback was called. We might not be
480: // in a transaction when not using the transactional locking behavior or when
481: // two callbacks (e.g. destroy and write) are invoked that both fail.
482: if ($this->inTransaction) {
483: if ('sqlite' === $this->driver) {
484: $this->pdo->exec('ROLLBACK');
485: } else {
486: $this->pdo->rollback();
487: }
488: $this->inTransaction = false;
489: }
490: }
491:
492: /**
493: * Reads the session data in respect to the different locking strategies.
494: *
495: * We need to make sure we do not return session data that is already considered garbage according
496: * to the session.gc_maxlifetime setting because gc() is called after read() and only sometimes.
497: *
498: * @param string $sessionId Session ID
499: *
500: * @return string The session data
501: */
502: private function doRead($sessionId)
503: {
504: $this->sessionExpired = false;
505:
506: if (self::LOCK_ADVISORY === $this->lockMode) {
507: $this->unlockStatements[] = $this->doAdvisoryLock($sessionId);
508: }
509:
510: $selectSql = $this->getSelectSql();
511: $selectStmt = $this->pdo->prepare($selectSql);
512: $selectStmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
513: $selectStmt->execute();
514:
515: $sessionRows = $selectStmt->fetchAll(\PDO::FETCH_NUM);
516:
517: if ($sessionRows) {
518: if ($sessionRows[0][1] + $sessionRows[0][2] < time()) {
519: $this->sessionExpired = true;
520:
521: return '';
522: }
523:
524: return is_resource($sessionRows[0][0]) ? stream_get_contents($sessionRows[0][0]) : $sessionRows[0][0];
525: }
526:
527: if (self::LOCK_TRANSACTIONAL === $this->lockMode && 'sqlite' !== $this->driver) {
528: // Exclusive-reading of non-existent rows does not block, so we need to do an insert to block
529: // until other connections to the session are committed.
530: try {
531: $insertStmt = $this->pdo->prepare(
532: "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time)"
533: );
534: $insertStmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
535: $insertStmt->bindValue(':data', '', \PDO::PARAM_LOB);
536: $insertStmt->bindValue(':lifetime', 0, \PDO::PARAM_INT);
537: $insertStmt->bindValue(':time', time(), \PDO::PARAM_INT);
538: $insertStmt->execute();
539: } catch (\PDOException $e) {
540: // Catch duplicate key error because other connection created the session already.
541: // It would only not be the case when the other connection destroyed the session.
542: if (0 === strpos($e->getCode(), '23')) {
543: // Retrieve finished session data written by concurrent connection. SELECT
544: // FOR UPDATE is necessary to avoid deadlock of connection that starts reading
545: // before we write (transform intention to real lock).
546: $selectStmt->execute();
547: $sessionRows = $selectStmt->fetchAll(\PDO::FETCH_NUM);
548:
549: if ($sessionRows) {
550: return is_resource($sessionRows[0][0]) ? stream_get_contents($sessionRows[0][0]) : $sessionRows[0][0];
551: }
552:
553: return '';
554: }
555:
556: throw $e;
557: }
558: }
559:
560: return '';
561: }
562:
563: /**
564: * Executes an application-level lock on the database.
565: *
566: * @param string $sessionId Session ID
567: *
568: * @return \PDOStatement The statement that needs to be executed later to release the lock
569: *
570: * @throws \DomainException When an unsupported PDO driver is used
571: *
572: * @todo implement missing advisory locks
573: * - for oci using DBMS_LOCK.REQUEST
574: * - for sqlsrv using sp_getapplock with LockOwner = Session
575: */
576: private function doAdvisoryLock($sessionId)
577: {
578: switch ($this->driver) {
579: case 'mysql':
580: // should we handle the return value? 0 on timeout, null on error
581: // we use a timeout of 50 seconds which is also the default for innodb_lock_wait_timeout
582: $stmt = $this->pdo->prepare('SELECT GET_LOCK(:key, 50)');
583: $stmt->bindValue(':key', $sessionId, \PDO::PARAM_STR);
584: $stmt->execute();
585:
586: $releaseStmt = $this->pdo->prepare('DO RELEASE_LOCK(:key)');
587: $releaseStmt->bindValue(':key', $sessionId, \PDO::PARAM_STR);
588:
589: return $releaseStmt;
590: case 'pgsql':
591: // Obtaining an exclusive session level advisory lock requires an integer key.
592: // So we convert the HEX representation of the session id to an integer.
593: // Since integers are signed, we have to skip one hex char to fit in the range.
594: if (4 === PHP_INT_SIZE) {
595: $sessionInt1 = hexdec(substr($sessionId, 0, 7));
596: $sessionInt2 = hexdec(substr($sessionId, 7, 7));
597:
598: $stmt = $this->pdo->prepare('SELECT pg_advisory_lock(:key1, :key2)');
599: $stmt->bindValue(':key1', $sessionInt1, \PDO::PARAM_INT);
600: $stmt->bindValue(':key2', $sessionInt2, \PDO::PARAM_INT);
601: $stmt->execute();
602:
603: $releaseStmt = $this->pdo->prepare('SELECT pg_advisory_unlock(:key1, :key2)');
604: $releaseStmt->bindValue(':key1', $sessionInt1, \PDO::PARAM_INT);
605: $releaseStmt->bindValue(':key2', $sessionInt2, \PDO::PARAM_INT);
606: } else {
607: $sessionBigInt = hexdec(substr($sessionId, 0, 15));
608:
609: $stmt = $this->pdo->prepare('SELECT pg_advisory_lock(:key)');
610: $stmt->bindValue(':key', $sessionBigInt, \PDO::PARAM_INT);
611: $stmt->execute();
612:
613: $releaseStmt = $this->pdo->prepare('SELECT pg_advisory_unlock(:key)');
614: $releaseStmt->bindValue(':key', $sessionBigInt, \PDO::PARAM_INT);
615: }
616:
617: return $releaseStmt;
618: case 'sqlite':
619: throw new \DomainException('SQLite does not support advisory locks.');
620: default:
621: throw new \DomainException(sprintf('Advisory locks are currently not implemented for PDO driver "%s".', $this->driver));
622: }
623: }
624:
625: /**
626: * Return a locking or nonlocking SQL query to read session information.
627: *
628: * @return string The SQL string
629: *
630: * @throws \DomainException When an unsupported PDO driver is used
631: */
632: private function getSelectSql()
633: {
634: if (self::LOCK_TRANSACTIONAL === $this->lockMode) {
635: $this->beginTransaction();
636:
637: switch ($this->driver) {
638: case 'mysql':
639: case 'oci':
640: case 'pgsql':
641: return "SELECT $this->dataCol, $this->lifetimeCol, $this->timeCol FROM $this->table WHERE $this->idCol = :id FOR UPDATE";
642: case 'sqlsrv':
643: return "SELECT $this->dataCol, $this->lifetimeCol, $this->timeCol FROM $this->table WITH (UPDLOCK, ROWLOCK) WHERE $this->idCol = :id";
644: case 'sqlite':
645: // we already locked when starting transaction
646: break;
647: default:
648: throw new \DomainException(sprintf('Transactional locks are currently not implemented for PDO driver "%s".', $this->driver));
649: }
650: }
651:
652: return "SELECT $this->dataCol, $this->lifetimeCol, $this->timeCol FROM $this->table WHERE $this->idCol = :id";
653: }
654:
655: /**
656: * Returns a merge/upsert (i.e. insert or update) SQL query when supported by the database for writing session data.
657: *
658: * @return string|null The SQL string or null when not supported
659: */
660: private function getMergeSql()
661: {
662: switch ($this->driver) {
663: case 'mysql':
664: return "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time) ".
665: "ON DUPLICATE KEY UPDATE $this->dataCol = VALUES($this->dataCol), $this->lifetimeCol = VALUES($this->lifetimeCol), $this->timeCol = VALUES($this->timeCol)";
666: case 'oci':
667: // DUAL is Oracle specific dummy table
668: return "MERGE INTO $this->table USING DUAL ON ($this->idCol = :id) ".
669: "WHEN NOT MATCHED THEN INSERT ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time) ".
670: "WHEN MATCHED THEN UPDATE SET $this->dataCol = :data, $this->lifetimeCol = :lifetime, $this->timeCol = :time";
671: case 'sqlsrv' === $this->driver && version_compare($this->pdo->getAttribute(\PDO::ATTR_SERVER_VERSION), '10', '>='):
672: // MERGE is only available since SQL Server 2008 and must be terminated by semicolon
673: // It also requires HOLDLOCK according to http://weblogs.sqlteam.com/dang/archive/2009/01/31/UPSERT-Race-Condition-With-MERGE.aspx
674: return "MERGE INTO $this->table WITH (HOLDLOCK) USING (SELECT 1 AS dummy) AS src ON ($this->idCol = :id) ".
675: "WHEN NOT MATCHED THEN INSERT ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time) ".
676: "WHEN MATCHED THEN UPDATE SET $this->dataCol = :data, $this->lifetimeCol = :lifetime, $this->timeCol = :time;";
677: case 'sqlite':
678: return "INSERT OR REPLACE INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time)";
679: }
680: }
681:
682: /**
683: * Return a PDO instance
684: *
685: * @return \PDO
686: */
687: protected function getConnection()
688: {
689: if (null === $this->pdo) {
690: $this->connect($this->dsn ?: ini_get('session.save_path'));
691: }
692:
693: return $this->pdo;
694: }
695: }
696: