From 010eb66e547325a5a6cbf6220b2a7dda0b22849d Mon Sep 17 00:00:00 2001 From: Jeremy Livingston Date: Mon, 15 Dec 2014 13:23:29 -0500 Subject: [PATCH 1/2] 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. --- UPGRADE-2.6.md | 2 + .../Handler/LegacyPdoSessionHandler.php | 268 ++++++++++++++++++ .../Handler/LegacyPdoSessionHandlerTest.php | 111 ++++++++ 3 files changed, 381 insertions(+) create mode 100644 src/Symfony/Component/HttpFoundation/Session/Storage/Handler/LegacyPdoSessionHandler.php create mode 100644 src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/LegacyPdoSessionHandlerTest.php diff --git a/UPGRADE-2.6.md b/UPGRADE-2.6.md index 265f9a44f511e..56c5ec8e688f7 100644 --- a/UPGRADE-2.6.md +++ b/UPGRADE-2.6.md @@ -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-compatability 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 diff --git a/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/LegacyPdoSessionHandler.php b/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/LegacyPdoSessionHandler.php new file mode 100644 index 0000000000000..fff83315aacbe --- /dev/null +++ b/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/LegacyPdoSessionHandler.php @@ -0,0 +1,268 @@ + + * + * 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 + * @author Michael Williams + * @author Tobias Schultze + * + * @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; + } +} diff --git a/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/LegacyPdoSessionHandlerTest.php b/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/LegacyPdoSessionHandlerTest.php new file mode 100644 index 0000000000000..5fa6255414a2d --- /dev/null +++ b/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/LegacyPdoSessionHandlerTest.php @@ -0,0 +1,111 @@ + + * + * 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)); + } +} From 203b83955daae20bf20af82ec875758cf3e90462 Mon Sep 17 00:00:00 2001 From: Jeremy Livingston Date: Thu, 18 Dec 2014 17:57:50 -0500 Subject: [PATCH 2/2] Fix misspelling of "compatibility" There were a few incorrect spellings of "compatibility" in the 2.6 upgrade file. Update all occurrences of the typo to be the correct spelling. --- UPGRADE-2.6.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/UPGRADE-2.6.md b/UPGRADE-2.6.md index 56c5ec8e688f7..01d3ac5a6c3f5 100644 --- a/UPGRADE-2.6.md +++ b/UPGRADE-2.6.md @@ -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,7 +125,7 @@ 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-compatability issues when upgrading. + 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.