diff --git a/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/PdoSessionHandler.php b/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/PdoSessionHandler.php index e5365dd31e22..dea848238000 100644 --- a/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/PdoSessionHandler.php +++ b/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/PdoSessionHandler.php @@ -26,7 +26,8 @@ * * Session data is a binary string that can contain non-printable characters like the null byte. * For this reason it must be saved in a binary column in the database like BLOB in MySQL. - * Saving it in a character column could corrupt the data. + * Saving it in a character column could corrupt the data. You can use createTable() + * to initialize a correctly defined table. * * @see http://php.net/sessionhandlerinterface * @@ -158,16 +159,58 @@ public function __construct($pdoOrDsn, array $options = array()) $this->connectionOptions = $options['db_connection_options']; } + /** + * Creates the table to store sessions which can be called once for setup. + * + * Session ID is saved in a VARCHAR(128) column because that is enough even for + * a 512 bit configured session.hash_function like Whirlpool. Session data is + * saved in a BLOB. One could also use a shorter inlined varbinary column + * if one was sure the data fits into it. + * + * @throws \PDOException When the table already exists + * @throws \DomainException When an unsupported PDO driver is used + */ + public function createTable() + { + // connect if we are not yet + $this->getConnection(); + + switch ($this->driver) { + case 'mysql': + $sql = "CREATE TABLE $this->table ($this->idCol VARCHAR(128) NOT NULL PRIMARY KEY, $this->dataCol BLOB NOT NULL, $this->lifetimeCol MEDIUMINT NOT NULL, $this->timeCol INTEGER NOT NULL) ENGINE = InnoDB"; + break; + case 'sqlite': + $sql = "CREATE TABLE $this->table ($this->idCol VARCHAR(128) NOT NULL PRIMARY KEY, $this->dataCol BLOB NOT NULL, $this->lifetimeCol MEDIUMINT NOT NULL, $this->timeCol INTEGER NOT NULL)"; + break; + case 'pgsql': + $sql = "CREATE TABLE $this->table ($this->idCol VARCHAR(128) NOT NULL PRIMARY KEY, $this->dataCol BYTEA NOT NULL, $this->lifetimeCol INTEGER NOT NULL, $this->timeCol INTEGER NOT NULL)"; + break; + case 'oci': + $sql = "CREATE TABLE $this->table ($this->idCol VARCHAR2(128) NOT NULL PRIMARY KEY, $this->dataCol BLOB NOT NULL, $this->lifetimeCol INTEGER NOT NULL, $this->timeCol INTEGER NOT NULL)"; + break; + case 'sqlsrv': + $sql = "CREATE TABLE $this->table ($this->idCol VARCHAR(128) NOT NULL PRIMARY KEY, $this->dataCol VARBINARY(MAX) NOT NULL, $this->lifetimeCol INTEGER NOT NULL, $this->timeCol INTEGER NOT NULL)"; + break; + default: + throw new \DomainException(sprintf('"%s" does not currently support PDO driver "%s".', __CLASS__, $this->driver)); + } + + try { + $this->pdo->exec($sql); + } catch (\PDOException $e) { + $this->rollback(); + + throw $e; + } + } + /** * {@inheritdoc} */ public function open($savePath, $sessionName) { - $this->gcCalled = false; if (null === $this->pdo) { - $this->pdo = new \PDO($this->dsn ?: $savePath, $this->username, $this->password, $this->connectionOptions); - $this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); - $this->driver = $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME); + $this->connect($this->dsn ?: $savePath); } return true; @@ -315,6 +358,8 @@ public function close() $this->commit(); if ($this->gcCalled) { + $this->gcCalled = false; + // delete the session records that have expired $sql = "DELETE FROM $this->table WHERE $this->lifetimeCol + $this->timeCol < :time"; @@ -330,6 +375,18 @@ public function close() return true; } + /** + * Lazy-connects to the database. + * + * @param string $dsn DSN string + */ + private function connect($dsn) + { + $this->pdo = new \PDO($dsn, $this->username, $this->password, $this->connectionOptions); + $this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); + $this->driver = $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME); + } + /** * Helper method to begin a transaction. * @@ -399,6 +456,8 @@ private function rollback() * INSERT when not found can result in a deadlock for one connection. * * @param string $sessionId Session ID + * + * @throws \DomainException When an unsupported PDO driver is used */ private function lockSession($sessionId) { @@ -429,8 +488,10 @@ private function lockSession($sessionId) $stmt->execute(); return; + case 'sqlite': + return; // we already locked when starting transaction default: - return; + throw new \DomainException(sprintf('"%s" does not currently support PDO driver "%s".', __CLASS__, $this->driver)); } // We create a DML lock for the session by inserting empty data or updating the row. @@ -478,6 +539,10 @@ private function getMergeSql() */ protected function getConnection() { + if (null === $this->pdo) { + $this->connect($this->dsn ?: ini_get('session.save_path')); + } + return $this->pdo; } } diff --git a/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/PdoSessionHandlerTest.php b/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/PdoSessionHandlerTest.php index 36da3645c556..3fa6cd58afe4 100644 --- a/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/PdoSessionHandlerTest.php +++ b/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/PdoSessionHandlerTest.php @@ -15,18 +15,38 @@ class PdoSessionHandlerTest extends \PHPUnit_Framework_TestCase { - private $pdo; + private $dbFile; protected function setUp() { if (!class_exists('PDO') || !in_array('sqlite', \PDO::getAvailableDrivers())) { $this->markTestSkipped('This test requires SQLite support in your environment'); } + } + + protected function tearDown() + { + // make sure the temporary database file is deleted when it has been created (even when a test fails) + if ($this->dbFile) { + @unlink($this->dbFile); + } + } + + protected function getPersistentSqliteDsn() + { + $this->dbFile = tempnam(sys_get_temp_dir(), 'sf2_sqlite_sessions'); - $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 BLOB, sess_lifetime MEDIUMINT, sess_time INTEGER)'; - $this->pdo->exec($sql); + return 'sqlite:' . $this->dbFile; + } + + protected function getMemorySqlitePdo() + { + $pdo = new \PDO('sqlite::memory:'); + $pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); + $storage = new PdoSessionHandler($pdo); + $storage->createTable(); + + return $pdo; } /** @@ -34,9 +54,10 @@ protected function setUp() */ public function testWrongPdoErrMode() { - $this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_SILENT); + $pdo = $this->getMemorySqlitePdo(); + $pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_SILENT); - $storage = new PdoSessionHandler($this->pdo); + $storage = new PdoSessionHandler($pdo); } /** @@ -44,26 +65,28 @@ public function testWrongPdoErrMode() */ public function testInexistentTable() { - $storage = new PdoSessionHandler($this->pdo, array('db_table' => 'inexistent_table')); + $storage = new PdoSessionHandler($this->getMemorySqlitePdo(), array('db_table' => 'inexistent_table')); $storage->open('', 'sid'); $storage->read('id'); $storage->write('id', 'data'); $storage->close(); } - public function testWithLazyDnsConnection() + /** + * @expectedException \RuntimeException + */ + public function testCreateTableTwice() { - $dbFile = tempnam(sys_get_temp_dir(), 'sf2_sqlite_sessions'); - if (file_exists($dbFile)) { - @unlink($dbFile); - } + $storage = new PdoSessionHandler($this->getMemorySqlitePdo()); + $storage->createTable(); + } - $pdo = new \PDO('sqlite:' . $dbFile); - $sql = 'CREATE TABLE sessions (sess_id VARCHAR(128) PRIMARY KEY, sess_data BLOB, sess_lifetime MEDIUMINT, sess_time INTEGER)'; - $pdo->exec($sql); - $pdo = null; + public function testWithLazyDsnConnection() + { + $dsn = $this->getPersistentSqliteDsn(); - $storage = new PdoSessionHandler('sqlite:' . $dbFile); + $storage = new PdoSessionHandler($dsn); + $storage->createTable(); $storage->open('', 'sid'); $data = $storage->read('id'); $storage->write('id', 'data'); @@ -74,43 +97,32 @@ public function testWithLazyDnsConnection() $data = $storage->read('id'); $storage->close(); $this->assertSame('data', $data, 'Written value can be read back correctly'); - - @unlink($dbFile); } public function testWithLazySavePathConnection() { - $dbFile = tempnam(sys_get_temp_dir(), 'sf2_sqlite_sessions'); - if (file_exists($dbFile)) { - @unlink($dbFile); - } + $dsn = $this->getPersistentSqliteDsn(); - $pdo = new \PDO('sqlite:' . $dbFile); - $sql = 'CREATE TABLE sessions (sess_id VARCHAR(128) PRIMARY KEY, sess_data BLOB, sess_lifetime MEDIUMINT, sess_time INTEGER)'; - $pdo->exec($sql); - $pdo = null; - - // Open is called with what ini_set('session.save_path', 'sqlite:' . $dbFile) would mean + // Open is called with what ini_set('session.save_path', $dsn) would mean $storage = new PdoSessionHandler(null); - $storage->open('sqlite:' . $dbFile, 'sid'); + $storage->open($dsn, 'sid'); + $storage->createTable(); $data = $storage->read('id'); $storage->write('id', 'data'); $storage->close(); $this->assertSame('', $data, 'New session returns empty string data'); - $storage->open('sqlite:' . $dbFile, 'sid'); + $storage->open($dsn, 'sid'); $data = $storage->read('id'); $storage->close(); $this->assertSame('data', $data, 'Written value can be read back correctly'); - - @unlink($dbFile); } public function testReadWriteReadWithNullByte() { $sessionData = 'da' . "\0" . 'ta'; - $storage = new PdoSessionHandler($this->pdo); + $storage = new PdoSessionHandler($this->getMemorySqlitePdo()); $storage->open('', 'sid'); $readData = $storage->read('id'); $storage->write('id', $sessionData); @@ -128,7 +140,7 @@ public function testReadWriteReadWithNullByte() */ public function testWriteDifferentSessionIdThanRead() { - $storage = new PdoSessionHandler($this->pdo); + $storage = new PdoSessionHandler($this->getMemorySqlitePdo()); $storage->open('', 'sid'); $storage->read('id'); $storage->destroy('id'); @@ -139,13 +151,13 @@ public function testWriteDifferentSessionIdThanRead() $data = $storage->read('new_id'); $storage->close(); - $this->assertSame('data_of_new_session_id', $data, 'Data of regenerated session id is available'); + $this->assertSame('data_of_new_session_id', $data, 'Data of regenerated session id is available'); } public function testWrongUsageStillWorks() { // wrong method sequence that should no happen, but still works - $storage = new PdoSessionHandler($this->pdo); + $storage = new PdoSessionHandler($this->getMemorySqlitePdo()); $storage->write('id', 'data'); $storage->write('other_id', 'other_data'); $storage->destroy('inexistent'); @@ -160,19 +172,20 @@ public function testWrongUsageStillWorks() public function testSessionDestroy() { - $storage = new PdoSessionHandler($this->pdo); + $pdo = $this->getMemorySqlitePdo(); + $storage = new PdoSessionHandler($pdo); $storage->open('', 'sid'); $storage->read('id'); $storage->write('id', 'data'); $storage->close(); - $this->assertEquals(1, $this->pdo->query('SELECT COUNT(*) FROM sessions')->fetchColumn()); + $this->assertEquals(1, $pdo->query('SELECT COUNT(*) FROM sessions')->fetchColumn()); $storage->open('', 'sid'); $storage->read('id'); $storage->destroy('id'); $storage->close(); - $this->assertEquals(0, $this->pdo->query('SELECT COUNT(*) FROM sessions')->fetchColumn()); + $this->assertEquals(0, $pdo->query('SELECT COUNT(*) FROM sessions')->fetchColumn()); $storage->open('', 'sid'); $data = $storage->read('id'); @@ -183,7 +196,8 @@ public function testSessionDestroy() public function testSessionGC() { $previousLifeTime = ini_set('session.gc_maxlifetime', 1000); - $storage = new PdoSessionHandler($this->pdo); + $pdo = $this->getMemorySqlitePdo(); + $storage = new PdoSessionHandler($pdo); $storage->open('', 'sid'); $storage->read('id'); @@ -195,7 +209,7 @@ public function testSessionGC() ini_set('session.gc_maxlifetime', -1); // test that you can set lifetime of a session after it has been read $storage->write('gc_id', 'data'); $storage->close(); - $this->assertEquals(2, $this->pdo->query('SELECT COUNT(*) FROM sessions')->fetchColumn(), 'No session pruned because gc not called'); + $this->assertEquals(2, $pdo->query('SELECT COUNT(*) FROM sessions')->fetchColumn(), 'No session pruned because gc not called'); $storage->open('', 'sid'); $data = $storage->read('gc_id'); @@ -205,12 +219,22 @@ public function testSessionGC() ini_set('session.gc_maxlifetime', $previousLifeTime); $this->assertSame('', $data, 'Session already considered garbage, so not returning data even if it is not pruned yet'); - $this->assertEquals(1, $this->pdo->query('SELECT COUNT(*) FROM sessions')->fetchColumn(), 'Expired session is pruned'); + $this->assertEquals(1, $pdo->query('SELECT COUNT(*) FROM sessions')->fetchColumn(), 'Expired session is pruned'); } public function testGetConnection() { - $storage = new PdoSessionHandler($this->pdo); + $storage = new PdoSessionHandler($this->getMemorySqlitePdo()); + + $method = new \ReflectionMethod($storage, 'getConnection'); + $method->setAccessible(true); + + $this->assertInstanceOf('\PDO', $method->invoke($storage)); + } + + public function testGetConnectionConnectsIfNeeded() + { + $storage = new PdoSessionHandler('sqlite::memory:'); $method = new \ReflectionMethod($storage, 'getConnection'); $method->setAccessible(true);