Verzeichnisstruktur phpBB-3.3.15


Veröffentlicht
28.08.2024

So funktioniert es


Auf das letzte Element klicken. Dies geht jeweils ein Schritt zurück

Auf das Icon klicken, dies öffnet das Verzeichnis. Nochmal klicken schließt das Verzeichnis.
Auf den Verzeichnisnamen klicken, dies zeigt nur das Verzeichnis mit Inhalt an

(Beispiel Datei-Icons)

Auf das Icon klicken um den Quellcode anzuzeigen

PdoSessionHandler.php

Zuletzt modifiziert: 02.04.2025, 15:04 - Dateigröße: 35.94 KiB


001  <?php
002   
003  /*
004   * This file is part of the Symfony package.
005   *
006   * (c) Fabien Potencier <fabien@symfony.com>
007   *
008   * For the full copyright and license information, please view the LICENSE
009   * file that was distributed with this source code.
010   */
011   
012  namespace Symfony\Component\HttpFoundation\Session\Storage\Handler;
013   
014  /**
015   * Session handler using a PDO connection to read and write data.
016   *
017   * It works with MySQL, PostgreSQL, Oracle, SQL Server and SQLite and implements
018   * different locking strategies to handle concurrent access to the same session.
019   * Locking is necessary to prevent loss of data due to race conditions and to keep
020   * the session data consistent between read() and write(). With locking, requests
021   * for the same session will wait until the other one finished writing. For this
022   * reason it's best practice to close a session as early as possible to improve
023   * concurrency. PHPs internal files session handler also implements locking.
024   *
025   * Attention: Since SQLite does not support row level locks but locks the whole database,
026   * it means only one session can be accessed at a time. Even different sessions would wait
027   * for another to finish. So saving session in SQLite should only be considered for
028   * development or prototypes.
029   *
030   * Session data is a binary string that can contain non-printable characters like the null byte.
031   * For this reason it must be saved in a binary column in the database like BLOB in MySQL.
032   * Saving it in a character column could corrupt the data. You can use createTable()
033   * to initialize a correctly defined table.
034   *
035   * @see https://php.net/sessionhandlerinterface
036   *
037   * @author Fabien Potencier <fabien@symfony.com>
038   * @author Michael Williams <michael.williams@funsational.com>
039   * @author Tobias Schultze <http://tobion.de>
040   */
041  class PdoSessionHandler extends AbstractSessionHandler
042  {
043      /**
044       * No locking is done. This means sessions are prone to loss of data due to
045       * race conditions of concurrent requests to the same session. The last session
046       * write will win in this case. It might be useful when you implement your own
047       * logic to deal with this like an optimistic approach.
048       */
049      const LOCK_NONE = 0;
050   
051      /**
052       * Creates an application-level lock on a session. The disadvantage is that the
053       * lock is not enforced by the database and thus other, unaware parts of the
054       * application could still concurrently modify the session. The advantage is it
055       * does not require a transaction.
056       * This mode is not available for SQLite and not yet implemented for oci and sqlsrv.
057       */
058      const LOCK_ADVISORY = 1;
059   
060      /**
061       * Issues a real row lock. Since it uses a transaction between opening and
062       * closing a session, you have to be careful when you use same database connection
063       * that you also use for your application logic. This mode is the default because
064       * it's the only reliable solution across DBMSs.
065       */
066      const LOCK_TRANSACTIONAL = 2;
067   
068      /**
069       * @var \PDO|null PDO instance or null when not connected yet
070       */
071      private $pdo;
072   
073      /**
074       * @var string|false|null DSN string or null for session.save_path or false when lazy connection disabled
075       */
076      private $dsn = false;
077   
078      /**
079       * @var string Database driver
080       */
081      private $driver;
082   
083      /**
084       * @var string Table name
085       */
086      private $table = 'sessions';
087   
088      /**
089       * @var string Column for session id
090       */
091      private $idCol = 'sess_id';
092   
093      /**
094       * @var string Column for session data
095       */
096      private $dataCol = 'sess_data';
097   
098      /**
099       * @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 = [];
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 = [];
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       * You can either pass an existing database connection as PDO instance or
152       * pass a DSN string that will be used to lazy-connect to the database
153       * when the session is actually used. Furthermore it's possible to pass null
154       * which will then use the session.save_path ini setting as PDO DSN parameter.
155       *
156       * List of available options:
157       *  * db_table: The name of the table [default: sessions]
158       *  * db_id_col: The column where to store the session id [default: sess_id]
159       *  * db_data_col: The column where to store the session data [default: sess_data]
160       *  * db_lifetime_col: The column where to store the lifetime [default: sess_lifetime]
161       *  * db_time_col: The column where to store the timestamp [default: sess_time]
162       *  * db_username: The username when lazy-connect [default: '']
163       *  * db_password: The password when lazy-connect [default: '']
164       *  * db_connection_options: An array of driver-specific connection options [default: []]
165       *  * lock_mode: The strategy for locking, see constants [default: LOCK_TRANSACTIONAL]
166       *
167       * @param \PDO|string|null $pdoOrDsn A \PDO instance or DSN string or URL string or null
168       * @param array            $options  An associative array of options
169       *
170       * @throws \InvalidArgumentException When PDO error mode is not PDO::ERRMODE_EXCEPTION
171       */
172      public function __construct($pdoOrDsn = null, array $options = [])
173      {
174          if ($pdoOrDsn instanceof \PDO) {
175              if (\PDO::ERRMODE_EXCEPTION !== $pdoOrDsn->getAttribute(\PDO::ATTR_ERRMODE)) {
176                  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__));
177              }
178   
179              $this->pdo = $pdoOrDsn;
180              $this->driver = $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME);
181          } elseif (\is_string($pdoOrDsn) && false !== strpos($pdoOrDsn, '://')) {
182              $this->dsn = $this->buildDsnFromUrl($pdoOrDsn);
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 INTEGER UNSIGNED NOT NULL, $this->timeCol INTEGER UNSIGNED NOT NULL) COLLATE utf8mb4_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          $this->sessionExpired = false;
266   
267          if (null === $this->pdo) {
268              $this->connect($this->dsn ?: $savePath);
269          }
270   
271          return parent::open($savePath, $sessionName);
272      }
273   
274      /**
275       * {@inheritdoc}
276       */
277      public function read($sessionId)
278      {
279          try {
280              return parent::read($sessionId);
281          } catch (\PDOException $e) {
282              $this->rollback();
283   
284              throw $e;
285          }
286      }
287   
288      /**
289       * @return bool
290       */
291      public function gc($maxlifetime)
292      {
293          // We delay gc() to close() so that it is executed outside the transactional and blocking read-write process.
294          // This way, pruning expired sessions does not block them from being started while the current session is used.
295          $this->gcCalled = true;
296   
297          return true;
298      }
299   
300      /**
301       * {@inheritdoc}
302       */
303      protected function doDestroy($sessionId)
304      {
305          // delete the record associated with this id
306          $sql = "DELETE FROM $this->table WHERE $this->idCol = :id";
307   
308          try {
309              $stmt = $this->pdo->prepare($sql);
310              $stmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
311              $stmt->execute();
312          } catch (\PDOException $e) {
313              $this->rollback();
314   
315              throw $e;
316          }
317   
318          return true;
319      }
320   
321      /**
322       * {@inheritdoc}
323       */
324      protected function doWrite($sessionId, $data)
325      {
326          $maxlifetime = (int) ini_get('session.gc_maxlifetime');
327   
328          try {
329              // We use a single MERGE SQL query when supported by the database.
330              $mergeStmt = $this->getMergeStatement($sessionId, $data, $maxlifetime);
331              if (null !== $mergeStmt) {
332                  $mergeStmt->execute();
333   
334                  return true;
335              }
336   
337              $updateStmt = $this->getUpdateStatement($sessionId, $data, $maxlifetime);
338              $updateStmt->execute();
339   
340              // When MERGE is not supported, like in Postgres < 9.5, we have to use this approach that can result in
341              // duplicate key errors when the same session is written simultaneously (given the LOCK_NONE behavior).
342              // We can just catch such an error and re-execute the update. This is similar to a serializable
343              // transaction with retry logic on serialization failures but without the overhead and without possible
344              // false positives due to longer gap locking.
345              if (!$updateStmt->rowCount()) {
346                  try {
347                      $insertStmt = $this->getInsertStatement($sessionId, $data, $maxlifetime);
348                      $insertStmt->execute();
349                  } catch (\PDOException $e) {
350                      // Handle integrity violation SQLSTATE 23000 (or a subclass like 23505 in Postgres) for duplicate keys
351                      if (0 === strpos($e->getCode(), '23')) {
352                          $updateStmt->execute();
353                      } else {
354                          throw $e;
355                      }
356                  }
357              }
358          } catch (\PDOException $e) {
359              $this->rollback();
360   
361              throw $e;
362          }
363   
364          return true;
365      }
366   
367      /**
368       * {@inheritdoc}
369       */
370      public function updateTimestamp($sessionId, $data)
371      {
372          $maxlifetime = (int) ini_get('session.gc_maxlifetime');
373   
374          try {
375              $updateStmt = $this->pdo->prepare(
376                  "UPDATE $this->table SET $this->lifetimeCol = :lifetime, $this->timeCol = :time WHERE $this->idCol = :id"
377              );
378              $updateStmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
379              $updateStmt->bindParam(':lifetime', $maxlifetime, \PDO::PARAM_INT);
380              $updateStmt->bindValue(':time', time(), \PDO::PARAM_INT);
381              $updateStmt->execute();
382          } catch (\PDOException $e) {
383              $this->rollback();
384   
385              throw $e;
386          }
387   
388          return true;
389      }
390   
391      /**
392       * {@inheritdoc}
393       */
394      public function close()
395      {
396          $this->commit();
397   
398          while ($unlockStmt = array_shift($this->unlockStatements)) {
399              $unlockStmt->execute();
400          }
401   
402          if ($this->gcCalled) {
403              $this->gcCalled = false;
404   
405              // delete the session records that have expired
406              if ('mysql' === $this->driver) {
407                  $sql = "DELETE FROM $this->table WHERE $this->lifetimeCol + $this->timeCol < :time";
408              } else {
409                  $sql = "DELETE FROM $this->table WHERE $this->lifetimeCol < :time - $this->timeCol";
410              }
411   
412              $stmt = $this->pdo->prepare($sql);
413              $stmt->bindValue(':time', time(), \PDO::PARAM_INT);
414              $stmt->execute();
415          }
416   
417          if (false !== $this->dsn) {
418              $this->pdo = null; // only close lazy-connection
419          }
420   
421          return true;
422      }
423   
424      /**
425       * Lazy-connects to the database.
426       *
427       * @param string $dsn DSN string
428       */
429      private function connect($dsn)
430      {
431          $this->pdo = new \PDO($dsn, $this->username, $this->password, $this->connectionOptions);
432          $this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
433          $this->driver = $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME);
434      }
435   
436      /**
437       * Builds a PDO DSN from a URL-like connection string.
438       *
439       * @param string $dsnOrUrl
440       *
441       * @return string
442       *
443       * @todo implement missing support for oci DSN (which look totally different from other PDO ones)
444       */
445      private function buildDsnFromUrl($dsnOrUrl)
446      {
447          // (pdo_)?sqlite3?:///... => (pdo_)?sqlite3?://localhost/... or else the URL will be invalid
448          $url = preg_replace('#^((?:pdo_)?sqlite3?):///#', '$1://localhost/', $dsnOrUrl);
449   
450          $params = parse_url($url);
451   
452          if (false === $params) {
453              return $dsnOrUrl; // If the URL is not valid, let's assume it might be a DSN already.
454          }
455   
456          $params = array_map('rawurldecode', $params);
457   
458          // Override the default username and password. Values passed through options will still win over these in the constructor.
459          if (isset($params['user'])) {
460              $this->username = $params['user'];
461          }
462   
463          if (isset($params['pass'])) {
464              $this->password = $params['pass'];
465          }
466   
467          if (!isset($params['scheme'])) {
468              throw new \InvalidArgumentException('URLs without scheme are not supported to configure the PdoSessionHandler.');
469          }
470   
471          $driverAliasMap = [
472              'mssql' => 'sqlsrv',
473              'mysql2' => 'mysql', // Amazon RDS, for some weird reason
474              'postgres' => 'pgsql',
475              'postgresql' => 'pgsql',
476              'sqlite3' => 'sqlite',
477          ];
478   
479          $driver = isset($driverAliasMap[$params['scheme']]) ? $driverAliasMap[$params['scheme']] : $params['scheme'];
480   
481          // Doctrine DBAL supports passing its internal pdo_* driver names directly too (allowing both dashes and underscores). This allows supporting the same here.
482          if (0 === strpos($driver, 'pdo_') || 0 === strpos($driver, 'pdo-')) {
483              $driver = substr($driver, 4);
484          }
485   
486          switch ($driver) {
487              case 'mysql':
488              case 'pgsql':
489                  $dsn = $driver.':';
490   
491                  if (isset($params['host']) && '' !== $params['host']) {
492                      $dsn .= 'host='.$params['host'].';';
493                  }
494   
495                  if (isset($params['port']) && '' !== $params['port']) {
496                      $dsn .= 'port='.$params['port'].';';
497                  }
498   
499                  if (isset($params['path'])) {
500                      $dbName = substr($params['path'], 1); // Remove the leading slash
501                      $dsn .= 'dbname='.$dbName.';';
502                  }
503   
504                  return $dsn;
505   
506              case 'sqlite':
507                  return 'sqlite:'.substr($params['path'], 1);
508   
509              case 'sqlsrv':
510                  $dsn = 'sqlsrv:server=';
511   
512                  if (isset($params['host'])) {
513                      $dsn .= $params['host'];
514                  }
515   
516                  if (isset($params['port']) && '' !== $params['port']) {
517                      $dsn .= ','.$params['port'];
518                  }
519   
520                  if (isset($params['path'])) {
521                      $dbName = substr($params['path'], 1); // Remove the leading slash
522                      $dsn .= ';Database='.$dbName;
523                  }
524   
525                  return $dsn;
526   
527              default:
528                  throw new \InvalidArgumentException(sprintf('The scheme "%s" is not supported by the PdoSessionHandler URL configuration. Pass a PDO DSN directly.', $params['scheme']));
529          }
530      }
531   
532      /**
533       * Helper method to begin a transaction.
534       *
535       * Since SQLite does not support row level locks, we have to acquire a reserved lock
536       * on the database immediately. Because of https://bugs.php.net/42766 we have to create
537       * such a transaction manually which also means we cannot use PDO::commit or
538       * PDO::rollback or PDO::inTransaction for SQLite.
539       *
540       * Also MySQLs default isolation, REPEATABLE READ, causes deadlock for different sessions
541       * due to https://percona.com/blog/2013/12/12/one-more-innodb-gap-lock-to-avoid/ .
542       * So we change it to READ COMMITTED.
543       */
544      private function beginTransaction()
545      {
546          if (!$this->inTransaction) {
547              if ('sqlite' === $this->driver) {
548                  $this->pdo->exec('BEGIN IMMEDIATE TRANSACTION');
549              } else {
550                  if ('mysql' === $this->driver) {
551                      $this->pdo->exec('SET TRANSACTION ISOLATION LEVEL READ COMMITTED');
552                  }
553                  $this->pdo->beginTransaction();
554              }
555              $this->inTransaction = true;
556          }
557      }
558   
559      /**
560       * Helper method to commit a transaction.
561       */
562      private function commit()
563      {
564          if ($this->inTransaction) {
565              try {
566                  // commit read-write transaction which also releases the lock
567                  if ('sqlite' === $this->driver) {
568                      $this->pdo->exec('COMMIT');
569                  } else {
570                      $this->pdo->commit();
571                  }
572                  $this->inTransaction = false;
573              } catch (\PDOException $e) {
574                  $this->rollback();
575   
576                  throw $e;
577              }
578          }
579      }
580   
581      /**
582       * Helper method to rollback a transaction.
583       */
584      private function rollback()
585      {
586          // We only need to rollback if we are in a transaction. Otherwise the resulting
587          // error would hide the real problem why rollback was called. We might not be
588          // in a transaction when not using the transactional locking behavior or when
589          // two callbacks (e.g. destroy and write) are invoked that both fail.
590          if ($this->inTransaction) {
591              if ('sqlite' === $this->driver) {
592                  $this->pdo->exec('ROLLBACK');
593              } else {
594                  $this->pdo->rollBack();
595              }
596              $this->inTransaction = false;
597          }
598      }
599   
600      /**
601       * Reads the session data in respect to the different locking strategies.
602       *
603       * We need to make sure we do not return session data that is already considered garbage according
604       * to the session.gc_maxlifetime setting because gc() is called after read() and only sometimes.
605       *
606       * @param string $sessionId Session ID
607       *
608       * @return string The session data
609       */
610      protected function doRead($sessionId)
611      {
612          if (self::LOCK_ADVISORY === $this->lockMode) {
613              $this->unlockStatements[] = $this->doAdvisoryLock($sessionId);
614          }
615   
616          $selectSql = $this->getSelectSql();
617          $selectStmt = $this->pdo->prepare($selectSql);
618          $selectStmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
619          $insertStmt = null;
620   
621          do {
622              $selectStmt->execute();
623              $sessionRows = $selectStmt->fetchAll(\PDO::FETCH_NUM);
624   
625              if ($sessionRows) {
626                  if ($sessionRows[0][1] + $sessionRows[0][2] < time()) {
627                      $this->sessionExpired = true;
628   
629                      return '';
630                  }
631   
632                  return \is_resource($sessionRows[0][0]) ? stream_get_contents($sessionRows[0][0]) : $sessionRows[0][0];
633              }
634   
635              if (null !== $insertStmt) {
636                  $this->rollback();
637                  throw new \RuntimeException('Failed to read session: INSERT reported a duplicate id but next SELECT did not return any data.');
638              }
639   
640              if (!filter_var(ini_get('session.use_strict_mode'), \FILTER_VALIDATE_BOOLEAN) && self::LOCK_TRANSACTIONAL === $this->lockMode && 'sqlite' !== $this->driver) {
641                  // In strict mode, session fixation is not possible: new sessions always start with a unique
642                  // random id, so that concurrency is not possible and this code path can be skipped.
643                  // Exclusive-reading of non-existent rows does not block, so we need to do an insert to block
644                  // until other connections to the session are committed.
645                  try {
646                      $insertStmt = $this->getInsertStatement($sessionId, '', 0);
647                      $insertStmt->execute();
648                  } catch (\PDOException $e) {
649                      // Catch duplicate key error because other connection created the session already.
650                      // It would only not be the case when the other connection destroyed the session.
651                      if (0 === strpos($e->getCode(), '23')) {
652                          // Retrieve finished session data written by concurrent connection by restarting the loop.
653                          // We have to start a new transaction as a failed query will mark the current transaction as
654                          // aborted in PostgreSQL and disallow further queries within it.
655                          $this->rollback();
656                          $this->beginTransaction();
657                          continue;
658                      }
659   
660                      throw $e;
661                  }
662              }
663   
664              return '';
665          } while (true);
666      }
667   
668      /**
669       * Executes an application-level lock on the database.
670       *
671       * @param string $sessionId Session ID
672       *
673       * @return \PDOStatement The statement that needs to be executed later to release the lock
674       *
675       * @throws \DomainException When an unsupported PDO driver is used
676       *
677       * @todo implement missing advisory locks
678       *       - for oci using DBMS_LOCK.REQUEST
679       *       - for sqlsrv using sp_getapplock with LockOwner = Session
680       */
681      private function doAdvisoryLock($sessionId)
682      {
683          switch ($this->driver) {
684              case 'mysql':
685                  // MySQL 5.7.5 and later enforces a maximum length on lock names of 64 characters. Previously, no limit was enforced.
686                  $lockId = substr($sessionId, 0, 64);
687                  // should we handle the return value? 0 on timeout, null on error
688                  // we use a timeout of 50 seconds which is also the default for innodb_lock_wait_timeout
689                  $stmt = $this->pdo->prepare('SELECT GET_LOCK(:key, 50)');
690                  $stmt->bindValue(':key', $lockId, \PDO::PARAM_STR);
691                  $stmt->execute();
692   
693                  $releaseStmt = $this->pdo->prepare('DO RELEASE_LOCK(:key)');
694                  $releaseStmt->bindValue(':key', $lockId, \PDO::PARAM_STR);
695   
696                  return $releaseStmt;
697              case 'pgsql':
698                  // Obtaining an exclusive session level advisory lock requires an integer key.
699                  // When session.sid_bits_per_character > 4, the session id can contain non-hex-characters.
700                  // So we cannot just use hexdec().
701                  if (4 === \PHP_INT_SIZE) {
702                      $sessionInt1 = $this->convertStringToInt($sessionId);
703                      $sessionInt2 = $this->convertStringToInt(substr($sessionId, 4, 4));
704   
705                      $stmt = $this->pdo->prepare('SELECT pg_advisory_lock(:key1, :key2)');
706                      $stmt->bindValue(':key1', $sessionInt1, \PDO::PARAM_INT);
707                      $stmt->bindValue(':key2', $sessionInt2, \PDO::PARAM_INT);
708                      $stmt->execute();
709   
710                      $releaseStmt = $this->pdo->prepare('SELECT pg_advisory_unlock(:key1, :key2)');
711                      $releaseStmt->bindValue(':key1', $sessionInt1, \PDO::PARAM_INT);
712                      $releaseStmt->bindValue(':key2', $sessionInt2, \PDO::PARAM_INT);
713                  } else {
714                      $sessionBigInt = $this->convertStringToInt($sessionId);
715   
716                      $stmt = $this->pdo->prepare('SELECT pg_advisory_lock(:key)');
717                      $stmt->bindValue(':key', $sessionBigInt, \PDO::PARAM_INT);
718                      $stmt->execute();
719   
720                      $releaseStmt = $this->pdo->prepare('SELECT pg_advisory_unlock(:key)');
721                      $releaseStmt->bindValue(':key', $sessionBigInt, \PDO::PARAM_INT);
722                  }
723   
724                  return $releaseStmt;
725              case 'sqlite':
726                  throw new \DomainException('SQLite does not support advisory locks.');
727              default:
728                  throw new \DomainException(sprintf('Advisory locks are currently not implemented for PDO driver "%s".', $this->driver));
729          }
730      }
731   
732      /**
733       * Encodes the first 4 (when PHP_INT_SIZE == 4) or 8 characters of the string as an integer.
734       *
735       * Keep in mind, PHP integers are signed.
736       *
737       * @param string $string
738       *
739       * @return int
740       */
741      private function convertStringToInt($string)
742      {
743          if (4 === \PHP_INT_SIZE) {
744              return (\ord($string[3]) << 24) + (\ord($string[2]) << 16) + (\ord($string[1]) << 8) + \ord($string[0]);
745          }
746   
747          $int1 = (\ord($string[7]) << 24) + (\ord($string[6]) << 16) + (\ord($string[5]) << 8) + \ord($string[4]);
748          $int2 = (\ord($string[3]) << 24) + (\ord($string[2]) << 16) + (\ord($string[1]) << 8) + \ord($string[0]);
749   
750          return $int2 + ($int1 << 32);
751      }
752   
753      /**
754       * Return a locking or nonlocking SQL query to read session information.
755       *
756       * @return string The SQL string
757       *
758       * @throws \DomainException When an unsupported PDO driver is used
759       */
760      private function getSelectSql()
761      {
762          if (self::LOCK_TRANSACTIONAL === $this->lockMode) {
763              $this->beginTransaction();
764   
765              switch ($this->driver) {
766                  case 'mysql':
767                  case 'oci':
768                  case 'pgsql':
769                      return "SELECT $this->dataCol$this->lifetimeCol$this->timeCol FROM $this->table WHERE $this->idCol = :id FOR UPDATE";
770                  case 'sqlsrv':
771                      return "SELECT $this->dataCol$this->lifetimeCol$this->timeCol FROM $this->table WITH (UPDLOCK, ROWLOCK) WHERE $this->idCol = :id";
772                  case 'sqlite':
773                      // we already locked when starting transaction
774                      break;
775                  default:
776                      throw new \DomainException(sprintf('Transactional locks are currently not implemented for PDO driver "%s".', $this->driver));
777              }
778          }
779   
780          return "SELECT $this->dataCol$this->lifetimeCol$this->timeCol FROM $this->table WHERE $this->idCol = :id";
781      }
782   
783      /**
784       * Returns an insert statement supported by the database for writing session data.
785       *
786       * @param string $sessionId   Session ID
787       * @param string $sessionData Encoded session data
788       * @param int    $maxlifetime session.gc_maxlifetime
789       *
790       * @return \PDOStatement The insert statement
791       */
792      private function getInsertStatement($sessionId, $sessionData, $maxlifetime)
793      {
794          switch ($this->driver) {
795              case 'oci':
796                  $data = fopen('php://memory', 'r+');
797                  fwrite($data, $sessionData);
798                  rewind($data);
799                  $sql = "INSERT INTO $this->table ($this->idCol$this->dataCol$this->lifetimeCol$this->timeCol) VALUES (:id, EMPTY_BLOB(), :lifetime, :time) RETURNING $this->dataCol into :data";
800                  break;
801              default:
802                  $data = $sessionData;
803                  $sql = "INSERT INTO $this->table ($this->idCol$this->dataCol$this->lifetimeCol$this->timeCol) VALUES (:id, :data, :lifetime, :time)";
804                  break;
805          }
806   
807          $stmt = $this->pdo->prepare($sql);
808          $stmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
809          $stmt->bindParam(':data', $data, \PDO::PARAM_LOB);
810          $stmt->bindParam(':lifetime', $maxlifetime, \PDO::PARAM_INT);
811          $stmt->bindValue(':time', time(), \PDO::PARAM_INT);
812   
813          return $stmt;
814      }
815   
816      /**
817       * Returns an update statement supported by the database for writing session data.
818       *
819       * @param string $sessionId   Session ID
820       * @param string $sessionData Encoded session data
821       * @param int    $maxlifetime session.gc_maxlifetime
822       *
823       * @return \PDOStatement The update statement
824       */
825      private function getUpdateStatement($sessionId, $sessionData, $maxlifetime)
826      {
827          switch ($this->driver) {
828              case 'oci':
829                  $data = fopen('php://memory', 'r+');
830                  fwrite($data, $sessionData);
831                  rewind($data);
832                  $sql = "UPDATE $this->table SET $this->dataCol = EMPTY_BLOB(), $this->lifetimeCol = :lifetime, $this->timeCol = :time WHERE $this->idCol = :id RETURNING $this->dataCol into :data";
833                  break;
834              default:
835                  $data = $sessionData;
836                  $sql = "UPDATE $this->table SET $this->dataCol = :data, $this->lifetimeCol = :lifetime, $this->timeCol = :time WHERE $this->idCol = :id";
837                  break;
838          }
839   
840          $stmt = $this->pdo->prepare($sql);
841          $stmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
842          $stmt->bindParam(':data', $data, \PDO::PARAM_LOB);
843          $stmt->bindParam(':lifetime', $maxlifetime, \PDO::PARAM_INT);
844          $stmt->bindValue(':time', time(), \PDO::PARAM_INT);
845   
846          return $stmt;
847      }
848   
849      /**
850       * Returns a merge/upsert (i.e. insert or update) statement when supported by the database for writing session data.
851       *
852       * @param string $sessionId   Session ID
853       * @param string $data        Encoded session data
854       * @param int    $maxlifetime session.gc_maxlifetime
855       *
856       * @return \PDOStatement|null The merge statement or null when not supported
857       */
858      private function getMergeStatement($sessionId, $data, $maxlifetime)
859      {
860          switch (true) {
861              case 'mysql' === $this->driver:
862                  $mergeSql = "INSERT INTO $this->table ($this->idCol$this->dataCol$this->lifetimeCol$this->timeCol) VALUES (:id, :data, :lifetime, :time) ".
863                      "ON DUPLICATE KEY UPDATE $this->dataCol = VALUES($this->dataCol), $this->lifetimeCol = VALUES($this->lifetimeCol), $this->timeCol = VALUES($this->timeCol)";
864                  break;
865              case 'sqlsrv' === $this->driver && version_compare($this->pdo->getAttribute(\PDO::ATTR_SERVER_VERSION), '10', '>='):
866                  // MERGE is only available since SQL Server 2008 and must be terminated by semicolon
867                  // It also requires HOLDLOCK according to https://weblogs.sqlteam.com/dang/2009/01/31/upsert-race-condition-with-merge/
868                  $mergeSql = "MERGE INTO $this->table WITH (HOLDLOCK) USING (SELECT 1 AS dummy) AS src ON ($this->idCol = ?) ".
869                      "WHEN NOT MATCHED THEN INSERT ($this->idCol$this->dataCol$this->lifetimeCol$this->timeCol) VALUES (?, ?, ?, ?) ".
870                      "WHEN MATCHED THEN UPDATE SET $this->dataCol = ?, $this->lifetimeCol = ?, $this->timeCol = ?;";
871                  break;
872              case 'sqlite' === $this->driver:
873                  $mergeSql = "INSERT OR REPLACE INTO $this->table ($this->idCol$this->dataCol$this->lifetimeCol$this->timeCol) VALUES (:id, :data, :lifetime, :time)";
874                  break;
875              case 'pgsql' === $this->driver && version_compare($this->pdo->getAttribute(\PDO::ATTR_SERVER_VERSION), '9.5', '>='):
876                  $mergeSql = "INSERT INTO $this->table ($this->idCol$this->dataCol$this->lifetimeCol$this->timeCol) VALUES (:id, :data, :lifetime, :time) ".
877                      "ON CONFLICT ($this->idCol) DO UPDATE SET ($this->dataCol$this->lifetimeCol$this->timeCol) = (EXCLUDED.$this->dataCol, EXCLUDED.$this->lifetimeCol, EXCLUDED.$this->timeCol)";
878                  break;
879              default:
880                  // MERGE is not supported with LOBs: https://oracle.com/technetwork/articles/fuecks-lobs-095315.html
881                  return null;
882          }
883   
884          $mergeStmt = $this->pdo->prepare($mergeSql);
885   
886          if ('sqlsrv' === $this->driver) {
887              $mergeStmt->bindParam(1, $sessionId, \PDO::PARAM_STR);
888              $mergeStmt->bindParam(2, $sessionId, \PDO::PARAM_STR);
889              $mergeStmt->bindParam(3, $data, \PDO::PARAM_LOB);
890              $mergeStmt->bindParam(4, $maxlifetime, \PDO::PARAM_INT);
891              $mergeStmt->bindValue(5, time(), \PDO::PARAM_INT);
892              $mergeStmt->bindParam(6, $data, \PDO::PARAM_LOB);
893              $mergeStmt->bindParam(7, $maxlifetime, \PDO::PARAM_INT);
894              $mergeStmt->bindValue(8, time(), \PDO::PARAM_INT);
895          } else {
896              $mergeStmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
897              $mergeStmt->bindParam(':data', $data, \PDO::PARAM_LOB);
898              $mergeStmt->bindParam(':lifetime', $maxlifetime, \PDO::PARAM_INT);
899              $mergeStmt->bindValue(':time', time(), \PDO::PARAM_INT);
900          }
901   
902          return $mergeStmt;
903      }
904   
905      /**
906       * Return a PDO instance.
907       *
908       * @return \PDO
909       */
910      protected function getConnection()
911      {
912          if (null === $this->pdo) {
913              $this->connect($this->dsn ?: ini_get('session.save_path'));
914          }
915   
916          return $this->pdo;
917      }
918  }
919