Skip to content

Commit

Permalink
[Doctrine Bridge] fix DbalSessionHandler for high concurrency, interf…
Browse files Browse the repository at this point in the history
…ace compliance, compatibility with all drivers (oci8, mysqli, pdo with mysql, sqlsrv, sqlite)
  • Loading branch information
Tobion committed Apr 16, 2014
1 parent e96b018 commit ccdfbe6
Showing 1 changed file with 136 additions and 67 deletions.
203 changes: 136 additions & 67 deletions src/Symfony/Bridge/Doctrine/HttpFoundation/DbalSessionHandler.php
Expand Up @@ -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 <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
* @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;
}

/**
Expand All @@ -57,7 +85,6 @@ public function open($path = null, $name = null)
*/
public function close()
{
// do nothing
return true;
}

Expand All @@ -66,12 +93,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;
Expand All @@ -82,12 +112,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);
}

return true;
Expand All @@ -98,21 +131,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);
}
}

Expand All @@ -121,54 +156,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:
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;
}
}

0 comments on commit ccdfbe6

Please sign in to comment.