From 6dcaae1968021ae61f413e144a8dd9cb0432f9a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20TAMARELLE?= Date: Wed, 6 Oct 2021 08:42:50 +0200 Subject: [PATCH] [Lock] Split PdoStore into DoctrineDbalStore --- UPGRADE-5.4.md | 6 + UPGRADE-6.0.md | 2 + src/Symfony/Component/Lock/CHANGELOG.md | 8 + .../Lock/Store/DatabaseTableTrait.php | 75 ++++++ .../Store/DoctrineDbalPostgreSqlStore.php | 235 ++++++++++++++++++ .../Lock/Store/DoctrineDbalStore.php | 235 ++++++++++++++++++ src/Symfony/Component/Lock/Store/PdoStore.php | 214 +++++----------- .../Component/Lock/Store/PostgreSqlStore.php | 100 +++++--- .../Component/Lock/Store/StoreFactory.php | 22 +- .../Store/DoctrineDbalPostgreSqlStoreTest.php | 62 +++++ .../Tests/Store/DoctrineDbalStoreTest.php | 91 +++++++ .../Lock/Tests/Store/PdoDbalStoreTest.php | 37 +++ .../Lock/Tests/Store/PdoStoreTest.php | 3 - .../Tests/Store/PostgreSqlDbalStoreTest.php | 16 +- .../Lock/Tests/Store/StoreFactoryTest.php | 29 ++- 15 files changed, 911 insertions(+), 224 deletions(-) create mode 100644 src/Symfony/Component/Lock/Store/DatabaseTableTrait.php create mode 100644 src/Symfony/Component/Lock/Store/DoctrineDbalPostgreSqlStore.php create mode 100644 src/Symfony/Component/Lock/Store/DoctrineDbalStore.php create mode 100644 src/Symfony/Component/Lock/Tests/Store/DoctrineDbalPostgreSqlStoreTest.php create mode 100644 src/Symfony/Component/Lock/Tests/Store/DoctrineDbalStoreTest.php diff --git a/UPGRADE-5.4.md b/UPGRADE-5.4.md index 1e29b77185a05..3c00f5d63d3cd 100644 --- a/UPGRADE-5.4.md +++ b/UPGRADE-5.4.md @@ -41,6 +41,12 @@ HttpFoundation * Mark `Request::get()` internal, use explicit input sources instead +Lock +---- + + * Deprecate usage of `PdoStore` with a `Doctrine\DBAL\Connection` or a DBAL url, use the new `DoctrineDbalStore` instead + * Deprecate usage of `PostgreSqlStore` with a `Doctrine\DBAL\Connection` or a DBAL url, use the new `DoctrineDbalPostgreSqlStore` instead + Messenger --------- diff --git a/UPGRADE-6.0.md b/UPGRADE-6.0.md index a28ec6b1b3949..2db8e95cbb795 100644 --- a/UPGRADE-6.0.md +++ b/UPGRADE-6.0.md @@ -139,6 +139,8 @@ Lock * Removed the `NotSupportedException`. It shouldn't be thrown anymore. * Removed the `RetryTillSaveStore`. Logic has been moved in `Lock` and is not needed anymore. + * Removed usage of `PdoStore` with a `Doctrine\DBAL\Connection` or a DBAL url, use the new `DoctrineDbalStore` instead + * Removed usage of `PostgreSqlStore` with a `Doctrine\DBAL\Connection` or a DBAL url, use the new `DoctrineDbalPostgreSqlStore` instead Mailer ------ diff --git a/src/Symfony/Component/Lock/CHANGELOG.md b/src/Symfony/Component/Lock/CHANGELOG.md index 4c85f76587973..b4cfd189ca3fa 100644 --- a/src/Symfony/Component/Lock/CHANGELOG.md +++ b/src/Symfony/Component/Lock/CHANGELOG.md @@ -1,6 +1,14 @@ CHANGELOG ========= +5.4.0 +----- + +* added `DoctrineDbalStore` identical to `PdoStore` for `Doctrine\DBAL\Connection` or DBAL url +* deprecated usage of `PdoStore` with `Doctrine\DBAL\Connection` or DBAL url +* added `DoctrineDbalPostgreSqlStore` identical to `PdoPostgreSqlStore` for `Doctrine\DBAL\Connection` or DBAL url +* deprecated usage of `PdoPostgreSqlStore` with `Doctrine\DBAL\Connection` or DBAL url + 5.2.0 ----- diff --git a/src/Symfony/Component/Lock/Store/DatabaseTableTrait.php b/src/Symfony/Component/Lock/Store/DatabaseTableTrait.php new file mode 100644 index 0000000000000..0392b8068de53 --- /dev/null +++ b/src/Symfony/Component/Lock/Store/DatabaseTableTrait.php @@ -0,0 +1,75 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Lock\Store; + +use Symfony\Component\Lock\Exception\InvalidArgumentException; +use Symfony\Component\Lock\Exception\InvalidTtlException; +use Symfony\Component\Lock\Key; + +/** + * @internal + */ +trait DatabaseTableTrait +{ + private $table = 'lock_keys'; + private $idCol = 'key_id'; + private $tokenCol = 'key_token'; + private $expirationCol = 'key_expiration'; + private $gcProbability; + private $initialTtl; + + private function init(array $options, float $gcProbability, int $initialTtl) + { + if ($gcProbability < 0 || $gcProbability > 1) { + throw new InvalidArgumentException(sprintf('"%s" requires gcProbability between 0 and 1, "%f" given.', __METHOD__, $gcProbability)); + } + if ($initialTtl < 1) { + throw new InvalidTtlException(sprintf('"%s()" expects a strictly positive TTL, "%d" given.', __METHOD__, $initialTtl)); + } + + $this->table = $options['db_table'] ?? $this->table; + $this->idCol = $options['db_id_col'] ?? $this->idCol; + $this->tokenCol = $options['db_token_col'] ?? $this->tokenCol; + $this->expirationCol = $options['db_expiration_col'] ?? $this->expirationCol; + + $this->gcProbability = $gcProbability; + $this->initialTtl = $initialTtl; + } + + /** + * Returns a hashed version of the key. + */ + private function getHashedKey(Key $key): string + { + return hash('sha256', (string) $key); + } + + private function getUniqueToken(Key $key): string + { + if (!$key->hasState(__CLASS__)) { + $token = base64_encode(random_bytes(32)); + $key->setState(__CLASS__, $token); + } + + return $key->getState(__CLASS__); + } + + /** + * Prune the table randomly, based on GC probability. + */ + private function randomlyPrune(): void + { + if ($this->gcProbability > 0 && (1.0 === $this->gcProbability || (random_int(0, \PHP_INT_MAX) / \PHP_INT_MAX) <= $this->gcProbability)) { + $this->prune(); + } + } +} diff --git a/src/Symfony/Component/Lock/Store/DoctrineDbalPostgreSqlStore.php b/src/Symfony/Component/Lock/Store/DoctrineDbalPostgreSqlStore.php new file mode 100644 index 0000000000000..b8ac259ef5ae8 --- /dev/null +++ b/src/Symfony/Component/Lock/Store/DoctrineDbalPostgreSqlStore.php @@ -0,0 +1,235 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Lock\Store; + +use Doctrine\DBAL\Connection; +use Doctrine\DBAL\DriverManager; +use Doctrine\DBAL\Platforms\PostgreSQLPlatform; +use Symfony\Component\Lock\BlockingSharedLockStoreInterface; +use Symfony\Component\Lock\BlockingStoreInterface; +use Symfony\Component\Lock\Exception\InvalidArgumentException; +use Symfony\Component\Lock\Exception\LockConflictedException; +use Symfony\Component\Lock\Key; +use Symfony\Component\Lock\SharedLockStoreInterface; + +/** + * DoctrineDbalPostgreSqlStore is a PersistingStoreInterface implementation using + * PostgreSql advisory locks with a Doctrine DBAL Connection. + * + * @author Jérémy Derussé + */ +class DoctrineDbalPostgreSqlStore implements BlockingSharedLockStoreInterface, BlockingStoreInterface +{ + private $conn; + private static $storeRegistry = []; + + /** + * You can either pass an existing database connection a Doctrine DBAL Connection + * or a URL that will be used to connect to the database. + * + * @param Connection|string $connOrUrl A Connection instance or Doctrine URL + * + * @throws InvalidArgumentException When first argument is not Connection nor string + */ + public function __construct($connOrUrl) + { + if ($connOrUrl instanceof Connection) { + if (!$connOrUrl->getDatabasePlatform() instanceof PostgreSQLPlatform) { + throw new InvalidArgumentException(sprintf('The adapter "%s" does not support the "%s" platform.', __CLASS__, \get_class($connOrUrl->getDatabasePlatform()))); + } + $this->conn = $connOrUrl; + } elseif (\is_string($connOrUrl)) { + if (!class_exists(DriverManager::class)) { + throw new InvalidArgumentException(sprintf('Failed to parse the DSN "%s". Try running "composer require doctrine/dbal".', $connOrUrl)); + } + $this->conn = DriverManager::getConnection(['url' => $this->filterDsn($connOrUrl)]); + } else { + throw new \TypeError(sprintf('Argument 1 passed to "%s()" must be "%s" or string, "%s" given.', Connection::class, __METHOD__, get_debug_type($connOrUrl))); + } + } + + public function save(Key $key) + { + // prevent concurrency within the same connection + $this->getInternalStore()->save($key); + + $sql = 'SELECT pg_try_advisory_lock(:key)'; + $result = $this->conn->executeQuery($sql, [ + 'key' => $this->getHashedKey($key), + ]); + + // Check if lock is acquired + if (true === $result->fetchOne()) { + $key->markUnserializable(); + // release sharedLock in case of promotion + $this->unlockShared($key); + + return; + } + + throw new LockConflictedException(); + } + + public function saveRead(Key $key) + { + // prevent concurrency within the same connection + $this->getInternalStore()->saveRead($key); + + $sql = 'SELECT pg_try_advisory_lock_shared(:key)'; + $result = $this->conn->executeQuery($sql, [ + 'key' => $this->getHashedKey($key), + ]); + + // Check if lock is acquired + if (true === $result->fetchOne()) { + $key->markUnserializable(); + // release lock in case of demotion + $this->unlock($key); + + return; + } + + throw new LockConflictedException(); + } + + public function putOffExpiration(Key $key, float $ttl) + { + // postgresql locks forever. + // check if lock still exists + if (!$this->exists($key)) { + throw new LockConflictedException(); + } + } + + public function delete(Key $key) + { + // Prevent deleting locks own by an other key in the same connection + if (!$this->exists($key)) { + return; + } + + $this->unlock($key); + + // Prevent deleting Readlocks own by current key AND an other key in the same connection + $store = $this->getInternalStore(); + try { + // If lock acquired = there is no other ReadLock + $store->save($key); + $this->unlockShared($key); + } catch (LockConflictedException $e) { + // an other key exists in this ReadLock + } + + $store->delete($key); + } + + public function exists(Key $key) + { + $sql = "SELECT count(*) FROM pg_locks WHERE locktype='advisory' AND objid=:key AND pid=pg_backend_pid()"; + $result = $this->conn->executeQuery($sql, [ + 'key' => $this->getHashedKey($key), + ]); + + if ($result->fetchOne() > 0) { + // connection is locked, check for lock in internal store + return $this->getInternalStore()->exists($key); + } + + return false; + } + + public function waitAndSave(Key $key) + { + // prevent concurrency within the same connection + // Internal store does not allow blocking mode, because there is no way to acquire one in a single process + $this->getInternalStore()->save($key); + + $sql = 'SELECT pg_advisory_lock(:key)'; + $this->conn->executeStatement($sql, [ + 'key' => $this->getHashedKey($key), + ]); + + // release lock in case of promotion + $this->unlockShared($key); + } + + public function waitAndSaveRead(Key $key) + { + // prevent concurrency within the same connection + // Internal store does not allow blocking mode, because there is no way to acquire one in a single process + $this->getInternalStore()->saveRead($key); + + $sql = 'SELECT pg_advisory_lock_shared(:key)'; + $this->conn->executeStatement($sql, [ + 'key' => $this->getHashedKey($key), + ]); + + // release lock in case of demotion + $this->unlock($key); + } + + /** + * Returns a hashed version of the key. + */ + private function getHashedKey(Key $key): int + { + return crc32((string) $key); + } + + private function unlock(Key $key): void + { + do { + $sql = "SELECT pg_advisory_unlock(objid::bigint) FROM pg_locks WHERE locktype='advisory' AND mode='ExclusiveLock' AND objid=:key AND pid=pg_backend_pid()"; + $result = $this->conn->executeQuery($sql, [ + 'key' => $this->getHashedKey($key), + ]); + } while (0 !== $result->rowCount()); + } + + private function unlockShared(Key $key): void + { + do { + $sql = "SELECT pg_advisory_unlock_shared(objid::bigint) FROM pg_locks WHERE locktype='advisory' AND mode='ShareLock' AND objid=:key AND pid=pg_backend_pid()"; + $result = $this->conn->executeQuery($sql, [ + 'key' => $this->getHashedKey($key), + ]); + } while (0 !== $result->rowCount()); + } + + /** + * Check driver and remove scheme extension from DSN. + * From pgsql+advisory://server/ to pgsql://server/. + * + * @throws InvalidArgumentException when driver is not supported + */ + private function filterDsn(string $dsn): string + { + if (!str_contains($dsn, '://')) { + throw new InvalidArgumentException(sprintf('String "%" is not a valid DSN for Doctrine DBAL.', $dsn)); + } + + [$scheme, $rest] = explode(':', $dsn, 2); + $driver = strtok($scheme, '+'); + if (!\in_array($driver, ['pgsql', 'postgres', 'postgresql'])) { + throw new InvalidArgumentException(sprintf('The adapter "%s" does not support the "%s" driver.', __CLASS__, $driver)); + } + + return sprintf('%s:%s', $driver, $rest); + } + + private function getInternalStore(): SharedLockStoreInterface + { + $namespace = spl_object_hash($this->conn); + + return self::$storeRegistry[$namespace] ?? self::$storeRegistry[$namespace] = new InMemoryStore(); + } +} diff --git a/src/Symfony/Component/Lock/Store/DoctrineDbalStore.php b/src/Symfony/Component/Lock/Store/DoctrineDbalStore.php new file mode 100644 index 0000000000000..b4ab774f284e5 --- /dev/null +++ b/src/Symfony/Component/Lock/Store/DoctrineDbalStore.php @@ -0,0 +1,235 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Lock\Store; + +use Doctrine\DBAL\Connection; +use Doctrine\DBAL\DriverManager; +use Doctrine\DBAL\Exception as DBALException; +use Doctrine\DBAL\ParameterType; +use Doctrine\DBAL\Schema\Schema; +use Symfony\Component\Lock\Exception\InvalidArgumentException; +use Symfony\Component\Lock\Exception\InvalidTtlException; +use Symfony\Component\Lock\Exception\LockConflictedException; +use Symfony\Component\Lock\Key; +use Symfony\Component\Lock\PersistingStoreInterface; + +/** + * DbalStore is a PersistingStoreInterface implementation using a Doctrine DBAL connection. + * + * Lock metadata are stored in a table. You can use createTable() to initialize + * a correctly defined table. + + * CAUTION: This store relies on all client and server nodes to have + * synchronized clocks for lock expiry to occur at the correct time. + * To ensure locks don't expire prematurely; the TTLs should be set with enough + * extra time to account for any clock drift between nodes. + * + * @author Jérémy Derussé + */ +class DoctrineDbalStore implements PersistingStoreInterface +{ + use DatabaseTableTrait; + use ExpiringStoreTrait; + + private $conn; + private $dsn; + + /** + * List of available options: + * * db_table: The name of the table [default: lock_keys] + * * db_id_col: The column where to store the lock key [default: key_id] + * * db_token_col: The column where to store the lock token [default: key_token] + * * db_expiration_col: The column where to store the expiration [default: key_expiration]. + * + * @param Connection|string $connOrUrl A DBAL Connection instance or Doctrine URL + * @param array $options An associative array of options + * @param float $gcProbability Probability expressed as floating number between 0 and 1 to clean old locks + * @param int $initialTtl The expiration delay of locks in seconds + * + * @throws InvalidArgumentException When namespace contains invalid characters + * @throws InvalidArgumentException When the initial ttl is not valid + */ + public function __construct($connOrUrl, array $options = [], float $gcProbability = 0.01, int $initialTtl = 300) + { + $this->init($options, $gcProbability, $initialTtl); + + if ($connOrUrl instanceof Connection) { + $this->conn = $connOrUrl; + } elseif (\is_string($connOrUrl)) { + if (!class_exists(DriverManager::class)) { + throw new InvalidArgumentException(sprintf('Failed to parse the DSN "%s". Try running "composer require doctrine/dbal".', $connOrUrl)); + } + $this->conn = DriverManager::getConnection(['url' => $connOrUrl]); + } else { + throw new \TypeError(sprintf('Argument 1 passed to "%s()" must be "%s" or string, "%s" given.', Connection::class, __METHOD__, get_debug_type($connOrUrl))); + } + } + + /** + * {@inheritdoc} + */ + public function save(Key $key) + { + $key->reduceLifetime($this->initialTtl); + + $sql = "INSERT INTO $this->table ($this->idCol, $this->tokenCol, $this->expirationCol) VALUES (?, ?, {$this->getCurrentTimestampStatement()} + $this->initialTtl)"; + + try { + $this->conn->executeStatement($sql, [ + $this->getHashedKey($key), + $this->getUniqueToken($key), + ], [ + ParameterType::STRING, + ParameterType::STRING, + ]); + } catch (DBALException $e) { + // the lock is already acquired. It could be us. Let's try to put off. + $this->putOffExpiration($key, $this->initialTtl); + } + + $this->randomlyPrune(); + $this->checkNotExpired($key); + } + + /** + * {@inheritdoc} + */ + public function putOffExpiration(Key $key, $ttl) + { + if ($ttl < 1) { + throw new InvalidTtlException(sprintf('"%s()" expects a TTL greater or equals to 1 second. Got "%s".', __METHOD__, $ttl)); + } + + $key->reduceLifetime($ttl); + + $sql = "UPDATE $this->table SET $this->expirationCol = {$this->getCurrentTimestampStatement()} + ?, $this->tokenCol = ? WHERE $this->idCol = ? AND ($this->tokenCol = ? OR $this->expirationCol <= {$this->getCurrentTimestampStatement()})"; + $uniqueToken = $this->getUniqueToken($key); + + $result = $this->conn->executeQuery($sql, [ + $ttl, + $uniqueToken, + $this->getHashedKey($key), + $uniqueToken, + ], [ + ParameterType::INTEGER, + ParameterType::STRING, + ParameterType::STRING, + ParameterType::STRING, + ]); + + // 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 + if (!$result->rowCount() && !$this->exists($key)) { + throw new LockConflictedException(); + } + + $this->checkNotExpired($key); + } + + /** + * {@inheritdoc} + */ + public function delete(Key $key) + { + $this->conn->delete($this->table, [ + $this->idCol => $this->getHashedKey($key), + $this->tokenCol => $this->getUniqueToken($key), + ]); + } + + /** + * {@inheritdoc} + */ + public function exists(Key $key) + { + $sql = "SELECT 1 FROM $this->table WHERE $this->idCol = ? AND $this->tokenCol = ? AND $this->expirationCol > {$this->getCurrentTimestampStatement()}"; + $result = $this->conn->fetchOne($sql, [ + $this->getHashedKey($key), + $this->getUniqueToken($key), + ], [ + ParameterType::STRING, + ParameterType::STRING, + ]); + + return (bool) $result; + } + + /** + * Creates the table to store lock keys which can be called once for setup. + * + * @throws DBALException When the table already exists + */ + public function createTable(): void + { + $schema = new Schema(); + $this->configureSchema($schema); + + foreach ($schema->toSql($this->conn->getDatabasePlatform()) as $sql) { + $this->conn->executeStatement($sql); + } + } + + /** + * Adds the Table to the Schema if it doesn't exist. + */ + public function configureSchema(Schema $schema): void + { + if ($schema->hasTable($this->table)) { + return; + } + + $table = $schema->createTable($this->table); + $table->addColumn($this->idCol, 'string', ['length' => 64]); + $table->addColumn($this->tokenCol, 'string', ['length' => 44]); + $table->addColumn($this->expirationCol, 'integer', ['unsigned' => true]); + $table->setPrimaryKey([$this->idCol]); + } + + /** + * Cleans up the table by removing all expired locks. + */ + private function prune(): void + { + $sql = "DELETE FROM $this->table WHERE $this->expirationCol <= {$this->getCurrentTimestampStatement()}"; + + $this->conn->executeStatement($sql); + } + + /** + * Provides an SQL function to get the current timestamp regarding the current connection's driver. + */ + private function getCurrentTimestampStatement(): string + { + $platform = $this->conn->getDatabasePlatform(); + switch (true) { + case $platform instanceof \Doctrine\DBAL\Platforms\MySQLPlatform: + case $platform instanceof \Doctrine\DBAL\Platforms\MySQL57Platform: + return 'UNIX_TIMESTAMP()'; + + case $platform instanceof \Doctrine\DBAL\Platforms\SqlitePlatform: + return 'strftime(\'%s\',\'now\')'; + + case $platform instanceof \Doctrine\DBAL\Platforms\PostgreSQLPlatform: + case $platform instanceof \Doctrine\DBAL\Platforms\PostgreSQL94Platform: + return 'CAST(EXTRACT(epoch FROM NOW()) AS INT)'; + + case $platform instanceof \Doctrine\DBAL\Platforms\OraclePlatform: + return '(SYSDATE - TO_DATE(\'19700101\',\'yyyymmdd\'))*86400 - TO_NUMBER(SUBSTR(TZ_OFFSET(sessiontimezone), 1, 3))*3600'; + + case $platform instanceof \Doctrine\DBAL\Platforms\SQLServerPlatform: + case $platform instanceof \Doctrine\DBAL\Platforms\SQLServer2012Platform: + return 'DATEDIFF(s, \'1970-01-01\', GETUTCDATE())'; + + default: + return (string) time(); + } + } +} diff --git a/src/Symfony/Component/Lock/Store/PdoStore.php b/src/Symfony/Component/Lock/Store/PdoStore.php index 8034e8a619c71..79fe680145960 100644 --- a/src/Symfony/Component/Lock/Store/PdoStore.php +++ b/src/Symfony/Component/Lock/Store/PdoStore.php @@ -12,9 +12,6 @@ namespace Symfony\Component\Lock\Store; use Doctrine\DBAL\Connection; -use Doctrine\DBAL\DriverManager; -use Doctrine\DBAL\Exception as DBALException; -use Doctrine\DBAL\Exception\TableNotFoundException; use Doctrine\DBAL\Schema\Schema; use Symfony\Component\Lock\Exception\InvalidArgumentException; use Symfony\Component\Lock\Exception\InvalidTtlException; @@ -37,25 +34,22 @@ */ class PdoStore implements PersistingStoreInterface { + use DatabaseTableTrait; use ExpiringStoreTrait; private $conn; private $dsn; private $driver; - private $table = 'lock_keys'; - private $idCol = 'key_id'; - private $tokenCol = 'key_token'; - private $expirationCol = 'key_expiration'; private $username = ''; private $password = ''; private $connectionOptions = []; - private $gcProbability; - private $initialTtl; + + private $dbalStore; /** - * You can either pass an existing database connection as PDO instance or - * a Doctrine DBAL Connection or a DSN string that will be used to - * lazy-connect to the database when the lock is actually used. + * You can either pass an existing database connection as PDO instance + * or a DSN string that will be used to lazy-connect to the database + * when the lock is actually used. * * List of available options: * * db_table: The name of the table [default: lock_keys] @@ -66,10 +60,10 @@ class PdoStore implements PersistingStoreInterface * * db_password: The password when lazy-connect [default: ''] * * db_connection_options: An array of driver-specific connection options [default: []] * - * @param \PDO|Connection|string $connOrDsn A \PDO or Connection instance or DSN string or null - * @param array $options An associative array of options - * @param float $gcProbability Probability expressed as floating number between 0 and 1 to clean old locks - * @param int $initialTtl The expiration delay of locks in seconds + * @param \PDO|string $connOrDsn A \PDO instance or DSN string or null + * @param array $options An associative array of options + * @param float $gcProbability Probability expressed as floating number between 0 and 1 to clean old locks + * @param int $initialTtl The expiration delay of locks in seconds * * @throws InvalidArgumentException When first argument is not PDO nor Connection nor string * @throws InvalidArgumentException When PDO error mode is not PDO::ERRMODE_EXCEPTION @@ -77,20 +71,20 @@ class PdoStore implements PersistingStoreInterface */ public function __construct($connOrDsn, array $options = [], float $gcProbability = 0.01, int $initialTtl = 300) { - if ($gcProbability < 0 || $gcProbability > 1) { - throw new InvalidArgumentException(sprintf('"%s" requires gcProbability between 0 and 1, "%f" given.', __METHOD__, $gcProbability)); - } - if ($initialTtl < 1) { - throw new InvalidTtlException(sprintf('"%s()" expects a strictly positive TTL, "%d" given.', __METHOD__, $initialTtl)); + if ($connOrDsn instanceof Connection || (\is_string($connOrDsn) && str_contains($connOrDsn, '://'))) { + 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); + $this->dbalStore = new DoctrineDbalStore($connOrDsn, $options, $gcProbability, $initialTtl); + + return; } + $this->init($options, $gcProbability, $initialTtl); + if ($connOrDsn instanceof \PDO) { if (\PDO::ERRMODE_EXCEPTION !== $connOrDsn->getAttribute(\PDO::ATTR_ERRMODE)) { 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__)); } - $this->conn = $connOrDsn; - } elseif ($connOrDsn instanceof Connection) { $this->conn = $connOrDsn; } elseif (\is_string($connOrDsn)) { $this->dsn = $connOrDsn; @@ -98,16 +92,9 @@ public function __construct($connOrDsn, array $options = [], float $gcProbabilit 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))); } - $this->table = $options['db_table'] ?? $this->table; - $this->idCol = $options['db_id_col'] ?? $this->idCol; - $this->tokenCol = $options['db_token_col'] ?? $this->tokenCol; - $this->expirationCol = $options['db_expiration_col'] ?? $this->expirationCol; $this->username = $options['db_username'] ?? $this->username; $this->password = $options['db_password'] ?? $this->password; $this->connectionOptions = $options['db_connection_options'] ?? $this->connectionOptions; - - $this->gcProbability = $gcProbability; - $this->initialTtl = $initialTtl; } /** @@ -115,17 +102,18 @@ public function __construct($connOrDsn, array $options = [], float $gcProbabilit */ public function save(Key $key) { + if (isset($this->dbalStore)) { + $this->dbalStore->save($key); + + return; + } + $key->reduceLifetime($this->initialTtl); $sql = "INSERT INTO $this->table ($this->idCol, $this->tokenCol, $this->expirationCol) VALUES (:id, :token, {$this->getCurrentTimestampStatement()} + $this->initialTtl)"; $conn = $this->getConnection(); try { $stmt = $conn->prepare($sql); - } catch (TableNotFoundException $e) { - if (!$conn->isTransactionActive() || \in_array($this->driver, ['pgsql', 'sqlite', 'sqlsrv'], true)) { - $this->createTable(); - } - $stmt = $conn->prepare($sql); } catch (\PDOException $e) { if (!$conn->inTransaction() || \in_array($this->driver, ['pgsql', 'sqlite', 'sqlsrv'], true)) { $this->createTable(); @@ -138,23 +126,12 @@ public function save(Key $key) try { $stmt->execute(); - } catch (TableNotFoundException $e) { - if (!$conn->isTransactionActive() || \in_array($this->driver, ['pgsql', 'sqlite', 'sqlsrv'], true)) { - $this->createTable(); - } - $stmt->execute(); - } catch (DBALException $e) { - // the lock is already acquired. It could be us. Let's try to put off. - $this->putOffExpiration($key, $this->initialTtl); } catch (\PDOException $e) { // the lock is already acquired. It could be us. Let's try to put off. $this->putOffExpiration($key, $this->initialTtl); } - if ($this->gcProbability > 0 && (1.0 === $this->gcProbability || (random_int(0, \PHP_INT_MAX) / \PHP_INT_MAX) <= $this->gcProbability)) { - $this->prune(); - } - + $this->randomlyPrune(); $this->checkNotExpired($key); } @@ -163,6 +140,12 @@ public function save(Key $key) */ public function putOffExpiration(Key $key, float $ttl) { + if (isset($this->dbalStore)) { + $this->dbalStore->putOffExpiration($key, $ttl); + + return; + } + if ($ttl < 1) { throw new InvalidTtlException(sprintf('"%s()" expects a TTL greater or equals to 1 second. Got "%s".', __METHOD__, $ttl)); } @@ -191,6 +174,12 @@ public function putOffExpiration(Key $key, float $ttl) */ public function delete(Key $key) { + if (isset($this->dbalStore)) { + $this->dbalStore->delete($key); + + return; + } + $sql = "DELETE FROM $this->table WHERE $this->idCol = :id AND $this->tokenCol = :token"; $stmt = $this->getConnection()->prepare($sql); @@ -204,6 +193,10 @@ public function delete(Key $key) */ public function exists(Key $key) { + if (isset($this->dbalStore)) { + return $this->dbalStore->exists($key); + } + $sql = "SELECT 1 FROM $this->table WHERE $this->idCol = :id AND $this->tokenCol = :token AND $this->expirationCol > {$this->getCurrentTimestampStatement()}"; $stmt = $this->getConnection()->prepare($sql); @@ -214,39 +207,11 @@ public function exists(Key $key) return (bool) (\is_object($result) ? $result->fetchOne() : $stmt->fetchColumn()); } - /** - * Returns a hashed version of the key. - */ - private function getHashedKey(Key $key): string - { - return hash('sha256', (string) $key); - } - - private function getUniqueToken(Key $key): string - { - if (!$key->hasState(__CLASS__)) { - $token = base64_encode(random_bytes(32)); - $key->setState(__CLASS__, $token); - } - - return $key->getState(__CLASS__); - } - - /** - * @return \PDO|Connection - */ - private function getConnection(): object + private function getConnection(): \PDO { if (null === $this->conn) { - if (strpos($this->dsn, '://')) { - if (!class_exists(DriverManager::class)) { - throw new InvalidArgumentException(sprintf('Failed to parse the DSN "%s". Try running "composer require doctrine/dbal".', $this->dsn)); - } - $this->conn = DriverManager::getConnection(['url' => $this->dsn]); - } else { - $this->conn = new \PDO($this->dsn, $this->username, $this->password, $this->connectionOptions); - $this->conn->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); - } + $this->conn = new \PDO($this->dsn, $this->username, $this->password, $this->connectionOptions); + $this->conn->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); } return $this->conn; @@ -256,30 +221,20 @@ private function getConnection(): object * Creates the table to store lock keys which can be called once for setup. * * @throws \PDOException When the table already exists - * @throws DBALException When the table already exists * @throws \DomainException When an unsupported PDO driver is used */ public function createTable(): void { - // connect if we are not yet - $conn = $this->getConnection(); - $driver = $this->getDriver(); - - if ($conn instanceof Connection) { - $schema = new Schema(); - $this->addTableToSchema($schema); - - foreach ($schema->toSql($conn->getDatabasePlatform()) as $sql) { - if ($conn instanceof Connection && method_exists($conn, 'executeStatement')) { - $conn->executeStatement($sql); - } else { - $conn->exec($sql); - } - } + if (isset($this->dbalStore)) { + $this->dbalStore->createTable(); return; } + // connect if we are not yet + $conn = $this->getConnection(); + $driver = $this->getDriver(); + switch ($driver) { case 'mysql': $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"; @@ -300,27 +255,23 @@ public function createTable(): void throw new \DomainException(sprintf('Creating the lock table is currently not implemented for platform "%s".', $driver)); } - if ($conn instanceof Connection && method_exists($conn, 'executeStatement')) { - $conn->executeStatement($sql); - } else { - $conn->exec($sql); - } + $conn->exec($sql); } /** * Adds the Table to the Schema if it doesn't exist. + * + * @deprecated since symfony/lock 5.4 use DoctrineDbalStore instead */ public function configureSchema(Schema $schema): void { - if (!$this->getConnection() instanceof Connection) { - throw new \BadMethodCallException(sprintf('"%s::%s()" is only supported when using a doctrine/dbal Connection.', __CLASS__, __METHOD__)); - } + if (isset($this->dbalStore)) { + $this->dbalStore->configureSchema($schema); - if ($schema->hasTable($this->table)) { return; } - $this->addTableToSchema($schema); + throw new \BadMethodCallException(sprintf('"%s::%s()" is only supported when using a doctrine/dbal Connection.', __CLASS__, __METHOD__)); } /** @@ -330,12 +281,7 @@ private function prune(): void { $sql = "DELETE FROM $this->table WHERE $this->expirationCol <= {$this->getCurrentTimestampStatement()}"; - $conn = $this->getConnection(); - if ($conn instanceof Connection && method_exists($conn, 'executeStatement')) { - $conn->executeStatement($sql); - } else { - $conn->exec($sql); - } + $this->getConnection()->exec($sql); } private function getDriver(): string @@ -344,41 +290,8 @@ private function getDriver(): string return $this->driver; } - $con = $this->getConnection(); - if ($con instanceof \PDO) { - $this->driver = $con->getAttribute(\PDO::ATTR_DRIVER_NAME); - } else { - $driver = $con->getDriver(); - $platform = $driver->getDatabasePlatform(); - - if ($driver instanceof \Doctrine\DBAL\Driver\Mysqli\Driver) { - throw new \LogicException(sprintf('The adapter "%s" does not support the mysqli driver, use pdo_mysql instead.', static::class)); - } - - switch (true) { - case $platform instanceof \Doctrine\DBAL\Platforms\MySQLPlatform: - case $platform instanceof \Doctrine\DBAL\Platforms\MySQL57Platform: - $this->driver = 'mysql'; - break; - case $platform instanceof \Doctrine\DBAL\Platforms\SqlitePlatform: - $this->driver = 'sqlite'; - break; - case $platform instanceof \Doctrine\DBAL\Platforms\PostgreSQLPlatform: - case $platform instanceof \Doctrine\DBAL\Platforms\PostgreSQL94Platform: - $this->driver = 'pgsql'; - break; - case $platform instanceof \Doctrine\DBAL\Platforms\OraclePlatform: - $this->driver = 'oci'; - break; - case $platform instanceof \Doctrine\DBAL\Platforms\SQLServerPlatform: - case $platform instanceof \Doctrine\DBAL\Platforms\SQLServer2012Platform: - $this->driver = 'sqlsrv'; - break; - default: - $this->driver = \get_class($platform); - break; - } - } + $conn = $this->getConnection(); + $this->driver = $conn->getAttribute(\PDO::ATTR_DRIVER_NAME); return $this->driver; } @@ -400,16 +313,7 @@ private function getCurrentTimestampStatement(): string case 'sqlsrv': return 'DATEDIFF(s, \'1970-01-01\', GETUTCDATE())'; default: - return time(); + return (string) time(); } } - - private function addTableToSchema(Schema $schema): void - { - $table = $schema->createTable($this->table); - $table->addColumn($this->idCol, 'string', ['length' => 64]); - $table->addColumn($this->tokenCol, 'string', ['length' => 44]); - $table->addColumn($this->expirationCol, 'integer', ['unsigned' => true]); - $table->setPrimaryKey([$this->idCol]); - } } diff --git a/src/Symfony/Component/Lock/Store/PostgreSqlStore.php b/src/Symfony/Component/Lock/Store/PostgreSqlStore.php index 9fdbe94bac2ba..e5b5cf4304c6e 100644 --- a/src/Symfony/Component/Lock/Store/PostgreSqlStore.php +++ b/src/Symfony/Component/Lock/Store/PostgreSqlStore.php @@ -12,7 +12,6 @@ namespace Symfony\Component\Lock\Store; use Doctrine\DBAL\Connection; -use Doctrine\DBAL\DriverManager; use Symfony\Component\Lock\BlockingSharedLockStoreInterface; use Symfony\Component\Lock\BlockingStoreInterface; use Symfony\Component\Lock\Exception\InvalidArgumentException; @@ -35,18 +34,20 @@ class PostgreSqlStore implements BlockingSharedLockStoreInterface, BlockingStore private $connectionOptions = []; private static $storeRegistry = []; + private $dbalStore; + /** * You can either pass an existing database connection as PDO instance or - * a Doctrine DBAL Connection or a DSN string that will be used to - * lazy-connect to the database when the lock is actually used. + * a DSN string that will be used to lazy-connect to the database when the + * lock is actually used. * * List of available options: * * db_username: The username when lazy-connect [default: ''] * * db_password: The password when lazy-connect [default: ''] * * db_connection_options: An array of driver-specific connection options [default: []] * - * @param \PDO|Connection|string $connOrDsn A \PDO or Connection instance or DSN string or null - * @param array $options An associative array of options + * @param \PDO|string $connOrDsn A \PDO instance or DSN string or null + * @param array $options An associative array of options * * @throws InvalidArgumentException When first argument is not PDO nor Connection nor string * @throws InvalidArgumentException When PDO error mode is not PDO::ERRMODE_EXCEPTION @@ -54,6 +55,13 @@ class PostgreSqlStore implements BlockingSharedLockStoreInterface, BlockingStore */ public function __construct($connOrDsn, array $options = []) { + if ($connOrDsn instanceof Connection || (\is_string($connOrDsn) && str_contains($connOrDsn, '://'))) { + 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__, DoctrineDbalPostgreSqlStore::class); + $this->dbalStore = new DoctrineDbalPostgreSqlStore($connOrDsn); + + return; + } + if ($connOrDsn instanceof \PDO) { if (\PDO::ERRMODE_EXCEPTION !== $connOrDsn->getAttribute(\PDO::ATTR_ERRMODE)) { 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__)); @@ -77,6 +85,12 @@ public function __construct($connOrDsn, array $options = []) public function save(Key $key) { + if (isset($this->dbalStore)) { + $this->dbalStore->save($key); + + return; + } + // prevent concurrency within the same connection $this->getInternalStore()->save($key); @@ -86,7 +100,7 @@ public function save(Key $key) $result = $stmt->execute(); // Check if lock is acquired - if (true === (\is_object($result) ? $result->fetchOne() : $stmt->fetchColumn())) { + if (true === $stmt->fetchColumn()) { $key->markUnserializable(); // release sharedLock in case of promotion $this->unlockShared($key); @@ -99,6 +113,12 @@ public function save(Key $key) public function saveRead(Key $key) { + if (isset($this->dbalStore)) { + $this->dbalStore->saveRead($key); + + return; + } + // prevent concurrency within the same connection $this->getInternalStore()->saveRead($key); @@ -109,7 +129,7 @@ public function saveRead(Key $key) $result = $stmt->execute(); // Check if lock is acquired - if (true === (\is_object($result) ? $result->fetchOne() : $stmt->fetchColumn())) { + if (true === $stmt->fetchColumn()) { $key->markUnserializable(); // release lock in case of demotion $this->unlock($key); @@ -122,6 +142,12 @@ public function saveRead(Key $key) public function putOffExpiration(Key $key, float $ttl) { + if (isset($this->dbalStore)) { + $this->dbalStore->putOffExpiration($key); + + return; + } + // postgresql locks forever. // check if lock still exists if (!$this->exists($key)) { @@ -131,6 +157,12 @@ public function putOffExpiration(Key $key, float $ttl) public function delete(Key $key) { + if (isset($this->dbalStore)) { + $this->dbalStore->delete($key); + + return; + } + // Prevent deleting locks own by an other key in the same connection if (!$this->exists($key)) { return; @@ -153,13 +185,17 @@ public function delete(Key $key) public function exists(Key $key) { + if (isset($this->dbalStore)) { + return $this->dbalStore->exists($key); + } + $sql = "SELECT count(*) FROM pg_locks WHERE locktype='advisory' AND objid=:key AND pid=pg_backend_pid()"; $stmt = $this->getConnection()->prepare($sql); $stmt->bindValue(':key', $this->getHashedKey($key)); $result = $stmt->execute(); - if ((\is_object($result) ? $result->fetchOne() : $stmt->fetchColumn()) > 0) { + if ($stmt->fetchColumn() > 0) { // connection is locked, check for lock in internal store return $this->getInternalStore()->exists($key); } @@ -169,6 +205,12 @@ public function exists(Key $key) public function waitAndSave(Key $key) { + if (isset($this->dbalStore)) { + $this->dbalStore->waitAndSave($key); + + return; + } + // prevent concurrency within the same connection // Internal store does not allow blocking mode, because there is no way to acquire one in a single process $this->getInternalStore()->save($key); @@ -185,6 +227,12 @@ public function waitAndSave(Key $key) public function waitAndSaveRead(Key $key) { + if (isset($this->dbalStore)) { + $this->dbalStore->waitAndSaveRead($key); + + return; + } + // prevent concurrency within the same connection // Internal store does not allow blocking mode, because there is no way to acquire one in a single process $this->getInternalStore()->saveRead($key); @@ -215,7 +263,7 @@ private function unlock(Key $key): void $stmt->bindValue(':key', $this->getHashedKey($key)); $result = $stmt->execute(); - if (0 === (\is_object($result) ? $result : $stmt)->rowCount()) { + if (0 === $stmt->rowCount()) { break; } } @@ -229,27 +277,17 @@ private function unlockShared(Key $key): void $stmt->bindValue(':key', $this->getHashedKey($key)); $result = $stmt->execute(); - if (0 === (\is_object($result) ? $result : $stmt)->rowCount()) { + if (0 === $stmt->rowCount()) { break; } } } - /** - * @return \PDO|Connection - */ - private function getConnection(): object + private function getConnection(): \PDO { if (null === $this->conn) { - if (strpos($this->dsn, '://')) { - if (!class_exists(DriverManager::class)) { - throw new InvalidArgumentException(sprintf('Failed to parse the DSN "%s". Try running "composer require doctrine/dbal".', $this->dsn)); - } - $this->conn = DriverManager::getConnection(['url' => $this->dsn]); - } else { - $this->conn = new \PDO($this->dsn, $this->username, $this->password, $this->connectionOptions); - $this->conn->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); - } + $this->conn = new \PDO($this->dsn, $this->username, $this->password, $this->connectionOptions); + $this->conn->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); $this->checkDriver(); } @@ -259,20 +297,8 @@ private function getConnection(): object private function checkDriver(): void { - if ($this->conn instanceof \PDO) { - if ('pgsql' !== $driver = $this->conn->getAttribute(\PDO::ATTR_DRIVER_NAME)) { - throw new InvalidArgumentException(sprintf('The adapter "%s" does not support the "%s" driver.', __CLASS__, $driver)); - } - } else { - $driver = $this->conn->getDriver(); - - switch (true) { - case $driver instanceof \Doctrine\DBAL\Driver\PDOPgSql\Driver: - case $driver instanceof \Doctrine\DBAL\Driver\PDO\PgSQL\Driver: - break; - default: - throw new InvalidArgumentException(sprintf('The adapter "%s" does not support the "%s" driver.', __CLASS__, \get_class($driver))); - } + if ('pgsql' !== $driver = $this->conn->getAttribute(\PDO::ATTR_DRIVER_NAME)) { + throw new InvalidArgumentException(sprintf('The adapter "%s" does not support the "%s" driver.', __CLASS__, $driver)); } } diff --git a/src/Symfony/Component/Lock/Store/StoreFactory.php b/src/Symfony/Component/Lock/Store/StoreFactory.php index 98098136ad82d..847928ef8c113 100644 --- a/src/Symfony/Component/Lock/Store/StoreFactory.php +++ b/src/Symfony/Component/Lock/Store/StoreFactory.php @@ -52,9 +52,11 @@ public static function createStore($connection) return new MongoDbStore($connection); case $connection instanceof \PDO: - case $connection instanceof Connection: return new PdoStore($connection); + case $connection instanceof Connection: + return new DoctrineDbalStore($connection); + case $connection instanceof \Zookeeper: return new ZookeeperStore($connection); @@ -84,22 +86,30 @@ public static function createStore($connection) return new MongoDbStore($connection); case str_starts_with($connection, 'mssql://'): - case str_starts_with($connection, 'mysql:'): + case str_starts_with($connection, 'mysql://'): case str_starts_with($connection, 'mysql2://'): - case str_starts_with($connection, 'oci:'): case str_starts_with($connection, 'oci8://'): case str_starts_with($connection, 'pdo_oci://'): - case str_starts_with($connection, 'pgsql:'): + case str_starts_with($connection, 'pgsql://'): case str_starts_with($connection, 'postgres://'): case str_starts_with($connection, 'postgresql://'): + case str_starts_with($connection, 'sqlite://'): + case str_starts_with($connection, 'sqlite3://'): + return new DoctrineDbalStore($connection); + + case str_starts_with($connection, 'mysql:'): + case str_starts_with($connection, 'oci:'): + case str_starts_with($connection, 'pgsql:'): case str_starts_with($connection, 'sqlsrv:'): case str_starts_with($connection, 'sqlite:'): - case str_starts_with($connection, 'sqlite3://'): return new PdoStore($connection); - case str_starts_with($connection, 'pgsql+advisory:'): + case str_starts_with($connection, 'pgsql+advisory://'): case str_starts_with($connection, 'postgres+advisory://'): case str_starts_with($connection, 'postgresql+advisory://'): + return new DoctrineDbalPostgreSqlStore($connection); + + case str_starts_with($connection, 'pgsql+advisory:'): return new PostgreSqlStore(preg_replace('/^([^:+]+)\+advisory/', '$1', $connection)); case str_starts_with($connection, 'zookeeper://'): diff --git a/src/Symfony/Component/Lock/Tests/Store/DoctrineDbalPostgreSqlStoreTest.php b/src/Symfony/Component/Lock/Tests/Store/DoctrineDbalPostgreSqlStoreTest.php new file mode 100644 index 0000000000000..9133280ddc133 --- /dev/null +++ b/src/Symfony/Component/Lock/Tests/Store/DoctrineDbalPostgreSqlStoreTest.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Lock\Tests\Store; + +use Doctrine\DBAL\DriverManager; +use Symfony\Component\Lock\Exception\InvalidArgumentException; +use Symfony\Component\Lock\Key; +use Symfony\Component\Lock\PersistingStoreInterface; +use Symfony\Component\Lock\Store\DoctrineDbalPostgreSqlStore; + +/** + * @author Jérémy Derussé + * + * @requires extension pdo_pgsql + * @group integration + */ +class DoctrineDbalPostgreSqlStoreTest extends AbstractStoreTest +{ + use BlockingStoreTestTrait; + use SharedLockStoreTestTrait; + + /** + * {@inheritdoc} + */ + public function getStore(): PersistingStoreInterface + { + if (!getenv('POSTGRES_HOST')) { + $this->markTestSkipped('Missing POSTGRES_HOST env variable'); + } + $conn = DriverManager::getConnection(['url' => 'pgsql://postgres:password@'.getenv('POSTGRES_HOST')]); + + return new DoctrineDbalPostgreSqlStore($conn); + } + + /** + * @requires extension pdo_sqlite + * @dataProvider getInvalidDrivers + */ + public function testInvalidDriver($connOrDsn) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The adapter "Symfony\Component\Lock\Store\DoctrineDbalPostgreSqlStore" does not support'); + + $store = new DoctrineDbalPostgreSqlStore($connOrDsn); + $store->exists(new Key('foo')); + } + + public function getInvalidDrivers() + { + yield ['sqlite:///tmp/foo.db']; + yield [DriverManager::getConnection(['url' => 'sqlite:///tmp/foo.db'])]; + } +} diff --git a/src/Symfony/Component/Lock/Tests/Store/DoctrineDbalStoreTest.php b/src/Symfony/Component/Lock/Tests/Store/DoctrineDbalStoreTest.php new file mode 100644 index 0000000000000..c20e2d3088111 --- /dev/null +++ b/src/Symfony/Component/Lock/Tests/Store/DoctrineDbalStoreTest.php @@ -0,0 +1,91 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Lock\Tests\Store; + +use Doctrine\DBAL\DriverManager; +use Symfony\Component\Lock\Key; +use Symfony\Component\Lock\PersistingStoreInterface; +use Symfony\Component\Lock\Store\DoctrineDbalStore; + +/** + * @author Jérémy Derussé + * + * @requires extension pdo_sqlite + */ +class DoctrineDbalStoreTest extends AbstractStoreTest +{ + use ExpiringStoreTestTrait; + + protected static $dbFile; + + public static function setUpBeforeClass(): void + { + self::$dbFile = tempnam(sys_get_temp_dir(), 'sf_sqlite_lock'); + + $store = new DoctrineDbalStore(DriverManager::getConnection(['driver' => 'pdo_sqlite', 'path' => self::$dbFile])); + $store->createTable(); + } + + public static function tearDownAfterClass(): void + { + @unlink(self::$dbFile); + } + + /** + * {@inheritdoc} + */ + protected function getClockDelay() + { + return 1000000; + } + + /** + * {@inheritdoc} + */ + public function getStore(): PersistingStoreInterface + { + return new DoctrineDbalStore(DriverManager::getConnection(['driver' => 'pdo_sqlite', 'path' => self::$dbFile])); + } + + public function testAbortAfterExpiration() + { + $this->markTestSkipped('Pdo expects a TTL greater than 1 sec. Simulating a slow network is too hard'); + } + + /** + * @dataProvider provideDsn + */ + public function testDsn(string $dsn, string $file = null) + { + $key = new Key(uniqid(__METHOD__, true)); + + try { + $store = new DoctrineDbalStore($dsn); + $store->createTable(); + + $store->save($key); + $this->assertTrue($store->exists($key)); + } finally { + if (null !== $file) { + @unlink($file); + } + } + } + + public function provideDsn() + { + $dbFile = tempnam(sys_get_temp_dir(), 'sf_sqlite_cache'); + yield ['sqlite://localhost/'.$dbFile.'1', $dbFile.'1']; + yield ['sqlite3:///'.$dbFile.'3', $dbFile.'3']; + yield ['sqlite://localhost/:memory:']; + } +} diff --git a/src/Symfony/Component/Lock/Tests/Store/PdoDbalStoreTest.php b/src/Symfony/Component/Lock/Tests/Store/PdoDbalStoreTest.php index 44adca1ca0689..57b89021a23c1 100644 --- a/src/Symfony/Component/Lock/Tests/Store/PdoDbalStoreTest.php +++ b/src/Symfony/Component/Lock/Tests/Store/PdoDbalStoreTest.php @@ -14,6 +14,8 @@ use Doctrine\DBAL\Connection; use Doctrine\DBAL\DriverManager; use Doctrine\DBAL\Schema\Schema; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; +use Symfony\Component\Lock\Key; use Symfony\Component\Lock\PersistingStoreInterface; use Symfony\Component\Lock\Store\PdoStore; @@ -21,9 +23,11 @@ * @author Jérémy Derussé * * @requires extension pdo_sqlite + * @group legacy */ class PdoDbalStoreTest extends AbstractStoreTest { + use ExpectDeprecationTrait; use ExpiringStoreTestTrait; protected static $dbFile; @@ -54,6 +58,8 @@ protected function getClockDelay() */ public function getStore(): PersistingStoreInterface { + $this->expectDeprecation('Since symfony/lock 5.4: Usage of a DBAL Connection with "Symfony\Component\Lock\Store\PdoStore" is deprecated and will be removed in symfony 6.0. Use "Symfony\Component\Lock\Store\DoctrineDbalStore" instead.'); + return new PdoStore(DriverManager::getConnection(['driver' => 'pdo_sqlite', 'path' => self::$dbFile])); } @@ -64,9 +70,40 @@ public function testAbortAfterExpiration() public function testConfigureSchema() { + $this->expectDeprecation('Since symfony/lock 5.4: Usage of a DBAL Connection with "Symfony\Component\Lock\Store\PdoStore" is deprecated and will be removed in symfony 6.0. Use "Symfony\Component\Lock\Store\DoctrineDbalStore" instead.'); + $store = new PdoStore($this->createMock(Connection::class), ['db_table' => 'lock_table']); $schema = new Schema(); $store->configureSchema($schema); $this->assertTrue($schema->hasTable('lock_table')); } + + /** + * @dataProvider provideDsn + */ + public function testDsn(string $dsn, string $file = null) + { + $this->expectDeprecation('Since symfony/lock 5.4: Usage of a DBAL Connection with "Symfony\Component\Lock\Store\PdoStore" is deprecated and will be removed in symfony 6.0. Use "Symfony\Component\Lock\Store\DoctrineDbalStore" instead.'); + $key = new Key(uniqid(__METHOD__, true)); + + try { + $store = new PdoStore($dsn); + $store->createTable(); + + $store->save($key); + $this->assertTrue($store->exists($key)); + } finally { + if (null !== $file) { + @unlink($file); + } + } + } + + public function provideDsn() + { + $dbFile = tempnam(sys_get_temp_dir(), 'sf_sqlite_cache'); + yield ['sqlite://localhost/'.$dbFile.'1', $dbFile.'1']; + yield ['sqlite3:///'.$dbFile.'3', $dbFile.'3']; + yield ['sqlite://localhost/:memory:']; + } } diff --git a/src/Symfony/Component/Lock/Tests/Store/PdoStoreTest.php b/src/Symfony/Component/Lock/Tests/Store/PdoStoreTest.php index 082f50aa75e63..69968a2f11b80 100644 --- a/src/Symfony/Component/Lock/Tests/Store/PdoStoreTest.php +++ b/src/Symfony/Component/Lock/Tests/Store/PdoStoreTest.php @@ -98,10 +98,7 @@ public function testDsn(string $dsn, string $file = null) public function provideDsn() { $dbFile = tempnam(sys_get_temp_dir(), 'sf_sqlite_cache'); - yield ['sqlite://localhost/'.$dbFile.'1', $dbFile.'1']; yield ['sqlite:'.$dbFile.'2', $dbFile.'2']; - yield ['sqlite3:///'.$dbFile.'3', $dbFile.'3']; - yield ['sqlite://localhost/:memory:']; yield ['sqlite::memory:']; } } diff --git a/src/Symfony/Component/Lock/Tests/Store/PostgreSqlDbalStoreTest.php b/src/Symfony/Component/Lock/Tests/Store/PostgreSqlDbalStoreTest.php index 6c007fabb3377..b5b1194dd2109 100644 --- a/src/Symfony/Component/Lock/Tests/Store/PostgreSqlDbalStoreTest.php +++ b/src/Symfony/Component/Lock/Tests/Store/PostgreSqlDbalStoreTest.php @@ -11,8 +11,7 @@ namespace Symfony\Component\Lock\Tests\Store; -use Symfony\Component\Lock\Exception\InvalidArgumentException; -use Symfony\Component\Lock\Key; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Symfony\Component\Lock\PersistingStoreInterface; use Symfony\Component\Lock\Store\PostgreSqlStore; @@ -21,10 +20,12 @@ * * @requires extension pdo_pgsql * @group integration + * @group legacy */ class PostgreSqlDbalStoreTest extends AbstractStoreTest { use BlockingStoreTestTrait; + use ExpectDeprecationTrait; use SharedLockStoreTestTrait; /** @@ -36,15 +37,8 @@ public function getStore(): PersistingStoreInterface $this->markTestSkipped('Missing POSTGRES_HOST env variable'); } - return new PostgreSqlStore('pgsql://postgres:password@'.getenv('POSTGRES_HOST')); - } + $this->expectDeprecation('Since symfony/lock 5.4: Usage of a DBAL Connection with "Symfony\Component\Lock\Store\PostgreSqlStore" is deprecated and will be removed in symfony 6.0. Use "Symfony\Component\Lock\Store\DoctrineDbalPostgreSqlStore" instead.'); - public function testInvalidDriver() - { - $store = new PostgreSqlStore('sqlite:///foo.db'); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('The adapter "Symfony\Component\Lock\Store\PostgreSqlStore" does not support'); - $store->exists(new Key('foo')); + return new PostgreSqlStore('pgsql://postgres:password@'.getenv('POSTGRES_HOST')); } } diff --git a/src/Symfony/Component/Lock/Tests/Store/StoreFactoryTest.php b/src/Symfony/Component/Lock/Tests/Store/StoreFactoryTest.php index e0f70a278543b..41634d5ce11a5 100644 --- a/src/Symfony/Component/Lock/Tests/Store/StoreFactoryTest.php +++ b/src/Symfony/Component/Lock/Tests/Store/StoreFactoryTest.php @@ -11,9 +11,12 @@ namespace Symfony\Component\Lock\Tests\Store; +use Doctrine\DBAL\Connection; use PHPUnit\Framework\TestCase; use Symfony\Component\Cache\Adapter\AbstractAdapter; use Symfony\Component\Cache\Traits\RedisProxy; +use Symfony\Component\Lock\Store\DoctrineDbalPostgreSqlStore; +use Symfony\Component\Lock\Store\DoctrineDbalStore; use Symfony\Component\Lock\Store\FlockStore; use Symfony\Component\Lock\Store\InMemoryStore; use Symfony\Component\Lock\Store\MemcachedStore; @@ -82,18 +85,20 @@ public function validConnections() yield ['pgsql+advisory:host=localhost;dbname=test;', PostgreSqlStore::class]; yield ['oci:host=localhost;dbname=test;', PdoStore::class]; yield ['sqlsrv:server=localhost;Database=test', PdoStore::class]; - yield ['mysql://server.com/test', PdoStore::class]; - yield ['mysql2://server.com/test', PdoStore::class]; - yield ['pgsql://server.com/test', PdoStore::class]; - yield ['pgsql+advisory://server.com/test', PostgreSqlStore::class]; - yield ['postgres://server.com/test', PdoStore::class]; - yield ['postgres+advisory://server.com/test', PostgreSqlStore::class]; - yield ['postgresql://server.com/test', PdoStore::class]; - yield ['postgresql+advisory://server.com/test', PostgreSqlStore::class]; - yield ['sqlite:///tmp/test', PdoStore::class]; - yield ['sqlite3:///tmp/test', PdoStore::class]; - yield ['oci:///server.com/test', PdoStore::class]; - yield ['mssql:///server.com/test', PdoStore::class]; + } + if (class_exists(Connection::class)) { + yield ['mysql://server.com/test', DoctrineDbalStore::class]; + yield ['mysql2://server.com/test', DoctrineDbalStore::class]; + yield ['pgsql://server.com/test', DoctrineDbalStore::class]; + yield ['postgres://server.com/test', DoctrineDbalStore::class]; + yield ['postgresql://server.com/test', DoctrineDbalStore::class]; + yield ['sqlite:///tmp/test', DoctrineDbalStore::class]; + yield ['sqlite3:///tmp/test', DoctrineDbalStore::class]; + yield ['oci8://server.com/test', DoctrineDbalStore::class]; + yield ['mssql://server.com/test', DoctrineDbalStore::class]; + yield ['pgsql+advisory://server.com/test', DoctrineDbalPostgreSqlStore::class]; + yield ['postgres+advisory://server.com/test', DoctrineDbalPostgreSqlStore::class]; + yield ['postgresql+advisory://server.com/test', DoctrineDbalPostgreSqlStore::class]; } yield ['in-memory', InMemoryStore::class];