PageRenderTime 51ms CodeModel.GetById 23ms RepoModel.GetById 0ms app.codeStats 0ms

/src/Symfony/Component/Lock/Store/PdoStore.php

https://github.com/FabienD/symfony
PHP | 244 lines | 138 code | 36 blank | 70 comment | 10 complexity | e14efee0d3bdd20d6a7595e2ee26dad7 MD5 | raw file
  1. <?php
  2. /*
  3. * This file is part of the Symfony package.
  4. *
  5. * (c) Fabien Potencier <fabien@symfony.com>
  6. *
  7. * For the full copyright and license information, please view the LICENSE
  8. * file that was distributed with this source code.
  9. */
  10. namespace Symfony\Component\Lock\Store;
  11. use Symfony\Component\Lock\Exception\InvalidArgumentException;
  12. use Symfony\Component\Lock\Exception\InvalidTtlException;
  13. use Symfony\Component\Lock\Exception\LockConflictedException;
  14. use Symfony\Component\Lock\Key;
  15. use Symfony\Component\Lock\PersistingStoreInterface;
  16. /**
  17. * PdoStore is a PersistingStoreInterface implementation using a PDO connection.
  18. *
  19. * Lock metadata are stored in a table. You can use createTable() to initialize
  20. * a correctly defined table.
  21. * CAUTION: This store relies on all client and server nodes to have
  22. * synchronized clocks for lock expiry to occur at the correct time.
  23. * To ensure locks don't expire prematurely; the TTLs should be set with enough
  24. * extra time to account for any clock drift between nodes.
  25. *
  26. * @author Jérémy Derussé <jeremy@derusse.com>
  27. */
  28. class PdoStore implements PersistingStoreInterface
  29. {
  30. use DatabaseTableTrait;
  31. use ExpiringStoreTrait;
  32. private \PDO $conn;
  33. private string $dsn;
  34. private string $driver;
  35. private string $username = '';
  36. private string $password = '';
  37. private array $connectionOptions = [];
  38. /**
  39. * You can either pass an existing database connection as PDO instance
  40. * or a DSN string that will be used to lazy-connect to the database
  41. * when the lock is actually used.
  42. *
  43. * List of available options:
  44. * * db_table: The name of the table [default: lock_keys]
  45. * * db_id_col: The column where to store the lock key [default: key_id]
  46. * * db_token_col: The column where to store the lock token [default: key_token]
  47. * * db_expiration_col: The column where to store the expiration [default: key_expiration]
  48. * * db_username: The username when lazy-connect [default: '']
  49. * * db_password: The password when lazy-connect [default: '']
  50. * * db_connection_options: An array of driver-specific connection options [default: []]
  51. *
  52. * @param array $options An associative array of options
  53. * @param float $gcProbability Probability expressed as floating number between 0 and 1 to clean old locks
  54. * @param int $initialTtl The expiration delay of locks in seconds
  55. *
  56. * @throws InvalidArgumentException When first argument is not PDO nor Connection nor string
  57. * @throws InvalidArgumentException When PDO error mode is not PDO::ERRMODE_EXCEPTION
  58. * @throws InvalidArgumentException When the initial ttl is not valid
  59. */
  60. public function __construct(\PDO|string $connOrDsn, array $options = [], float $gcProbability = 0.01, int $initialTtl = 300)
  61. {
  62. $this->init($options, $gcProbability, $initialTtl);
  63. if ($connOrDsn instanceof \PDO) {
  64. if (\PDO::ERRMODE_EXCEPTION !== $connOrDsn->getAttribute(\PDO::ATTR_ERRMODE)) {
  65. 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)).', __METHOD__));
  66. }
  67. $this->conn = $connOrDsn;
  68. } else {
  69. $this->dsn = $connOrDsn;
  70. }
  71. $this->username = $options['db_username'] ?? $this->username;
  72. $this->password = $options['db_password'] ?? $this->password;
  73. $this->connectionOptions = $options['db_connection_options'] ?? $this->connectionOptions;
  74. }
  75. /**
  76. * {@inheritdoc}
  77. */
  78. public function save(Key $key)
  79. {
  80. $key->reduceLifetime($this->initialTtl);
  81. $sql = "INSERT INTO $this->table ($this->idCol, $this->tokenCol, $this->expirationCol) VALUES (:id, :token, {$this->getCurrentTimestampStatement()} + $this->initialTtl)";
  82. $conn = $this->getConnection();
  83. try {
  84. $stmt = $conn->prepare($sql);
  85. } catch (\PDOException) {
  86. if (!$conn->inTransaction() || \in_array($this->driver, ['pgsql', 'sqlite', 'sqlsrv'], true)) {
  87. $this->createTable();
  88. }
  89. $stmt = $conn->prepare($sql);
  90. }
  91. $stmt->bindValue(':id', $this->getHashedKey($key));
  92. $stmt->bindValue(':token', $this->getUniqueToken($key));
  93. try {
  94. $stmt->execute();
  95. } catch (\PDOException) {
  96. // the lock is already acquired. It could be us. Let's try to put off.
  97. $this->putOffExpiration($key, $this->initialTtl);
  98. }
  99. $this->randomlyPrune();
  100. $this->checkNotExpired($key);
  101. }
  102. /**
  103. * {@inheritdoc}
  104. */
  105. public function putOffExpiration(Key $key, float $ttl)
  106. {
  107. if ($ttl < 1) {
  108. throw new InvalidTtlException(sprintf('"%s()" expects a TTL greater or equals to 1 second. Got "%s".', __METHOD__, $ttl));
  109. }
  110. $key->reduceLifetime($ttl);
  111. $sql = "UPDATE $this->table SET $this->expirationCol = {$this->getCurrentTimestampStatement()} + $ttl, $this->tokenCol = :token1 WHERE $this->idCol = :id AND ($this->tokenCol = :token2 OR $this->expirationCol <= {$this->getCurrentTimestampStatement()})";
  112. $stmt = $this->getConnection()->prepare($sql);
  113. $uniqueToken = $this->getUniqueToken($key);
  114. $stmt->bindValue(':id', $this->getHashedKey($key));
  115. $stmt->bindValue(':token1', $uniqueToken);
  116. $stmt->bindValue(':token2', $uniqueToken);
  117. $result = $stmt->execute();
  118. // If this method is called twice in the same second, the row wouldn't be updated. We have to call exists to know if we are the owner
  119. if (!(\is_object($result) ? $result : $stmt)->rowCount() && !$this->exists($key)) {
  120. throw new LockConflictedException();
  121. }
  122. $this->checkNotExpired($key);
  123. }
  124. /**
  125. * {@inheritdoc}
  126. */
  127. public function delete(Key $key)
  128. {
  129. $sql = "DELETE FROM $this->table WHERE $this->idCol = :id AND $this->tokenCol = :token";
  130. $stmt = $this->getConnection()->prepare($sql);
  131. $stmt->bindValue(':id', $this->getHashedKey($key));
  132. $stmt->bindValue(':token', $this->getUniqueToken($key));
  133. $stmt->execute();
  134. }
  135. /**
  136. * {@inheritdoc}
  137. */
  138. public function exists(Key $key): bool
  139. {
  140. $sql = "SELECT 1 FROM $this->table WHERE $this->idCol = :id AND $this->tokenCol = :token AND $this->expirationCol > {$this->getCurrentTimestampStatement()}";
  141. $stmt = $this->getConnection()->prepare($sql);
  142. $stmt->bindValue(':id', $this->getHashedKey($key));
  143. $stmt->bindValue(':token', $this->getUniqueToken($key));
  144. $result = $stmt->execute();
  145. return (bool) (\is_object($result) ? $result->fetchOne() : $stmt->fetchColumn());
  146. }
  147. private function getConnection(): \PDO
  148. {
  149. if (!isset($this->conn)) {
  150. $this->conn = new \PDO($this->dsn, $this->username, $this->password, $this->connectionOptions);
  151. $this->conn->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
  152. }
  153. return $this->conn;
  154. }
  155. /**
  156. * Creates the table to store lock keys which can be called once for setup.
  157. *
  158. * @throws \PDOException When the table already exists
  159. * @throws \DomainException When an unsupported PDO driver is used
  160. */
  161. public function createTable(): void
  162. {
  163. // connect if we are not yet
  164. $conn = $this->getConnection();
  165. $driver = $this->getDriver();
  166. $sql = match ($driver) {
  167. 'mysql' => "CREATE TABLE $this->table ($this->idCol VARCHAR(64) NOT NULL PRIMARY KEY, $this->tokenCol VARCHAR(44) NOT NULL, $this->expirationCol INTEGER UNSIGNED NOT NULL) COLLATE utf8mb4_bin, ENGINE = InnoDB",
  168. 'sqlite' => "CREATE TABLE $this->table ($this->idCol TEXT NOT NULL PRIMARY KEY, $this->tokenCol TEXT NOT NULL, $this->expirationCol INTEGER)",
  169. 'pgsql' => "CREATE TABLE $this->table ($this->idCol VARCHAR(64) NOT NULL PRIMARY KEY, $this->tokenCol VARCHAR(64) NOT NULL, $this->expirationCol INTEGER)",
  170. 'oci' => "CREATE TABLE $this->table ($this->idCol VARCHAR2(64) NOT NULL PRIMARY KEY, $this->tokenCol VARCHAR2(64) NOT NULL, $this->expirationCol INTEGER)",
  171. 'sqlsrv' => "CREATE TABLE $this->table ($this->idCol VARCHAR(64) NOT NULL PRIMARY KEY, $this->tokenCol VARCHAR(64) NOT NULL, $this->expirationCol INTEGER)",
  172. default => throw new \DomainException(sprintf('Creating the lock table is currently not implemented for platform "%s".', $driver)),
  173. };
  174. $conn->exec($sql);
  175. }
  176. /**
  177. * Cleans up the table by removing all expired locks.
  178. */
  179. private function prune(): void
  180. {
  181. $sql = "DELETE FROM $this->table WHERE $this->expirationCol <= {$this->getCurrentTimestampStatement()}";
  182. $this->getConnection()->exec($sql);
  183. }
  184. private function getDriver(): string
  185. {
  186. if (isset($this->driver)) {
  187. return $this->driver;
  188. }
  189. $conn = $this->getConnection();
  190. $this->driver = $conn->getAttribute(\PDO::ATTR_DRIVER_NAME);
  191. return $this->driver;
  192. }
  193. /**
  194. * Provides an SQL function to get the current timestamp regarding the current connection's driver.
  195. */
  196. private function getCurrentTimestampStatement(): string
  197. {
  198. return match ($this->getDriver()) {
  199. 'mysql' => 'UNIX_TIMESTAMP()',
  200. 'sqlite' => 'strftime(\'%s\',\'now\')',
  201. 'pgsql' => 'CAST(EXTRACT(epoch FROM NOW()) AS INT)',
  202. 'oci' => '(SYSDATE - TO_DATE(\'19700101\',\'yyyymmdd\'))*86400 - TO_NUMBER(SUBSTR(TZ_OFFSET(sessiontimezone), 1, 3))*3600',
  203. 'sqlsrv' => 'DATEDIFF(s, \'1970-01-01\', GETUTCDATE())',
  204. default => (string) time(),
  205. };
  206. }
  207. }