diff --git a/UPGRADE-4.4.md b/UPGRADE-4.4.md index 46f76af7cbf73..3d17351ae15f9 100644 --- a/UPGRADE-4.4.md +++ b/UPGRADE-4.4.md @@ -285,3 +285,8 @@ WebServerBundle --------------- * The bundle is deprecated and will be removed in 5.0. + +HttpFoundation +-------------- + + * The `PdoSessionHandler` option `db_lifetime_col` has been deprecated in favor of `db_time_col` (an indexed time column). diff --git a/UPGRADE-5.0.md b/UPGRADE-5.0.md index c03573af53372..51b424a39897e 100644 --- a/UPGRADE-5.0.md +++ b/UPGRADE-5.0.md @@ -287,6 +287,7 @@ HttpFoundation use `Symfony\Component\Mime\FileinfoMimeTypeGuesser` instead. * `ApacheRequest` has been removed, use the `Request` class instead. * The third argument of the `HeaderBag::get()` method has been removed, use method `all()` instead. + * The `PdoSessionHandler` option `db_lifetime_col` has been removed in favor of `db_time_col` (an indexed time column). HttpKernel ---------- diff --git a/src/Symfony/Component/HttpFoundation/CHANGELOG.md b/src/Symfony/Component/HttpFoundation/CHANGELOG.md index 62aa6be13d1b4..c679a3d31096a 100644 --- a/src/Symfony/Component/HttpFoundation/CHANGELOG.md +++ b/src/Symfony/Component/HttpFoundation/CHANGELOG.md @@ -7,6 +7,8 @@ CHANGELOG * passing arguments to `Request::isMethodSafe()` is deprecated. * `ApacheRequest` is deprecated, use the `Request` class instead. * passing a third argument to `HeaderBag::get()` is deprecated, use method `all()` instead + * PdoSessionHandler: Deprecated the `lifetime` column + 4.3.0 ----- diff --git a/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/PdoSessionHandler.php b/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/PdoSessionHandler.php index f1648adaa52e4..af90c081f73e4 100644 --- a/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/PdoSessionHandler.php +++ b/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/PdoSessionHandler.php @@ -97,6 +97,8 @@ class PdoSessionHandler extends AbstractSessionHandler /** * @var string Column for lifetime + * + * @deprecated since version 4.4, to be removed in 5.0 */ private $lifetimeCol = 'sess_lifetime'; @@ -192,6 +194,10 @@ public function __construct($pdoOrDsn = null, array $options = []) $this->password = isset($options['db_password']) ? $options['db_password'] : $this->password; $this->connectionOptions = isset($options['db_connection_options']) ? $options['db_connection_options'] : $this->connectionOptions; $this->lockMode = isset($options['lock_mode']) ? $options['lock_mode'] : $this->lockMode; + + if (false !== $this->lifetimeCol) { + @trigger_error(sprintf('The "%s" column is deprecated since version 4.4 and won\'t be used anymore in 5.0. Migrate your session database then set the "db_lifetime_col" option to false to opt-in for the new behavior.', $this->lifetimeCol), E_USER_DEPRECATED); + } } /** @@ -210,29 +216,51 @@ public function createTable() // connect if we are not yet $this->getConnection(); - switch ($this->driver) { - case 'mysql': - // We use varbinary for the ID column because it prevents unwanted conversions: - // - character set conversions between server and client - // - trailing space removal - // - case-insensitivity - // - language processing like é == e - $sql = "CREATE TABLE $this->table ($this->idCol VARBINARY(128) NOT NULL PRIMARY KEY, $this->dataCol BLOB NOT NULL, $this->lifetimeCol MEDIUMINT NOT NULL, $this->timeCol INTEGER UNSIGNED NOT NULL) COLLATE utf8_bin, ENGINE = InnoDB"; - break; - case 'sqlite': - $sql = "CREATE TABLE $this->table ($this->idCol TEXT NOT NULL PRIMARY KEY, $this->dataCol BLOB NOT NULL, $this->lifetimeCol INTEGER 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('Creating the session table is currently not implemented for PDO driver "%s".', $this->driver)); + if (false === $this->lifetimeCol) { + switch ($this->driver) { + case 'mysql': + // We use varbinary for the ID column because it prevents unwanted conversions: + // - character set conversions between server and client + // - trailing space removal + // - case-insensitivity + // - language processing like é == e + $sql = "CREATE TABLE $this->table ($this->idCol VARBINARY(128) NOT NULL PRIMARY KEY, $this->dataCol BLOB NOT NULL, $this->timeCol INTEGER UNSIGNED NOT NULL, KEY {$this->table}_{$this->timeCol}_idx ($this->timeCol)) COLLATE utf8_bin, ENGINE = InnoDB"; + break; + case 'sqlite': + $sql = "BEGIN; CREATE TABLE $this->table ($this->idCol TEXT NOT NULL PRIMARY KEY, $this->dataCol BLOB NOT NULL, $this->timeCol INTEGER NOT NULL); CREATE INDEX {$this->table}_{$this->timeCol}_idx ON $this->table ($this->timeCol); COMMIT"; + break; + case 'pgsql': + $sql = "BEGIN; CREATE TABLE $this->table ($this->idCol VARCHAR(128) NOT NULL PRIMARY KEY, $this->dataCol BYTEA NOT NULL, $this->timeCol INTEGER NOT NULL); CREATE INDEX {$this->table}_{$this->timeCol}_idx ON $this->table ($this->timeCol); COMMIT"; + break; + case 'oci': + $sql = "CREATE TABLE $this->table ($this->idCol VARCHAR2(128) NOT NULL PRIMARY KEY, $this->dataCol BLOB 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->timeCol INTEGER NOT NULL)"; + break; + default: + throw new \DomainException(sprintf('Creating the session table is currently not implemented for PDO driver "%s".', $this->driver)); + } + } else { + switch ($this->driver) { + case 'mysql': + $sql = "CREATE TABLE $this->table ($this->idCol VARBINARY(128) NOT NULL PRIMARY KEY, $this->dataCol BLOB NOT NULL, $this->lifetimeCol MEDIUMINT NOT NULL, $this->timeCol INTEGER UNSIGNED NOT NULL) COLLATE utf8_bin, ENGINE = InnoDB"; + break; + case 'sqlite': + $sql = "CREATE TABLE $this->table ($this->idCol TEXT NOT NULL PRIMARY KEY, $this->dataCol BLOB NOT NULL, $this->lifetimeCol INTEGER 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('Creating the session table is currently not implemented for PDO driver "%s".', $this->driver)); + } } try { @@ -402,7 +430,9 @@ public function close() $this->gcCalled = false; // delete the session records that have expired - if ('mysql' === $this->driver) { + if (false === $this->lifetimeCol) { + $sql = "DELETE FROM $this->table WHERE $this->timeCol < :time"; + } elseif ('mysql' === $this->driver) { $sql = "DELETE FROM $this->table WHERE $this->lifetimeCol + $this->timeCol < :time"; } else { $sql = "DELETE FROM $this->table WHERE $this->lifetimeCol < :time - $this->timeCol"; @@ -616,7 +646,13 @@ protected function doRead($sessionId) $sessionRows = $selectStmt->fetchAll(\PDO::FETCH_NUM); if ($sessionRows) { - if ($sessionRows[0][1] + $sessionRows[0][2] < time()) { + if (false !== $this->lifetimeCol) { + if ($sessionRows[0][1] + $sessionRows[0][2] < time()) { + $this->sessionExpired = true; + + return ''; + } + } elseif ($sessionRows[0][1] < time()) { $this->sessionExpired = true; return ''; @@ -751,9 +787,17 @@ private function getSelectSql(): string case 'mysql': case 'oci': case 'pgsql': - return "SELECT $this->dataCol, $this->lifetimeCol, $this->timeCol FROM $this->table WHERE $this->idCol = :id FOR UPDATE"; + if (false !== $this->lifetimeCol) { + return "SELECT $this->dataCol, $this->lifetimeCol, $this->timeCol FROM $this->table WHERE $this->idCol = :id FOR UPDATE"; + } + + return "SELECT $this->dataCol, $this->timeCol FROM $this->table WHERE $this->idCol = :id FOR UPDATE"; case 'sqlsrv': - return "SELECT $this->dataCol, $this->lifetimeCol, $this->timeCol FROM $this->table WITH (UPDLOCK, ROWLOCK) WHERE $this->idCol = :id"; + if (false !== $this->lifetimeCol) { + return "SELECT $this->dataCol, $this->lifetimeCol, $this->timeCol FROM $this->table WITH (UPDLOCK, ROWLOCK) WHERE $this->idCol = :id"; + } + + return "SELECT $this->dataCol, $this->timeCol FROM $this->table WITH (UPDLOCK, ROWLOCK) WHERE $this->idCol = :id"; case 'sqlite': // we already locked when starting transaction break; @@ -762,7 +806,11 @@ private function getSelectSql(): string } } - return "SELECT $this->dataCol, $this->lifetimeCol, $this->timeCol FROM $this->table WHERE $this->idCol = :id"; + if (false !== $this->lifetimeCol) { + return "SELECT $this->dataCol, $this->lifetimeCol, $this->timeCol FROM $this->table WHERE $this->idCol = :id"; + } + + return "SELECT $this->dataCol, $this->timeCol FROM $this->table WHERE $this->idCol = :id"; } /** @@ -775,19 +823,26 @@ private function getInsertStatement(string $sessionId, string $sessionData, int $data = fopen('php://memory', 'r+'); fwrite($data, $sessionData); rewind($data); - $sql = "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, EMPTY_BLOB(), :lifetime, :time) RETURNING $this->dataCol into :data"; + if (false === $this->lifetimeCol) { + $sql = "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->timeCol) VALUES (:id, EMPTY_BLOB(), :time) RETURNING $this->dataCol into :data"; + } else { + $sql = "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, EMPTY_BLOB(), 0, :time) RETURNING $this->dataCol into :data"; + } break; default: $data = $sessionData; - $sql = "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time)"; + if (false === $this->lifetimeCol) { + $sql = "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->timeCol) VALUES (:id, :data, :time)"; + } else { + $sql = "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, 0, :time)"; + } break; } $stmt = $this->pdo->prepare($sql); $stmt->bindParam(':id', $sessionId, \PDO::PARAM_STR); $stmt->bindParam(':data', $data, \PDO::PARAM_LOB); - $stmt->bindParam(':lifetime', $maxlifetime, \PDO::PARAM_INT); - $stmt->bindValue(':time', time(), \PDO::PARAM_INT); + $stmt->bindValue(':time', time() + $maxlifetime, \PDO::PARAM_INT); return $stmt; } @@ -802,11 +857,11 @@ private function getUpdateStatement(string $sessionId, string $sessionData, int $data = fopen('php://memory', 'r+'); fwrite($data, $sessionData); rewind($data); - $sql = "UPDATE $this->table SET $this->dataCol = EMPTY_BLOB(), $this->lifetimeCol = :lifetime, $this->timeCol = :time WHERE $this->idCol = :id RETURNING $this->dataCol into :data"; + $sql = "UPDATE $this->table SET $this->dataCol = EMPTY_BLOB(), $this->timeCol = :time WHERE $this->idCol = :id RETURNING $this->dataCol into :data"; break; default: $data = $sessionData; - $sql = "UPDATE $this->table SET $this->dataCol = :data, $this->lifetimeCol = :lifetime, $this->timeCol = :time WHERE $this->idCol = :id"; + $sql = "UPDATE $this->table SET $this->dataCol = :data, $this->timeCol = :time WHERE $this->idCol = :id"; break; } @@ -814,7 +869,7 @@ private function getUpdateStatement(string $sessionId, string $sessionData, int $stmt->bindParam(':id', $sessionId, \PDO::PARAM_STR); $stmt->bindParam(':data', $data, \PDO::PARAM_LOB); $stmt->bindParam(':lifetime', $maxlifetime, \PDO::PARAM_INT); - $stmt->bindValue(':time', time(), \PDO::PARAM_INT); + $stmt->bindValue(':time', time() + $maxlifetime, \PDO::PARAM_INT); return $stmt; } @@ -824,28 +879,54 @@ private function getUpdateStatement(string $sessionId, string $sessionData, int */ private function getMergeStatement(string $sessionId, string $data, int $maxlifetime): ?\PDOStatement { - switch (true) { - case 'mysql' === $this->driver: - $mergeSql = "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)"; - break; - case 'sqlsrv' === $this->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 https://weblogs.sqlteam.com/dang/2009/01/31/upsert-race-condition-with-merge/ - $mergeSql = "MERGE INTO $this->table WITH (HOLDLOCK) USING (SELECT 1 AS dummy) AS src ON ($this->idCol = ?) ". - "WHEN NOT MATCHED THEN INSERT ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (?, ?, ?, ?) ". - "WHEN MATCHED THEN UPDATE SET $this->dataCol = ?, $this->lifetimeCol = ?, $this->timeCol = ?;"; - break; - case 'sqlite' === $this->driver: - $mergeSql = "INSERT OR REPLACE INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time)"; - break; - case 'pgsql' === $this->driver && version_compare($this->pdo->getAttribute(\PDO::ATTR_SERVER_VERSION), '9.5', '>='): - $mergeSql = "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time) ". - "ON CONFLICT ($this->idCol) DO UPDATE SET ($this->dataCol, $this->lifetimeCol, $this->timeCol) = (EXCLUDED.$this->dataCol, EXCLUDED.$this->lifetimeCol, EXCLUDED.$this->timeCol)"; - break; - default: - // MERGE is not supported with LOBs: https://oracle.com/technetwork/articles/fuecks-lobs-095315.html - return null; + if (false === $this->lifetimeCol) { + switch (true) { + case 'mysql' === $this->driver: + $mergeSql = "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)"; + break; + case 'sqlsrv' === $this->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 https://weblogs.sqlteam.com/dang/2009/01/31/upsert-race-condition-with-merge/ + $mergeSql = "MERGE INTO $this->table WITH (HOLDLOCK) USING (SELECT 1 AS dummy) AS src ON ($this->idCol = ?) ". + "WHEN NOT MATCHED THEN INSERT ($this->idCol, $this->dataCol, $this->timeCol) VALUES (?, ?, ?) ". + "WHEN MATCHED THEN UPDATE SET $this->dataCol = ?, $this->timeCol = ?;"; + break; + case 'sqlite' === $this->driver: + $mergeSql = "INSERT OR REPLACE INTO $this->table ($this->idCol, $this->dataCol, $this->timeCol) VALUES (:id, :data, :time)"; + break; + case 'pgsql' === $this->driver && version_compare($this->pdo->getAttribute(\PDO::ATTR_SERVER_VERSION), '9.5', '>='): + $mergeSql = "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->timeCol) VALUES (:id, :data, :time) ". + "ON CONFLICT ($this->idCol) DO UPDATE SET ($this->dataCol, $this->timeCol) = (EXCLUDED.$this->dataCol, EXCLUDED.$this->timeCol)"; + break; + default: + // MERGE is not supported with LOBs: https://oracle.com/technetwork/articles/fuecks-lobs-095315.html + return null; + } + } else { + switch (true) { + case 'mysql' === $this->driver: + $mergeSql = "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, 0, :time) ". + "ON DUPLICATE KEY UPDATE $this->dataCol = VALUES($this->dataCol), $this->lifetimeCol = VALUES($this->lifetimeCol), $this->timeCol = VALUES($this->timeCol)"; + break; + case 'sqlsrv' === $this->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 https://weblogs.sqlteam.com/dang/2009/01/31/upsert-race-condition-with-merge/ + $mergeSql = "MERGE INTO $this->table WITH (HOLDLOCK) USING (SELECT 1 AS dummy) AS src ON ($this->idCol = ?) ". + "WHEN NOT MATCHED THEN INSERT ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (?, ?, 0, ?) ". + "WHEN MATCHED THEN UPDATE SET $this->dataCol = ?, $this->lifetimeCol = 0, $this->timeCol = ?;"; + break; + case 'sqlite' === $this->driver: + $mergeSql = "INSERT OR REPLACE INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, 0, :time)"; + break; + case 'pgsql' === $this->driver && version_compare($this->pdo->getAttribute(\PDO::ATTR_SERVER_VERSION), '9.5', '>='): + $mergeSql = "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, 0, :time) ". + "ON CONFLICT ($this->idCol) DO UPDATE SET ($this->dataCol, $this->lifetimeCol, $this->timeCol) = (EXCLUDED.$this->dataCol, EXCLUDED.$this->lifetimeCol, EXCLUDED.$this->timeCol)"; + break; + default: + // MERGE is not supported with LOBs: https://oracle.com/technetwork/articles/fuecks-lobs-095315.html + return null; + } } $mergeStmt = $this->pdo->prepare($mergeSql); @@ -854,16 +935,13 @@ private function getMergeStatement(string $sessionId, string $data, int $maxlife $mergeStmt->bindParam(1, $sessionId, \PDO::PARAM_STR); $mergeStmt->bindParam(2, $sessionId, \PDO::PARAM_STR); $mergeStmt->bindParam(3, $data, \PDO::PARAM_LOB); - $mergeStmt->bindParam(4, $maxlifetime, \PDO::PARAM_INT); - $mergeStmt->bindValue(5, time(), \PDO::PARAM_INT); - $mergeStmt->bindParam(6, $data, \PDO::PARAM_LOB); - $mergeStmt->bindParam(7, $maxlifetime, \PDO::PARAM_INT); - $mergeStmt->bindValue(8, time(), \PDO::PARAM_INT); + $mergeStmt->bindValue(4, time() + $maxlifetime, \PDO::PARAM_INT); + $mergeStmt->bindParam(5, $data, \PDO::PARAM_LOB); + $mergeStmt->bindValue(6, time() + $maxlifetime, \PDO::PARAM_INT); } else { $mergeStmt->bindParam(':id', $sessionId, \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->bindValue(':time', time() + $maxlifetime, \PDO::PARAM_INT); } return $mergeStmt; 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 d080ce3ca6e5c..b738136061a87 100644 --- a/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/PdoSessionHandlerTest.php +++ b/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/PdoSessionHandlerTest.php @@ -38,29 +38,54 @@ protected function getPersistentSqliteDsn() return 'sqlite:'.$this->dbFile; } - protected function getMemorySqlitePdo() + protected function getPdoMemorySqlite(array $attributes = [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]) { $pdo = new \PDO('sqlite::memory:'); $pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); - $storage = new PdoSessionHandler($pdo); - $storage->createTable(); + foreach ($attributes as $i => $v) { + $pdo->setAttribute($i, $v); + } return $pdo; } + private function getSessionHandler($pdoOrDsn = null, array $options = ['db_lifetime_col' => false], bool $createTable = true) + { + if (null === $pdoOrDsn) { + $pdoOrDsn = $this->getPdoMemorySqlite(); + } + + $storage = new PdoSessionHandler($pdoOrDsn, $options); + if ($createTable) { + $storage->createTable(); + } + + return $storage; + } + public function testWrongPdoErrMode() { $this->expectException('InvalidArgumentException'); - $pdo = $this->getMemorySqlitePdo(); - $pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_SILENT); - - $storage = new PdoSessionHandler($pdo); + $this->getSessionHandler($this->getPdoMemorySqlite([\PDO::ATTR_ERRMODE => \PDO::ERRMODE_SILENT])); } - public function testInexistentTable() + /** + * @group legacy + * @expectedDeprecation The "%s" column is deprecated since version 4.4 and won't be used anymore in 5.0. Migrate your session database then set the "db_lifetime_col" option to false to opt-in for the new behavior. + */ + public function testLegacyNonexistentTable() { $this->expectException('RuntimeException'); - $storage = new PdoSessionHandler($this->getMemorySqlitePdo(), ['db_table' => 'inexistent_table']); + $this->doTestNonexistentTable($this->getSessionHandler(null, ['db_lifetime_col' => 'foobar', 'db_table' => 'nonexistent_table'], false)); + } + + public function testNonexistentTable() + { + $this->doTestNonexistentTable($this->getSessionHandler(null, ['db_lifetime_col' => false, 'db_table' => 'nonexistent_table'], false)); + } + + private function doTestNonexistentTable(PdoSessionHandler $storage) + { $storage->open('', 'sid'); $storage->read('id'); $storage->write('id', 'data'); @@ -70,41 +95,59 @@ public function testInexistentTable() public function testCreateTableTwice() { $this->expectException('RuntimeException'); - $storage = new PdoSessionHandler($this->getMemorySqlitePdo()); + $storage = $this->getSessionHandler(); $storage->createTable(); } - public function testWithLazyDsnConnection() + /** + * @group legacy + */ + public function testLegacyCreateTableTwice() { - $dsn = $this->getPersistentSqliteDsn(); + $this->expectException(\RuntimeException::class); - $storage = new PdoSessionHandler($dsn); + $storage = $this->getSessionHandler(); $storage->createTable(); + } + + public function testWithLazyDsnConnection() + { + $storage = $this->getSessionHandler($this->getPersistentSqliteDsn()); $storage->open('', 'sid'); $data = $storage->read('id'); $storage->write('id', 'data'); $storage->close(); $this->assertSame('', $data, 'New session returns empty string data'); - $storage->open('', 'sid'); $data = $storage->read('id'); $storage->close(); $this->assertSame('data', $data, 'Written value can be read back correctly'); } - public function testWithLazySavePathConnection() + public function testWithLazySavePathConnection(): void { - $dsn = $this->getPersistentSqliteDsn(); + $this->doTestReadWriteReadWithNullByte($this->getSessionHandler(null, ['db_lifetime_col' => false], false)); + } + + /** + * @group legacy + * @expectedDeprecation The "%s" column is deprecated since version 4.4 and won't be used anymore in 5.0. Migrate your session database then set the "db_lifetime_col" option to false to opt-in for the new behavior. + */ + public function testLegacyWithLazySavePathConnection(): void + { + $this->doTestReadWriteReadWithNullByte($this->getSessionHandler(null, ['db_lifetime_col' => 'foobar'], false)); + } + private function doTestWithLazySavePathConnection(PdoSessionHandler $storage) + { + $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(); @@ -113,15 +156,27 @@ public function testWithLazySavePathConnection() public function testReadWriteReadWithNullByte() { - $sessionData = 'da'."\0".'ta'; + $this->doTestReadWriteReadWithNullByte($this->getSessionHandler(null, ['db_lifetime_col' => false], false)); + } - $storage = new PdoSessionHandler($this->getMemorySqlitePdo()); + /** + * @group legacy + * @expectedDeprecation The "%s" column is deprecated since version 4.4 and won't be used anymore in 5.0. Migrate your session database then set the "db_lifetime_col" option to false to opt-in for the new behavior. + */ + public function testLegacyReadWriteReadWithNullByte() + { + $this->doTestReadWriteReadWithNullByte($this->getSessionHandler(null, ['db_lifetime_col' => 'foobar'], false)); + } + + private function doTestReadWriteReadWithNullByte(PdoSessionHandler $storage) + { + $sessionData = 'da'."\0".'ta'; $storage->open('', 'sid'); + $storage->createTable(); $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(); @@ -130,100 +185,126 @@ public function testReadWriteReadWithNullByte() public function testReadConvertsStreamToString() { + if (\defined('HHVM_VERSION')) { + $this->markTestSkipped('PHPUnit_MockObject cannot mock the PDOStatement class on HHVM. See https://github.com/sebastianbergmann/phpunit-mock-objects/pull/289'); + } $pdo = new MockPdo('pgsql'); $pdo->prepareResult = $this->getMockBuilder('PDOStatement')->getMock(); - $content = 'foobar'; $stream = $this->createStream($content); - $pdo->prepareResult->expects($this->once())->method('fetchAll') - ->willReturn([[$stream, 42, time()]]); - - $storage = new PdoSessionHandler($pdo); + ->willReturn([[$stream, 42 + time()]]); + $storage = new PdoSessionHandler($pdo, ['db_lifetime_col' => false]); $result = $storage->read('foo'); - $this->assertSame($content, $result); } public function testReadLockedConvertsStreamToString() { - if (filter_var(ini_get('session.use_strict_mode'), FILTER_VALIDATE_BOOLEAN)) { - $this->markTestSkipped('Strict mode needs no locking for new sessions.'); + if (\defined('HHVM_VERSION')) { + $this->markTestSkipped('PHPUnit_MockObject cannot mock the PDOStatement class on HHVM. See https://github.com/sebastianbergmann/phpunit-mock-objects/pull/289'); } - $pdo = new MockPdo('pgsql'); $selectStmt = $this->getMockBuilder('PDOStatement')->getMock(); $insertStmt = $this->getMockBuilder('PDOStatement')->getMock(); - - $pdo->prepareResult = function ($statement) use ($selectStmt, $insertStmt) { + $pdo->prepareResult = static function (string $statement) use ($selectStmt, $insertStmt) { return 0 === strpos($statement, 'INSERT') ? $insertStmt : $selectStmt; }; - $content = 'foobar'; $stream = $this->createStream($content); $exception = null; - $selectStmt->expects($this->atLeast(2))->method('fetchAll') - ->willReturnCallback(function () use (&$exception, $stream) { - return $exception ? [[$stream, 42, time()]] : []; + ->willReturnCallback(static function () use (&$exception, $stream) { + return $exception ? [[$stream, 42 + time()]] : []; }); - $insertStmt->expects($this->once())->method('execute') - ->willReturnCallback(function () use (&$exception) { + ->willReturnCallback(static function () use (&$exception) { throw $exception = new \PDOException('', '23'); }); - - $storage = new PdoSessionHandler($pdo); + $storage = new PdoSessionHandler($pdo, ['db_lifetime_col' => false]); $result = $storage->read('foo'); - $this->assertSame($content, $result); } public function testReadingRequiresExactlySameId() { - $storage = new PdoSessionHandler($this->getMemorySqlitePdo()); + $this->doTestReadingRequiresExactlySameId($this->getSessionHandler()); + } + + /** + * @group legacy + * @expectedDeprecation The "%s" column is deprecated since version 4.4 and won't be used anymore in 5.0. Migrate your session database then set the "db_lifetime_col" option to false to opt-in for the new behavior. + */ + public function testLegacyReadingRequiresExactlySameId() + { + $this->doTestReadingRequiresExactlySameId($this->getSessionHandler(null, ['db_lifetime_col' => 'foobar'])); + } + + private function doTestReadingRequiresExactlySameId(PdoSessionHandler $storage) + { $storage->open('', 'sid'); $storage->write('id', 'data'); $storage->write('test', 'data'); $storage->write('space ', 'data'); $storage->close(); - $storage->open('', 'sid'); $readDataCaseSensitive = $storage->read('ID'); $readDataNoCharFolding = $storage->read('tést'); $readDataKeepSpace = $storage->read('space '); $readDataExtraSpace = $storage->read('space '); $storage->close(); - $this->assertSame('', $readDataCaseSensitive, 'Retrieval by ID should be case-sensitive (collation setting)'); $this->assertSame('', $readDataNoCharFolding, 'Retrieval by ID should not do character folding (collation setting)'); $this->assertSame('data', $readDataKeepSpace, 'Retrieval by ID requires spaces as-is'); $this->assertSame('', $readDataExtraSpace, 'Retrieval by ID requires spaces as-is'); } + public function testWriteDifferentSessionIdThanRead() + { + $this->doTestWriteDifferentSessionIdThanRead($this->getSessionHandler()); + } + + /** + * @group legacy + * @expectedDeprecation The "%s" column is deprecated since version 4.4 and won't be used anymore in 5.0. Migrate your session database then set the "db_lifetime_col" option to false to opt-in for the new behavior. + */ + public function testLegacyWriteDifferentSessionIdThanRead() + { + $this->doTestWriteDifferentSessionIdThanRead($this->getSessionHandler(null, ['db_lifetime_col' => 'foobar'])); + } + /** * Simulates session_regenerate_id(true) which will require an INSERT or UPDATE (replace). */ - public function testWriteDifferentSessionIdThanRead() + private function doTestWriteDifferentSessionIdThanRead(PdoSessionHandler $storage) { - $storage = new PdoSessionHandler($this->getMemorySqlitePdo()); $storage->open('', 'sid'); $storage->read('id'); $storage->destroy('id'); $storage->write('new_id', 'data_of_new_session_id'); $storage->close(); - $storage->open('', 'sid'); $data = $storage->read('new_id'); $storage->close(); - $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->getMemorySqlitePdo()); + $this->doTestWrongUsageStillWorks($this->getSessionHandler()); + } + + /** + * @group legacy + * @expectedDeprecation The "%s" column is deprecated since version 4.4 and won't be used anymore in 5.0. Migrate your session database then set the "db_lifetime_col" option to false to opt-in for the new behavior. + */ + public function testLegacyWrongUsageStillWorks() + { + $this->doTestWrongUsageStillWorks($this->getSessionHandler(null, ['db_lifetime_col' => 'foobar'])); + } + + private function doTestWrongUsageStillWorks(PdoSessionHandler $storage) + { $storage->write('id', 'data'); $storage->write('other_id', 'other_data'); $storage->destroy('inexistent'); @@ -231,86 +312,107 @@ public function testWrongUsageStillWorks() $data = $storage->read('id'); $otherData = $storage->read('other_id'); $storage->close(); - $this->assertSame('data', $data); $this->assertSame('other_data', $otherData); } public function testSessionDestroy() { - $pdo = $this->getMemorySqlitePdo(); - $storage = new PdoSessionHandler($pdo); + $storage = $this->getSessionHandler($pdo = $this->getPdoMemorySqlite()); + $this->doTestSessionDestroy($pdo, $storage); + } + /** + * @group legacy + * @expectedDeprecation The "%s" column is deprecated since version 4.4 and won't be used anymore in 5.0. Migrate your session database then set the "db_lifetime_col" option to false to opt-in for the new behavior. + */ + public function testLegacySessionDestroy() + { + $storage = $this->getSessionHandler($pdo = $this->getPdoMemorySqlite(), ['db_lifetime_col' => 'foobar']); + $this->doTestSessionDestroy($pdo, $storage); + } + + private function doTestSessionDestroy(\PDO $pdo, PdoSessionHandler $storage) + { $storage->open('', 'sid'); $storage->read('id'); $storage->write('id', 'data'); $storage->close(); $this->assertEquals(1, $pdo->query('SELECT COUNT(*) FROM sessions')->fetchColumn()); - $storage->open('', 'sid'); $storage->read('id'); $storage->destroy('id'); $storage->close(); $this->assertEquals(0, $pdo->query('SELECT COUNT(*) FROM sessions')->fetchColumn()); - $storage->open('', 'sid'); $data = $storage->read('id'); $storage->close(); $this->assertSame('', $data, 'Destroyed session returns empty string'); } + public function testSessionGC() + { + $storage = $this->getSessionHandler($pdo = $this->getPdoMemorySqlite()); + $this->doTestSessionGC($pdo, $storage); + } + /** - * @runInSeparateProcess + * @group legacy + * @expectedDeprecation The "%s" column is deprecated since version 4.4 and won't be used anymore in 5.0. Migrate your session database then set the "db_lifetime_col" option to false to opt-in for the new behavior. */ - public function testSessionGC() + public function testLegacySessionGC() { - $previousLifeTime = ini_set('session.gc_maxlifetime', 1000); - $pdo = $this->getMemorySqlitePdo(); - $storage = new PdoSessionHandler($pdo); + $storage = $this->getSessionHandler($pdo = $this->getPdoMemorySqlite(), ['db_lifetime_col' => 'foobar']); + $this->doTestSessionGC($pdo, $storage); + } + private function doTestSessionGC(\PDO $pdo, PdoSessionHandler $storage) + { + $previousLifeTime = ini_set('session.gc_maxlifetime', 1000); $storage->open('', 'sid'); $storage->read('id'); $storage->write('id', 'data'); $storage->close(); - $storage->open('', 'sid'); $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(); - 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->getMemorySqlitePdo()); - + $storage = $this->getSessionHandler(); $method = new \ReflectionMethod($storage, 'getConnection'); $method->setAccessible(true); - $this->assertInstanceOf('\PDO', $method->invoke($storage)); } public function testGetConnectionConnectsIfNeeded() { - $storage = new PdoSessionHandler('sqlite::memory:'); - + $storage = new PdoSessionHandler('sqlite::memory:', ['db_lifetime_col' => false]); $method = new \ReflectionMethod($storage, 'getConnection'); $method->setAccessible(true); - $this->assertInstanceOf('\PDO', $method->invoke($storage)); } + private function createStream($content) + { + $stream = tmpfile(); + fwrite($stream, $content); + fseek($stream, 0); + + return $stream; + } + /** * @dataProvider provideUrlDsnPairs */ @@ -345,15 +447,6 @@ public function provideUrlDsnPairs() yield ['mssql://localhost/test', 'sqlsrv:server=localhost;Database=test']; yield ['mssql://localhost:56/test', 'sqlsrv:server=localhost,56;Database=test']; } - - private function createStream($content) - { - $stream = tmpfile(); - fwrite($stream, $content); - fseek($stream, 0); - - return $stream; - } } class MockPdo extends \PDO