diff --git a/UPGRADE-2.5.md b/UPGRADE-2.5.md index be2110ceafa8f..de2b2264118c0 100644 --- a/UPGRADE-2.5.md +++ b/UPGRADE-2.5.md @@ -1,6 +1,26 @@ UPGRADE FROM 2.4 to 2.5 ======================= +HttpFoundation +-------------- + + * The PdoSessionHandler to store sessions in a database changed significantly. + - 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 recommended to + use the same database connection that you also use for your application logic. Otherwise you have to make sure + to access your database after the session is closed and committed. Instead of passing an existing connection + to the handler, you can now also pass a DSN string which will be used to lazy-connect when a session is started. + - Since accessing a session now blocks when the same session is still open, it is best practice to save the session + as soon as you don't need to write to it anymore. For example, read-only AJAX request to a session can save the + session immediately after opening it to increase concurrency. + - The expected schema of the table changed. + - Session data is binary text that can contain null bytes and thus should also be saved as-is in a binary column like BLOB. + For this reason, the handler does not base64_encode the data anymore. + - A new column to store the lifetime of a session is required. This allows to have different lifetimes per session + configured via session.gc_maxlifetime ini setting. + - You would need to migrate the table manually if you want to keep session information of your users. + - You could use PdoSessionHandler::createTable to initialize a correctly defined table depending on the used database vendor. + Routing ------- diff --git a/src/Symfony/Component/HttpFoundation/CHANGELOG.md b/src/Symfony/Component/HttpFoundation/CHANGELOG.md index 0da734bce2f1c..f1af94160385e 100644 --- a/src/Symfony/Component/HttpFoundation/CHANGELOG.md +++ b/src/Symfony/Component/HttpFoundation/CHANGELOG.md @@ -4,6 +4,13 @@ CHANGELOG 2.5.0 ----- + * PdoSessionHandler changes + - implemented session locking to prevent loss of data by concurrent access to the same session + - save session data in a binary column without base64_encode + - added lifetime column to the session table which allows to have different lifetimes for each session + - implemented lazy connections that are only opened when a session is used by either passing a dsn string explicitly + or falling back to session.save_path ini setting + - added a createTable method that initializes a correctly defined table depending on the database vendor * added `JsonResponse::setEncodingOptions()` & `JsonResponse::getEncodingOptions()` for easier manipulation of the options used while encoding data to JSON format. diff --git a/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/PdoSessionHandler.php b/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/PdoSessionHandler.php index 4cdf3a89856e8..ee22f8214a7d2 100644 --- a/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/PdoSessionHandler.php +++ b/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/PdoSessionHandler.php @@ -24,6 +24,11 @@ * for another to finish. So saving session in SQLite should only be considered for * development or prototypes. * + * 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. You can use createTable() + * to initialize a correctly defined table. + * * @see http://php.net/sessionhandlerinterface * * @author Fabien Potencier @@ -33,10 +38,15 @@ class PdoSessionHandler implements \SessionHandlerInterface { /** - * @var \PDO PDO instance + * @var \PDO|null PDO instance or null when not connected yet */ private $pdo; + /** + * @var string|null|false DSN string or null for session.save_path or false when lazy connection disabled + */ + private $dsn = false; + /** * @var string Database driver */ @@ -57,11 +67,31 @@ class PdoSessionHandler implements \SessionHandlerInterface */ private $dataCol; + /** + * @var string Column for lifetime + */ + private $lifetimeCol; + /** * @var string Column for timestamp */ private $timeCol; + /** + * @var string Username when lazy-connect + */ + private $username; + + /** + * @var string Password when lazy-connect + */ + private $password; + + /** + * @var array Connection options when lazy-connect + */ + private $connectionOptions = array(); + /** * @var bool Whether a transaction is active */ @@ -75,37 +105,100 @@ class PdoSessionHandler implements \SessionHandlerInterface /** * Constructor. * + * You can either pass an existing database connection as PDO instance or + * pass a DSN string that will be used to lazy-connect to the database + * when the session is actually used. Furthermore it's possible to pass null + * which will then use the session.save_path ini setting as PDO DSN parameter. + * Since locking uses a transaction between opening and closing a session, + * it's not recommended to use the same database connection that you also use + * for your application logic. + * * List of available options: * * db_table: The name of the table [default: sessions] * * 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_lifetime_col: The column where to store the lifetime [default: sess_lifetime] * * db_time_col: The column where to store the timestamp [default: sess_time] + * * db_username: The username when lazy-connect [default: ''] + * * db_password: The password when lazy-connect [default: ''] + * * db_connection_options: An array of driver-specific connection options [default: array()] * - * @param \PDO $pdo A \PDO instance - * @param array $options An associative array of DB options + * @param \PDO|string|null $pdoOrDsn A \PDO instance or DSN string or null + * @param array $options An associative array of DB options * * @throws \InvalidArgumentException When PDO error mode is not PDO::ERRMODE_EXCEPTION */ - public function __construct(\PDO $pdo, array $options = array()) + public function __construct($pdoOrDsn, array $options = array()) { - 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__)); - } + if ($pdoOrDsn instanceof \PDO) { + if (\PDO::ERRMODE_EXCEPTION !== $pdoOrDsn->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; - $this->driver = $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME); + $this->pdo = $pdoOrDsn; + $this->driver = $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME); + } else { + $this->dsn = $pdoOrDsn; + } $options = array_replace(array( - 'db_table' => 'sessions', - 'db_id_col' => 'sess_id', - 'db_data_col' => 'sess_data', - 'db_time_col' => 'sess_time', + 'db_table' => 'sessions', + 'db_id_col' => 'sess_id', + 'db_data_col' => 'sess_data', + 'db_lifetime_col' => 'sess_lifetime', + 'db_time_col' => 'sess_time', + 'db_username' => '', + 'db_password' => '', + 'db_connection_options' => array() ), $options); $this->table = $options['db_table']; $this->idCol = $options['db_id_col']; $this->dataCol = $options['db_data_col']; + $this->lifetimeCol = $options['db_lifetime_col']; $this->timeCol = $options['db_time_col']; + $this->username = $options['db_username']; + $this->password = $options['db_password']; + $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)); + } + + $this->pdo->exec($sql); } /** @@ -114,6 +207,9 @@ public function __construct(\PDO $pdo, array $options = array()) public function open($savePath, $sessionName) { $this->gcCalled = false; + if (null === $this->pdo) { + $this->connect($this->dsn ?: $savePath); + } return true; } @@ -130,23 +226,18 @@ public function read($sessionId) // We need to make sure we do not return session data that is already considered garbage according // to the session.gc_maxlifetime setting because gc() is called after read() and only sometimes. - $maxlifetime = (int) ini_get('session.gc_maxlifetime'); - $sql = "SELECT $this->dataCol FROM $this->table WHERE $this->idCol = :id AND $this->timeCol > :time"; + $sql = "SELECT $this->dataCol FROM $this->table WHERE $this->idCol = :id AND $this->lifetimeCol + $this->timeCol >= :time"; $stmt = $this->pdo->prepare($sql); $stmt->bindParam(':id', $sessionId, \PDO::PARAM_STR); - $stmt->bindValue(':time', time() - $maxlifetime, \PDO::PARAM_INT); + $stmt->bindValue(':time', time(), \PDO::PARAM_INT); $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 ''; + return $sessionRows ? $sessionRows[0][0] : ''; } catch (\PDOException $e) { $this->rollback(); @@ -192,21 +283,21 @@ public function destroy($sessionId) */ public function write($sessionId, $data) { - // Session data can contain non binary safe characters so we need to encode it. - $encoded = base64_encode($data); - // The session ID can be different from the one previously received in read() // when the session ID changed due to session_regenerate_id(). So we have to // do an insert or update even if we created a row in read() for locking. // We use a MERGE SQL query when supported by the database. + $maxlifetime = (int) ini_get('session.gc_maxlifetime'); + try { $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->bindParam(':data', $data, \PDO::PARAM_LOB); + $mergeStmt->bindParam(':lifetime', $maxlifetime, \PDO::PARAM_INT); $mergeStmt->bindValue(':time', time(), \PDO::PARAM_INT); $mergeStmt->execute(); @@ -214,10 +305,11 @@ public function write($sessionId, $data) } $updateStmt = $this->pdo->prepare( - "UPDATE $this->table SET $this->dataCol = :data, $this->timeCol = :time WHERE $this->idCol = :id" + "UPDATE $this->table SET $this->dataCol = :data, $this->lifetimeCol = :lifetime, $this->timeCol = :time WHERE $this->idCol = :id" ); $updateStmt->bindParam(':id', $sessionId, \PDO::PARAM_STR); - $updateStmt->bindParam(':data', $encoded, \PDO::PARAM_STR); + $updateStmt->bindParam(':data', $data, \PDO::PARAM_LOB); + $updateStmt->bindParam(':lifetime', $maxlifetime, \PDO::PARAM_INT); $updateStmt->bindValue(':time', time(), \PDO::PARAM_INT); $updateStmt->execute(); @@ -226,10 +318,11 @@ public function write($sessionId, $data) // unique anyway. if (!$updateStmt->rowCount()) { $insertStmt = $this->pdo->prepare( - "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->timeCol) VALUES (:id, :data, :time)" + "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time)" ); $insertStmt->bindParam(':id', $sessionId, \PDO::PARAM_STR); - $insertStmt->bindParam(':data', $encoded, \PDO::PARAM_STR); + $insertStmt->bindParam(':data', $data, \PDO::PARAM_LOB); + $insertStmt->bindParam(':lifetime', $maxlifetime, \PDO::PARAM_INT); $insertStmt->bindValue(':time', time(), \PDO::PARAM_INT); $insertStmt->execute(); } @@ -250,19 +343,33 @@ public function close() $this->commit(); if ($this->gcCalled) { - $maxlifetime = (int) ini_get('session.gc_maxlifetime'); - // delete the session records that have expired - $sql = "DELETE FROM $this->table WHERE $this->timeCol <= :time"; + $sql = "DELETE FROM $this->table WHERE $this->lifetimeCol + $this->timeCol < :time"; $stmt = $this->pdo->prepare($sql); - $stmt->bindValue(':time', time() - $maxlifetime, \PDO::PARAM_INT); + $stmt->bindValue(':time', time(), \PDO::PARAM_INT); $stmt->execute(); } + if (false !== $this->dsn) { + $this->pdo = null; + } + 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. * @@ -273,20 +380,14 @@ public function close() */ private function beginTransaction() { - if ($this->inTransaction) { - $this->rollback(); - - throw new \BadMethodCallException( - 'Session handler methods have been invoked in wrong sequence. ' . - 'Expected sequence: open() -> read() -> destroy() / write() -> close()'); - } - - if ('sqlite' === $this->driver) { - $this->pdo->exec('BEGIN IMMEDIATE TRANSACTION'); - } else { - $this->pdo->beginTransaction(); + if (!$this->inTransaction) { + if ('sqlite' === $this->driver) { + $this->pdo->exec('BEGIN IMMEDIATE TRANSACTION'); + } else { + $this->pdo->beginTransaction(); + } + $this->inTransaction = true; } - $this->inTransaction = true; } /** @@ -338,25 +439,27 @@ 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) { switch ($this->driver) { case 'mysql': // will also lock the row when actually nothing got updated (id = id) - $sql = "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->timeCol) VALUES (:id, :data, :time) " . + $sql = "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time) " . "ON DUPLICATE KEY UPDATE $this->idCol = $this->idCol"; break; case 'oci': // DUAL is Oracle specific dummy table $sql = "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 NOT MATCHED THEN INSERT ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time) " . "WHEN MATCHED THEN UPDATE SET $this->idCol = $this->idCol"; break; case 'sqlsrv': // MS SQL Server requires MERGE be terminated by semicolon $sql = "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 NOT MATCHED THEN INSERT ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time) " . "WHEN MATCHED THEN UPDATE SET $this->idCol = $this->idCol;"; break; case 'pgsql': @@ -367,8 +470,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. @@ -377,6 +482,7 @@ private function lockSession($sessionId) $stmt = $this->pdo->prepare($sql); $stmt->bindParam(':id', $sessionId, \PDO::PARAM_STR); $stmt->bindValue(':data', '', \PDO::PARAM_STR); + $stmt->bindValue(':lifetime', 0, \PDO::PARAM_INT); $stmt->bindValue(':time', time(), \PDO::PARAM_INT); $stmt->execute(); } @@ -390,20 +496,20 @@ private function getMergeSql() { switch ($this->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)"; + return "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time) " . + "ON DUPLICATE KEY UPDATE $this->dataCol = VALUES($this->dataCol), $this->lifetimeCol = VALUES($this->lifetimeCol), $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"; + "WHEN NOT MATCHED THEN INSERT ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time) " . + "WHEN MATCHED THEN UPDATE SET $this->dataCol = :data, $this->lifetimeCol = :lifetime, $this->timeCol = :time"; case 'sqlsrv': // 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, $this->timeCol = :time;"; + "WHEN NOT MATCHED THEN INSERT ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time) " . + "WHEN MATCHED THEN UPDATE SET $this->dataCol = :data, $this->lifetimeCol = :lifetime, $this->timeCol = :time;"; case 'sqlite': - return "INSERT OR REPLACE INTO $this->table ($this->idCol, $this->dataCol, $this->timeCol) VALUES (:id, :data, :time)"; + return "INSERT OR REPLACE INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time)"; } } @@ -414,6 +520,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 109addbcbd93a..3fa6cd58afe4b 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(255) PRIMARY KEY, sess_data TEXT, 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,24 +65,74 @@ 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 testReadWriteRead() + /** + * @expectedException \RuntimeException + */ + public function testCreateTableTwice() + { + $storage = new PdoSessionHandler($this->getMemorySqlitePdo()); + $storage->createTable(); + } + + public function testWithLazyDsnConnection() { - $storage = new PdoSessionHandler($this->pdo); + $dsn = $this->getPersistentSqliteDsn(); + + $storage = new PdoSessionHandler($dsn); + $storage->createTable(); $storage->open('', 'sid'); - $this->assertSame('', $storage->read('id'), 'New session returns empty string data'); + $data = $storage->read('id'); $storage->write('id', 'data'); $storage->close(); + $this->assertSame('', $data, 'New session returns empty string data'); $storage->open('', 'sid'); - $this->assertSame('data', $storage->read('id'), 'Written value can be read back correctly'); + $data = $storage->read('id'); + $storage->close(); + $this->assertSame('data', $data, 'Written value can be read back correctly'); + } + + public function testWithLazySavePathConnection() + { + $dsn = $this->getPersistentSqliteDsn(); + + // Open is called with what ini_set('session.save_path', $dsn) would mean + $storage = new PdoSessionHandler(null); + $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($dsn, 'sid'); + $data = $storage->read('id'); + $storage->close(); + $this->assertSame('data', $data, 'Written value can be read back correctly'); + } + + public function testReadWriteReadWithNullByte() + { + $sessionData = 'da' . "\0" . 'ta'; + + $storage = new PdoSessionHandler($this->getMemorySqlitePdo()); + $storage->open('', 'sid'); + $readData = $storage->read('id'); + $storage->write('id', $sessionData); + $storage->close(); + $this->assertSame('', $readData, 'New session returns empty string data'); + + $storage->open('', 'sid'); + $readData = $storage->read('id'); + $storage->close(); + $this->assertSame($sessionData, $readData, 'Written value can be read back correctly'); } /** @@ -69,7 +140,7 @@ public function testReadWriteRead() */ public function testWriteDifferentSessionIdThanRead() { - $storage = new PdoSessionHandler($this->pdo); + $storage = new PdoSessionHandler($this->getMemorySqlitePdo()); $storage->open('', 'sid'); $storage->read('id'); $storage->destroy('id'); @@ -77,65 +148,93 @@ public function testWriteDifferentSessionIdThanRead() $storage->close(); $storage->open('', 'sid'); - $this->assertSame('data_of_new_session_id', $storage->read('new_id'), 'Data of regenerated session id is available'); + $data = $storage->read('new_id'); $storage->close(); + + $this->assertSame('data_of_new_session_id', $data, 'Data of regenerated session id is available'); } - /** - * @expectedException \BadMethodCallException - */ - public function testWrongUsage() + public function testWrongUsageStillWorks() { - $storage = new PdoSessionHandler($this->pdo); + // wrong method sequence that should no happen, but still works + $storage = new PdoSessionHandler($this->getMemorySqlitePdo()); + $storage->write('id', 'data'); + $storage->write('other_id', 'other_data'); + $storage->destroy('inexistent'); $storage->open('', 'sid'); - $storage->read('id'); - $storage->read('id'); + $data = $storage->read('id'); + $otherData = $storage->read('other_id'); + $storage->close(); + + $this->assertSame('data', $data); + $this->assertSame('other_data', $otherData); } 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'); - $this->assertSame('', $storage->read('id'), 'Destroyed session returns empty string'); + $data = $storage->read('id'); $storage->close(); + $this->assertSame('', $data, 'Destroyed session returns empty string'); } public function testSessionGC() { - $previousLifeTime = ini_set('session.gc_maxlifetime', 0); - $storage = new PdoSessionHandler($this->pdo); + $previousLifeTime = ini_set('session.gc_maxlifetime', 1000); + $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()); $storage->open('', 'sid'); - $this->assertSame('', $storage->read('id'), 'Session already considered garbage, so not returning data even if it is not pruned yet'); - $storage->gc(0); + $storage->read('gc_id'); + 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, $pdo->query('SELECT COUNT(*) FROM sessions')->fetchColumn(), 'No session pruned because gc not called'); + + $storage->open('', 'sid'); + $data = $storage->read('gc_id'); + $storage->gc(-1); $storage->close(); - $this->assertEquals(0, $this->pdo->query('SELECT COUNT(*) FROM sessions')->fetchColumn()); 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, $pdo->query('SELECT COUNT(*) FROM sessions')->fetchColumn(), 'Expired session is pruned'); } public function testGetConnection() { - $storage = new PdoSessionHandler($this->pdo, array('db_table' => 'sessions'), array()); + $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);