Add LegacyPdoSessionHandler class

The changes made to the PdoSessionHandler in 2.6 introduced a
backwards-compatability break for users upgrading from 2.5. This update
introduces a LegacyPdoSessionHandler class that uses the old service's
functionality.

Users who cannot make schema updates or do not want to lose sessions can
use LegacyPdoSessionHandler until 3.0.
This commit is contained in:
Jeremy Livingston 2014-12-15 13:23:29 -05:00 committed by Fabien Potencier
parent 3402fffb97
commit 3bd1616db9
3 changed files with 383 additions and 2 deletions

View File

@ -1,7 +1,7 @@
UPGRADE FROM 2.5 to 2.6
=======================
Known Backwards-Compatability Breaks
Known Backwards-Compatibility Breaks
------------------------------------
* If you use the `PdoSessionHandler`, the session table now has a different
@ -112,7 +112,7 @@ HttpFoundation
--------------
* The `PdoSessionHandler` to store sessions in a database changed significantly.
This introduced a **backwards-compatability** break in the schema of the
This introduced a **backwards-compatibility** break in the schema of the
session table. The following changes must be made to your session table:
- Add a new integer column called `sess_lifetime`. Assuming you have the
@ -125,6 +125,8 @@ HttpFoundation
There is also an [issue](https://github.com/symfony/symfony/issues/12834)
that affects Windows servers.
A legacy class, `LegacyPdoSessionHandler` has been created to ease backwards-compatibility issues when upgrading.
The changes to the `PdoSessionHandler` are:
- By default, it now implements session locking to prevent loss of data by concurrent access to the same session.
- It does so using a transaction between opening and closing a session. For this reason, it's not

View File

@ -0,0 +1,268 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpFoundation\Session\Storage\Handler;
/**
* Session handler using a PDO connection to read and write data.
*
* Session data is a binary string that can contain non-printable characters like the null byte.
* For this reason this handler base64 encodes the data to be able to save it in a character column.
*
* This version of the PdoSessionHandler does NOT implement locking. So concurrent requests to the
* same session can result in data loss due to race conditions.
*
* @author Fabien Potencier <fabien@symfony.com>
* @author Michael Williams <michael.williams@funsational.com>
* @author Tobias Schultze <http://tobion.de>
*
* @deprecated Deprecated since version 2.6, to be removed in 3.0. Use
* {@link PdoSessionHandler} instead.
*/
class LegacyPdoSessionHandler implements \SessionHandlerInterface
{
/**
* @var \PDO PDO instance
*/
private $pdo;
/**
* @var string Table name
*/
private $table;
/**
* @var string Column for session id
*/
private $idCol;
/**
* @var string Column for session data
*/
private $dataCol;
/**
* @var string Column for timestamp
*/
private $timeCol;
/**
* Constructor.
*
* List of available options:
* * db_table: The name of the table [required]
* * db_id_col: The column where to store the session id [default: sess_id]
* * db_data_col: The column where to store the session data [default: sess_data]
* * db_time_col: The column where to store the timestamp [default: sess_time]
*
* @param \PDO $pdo A \PDO instance
* @param array $dbOptions An associative array of DB options
*
* @throws \InvalidArgumentException When "db_table" option is not provided
*/
public function __construct(\PDO $pdo, array $dbOptions = array())
{
if (!array_key_exists('db_table', $dbOptions)) {
throw new \InvalidArgumentException('You must provide the "db_table" option for a PdoSessionStorage.');
}
if (\PDO::ERRMODE_EXCEPTION !== $pdo->getAttribute(\PDO::ATTR_ERRMODE)) {
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__));
}
$this->pdo = $pdo;
$dbOptions = array_merge(array(
'db_id_col' => 'sess_id',
'db_data_col' => 'sess_data',
'db_time_col' => 'sess_time',
), $dbOptions);
$this->table = $dbOptions['db_table'];
$this->idCol = $dbOptions['db_id_col'];
$this->dataCol = $dbOptions['db_data_col'];
$this->timeCol = $dbOptions['db_time_col'];
}
/**
* {@inheritdoc}
*/
public function open($savePath, $sessionName)
{
return true;
}
/**
* {@inheritdoc}
*/
public function close()
{
return true;
}
/**
* {@inheritdoc}
*/
public function destroy($sessionId)
{
// delete the record associated with this id
$sql = "DELETE FROM $this->table WHERE $this->idCol = :id";
try {
$stmt = $this->pdo->prepare($sql);
$stmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
$stmt->execute();
} catch (\PDOException $e) {
throw new \RuntimeException(sprintf('PDOException was thrown when trying to delete a session: %s', $e->getMessage()), 0, $e);
}
return true;
}
/**
* {@inheritdoc}
*/
public function gc($maxlifetime)
{
// delete the session records that have expired
$sql = "DELETE FROM $this->table WHERE $this->timeCol < :time";
try {
$stmt = $this->pdo->prepare($sql);
$stmt->bindValue(':time', time() - $maxlifetime, \PDO::PARAM_INT);
$stmt->execute();
} catch (\PDOException $e) {
throw new \RuntimeException(sprintf('PDOException was thrown when trying to delete expired sessions: %s', $e->getMessage()), 0, $e);
}
return true;
}
/**
* {@inheritdoc}
*/
public function read($sessionId)
{
$sql = "SELECT $this->dataCol FROM $this->table WHERE $this->idCol = :id";
try {
$stmt = $this->pdo->prepare($sql);
$stmt->bindParam(':id', $sessionId, \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]);
}
return '';
} catch (\PDOException $e) {
throw new \RuntimeException(sprintf('PDOException was thrown when trying to read the session data: %s', $e->getMessage()), 0, $e);
}
}
/**
* {@inheritdoc}
*/
public function write($sessionId, $data)
{
$encoded = base64_encode($data);
try {
// We use a single MERGE SQL query when supported by the database.
$mergeSql = $this->getMergeSql();
if (null !== $mergeSql) {
$mergeStmt = $this->pdo->prepare($mergeSql);
$mergeStmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
$mergeStmt->bindParam(':data', $encoded, \PDO::PARAM_STR);
$mergeStmt->bindValue(':time', time(), \PDO::PARAM_INT);
$mergeStmt->execute();
return true;
}
$updateStmt = $this->pdo->prepare(
"UPDATE $this->table SET $this->dataCol = :data, $this->timeCol = :time WHERE $this->idCol = :id"
);
$updateStmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
$updateStmt->bindParam(':data', $encoded, \PDO::PARAM_STR);
$updateStmt->bindValue(':time', time(), \PDO::PARAM_INT);
$updateStmt->execute();
// When MERGE is not supported, like in Postgres, we have to use this approach that can result in
// duplicate key errors when the same session is written simultaneously. We can just catch such an
// error and re-execute the update. This is similar to a serializable transaction with retry logic
// on serialization failures but without the overhead and without possible false positives due to
// longer gap locking.
if (!$updateStmt->rowCount()) {
try {
$insertStmt = $this->pdo->prepare(
"INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->timeCol) VALUES (:id, :data, :time)"
);
$insertStmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
$insertStmt->bindParam(':data', $encoded, \PDO::PARAM_STR);
$insertStmt->bindValue(':time', time(), \PDO::PARAM_INT);
$insertStmt->execute();
} catch (\PDOException $e) {
// Handle integrity violation SQLSTATE 23000 (or a subclass like 23505 in Postgres) for duplicate keys
if (0 === strpos($e->getCode(), '23')) {
$updateStmt->execute();
} else {
throw $e;
}
}
}
} catch (\PDOException $e) {
throw new \RuntimeException(sprintf('PDOException was thrown when trying to write the session data: %s', $e->getMessage()), 0, $e);
}
return true;
}
/**
* 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()
{
$driver = $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME);
switch ($driver) {
case 'mysql':
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 'oci':
// 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, $this->timeCol = :time";
case 'sqlsrv' === $driver && version_compare($this->pdo->getAttribute(\PDO::ATTR_SERVER_VERSION), '10', '>='):
// MERGE is only available since SQL Server 2008 and must be terminated by semicolon
// It also requires HOLDLOCK according to http://weblogs.sqlteam.com/dang/archive/2009/01/31/UPSERT-Race-Condition-With-MERGE.aspx
return "MERGE INTO $this->table WITH (HOLDLOCK) USING (SELECT 1 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, $this->timeCol = :time;";
case 'sqlite':
return "INSERT OR REPLACE INTO $this->table ($this->idCol, $this->dataCol, $this->timeCol) VALUES (:id, :data, :time)";
}
}
/**
* Return a PDO instance
*
* @return \PDO
*/
protected function getConnection()
{
return $this->pdo;
}
}

View File

@ -0,0 +1,111 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpFoundation\Tests\Session\Storage\Handler;
use Symfony\Component\HttpFoundation\Session\Storage\Handler\LegacyPdoSessionHandler;
class LegacyPdoSessionHandlerTest extends \PHPUnit_Framework_TestCase
{
private $pdo;
protected function setUp()
{
if (!class_exists('PDO') || !in_array('sqlite', \PDO::getAvailableDrivers())) {
$this->markTestSkipped('This test requires SQLite support in your environment');
}
$this->pdo = new \PDO('sqlite::memory:');
$this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
$sql = 'CREATE TABLE sessions (sess_id VARCHAR(128) PRIMARY KEY, sess_data TEXT, sess_time INTEGER)';
$this->pdo->exec($sql);
}
public function testIncompleteOptions()
{
$this->setExpectedException('InvalidArgumentException');
$storage = new LegacyPdoSessionHandler($this->pdo, array());
}
public function testWrongPdoErrMode()
{
$pdo = new \PDO('sqlite::memory:');
$pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_SILENT);
$pdo->exec('CREATE TABLE sessions (sess_id VARCHAR(128) PRIMARY KEY, sess_data TEXT, sess_time INTEGER)');
$this->setExpectedException('InvalidArgumentException');
$storage = new LegacyPdoSessionHandler($pdo, array('db_table' => 'sessions'));
}
public function testWrongTableOptionsWrite()
{
$storage = new LegacyPdoSessionHandler($this->pdo, array('db_table' => 'bad_name'));
$this->setExpectedException('RuntimeException');
$storage->write('foo', 'bar');
}
public function testWrongTableOptionsRead()
{
$storage = new LegacyPdoSessionHandler($this->pdo, array('db_table' => 'bad_name'));
$this->setExpectedException('RuntimeException');
$storage->read('foo', 'bar');
}
public function testWriteRead()
{
$storage = new LegacyPdoSessionHandler($this->pdo, array('db_table' => 'sessions'));
$storage->write('foo', 'bar');
$this->assertEquals('bar', $storage->read('foo'), 'written value can be read back correctly');
}
public function testMultipleInstances()
{
$storage1 = new LegacyPdoSessionHandler($this->pdo, array('db_table' => 'sessions'));
$storage1->write('foo', 'bar');
$storage2 = new LegacyPdoSessionHandler($this->pdo, array('db_table' => 'sessions'));
$this->assertEquals('bar', $storage2->read('foo'), 'values persist between instances');
}
public function testSessionDestroy()
{
$storage = new LegacyPdoSessionHandler($this->pdo, array('db_table' => 'sessions'));
$storage->write('foo', 'bar');
$this->assertCount(1, $this->pdo->query('SELECT * FROM sessions')->fetchAll());
$storage->destroy('foo');
$this->assertCount(0, $this->pdo->query('SELECT * FROM sessions')->fetchAll());
}
public function testSessionGC()
{
$storage = new LegacyPdoSessionHandler($this->pdo, array('db_table' => 'sessions'));
$storage->write('foo', 'bar');
$storage->write('baz', 'bar');
$this->assertCount(2, $this->pdo->query('SELECT * FROM sessions')->fetchAll());
$storage->gc(-1);
$this->assertCount(0, $this->pdo->query('SELECT * FROM sessions')->fetchAll());
}
public function testGetConnection()
{
$storage = new LegacyPdoSessionHandler($this->pdo, array('db_table' => 'sessions'), array());
$method = new \ReflectionMethod($storage, 'getConnection');
$method->setAccessible(true);
$this->assertInstanceOf('\PDO', $method->invoke($storage));
}
}