-
-
Notifications
You must be signed in to change notification settings - Fork 9.7k
[HttpFoundation] Fix DbalSessionHandler #10720
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -11,37 +11,64 @@ | |
|
||
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 and thus also works with non-PDO-based drivers like mysqli and OCI8. | ||
* | ||
* @author Fabien Potencier <fabien@symfony.com> | ||
* @author Johannes M. Schmitt <schmittjoh@gmail.com> | ||
* @author Tobias Schultze <http://tobion.de> | ||
*/ | ||
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 for lazy connections | ||
* @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 +84,6 @@ public function open($path = null, $name = null) | |
*/ | ||
public function close() | ||
{ | ||
// do nothing | ||
return true; | ||
} | ||
|
||
|
@@ -66,12 +92,15 @@ public function close() | |
*/ | ||
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 +111,15 @@ public function destroy($id) | |
*/ | ||
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); | ||
} | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. not part of Doctrine\DBAL\Driver\Connection either |
||
return true; | ||
|
@@ -98,21 +130,23 @@ public function gc($lifetime) | |
*/ | ||
public function read($id) | ||
{ | ||
$sql = "SELECT $this->dataCol FROM $this->table WHERE $this->idCol = :id"; | ||
|
||
try { | ||
$data = $this->con->executeQuery("SELECT sess_data FROM {$this->tableName} WHERE sess_id = :id", array( | ||
'id' => $id, | ||
))->fetchColumn(); | ||
$stmt = $this->con->prepare($sql); | ||
$stmt->bindParam(':id', $id, \PDO::PARAM_STR); | ||
$stmt->execute(); | ||
|
||
if (false !== $data) { | ||
return base64_decode($data); | ||
} | ||
// We use fetchAll instead of fetchColumn to make sure the DB cursor gets closed | ||
$sessionRows = $stmt->fetchAll(\PDO::FETCH_NUM); | ||
|
||
// session does not exist, create it | ||
$this->createNewSession($id); | ||
if ($sessionRows) { | ||
return base64_decode($sessionRows[0][0]); | ||
} | ||
|
||
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 +155,88 @@ public function read($id) | |
*/ | ||
public function write($id, $data) | ||
{ | ||
$platform = $this->con->getDatabasePlatform(); | ||
|
||
// 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"; | ||
} | ||
// Session data can contain non binary safe characters so we need to encode it. | ||
$encoded = base64_encode($data); | ||
|
||
// 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() | ||
)); | ||
|
||
if (!$rowCount) { | ||
// No session exists in the database to update. This happens when we have called | ||
// session_regenerate_id() | ||
$this->createNewSession($id, $data); | ||
$mergeSql = $this->getMergeSql(); | ||
|
||
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; | ||
} | ||
|
||
$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 (\PDOException $e) { | ||
throw new \RuntimeException(sprintf('PDOException was thrown when trying to write the session data: %s', $e->getMessage()), 0, $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: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. very verbose but the only way to determine if our DriverConnection is Mysql
Determining this is not nice and doctrine could probably also abstract this logic for "upserts" |
||
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)"; | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. PostgreSQL is missing here... like in the other PR but I totally forgot to mention it back then. The problem is that PostgreSQL does not support upserts but there are some workarounds. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes I already read that when researching for upserts in each database engine. And this article says that postgresql does not have upserts and wishes there was one. So I'm not sure what you expect here? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You could use a custom database function, but that will require someone manual work trough. For PostgreSQL 9.1 you can use a writable CTE. I wonder if checking if the id exists is faster then deleting it and then directly inserting it. Edit. To bad there is no clear answer on the performance question. Edit2. Ah wait! you can update and then check the affected-rows count to see if any changes have happened. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @Tobion DBAL 2.5 is throwing a specific exception for constraint violations, so this will help There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. http://www.depesz.com/2012/06/10/why-is-upsert-so-complicated/ is an importand read for postgresql. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @stof but there are many different types of constraint violations and we only need to ignore one of it |
||
|
||
return null; | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
executeQuery is not part of Doctrine\DBAL\Driver\Connection (only in Doctrine\DBAL\Connection) so this wasn't working without wrapper
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the expectation of the class was actually
Doctrine\DBAL\Connection
, which is the public facade of Doctrine.Given the class is throwing a fatal error if usign the internal connection instead of the public class anyway, we can fix the typehint without breaking BC. It will simplify your implementation a lot
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe, but typehinting the interface instead of the wrapper makes sense. And I already found a solution now which is only relevant for the MERGE SQL.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
not in this case.
Doctrine\DBAL\Driver\Connection
is the interface for internal implementations for each driver.Doctrine\DBAL\Connection
is the public API of DBAL. The fact thatDoctrine\DBAL\Connection
implementsDoctrine\DBAL\Driver\Connection
is a legacy mistake which cannot be fixed until Doctrine 3.0 because of BC. It totally makes sense to depend on the public API rather than on the internal oneThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In that case the doctrine phpdoc needs an update. Driver connection should be marked as internal and the description of Connection is also irritating
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
agreed. reported as http://www.doctrine-project.org/jira/browse/DBAL-867
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Btw, Symfony\Component\Security\Acl\Dbal\AclProvider and Symfony\Component\Security\Acl\Dbal\MutableAclProvider also use the wrong connection typehint.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in #10723