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.01int $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 $keyfloat $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. }