vendor/symfony/lock/Store/PdoStore.php line 72

Open in your IDE?
  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 Doctrine\DBAL\Connection;
  12. use Doctrine\DBAL\Schema\Schema;
  13. use Symfony\Component\Lock\Exception\InvalidArgumentException;
  14. use Symfony\Component\Lock\Exception\InvalidTtlException;
  15. use Symfony\Component\Lock\Exception\LockConflictedException;
  16. use Symfony\Component\Lock\Key;
  17. use Symfony\Component\Lock\PersistingStoreInterface;
  18. /**
  19. * PdoStore is a PersistingStoreInterface implementation using a PDO connection.
  20. *
  21. * Lock metadata are stored in a table. You can use createTable() to initialize
  22. * a correctly defined table.
  23. *
  24. * CAUTION: This store relies on all client and server nodes to have
  25. * synchronized clocks for lock expiry to occur at the correct time.
  26. * To ensure locks don't expire prematurely; the TTLs should be set with enough
  27. * extra time to account for any clock drift between nodes.
  28. *
  29. * @author Jérémy Derussé <jeremy@derusse.com>
  30. */
  31. class PdoStore implements PersistingStoreInterface
  32. {
  33. use DatabaseTableTrait;
  34. use ExpiringStoreTrait;
  35. private $conn;
  36. private $dsn;
  37. private $driver;
  38. private $username = null;
  39. private $password = null;
  40. private $connectionOptions = [];
  41. private $dbalStore;
  42. /**
  43. * You can either pass an existing database connection as PDO instance
  44. * or a DSN string that will be used to lazy-connect to the database
  45. * when the lock is actually used.
  46. *
  47. * List of available options:
  48. * * db_table: The name of the table [default: lock_keys]
  49. * * db_id_col: The column where to store the lock key [default: key_id]
  50. * * db_token_col: The column where to store the lock token [default: key_token]
  51. * * db_expiration_col: The column where to store the expiration [default: key_expiration]
  52. * * db_username: The username when lazy-connect [default: '']
  53. * * db_password: The password when lazy-connect [default: '']
  54. * * db_connection_options: An array of driver-specific connection options [default: []]
  55. *
  56. * @param \PDO|string $connOrDsn A \PDO instance or DSN string or null
  57. * @param array $options An associative array of options
  58. * @param float $gcProbability Probability expressed as floating number between 0 and 1 to clean old locks
  59. * @param int $initialTtl The expiration delay of locks in seconds
  60. *
  61. * @throws InvalidArgumentException When first argument is not PDO nor Connection nor string
  62. * @throws InvalidArgumentException When PDO error mode is not PDO::ERRMODE_EXCEPTION
  63. * @throws InvalidArgumentException When the initial ttl is not valid
  64. */
  65. public function __construct($connOrDsn, array $options = [], float $gcProbability = 0.01, int $initialTtl = 300)
  66. {
  67. if ($connOrDsn instanceof Connection || (\is_string($connOrDsn) && str_contains($connOrDsn, '://'))) {
  68. trigger_deprecation('symfony/lock', '5.4', 'Usage of a DBAL Connection with "%s" is deprecated and will be removed in symfony 6.0. Use "%s" instead.', __CLASS__, DoctrineDbalStore::class);
  69. $this->dbalStore = new DoctrineDbalStore($connOrDsn, $options, $gcProbability, $initialTtl);
  70. return;
  71. }
  72. $this->init($options, $gcProbability, $initialTtl);
  73. if ($connOrDsn instanceof \PDO) {
  74. if (\PDO::ERRMODE_EXCEPTION !== $connOrDsn->getAttribute(\PDO::ATTR_ERRMODE)) {
  75. 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__));
  76. }
  77. $this->conn = $connOrDsn;
  78. } elseif (\is_string($connOrDsn)) {
  79. $this->dsn = $connOrDsn;
  80. } else {
  81. throw new InvalidArgumentException(sprintf('"%s" requires PDO or Doctrine\DBAL\Connection instance or DSN string as first argument, "%s" given.', __CLASS__, get_debug_type($connOrDsn)));
  82. }
  83. $this->username = $options['db_username'] ?? $this->username;
  84. $this->password = $options['db_password'] ?? $this->password;
  85. $this->connectionOptions = $options['db_connection_options'] ?? $this->connectionOptions;
  86. }
  87. /**
  88. * {@inheritdoc}
  89. */
  90. public function save(Key $key)
  91. {
  92. if (isset($this->dbalStore)) {
  93. $this->dbalStore->save($key);
  94. return;
  95. }
  96. $key->reduceLifetime($this->initialTtl);
  97. $sql = "INSERT INTO $this->table ($this->idCol, $this->tokenCol, $this->expirationCol) VALUES (:id, :token, {$this->getCurrentTimestampStatement()} + $this->initialTtl)";
  98. $conn = $this->getConnection();
  99. try {
  100. $stmt = $conn->prepare($sql);
  101. } catch (\PDOException $e) {
  102. if ($this->isTableMissing($e) && (!$conn->inTransaction() || \in_array($this->driver, ['pgsql', 'sqlite', 'sqlsrv'], true))) {
  103. $this->createTable();
  104. }
  105. $stmt = $conn->prepare($sql);
  106. }
  107. $stmt->bindValue(':id', $this->getHashedKey($key));
  108. $stmt->bindValue(':token', $this->getUniqueToken($key));
  109. try {
  110. $stmt->execute();
  111. } catch (\PDOException $e) {
  112. if ($this->isTableMissing($e) && (!$conn->inTransaction() || \in_array($this->driver, ['pgsql', 'sqlite', 'sqlsrv'], true))) {
  113. $this->createTable();
  114. try {
  115. $stmt->execute();
  116. } catch (\PDOException $e) {
  117. $this->putOffExpiration($key, $this->initialTtl);
  118. }
  119. } else {
  120. // the lock is already acquired. It could be us. Let's try to put off.
  121. $this->putOffExpiration($key, $this->initialTtl);
  122. }
  123. }
  124. $this->randomlyPrune();
  125. $this->checkNotExpired($key);
  126. }
  127. /**
  128. * {@inheritdoc}
  129. */
  130. public function putOffExpiration(Key $key, float $ttl)
  131. {
  132. if (isset($this->dbalStore)) {
  133. $this->dbalStore->putOffExpiration($key, $ttl);
  134. return;
  135. }
  136. if ($ttl < 1) {
  137. throw new InvalidTtlException(sprintf('"%s()" expects a TTL greater or equals to 1 second. Got "%s".', __METHOD__, $ttl));
  138. }
  139. $key->reduceLifetime($ttl);
  140. $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()})";
  141. $stmt = $this->getConnection()->prepare($sql);
  142. $uniqueToken = $this->getUniqueToken($key);
  143. $stmt->bindValue(':id', $this->getHashedKey($key));
  144. $stmt->bindValue(':token1', $uniqueToken);
  145. $stmt->bindValue(':token2', $uniqueToken);
  146. $result = $stmt->execute();
  147. // 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
  148. if (!(\is_object($result) ? $result : $stmt)->rowCount() && !$this->exists($key)) {
  149. throw new LockConflictedException();
  150. }
  151. $this->checkNotExpired($key);
  152. }
  153. /**
  154. * {@inheritdoc}
  155. */
  156. public function delete(Key $key)
  157. {
  158. if (isset($this->dbalStore)) {
  159. $this->dbalStore->delete($key);
  160. return;
  161. }
  162. $sql = "DELETE FROM $this->table WHERE $this->idCol = :id AND $this->tokenCol = :token";
  163. $stmt = $this->getConnection()->prepare($sql);
  164. $stmt->bindValue(':id', $this->getHashedKey($key));
  165. $stmt->bindValue(':token', $this->getUniqueToken($key));
  166. $stmt->execute();
  167. }
  168. /**
  169. * {@inheritdoc}
  170. */
  171. public function exists(Key $key)
  172. {
  173. if (isset($this->dbalStore)) {
  174. return $this->dbalStore->exists($key);
  175. }
  176. $sql = "SELECT 1 FROM $this->table WHERE $this->idCol = :id AND $this->tokenCol = :token AND $this->expirationCol > {$this->getCurrentTimestampStatement()}";
  177. $stmt = $this->getConnection()->prepare($sql);
  178. $stmt->bindValue(':id', $this->getHashedKey($key));
  179. $stmt->bindValue(':token', $this->getUniqueToken($key));
  180. $result = $stmt->execute();
  181. return (bool) (\is_object($result) ? $result->fetchOne() : $stmt->fetchColumn());
  182. }
  183. private function getConnection(): \PDO
  184. {
  185. if (null === $this->conn) {
  186. $this->conn = new \PDO($this->dsn, $this->username, $this->password, $this->connectionOptions);
  187. $this->conn->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
  188. }
  189. return $this->conn;
  190. }
  191. /**
  192. * Creates the table to store lock keys which can be called once for setup.
  193. *
  194. * @throws \PDOException When the table already exists
  195. * @throws \DomainException When an unsupported PDO driver is used
  196. */
  197. public function createTable(): void
  198. {
  199. if (isset($this->dbalStore)) {
  200. $this->dbalStore->createTable();
  201. return;
  202. }
  203. // connect if we are not yet
  204. $conn = $this->getConnection();
  205. $driver = $this->getDriver();
  206. switch ($driver) {
  207. case 'mysql':
  208. $sql = "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";
  209. break;
  210. case 'sqlite':
  211. $sql = "CREATE TABLE $this->table ($this->idCol TEXT NOT NULL PRIMARY KEY, $this->tokenCol TEXT NOT NULL, $this->expirationCol INTEGER)";
  212. break;
  213. case 'pgsql':
  214. $sql = "CREATE TABLE $this->table ($this->idCol VARCHAR(64) NOT NULL PRIMARY KEY, $this->tokenCol VARCHAR(64) NOT NULL, $this->expirationCol INTEGER)";
  215. break;
  216. case 'oci':
  217. $sql = "CREATE TABLE $this->table ($this->idCol VARCHAR2(64) NOT NULL PRIMARY KEY, $this->tokenCol VARCHAR2(64) NOT NULL, $this->expirationCol INTEGER)";
  218. break;
  219. case 'sqlsrv':
  220. $sql = "CREATE TABLE $this->table ($this->idCol VARCHAR(64) NOT NULL PRIMARY KEY, $this->tokenCol VARCHAR(64) NOT NULL, $this->expirationCol INTEGER)";
  221. break;
  222. default:
  223. throw new \DomainException(sprintf('Creating the lock table is currently not implemented for platform "%s".', $driver));
  224. }
  225. $conn->exec($sql);
  226. }
  227. /**
  228. * Adds the Table to the Schema if it doesn't exist.
  229. *
  230. * @deprecated since symfony/lock 5.4 use DoctrineDbalStore instead
  231. */
  232. public function configureSchema(Schema $schema): void
  233. {
  234. if (isset($this->dbalStore)) {
  235. $this->dbalStore->configureSchema($schema);
  236. return;
  237. }
  238. throw new \BadMethodCallException(sprintf('"%s::%s()" is only supported when using a doctrine/dbal Connection.', __CLASS__, __METHOD__));
  239. }
  240. /**
  241. * Cleans up the table by removing all expired locks.
  242. */
  243. private function prune(): void
  244. {
  245. $sql = "DELETE FROM $this->table WHERE $this->expirationCol <= {$this->getCurrentTimestampStatement()}";
  246. $this->getConnection()->exec($sql);
  247. }
  248. private function getDriver(): string
  249. {
  250. if (null !== $this->driver) {
  251. return $this->driver;
  252. }
  253. $conn = $this->getConnection();
  254. $this->driver = $conn->getAttribute(\PDO::ATTR_DRIVER_NAME);
  255. return $this->driver;
  256. }
  257. /**
  258. * Provides an SQL function to get the current timestamp regarding the current connection's driver.
  259. */
  260. private function getCurrentTimestampStatement(): string
  261. {
  262. switch ($this->getDriver()) {
  263. case 'mysql':
  264. return 'UNIX_TIMESTAMP()';
  265. case 'sqlite':
  266. return 'strftime(\'%s\',\'now\')';
  267. case 'pgsql':
  268. return 'CAST(EXTRACT(epoch FROM NOW()) AS INT)';
  269. case 'oci':
  270. return '(SYSDATE - TO_DATE(\'19700101\',\'yyyymmdd\'))*86400 - TO_NUMBER(SUBSTR(TZ_OFFSET(sessiontimezone), 1, 3))*3600';
  271. case 'sqlsrv':
  272. return 'DATEDIFF(s, \'1970-01-01\', GETUTCDATE())';
  273. default:
  274. return (string) time();
  275. }
  276. }
  277. private function isTableMissing(\PDOException $exception): bool
  278. {
  279. $driver = $this->getDriver();
  280. [$sqlState, $code] = $exception->errorInfo ?? [null, $exception->getCode()];
  281. switch (true) {
  282. case 'pgsql' === $driver && '42P01' === $sqlState:
  283. case 'sqlite' === $driver && str_contains($exception->getMessage(), 'no such table:'):
  284. case 'oci' === $driver && 942 === $code:
  285. case 'sqlsrv' === $driver && 208 === $code:
  286. case 'mysql' === $driver && 1146 === $code:
  287. return true;
  288. default:
  289. return false;
  290. }
  291. }
  292. }