vendor/symfony/http-foundation/Session/Storage/Handler/PdoSessionHandler.php line 438

Open in your IDE?
  1. <?php
  2. /*
  3.  * This file is part of the Symfony package.
  4.  *
  5.  * (c) Fabien Potencier <fabien@symfony.com>
  6.  *
  7.  * For the full copyright and license information, please view the LICENSE
  8.  * file that was distributed with this source code.
  9.  */
  10. namespace Symfony\Component\HttpFoundation\Session\Storage\Handler;
  11. /**
  12.  * Session handler using a PDO connection to read and write data.
  13.  *
  14.  * It works with MySQL, PostgreSQL, Oracle, SQL Server and SQLite and implements
  15.  * different locking strategies to handle concurrent access to the same session.
  16.  * Locking is necessary to prevent loss of data due to race conditions and to keep
  17.  * the session data consistent between read() and write(). With locking, requests
  18.  * for the same session will wait until the other one finished writing. For this
  19.  * reason it's best practice to close a session as early as possible to improve
  20.  * concurrency. PHPs internal files session handler also implements locking.
  21.  *
  22.  * Attention: Since SQLite does not support row level locks but locks the whole database,
  23.  * it means only one session can be accessed at a time. Even different sessions would wait
  24.  * for another to finish. So saving session in SQLite should only be considered for
  25.  * development or prototypes.
  26.  *
  27.  * Session data is a binary string that can contain non-printable characters like the null byte.
  28.  * For this reason it must be saved in a binary column in the database like BLOB in MySQL.
  29.  * Saving it in a character column could corrupt the data. You can use createTable()
  30.  * to initialize a correctly defined table.
  31.  *
  32.  * @see https://php.net/sessionhandlerinterface
  33.  *
  34.  * @author Fabien Potencier <fabien@symfony.com>
  35.  * @author Michael Williams <michael.williams@funsational.com>
  36.  * @author Tobias Schultze <http://tobion.de>
  37.  */
  38. class PdoSessionHandler extends AbstractSessionHandler
  39. {
  40.     /**
  41.      * No locking is done. This means sessions are prone to loss of data due to
  42.      * race conditions of concurrent requests to the same session. The last session
  43.      * write will win in this case. It might be useful when you implement your own
  44.      * logic to deal with this like an optimistic approach.
  45.      */
  46.     const LOCK_NONE 0;
  47.     /**
  48.      * Creates an application-level lock on a session. The disadvantage is that the
  49.      * lock is not enforced by the database and thus other, unaware parts of the
  50.      * application could still concurrently modify the session. The advantage is it
  51.      * does not require a transaction.
  52.      * This mode is not available for SQLite and not yet implemented for oci and sqlsrv.
  53.      */
  54.     const LOCK_ADVISORY 1;
  55.     /**
  56.      * Issues a real row lock. Since it uses a transaction between opening and
  57.      * closing a session, you have to be careful when you use same database connection
  58.      * that you also use for your application logic. This mode is the default because
  59.      * it's the only reliable solution across DBMSs.
  60.      */
  61.     const LOCK_TRANSACTIONAL 2;
  62.     private const MAX_LIFETIME 315576000;
  63.     /**
  64.      * @var \PDO|null PDO instance or null when not connected yet
  65.      */
  66.     private $pdo;
  67.     /**
  68.      * @var string|false|null DSN string or null for session.save_path or false when lazy connection disabled
  69.      */
  70.     private $dsn false;
  71.     /**
  72.      * @var string Database driver
  73.      */
  74.     private $driver;
  75.     /**
  76.      * @var string Table name
  77.      */
  78.     private $table 'sessions';
  79.     /**
  80.      * @var string Column for session id
  81.      */
  82.     private $idCol 'sess_id';
  83.     /**
  84.      * @var string Column for session data
  85.      */
  86.     private $dataCol 'sess_data';
  87.     /**
  88.      * @var string Column for lifetime
  89.      */
  90.     private $lifetimeCol 'sess_lifetime';
  91.     /**
  92.      * @var string Column for timestamp
  93.      */
  94.     private $timeCol 'sess_time';
  95.     /**
  96.      * @var string Username when lazy-connect
  97.      */
  98.     private $username '';
  99.     /**
  100.      * @var string Password when lazy-connect
  101.      */
  102.     private $password '';
  103.     /**
  104.      * @var array Connection options when lazy-connect
  105.      */
  106.     private $connectionOptions = [];
  107.     /**
  108.      * @var int The strategy for locking, see constants
  109.      */
  110.     private $lockMode self::LOCK_TRANSACTIONAL;
  111.     /**
  112.      * It's an array to support multiple reads before closing which is manual, non-standard usage.
  113.      *
  114.      * @var \PDOStatement[] An array of statements to release advisory locks
  115.      */
  116.     private $unlockStatements = [];
  117.     /**
  118.      * @var bool True when the current session exists but expired according to session.gc_maxlifetime
  119.      */
  120.     private $sessionExpired false;
  121.     /**
  122.      * @var bool Whether a transaction is active
  123.      */
  124.     private $inTransaction false;
  125.     /**
  126.      * @var bool Whether gc() has been called
  127.      */
  128.     private $gcCalled false;
  129.     /**
  130.      * You can either pass an existing database connection as PDO instance or
  131.      * pass a DSN string that will be used to lazy-connect to the database
  132.      * when the session is actually used. Furthermore it's possible to pass null
  133.      * which will then use the session.save_path ini setting as PDO DSN parameter.
  134.      *
  135.      * List of available options:
  136.      *  * db_table: The name of the table [default: sessions]
  137.      *  * db_id_col: The column where to store the session id [default: sess_id]
  138.      *  * db_data_col: The column where to store the session data [default: sess_data]
  139.      *  * db_lifetime_col: The column where to store the lifetime [default: sess_lifetime]
  140.      *  * db_time_col: The column where to store the timestamp [default: sess_time]
  141.      *  * db_username: The username when lazy-connect [default: '']
  142.      *  * db_password: The password when lazy-connect [default: '']
  143.      *  * db_connection_options: An array of driver-specific connection options [default: []]
  144.      *  * lock_mode: The strategy for locking, see constants [default: LOCK_TRANSACTIONAL]
  145.      *
  146.      * @param \PDO|string|null $pdoOrDsn A \PDO instance or DSN string or URL string or null
  147.      *
  148.      * @throws \InvalidArgumentException When PDO error mode is not PDO::ERRMODE_EXCEPTION
  149.      */
  150.     public function __construct($pdoOrDsn null, array $options = [])
  151.     {
  152.         if ($pdoOrDsn instanceof \PDO) {
  153.             if (\PDO::ERRMODE_EXCEPTION !== $pdoOrDsn->getAttribute(\PDO::ATTR_ERRMODE)) {
  154.                 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__));
  155.             }
  156.             $this->pdo $pdoOrDsn;
  157.             $this->driver $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME);
  158.         } elseif (\is_string($pdoOrDsn) && false !== strpos($pdoOrDsn'://')) {
  159.             $this->dsn $this->buildDsnFromUrl($pdoOrDsn);
  160.         } else {
  161.             $this->dsn $pdoOrDsn;
  162.         }
  163.         $this->table = isset($options['db_table']) ? $options['db_table'] : $this->table;
  164.         $this->idCol = isset($options['db_id_col']) ? $options['db_id_col'] : $this->idCol;
  165.         $this->dataCol = isset($options['db_data_col']) ? $options['db_data_col'] : $this->dataCol;
  166.         $this->lifetimeCol = isset($options['db_lifetime_col']) ? $options['db_lifetime_col'] : $this->lifetimeCol;
  167.         $this->timeCol = isset($options['db_time_col']) ? $options['db_time_col'] : $this->timeCol;
  168.         $this->username = isset($options['db_username']) ? $options['db_username'] : $this->username;
  169.         $this->password = isset($options['db_password']) ? $options['db_password'] : $this->password;
  170.         $this->connectionOptions = isset($options['db_connection_options']) ? $options['db_connection_options'] : $this->connectionOptions;
  171.         $this->lockMode = isset($options['lock_mode']) ? $options['lock_mode'] : $this->lockMode;
  172.     }
  173.     /**
  174.      * Creates the table to store sessions which can be called once for setup.
  175.      *
  176.      * Session ID is saved in a column of maximum length 128 because that is enough even
  177.      * for a 512 bit configured session.hash_function like Whirlpool. Session data is
  178.      * saved in a BLOB. One could also use a shorter inlined varbinary column
  179.      * if one was sure the data fits into it.
  180.      *
  181.      * @throws \PDOException    When the table already exists
  182.      * @throws \DomainException When an unsupported PDO driver is used
  183.      */
  184.     public function createTable()
  185.     {
  186.         // connect if we are not yet
  187.         $this->getConnection();
  188.         switch ($this->driver) {
  189.             case 'mysql':
  190.                 // We use varbinary for the ID column because it prevents unwanted conversions:
  191.                 // - character set conversions between server and client
  192.                 // - trailing space removal
  193.                 // - case-insensitivity
  194.                 // - language processing like é == e
  195.                 $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 utf8_bin, ENGINE = InnoDB";
  196.                 break;
  197.             case 'sqlite':
  198.                 $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)";
  199.                 break;
  200.             case 'pgsql':
  201.                 $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)";
  202.                 break;
  203.             case 'oci':
  204.                 $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)";
  205.                 break;
  206.             case 'sqlsrv':
  207.                 $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)";
  208.                 break;
  209.             default:
  210.                 throw new \DomainException(sprintf('Creating the session table is currently not implemented for PDO driver "%s".'$this->driver));
  211.         }
  212.         try {
  213.             $this->pdo->exec($sql);
  214.             $this->pdo->exec("CREATE INDEX EXPIRY ON $this->table ($this->lifetimeCol)");
  215.         } catch (\PDOException $e) {
  216.             $this->rollback();
  217.             throw $e;
  218.         }
  219.     }
  220.     /**
  221.      * Returns true when the current session exists but expired according to session.gc_maxlifetime.
  222.      *
  223.      * Can be used to distinguish between a new session and one that expired due to inactivity.
  224.      *
  225.      * @return bool Whether current session expired
  226.      */
  227.     public function isSessionExpired()
  228.     {
  229.         return $this->sessionExpired;
  230.     }
  231.     /**
  232.      * @return bool
  233.      */
  234.     public function open($savePath$sessionName)
  235.     {
  236.         $this->sessionExpired false;
  237.         if (null === $this->pdo) {
  238.             $this->connect($this->dsn ?: $savePath);
  239.         }
  240.         return parent::open($savePath$sessionName);
  241.     }
  242.     /**
  243.      * @return string
  244.      */
  245.     public function read($sessionId)
  246.     {
  247.         try {
  248.             return parent::read($sessionId);
  249.         } catch (\PDOException $e) {
  250.             $this->rollback();
  251.             throw $e;
  252.         }
  253.     }
  254.     /**
  255.      * @return bool
  256.      */
  257.     public function gc($maxlifetime)
  258.     {
  259.         // We delay gc() to close() so that it is executed outside the transactional and blocking read-write process.
  260.         // This way, pruning expired sessions does not block them from being started while the current session is used.
  261.         $this->gcCalled true;
  262.         return true;
  263.     }
  264.     /**
  265.      * {@inheritdoc}
  266.      */
  267.     protected function doDestroy($sessionId)
  268.     {
  269.         // delete the record associated with this id
  270.         $sql "DELETE FROM $this->table WHERE $this->idCol = :id";
  271.         try {
  272.             $stmt $this->pdo->prepare($sql);
  273.             $stmt->bindParam(':id'$sessionId, \PDO::PARAM_STR);
  274.             $stmt->execute();
  275.         } catch (\PDOException $e) {
  276.             $this->rollback();
  277.             throw $e;
  278.         }
  279.         return true;
  280.     }
  281.     /**
  282.      * {@inheritdoc}
  283.      */
  284.     protected function doWrite($sessionId$data)
  285.     {
  286.         $maxlifetime = (int) ini_get('session.gc_maxlifetime');
  287.         try {
  288.             // We use a single MERGE SQL query when supported by the database.
  289.             $mergeStmt $this->getMergeStatement($sessionId$data$maxlifetime);
  290.             if (null !== $mergeStmt) {
  291.                 $mergeStmt->execute();
  292.                 return true;
  293.             }
  294.             $updateStmt $this->getUpdateStatement($sessionId$data$maxlifetime);
  295.             $updateStmt->execute();
  296.             // When MERGE is not supported, like in Postgres < 9.5, we have to use this approach that can result in
  297.             // duplicate key errors when the same session is written simultaneously (given the LOCK_NONE behavior).
  298.             // We can just catch such an error and re-execute the update. This is similar to a serializable
  299.             // transaction with retry logic on serialization failures but without the overhead and without possible
  300.             // false positives due to longer gap locking.
  301.             if (!$updateStmt->rowCount()) {
  302.                 try {
  303.                     $insertStmt $this->getInsertStatement($sessionId$data$maxlifetime);
  304.                     $insertStmt->execute();
  305.                 } catch (\PDOException $e) {
  306.                     // Handle integrity violation SQLSTATE 23000 (or a subclass like 23505 in Postgres) for duplicate keys
  307.                     if (=== strpos($e->getCode(), '23')) {
  308.                         $updateStmt->execute();
  309.                     } else {
  310.                         throw $e;
  311.                     }
  312.                 }
  313.             }
  314.         } catch (\PDOException $e) {
  315.             $this->rollback();
  316.             throw $e;
  317.         }
  318.         return true;
  319.     }
  320.     /**
  321.      * @return bool
  322.      */
  323.     public function updateTimestamp($sessionId$data)
  324.     {
  325.         $expiry time() + (int) ini_get('session.gc_maxlifetime');
  326.         try {
  327.             $updateStmt $this->pdo->prepare(
  328.                 "UPDATE $this->table SET $this->lifetimeCol = :expiry, $this->timeCol = :time WHERE $this->idCol = :id"
  329.             );
  330.             $updateStmt->bindParam(':id'$sessionId, \PDO::PARAM_STR);
  331.             $updateStmt->bindParam(':expiry'$expiry, \PDO::PARAM_INT);
  332.             $updateStmt->bindValue(':time'time(), \PDO::PARAM_INT);
  333.             $updateStmt->execute();
  334.         } catch (\PDOException $e) {
  335.             $this->rollback();
  336.             throw $e;
  337.         }
  338.         return true;
  339.     }
  340.     /**
  341.      * @return bool
  342.      */
  343.     public function close()
  344.     {
  345.         $this->commit();
  346.         while ($unlockStmt array_shift($this->unlockStatements)) {
  347.             $unlockStmt->execute();
  348.         }
  349.         if ($this->gcCalled) {
  350.             $this->gcCalled false;
  351.             // delete the session records that have expired
  352.             $sql "DELETE FROM $this->table WHERE $this->lifetimeCol < :time AND $this->lifetimeCol > :min";
  353.             $stmt $this->pdo->prepare($sql);
  354.             $stmt->bindValue(':time'time(), \PDO::PARAM_INT);
  355.             $stmt->bindValue(':min'self::MAX_LIFETIME, \PDO::PARAM_INT);
  356.             $stmt->execute();
  357.             // to be removed in 6.0
  358.             if ('mysql' === $this->driver) {
  359.                 $legacySql "DELETE FROM $this->table WHERE $this->lifetimeCol <= :min AND $this->lifetimeCol + $this->timeCol < :time";
  360.             } else {
  361.                 $legacySql "DELETE FROM $this->table WHERE $this->lifetimeCol <= :min AND $this->lifetimeCol < :time - $this->timeCol";
  362.             }
  363.             $stmt $this->pdo->prepare($legacySql);
  364.             $stmt->bindValue(':time'time(), \PDO::PARAM_INT);
  365.             $stmt->bindValue(':min'self::MAX_LIFETIME, \PDO::PARAM_INT);
  366.             $stmt->execute();
  367.         }
  368.         if (false !== $this->dsn) {
  369.             $this->pdo null// only close lazy-connection
  370.         }
  371.         return true;
  372.     }
  373.     /**
  374.      * Lazy-connects to the database.
  375.      */
  376.     private function connect(string $dsn): void
  377.     {
  378.         $this->pdo = new \PDO($dsn$this->username$this->password$this->connectionOptions);
  379.         $this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
  380.         $this->driver $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME);
  381.     }
  382.     /**
  383.      * Builds a PDO DSN from a URL-like connection string.
  384.      *
  385.      * @todo implement missing support for oci DSN (which look totally different from other PDO ones)
  386.      */
  387.     private function buildDsnFromUrl(string $dsnOrUrl): string
  388.     {
  389.         // (pdo_)?sqlite3?:///... => (pdo_)?sqlite3?://localhost/... or else the URL will be invalid
  390.         $url preg_replace('#^((?:pdo_)?sqlite3?):///#''$1://localhost/'$dsnOrUrl);
  391.         $params parse_url($url);
  392.         if (false === $params) {
  393.             return $dsnOrUrl// If the URL is not valid, let's assume it might be a DSN already.
  394.         }
  395.         $params array_map('rawurldecode'$params);
  396.         // Override the default username and password. Values passed through options will still win over these in the constructor.
  397.         if (isset($params['user'])) {
  398.             $this->username $params['user'];
  399.         }
  400.         if (isset($params['pass'])) {
  401.             $this->password $params['pass'];
  402.         }
  403.         if (!isset($params['scheme'])) {
  404.             throw new \InvalidArgumentException('URLs without scheme are not supported to configure the PdoSessionHandler');
  405.         }
  406.         $driverAliasMap = [
  407.             'mssql' => 'sqlsrv',
  408.             'mysql2' => 'mysql'// Amazon RDS, for some weird reason
  409.             'postgres' => 'pgsql',
  410.             'postgresql' => 'pgsql',
  411.             'sqlite3' => 'sqlite',
  412.         ];
  413.         $driver = isset($driverAliasMap[$params['scheme']]) ? $driverAliasMap[$params['scheme']] : $params['scheme'];
  414.         // Doctrine DBAL supports passing its internal pdo_* driver names directly too (allowing both dashes and underscores). This allows supporting the same here.
  415.         if (=== strpos($driver'pdo_') || === strpos($driver'pdo-')) {
  416.             $driver substr($driver4);
  417.         }
  418.         switch ($driver) {
  419.             case 'mysql':
  420.             case 'pgsql':
  421.                 $dsn $driver.':';
  422.                 if (isset($params['host']) && '' !== $params['host']) {
  423.                     $dsn .= 'host='.$params['host'].';';
  424.                 }
  425.                 if (isset($params['port']) && '' !== $params['port']) {
  426.                     $dsn .= 'port='.$params['port'].';';
  427.                 }
  428.                 if (isset($params['path'])) {
  429.                     $dbName substr($params['path'], 1); // Remove the leading slash
  430.                     $dsn .= 'dbname='.$dbName.';';
  431.                 }
  432.                 return $dsn;
  433.             case 'sqlite':
  434.                 return 'sqlite:'.substr($params['path'], 1);
  435.             case 'sqlsrv':
  436.                 $dsn 'sqlsrv:server=';
  437.                 if (isset($params['host'])) {
  438.                     $dsn .= $params['host'];
  439.                 }
  440.                 if (isset($params['port']) && '' !== $params['port']) {
  441.                     $dsn .= ','.$params['port'];
  442.                 }
  443.                 if (isset($params['path'])) {
  444.                     $dbName substr($params['path'], 1); // Remove the leading slash
  445.                     $dsn .= ';Database='.$dbName;
  446.                 }
  447.                 return $dsn;
  448.             default:
  449.                 throw new \InvalidArgumentException(sprintf('The scheme "%s" is not supported by the PdoSessionHandler URL configuration. Pass a PDO DSN directly.'$params['scheme']));
  450.         }
  451.     }
  452.     /**
  453.      * Helper method to begin a transaction.
  454.      *
  455.      * Since SQLite does not support row level locks, we have to acquire a reserved lock
  456.      * on the database immediately. Because of https://bugs.php.net/42766 we have to create
  457.      * such a transaction manually which also means we cannot use PDO::commit or
  458.      * PDO::rollback or PDO::inTransaction for SQLite.
  459.      *
  460.      * Also MySQLs default isolation, REPEATABLE READ, causes deadlock for different sessions
  461.      * due to https://percona.com/blog/2013/12/12/one-more-innodb-gap-lock-to-avoid/ .
  462.      * So we change it to READ COMMITTED.
  463.      */
  464.     private function beginTransaction(): void
  465.     {
  466.         if (!$this->inTransaction) {
  467.             if ('sqlite' === $this->driver) {
  468.                 $this->pdo->exec('BEGIN IMMEDIATE TRANSACTION');
  469.             } else {
  470.                 if ('mysql' === $this->driver) {
  471.                     $this->pdo->exec('SET TRANSACTION ISOLATION LEVEL READ COMMITTED');
  472.                 }
  473.                 $this->pdo->beginTransaction();
  474.             }
  475.             $this->inTransaction true;
  476.         }
  477.     }
  478.     /**
  479.      * Helper method to commit a transaction.
  480.      */
  481.     private function commit(): void
  482.     {
  483.         if ($this->inTransaction) {
  484.             try {
  485.                 // commit read-write transaction which also releases the lock
  486.                 if ('sqlite' === $this->driver) {
  487.                     $this->pdo->exec('COMMIT');
  488.                 } else {
  489.                     $this->pdo->commit();
  490.                 }
  491.                 $this->inTransaction false;
  492.             } catch (\PDOException $e) {
  493.                 $this->rollback();
  494.                 throw $e;
  495.             }
  496.         }
  497.     }
  498.     /**
  499.      * Helper method to rollback a transaction.
  500.      */
  501.     private function rollback(): void
  502.     {
  503.         // We only need to rollback if we are in a transaction. Otherwise the resulting
  504.         // error would hide the real problem why rollback was called. We might not be
  505.         // in a transaction when not using the transactional locking behavior or when
  506.         // two callbacks (e.g. destroy and write) are invoked that both fail.
  507.         if ($this->inTransaction) {
  508.             if ('sqlite' === $this->driver) {
  509.                 $this->pdo->exec('ROLLBACK');
  510.             } else {
  511.                 $this->pdo->rollBack();
  512.             }
  513.             $this->inTransaction false;
  514.         }
  515.     }
  516.     /**
  517.      * Reads the session data in respect to the different locking strategies.
  518.      *
  519.      * We need to make sure we do not return session data that is already considered garbage according
  520.      * to the session.gc_maxlifetime setting because gc() is called after read() and only sometimes.
  521.      *
  522.      * @param string $sessionId Session ID
  523.      *
  524.      * @return string The session data
  525.      */
  526.     protected function doRead($sessionId)
  527.     {
  528.         if (self::LOCK_ADVISORY === $this->lockMode) {
  529.             $this->unlockStatements[] = $this->doAdvisoryLock($sessionId);
  530.         }
  531.         $selectSql $this->getSelectSql();
  532.         $selectStmt $this->pdo->prepare($selectSql);
  533.         $selectStmt->bindParam(':id'$sessionId, \PDO::PARAM_STR);
  534.         $insertStmt null;
  535.         do {
  536.             $selectStmt->execute();
  537.             $sessionRows $selectStmt->fetchAll(\PDO::FETCH_NUM);
  538.             if ($sessionRows) {
  539.                 $expiry = (int) $sessionRows[0][1];
  540.                 if ($expiry <= self::MAX_LIFETIME) {
  541.                     $expiry += $sessionRows[0][2];
  542.                 }
  543.                 if ($expiry time()) {
  544.                     $this->sessionExpired true;
  545.                     return '';
  546.                 }
  547.                 return \is_resource($sessionRows[0][0]) ? stream_get_contents($sessionRows[0][0]) : $sessionRows[0][0];
  548.             }
  549.             if (null !== $insertStmt) {
  550.                 $this->rollback();
  551.                 throw new \RuntimeException('Failed to read session: INSERT reported a duplicate id but next SELECT did not return any data.');
  552.             }
  553.             if (!filter_var(ini_get('session.use_strict_mode'), FILTER_VALIDATE_BOOLEAN) && self::LOCK_TRANSACTIONAL === $this->lockMode && 'sqlite' !== $this->driver) {
  554.                 // In strict mode, session fixation is not possible: new sessions always start with a unique
  555.                 // random id, so that concurrency is not possible and this code path can be skipped.
  556.                 // Exclusive-reading of non-existent rows does not block, so we need to do an insert to block
  557.                 // until other connections to the session are committed.
  558.                 try {
  559.                     $insertStmt $this->getInsertStatement($sessionId''0);
  560.                     $insertStmt->execute();
  561.                 } catch (\PDOException $e) {
  562.                     // Catch duplicate key error because other connection created the session already.
  563.                     // It would only not be the case when the other connection destroyed the session.
  564.                     if (=== strpos($e->getCode(), '23')) {
  565.                         // Retrieve finished session data written by concurrent connection by restarting the loop.
  566.                         // We have to start a new transaction as a failed query will mark the current transaction as
  567.                         // aborted in PostgreSQL and disallow further queries within it.
  568.                         $this->rollback();
  569.                         $this->beginTransaction();
  570.                         continue;
  571.                     }
  572.                     throw $e;
  573.                 }
  574.             }
  575.             return '';
  576.         } while (true);
  577.     }
  578.     /**
  579.      * Executes an application-level lock on the database.
  580.      *
  581.      * @return \PDOStatement The statement that needs to be executed later to release the lock
  582.      *
  583.      * @throws \DomainException When an unsupported PDO driver is used
  584.      *
  585.      * @todo implement missing advisory locks
  586.      *       - for oci using DBMS_LOCK.REQUEST
  587.      *       - for sqlsrv using sp_getapplock with LockOwner = Session
  588.      */
  589.     private function doAdvisoryLock(string $sessionId): \PDOStatement
  590.     {
  591.         switch ($this->driver) {
  592.             case 'mysql':
  593.                 // MySQL 5.7.5 and later enforces a maximum length on lock names of 64 characters. Previously, no limit was enforced.
  594.                 $lockId substr($sessionId064);
  595.                 // should we handle the return value? 0 on timeout, null on error
  596.                 // we use a timeout of 50 seconds which is also the default for innodb_lock_wait_timeout
  597.                 $stmt $this->pdo->prepare('SELECT GET_LOCK(:key, 50)');
  598.                 $stmt->bindValue(':key'$lockId, \PDO::PARAM_STR);
  599.                 $stmt->execute();
  600.                 $releaseStmt $this->pdo->prepare('DO RELEASE_LOCK(:key)');
  601.                 $releaseStmt->bindValue(':key'$lockId, \PDO::PARAM_STR);
  602.                 return $releaseStmt;
  603.             case 'pgsql':
  604.                 // Obtaining an exclusive session level advisory lock requires an integer key.
  605.                 // When session.sid_bits_per_character > 4, the session id can contain non-hex-characters.
  606.                 // So we cannot just use hexdec().
  607.                 if (=== \PHP_INT_SIZE) {
  608.                     $sessionInt1 $this->convertStringToInt($sessionId);
  609.                     $sessionInt2 $this->convertStringToInt(substr($sessionId44));
  610.                     $stmt $this->pdo->prepare('SELECT pg_advisory_lock(:key1, :key2)');
  611.                     $stmt->bindValue(':key1'$sessionInt1, \PDO::PARAM_INT);
  612.                     $stmt->bindValue(':key2'$sessionInt2, \PDO::PARAM_INT);
  613.                     $stmt->execute();
  614.                     $releaseStmt $this->pdo->prepare('SELECT pg_advisory_unlock(:key1, :key2)');
  615.                     $releaseStmt->bindValue(':key1'$sessionInt1, \PDO::PARAM_INT);
  616.                     $releaseStmt->bindValue(':key2'$sessionInt2, \PDO::PARAM_INT);
  617.                 } else {
  618.                     $sessionBigInt $this->convertStringToInt($sessionId);
  619.                     $stmt $this->pdo->prepare('SELECT pg_advisory_lock(:key)');
  620.                     $stmt->bindValue(':key'$sessionBigInt, \PDO::PARAM_INT);
  621.                     $stmt->execute();
  622.                     $releaseStmt $this->pdo->prepare('SELECT pg_advisory_unlock(:key)');
  623.                     $releaseStmt->bindValue(':key'$sessionBigInt, \PDO::PARAM_INT);
  624.                 }
  625.                 return $releaseStmt;
  626.             case 'sqlite':
  627.                 throw new \DomainException('SQLite does not support advisory locks.');
  628.             default:
  629.                 throw new \DomainException(sprintf('Advisory locks are currently not implemented for PDO driver "%s".'$this->driver));
  630.         }
  631.     }
  632.     /**
  633.      * Encodes the first 4 (when PHP_INT_SIZE == 4) or 8 characters of the string as an integer.
  634.      *
  635.      * Keep in mind, PHP integers are signed.
  636.      */
  637.     private function convertStringToInt(string $string): int
  638.     {
  639.         if (=== \PHP_INT_SIZE) {
  640.             return (\ord($string[3]) << 24) + (\ord($string[2]) << 16) + (\ord($string[1]) << 8) + \ord($string[0]);
  641.         }
  642.         $int1 = (\ord($string[7]) << 24) + (\ord($string[6]) << 16) + (\ord($string[5]) << 8) + \ord($string[4]);
  643.         $int2 = (\ord($string[3]) << 24) + (\ord($string[2]) << 16) + (\ord($string[1]) << 8) + \ord($string[0]);
  644.         return $int2 + ($int1 << 32);
  645.     }
  646.     /**
  647.      * Return a locking or nonlocking SQL query to read session information.
  648.      *
  649.      * @throws \DomainException When an unsupported PDO driver is used
  650.      */
  651.     private function getSelectSql(): string
  652.     {
  653.         if (self::LOCK_TRANSACTIONAL === $this->lockMode) {
  654.             $this->beginTransaction();
  655.             // selecting the time column should be removed in 6.0
  656.             switch ($this->driver) {
  657.                 case 'mysql':
  658.                 case 'oci':
  659.                 case 'pgsql':
  660.                     return "SELECT $this->dataCol$this->lifetimeCol$this->timeCol FROM $this->table WHERE $this->idCol = :id FOR UPDATE";
  661.                 case 'sqlsrv':
  662.                     return "SELECT $this->dataCol$this->lifetimeCol$this->timeCol FROM $this->table WITH (UPDLOCK, ROWLOCK) WHERE $this->idCol = :id";
  663.                 case 'sqlite':
  664.                     // we already locked when starting transaction
  665.                     break;
  666.                 default:
  667.                     throw new \DomainException(sprintf('Transactional locks are currently not implemented for PDO driver "%s".'$this->driver));
  668.             }
  669.         }
  670.         return "SELECT $this->dataCol$this->lifetimeCol$this->timeCol FROM $this->table WHERE $this->idCol = :id";
  671.     }
  672.     /**
  673.      * Returns an insert statement supported by the database for writing session data.
  674.      */
  675.     private function getInsertStatement(string $sessionIdstring $sessionDataint $maxlifetime): \PDOStatement
  676.     {
  677.         switch ($this->driver) {
  678.             case 'oci':
  679.                 $data fopen('php://memory''r+');
  680.                 fwrite($data$sessionData);
  681.                 rewind($data);
  682.                 $sql "INSERT INTO $this->table ($this->idCol$this->dataCol$this->lifetimeCol$this->timeCol) VALUES (:id, EMPTY_BLOB(), :expiry, :time) RETURNING $this->dataCol into :data";
  683.                 break;
  684.             default:
  685.                 $data $sessionData;
  686.                 $sql "INSERT INTO $this->table ($this->idCol$this->dataCol$this->lifetimeCol$this->timeCol) VALUES (:id, :data, :expiry, :time)";
  687.                 break;
  688.         }
  689.         $stmt $this->pdo->prepare($sql);
  690.         $stmt->bindParam(':id'$sessionId, \PDO::PARAM_STR);
  691.         $stmt->bindParam(':data'$data, \PDO::PARAM_LOB);
  692.         $stmt->bindValue(':expiry'time() + $maxlifetime, \PDO::PARAM_INT);
  693.         $stmt->bindValue(':time'time(), \PDO::PARAM_INT);
  694.         return $stmt;
  695.     }
  696.     /**
  697.      * Returns an update statement supported by the database for writing session data.
  698.      */
  699.     private function getUpdateStatement(string $sessionIdstring $sessionDataint $maxlifetime): \PDOStatement
  700.     {
  701.         switch ($this->driver) {
  702.             case 'oci':
  703.                 $data fopen('php://memory''r+');
  704.                 fwrite($data$sessionData);
  705.                 rewind($data);
  706.                 $sql "UPDATE $this->table SET $this->dataCol = EMPTY_BLOB(), $this->lifetimeCol = :expiry, $this->timeCol = :time WHERE $this->idCol = :id RETURNING $this->dataCol into :data";
  707.                 break;
  708.             default:
  709.                 $data $sessionData;
  710.                 $sql "UPDATE $this->table SET $this->dataCol = :data, $this->lifetimeCol = :expiry, $this->timeCol = :time WHERE $this->idCol = :id";
  711.                 break;
  712.         }
  713.         $stmt $this->pdo->prepare($sql);
  714.         $stmt->bindParam(':id'$sessionId, \PDO::PARAM_STR);
  715.         $stmt->bindParam(':data'$data, \PDO::PARAM_LOB);
  716.         $stmt->bindValue(':expiry'time() + $maxlifetime, \PDO::PARAM_INT);
  717.         $stmt->bindValue(':time'time(), \PDO::PARAM_INT);
  718.         return $stmt;
  719.     }
  720.     /**
  721.      * Returns a merge/upsert (i.e. insert or update) statement when supported by the database for writing session data.
  722.      */
  723.     private function getMergeStatement(string $sessionIdstring $dataint $maxlifetime): ?\PDOStatement
  724.     {
  725.         switch (true) {
  726.             case 'mysql' === $this->driver:
  727.                 $mergeSql "INSERT INTO $this->table ($this->idCol$this->dataCol$this->lifetimeCol$this->timeCol) VALUES (:id, :data, :expiry, :time) ".
  728.                     "ON DUPLICATE KEY UPDATE $this->dataCol = VALUES($this->dataCol), $this->lifetimeCol = VALUES($this->lifetimeCol), $this->timeCol = VALUES($this->timeCol)";
  729.                 break;
  730.             case 'sqlsrv' === $this->driver && version_compare($this->pdo->getAttribute(\PDO::ATTR_SERVER_VERSION), '10''>='):
  731.                 // MERGE is only available since SQL Server 2008 and must be terminated by semicolon
  732.                 // It also requires HOLDLOCK according to https://weblogs.sqlteam.com/dang/2009/01/31/upsert-race-condition-with-merge/
  733.                 $mergeSql "MERGE INTO $this->table WITH (HOLDLOCK) USING (SELECT 1 AS dummy) AS src ON ($this->idCol = ?) ".
  734.                     "WHEN NOT MATCHED THEN INSERT ($this->idCol$this->dataCol$this->lifetimeCol$this->timeCol) VALUES (?, ?, ?, ?) ".
  735.                     "WHEN MATCHED THEN UPDATE SET $this->dataCol = ?, $this->lifetimeCol = ?, $this->timeCol = ?;";
  736.                 break;
  737.             case 'sqlite' === $this->driver:
  738.                 $mergeSql "INSERT OR REPLACE INTO $this->table ($this->idCol$this->dataCol$this->lifetimeCol$this->timeCol) VALUES (:id, :data, :expiry, :time)";
  739.                 break;
  740.             case 'pgsql' === $this->driver && version_compare($this->pdo->getAttribute(\PDO::ATTR_SERVER_VERSION), '9.5''>='):
  741.                 $mergeSql "INSERT INTO $this->table ($this->idCol$this->dataCol$this->lifetimeCol$this->timeCol) VALUES (:id, :data, :expiry, :time) ".
  742.                     "ON CONFLICT ($this->idCol) DO UPDATE SET ($this->dataCol$this->lifetimeCol$this->timeCol) = (EXCLUDED.$this->dataCol, EXCLUDED.$this->lifetimeCol, EXCLUDED.$this->timeCol)";
  743.                 break;
  744.             default:
  745.                 // MERGE is not supported with LOBs: https://oracle.com/technetwork/articles/fuecks-lobs-095315.html
  746.                 return null;
  747.         }
  748.         $mergeStmt $this->pdo->prepare($mergeSql);
  749.         if ('sqlsrv' === $this->driver) {
  750.             $mergeStmt->bindParam(1$sessionId, \PDO::PARAM_STR);
  751.             $mergeStmt->bindParam(2$sessionId, \PDO::PARAM_STR);
  752.             $mergeStmt->bindParam(3$data, \PDO::PARAM_LOB);
  753.             $mergeStmt->bindValue(4time() + $maxlifetime, \PDO::PARAM_INT);
  754.             $mergeStmt->bindValue(4time(), \PDO::PARAM_INT);
  755.             $mergeStmt->bindParam(5$data, \PDO::PARAM_LOB);
  756.             $mergeStmt->bindValue(6time() + $maxlifetime, \PDO::PARAM_INT);
  757.             $mergeStmt->bindValue(6time(), \PDO::PARAM_INT);
  758.         } else {
  759.             $mergeStmt->bindParam(':id'$sessionId, \PDO::PARAM_STR);
  760.             $mergeStmt->bindParam(':data'$data, \PDO::PARAM_LOB);
  761.             $mergeStmt->bindValue(':expiry'time() + $maxlifetime, \PDO::PARAM_INT);
  762.             $mergeStmt->bindValue(':time'time(), \PDO::PARAM_INT);
  763.         }
  764.         return $mergeStmt;
  765.     }
  766.     /**
  767.      * Return a PDO instance.
  768.      *
  769.      * @return \PDO
  770.      */
  771.     protected function getConnection()
  772.     {
  773.         if (null === $this->pdo) {
  774.             $this->connect($this->dsn ?: ini_get('session.save_path'));
  775.         }
  776.         return $this->pdo;
  777.     }
  778. }