diff --git a/src/Symfony/Bridge/Doctrine/HttpFoundation/DbalSessionHandler.php b/src/Symfony/Bridge/Doctrine/HttpFoundation/DbalSessionHandler.php index f2ad6fcfeb..0c1436fa9c 100644 --- a/src/Symfony/Bridge/Doctrine/HttpFoundation/DbalSessionHandler.php +++ b/src/Symfony/Bridge/Doctrine/HttpFoundation/DbalSessionHandler.php @@ -11,37 +11,65 @@ namespace Symfony\Bridge\Doctrine\HttpFoundation; +use Doctrine\DBAL\Connection; +use Doctrine\DBAL\Driver\Connection as DriverConnection; +use Doctrine\DBAL\Driver\Mysqli\MysqliConnection; +use Doctrine\DBAL\Driver\OCI8\OCI8Connection; +use Doctrine\DBAL\Driver\PDOConnection; +use Doctrine\DBAL\Driver\SQLSrv\SQLSrvConnection; use Doctrine\DBAL\Platforms\MySqlPlatform; -use Doctrine\DBAL\Driver\Connection; +use Doctrine\DBAL\Platforms\OraclePlatform; +use Doctrine\DBAL\Platforms\SqlitePlatform; +use Doctrine\DBAL\Platforms\SQLServerPlatform; /** * DBAL based session storage. * + * This implementation is very similar to Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler + * but uses the Doctrine driver connection interface for non-PDO-based drivers like mysqli or OCI8. + * It is recommended to use the wrapper Doctrine\DBAL\Connection for lazy connections and optimized database-specific queries. + * * @author Fabien Potencier * @author Johannes M. Schmitt + * @author Tobias Schultze */ class DbalSessionHandler implements \SessionHandlerInterface { /** - * @var Connection + * @var DriverConnection */ private $con; /** * @var string */ - private $tableName; + private $table; + + /** + * @var string Column for session id + */ + private $idCol = 'sess_id'; + + /** + * @var string Column for session data + */ + private $dataCol = 'sess_data'; + + /** + * @var string Column for timestamp + */ + private $timeCol = 'sess_time'; /** * Constructor. * - * @param Connection $con An instance of Connection. - * @param string $tableName Table name. + * @param DriverConnection $con A driver connection, preferably a wrapper Doctrine\DBAL\Connection + * @param string $tableName Table name */ - public function __construct(Connection $con, $tableName = 'sessions') + public function __construct(DriverConnection $con, $tableName = 'sessions') { $this->con = $con; - $this->tableName = $tableName; + $this->table = $tableName; } /** @@ -57,7 +85,6 @@ class DbalSessionHandler implements \SessionHandlerInterface */ public function close() { - // do nothing return true; } @@ -66,12 +93,15 @@ class DbalSessionHandler implements \SessionHandlerInterface */ public function destroy($id) { + // delete the record associated with this id + $sql = "DELETE FROM $this->table WHERE $this->idCol = :id"; + try { - $this->con->executeQuery("DELETE FROM {$this->tableName} WHERE sess_id = :id", array( - 'id' => $id, - )); - } catch (\PDOException $e) { - throw new \RuntimeException(sprintf('PDOException was thrown when trying to manipulate session data: %s', $e->getMessage()), 0, $e); + $stmt = $this->con->prepare($sql); + $stmt->bindParam(':id', $id, \PDO::PARAM_STR); + $stmt->execute(); + } catch (\Exception $e) { + throw new \RuntimeException(sprintf('Exception was thrown when trying to delete a session: %s', $e->getMessage()), 0, $e); } return true; @@ -82,12 +112,15 @@ class DbalSessionHandler implements \SessionHandlerInterface */ public function gc($lifetime) { + // delete the session records that have expired + $sql = "DELETE FROM $this->table WHERE $this->timeCol < :time"; + try { - $this->con->executeQuery("DELETE FROM {$this->tableName} WHERE sess_time < :time", array( - 'time' => time() - $lifetime, - )); - } catch (\PDOException $e) { - throw new \RuntimeException(sprintf('PDOException was thrown when trying to manipulate session data: %s', $e->getMessage()), 0, $e); + $stmt = $this->con->prepare($sql); + $stmt->bindValue(':time', time() - $lifetime, \PDO::PARAM_INT); + $stmt->execute(); + } catch (\Exception $e) { + throw new \RuntimeException(sprintf('Exception was thrown when trying to delete expired sessions: %s', $e->getMessage()), 0, $e); } return true; @@ -98,21 +131,23 @@ class DbalSessionHandler implements \SessionHandlerInterface */ public function read($id) { - try { - $data = $this->con->executeQuery("SELECT sess_data FROM {$this->tableName} WHERE sess_id = :id", array( - 'id' => $id, - ))->fetchColumn(); + $sql = "SELECT $this->dataCol FROM $this->table WHERE $this->idCol = :id"; - if (false !== $data) { - return base64_decode($data); + try { + $stmt = $this->con->prepare($sql); + $stmt->bindParam(':id', $id, \PDO::PARAM_STR); + $stmt->execute(); + + // We use fetchAll instead of fetchColumn to make sure the DB cursor gets closed + $sessionRows = $stmt->fetchAll(\PDO::FETCH_NUM); + + if ($sessionRows) { + return base64_decode($sessionRows[0][0]); } - // session does not exist, create it - $this->createNewSession($id); - return ''; - } catch (\PDOException $e) { - throw new \RuntimeException(sprintf('PDOException was thrown when trying to read the session data: %s', $e->getMessage()), 0, $e); + } catch (\Exception $e) { + throw new \RuntimeException(sprintf('Exception was thrown when trying to read the session data: %s', $e->getMessage()), 0, $e); } } @@ -121,54 +156,88 @@ class DbalSessionHandler implements \SessionHandlerInterface */ public function write($id, $data) { - $platform = $this->con->getDatabasePlatform(); + // Session data can contain non binary safe characters so we need to encode it. + $encoded = base64_encode($data); - // this should maybe be abstracted in Doctrine DBAL - if ($platform instanceof MySqlPlatform) { - $sql = "INSERT INTO {$this->tableName} (sess_id, sess_data, sess_time) VALUES (%1\$s, %2\$s, %3\$d) " - ."ON DUPLICATE KEY UPDATE sess_data = VALUES(sess_data), sess_time = CASE WHEN sess_time = %3\$d THEN (VALUES(sess_time) + 1) ELSE VALUES(sess_time) END"; - } else { - $sql = "UPDATE {$this->tableName} SET sess_data = %2\$s, sess_time = %3\$d WHERE sess_id = %1\$s"; - } + // We use a MERGE SQL query when supported by the database. + // Otherwise we have to use a transactional DELETE followed by INSERT to prevent duplicate entries under high concurrency. try { - $rowCount = $this->con->exec(sprintf( - $sql, - $this->con->quote($id), - //session data can contain non binary safe characters so we need to encode it - $this->con->quote(base64_encode($data)), - time() - )); + $mergeSql = $this->getMergeSql(); - if (!$rowCount) { - // No session exists in the database to update. This happens when we have called - // session_regenerate_id() - $this->createNewSession($id, $data); + if (null !== $mergeSql) { + $mergeStmt = $this->con->prepare($mergeSql); + $mergeStmt->bindParam(':id', $id, \PDO::PARAM_STR); + $mergeStmt->bindParam(':data', $encoded, \PDO::PARAM_STR); + $mergeStmt->bindValue(':time', time(), \PDO::PARAM_INT); + $mergeStmt->execute(); + + return true; } - } catch (\PDOException $e) { - throw new \RuntimeException(sprintf('PDOException was thrown when trying to write the session data: %s', $e->getMessage()), 0, $e); + + $this->con->beginTransaction(); + + try { + $deleteStmt = $this->con->prepare( + "DELETE FROM $this->table WHERE $this->idCol = :id" + ); + $deleteStmt->bindParam(':id', $id, \PDO::PARAM_STR); + $deleteStmt->execute(); + + $insertStmt = $this->con->prepare( + "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->timeCol) VALUES (:id, :data, :time)" + ); + $insertStmt->bindParam(':id', $id, \PDO::PARAM_STR); + $insertStmt->bindParam(':data', $encoded, \PDO::PARAM_STR); + $insertStmt->bindValue(':time', time(), \PDO::PARAM_INT); + $insertStmt->execute(); + + $this->con->commit(); + } catch (\Exception $e) { + $this->con->rollback(); + + throw $e; + } + } catch (\Exception $e) { + throw new \RuntimeException(sprintf('Exception was thrown when trying to write the session data: %s', $e->getMessage()), 0, $e); } return true; } - /** - * Creates a new session with the given $id and $data - * - * @param string $id - * @param string $data - * - * @return bool - */ - private function createNewSession($id, $data = '') + /** + * Returns a merge/upsert (i.e. insert or update) SQL query when supported by the database. + * + * @return string|null The SQL string or null when not supported + */ + private function getMergeSql() { - $this->con->exec(sprintf("INSERT INTO {$this->tableName} (sess_id, sess_data, sess_time) VALUES (%s, %s, %d)", - $this->con->quote($id), - //session data can contain non binary safe characters so we need to encode it - $this->con->quote(base64_encode($data)), - time() - )); + $platform = $pdoDriver = null; - return true; + if ($this->con instanceof Connection) { + $platform = $this->con->getDatabasePlatform(); + } elseif ($this->con instanceof PDOConnection) { + $pdoDriver = $this->con->getAttribute(\PDO::ATTR_DRIVER_NAME); + } + + switch (true) { + case $this->con instanceof MysqliConnection || $platform instanceof MySqlPlatform || 'mysql' === $pdoDriver: + return "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->timeCol) VALUES (:id, :data, :time) " . + "ON DUPLICATE KEY UPDATE $this->dataCol = VALUES($this->dataCol), $this->timeCol = VALUES($this->timeCol)"; + case $this->con instanceof OCI8Connection || $platform instanceof OraclePlatform || 'oci' === $pdoDriver: + // DUAL is Oracle specific dummy table + return "MERGE INTO $this->table USING DUAL ON ($this->idCol = :id) " . + "WHEN NOT MATCHED THEN INSERT ($this->idCol, $this->dataCol, $this->timeCol) VALUES (:id, :data, :time) " . + "WHEN MATCHED THEN UPDATE SET $this->dataCol = :data"; + case $this->con instanceof SQLSrvConnection || $platform instanceof SQLServerPlatform || 'sqlsrv' === $pdoDriver: + // MS SQL Server requires MERGE be terminated by semicolon + return "MERGE INTO $this->table USING (SELECT 'x' AS dummy) AS src ON ($this->idCol = :id) " . + "WHEN NOT MATCHED THEN INSERT ($this->idCol, $this->dataCol, $this->timeCol) VALUES (:id, :data, :time) " . + "WHEN MATCHED THEN UPDATE SET $this->dataCol = :data;"; + case $platform instanceof SqlitePlatform || 'sqlite' === $pdoDriver: + return "INSERT OR REPLACE INTO $this->table ($this->idCol, $this->dataCol, $this->timeCol) VALUES (:id, :data, :time)"; + } + + return null; } }