| Total Complexity | 86 |
| Total Lines | 679 |
| Duplicated Lines | 0 % |
| Changes | 0 | ||
Complex classes like PdoSessionHandler often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.
Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.
While breaking up the class, it is a good idea to analyze how other classes use PdoSessionHandler, and based on these observations, apply Extract Interface, too.
| 1 | <?php |
||
| 41 | class PdoSessionHandler implements \SessionHandlerInterface |
||
| 42 | { |
||
| 43 | /** |
||
| 44 | * No locking is done. This means sessions are prone to loss of data due to |
||
| 45 | * race conditions of concurrent requests to the same session. The last session |
||
| 46 | * write will win in this case. It might be useful when you implement your own |
||
| 47 | * logic to deal with this like an optimistic approach. |
||
| 48 | */ |
||
| 49 | const LOCK_NONE = 0; |
||
| 50 | |||
| 51 | /** |
||
| 52 | * Creates an application-level lock on a session. The disadvantage is that the |
||
| 53 | * lock is not enforced by the database and thus other, unaware parts of the |
||
| 54 | * application could still concurrently modify the session. The advantage is it |
||
| 55 | * does not require a transaction. |
||
| 56 | * This mode is not available for SQLite and not yet implemented for oci and sqlsrv. |
||
| 57 | */ |
||
| 58 | const LOCK_ADVISORY = 1; |
||
| 59 | |||
| 60 | /** |
||
| 61 | * Issues a real row lock. Since it uses a transaction between opening and |
||
| 62 | * closing a session, you have to be careful when you use same database connection |
||
| 63 | * that you also use for your application logic. This mode is the default because |
||
| 64 | * it's the only reliable solution across DBMSs. |
||
| 65 | */ |
||
| 66 | const LOCK_TRANSACTIONAL = 2; |
||
| 67 | |||
| 68 | /** |
||
| 69 | * @var \PDO|null PDO instance or null when not connected yet |
||
| 70 | */ |
||
| 71 | private $pdo; |
||
| 72 | |||
| 73 | /** |
||
| 74 | * @var string|null|false DSN string or null for session.save_path or false when lazy connection disabled |
||
| 75 | */ |
||
| 76 | private $dsn = false; |
||
| 77 | |||
| 78 | /** |
||
| 79 | * @var string Database driver |
||
| 80 | */ |
||
| 81 | private $driver; |
||
| 82 | |||
| 83 | /** |
||
| 84 | * @var string Table name |
||
| 85 | */ |
||
| 86 | private $table = 'sessions'; |
||
| 87 | |||
| 88 | /** |
||
| 89 | * @var string Column for session id |
||
| 90 | */ |
||
| 91 | private $idCol = 'sess_id'; |
||
| 92 | |||
| 93 | /** |
||
| 94 | * @var string Column for session data |
||
| 95 | */ |
||
| 96 | private $dataCol = 'sess_data'; |
||
| 97 | |||
| 98 | /** |
||
| 99 | * @var string Column for lifetime |
||
| 100 | */ |
||
| 101 | private $lifetimeCol = 'sess_lifetime'; |
||
| 102 | |||
| 103 | /** |
||
| 104 | * @var string Column for timestamp |
||
| 105 | */ |
||
| 106 | private $timeCol = 'sess_time'; |
||
| 107 | |||
| 108 | /** |
||
| 109 | * @var string Username when lazy-connect |
||
| 110 | */ |
||
| 111 | private $username = ''; |
||
| 112 | |||
| 113 | /** |
||
| 114 | * @var string Password when lazy-connect |
||
| 115 | */ |
||
| 116 | private $password = ''; |
||
| 117 | |||
| 118 | /** |
||
| 119 | * @var array Connection options when lazy-connect |
||
| 120 | */ |
||
| 121 | private $connectionOptions = array(); |
||
| 122 | |||
| 123 | /** |
||
| 124 | * @var int The strategy for locking, see constants |
||
| 125 | */ |
||
| 126 | private $lockMode = self::LOCK_TRANSACTIONAL; |
||
| 127 | |||
| 128 | /** |
||
| 129 | * It's an array to support multiple reads before closing which is manual, non-standard usage. |
||
| 130 | * |
||
| 131 | * @var \PDOStatement[] An array of statements to release advisory locks |
||
| 132 | */ |
||
| 133 | private $unlockStatements = array(); |
||
| 134 | |||
| 135 | /** |
||
| 136 | * @var bool True when the current session exists but expired according to session.gc_maxlifetime |
||
| 137 | */ |
||
| 138 | private $sessionExpired = false; |
||
| 139 | |||
| 140 | /** |
||
| 141 | * @var bool Whether a transaction is active |
||
| 142 | */ |
||
| 143 | private $inTransaction = false; |
||
| 144 | |||
| 145 | /** |
||
| 146 | * @var bool Whether gc() has been called |
||
| 147 | */ |
||
| 148 | private $gcCalled = false; |
||
| 149 | |||
| 150 | /** |
||
| 151 | * Constructor. |
||
| 152 | * |
||
| 153 | * You can either pass an existing database connection as PDO instance or |
||
| 154 | * pass a DSN string that will be used to lazy-connect to the database |
||
| 155 | * when the session is actually used. Furthermore it's possible to pass null |
||
| 156 | * which will then use the session.save_path ini setting as PDO DSN parameter. |
||
| 157 | * |
||
| 158 | * List of available options: |
||
| 159 | * * db_table: The name of the table [default: sessions] |
||
| 160 | * * db_id_col: The column where to store the session id [default: sess_id] |
||
| 161 | * * db_data_col: The column where to store the session data [default: sess_data] |
||
| 162 | * * db_lifetime_col: The column where to store the lifetime [default: sess_lifetime] |
||
| 163 | * * db_time_col: The column where to store the timestamp [default: sess_time] |
||
| 164 | * * db_username: The username when lazy-connect [default: ''] |
||
| 165 | * * db_password: The password when lazy-connect [default: ''] |
||
| 166 | * * db_connection_options: An array of driver-specific connection options [default: array()] |
||
| 167 | * * lock_mode: The strategy for locking, see constants [default: LOCK_TRANSACTIONAL] |
||
| 168 | * |
||
| 169 | * @param \PDO|string|null $pdoOrDsn A \PDO instance or DSN string or null |
||
| 170 | * @param array $options An associative array of options |
||
| 171 | * |
||
| 172 | * @throws \InvalidArgumentException When PDO error mode is not PDO::ERRMODE_EXCEPTION |
||
| 173 | */ |
||
| 174 | public function __construct($pdoOrDsn = null, array $options = array()) |
||
| 175 | { |
||
| 176 | if ($pdoOrDsn instanceof \PDO) { |
||
| 177 | if (\PDO::ERRMODE_EXCEPTION !== $pdoOrDsn->getAttribute(\PDO::ATTR_ERRMODE)) { |
||
| 178 | 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__)); |
||
| 179 | } |
||
| 180 | |||
| 181 | $this->pdo = $pdoOrDsn; |
||
| 182 | $this->driver = $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME); |
||
| 183 | } else { |
||
| 184 | $this->dsn = $pdoOrDsn; |
||
| 185 | } |
||
| 186 | |||
| 187 | $this->table = isset($options['db_table']) ? $options['db_table'] : $this->table; |
||
| 188 | $this->idCol = isset($options['db_id_col']) ? $options['db_id_col'] : $this->idCol; |
||
| 189 | $this->dataCol = isset($options['db_data_col']) ? $options['db_data_col'] : $this->dataCol; |
||
| 190 | $this->lifetimeCol = isset($options['db_lifetime_col']) ? $options['db_lifetime_col'] : $this->lifetimeCol; |
||
| 191 | $this->timeCol = isset($options['db_time_col']) ? $options['db_time_col'] : $this->timeCol; |
||
| 192 | $this->username = isset($options['db_username']) ? $options['db_username'] : $this->username; |
||
| 193 | $this->password = isset($options['db_password']) ? $options['db_password'] : $this->password; |
||
| 194 | $this->connectionOptions = isset($options['db_connection_options']) ? $options['db_connection_options'] : $this->connectionOptions; |
||
| 195 | $this->lockMode = isset($options['lock_mode']) ? $options['lock_mode'] : $this->lockMode; |
||
| 196 | } |
||
| 197 | |||
| 198 | /** |
||
| 199 | * Creates the table to store sessions which can be called once for setup. |
||
| 200 | * |
||
| 201 | * Session ID is saved in a column of maximum length 128 because that is enough even |
||
| 202 | * for a 512 bit configured session.hash_function like Whirlpool. Session data is |
||
| 203 | * saved in a BLOB. One could also use a shorter inlined varbinary column |
||
| 204 | * if one was sure the data fits into it. |
||
| 205 | * |
||
| 206 | * @throws \PDOException When the table already exists |
||
| 207 | * @throws \DomainException When an unsupported PDO driver is used |
||
| 208 | */ |
||
| 209 | public function createTable() |
||
| 210 | { |
||
| 211 | // connect if we are not yet |
||
| 212 | $this->getConnection(); |
||
| 213 | |||
| 214 | switch ($this->driver) { |
||
| 215 | case 'mysql': |
||
| 216 | // We use varbinary for the ID column because it prevents unwanted conversions: |
||
| 217 | // - character set conversions between server and client |
||
| 218 | // - trailing space removal |
||
| 219 | // - case-insensitivity |
||
| 220 | // - language processing like é == e |
||
| 221 | $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"; |
||
| 222 | break; |
||
| 223 | case 'sqlite': |
||
| 224 | $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)"; |
||
| 225 | break; |
||
| 226 | case 'pgsql': |
||
| 227 | $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)"; |
||
| 228 | break; |
||
| 229 | case 'oci': |
||
| 230 | $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)"; |
||
| 231 | break; |
||
| 232 | case 'sqlsrv': |
||
| 233 | $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)"; |
||
| 234 | break; |
||
| 235 | default: |
||
| 236 | throw new \DomainException(sprintf('Creating the session table is currently not implemented for PDO driver "%s".', $this->driver)); |
||
| 237 | } |
||
| 238 | |||
| 239 | try { |
||
| 240 | $this->pdo->exec($sql); |
||
| 241 | } catch (\PDOException $e) { |
||
| 242 | $this->rollback(); |
||
| 243 | |||
| 244 | throw $e; |
||
| 245 | } |
||
| 246 | } |
||
| 247 | |||
| 248 | /** |
||
| 249 | * Returns true when the current session exists but expired according to session.gc_maxlifetime. |
||
| 250 | * |
||
| 251 | * Can be used to distinguish between a new session and one that expired due to inactivity. |
||
| 252 | * |
||
| 253 | * @return bool Whether current session expired |
||
| 254 | */ |
||
| 255 | public function isSessionExpired() |
||
| 256 | { |
||
| 257 | return $this->sessionExpired; |
||
| 258 | } |
||
| 259 | |||
| 260 | /** |
||
| 261 | * {@inheritdoc} |
||
| 262 | */ |
||
| 263 | public function open($savePath, $sessionName) |
||
| 270 | } |
||
| 271 | |||
| 272 | /** |
||
| 273 | * {@inheritdoc} |
||
| 274 | */ |
||
| 275 | public function read($sessionId) |
||
| 276 | { |
||
| 277 | try { |
||
| 278 | return $this->doRead($sessionId); |
||
| 279 | } catch (\PDOException $e) { |
||
| 280 | $this->rollback(); |
||
| 281 | |||
| 282 | throw $e; |
||
| 283 | } |
||
| 284 | } |
||
| 285 | |||
| 286 | /** |
||
| 287 | * {@inheritdoc} |
||
| 288 | */ |
||
| 289 | public function gc($maxlifetime) |
||
| 290 | { |
||
| 291 | // We delay gc() to close() so that it is executed outside the transactional and blocking read-write process. |
||
| 292 | // This way, pruning expired sessions does not block them from being started while the current session is used. |
||
| 293 | $this->gcCalled = true; |
||
| 294 | |||
| 295 | return true; |
||
| 296 | } |
||
| 297 | |||
| 298 | /** |
||
| 299 | * {@inheritdoc} |
||
| 300 | */ |
||
| 301 | public function destroy($sessionId) |
||
| 302 | { |
||
| 303 | // delete the record associated with this id |
||
| 304 | $sql = "DELETE FROM $this->table WHERE $this->idCol = :id"; |
||
| 305 | |||
| 306 | try { |
||
| 307 | $stmt = $this->pdo->prepare($sql); |
||
| 308 | $stmt->bindParam(':id', $sessionId, \PDO::PARAM_STR); |
||
| 309 | $stmt->execute(); |
||
| 310 | } catch (\PDOException $e) { |
||
| 311 | $this->rollback(); |
||
| 312 | |||
| 313 | throw $e; |
||
| 314 | } |
||
| 315 | |||
| 316 | return true; |
||
| 317 | } |
||
| 318 | |||
| 319 | /** |
||
| 320 | * {@inheritdoc} |
||
| 321 | */ |
||
| 322 | public function write($sessionId, $data) |
||
| 323 | { |
||
| 324 | $maxlifetime = (int) ini_get('session.gc_maxlifetime'); |
||
| 325 | |||
| 326 | try { |
||
| 327 | // We use a single MERGE SQL query when supported by the database. |
||
| 328 | $mergeStmt = $this->getMergeStatement($sessionId, $data, $maxlifetime); |
||
| 329 | if (null !== $mergeStmt) { |
||
| 330 | $mergeStmt->execute(); |
||
| 331 | |||
| 332 | return true; |
||
| 333 | } |
||
| 334 | |||
| 335 | $updateStmt = $this->pdo->prepare( |
||
| 336 | "UPDATE $this->table SET $this->dataCol = :data, $this->lifetimeCol = :lifetime, $this->timeCol = :time WHERE $this->idCol = :id" |
||
| 337 | ); |
||
| 338 | $updateStmt->bindParam(':id', $sessionId, \PDO::PARAM_STR); |
||
| 339 | $updateStmt->bindParam(':data', $data, \PDO::PARAM_LOB); |
||
| 340 | $updateStmt->bindParam(':lifetime', $maxlifetime, \PDO::PARAM_INT); |
||
| 341 | $updateStmt->bindValue(':time', time(), \PDO::PARAM_INT); |
||
| 342 | $updateStmt->execute(); |
||
| 343 | |||
| 344 | // When MERGE is not supported, like in Postgres < 9.5, we have to use this approach that can result in |
||
| 345 | // duplicate key errors when the same session is written simultaneously (given the LOCK_NONE behavior). |
||
| 346 | // We can just catch such an error and re-execute the update. This is similar to a serializable |
||
| 347 | // transaction with retry logic on serialization failures but without the overhead and without possible |
||
| 348 | // false positives due to longer gap locking. |
||
| 349 | if (!$updateStmt->rowCount()) { |
||
| 350 | try { |
||
| 351 | $insertStmt = $this->pdo->prepare( |
||
| 352 | "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time)" |
||
| 353 | ); |
||
| 354 | $insertStmt->bindParam(':id', $sessionId, \PDO::PARAM_STR); |
||
| 355 | $insertStmt->bindParam(':data', $data, \PDO::PARAM_LOB); |
||
| 356 | $insertStmt->bindParam(':lifetime', $maxlifetime, \PDO::PARAM_INT); |
||
| 357 | $insertStmt->bindValue(':time', time(), \PDO::PARAM_INT); |
||
| 358 | $insertStmt->execute(); |
||
| 359 | } catch (\PDOException $e) { |
||
| 360 | // Handle integrity violation SQLSTATE 23000 (or a subclass like 23505 in Postgres) for duplicate keys |
||
| 361 | if (0 === strpos($e->getCode(), '23')) { |
||
| 362 | $updateStmt->execute(); |
||
| 363 | } else { |
||
| 364 | throw $e; |
||
| 365 | } |
||
| 366 | } |
||
| 367 | } |
||
| 368 | } catch (\PDOException $e) { |
||
| 369 | $this->rollback(); |
||
| 370 | |||
| 371 | throw $e; |
||
| 372 | } |
||
| 373 | |||
| 374 | return true; |
||
| 375 | } |
||
| 376 | |||
| 377 | /** |
||
| 378 | * {@inheritdoc} |
||
| 379 | */ |
||
| 380 | public function close() |
||
| 381 | { |
||
| 382 | $this->commit(); |
||
| 383 | |||
| 384 | while ($unlockStmt = array_shift($this->unlockStatements)) { |
||
| 385 | $unlockStmt->execute(); |
||
| 386 | } |
||
| 387 | |||
| 388 | if ($this->gcCalled) { |
||
| 389 | $this->gcCalled = false; |
||
| 390 | |||
| 391 | // delete the session records that have expired |
||
| 392 | $sql = "DELETE FROM $this->table WHERE $this->lifetimeCol + $this->timeCol < :time"; |
||
| 393 | |||
| 394 | $stmt = $this->pdo->prepare($sql); |
||
| 395 | $stmt->bindValue(':time', time(), \PDO::PARAM_INT); |
||
| 396 | $stmt->execute(); |
||
| 397 | } |
||
| 398 | |||
| 399 | if (false !== $this->dsn) { |
||
| 400 | $this->pdo = null; // only close lazy-connection |
||
| 401 | } |
||
| 402 | |||
| 403 | return true; |
||
| 404 | } |
||
| 405 | |||
| 406 | /** |
||
| 407 | * Lazy-connects to the database. |
||
| 408 | * |
||
| 409 | * @param string $dsn DSN string |
||
| 410 | */ |
||
| 411 | private function connect($dsn) |
||
| 412 | { |
||
| 413 | $this->pdo = new \PDO($dsn, $this->username, $this->password, $this->connectionOptions); |
||
| 414 | $this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); |
||
| 415 | $this->driver = $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME); |
||
| 416 | } |
||
| 417 | |||
| 418 | /** |
||
| 419 | * Helper method to begin a transaction. |
||
| 420 | * |
||
| 421 | * Since SQLite does not support row level locks, we have to acquire a reserved lock |
||
| 422 | * on the database immediately. Because of https://bugs.php.net/42766 we have to create |
||
| 423 | * such a transaction manually which also means we cannot use PDO::commit or |
||
| 424 | * PDO::rollback or PDO::inTransaction for SQLite. |
||
| 425 | * |
||
| 426 | * Also MySQLs default isolation, REPEATABLE READ, causes deadlock for different sessions |
||
| 427 | * due to http://www.mysqlperformanceblog.com/2013/12/12/one-more-innodb-gap-lock-to-avoid/ . |
||
| 428 | * So we change it to READ COMMITTED. |
||
| 429 | */ |
||
| 430 | private function beginTransaction() |
||
| 431 | { |
||
| 432 | if (!$this->inTransaction) { |
||
| 433 | if ('sqlite' === $this->driver) { |
||
| 434 | $this->pdo->exec('BEGIN IMMEDIATE TRANSACTION'); |
||
| 435 | } else { |
||
| 436 | if ('mysql' === $this->driver) { |
||
| 437 | $this->pdo->exec('SET TRANSACTION ISOLATION LEVEL READ COMMITTED'); |
||
| 438 | } |
||
| 439 | $this->pdo->beginTransaction(); |
||
| 440 | } |
||
| 441 | $this->inTransaction = true; |
||
| 442 | } |
||
| 443 | } |
||
| 444 | |||
| 445 | /** |
||
| 446 | * Helper method to commit a transaction. |
||
| 447 | */ |
||
| 448 | private function commit() |
||
| 449 | { |
||
| 450 | if ($this->inTransaction) { |
||
| 451 | try { |
||
| 452 | // commit read-write transaction which also releases the lock |
||
| 453 | if ('sqlite' === $this->driver) { |
||
| 454 | $this->pdo->exec('COMMIT'); |
||
| 455 | } else { |
||
| 456 | $this->pdo->commit(); |
||
| 457 | } |
||
| 458 | $this->inTransaction = false; |
||
| 459 | } catch (\PDOException $e) { |
||
| 460 | $this->rollback(); |
||
| 461 | |||
| 462 | throw $e; |
||
| 463 | } |
||
| 464 | } |
||
| 465 | } |
||
| 466 | |||
| 467 | /** |
||
| 468 | * Helper method to rollback a transaction. |
||
| 469 | */ |
||
| 470 | private function rollback() |
||
| 471 | { |
||
| 472 | // We only need to rollback if we are in a transaction. Otherwise the resulting |
||
| 473 | // error would hide the real problem why rollback was called. We might not be |
||
| 474 | // in a transaction when not using the transactional locking behavior or when |
||
| 475 | // two callbacks (e.g. destroy and write) are invoked that both fail. |
||
| 476 | if ($this->inTransaction) { |
||
| 477 | if ('sqlite' === $this->driver) { |
||
| 478 | $this->pdo->exec('ROLLBACK'); |
||
| 479 | } else { |
||
| 480 | $this->pdo->rollBack(); |
||
| 481 | } |
||
| 482 | $this->inTransaction = false; |
||
| 483 | } |
||
| 484 | } |
||
| 485 | |||
| 486 | /** |
||
| 487 | * Reads the session data in respect to the different locking strategies. |
||
| 488 | * |
||
| 489 | * We need to make sure we do not return session data that is already considered garbage according |
||
| 490 | * to the session.gc_maxlifetime setting because gc() is called after read() and only sometimes. |
||
| 491 | * |
||
| 492 | * @param string $sessionId Session ID |
||
| 493 | * |
||
| 494 | * @return string The session data |
||
| 495 | */ |
||
| 496 | private function doRead($sessionId) |
||
| 497 | { |
||
| 498 | $this->sessionExpired = false; |
||
| 499 | |||
| 500 | if (self::LOCK_ADVISORY === $this->lockMode) { |
||
| 501 | $this->unlockStatements[] = $this->doAdvisoryLock($sessionId); |
||
| 502 | } |
||
| 503 | |||
| 504 | $selectSql = $this->getSelectSql(); |
||
| 505 | $selectStmt = $this->pdo->prepare($selectSql); |
||
| 506 | $selectStmt->bindParam(':id', $sessionId, \PDO::PARAM_STR); |
||
| 507 | |||
| 508 | do { |
||
| 509 | $selectStmt->execute(); |
||
| 510 | $sessionRows = $selectStmt->fetchAll(\PDO::FETCH_NUM); |
||
| 511 | |||
| 512 | if ($sessionRows) { |
||
| 513 | if ($sessionRows[0][1] + $sessionRows[0][2] < time()) { |
||
| 514 | $this->sessionExpired = true; |
||
| 515 | |||
| 516 | return ''; |
||
| 517 | } |
||
| 518 | |||
| 519 | return is_resource($sessionRows[0][0]) ? stream_get_contents($sessionRows[0][0]) : $sessionRows[0][0]; |
||
| 520 | } |
||
| 521 | |||
| 522 | if (self::LOCK_TRANSACTIONAL === $this->lockMode && 'sqlite' !== $this->driver) { |
||
| 523 | // Exclusive-reading of non-existent rows does not block, so we need to do an insert to block |
||
| 524 | // until other connections to the session are committed. |
||
| 525 | try { |
||
| 526 | $insertStmt = $this->pdo->prepare( |
||
| 527 | "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time)" |
||
| 528 | ); |
||
| 529 | $insertStmt->bindParam(':id', $sessionId, \PDO::PARAM_STR); |
||
| 530 | $insertStmt->bindValue(':data', '', \PDO::PARAM_LOB); |
||
| 531 | $insertStmt->bindValue(':lifetime', 0, \PDO::PARAM_INT); |
||
| 532 | $insertStmt->bindValue(':time', time(), \PDO::PARAM_INT); |
||
| 533 | $insertStmt->execute(); |
||
| 534 | } catch (\PDOException $e) { |
||
| 535 | // Catch duplicate key error because other connection created the session already. |
||
| 536 | // It would only not be the case when the other connection destroyed the session. |
||
| 537 | if (0 === strpos($e->getCode(), '23')) { |
||
| 538 | // Retrieve finished session data written by concurrent connection by restarting the loop. |
||
| 539 | // We have to start a new transaction as a failed query will mark the current transaction as |
||
| 540 | // aborted in PostgreSQL and disallow further queries within it. |
||
| 541 | $this->rollback(); |
||
| 542 | $this->beginTransaction(); |
||
| 543 | continue; |
||
| 544 | } |
||
| 545 | |||
| 546 | throw $e; |
||
| 547 | } |
||
| 548 | } |
||
| 549 | |||
| 550 | return ''; |
||
| 551 | } while (true); |
||
| 552 | } |
||
| 553 | |||
| 554 | /** |
||
| 555 | * Executes an application-level lock on the database. |
||
| 556 | * |
||
| 557 | * @param string $sessionId Session ID |
||
| 558 | * |
||
| 559 | * @return \PDOStatement The statement that needs to be executed later to release the lock |
||
| 560 | * |
||
| 561 | * @throws \DomainException When an unsupported PDO driver is used |
||
| 562 | * |
||
| 563 | * @todo implement missing advisory locks |
||
| 564 | * - for oci using DBMS_LOCK.REQUEST |
||
| 565 | * - for sqlsrv using sp_getapplock with LockOwner = Session |
||
| 566 | */ |
||
| 567 | private function doAdvisoryLock($sessionId) |
||
| 568 | { |
||
| 569 | switch ($this->driver) { |
||
| 570 | case 'mysql': |
||
| 571 | // should we handle the return value? 0 on timeout, null on error |
||
| 572 | // we use a timeout of 50 seconds which is also the default for innodb_lock_wait_timeout |
||
| 573 | $stmt = $this->pdo->prepare('SELECT GET_LOCK(:key, 50)'); |
||
| 574 | $stmt->bindValue(':key', $sessionId, \PDO::PARAM_STR); |
||
| 575 | $stmt->execute(); |
||
| 576 | |||
| 577 | $releaseStmt = $this->pdo->prepare('DO RELEASE_LOCK(:key)'); |
||
| 578 | $releaseStmt->bindValue(':key', $sessionId, \PDO::PARAM_STR); |
||
| 579 | |||
| 580 | return $releaseStmt; |
||
| 581 | case 'pgsql': |
||
| 582 | // Obtaining an exclusive session level advisory lock requires an integer key. |
||
| 583 | // So we convert the HEX representation of the session id to an integer. |
||
| 584 | // Since integers are signed, we have to skip one hex char to fit in the range. |
||
| 585 | if (4 === PHP_INT_SIZE) { |
||
| 586 | $sessionInt1 = hexdec(substr($sessionId, 0, 7)); |
||
| 587 | $sessionInt2 = hexdec(substr($sessionId, 7, 7)); |
||
| 588 | |||
| 589 | $stmt = $this->pdo->prepare('SELECT pg_advisory_lock(:key1, :key2)'); |
||
| 590 | $stmt->bindValue(':key1', $sessionInt1, \PDO::PARAM_INT); |
||
| 591 | $stmt->bindValue(':key2', $sessionInt2, \PDO::PARAM_INT); |
||
| 592 | $stmt->execute(); |
||
| 593 | |||
| 594 | $releaseStmt = $this->pdo->prepare('SELECT pg_advisory_unlock(:key1, :key2)'); |
||
| 595 | $releaseStmt->bindValue(':key1', $sessionInt1, \PDO::PARAM_INT); |
||
| 596 | $releaseStmt->bindValue(':key2', $sessionInt2, \PDO::PARAM_INT); |
||
| 597 | } else { |
||
| 598 | $sessionBigInt = hexdec(substr($sessionId, 0, 15)); |
||
| 599 | |||
| 600 | $stmt = $this->pdo->prepare('SELECT pg_advisory_lock(:key)'); |
||
| 601 | $stmt->bindValue(':key', $sessionBigInt, \PDO::PARAM_INT); |
||
| 602 | $stmt->execute(); |
||
| 603 | |||
| 604 | $releaseStmt = $this->pdo->prepare('SELECT pg_advisory_unlock(:key)'); |
||
| 605 | $releaseStmt->bindValue(':key', $sessionBigInt, \PDO::PARAM_INT); |
||
| 606 | } |
||
| 607 | |||
| 608 | return $releaseStmt; |
||
| 609 | case 'sqlite': |
||
| 610 | throw new \DomainException('SQLite does not support advisory locks.'); |
||
| 611 | default: |
||
| 612 | throw new \DomainException(sprintf('Advisory locks are currently not implemented for PDO driver "%s".', $this->driver)); |
||
| 613 | } |
||
| 614 | } |
||
| 615 | |||
| 616 | /** |
||
| 617 | * Return a locking or nonlocking SQL query to read session information. |
||
| 618 | * |
||
| 619 | * @return string The SQL string |
||
| 620 | * |
||
| 621 | * @throws \DomainException When an unsupported PDO driver is used |
||
| 622 | */ |
||
| 623 | private function getSelectSql() |
||
| 624 | { |
||
| 625 | if (self::LOCK_TRANSACTIONAL === $this->lockMode) { |
||
| 626 | $this->beginTransaction(); |
||
| 627 | |||
| 628 | switch ($this->driver) { |
||
| 629 | case 'mysql': |
||
| 630 | case 'oci': |
||
| 631 | case 'pgsql': |
||
| 632 | return "SELECT $this->dataCol, $this->lifetimeCol, $this->timeCol FROM $this->table WHERE $this->idCol = :id FOR UPDATE"; |
||
| 633 | case 'sqlsrv': |
||
| 634 | return "SELECT $this->dataCol, $this->lifetimeCol, $this->timeCol FROM $this->table WITH (UPDLOCK, ROWLOCK) WHERE $this->idCol = :id"; |
||
| 635 | case 'sqlite': |
||
| 636 | // we already locked when starting transaction |
||
| 637 | break; |
||
| 638 | default: |
||
| 639 | throw new \DomainException(sprintf('Transactional locks are currently not implemented for PDO driver "%s".', $this->driver)); |
||
| 640 | } |
||
| 641 | } |
||
| 642 | |||
| 643 | return "SELECT $this->dataCol, $this->lifetimeCol, $this->timeCol FROM $this->table WHERE $this->idCol = :id"; |
||
| 644 | } |
||
| 645 | |||
| 646 | /** |
||
| 647 | * Returns a merge/upsert (i.e. insert or update) statement when supported by the database for writing session data. |
||
| 648 | * |
||
| 649 | * @param string $sessionId Session ID |
||
| 650 | * @param string $data Encoded session data |
||
| 651 | * @param int $maxlifetime session.gc_maxlifetime |
||
| 652 | * |
||
| 653 | * @return \PDOStatement|null The merge statement or null when not supported |
||
| 654 | */ |
||
| 655 | private function getMergeStatement($sessionId, $data, $maxlifetime) |
||
| 705 | } |
||
| 706 | } |
||
| 707 | |||
| 708 | /** |
||
| 709 | * Return a PDO instance. |
||
| 710 | * |
||
| 711 | * @return \PDO |
||
| 712 | */ |
||
| 713 | protected function getConnection() |
||
| 720 | } |
||
| 721 | } |
||
| 722 |