diff --git a/UPGRADE-5.4.md b/UPGRADE-5.4.md index fde9127d139be..adbe23b1abdd5 100644 --- a/UPGRADE-5.4.md +++ b/UPGRADE-5.4.md @@ -5,6 +5,7 @@ Cache ----- * Deprecate `DoctrineProvider` and `DoctrineAdapter` because these classes have been added to the `doctrine/cache` package + * Deprecate usage of `PdoAdapter` with a `Doctrine\DBAL\Connection` or a DBAL URL. Use the new `DoctrineDbalAdapter` instead Console ------- diff --git a/UPGRADE-6.0.md b/UPGRADE-6.0.md index 0c504895d43af..484fc5d03516d 100644 --- a/UPGRADE-6.0.md +++ b/UPGRADE-6.0.md @@ -15,6 +15,7 @@ Cache ----- * Remove `DoctrineProvider` and `DoctrineAdapter` because these classes have been added to the `doctrine/cache` package + * `PdoAdapter` does not accept `Doctrine\DBAL\Connection` or DBAL URL. Use the new `DoctrineDbalAdapter` instead Config ------ diff --git a/composer.json b/composer.json index f051ea5c7948e..a933c12e8fc89 100644 --- a/composer.json +++ b/composer.json @@ -129,7 +129,7 @@ "doctrine/cache": "^1.11|^2.0", "doctrine/collections": "~1.0", "doctrine/data-fixtures": "^1.1", - "doctrine/dbal": "^2.13|^3.0", + "doctrine/dbal": "^2.13.1|^3.0", "doctrine/orm": "^2.7.3", "guzzlehttp/promises": "^1.4", "masterminds/html5": "^2.6", @@ -155,7 +155,7 @@ "ext-psr": "<1.1|>=2", "async-aws/core": "<1.5", "doctrine/annotations": "<1.13.1", - "doctrine/dbal": "<2.13", + "doctrine/dbal": "<2.13.1", "egulias/email-validator": "~3.0.0", "masterminds/html5": "<2.6", "phpdocumentor/reflection-docblock": "<3.2.2", diff --git a/src/Symfony/Bridge/Doctrine/CHANGELOG.md b/src/Symfony/Bridge/Doctrine/CHANGELOG.md index 08416aa755fd4..37b39cf8d980e 100644 --- a/src/Symfony/Bridge/Doctrine/CHANGELOG.md +++ b/src/Symfony/Bridge/Doctrine/CHANGELOG.md @@ -5,6 +5,7 @@ CHANGELOG --- * Add `DoctrineOpenTransactionLoggerMiddleware` to log when a transaction has been left open + * Deprecate `PdoCacheAdapterDoctrineSchemaSubscriber` and add `DoctrineDbalCacheAdapterSchemaSubscriber` instead 5.3 --- diff --git a/src/Symfony/Bridge/Doctrine/SchemaListener/DoctrineDbalCacheAdapterSchemaSubscriber.php b/src/Symfony/Bridge/Doctrine/SchemaListener/DoctrineDbalCacheAdapterSchemaSubscriber.php new file mode 100644 index 0000000000000..e61564807befd --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/SchemaListener/DoctrineDbalCacheAdapterSchemaSubscriber.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\SchemaListener; + +use Doctrine\Common\EventSubscriber; +use Doctrine\ORM\Tools\Event\GenerateSchemaEventArgs; +use Doctrine\ORM\Tools\ToolEvents; +use Symfony\Component\Cache\Adapter\DoctrineSchemaConfiguratorInterface; + +/** + * Automatically adds the cache table needed for the DoctrineDbalAdapter of + * the Cache component. + * + * @author Ryan Weaver + */ +final class DoctrineDbalCacheAdapterSchemaSubscriber implements EventSubscriber +{ + private $dbalAdapters; + + /** + * @param iterable $dbalAdapters + */ + public function __construct(iterable $dbalAdapters) + { + $this->dbalAdapters = $dbalAdapters; + } + + public function postGenerateSchema(GenerateSchemaEventArgs $event): void + { + $dbalConnection = $event->getEntityManager()->getConnection(); + foreach ($this->dbalAdapters as $dbalAdapter) { + $dbalAdapter->configureSchema($event->getSchema(), $dbalConnection); + } + } + + public function getSubscribedEvents(): array + { + if (!class_exists(ToolEvents::class)) { + return []; + } + + return [ + ToolEvents::postGenerateSchema, + ]; + } +} diff --git a/src/Symfony/Bridge/Doctrine/SchemaListener/PdoCacheAdapterDoctrineSchemaSubscriber.php b/src/Symfony/Bridge/Doctrine/SchemaListener/PdoCacheAdapterDoctrineSchemaSubscriber.php index 527b055b28078..3bf19205fb7cd 100644 --- a/src/Symfony/Bridge/Doctrine/SchemaListener/PdoCacheAdapterDoctrineSchemaSubscriber.php +++ b/src/Symfony/Bridge/Doctrine/SchemaListener/PdoCacheAdapterDoctrineSchemaSubscriber.php @@ -16,10 +16,14 @@ use Doctrine\ORM\Tools\ToolEvents; use Symfony\Component\Cache\Adapter\PdoAdapter; +trigger_deprecation('symfony/doctrine-bridge', '5.4', 'The "%s" class is deprecated, use "%s" instead.', PdoCacheAdapterDoctrineSchemaSubscriber::class, DoctrineDbalCacheAdapterSchemaSubscriber::class); + /** * Automatically adds the cache table needed for the PdoAdapter. * * @author Ryan Weaver + * + * @deprecated since symfony 5.4 use DoctrineDbalCacheAdapterSchemaSubscriber */ final class PdoCacheAdapterDoctrineSchemaSubscriber implements EventSubscriber { diff --git a/src/Symfony/Bridge/Doctrine/Tests/SchemaListener/DoctrineDbalCacheAdapterSchemaSubscriberTest.php b/src/Symfony/Bridge/Doctrine/Tests/SchemaListener/DoctrineDbalCacheAdapterSchemaSubscriberTest.php new file mode 100644 index 0000000000000..8f1afa99b1319 --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Tests/SchemaListener/DoctrineDbalCacheAdapterSchemaSubscriberTest.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\Tests\SchemaListener; + +use Doctrine\DBAL\Connection; +use Doctrine\DBAL\Schema\Schema; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Tools\Event\GenerateSchemaEventArgs; +use PHPUnit\Framework\TestCase; +use Symfony\Bridge\Doctrine\SchemaListener\DoctrineDbalCacheAdapterSchemaSubscriber; +use Symfony\Component\Cache\Adapter\DoctrineSchemaConfiguratorInterface; + +class DoctrineDbalCacheAdapterSchemaSubscriberTest extends TestCase +{ + public function testPostGenerateSchema() + { + $schema = new Schema(); + $dbalConnection = $this->createMock(Connection::class); + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects($this->once()) + ->method('getConnection') + ->willReturn($dbalConnection); + $event = new GenerateSchemaEventArgs($entityManager, $schema); + + $pdoAdapter = $this->createMock(DoctrineSchemaConfiguratorInterface::class); + $pdoAdapter->expects($this->once()) + ->method('configureSchema') + ->with($schema, $dbalConnection); + + $subscriber = new DoctrineDbalCacheAdapterSchemaSubscriber([$pdoAdapter]); + $subscriber->postGenerateSchema($event); + } +} diff --git a/src/Symfony/Bridge/Doctrine/Tests/SchemaListener/PdoCacheAdapterDoctrineSchemaSubscriberTest.php b/src/Symfony/Bridge/Doctrine/Tests/SchemaListener/PdoCacheAdapterDoctrineSchemaSubscriberTest.php index 9cf70e943ed25..90b76328db9f9 100644 --- a/src/Symfony/Bridge/Doctrine/Tests/SchemaListener/PdoCacheAdapterDoctrineSchemaSubscriberTest.php +++ b/src/Symfony/Bridge/Doctrine/Tests/SchemaListener/PdoCacheAdapterDoctrineSchemaSubscriberTest.php @@ -19,6 +19,9 @@ use Symfony\Bridge\Doctrine\SchemaListener\PdoCacheAdapterDoctrineSchemaSubscriber; use Symfony\Component\Cache\Adapter\PdoAdapter; +/** + * @group legacy + */ class PdoCacheAdapterDoctrineSchemaSubscriberTest extends TestCase { public function testPostGenerateSchema() diff --git a/src/Symfony/Bridge/Doctrine/composer.json b/src/Symfony/Bridge/Doctrine/composer.json index a4f5985ba6db6..c1feaf4e4213b 100644 --- a/src/Symfony/Bridge/Doctrine/composer.json +++ b/src/Symfony/Bridge/Doctrine/composer.json @@ -28,7 +28,7 @@ "require-dev": { "composer/package-versions-deprecated": "^1.8", "symfony/stopwatch": "^4.4|^5.0|^6.0", - "symfony/cache": "^5.1|^6.0", + "symfony/cache": "^5.4|^6.0", "symfony/config": "^4.4|^5.0|^6.0", "symfony/dependency-injection": "^4.4|^5.0|^6.0", "symfony/form": "^5.1.3|^6.0", @@ -47,14 +47,15 @@ "doctrine/annotations": "^1.10.4", "doctrine/collections": "~1.0", "doctrine/data-fixtures": "^1.1", - "doctrine/dbal": "^2.13|^3.0", + "doctrine/dbal": "^2.13.1|^3.0", "doctrine/orm": "^2.7.3", "psr/log": "^1|^2|^3" }, "conflict": { - "doctrine/dbal": "<2.13", + "doctrine/dbal": "<2.13.1", "doctrine/orm": "<2.7.3", "phpunit/phpunit": "<5.4.3", + "symfony/cache": "<5.4", "symfony/dependency-injection": "<4.4", "symfony/form": "<5.1", "symfony/http-kernel": "<5", diff --git a/src/Symfony/Component/Cache/Adapter/DoctrineDbalAdapter.php b/src/Symfony/Component/Cache/Adapter/DoctrineDbalAdapter.php new file mode 100644 index 0000000000000..58e1693798339 --- /dev/null +++ b/src/Symfony/Component/Cache/Adapter/DoctrineDbalAdapter.php @@ -0,0 +1,397 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Adapter; + +use Doctrine\DBAL\Connection; +use Doctrine\DBAL\Driver\ServerInfoAwareConnection; +use Doctrine\DBAL\DriverManager; +use Doctrine\DBAL\Exception as DBALException; +use Doctrine\DBAL\Exception\TableNotFoundException; +use Doctrine\DBAL\ParameterType; +use Doctrine\DBAL\Schema\Schema; +use Symfony\Component\Cache\Exception\InvalidArgumentException; +use Symfony\Component\Cache\Marshaller\DefaultMarshaller; +use Symfony\Component\Cache\Marshaller\MarshallerInterface; +use Symfony\Component\Cache\PruneableInterface; + +final class DoctrineDbalAdapter extends AbstractAdapter implements PruneableInterface, DoctrineSchemaConfiguratorInterface +{ + protected $maxIdLength = 255; + + private $marshaller; + private $conn; + private $platformName; + private $serverVersion; + private $table = 'cache_items'; + private $idCol = 'item_id'; + private $dataCol = 'item_data'; + private $lifetimeCol = 'item_lifetime'; + private $timeCol = 'item_time'; + private $namespace; + + /** + * You can either pass an existing database Doctrine DBAL Connection or + * a DSN string that will be used to connect to the database. + * + * The cache table is created automatically when possible. + * Otherwise, use the createTable() method. + * + * List of available options: + * * db_table: The name of the table [default: cache_items] + * * db_id_col: The column where to store the cache id [default: item_id] + * * db_data_col: The column where to store the cache data [default: item_data] + * * db_lifetime_col: The column where to store the lifetime [default: item_lifetime] + * * db_time_col: The column where to store the timestamp [default: item_time] + * + * @param Connection|string $connOrDsn + * + * @throws InvalidArgumentException When namespace contains invalid characters + */ + public function __construct($connOrDsn, string $namespace = '', int $defaultLifetime = 0, array $options = [], MarshallerInterface $marshaller = null) + { + if (isset($namespace[0]) && preg_match('#[^-+.A-Za-z0-9]#', $namespace, $match)) { + throw new InvalidArgumentException(sprintf('Namespace contains "%s" but only characters in [-+.A-Za-z0-9] are allowed.', $match[0])); + } + + if ($connOrDsn instanceof Connection) { + $this->conn = $connOrDsn; + } elseif (\is_string($connOrDsn)) { + if (!class_exists(DriverManager::class)) { + throw new InvalidArgumentException(sprintf('Failed to parse the DSN "%s". Try running "composer require doctrine/dbal".', $connOrDsn)); + } + $this->conn = DriverManager::getConnection(['url' => $connOrDsn]); + } else { + throw new \TypeError(sprintf('Argument 1 passed to "%s()" must be "%s" or string, "%s" given.', Connection::class, __METHOD__, get_debug_type($connOrDsn))); + } + + $this->table = $options['db_table'] ?? $this->table; + $this->idCol = $options['db_id_col'] ?? $this->idCol; + $this->dataCol = $options['db_data_col'] ?? $this->dataCol; + $this->lifetimeCol = $options['db_lifetime_col'] ?? $this->lifetimeCol; + $this->timeCol = $options['db_time_col'] ?? $this->timeCol; + $this->namespace = $namespace; + $this->marshaller = $marshaller ?? new DefaultMarshaller(); + + parent::__construct($namespace, $defaultLifetime); + } + + /** + * Creates the table to store cache items which can be called once for setup. + * + * Cache ID are saved in a column of maximum length 255. Cache data is + * saved in a BLOB. + * + * @throws DBALException When the table already exists + */ + public function createTable() + { + $schema = new Schema(); + $this->addTableToSchema($schema); + + foreach ($schema->toSql($this->conn->getDatabasePlatform()) as $sql) { + $this->conn->executeStatement($sql); + } + } + + /** + * {@inheritdoc} + */ + public function configureSchema(Schema $schema, Connection $forConnection): void + { + // only update the schema for this connection + if ($forConnection !== $this->conn) { + return; + } + + if ($schema->hasTable($this->table)) { + return; + } + + $this->addTableToSchema($schema); + } + + /** + * {@inheritdoc} + */ + public function prune(): bool + { + $deleteSql = "DELETE FROM $this->table WHERE $this->lifetimeCol + $this->timeCol <= ?"; + $params = [time()]; + $paramTypes = [ParameterType::INTEGER]; + + if ('' !== $this->namespace) { + $deleteSql .= " AND $this->idCol LIKE ?"; + $params[] = sprintf('%s%%', $this->namespace); + $paramTypes[] = ParameterType::STRING; + } + + try { + $this->conn->executeStatement($deleteSql, $params, $paramTypes); + } catch (TableNotFoundException $e) { + } + + return true; + } + + /** + * {@inheritdoc} + */ + protected function doFetch(array $ids): iterable + { + $now = time(); + $expired = []; + + $sql = "SELECT $this->idCol, CASE WHEN $this->lifetimeCol IS NULL OR $this->lifetimeCol + $this->timeCol > ? THEN $this->dataCol ELSE NULL END FROM $this->table WHERE $this->idCol IN (?)"; + $result = $this->conn->executeQuery($sql, [ + $now, + $ids, + ], [ + ParameterType::INTEGER, + Connection::PARAM_STR_ARRAY, + ])->iterateNumeric(); + + foreach ($result as $row) { + if (null === $row[1]) { + $expired[] = $row[0]; + } else { + yield $row[0] => $this->marshaller->unmarshall(\is_resource($row[1]) ? stream_get_contents($row[1]) : $row[1]); + } + } + + if ($expired) { + $sql = "DELETE FROM $this->table WHERE $this->lifetimeCol + $this->timeCol <= ? AND $this->idCol IN (?)"; + $this->conn->executeStatement($sql, [ + $now, + $expired, + ], [ + ParameterType::INTEGER, + Connection::PARAM_STR_ARRAY, + ]); + } + } + + /** + * {@inheritdoc} + */ + protected function doHave(string $id): bool + { + $sql = "SELECT 1 FROM $this->table WHERE $this->idCol = ? AND ($this->lifetimeCol IS NULL OR $this->lifetimeCol + $this->timeCol > ?)"; + $result = $this->conn->executeQuery($sql, [ + $id, + time(), + ], [ + ParameterType::STRING, + ParameterType::INTEGER, + ]); + + return (bool) $result->fetchOne(); + } + + /** + * {@inheritdoc} + */ + protected function doClear(string $namespace): bool + { + if ('' === $namespace) { + if ('sqlite' === $this->getPlatformName()) { + $sql = "DELETE FROM $this->table"; + } else { + $sql = "TRUNCATE TABLE $this->table"; + } + } else { + $sql = "DELETE FROM $this->table WHERE $this->idCol LIKE '$namespace%'"; + } + + try { + $this->conn->executeStatement($sql); + } catch (TableNotFoundException $e) { + } + + return true; + } + + /** + * {@inheritdoc} + */ + protected function doDelete(array $ids): bool + { + $sql = "DELETE FROM $this->table WHERE $this->idCol IN (?)"; + try { + $this->conn->executeStatement($sql, [array_values($ids)], [Connection::PARAM_STR_ARRAY]); + } catch (TableNotFoundException $e) { + } + + return true; + } + + /** + * {@inheritdoc} + */ + protected function doSave(array $values, int $lifetime) + { + if (!$values = $this->marshaller->marshall($values, $failed)) { + return $failed; + } + + $platformName = $this->getPlatformName(); + $insertSql = "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (?, ?, ?, ?)"; + + switch (true) { + case 'mysql' === $platformName: + $sql = $insertSql." ON DUPLICATE KEY UPDATE $this->dataCol = VALUES($this->dataCol), $this->lifetimeCol = VALUES($this->lifetimeCol), $this->timeCol = VALUES($this->timeCol)"; + break; + case 'oci' === $platformName: + // DUAL is Oracle specific dummy table + $sql = "MERGE INTO $this->table USING DUAL ON ($this->idCol = ?) ". + "WHEN NOT MATCHED THEN INSERT ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (?, ?, ?, ?) ". + "WHEN MATCHED THEN UPDATE SET $this->dataCol = ?, $this->lifetimeCol = ?, $this->timeCol = ?"; + break; + case 'sqlsrv' === $platformName && version_compare($this->getServerVersion(), '10', '>='): + // MERGE is only available since SQL Server 2008 and must be terminated by semicolon + // It also requires HOLDLOCK according to http://weblogs.sqlteam.com/dang/archive/2009/01/31/UPSERT-Race-Condition-With-MERGE.aspx + $sql = "MERGE INTO $this->table WITH (HOLDLOCK) USING (SELECT 1 AS dummy) AS src ON ($this->idCol = ?) ". + "WHEN NOT MATCHED THEN INSERT ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (?, ?, ?, ?) ". + "WHEN MATCHED THEN UPDATE SET $this->dataCol = ?, $this->lifetimeCol = ?, $this->timeCol = ?;"; + break; + case 'sqlite' === $platformName: + $sql = 'INSERT OR REPLACE'.substr($insertSql, 6); + break; + case 'pgsql' === $platformName && version_compare($this->getServerVersion(), '9.5', '>='): + $sql = $insertSql." ON CONFLICT ($this->idCol) DO UPDATE SET ($this->dataCol, $this->lifetimeCol, $this->timeCol) = (EXCLUDED.$this->dataCol, EXCLUDED.$this->lifetimeCol, EXCLUDED.$this->timeCol)"; + break; + default: + $platformName = null; + $sql = "UPDATE $this->table SET $this->dataCol = ?, $this->lifetimeCol = ?, $this->timeCol = ? WHERE $this->idCol = ?"; + break; + } + + $now = time(); + $lifetime = $lifetime ?: null; + try { + $stmt = $this->conn->prepare($sql); + } catch (TableNotFoundException $e) { + if (!$this->conn->isTransactionActive() || \in_array($platformName, ['pgsql', 'sqlite', 'sqlsrv'], true)) { + $this->createTable(); + } + $stmt = $this->conn->prepare($sql); + } + + // $id and $data are defined later in the loop. Binding is done by reference, values are read on execution. + if ('sqlsrv' === $platformName || 'oci' === $platformName) { + $stmt->bindParam(1, $id); + $stmt->bindParam(2, $id); + $stmt->bindParam(3, $data, ParameterType::LARGE_OBJECT); + $stmt->bindValue(4, $lifetime, ParameterType::INTEGER); + $stmt->bindValue(5, $now, ParameterType::INTEGER); + $stmt->bindParam(6, $data, ParameterType::LARGE_OBJECT); + $stmt->bindValue(7, $lifetime, ParameterType::INTEGER); + $stmt->bindValue(8, $now, ParameterType::INTEGER); + } elseif (null !== $platformName) { + $stmt->bindParam(1, $id); + $stmt->bindParam(2, $data, ParameterType::LARGE_OBJECT); + $stmt->bindValue(3, $lifetime, ParameterType::INTEGER); + $stmt->bindValue(4, $now, ParameterType::INTEGER); + } else { + $stmt->bindParam(1, $data, ParameterType::LARGE_OBJECT); + $stmt->bindValue(2, $lifetime, ParameterType::INTEGER); + $stmt->bindValue(3, $now, ParameterType::INTEGER); + $stmt->bindParam(4, $id); + + $insertStmt = $this->conn->prepare($insertSql); + $insertStmt->bindParam(1, $id); + $insertStmt->bindParam(2, $data, ParameterType::LARGE_OBJECT); + $insertStmt->bindValue(3, $lifetime, ParameterType::INTEGER); + $insertStmt->bindValue(4, $now, ParameterType::INTEGER); + } + + foreach ($values as $id => $data) { + try { + $rowCount = $stmt->executeStatement(); + } catch (TableNotFoundException $e) { + if (!$this->conn->isTransactionActive() || \in_array($platformName, ['pgsql', 'sqlite', 'sqlsrv'], true)) { + $this->createTable(); + } + $rowCount = $stmt->executeStatement(); + } + if (null === $platformName && 0 === $rowCount) { + try { + $insertStmt->executeStatement(); + } catch (DBALException $e) { + // A concurrent write won, let it be + } + } + } + + return $failed; + } + + private function getPlatformName(): string + { + if (isset($this->platformName)) { + return $this->platformName; + } + + $platform = $this->conn->getDatabasePlatform(); + + switch (true) { + case $platform instanceof \Doctrine\DBAL\Platforms\MySQLPlatform: + case $platform instanceof \Doctrine\DBAL\Platforms\MySQL57Platform: + return $this->platformName = 'mysql'; + + case $platform instanceof \Doctrine\DBAL\Platforms\SqlitePlatform: + return $this->platformName = 'sqlite'; + + case $platform instanceof \Doctrine\DBAL\Platforms\PostgreSQLPlatform: + case $platform instanceof \Doctrine\DBAL\Platforms\PostgreSQL94Platform: + return $this->platformName = 'pgsql'; + + case $platform instanceof \Doctrine\DBAL\Platforms\OraclePlatform: + return $this->platformName = 'oci'; + + case $platform instanceof \Doctrine\DBAL\Platforms\SQLServerPlatform: + case $platform instanceof \Doctrine\DBAL\Platforms\SQLServer2012Platform: + return $this->platformName = 'sqlsrv'; + + default: + return $this->platformName = \get_class($platform); + } + } + + private function getServerVersion(): string + { + if (isset($this->serverVersion)) { + return $this->serverVersion; + } + + $conn = $this->conn->getWrappedConnection(); + if ($conn instanceof ServerInfoAwareConnection) { + return $this->serverVersion = $conn->getServerVersion(); + } + + return $this->serverVersion = '0'; + } + + private function addTableToSchema(Schema $schema): void + { + $types = [ + 'mysql' => 'binary', + 'sqlite' => 'text', + ]; + + $table = $schema->createTable($this->table); + $table->addColumn($this->idCol, $types[$this->getPlatformName()] ?? 'string', ['length' => 255]); + $table->addColumn($this->dataCol, 'blob', ['length' => 16777215]); + $table->addColumn($this->lifetimeCol, 'integer', ['unsigned' => true, 'notnull' => false]); + $table->addColumn($this->timeCol, 'integer', ['unsigned' => true]); + $table->setPrimaryKey([$this->idCol]); + } +} diff --git a/src/Symfony/Component/Cache/Adapter/DoctrineSchemaConfiguratorInterface.php b/src/Symfony/Component/Cache/Adapter/DoctrineSchemaConfiguratorInterface.php new file mode 100644 index 0000000000000..57812cabaf396 --- /dev/null +++ b/src/Symfony/Component/Cache/Adapter/DoctrineSchemaConfiguratorInterface.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Adapter; + +use Doctrine\DBAL\Connection; +use Doctrine\DBAL\Schema\Schema; + +/** + * @internal + */ +interface DoctrineSchemaConfiguratorInterface +{ + /** + * Adds the Table to the Schema if the adapter uses this Connection. + */ + public function configureSchema(Schema $schema, Connection $forConnection): void; +} diff --git a/src/Symfony/Component/Cache/Adapter/PdoAdapter.php b/src/Symfony/Component/Cache/Adapter/PdoAdapter.php index 470b36b482c90..5d107244312e7 100644 --- a/src/Symfony/Component/Cache/Adapter/PdoAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/PdoAdapter.php @@ -12,13 +12,9 @@ namespace Symfony\Component\Cache\Adapter; use Doctrine\DBAL\Connection; -use Doctrine\DBAL\Driver\ServerInfoAwareConnection; -use Doctrine\DBAL\DriverManager; -use Doctrine\DBAL\Exception as DBALException; -use Doctrine\DBAL\Exception\TableNotFoundException; -use Doctrine\DBAL\ParameterType; use Doctrine\DBAL\Schema\Schema; -use Doctrine\DBAL\Statement; +use Psr\Cache\CacheItemInterface; +use Psr\Log\LoggerInterface; use Symfony\Component\Cache\Exception\InvalidArgumentException; use Symfony\Component\Cache\Marshaller\DefaultMarshaller; use Symfony\Component\Cache\Marshaller\MarshallerInterface; @@ -43,13 +39,12 @@ class PdoAdapter extends AbstractAdapter implements PruneableInterface private $connectionOptions = []; private $namespace; + private $dbalAdapter; + /** * 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 cache is actually used. - * - * When a Doctrine DBAL Connection is passed, the cache table is created - * automatically when possible. Otherwise, use the createTable() method. + * a DSN string that will be used to lazy-connect to the database when the + * cache is actually used. * * List of available options: * * db_table: The name of the table [default: cache_items] @@ -61,7 +56,7 @@ class PdoAdapter extends AbstractAdapter implements PruneableInterface * * db_password: The password when lazy-connect [default: ''] * * db_connection_options: An array of driver-specific connection options [default: []] * - * @param \PDO|Connection|string $connOrDsn + * @param \PDO|string $connOrDsn * * @throws InvalidArgumentException When first argument is not PDO nor Connection nor string * @throws InvalidArgumentException When PDO error mode is not PDO::ERRMODE_EXCEPTION @@ -69,6 +64,13 @@ class PdoAdapter extends AbstractAdapter implements PruneableInterface */ public function __construct($connOrDsn, string $namespace = '', int $defaultLifetime = 0, array $options = [], MarshallerInterface $marshaller = null) { + if ($connOrDsn instanceof Connection || (\is_string($connOrDsn) && str_contains($connOrDsn, '://'))) { + trigger_deprecation('symfony/cache', '5.4', 'Usage of a DBAL Connection with "%s" is deprecated and will be removed in symfony 6.0. Use "%s" instead.', __CLASS__, DoctrineDbalAdapter::class); + $this->dbalAdapter = new DoctrineDbalAdapter($connOrDsn, $namespace, $defaultLifetime, $options, $marshaller); + + return; + } + if (isset($namespace[0]) && preg_match('#[^-+.A-Za-z0-9]#', $namespace, $match)) { throw new InvalidArgumentException(sprintf('Namespace contains "%s" but only characters in [-+.A-Za-z0-9] are allowed.', $match[0])); } @@ -78,8 +80,6 @@ public function __construct($connOrDsn, string $namespace = '', int $defaultLife throw new InvalidArgumentException(sprintf('"%s" requires PDO error mode attribute be set to throw Exceptions (i.e. $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION)).', __CLASS__)); } - $this->conn = $connOrDsn; - } elseif ($connOrDsn instanceof Connection) { $this->conn = $connOrDsn; } elseif (\is_string($connOrDsn)) { $this->dsn = $connOrDsn; @@ -101,6 +101,166 @@ public function __construct($connOrDsn, string $namespace = '', int $defaultLife parent::__construct($namespace, $defaultLifetime); } + /** + * {@inheritDoc} + */ + public function getItem($key) + { + if (isset($this->dbalAdapter)) { + return $this->dbalAdapter->getItem($key); + } + + return parent::getItem($key); + } + + /** + * {@inheritDoc} + */ + public function getItems(array $keys = []) + { + if (isset($this->dbalAdapter)) { + return $this->dbalAdapter->getItems($keys); + } + + return parent::getItems($keys); + } + + /** + * {@inheritDoc} + */ + public function hasItem($key) + { + if (isset($this->dbalAdapter)) { + return $this->dbalAdapter->hasItem($key); + } + + return parent::hasItem($key); + } + + /** + * {@inheritDoc} + */ + public function deleteItem($key) + { + if (isset($this->dbalAdapter)) { + return $this->dbalAdapter->deleteItem($key); + } + + return parent::deleteItem($key); + } + + /** + * {@inheritDoc} + */ + public function deleteItems(array $keys) + { + if (isset($this->dbalAdapter)) { + return $this->dbalAdapter->deleteItems($keys); + } + + return parent::deleteItems($keys); + } + + /** + * {@inheritDoc} + */ + public function clear(string $prefix = '') + { + if (isset($this->dbalAdapter)) { + return $this->dbalAdapter->clear($prefix); + } + + return parent::clear($prefix); + } + + /** + * {@inheritDoc} + */ + public function get(string $key, callable $callback, float $beta = null, array &$metadata = null) + { + if (isset($this->dbalAdapter)) { + return $this->dbalAdapter->get($key, $callback, $beta, $metadata); + } + + return parent::get($key, $callback, $beta, $metadata); + } + + /** + * {@inheritDoc} + */ + public function delete(string $key): bool + { + if (isset($this->dbalAdapter)) { + return $this->dbalAdapter->delete($key); + } + + return parent::delete($key); + } + + /** + * {@inheritDoc} + */ + public function save(CacheItemInterface $item) + { + if (isset($this->dbalAdapter)) { + return $this->dbalAdapter->save($item); + } + + return parent::save($item); + } + + /** + * {@inheritDoc} + */ + public function saveDeferred(CacheItemInterface $item) + { + if (isset($this->dbalAdapter)) { + return $this->dbalAdapter->saveDeferred($item); + } + + return parent::saveDeferred($item); + } + + /** + * {@inheritDoc} + */ + public function setLogger(LoggerInterface $logger): void + { + if (isset($this->dbalAdapter)) { + $this->dbalAdapter->setLogger($logger); + + return; + } + + parent::setLogger($logger); + } + + /** + * {@inheritDoc} + */ + public function commit() + { + if (isset($this->dbalAdapter)) { + return $this->dbalAdapter->commit(); + } + + return parent::commit(); + } + + /** + * {@inheritDoc} + */ + public function reset() + { + if (isset($this->dbalAdapter)) { + $this->dbalAdapter->reset(); + + return; + } + + parent::reset(); + } + /** * Creates the table to store cache items which can be called once for setup. * @@ -108,29 +268,19 @@ public function __construct($connOrDsn, string $namespace = '', int $defaultLife * saved in a BLOB. * * @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() { - // connect if we are not yet - $conn = $this->getConnection(); - - 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->dbalAdapter)) { + $this->dbalAdapter->createTable(); return; } + // connect if we are not yet + $conn = $this->getConnection(); + switch ($this->driver) { case 'mysql': // We use varbinary for the ID column because it prevents unwanted conversions: @@ -156,28 +306,19 @@ public function createTable() throw new \DomainException(sprintf('Creating the cache table is currently not implemented for PDO driver "%s".', $this->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 the adapter uses this Connection. + * + * @deprecated since symfony/cache 5.4 use DoctrineDbalAdapter instead */ public function configureSchema(Schema $schema, Connection $forConnection): void { - // only update the schema for this connection - if ($forConnection !== $this->getConnection()) { - return; - } - - if ($schema->hasTable($this->table)) { - return; + if (isset($this->dbalAdapter)) { + $this->dbalAdapter->configureSchema($schema, $forConnection); } - - $this->addTableToSchema($schema); } /** @@ -185,6 +326,10 @@ public function configureSchema(Schema $schema, Connection $forConnection): void */ public function prune() { + if (isset($this->dbalAdapter)) { + return $this->dbalAdapter->prune(); + } + $deleteSql = "DELETE FROM $this->table WHERE $this->lifetimeCol + $this->timeCol <= :time"; if ('' !== $this->namespace) { @@ -192,31 +337,19 @@ public function prune() } $connection = $this->getConnection(); - $useDbalConstants = $connection instanceof Connection; try { $delete = $connection->prepare($deleteSql); - } catch (TableNotFoundException $e) { - return true; } catch (\PDOException $e) { return true; } - $delete->bindValue(':time', time(), $useDbalConstants ? ParameterType::INTEGER : \PDO::PARAM_INT); + $delete->bindValue(':time', time(), \PDO::PARAM_INT); if ('' !== $this->namespace) { - $delete->bindValue(':namespace', sprintf('%s%%', $this->namespace), $useDbalConstants ? ParameterType::STRING : \PDO::PARAM_STR); + $delete->bindValue(':namespace', sprintf('%s%%', $this->namespace), \PDO::PARAM_STR); } try { - // Doctrine DBAL ^2.13 || >= 3.1 - if ($delete instanceof Statement && method_exists($delete, 'executeStatement')) { - $delete->executeStatement(); - - return true; - } - return $delete->execute(); - } catch (TableNotFoundException $e) { - return true; } catch (\PDOException $e) { return true; } @@ -228,7 +361,6 @@ public function prune() protected function doFetch(array $ids) { $connection = $this->getConnection(); - $useDbalConstants = $connection instanceof Connection; $now = time(); $expired = []; @@ -236,7 +368,7 @@ protected function doFetch(array $ids) $sql = str_pad('', (\count($ids) << 1) - 1, '?,'); $sql = "SELECT $this->idCol, CASE WHEN $this->lifetimeCol IS NULL OR $this->lifetimeCol + $this->timeCol > ? THEN $this->dataCol ELSE NULL END FROM $this->table WHERE $this->idCol IN ($sql)"; $stmt = $connection->prepare($sql); - $stmt->bindValue($i = 1, $now, $useDbalConstants ? ParameterType::INTEGER : \PDO::PARAM_INT); + $stmt->bindValue($i = 1, $now, \PDO::PARAM_INT); foreach ($ids as $id) { $stmt->bindValue(++$i, $id); } @@ -261,7 +393,7 @@ protected function doFetch(array $ids) $sql = str_pad('', (\count($expired) << 1) - 1, '?,'); $sql = "DELETE FROM $this->table WHERE $this->lifetimeCol + $this->timeCol <= ? AND $this->idCol IN ($sql)"; $stmt = $connection->prepare($sql); - $stmt->bindValue($i = 1, $now, $useDbalConstants ? ParameterType::INTEGER : \PDO::PARAM_INT); + $stmt->bindValue($i = 1, $now, \PDO::PARAM_INT); foreach ($expired as $id) { $stmt->bindValue(++$i, $id); } @@ -275,16 +407,15 @@ protected function doFetch(array $ids) protected function doHave(string $id) { $connection = $this->getConnection(); - $useDbalConstants = $connection instanceof Connection; $sql = "SELECT 1 FROM $this->table WHERE $this->idCol = :id AND ($this->lifetimeCol IS NULL OR $this->lifetimeCol + $this->timeCol > :time)"; $stmt = $connection->prepare($sql); $stmt->bindValue(':id', $id); - $stmt->bindValue(':time', time(), $useDbalConstants ? ParameterType::INTEGER : \PDO::PARAM_INT); - $result = $stmt->execute(); + $stmt->bindValue(':time', time(), \PDO::PARAM_INT); + $stmt->execute(); - return (bool) (\is_object($result) ? $result->fetchOne() : $stmt->fetchColumn()); + return (bool) $stmt->fetchColumn(); } /** @@ -305,12 +436,7 @@ protected function doClear(string $namespace) } try { - if ($conn instanceof Connection && method_exists($conn, 'executeStatement')) { - $conn->executeStatement($sql); - } else { - $conn->exec($sql); - } - } catch (TableNotFoundException $e) { + $conn->exec($sql); } catch (\PDOException $e) { } @@ -327,7 +453,6 @@ protected function doDelete(array $ids) try { $stmt = $this->getConnection()->prepare($sql); $stmt->execute(array_values($ids)); - } catch (TableNotFoundException $e) { } catch (\PDOException $e) { } @@ -344,7 +469,6 @@ protected function doSave(array $values, int $lifetime) } $conn = $this->getConnection(); - $useDbalConstants = $conn instanceof Connection; $driver = $this->driver; $insertSql = "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time)"; @@ -382,11 +506,6 @@ protected function doSave(array $values, int $lifetime) $lifetime = $lifetime ?: null; 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(); @@ -394,48 +513,43 @@ protected function doSave(array $values, int $lifetime) $stmt = $conn->prepare($sql); } + // $id and $data are defined later in the loop. Binding is done by reference, values are read on execution. if ('sqlsrv' === $driver || 'oci' === $driver) { $stmt->bindParam(1, $id); $stmt->bindParam(2, $id); - $stmt->bindParam(3, $data, $useDbalConstants ? ParameterType::LARGE_OBJECT : \PDO::PARAM_LOB); - $stmt->bindValue(4, $lifetime, $useDbalConstants ? ParameterType::INTEGER : \PDO::PARAM_INT); - $stmt->bindValue(5, $now, $useDbalConstants ? ParameterType::INTEGER : \PDO::PARAM_INT); - $stmt->bindParam(6, $data, $useDbalConstants ? ParameterType::LARGE_OBJECT : \PDO::PARAM_LOB); - $stmt->bindValue(7, $lifetime, $useDbalConstants ? ParameterType::INTEGER : \PDO::PARAM_INT); - $stmt->bindValue(8, $now, $useDbalConstants ? ParameterType::INTEGER : \PDO::PARAM_INT); + $stmt->bindParam(3, $data, \PDO::PARAM_LOB); + $stmt->bindValue(4, $lifetime, \PDO::PARAM_INT); + $stmt->bindValue(5, $now, \PDO::PARAM_INT); + $stmt->bindParam(6, $data, \PDO::PARAM_LOB); + $stmt->bindValue(7, $lifetime, \PDO::PARAM_INT); + $stmt->bindValue(8, $now, \PDO::PARAM_INT); } else { $stmt->bindParam(':id', $id); - $stmt->bindParam(':data', $data, $useDbalConstants ? ParameterType::LARGE_OBJECT : \PDO::PARAM_LOB); - $stmt->bindValue(':lifetime', $lifetime, $useDbalConstants ? ParameterType::INTEGER : \PDO::PARAM_INT); - $stmt->bindValue(':time', $now, $useDbalConstants ? ParameterType::INTEGER : \PDO::PARAM_INT); + $stmt->bindParam(':data', $data, \PDO::PARAM_LOB); + $stmt->bindValue(':lifetime', $lifetime, \PDO::PARAM_INT); + $stmt->bindValue(':time', $now, \PDO::PARAM_INT); } if (null === $driver) { $insertStmt = $conn->prepare($insertSql); $insertStmt->bindParam(':id', $id); - $insertStmt->bindParam(':data', $data, $useDbalConstants ? ParameterType::LARGE_OBJECT : \PDO::PARAM_LOB); - $insertStmt->bindValue(':lifetime', $lifetime, $useDbalConstants ? ParameterType::INTEGER : \PDO::PARAM_INT); - $insertStmt->bindValue(':time', $now, $useDbalConstants ? ParameterType::INTEGER : \PDO::PARAM_INT); + $insertStmt->bindParam(':data', $data, \PDO::PARAM_LOB); + $insertStmt->bindValue(':lifetime', $lifetime, \PDO::PARAM_INT); + $insertStmt->bindValue(':time', $now, \PDO::PARAM_INT); } foreach ($values as $id => $data) { try { - $result = $stmt->execute(); - } catch (TableNotFoundException $e) { - if (!$conn->isTransactionActive() || \in_array($this->driver, ['pgsql', 'sqlite', 'sqlsrv'], true)) { - $this->createTable(); - } - $result = $stmt->execute(); + $stmt->execute(); } catch (\PDOException $e) { if (!$conn->inTransaction() || \in_array($this->driver, ['pgsql', 'sqlite', 'sqlsrv'], true)) { $this->createTable(); } - $result = $stmt->execute(); + $stmt->execute(); } - if (null === $driver && !(\is_object($result) ? $result->rowCount() : $stmt->rowCount())) { + if (null === $driver && !$stmt->rowCount()) { try { $insertStmt->execute(); - } catch (DBALException $e) { } catch (\PDOException $e) { // A concurrent write won, let it be } @@ -445,66 +559,14 @@ protected function doSave(array $values, int $lifetime) return $failed; } - /** - * @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); } if (null === $this->driver) { - if ($this->conn instanceof \PDO) { - $this->driver = $this->conn->getAttribute(\PDO::ATTR_DRIVER_NAME); - } else { - $driver = $this->conn->getDriver(); - - switch (true) { - case $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)); - case $driver instanceof \Doctrine\DBAL\Driver\AbstractMySQLDriver: - $this->driver = 'mysql'; - break; - case $driver instanceof \Doctrine\DBAL\Driver\PDOSqlite\Driver: - case $driver instanceof \Doctrine\DBAL\Driver\PDO\SQLite\Driver: - $this->driver = 'sqlite'; - break; - case $driver instanceof \Doctrine\DBAL\Driver\PDOPgSql\Driver: - case $driver instanceof \Doctrine\DBAL\Driver\PDO\PgSQL\Driver: - $this->driver = 'pgsql'; - break; - case $driver instanceof \Doctrine\DBAL\Driver\OCI8\Driver: - case $driver instanceof \Doctrine\DBAL\Driver\PDOOracle\Driver: - case $driver instanceof \Doctrine\DBAL\Driver\PDO\OCI\Driver: - $this->driver = 'oci'; - break; - case $driver instanceof \Doctrine\DBAL\Driver\SQLSrv\Driver: - case $driver instanceof \Doctrine\DBAL\Driver\PDOSqlsrv\Driver: - case $driver instanceof \Doctrine\DBAL\Driver\PDO\SQLSrv\Driver: - $this->driver = 'sqlsrv'; - break; - case $driver instanceof \Doctrine\DBAL\Driver: - $this->driver = [ - 'mssql' => 'sqlsrv', - 'oracle' => 'oci', - 'postgresql' => 'pgsql', - 'sqlite' => 'sqlite', - 'mysql' => 'mysql', - ][$driver->getDatabasePlatform()->getName()] ?? \get_class($driver); - break; - default: - $this->driver = \get_class($driver); - break; - } - } + $this->driver = $this->conn->getAttribute(\PDO::ATTR_DRIVER_NAME); } return $this->conn; @@ -513,37 +575,9 @@ private function getConnection(): object private function getServerVersion(): string { if (null === $this->serverVersion) { - $conn = $this->conn instanceof \PDO ? $this->conn : $this->conn->getWrappedConnection(); - if ($conn instanceof \PDO) { - $this->serverVersion = $conn->getAttribute(\PDO::ATTR_SERVER_VERSION); - } elseif ($conn instanceof ServerInfoAwareConnection) { - $this->serverVersion = $conn->getServerVersion(); - } else { - $this->serverVersion = '0'; - } + $this->serverVersion = $this->conn->getAttribute(\PDO::ATTR_SERVER_VERSION); } return $this->serverVersion; } - - private function addTableToSchema(Schema $schema): void - { - $types = [ - 'mysql' => 'binary', - 'sqlite' => 'text', - 'pgsql' => 'string', - 'oci' => 'string', - 'sqlsrv' => 'string', - ]; - if (!isset($types[$this->driver])) { - throw new \DomainException(sprintf('Creating the cache table is currently not implemented for PDO driver "%s".', $this->driver)); - } - - $table = $schema->createTable($this->table); - $table->addColumn($this->idCol, $types[$this->driver], ['length' => 255]); - $table->addColumn($this->dataCol, 'blob', ['length' => 16777215]); - $table->addColumn($this->lifetimeCol, 'integer', ['unsigned' => true, 'notnull' => false]); - $table->addColumn($this->timeCol, 'integer', ['unsigned' => true]); - $table->setPrimaryKey([$this->idCol]); - } } diff --git a/src/Symfony/Component/Cache/CHANGELOG.md b/src/Symfony/Component/Cache/CHANGELOG.md index 4c77b45d4660b..0654b0389ad6a 100644 --- a/src/Symfony/Component/Cache/CHANGELOG.md +++ b/src/Symfony/Component/Cache/CHANGELOG.md @@ -6,6 +6,8 @@ CHANGELOG * Make `LockRegistry` use semaphores when possible * Deprecate `DoctrineProvider` and `DoctrineAdapter` because these classes have been added to the `doctrine/cache` package + * Add `DoctrineDbalAdapter` identical to `PdoAdapter` for `Doctrine\DBAL\Connection` or DBAL URL + * Deprecate usage of `PdoAdapter` with `Doctrine\DBAL\Connection` or DBAL URL 5.3 --- diff --git a/src/Symfony/Component/Cache/LockRegistry.php b/src/Symfony/Component/Cache/LockRegistry.php index 9edfe3afe62ae..af4c11382f070 100644 --- a/src/Symfony/Component/Cache/LockRegistry.php +++ b/src/Symfony/Component/Cache/LockRegistry.php @@ -42,6 +42,8 @@ final class LockRegistry __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'CouchbaseBucketAdapter.php', __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'CouchbaseCollectionAdapter.php', __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'DoctrineAdapter.php', + __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'DoctrineDbalAdapter.php', + __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'DoctrineSchemaConfiguratorInterface.php', __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'FilesystemAdapter.php', __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'FilesystemTagAwareAdapter.php', __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'MemcachedAdapter.php', diff --git a/src/Symfony/Component/Cache/Tests/Adapter/DoctrineDbalAdapterTest.php b/src/Symfony/Component/Cache/Tests/Adapter/DoctrineDbalAdapterTest.php new file mode 100644 index 0000000000000..cf8a60a598ae9 --- /dev/null +++ b/src/Symfony/Component/Cache/Tests/Adapter/DoctrineDbalAdapterTest.php @@ -0,0 +1,158 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Tests\Adapter; + +use Doctrine\DBAL\Configuration; +use Doctrine\DBAL\Connection; +use Doctrine\DBAL\Driver\AbstractMySQLDriver; +use Doctrine\DBAL\Driver\Middleware; +use Doctrine\DBAL\DriverManager; +use Doctrine\DBAL\Schema\Schema; +use PHPUnit\Framework\SkippedTestSuiteError; +use Psr\Cache\CacheItemPoolInterface; +use Symfony\Component\Cache\Adapter\DoctrineDbalAdapter; +use Symfony\Component\Cache\Tests\Fixtures\DriverWrapper; + +/** + * @group time-sensitive + */ +class DoctrineDbalAdapterTest extends AdapterTestCase +{ + protected static $dbFile; + + public static function setUpBeforeClass(): void + { + if (!\extension_loaded('pdo_sqlite')) { + throw new SkippedTestSuiteError('Extension pdo_sqlite required.'); + } + + self::$dbFile = tempnam(sys_get_temp_dir(), 'sf_sqlite_cache'); + } + + public static function tearDownAfterClass(): void + { + @unlink(self::$dbFile); + } + + public function createCachePool(int $defaultLifetime = 0): CacheItemPoolInterface + { + return new DoctrineDbalAdapter(DriverManager::getConnection(['driver' => 'pdo_sqlite', 'path' => self::$dbFile]), '', $defaultLifetime); + } + + public function testConfigureSchemaDecoratedDbalDriver() + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'path' => self::$dbFile]); + if (!interface_exists(Middleware::class)) { + $this->markTestSkipped('doctrine/dbal v2 does not support custom drivers using middleware'); + } + + $middleware = $this->createMock(Middleware::class); + $middleware + ->method('wrap') + ->willReturn(new DriverWrapper($connection->getDriver())); + + $config = new Configuration(); + $config->setMiddlewares([$middleware]); + + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'path' => self::$dbFile], $config); + + $adapter = new DoctrineDbalAdapter($connection); + $adapter->createTable(); + + $item = $adapter->getItem('key'); + $item->set('value'); + $this->assertTrue($adapter->save($item)); + } + + public function testConfigureSchema() + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'path' => self::$dbFile]); + $schema = new Schema(); + + $adapter = new DoctrineDbalAdapter($connection); + $adapter->configureSchema($schema, $connection); + $this->assertTrue($schema->hasTable('cache_items')); + } + + public function testConfigureSchemaDifferentDbalConnection() + { + $otherConnection = $this->createConnectionMock(); + $schema = new Schema(); + + $adapter = $this->createCachePool(); + $adapter->configureSchema($schema, $otherConnection); + $this->assertFalse($schema->hasTable('cache_items')); + } + + public function testConfigureSchemaTableExists() + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'path' => self::$dbFile]); + $schema = new Schema(); + $schema->createTable('cache_items'); + + $adapter = new DoctrineDbalAdapter($connection); + $adapter->configureSchema($schema, $connection); + $table = $schema->getTable('cache_items'); + $this->assertEmpty($table->getColumns(), 'The table was not overwritten'); + } + + /** + * @dataProvider provideDsn + */ + public function testDsn(string $dsn, string $file = null) + { + try { + $pool = new DoctrineDbalAdapter($dsn); + $pool->createTable(); + + $item = $pool->getItem('key'); + $item->set('value'); + $this->assertTrue($pool->save($item)); + } 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:']; + } + + protected function isPruned(DoctrineDbalAdapter $cache, string $name): bool + { + $o = new \ReflectionObject($cache); + $connProp = $o->getProperty('conn'); + $connProp->setAccessible(true); + + /** @var Connection $conn */ + $conn = $connProp->getValue($cache); + $result = $conn->executeQuery('SELECT 1 FROM cache_items WHERE item_id LIKE ?', [sprintf('%%%s', $name)]); + + return 1 !== (int) $result->fetchOne(); + } + + private function createConnectionMock() + { + $connection = $this->createMock(Connection::class); + $driver = $this->createMock(AbstractMySQLDriver::class); + $connection->expects($this->any()) + ->method('getDriver') + ->willReturn($driver); + + return $connection; + } +} diff --git a/src/Symfony/Component/Cache/Tests/Adapter/PdoAdapterTest.php b/src/Symfony/Component/Cache/Tests/Adapter/PdoAdapterTest.php index 95c0fd6f8a596..27429f33c9022 100644 --- a/src/Symfony/Component/Cache/Tests/Adapter/PdoAdapterTest.php +++ b/src/Symfony/Component/Cache/Tests/Adapter/PdoAdapterTest.php @@ -20,8 +20,6 @@ */ class PdoAdapterTest extends AdapterTestCase { - use PdoPruneableTrait; - protected static $dbFile; public static function setUpBeforeClass(): void @@ -94,10 +92,22 @@ 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:']; } + + protected function isPruned(PdoAdapter $cache, string $name): bool + { + $o = new \ReflectionObject($cache); + + $getPdoConn = $o->getMethod('getConnection'); + $getPdoConn->setAccessible(true); + + /** @var \PDOStatement $select */ + $select = $getPdoConn->invoke($cache)->prepare('SELECT 1 FROM cache_items WHERE item_id LIKE :id'); + $select->bindValue(':id', sprintf('%%%s', $name)); + $select->execute(); + + return 1 !== (int) $select->fetch(\PDO::FETCH_COLUMN); + } } diff --git a/src/Symfony/Component/Cache/Tests/Adapter/PdoDbalAdapterTest.php b/src/Symfony/Component/Cache/Tests/Adapter/PdoDbalAdapterTest.php index 61491c7f2a004..0c7a9fde07007 100644 --- a/src/Symfony/Component/Cache/Tests/Adapter/PdoDbalAdapterTest.php +++ b/src/Symfony/Component/Cache/Tests/Adapter/PdoDbalAdapterTest.php @@ -19,15 +19,17 @@ use Doctrine\DBAL\Schema\Schema; use PHPUnit\Framework\SkippedTestSuiteError; use Psr\Cache\CacheItemPoolInterface; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Symfony\Component\Cache\Adapter\PdoAdapter; use Symfony\Component\Cache\Tests\Fixtures\DriverWrapper; /** * @group time-sensitive + * @group legacy */ class PdoDbalAdapterTest extends AdapterTestCase { - use PdoPruneableTrait; + use ExpectDeprecationTrait; protected static $dbFile; @@ -47,11 +49,14 @@ public static function tearDownAfterClass(): void public function createCachePool(int $defaultLifetime = 0): CacheItemPoolInterface { + $this->expectDeprecation('Since symfony/cache 5.4: Usage of a DBAL Connection with "Symfony\Component\Cache\Adapter\PdoAdapter" is deprecated and will be removed in symfony 6.0. Use "Symfony\Component\Cache\Adapter\DoctrineDbalAdapter" instead.'); + return new PdoAdapter(DriverManager::getConnection(['driver' => 'pdo_sqlite', 'path' => self::$dbFile]), '', $defaultLifetime); } public function testConfigureSchemaDecoratedDbalDriver() { + $this->expectDeprecation('Since symfony/cache 5.4: Usage of a DBAL Connection with "Symfony\Component\Cache\Adapter\PdoAdapter" is deprecated and will be removed in symfony 6.0. Use "Symfony\Component\Cache\Adapter\DoctrineDbalAdapter" instead.'); $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'path' => self::$dbFile]); if (!interface_exists(Middleware::class)) { $this->markTestSkipped('doctrine/dbal v2 does not support custom drivers using middleware'); @@ -77,6 +82,7 @@ public function testConfigureSchemaDecoratedDbalDriver() public function testConfigureSchema() { + $this->expectDeprecation('Since symfony/cache 5.4: Usage of a DBAL Connection with "Symfony\Component\Cache\Adapter\PdoAdapter" is deprecated and will be removed in symfony 6.0. Use "Symfony\Component\Cache\Adapter\DoctrineDbalAdapter" instead.'); $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'path' => self::$dbFile]); $schema = new Schema(); @@ -97,6 +103,7 @@ public function testConfigureSchemaDifferentDbalConnection() public function testConfigureSchemaTableExists() { + $this->expectDeprecation('Since symfony/cache 5.4: Usage of a DBAL Connection with "Symfony\Component\Cache\Adapter\PdoAdapter" is deprecated and will be removed in symfony 6.0. Use "Symfony\Component\Cache\Adapter\DoctrineDbalAdapter" instead.'); $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'path' => self::$dbFile]); $schema = new Schema(); $schema->createTable('cache_items'); @@ -107,6 +114,50 @@ public function testConfigureSchemaTableExists() $this->assertEmpty($table->getColumns(), 'The table was not overwritten'); } + /** + * @dataProvider provideDsn + */ + public function testDsn(string $dsn, string $file = null) + { + $this->expectDeprecation('Since symfony/cache 5.4: Usage of a DBAL Connection with "Symfony\Component\Cache\Adapter\PdoAdapter" is deprecated and will be removed in symfony 6.0. Use "Symfony\Component\Cache\Adapter\DoctrineDbalAdapter" instead.'); + try { + $pool = new PdoAdapter($dsn); + $pool->createTable(); + + $item = $pool->getItem('key'); + $item->set('value'); + $this->assertTrue($pool->save($item)); + } 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:']; + } + + protected function isPruned(PdoAdapter $cache, string $name): bool + { + $dbalAdapterProp = (new \ReflectionObject($cache))->getProperty('dbalAdapter'); + $dbalAdapterProp->setAccessible(true); + $dbalAdapter = $dbalAdapterProp->getValue($cache); + + $connProp = (new \ReflectionObject($dbalAdapter))->getProperty('conn'); + $connProp->setAccessible(true); + + /** @var Connection $conn */ + $conn = $connProp->getValue($dbalAdapter); + $result = $conn->executeQuery('SELECT 1 FROM cache_items WHERE item_id LIKE ?', [sprintf('%%%s', $name)]); + + return 1 !== (int) $result->fetchOne(); + } + private function createConnectionMock() { $connection = $this->createMock(Connection::class); diff --git a/src/Symfony/Component/Cache/Tests/Adapter/PdoPruneableTrait.php b/src/Symfony/Component/Cache/Tests/Adapter/PdoPruneableTrait.php deleted file mode 100644 index 23f977fd6ee5b..0000000000000 --- a/src/Symfony/Component/Cache/Tests/Adapter/PdoPruneableTrait.php +++ /dev/null @@ -1,34 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Cache\Tests\Adapter; - -trait PdoPruneableTrait -{ - protected function isPruned($cache, string $name): bool - { - $o = new \ReflectionObject($cache); - - if (!$o->hasMethod('getConnection')) { - self::fail('Cache does not have "getConnection()" method.'); - } - - $getPdoConn = $o->getMethod('getConnection'); - $getPdoConn->setAccessible(true); - - /** @var \Doctrine\DBAL\Statement|\PDOStatement $select */ - $select = $getPdoConn->invoke($cache)->prepare('SELECT 1 FROM cache_items WHERE item_id LIKE :id'); - $select->bindValue(':id', sprintf('%%%s', $name)); - $result = $select->execute(); - - return 1 !== (int) (\is_object($result) ? $result->fetchOne() : $select->fetch(\PDO::FETCH_COLUMN)); - } -} diff --git a/src/Symfony/Component/Cache/composer.json b/src/Symfony/Component/Cache/composer.json index 58c225d3c148a..1f68f326867fa 100644 --- a/src/Symfony/Component/Cache/composer.json +++ b/src/Symfony/Component/Cache/composer.json @@ -34,7 +34,7 @@ "require-dev": { "cache/integration-tests": "dev-master", "doctrine/cache": "^1.6|^2.0", - "doctrine/dbal": "^2.13|^3.0", + "doctrine/dbal": "^2.13.1|^3.0", "predis/predis": "^1.1", "psr/simple-cache": "^1.0", "symfony/config": "^4.4|^5.0|^6.0", @@ -45,7 +45,7 @@ "symfony/var-dumper": "^4.4|^5.0|^6.0" }, "conflict": { - "doctrine/dbal": "<2.13", + "doctrine/dbal": "<2.13.1", "symfony/dependency-injection": "<4.4", "symfony/http-kernel": "<4.4", "symfony/var-dumper": "<4.4"