From aa98410fa8778175bda37718d33af4ffac9eaa8a Mon Sep 17 00:00:00 2001 From: Yanick Witschi Date: Tue, 17 Sep 2024 18:56:44 +0200 Subject: [PATCH 01/18] Created foundation for further discussion --- .../Component/Cache/Adapter/PdoAdapter.php | 165 +++++++++++++++++- .../Cache/Tests/Adapter/PdoAdapterTest.php | 2 + 2 files changed, 165 insertions(+), 2 deletions(-) diff --git a/src/Symfony/Component/Cache/Adapter/PdoAdapter.php b/src/Symfony/Component/Cache/Adapter/PdoAdapter.php index 525e2c6db602..fc7bc89414ed 100644 --- a/src/Symfony/Component/Cache/Adapter/PdoAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/PdoAdapter.php @@ -12,13 +12,15 @@ namespace Symfony\Component\Cache\Adapter; use Symfony\Component\Cache\Exception\InvalidArgumentException; +use Symfony\Component\Cache\Exception\LogicException; use Symfony\Component\Cache\Marshaller\DefaultMarshaller; use Symfony\Component\Cache\Marshaller\MarshallerInterface; use Symfony\Component\Cache\PruneableInterface; -class PdoAdapter extends AbstractAdapter implements PruneableInterface +class PdoAdapter extends AbstractTagAwareAdapter implements PruneableInterface { private const MAX_KEY_LENGTH = 255; + protected const NS_SEPARATOR = ':'; private MarshallerInterface $marshaller; private \PDO $conn; @@ -26,10 +28,12 @@ class PdoAdapter extends AbstractAdapter implements PruneableInterface private string $driver; private string $serverVersion; private string $table = 'cache_items'; + private string $tagsTable = 'cache_tags'; private string $idCol = 'item_id'; private string $dataCol = 'item_data'; private string $lifetimeCol = 'item_lifetime'; private string $timeCol = 'item_time'; + private string $tagCol = 'item_tag'; private ?string $username = null; private ?string $password = null; private array $connectionOptions = []; @@ -129,6 +133,31 @@ public function createTable(): void $this->getConnection()->exec($sql); } + /** + * Creates the tags table to store tag items which can be called once for setup. + * + * Both, cache ID and tag ID are saved in a column of maximum length 255 respecitvely. + * + * @throws \PDOException When the table already exists + * @throws \DomainException When an unsupported PDO driver is used + */ + public function createTagsTable(): void + { + $sql = match ($driver = $this->getDriver()) { + // We use varbinary for the ID column because it prevents unwanted conversions: + // - character set conversions between server and client + // - trailing space removal + // - case-insensitivity + // - language processing like é == e + 'mysql' => "CREATE TABLE $this->tagsTable ($this->idCol VARBINARY(255) NOT NULL, $this->tagCol VARBINARY(255) NOT NULL, PRIMARY KEY($this->idCol, $this->tagCol), INDEX idx_id_col ($this->idCol), INDEX idx_tag_col ($this->tagCol)) COLLATE utf8mb4_bin, ENGINE = InnoDB", + 'sqlite' => "CREATE TABLE $this->tagsTable ($this->idCol TEXT NOT NULL, $this->tagCol TEXT NOT NULL, PRIMARY KEY ($this->idCol, $this->tagCol));CREATE INDEX idx_id_col ON $this->tagsTable($this->idCol);CREATE INDEX idx_tag_col ON $this->tagsTable($this->tagCol);", + // TODO: Need help for oci, sqlsrv and pgsql here + default => throw new \DomainException(\sprintf('Creating the cache table is currently not implemented for PDO driver "%s".', $driver)), + }; + + $this->getConnection()->exec($sql); + } + public function prune(): bool { $deleteSql = "DELETE FROM $this->table WHERE $this->lifetimeCol + $this->timeCol <= :time"; @@ -154,6 +183,8 @@ public function prune(): bool } catch (\PDOException) { return true; } + + $this->pruneOrphanedTags(); } protected function doFetch(array $ids): iterable @@ -248,7 +279,79 @@ protected function doDelete(array $ids): bool return true; } - protected function doSave(array $values, int $lifetime): array|bool + protected function doSave(array $values, int $lifetime, array $addTagData = [], array $removeTagData = []): array + { + $failed = $this->doSaveCache($values, $lifetime); + + $driver = $this->getDriver(); + $insertSql = "INSERT INTO $this->tagsTable ($this->idCol, $this->tagCol) VALUES (:id, :tagId)"; + + switch (true) { + case 'mysql' === $driver: + $sql = $insertSql." ON DUPLICATE KEY IGNORE"; + break; + case 'oci' === $driver: + throw new LogicException('oci driver support must be added'); // TODO: I need help here + break; + case 'sqlsrv' === $driver && version_compare($this->getServerVersion(), '10', '>='): + throw new LogicException('sqlsrv driver support must be added'); // TODO: I need help here + break; + case 'sqlite' === $driver: + $sql = 'INSERT OR REPLACE'.substr($insertSql, 6); + break; + case 'pgsql' === $driver && version_compare($this->getServerVersion(), '9.5', '>='): + throw new LogicException('pgsql driver support must be added'); // TODO: I need help here + break; + default: + $driver = null; + } + + foreach ($addTagData as $tagId => $ids) { + foreach ($ids as $id) { + if ($failed && \in_array($id, $failed, true)) { + continue; + } + + try { + $stmt = $this->prepareStatementWithFallback($sql, function () { + $this->createTagsTable(); + }); + + $stmt->bindParam(':id', $id); + $stmt->bindParam(':tagId', $tagId); + $stmt->execute(); + } catch (\PDOException $e) { + $failed[] = $id; + } + } + } + + foreach ($removeTagData as $tagId => $ids) { + foreach ($ids as $id) { + if ($failed && \in_array($id, $failed, true)) { + continue; + } + + $sql = "DELETE FROM $this->tagsTable WHERE $this->idCol=:id AND $this->tagCol=:tagId"; + + try { + $stmt = $this->prepareStatementWithFallback($sql, function () { + $this->createTagsTable(); + }); + + $stmt->bindParam(':id', $id); + $stmt->bindParam(':tagId', $tagId); + $stmt->execute(); + } catch (\PDOException $e) { + $failed[] = $id; + } + } + } + + return $failed; + } + + protected function doSaveCache(array $values, int $lifetime): array|bool { if (!$values = $this->marshaller->marshall($values, $failed)) { return $failed; @@ -395,4 +498,62 @@ private function isTableMissing(\PDOException $exception): bool default => false, }; } + + protected function doDeleteTagRelations(array $tagData): bool + { + foreach ($tagData as $tagId => $idList) { + $placeholders = implode(',', array_fill(0, count($idList), '?')); + $stmt = $this->prepareStatementWithFallback("DELETE FROM $this->tagsTable WHERE $this->tagCol=:tagId AND $this->idCol IN ($placeholders);", function () { + $this->createTagsTable(); + }); + + $stmt->bindValue(1, $tagId, \PDO::PARAM_STR); + + foreach ($idList as $index => $value) { + $stmt->bindValue($index + 2, $value, \PDO::PARAM_STR); + } + $stmt->execute(); + } + + return true; + } + + protected function doInvalidate(array $tagIds): bool + { + $placeholders = implode(',', array_fill(0, count($tagIds), '?')); + $stmt = $this->prepareStatementWithFallback("DELETE FROM $this->table WHERE $this->idCol IN (SELECT $this->idCol FROM $this->tagsTable WHERE $this->tagCol IN ($placeholders));", function () { + $this->createTagsTable(); + }); + + foreach ($tagIds as $index => $value) { + $stmt->bindValue($index + 1, $value, \PDO::PARAM_STR); + } + $stmt->execute(); + + return true; + } + + private function prepareStatementWithFallback(string $query, \Closure $createTable): \PDOStatement + { + $driver = $this->getDriver(); + $conn = $this->getConnection(); + + try { + return $conn->prepare($query); + } catch (\PDOException $e) { + if ($this->isTableMissing($e) && (!$conn->inTransaction() || \in_array($driver, ['pgsql', 'sqlite', 'sqlsrv'], true))) { + $createTable(); + } + + return $conn->prepare($query); + } + } + + /** + * Prunes the tags table and removes all tags that are not used anywhere anymore. + */ + private function pruneOrphanedTags(): void + { + // TODO: implement me + } } diff --git a/src/Symfony/Component/Cache/Tests/Adapter/PdoAdapterTest.php b/src/Symfony/Component/Cache/Tests/Adapter/PdoAdapterTest.php index 49f3da83fe23..3be980544a09 100644 --- a/src/Symfony/Component/Cache/Tests/Adapter/PdoAdapterTest.php +++ b/src/Symfony/Component/Cache/Tests/Adapter/PdoAdapterTest.php @@ -22,6 +22,8 @@ */ class PdoAdapterTest extends AdapterTestCase { + use TagAwareTestTrait; + protected static string $dbFile; public static function setUpBeforeClass(): void From 3e559672bd37d6b92af2497e284860eef6bb510b Mon Sep 17 00:00:00 2001 From: Yanick Witschi Date: Tue, 17 Sep 2024 18:58:30 +0200 Subject: [PATCH 02/18] Added changelog entry --- src/Symfony/Component/Cache/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Symfony/Component/Cache/CHANGELOG.md b/src/Symfony/Component/Cache/CHANGELOG.md index 7f7cfa42dbe4..b5a5099b660d 100644 --- a/src/Symfony/Component/Cache/CHANGELOG.md +++ b/src/Symfony/Component/Cache/CHANGELOG.md @@ -6,6 +6,7 @@ CHANGELOG * `igbinary_serialize()` is not used by default when the igbinary extension is installed * Add optional `Psr\Clock\ClockInterface` parameter to `ArrayAdapter` + * Add `TagAwareCacheInterface` to `PdoAdapter` 7.1 --- From c8dc32e8a1365e4a8f3470d21aae3678c348b501 Mon Sep 17 00:00:00 2001 From: Yanick Witschi Date: Tue, 17 Sep 2024 19:04:33 +0200 Subject: [PATCH 03/18] CS --- src/Symfony/Component/Cache/Adapter/PdoAdapter.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Symfony/Component/Cache/Adapter/PdoAdapter.php b/src/Symfony/Component/Cache/Adapter/PdoAdapter.php index fc7bc89414ed..9bcf499b9f90 100644 --- a/src/Symfony/Component/Cache/Adapter/PdoAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/PdoAdapter.php @@ -288,7 +288,7 @@ protected function doSave(array $values, int $lifetime, array $addTagData = [], switch (true) { case 'mysql' === $driver: - $sql = $insertSql." ON DUPLICATE KEY IGNORE"; + $sql = $insertSql.' ON DUPLICATE KEY IGNORE'; break; case 'oci' === $driver: throw new LogicException('oci driver support must be added'); // TODO: I need help here @@ -502,7 +502,7 @@ private function isTableMissing(\PDOException $exception): bool protected function doDeleteTagRelations(array $tagData): bool { foreach ($tagData as $tagId => $idList) { - $placeholders = implode(',', array_fill(0, count($idList), '?')); + $placeholders = implode(',', array_fill(0, \count($idList), '?')); $stmt = $this->prepareStatementWithFallback("DELETE FROM $this->tagsTable WHERE $this->tagCol=:tagId AND $this->idCol IN ($placeholders);", function () { $this->createTagsTable(); }); @@ -520,7 +520,7 @@ protected function doDeleteTagRelations(array $tagData): bool protected function doInvalidate(array $tagIds): bool { - $placeholders = implode(',', array_fill(0, count($tagIds), '?')); + $placeholders = implode(',', array_fill(0, \count($tagIds), '?')); $stmt = $this->prepareStatementWithFallback("DELETE FROM $this->table WHERE $this->idCol IN (SELECT $this->idCol FROM $this->tagsTable WHERE $this->tagCol IN ($placeholders));", function () { $this->createTagsTable(); }); From 4904b06bc28e19f2365bba3cb077814388e05a96 Mon Sep 17 00:00:00 2001 From: Yanick Witschi Date: Tue, 17 Sep 2024 19:06:22 +0200 Subject: [PATCH 04/18] CS --- src/Symfony/Component/Cache/Adapter/PdoAdapter.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Symfony/Component/Cache/Adapter/PdoAdapter.php b/src/Symfony/Component/Cache/Adapter/PdoAdapter.php index 9bcf499b9f90..556b1c85d3db 100644 --- a/src/Symfony/Component/Cache/Adapter/PdoAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/PdoAdapter.php @@ -291,16 +291,16 @@ protected function doSave(array $values, int $lifetime, array $addTagData = [], $sql = $insertSql.' ON DUPLICATE KEY IGNORE'; break; case 'oci' === $driver: - throw new LogicException('oci driver support must be added'); // TODO: I need help here + throw new LogicException('oci driver support must be added.'); // TODO: I need help here break; case 'sqlsrv' === $driver && version_compare($this->getServerVersion(), '10', '>='): - throw new LogicException('sqlsrv driver support must be added'); // TODO: I need help here + throw new LogicException('sqlsrv driver support must be added.'); // TODO: I need help here break; case 'sqlite' === $driver: $sql = 'INSERT OR REPLACE'.substr($insertSql, 6); break; case 'pgsql' === $driver && version_compare($this->getServerVersion(), '9.5', '>='): - throw new LogicException('pgsql driver support must be added'); // TODO: I need help here + throw new LogicException('pgsql driver support must be added.'); // TODO: I need help here break; default: $driver = null; From 8bc548b7d5344cdcea1d6c36aef568fdd3d34838 Mon Sep 17 00:00:00 2001 From: Yanick Witschi Date: Wed, 18 Sep 2024 10:14:51 +0200 Subject: [PATCH 05/18] Implemented the rest of the drivers and todos --- .../Component/Cache/Adapter/PdoAdapter.php | 86 +++++++++++++------ 1 file changed, 58 insertions(+), 28 deletions(-) diff --git a/src/Symfony/Component/Cache/Adapter/PdoAdapter.php b/src/Symfony/Component/Cache/Adapter/PdoAdapter.php index 556b1c85d3db..22d6870cb69c 100644 --- a/src/Symfony/Component/Cache/Adapter/PdoAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/PdoAdapter.php @@ -150,8 +150,9 @@ public function createTagsTable(): void // - case-insensitivity // - language processing like é == e 'mysql' => "CREATE TABLE $this->tagsTable ($this->idCol VARBINARY(255) NOT NULL, $this->tagCol VARBINARY(255) NOT NULL, PRIMARY KEY($this->idCol, $this->tagCol), INDEX idx_id_col ($this->idCol), INDEX idx_tag_col ($this->tagCol)) COLLATE utf8mb4_bin, ENGINE = InnoDB", - 'sqlite' => "CREATE TABLE $this->tagsTable ($this->idCol TEXT NOT NULL, $this->tagCol TEXT NOT NULL, PRIMARY KEY ($this->idCol, $this->tagCol));CREATE INDEX idx_id_col ON $this->tagsTable($this->idCol);CREATE INDEX idx_tag_col ON $this->tagsTable($this->tagCol);", - // TODO: Need help for oci, sqlsrv and pgsql here + 'sqlite' => "CREATE TABLE $this->tagsTable ($this->idCol TEXT NOT NULL, $this->tagCol TEXT NOT NULL, PRIMARY KEY ($this->idCol, $this->tagCol));CREATE INDEX idx_id_col ON $this->tagsTable($this->idCol);CREATE INDEX idx_tag_col ON $this->tagsTable($this->tagCol)", + 'pgsql', 'sqlsrv' => "CREATE TABLE $this->tagsTable ($this->idCol VARCHAR(255) NOT NULL, $this->tagCol VARCHAR(255) NOT NULL, PRIMARY KEY($this->idCol, $this->tagCol);CREATE INDEX idx_id_col ON $this->tagsTable($this->idCol);CREATE INDEX idx_tag_col ON $this->tagsTable($this->tagCol)", + 'oci' => "CREATE TABLE $this->tagsTable ($this->idCol VARCHAR2(255) NOT NULL, $this->tagCol VARCHAR2(255) NOT NULL, PRIMARY KEY($this->idCol, $this->tagCol);CREATE INDEX idx_id_col ON $this->tagsTable($this->idCol);CREATE INDEX idx_tag_col ON $this->tagsTable($this->tagCol)", default => throw new \DomainException(\sprintf('Creating the cache table is currently not implemented for PDO driver "%s".', $driver)), }; @@ -159,6 +160,11 @@ public function createTagsTable(): void } public function prune(): bool + { + return $this->pruneExpiredItems() && $this->pruneOrphanedTags(); + } + + public function pruneExpiredItems(): bool { $deleteSql = "DELETE FROM $this->table WHERE $this->lifetimeCol + $this->timeCol <= :time"; @@ -183,8 +189,6 @@ public function prune(): bool } catch (\PDOException) { return true; } - - $this->pruneOrphanedTags(); } protected function doFetch(array $ids): iterable @@ -288,19 +292,26 @@ protected function doSave(array $values, int $lifetime, array $addTagData = [], switch (true) { case 'mysql' === $driver: - $sql = $insertSql.' ON DUPLICATE KEY IGNORE'; + $sql = $insertSql." ON DUPLICATE KEY UPDATE $this->idCol = VALUES($this->idCol), $this->tagCol = VALUES($this->tagCol)"; break; case 'oci' === $driver: - throw new LogicException('oci driver support must be added.'); // TODO: I need help here + // DUAL is Oracle specific dummy table + $sql = "MERGE INTO $this->tagsTable USING DUAL ON ($this->idCol = :id, $this->tagCol = :tagId) ". + "WHEN NOT MATCHED THEN INSERT ($this->idCol, $this->tagCol) VALUES (:id, :tagId) ". + "WHEN MATCHED THEN UPDATE SET $this->idCol = :id, $this->tagCol = :tagId"; break; case 'sqlsrv' === $driver && version_compare($this->getServerVersion(), '10', '>='): - throw new LogicException('sqlsrv driver support must be added.'); // TODO: I need help here + // 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->tagsTable WITH (HOLDLOCK) USING (SELECT 1 AS dummy) AS src ON ($this->idCol = :id, $this->tagCol = :tagId) ". + "WHEN NOT MATCHED THEN INSERT ($this->idCol, $this->tagCol) VALUES (:id, :tagId) ". + "WHEN MATCHED THEN UPDATE SET $this->idCol = :id, $this->tagCol = :tagId;"; break; case 'sqlite' === $driver: $sql = 'INSERT OR REPLACE'.substr($insertSql, 6); break; case 'pgsql' === $driver && version_compare($this->getServerVersion(), '9.5', '>='): - throw new LogicException('pgsql driver support must be added.'); // TODO: I need help here + $sql = $insertSql." ON CONFLICT ($this->idCol, $this->tagCol) DO UPDATE SET ($this->idCol, $this->tagCol) = (EXCLUDED.$this->idCol, EXCLUDED.$this->tagCol)"; break; default: $driver = null; @@ -319,7 +330,10 @@ protected function doSave(array $values, int $lifetime, array $addTagData = [], $stmt->bindParam(':id', $id); $stmt->bindParam(':tagId', $tagId); - $stmt->execute(); + + $this->executeStatementWithFallback($stmt, function () { + $this->createTagsTable(); + }); } catch (\PDOException $e) { $failed[] = $id; } @@ -341,7 +355,10 @@ protected function doSave(array $values, int $lifetime, array $addTagData = [], $stmt->bindParam(':id', $id); $stmt->bindParam(':tagId', $tagId); - $stmt->execute(); + + $this->executeStatementWithFallback($stmt, function () { + $this->createTagsTable(); + }); } catch (\PDOException $e) { $failed[] = $id; } @@ -393,14 +410,10 @@ protected function doSaveCache(array $values, int $lifetime): array|bool $now = time(); $lifetime = $lifetime ?: null; - try { - $stmt = $conn->prepare($sql); - } catch (\PDOException $e) { - if ($this->isTableMissing($e) && (!$conn->inTransaction() || \in_array($driver, ['pgsql', 'sqlite', 'sqlsrv'], true))) { - $this->createTable(); - } - $stmt = $conn->prepare($sql); - } + + $stmt = $this->prepareStatementWithFallback($sql, function () { + $this->createTable(); + }); // $id and $data are defined later in the loop. Binding is done by reference, values are read on execution. if ('sqlsrv' === $driver || 'oci' === $driver) { @@ -428,14 +441,10 @@ protected function doSaveCache(array $values, int $lifetime): array|bool } foreach ($values as $id => $data) { - try { - $stmt->execute(); - } catch (\PDOException $e) { - if ($this->isTableMissing($e) && (!$conn->inTransaction() || \in_array($driver, ['pgsql', 'sqlite', 'sqlsrv'], true))) { - $this->createTable(); - } - $stmt->execute(); - } + $this->executeStatementWithFallback($stmt, function () { + $this->createTagsTable(); + }); + if (null === $driver && !$stmt->rowCount()) { try { $insertStmt->execute(); @@ -549,11 +558,32 @@ private function prepareStatementWithFallback(string $query, \Closure $createTab } } + private function executeStatementWithFallback(\PDOStatement $statement, \Closure $createTable): void + { + $driver = $this->getDriver(); + $conn = $this->getConnection(); + + try { + $statement->execute(); + } catch (\PDOException $e) { + if ($this->isTableMissing($e) && (!$conn->inTransaction() || \in_array($driver, ['pgsql', 'sqlite', 'sqlsrv'], true))) { + $createTable(); + } + + $statement->execute(); + } + } + /** * Prunes the tags table and removes all tags that are not used anywhere anymore. */ - private function pruneOrphanedTags(): void + private function pruneOrphanedTags(): bool { - // TODO: implement me + $conn = $this->getConnection(); + + $stmt = $conn->prepare("DELETE FROM $this->tagsTable WHERE $this->idCol NOT IN (SELECT $this->idCol FROM $this->table)"); + $stmt->execute(); + + return true; } } From 28812ef54a316e60a6c66c4be54e141d77b0e323 Mon Sep 17 00:00:00 2001 From: Yanick Witschi Date: Wed, 18 Sep 2024 10:28:24 +0200 Subject: [PATCH 06/18] CS --- src/Symfony/Component/Cache/Adapter/PdoAdapter.php | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/Symfony/Component/Cache/Adapter/PdoAdapter.php b/src/Symfony/Component/Cache/Adapter/PdoAdapter.php index 22d6870cb69c..b032145b4c0a 100644 --- a/src/Symfony/Component/Cache/Adapter/PdoAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/PdoAdapter.php @@ -12,7 +12,6 @@ namespace Symfony\Component\Cache\Adapter; use Symfony\Component\Cache\Exception\InvalidArgumentException; -use Symfony\Component\Cache\Exception\LogicException; use Symfony\Component\Cache\Marshaller\DefaultMarshaller; use Symfony\Component\Cache\Marshaller\MarshallerInterface; use Symfony\Component\Cache\PruneableInterface; @@ -287,6 +286,10 @@ protected function doSave(array $values, int $lifetime, array $addTagData = [], { $failed = $this->doSaveCache($values, $lifetime); + if (!\is_array($failed)) { + return array_keys($values); + } + $driver = $this->getDriver(); $insertSql = "INSERT INTO $this->tagsTable ($this->idCol, $this->tagCol) VALUES (:id, :tagId)"; @@ -314,12 +317,12 @@ protected function doSave(array $values, int $lifetime, array $addTagData = [], $sql = $insertSql." ON CONFLICT ($this->idCol, $this->tagCol) DO UPDATE SET ($this->idCol, $this->tagCol) = (EXCLUDED.$this->idCol, EXCLUDED.$this->tagCol)"; break; default: - $driver = null; + throw new \DomainException(\sprintf('Caching support is currently not implemented for PDO driver "%s".', $driver)); } foreach ($addTagData as $tagId => $ids) { foreach ($ids as $id) { - if ($failed && \in_array($id, $failed, true)) { + if (in_array($id, $failed, true)) { continue; } @@ -342,7 +345,7 @@ protected function doSave(array $values, int $lifetime, array $addTagData = [], foreach ($removeTagData as $tagId => $ids) { foreach ($ids as $id) { - if ($failed && \in_array($id, $failed, true)) { + if (\in_array($id, $failed, true)) { continue; } @@ -581,7 +584,7 @@ private function pruneOrphanedTags(): bool { $conn = $this->getConnection(); - $stmt = $conn->prepare("DELETE FROM $this->tagsTable WHERE $this->idCol NOT IN (SELECT $this->idCol FROM $this->table)"); + $stmt = $conn->prepare("DELETE FROM $this->tagsTable WHERE $this->idCol NOT IN (SELECT $this->idCol FROM $this->table)"); $stmt->execute(); return true; From cc7178afd85113d10436e518f95228509d7ca9df Mon Sep 17 00:00:00 2001 From: Yanick Witschi Date: Wed, 18 Sep 2024 18:25:09 +0200 Subject: [PATCH 07/18] Split adapters --- .../Component/Cache/Adapter/PdoAdapter.php | 552 +----------------- .../Cache/Adapter/PdoTagAwareAdapter.php | 230 ++++++++ .../Tests/Adapter/AbstractPdoAdapterTest.php | 140 +++++ .../Cache/Tests/Adapter/PdoAdapterTest.php | 117 +--- .../Tests/Adapter/PdoTagAwareAdapterTest.php | 32 + .../Component/Cache/Traits/PdoTrait.php | 413 +++++++++++++ 6 files changed, 823 insertions(+), 661 deletions(-) create mode 100644 src/Symfony/Component/Cache/Adapter/PdoTagAwareAdapter.php create mode 100644 src/Symfony/Component/Cache/Tests/Adapter/AbstractPdoAdapterTest.php create mode 100644 src/Symfony/Component/Cache/Tests/Adapter/PdoTagAwareAdapterTest.php create mode 100644 src/Symfony/Component/Cache/Traits/PdoTrait.php diff --git a/src/Symfony/Component/Cache/Adapter/PdoAdapter.php b/src/Symfony/Component/Cache/Adapter/PdoAdapter.php index b032145b4c0a..caeb370abeef 100644 --- a/src/Symfony/Component/Cache/Adapter/PdoAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/PdoAdapter.php @@ -15,28 +15,13 @@ use Symfony\Component\Cache\Marshaller\DefaultMarshaller; use Symfony\Component\Cache\Marshaller\MarshallerInterface; use Symfony\Component\Cache\PruneableInterface; +use Symfony\Component\Cache\Traits\PdoTrait; -class PdoAdapter extends AbstractTagAwareAdapter implements PruneableInterface +class PdoAdapter extends AbstractAdapter implements PruneableInterface { - private const MAX_KEY_LENGTH = 255; - protected const NS_SEPARATOR = ':'; - - private MarshallerInterface $marshaller; - private \PDO $conn; - private string $dsn; - private string $driver; - private string $serverVersion; - private string $table = 'cache_items'; - private string $tagsTable = 'cache_tags'; - private string $idCol = 'item_id'; - private string $dataCol = 'item_data'; - private string $lifetimeCol = 'item_lifetime'; - private string $timeCol = 'item_time'; - private string $tagCol = 'item_tag'; - private ?string $username = null; - private ?string $password = null; - private array $connectionOptions = []; - private string $namespace; + use PdoTrait { + createItemsTable as public createTable; + } /** * You can either pass an existing database connection as PDO instance or @@ -59,534 +44,11 @@ class PdoAdapter extends AbstractTagAwareAdapter implements PruneableInterface */ public function __construct(#[\SensitiveParameter] \PDO|string $connOrDsn, string $namespace = '', int $defaultLifetime = 0, array $options = [], ?MarshallerInterface $marshaller = null) { - if (\is_string($connOrDsn) && str_contains($connOrDsn, '://')) { - throw new InvalidArgumentException(\sprintf('Usage of Doctrine DBAL URL with "%s" is not supported. Use a PDO DSN or "%s" instead.', __CLASS__, DoctrineDbalAdapter::class)); - } - - 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 \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)).', __CLASS__)); - } - - $this->conn = $connOrDsn; - } else { - $this->dsn = $connOrDsn; - } - - $this->maxIdLength = self::MAX_KEY_LENGTH; - $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->username = $options['db_username'] ?? $this->username; - $this->password = $options['db_password'] ?? $this->password; - $this->connectionOptions = $options['db_connection_options'] ?? $this->connectionOptions; - $this->namespace = $namespace; - $this->marshaller = $marshaller ?? new DefaultMarshaller(); - - parent::__construct($namespace, $defaultLifetime); - } - - public static function createConnection(#[\SensitiveParameter] string $dsn, array $options = []): \PDO|string - { - if ($options['lazy'] ?? true) { - return $dsn; - } - - $pdo = new \PDO($dsn); - $pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); - - return $pdo; - } - - /** - * 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 \PDOException When the table already exists - * @throws \DomainException When an unsupported PDO driver is used - */ - public function createTable(): void - { - $sql = match ($driver = $this->getDriver()) { - // We use varbinary for the ID column because it prevents unwanted conversions: - // - character set conversions between server and client - // - trailing space removal - // - case-insensitivity - // - language processing like é == e - 'mysql' => "CREATE TABLE $this->table ($this->idCol VARBINARY(255) NOT NULL PRIMARY KEY, $this->dataCol MEDIUMBLOB NOT NULL, $this->lifetimeCol INTEGER UNSIGNED, $this->timeCol INTEGER UNSIGNED NOT NULL) COLLATE utf8mb4_bin, ENGINE = InnoDB", - 'sqlite' => "CREATE TABLE $this->table ($this->idCol TEXT NOT NULL PRIMARY KEY, $this->dataCol BLOB NOT NULL, $this->lifetimeCol INTEGER, $this->timeCol INTEGER NOT NULL)", - 'pgsql' => "CREATE TABLE $this->table ($this->idCol VARCHAR(255) NOT NULL PRIMARY KEY, $this->dataCol BYTEA NOT NULL, $this->lifetimeCol INTEGER, $this->timeCol INTEGER NOT NULL)", - 'oci' => "CREATE TABLE $this->table ($this->idCol VARCHAR2(255) NOT NULL PRIMARY KEY, $this->dataCol BLOB NOT NULL, $this->lifetimeCol INTEGER, $this->timeCol INTEGER NOT NULL)", - 'sqlsrv' => "CREATE TABLE $this->table ($this->idCol VARCHAR(255) NOT NULL PRIMARY KEY, $this->dataCol VARBINARY(MAX) NOT NULL, $this->lifetimeCol INTEGER, $this->timeCol INTEGER NOT NULL)", - default => throw new \DomainException(\sprintf('Creating the cache table is currently not implemented for PDO driver "%s".', $driver)), - }; - - $this->getConnection()->exec($sql); - } - - /** - * Creates the tags table to store tag items which can be called once for setup. - * - * Both, cache ID and tag ID are saved in a column of maximum length 255 respecitvely. - * - * @throws \PDOException When the table already exists - * @throws \DomainException When an unsupported PDO driver is used - */ - public function createTagsTable(): void - { - $sql = match ($driver = $this->getDriver()) { - // We use varbinary for the ID column because it prevents unwanted conversions: - // - character set conversions between server and client - // - trailing space removal - // - case-insensitivity - // - language processing like é == e - 'mysql' => "CREATE TABLE $this->tagsTable ($this->idCol VARBINARY(255) NOT NULL, $this->tagCol VARBINARY(255) NOT NULL, PRIMARY KEY($this->idCol, $this->tagCol), INDEX idx_id_col ($this->idCol), INDEX idx_tag_col ($this->tagCol)) COLLATE utf8mb4_bin, ENGINE = InnoDB", - 'sqlite' => "CREATE TABLE $this->tagsTable ($this->idCol TEXT NOT NULL, $this->tagCol TEXT NOT NULL, PRIMARY KEY ($this->idCol, $this->tagCol));CREATE INDEX idx_id_col ON $this->tagsTable($this->idCol);CREATE INDEX idx_tag_col ON $this->tagsTable($this->tagCol)", - 'pgsql', 'sqlsrv' => "CREATE TABLE $this->tagsTable ($this->idCol VARCHAR(255) NOT NULL, $this->tagCol VARCHAR(255) NOT NULL, PRIMARY KEY($this->idCol, $this->tagCol);CREATE INDEX idx_id_col ON $this->tagsTable($this->idCol);CREATE INDEX idx_tag_col ON $this->tagsTable($this->tagCol)", - 'oci' => "CREATE TABLE $this->tagsTable ($this->idCol VARCHAR2(255) NOT NULL, $this->tagCol VARCHAR2(255) NOT NULL, PRIMARY KEY($this->idCol, $this->tagCol);CREATE INDEX idx_id_col ON $this->tagsTable($this->idCol);CREATE INDEX idx_tag_col ON $this->tagsTable($this->tagCol)", - default => throw new \DomainException(\sprintf('Creating the cache table is currently not implemented for PDO driver "%s".', $driver)), - }; - - $this->getConnection()->exec($sql); + $this->init($connOrDsn, $namespace, $defaultLifetime, $options, $marshaller); } public function prune(): bool { - return $this->pruneExpiredItems() && $this->pruneOrphanedTags(); - } - - public function pruneExpiredItems(): bool - { - $deleteSql = "DELETE FROM $this->table WHERE $this->lifetimeCol + $this->timeCol <= :time"; - - if ('' !== $this->namespace) { - $deleteSql .= " AND $this->idCol LIKE :namespace"; - } - - $connection = $this->getConnection(); - - try { - $delete = $connection->prepare($deleteSql); - } catch (\PDOException) { - return true; - } - $delete->bindValue(':time', time(), \PDO::PARAM_INT); - - if ('' !== $this->namespace) { - $delete->bindValue(':namespace', \sprintf('%s%%', $this->namespace), \PDO::PARAM_STR); - } - try { - return $delete->execute(); - } catch (\PDOException) { - return true; - } - } - - protected function doFetch(array $ids): iterable - { - $connection = $this->getConnection(); - - $now = time(); - $expired = []; - - $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, \PDO::PARAM_INT); - foreach ($ids as $id) { - $stmt->bindValue(++$i, $id); - } - $result = $stmt->execute(); - - if (\is_object($result)) { - $result = $result->iterateNumeric(); - } else { - $stmt->setFetchMode(\PDO::FETCH_NUM); - $result = $stmt; - } - - 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 = 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, \PDO::PARAM_INT); - foreach ($expired as $id) { - $stmt->bindValue(++$i, $id); - } - $stmt->execute(); - } - } - - protected function doHave(string $id): bool - { - $connection = $this->getConnection(); - - $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(), \PDO::PARAM_INT); - $stmt->execute(); - - return (bool) $stmt->fetchColumn(); - } - - protected function doClear(string $namespace): bool - { - $conn = $this->getConnection(); - - if ('' === $namespace) { - if ('sqlite' === $this->getDriver()) { - $sql = "DELETE FROM $this->table"; - } else { - $sql = "TRUNCATE TABLE $this->table"; - } - } else { - $sql = "DELETE FROM $this->table WHERE $this->idCol LIKE '$namespace%'"; - } - - try { - $conn->exec($sql); - } catch (\PDOException) { - } - - return true; - } - - protected function doDelete(array $ids): bool - { - $sql = str_pad('', (\count($ids) << 1) - 1, '?,'); - $sql = "DELETE FROM $this->table WHERE $this->idCol IN ($sql)"; - try { - $stmt = $this->getConnection()->prepare($sql); - $stmt->execute(array_values($ids)); - } catch (\PDOException) { - } - - return true; - } - - protected function doSave(array $values, int $lifetime, array $addTagData = [], array $removeTagData = []): array - { - $failed = $this->doSaveCache($values, $lifetime); - - if (!\is_array($failed)) { - return array_keys($values); - } - - $driver = $this->getDriver(); - $insertSql = "INSERT INTO $this->tagsTable ($this->idCol, $this->tagCol) VALUES (:id, :tagId)"; - - switch (true) { - case 'mysql' === $driver: - $sql = $insertSql." ON DUPLICATE KEY UPDATE $this->idCol = VALUES($this->idCol), $this->tagCol = VALUES($this->tagCol)"; - break; - case 'oci' === $driver: - // DUAL is Oracle specific dummy table - $sql = "MERGE INTO $this->tagsTable USING DUAL ON ($this->idCol = :id, $this->tagCol = :tagId) ". - "WHEN NOT MATCHED THEN INSERT ($this->idCol, $this->tagCol) VALUES (:id, :tagId) ". - "WHEN MATCHED THEN UPDATE SET $this->idCol = :id, $this->tagCol = :tagId"; - break; - case 'sqlsrv' === $driver && 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->tagsTable WITH (HOLDLOCK) USING (SELECT 1 AS dummy) AS src ON ($this->idCol = :id, $this->tagCol = :tagId) ". - "WHEN NOT MATCHED THEN INSERT ($this->idCol, $this->tagCol) VALUES (:id, :tagId) ". - "WHEN MATCHED THEN UPDATE SET $this->idCol = :id, $this->tagCol = :tagId;"; - break; - case 'sqlite' === $driver: - $sql = 'INSERT OR REPLACE'.substr($insertSql, 6); - break; - case 'pgsql' === $driver && version_compare($this->getServerVersion(), '9.5', '>='): - $sql = $insertSql." ON CONFLICT ($this->idCol, $this->tagCol) DO UPDATE SET ($this->idCol, $this->tagCol) = (EXCLUDED.$this->idCol, EXCLUDED.$this->tagCol)"; - break; - default: - throw new \DomainException(\sprintf('Caching support is currently not implemented for PDO driver "%s".', $driver)); - } - - foreach ($addTagData as $tagId => $ids) { - foreach ($ids as $id) { - if (in_array($id, $failed, true)) { - continue; - } - - try { - $stmt = $this->prepareStatementWithFallback($sql, function () { - $this->createTagsTable(); - }); - - $stmt->bindParam(':id', $id); - $stmt->bindParam(':tagId', $tagId); - - $this->executeStatementWithFallback($stmt, function () { - $this->createTagsTable(); - }); - } catch (\PDOException $e) { - $failed[] = $id; - } - } - } - - foreach ($removeTagData as $tagId => $ids) { - foreach ($ids as $id) { - if (\in_array($id, $failed, true)) { - continue; - } - - $sql = "DELETE FROM $this->tagsTable WHERE $this->idCol=:id AND $this->tagCol=:tagId"; - - try { - $stmt = $this->prepareStatementWithFallback($sql, function () { - $this->createTagsTable(); - }); - - $stmt->bindParam(':id', $id); - $stmt->bindParam(':tagId', $tagId); - - $this->executeStatementWithFallback($stmt, function () { - $this->createTagsTable(); - }); - } catch (\PDOException $e) { - $failed[] = $id; - } - } - } - - return $failed; - } - - protected function doSaveCache(array $values, int $lifetime): array|bool - { - if (!$values = $this->marshaller->marshall($values, $failed)) { - return $failed; - } - - $conn = $this->getConnection(); - - $driver = $this->getDriver(); - $insertSql = "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time)"; - - switch (true) { - case 'mysql' === $driver: - $sql = $insertSql." ON DUPLICATE KEY UPDATE $this->dataCol = VALUES($this->dataCol), $this->lifetimeCol = VALUES($this->lifetimeCol), $this->timeCol = VALUES($this->timeCol)"; - break; - case 'oci' === $driver: - // 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' === $driver && 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' === $driver: - $sql = 'INSERT OR REPLACE'.substr($insertSql, 6); - break; - case 'pgsql' === $driver && 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: - $driver = null; - $sql = "UPDATE $this->table SET $this->dataCol = :data, $this->lifetimeCol = :lifetime, $this->timeCol = :time WHERE $this->idCol = :id"; - break; - } - - $now = time(); - $lifetime = $lifetime ?: null; - - $stmt = $this->prepareStatementWithFallback($sql, function () { - $this->createTable(); - }); - - // $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, \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, \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, \PDO::PARAM_LOB); - $insertStmt->bindValue(':lifetime', $lifetime, \PDO::PARAM_INT); - $insertStmt->bindValue(':time', $now, \PDO::PARAM_INT); - } - - foreach ($values as $id => $data) { - $this->executeStatementWithFallback($stmt, function () { - $this->createTagsTable(); - }); - - if (null === $driver && !$stmt->rowCount()) { - try { - $insertStmt->execute(); - } catch (\PDOException) { - // A concurrent write won, let it be - } - } - } - - return $failed; - } - - /** - * @internal - */ - protected function getId(mixed $key): string - { - if ('pgsql' !== $this->getDriver()) { - return parent::getId($key); - } - - if (str_contains($key, "\0") || str_contains($key, '%') || !preg_match('//u', $key)) { - $key = rawurlencode($key); - } - - return parent::getId($key); - } - - private function getConnection(): \PDO - { - if (!isset($this->conn)) { - $this->conn = new \PDO($this->dsn, $this->username, $this->password, $this->connectionOptions); - $this->conn->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); - } - - return $this->conn; - } - - private function getDriver(): string - { - return $this->driver ??= $this->getConnection()->getAttribute(\PDO::ATTR_DRIVER_NAME); - } - - private function getServerVersion(): string - { - return $this->serverVersion ??= $this->getConnection()->getAttribute(\PDO::ATTR_SERVER_VERSION); - } - - private function isTableMissing(\PDOException $exception): bool - { - $driver = $this->getDriver(); - [$sqlState, $code] = $exception->errorInfo ?? [null, $exception->getCode()]; - - return match ($driver) { - 'pgsql' => '42P01' === $sqlState, - 'sqlite' => str_contains($exception->getMessage(), 'no such table:'), - 'oci' => 942 === $code, - 'sqlsrv' => 208 === $code, - 'mysql' => 1146 === $code, - default => false, - }; - } - - protected function doDeleteTagRelations(array $tagData): bool - { - foreach ($tagData as $tagId => $idList) { - $placeholders = implode(',', array_fill(0, \count($idList), '?')); - $stmt = $this->prepareStatementWithFallback("DELETE FROM $this->tagsTable WHERE $this->tagCol=:tagId AND $this->idCol IN ($placeholders);", function () { - $this->createTagsTable(); - }); - - $stmt->bindValue(1, $tagId, \PDO::PARAM_STR); - - foreach ($idList as $index => $value) { - $stmt->bindValue($index + 2, $value, \PDO::PARAM_STR); - } - $stmt->execute(); - } - - return true; - } - - protected function doInvalidate(array $tagIds): bool - { - $placeholders = implode(',', array_fill(0, \count($tagIds), '?')); - $stmt = $this->prepareStatementWithFallback("DELETE FROM $this->table WHERE $this->idCol IN (SELECT $this->idCol FROM $this->tagsTable WHERE $this->tagCol IN ($placeholders));", function () { - $this->createTagsTable(); - }); - - foreach ($tagIds as $index => $value) { - $stmt->bindValue($index + 1, $value, \PDO::PARAM_STR); - } - $stmt->execute(); - - return true; - } - - private function prepareStatementWithFallback(string $query, \Closure $createTable): \PDOStatement - { - $driver = $this->getDriver(); - $conn = $this->getConnection(); - - try { - return $conn->prepare($query); - } catch (\PDOException $e) { - if ($this->isTableMissing($e) && (!$conn->inTransaction() || \in_array($driver, ['pgsql', 'sqlite', 'sqlsrv'], true))) { - $createTable(); - } - - return $conn->prepare($query); - } - } - - private function executeStatementWithFallback(\PDOStatement $statement, \Closure $createTable): void - { - $driver = $this->getDriver(); - $conn = $this->getConnection(); - - try { - $statement->execute(); - } catch (\PDOException $e) { - if ($this->isTableMissing($e) && (!$conn->inTransaction() || \in_array($driver, ['pgsql', 'sqlite', 'sqlsrv'], true))) { - $createTable(); - } - - $statement->execute(); - } - } - - /** - * Prunes the tags table and removes all tags that are not used anywhere anymore. - */ - private function pruneOrphanedTags(): bool - { - $conn = $this->getConnection(); - - $stmt = $conn->prepare("DELETE FROM $this->tagsTable WHERE $this->idCol NOT IN (SELECT $this->idCol FROM $this->table)"); - $stmt->execute(); - - return true; + return $this->pruneExpiredItems(); } } diff --git a/src/Symfony/Component/Cache/Adapter/PdoTagAwareAdapter.php b/src/Symfony/Component/Cache/Adapter/PdoTagAwareAdapter.php new file mode 100644 index 000000000000..7ada2c364e8a --- /dev/null +++ b/src/Symfony/Component/Cache/Adapter/PdoTagAwareAdapter.php @@ -0,0 +1,230 @@ + + * + * 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 Symfony\Component\Cache\Exception\InvalidArgumentException; +use Symfony\Component\Cache\Marshaller\DefaultMarshaller; +use Symfony\Component\Cache\Marshaller\MarshallerInterface; +use Symfony\Component\Cache\PruneableInterface; +use Symfony\Component\Cache\Traits\PdoTrait; + +class PdoTagAwareAdapter extends AbstractTagAwareAdapter implements PruneableInterface +{ + use PdoTrait { + doSave as public doSaveItem; + } + /** + * 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 + * cache is actually used. + * + * 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] + * * 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: []] + * + * @throws InvalidArgumentException When first argument is not PDO nor Connection nor string + * @throws InvalidArgumentException When PDO error mode is not PDO::ERRMODE_EXCEPTION + * @throws InvalidArgumentException When namespace contains invalid characters + */ + public function __construct(#[\SensitiveParameter] \PDO|string $connOrDsn, string $namespace = '', int $defaultLifetime = 0, array $options = [], ?MarshallerInterface $marshaller = null) + { + $this->init($connOrDsn, $namespace, $defaultLifetime, $options, $marshaller); + } + + /** + * Creates the tags table to store tag items which can be called once for setup. + * + * Both, cache ID and tag ID are saved in a column of maximum length 255 respecitvely. + * + * @throws \PDOException When the table already exists + * @throws \DomainException When an unsupported PDO driver is used + */ + private function createTagsTable(): void + { + $sql = match ($driver = $this->getDriver()) { + // We use varbinary for the ID column because it prevents unwanted conversions: + // - character set conversions between server and client + // - trailing space removal + // - case-insensitivity + // - language processing like é == e + 'mysql' => "CREATE TABLE $this->tagsTable ($this->idCol VARBINARY(255) NOT NULL, $this->tagCol VARBINARY(255) NOT NULL, PRIMARY KEY($this->idCol, $this->tagCol), INDEX idx_id_col ($this->idCol), INDEX idx_tag_col ($this->tagCol)) COLLATE utf8mb4_bin, ENGINE = InnoDB", + 'sqlite' => "CREATE TABLE $this->tagsTable ($this->idCol TEXT NOT NULL, $this->tagCol TEXT NOT NULL, PRIMARY KEY ($this->idCol, $this->tagCol));CREATE INDEX idx_id_col ON $this->tagsTable($this->idCol);CREATE INDEX idx_tag_col ON $this->tagsTable($this->tagCol)", + 'pgsql', 'sqlsrv' => "CREATE TABLE $this->tagsTable ($this->idCol VARCHAR(255) NOT NULL, $this->tagCol VARCHAR(255) NOT NULL, PRIMARY KEY($this->idCol, $this->tagCol);CREATE INDEX idx_id_col ON $this->tagsTable($this->idCol);CREATE INDEX idx_tag_col ON $this->tagsTable($this->tagCol)", + 'oci' => "CREATE TABLE $this->tagsTable ($this->idCol VARCHAR2(255) NOT NULL, $this->tagCol VARCHAR2(255) NOT NULL, PRIMARY KEY($this->idCol, $this->tagCol);CREATE INDEX idx_id_col ON $this->tagsTable($this->idCol);CREATE INDEX idx_tag_col ON $this->tagsTable($this->tagCol)", + default => throw new \DomainException(\sprintf('Creating the cache table is currently not implemented for PDO driver "%s".', $driver)), + }; + + $this->getConnection()->exec($sql); + } + + /** + * 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 \PDOException When the table already exists + * @throws \DomainException When an unsupported PDO driver is used + */ + private function createTables(): void + { + $this->createItemsTable(); + $this->createTagsTable(); + } + + public function prune(): bool + { + return $this->pruneExpiredItems() && $this->pruneOrphanedTags(); + } + + protected function doSave(array $values, int $lifetime, array $addTagData = [], array $removeTagData = []): array + { + $failed = $this->doSaveItem($values, $lifetime); + + if (!\is_array($failed)) { + return array_keys($values); + } + + $driver = $this->getDriver(); + $insertSql = "INSERT INTO $this->tagsTable ($this->idCol, $this->tagCol) VALUES (:id, :tagId)"; + + switch (true) { + case 'mysql' === $driver: + $sql = $insertSql." ON DUPLICATE KEY UPDATE $this->idCol = VALUES($this->idCol), $this->tagCol = VALUES($this->tagCol)"; + break; + case 'oci' === $driver: + // DUAL is Oracle specific dummy table + $sql = "MERGE INTO $this->tagsTable USING DUAL ON ($this->idCol = :id, $this->tagCol = :tagId) ". + "WHEN NOT MATCHED THEN INSERT ($this->idCol, $this->tagCol) VALUES (:id, :tagId) ". + "WHEN MATCHED THEN UPDATE SET $this->idCol = :id, $this->tagCol = :tagId"; + break; + case 'sqlsrv' === $driver && 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->tagsTable WITH (HOLDLOCK) USING (SELECT 1 AS dummy) AS src ON ($this->idCol = :id, $this->tagCol = :tagId) ". + "WHEN NOT MATCHED THEN INSERT ($this->idCol, $this->tagCol) VALUES (:id, :tagId) ". + "WHEN MATCHED THEN UPDATE SET $this->idCol = :id, $this->tagCol = :tagId;"; + break; + case 'sqlite' === $driver: + $sql = 'INSERT OR REPLACE'.substr($insertSql, 6); + break; + case 'pgsql' === $driver && version_compare($this->getServerVersion(), '9.5', '>='): + $sql = $insertSql." ON CONFLICT ($this->idCol, $this->tagCol) DO UPDATE SET ($this->idCol, $this->tagCol) = (EXCLUDED.$this->idCol, EXCLUDED.$this->tagCol)"; + break; + default: + throw new \DomainException(\sprintf('Caching support is currently not implemented for PDO driver "%s".', $driver)); + } + + foreach ($addTagData as $tagId => $ids) { + foreach ($ids as $id) { + if (in_array($id, $failed, true)) { + continue; + } + + try { + $stmt = $this->prepareStatementWithFallback($sql, function () { + $this->createTagsTable(); + }); + + $stmt->bindParam(':id', $id); + $stmt->bindParam(':tagId', $tagId); + + $this->executeStatementWithFallback($stmt, function () { + $this->createTagsTable(); + }); + } catch (\PDOException $e) { + $failed[] = $id; + } + } + } + + foreach ($removeTagData as $tagId => $ids) { + foreach ($ids as $id) { + if (\in_array($id, $failed, true)) { + continue; + } + + $sql = "DELETE FROM $this->tagsTable WHERE $this->idCol=:id AND $this->tagCol=:tagId"; + + try { + $stmt = $this->prepareStatementWithFallback($sql, function () { + $this->createTagsTable(); + }); + + $stmt->bindParam(':id', $id); + $stmt->bindParam(':tagId', $tagId); + + $this->executeStatementWithFallback($stmt, function () { + $this->createTagsTable(); + }); + } catch (\PDOException $e) { + $failed[] = $id; + } + } + } + + return $failed; + } + + protected function doDeleteTagRelations(array $tagData): bool + { + foreach ($tagData as $tagId => $idList) { + $placeholders = implode(',', array_fill(0, \count($idList), '?')); + $stmt = $this->prepareStatementWithFallback("DELETE FROM $this->tagsTable WHERE $this->tagCol=:tagId AND $this->idCol IN ($placeholders);", function () { + $this->createTagsTable(); + }); + + $stmt->bindValue(1, $tagId, \PDO::PARAM_STR); + + foreach ($idList as $index => $value) { + $stmt->bindValue($index + 2, $value, \PDO::PARAM_STR); + } + $stmt->execute(); + } + + return true; + } + + protected function doInvalidate(array $tagIds): bool + { + $placeholders = implode(',', array_fill(0, \count($tagIds), '?')); + $stmt = $this->prepareStatementWithFallback("DELETE FROM $this->table WHERE $this->idCol IN (SELECT $this->idCol FROM $this->tagsTable WHERE $this->tagCol IN ($placeholders));", function () { + $this->createTagsTable(); + }); + + foreach ($tagIds as $index => $value) { + $stmt->bindValue($index + 1, $value, \PDO::PARAM_STR); + } + $stmt->execute(); + + return true; + } + + /** + * Prunes the tags table and removes all tags that are not used anywhere anymore. + */ + private function pruneOrphanedTags(): bool + { + $conn = $this->getConnection(); + + $stmt = $conn->prepare("DELETE FROM $this->tagsTable WHERE $this->idCol NOT IN (SELECT $this->idCol FROM $this->table)"); + $stmt->execute(); + + return true; + } +} diff --git a/src/Symfony/Component/Cache/Tests/Adapter/AbstractPdoAdapterTest.php b/src/Symfony/Component/Cache/Tests/Adapter/AbstractPdoAdapterTest.php new file mode 100644 index 000000000000..bfacf9a24abd --- /dev/null +++ b/src/Symfony/Component/Cache/Tests/Adapter/AbstractPdoAdapterTest.php @@ -0,0 +1,140 @@ + + * + * 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 Psr\Cache\CacheItemPoolInterface; +use Symfony\Component\Cache\Adapter\AbstractAdapter; +use Symfony\Component\Cache\Adapter\PdoAdapter; +use Symfony\Component\Cache\Adapter\PdoTagAwareAdapter; + +/** + * @requires extension pdo_sqlite + * + * @group time-sensitive + */ +abstract class AbstractPdoAdapterTest extends AdapterTestCase +{ + protected static string $dbFile; + + public static function setUpBeforeClass(): void + { + self::$dbFile = tempnam(sys_get_temp_dir(), 'sf_sqlite_cache'); + + $pool = new PdoAdapter('sqlite:'.self::$dbFile); + $pool->createTable(); + } + + public static function tearDownAfterClass(): void + { + @unlink(self::$dbFile); + } + + abstract public function createCachePool(int $defaultLifetime = 0): CacheItemPoolInterface; + + public function testCreateConnectionReturnsStringWithLazyTrue() + { + self::assertSame('sqlite:'.self::$dbFile, AbstractAdapter::createConnection('sqlite:'.self::$dbFile)); + } + + public function testCreateConnectionReturnsPDOWithLazyFalse() + { + self::assertInstanceOf(\PDO::class, AbstractAdapter::createConnection('sqlite:'.self::$dbFile, ['lazy' => false])); + } + + public function testCleanupExpiredItems() + { + $pdo = new \PDO('sqlite:'.self::$dbFile); + + $getCacheItemCount = fn () => (int) $pdo->query('SELECT COUNT(*) FROM cache_items')->fetch(\PDO::FETCH_COLUMN); + + $this->assertSame(0, $getCacheItemCount()); + + $cache = $this->createCachePool(); + + $item = $cache->getItem('some_nice_key'); + $item->expiresAfter(1); + $item->set(1); + + $cache->save($item); + $this->assertSame(1, $getCacheItemCount()); + + sleep(2); + + $newItem = $cache->getItem($item->getKey()); + $this->assertFalse($newItem->isHit()); + $this->assertSame(0, $getCacheItemCount(), 'PDOAdapter must clean up expired items'); + } + + /** + * @dataProvider provideDsnSQLite + */ + public function testDsnWithSQLite(string $dsn, ?string $file = null) + { + try { + $pool = new PdoAdapter($dsn); + + $item = $pool->getItem('key'); + $item->set('value'); + $this->assertTrue($pool->save($item)); + } finally { + if (null !== $file) { + @unlink($file); + } + } + } + + public static function provideDsnSQLite() + { + $dbFile = tempnam(sys_get_temp_dir(), 'sf_sqlite_cache'); + yield 'SQLite file' => ['sqlite:'.$dbFile.'2', $dbFile.'2']; + yield 'SQLite in memory' => ['sqlite::memory:']; + } + + /** + * @requires extension pdo_pgsql + * + * @group integration + */ + public function testDsnWithPostgreSQL() + { + if (!$host = getenv('POSTGRES_HOST')) { + $this->markTestSkipped('Missing POSTGRES_HOST env variable'); + } + + $dsn = 'pgsql:host='.$host.';user=postgres;password=password'; + + try { + $pool = new PdoAdapter($dsn); + + $item = $pool->getItem('key'); + $item->set('value'); + $this->assertTrue($pool->save($item)); + } finally { + $pdo = new \PDO($dsn); + $pdo->exec('DROP TABLE IF EXISTS cache_items'); + } + } + + protected function isPruned(PdoAdapter|PdoTagAwareAdapter $cache, string $name): bool + { + $o = new \ReflectionObject($cache); + + $getPdoConn = $o->getMethod('getConnection'); + + /** @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/PdoAdapterTest.php b/src/Symfony/Component/Cache/Tests/Adapter/PdoAdapterTest.php index 3be980544a09..32ef5808180d 100644 --- a/src/Symfony/Component/Cache/Tests/Adapter/PdoAdapterTest.php +++ b/src/Symfony/Component/Cache/Tests/Adapter/PdoAdapterTest.php @@ -20,125 +20,10 @@ * * @group time-sensitive */ -class PdoAdapterTest extends AdapterTestCase +class PdoAdapterTest extends AbstractPdoAdapterTest { - use TagAwareTestTrait; - - protected static string $dbFile; - - public static function setUpBeforeClass(): void - { - self::$dbFile = tempnam(sys_get_temp_dir(), 'sf_sqlite_cache'); - - $pool = new PdoAdapter('sqlite:'.self::$dbFile); - $pool->createTable(); - } - - public static function tearDownAfterClass(): void - { - @unlink(self::$dbFile); - } - public function createCachePool(int $defaultLifetime = 0): CacheItemPoolInterface { return new PdoAdapter('sqlite:'.self::$dbFile, 'ns', $defaultLifetime); } - - public function testCreateConnectionReturnsStringWithLazyTrue() - { - self::assertSame('sqlite:'.self::$dbFile, AbstractAdapter::createConnection('sqlite:'.self::$dbFile)); - } - - public function testCreateConnectionReturnsPDOWithLazyFalse() - { - self::assertInstanceOf(\PDO::class, AbstractAdapter::createConnection('sqlite:'.self::$dbFile, ['lazy' => false])); - } - - public function testCleanupExpiredItems() - { - $pdo = new \PDO('sqlite:'.self::$dbFile); - - $getCacheItemCount = fn () => (int) $pdo->query('SELECT COUNT(*) FROM cache_items')->fetch(\PDO::FETCH_COLUMN); - - $this->assertSame(0, $getCacheItemCount()); - - $cache = $this->createCachePool(); - - $item = $cache->getItem('some_nice_key'); - $item->expiresAfter(1); - $item->set(1); - - $cache->save($item); - $this->assertSame(1, $getCacheItemCount()); - - sleep(2); - - $newItem = $cache->getItem($item->getKey()); - $this->assertFalse($newItem->isHit()); - $this->assertSame(0, $getCacheItemCount(), 'PDOAdapter must clean up expired items'); - } - - /** - * @dataProvider provideDsnSQLite - */ - public function testDsnWithSQLite(string $dsn, ?string $file = null) - { - try { - $pool = new PdoAdapter($dsn); - - $item = $pool->getItem('key'); - $item->set('value'); - $this->assertTrue($pool->save($item)); - } finally { - if (null !== $file) { - @unlink($file); - } - } - } - - public static function provideDsnSQLite() - { - $dbFile = tempnam(sys_get_temp_dir(), 'sf_sqlite_cache'); - yield 'SQLite file' => ['sqlite:'.$dbFile.'2', $dbFile.'2']; - yield 'SQLite in memory' => ['sqlite::memory:']; - } - - /** - * @requires extension pdo_pgsql - * - * @group integration - */ - public function testDsnWithPostgreSQL() - { - if (!$host = getenv('POSTGRES_HOST')) { - $this->markTestSkipped('Missing POSTGRES_HOST env variable'); - } - - $dsn = 'pgsql:host='.$host.';user=postgres;password=password'; - - try { - $pool = new PdoAdapter($dsn); - - $item = $pool->getItem('key'); - $item->set('value'); - $this->assertTrue($pool->save($item)); - } finally { - $pdo = new \PDO($dsn); - $pdo->exec('DROP TABLE IF EXISTS cache_items'); - } - } - - protected function isPruned(PdoAdapter $cache, string $name): bool - { - $o = new \ReflectionObject($cache); - - $getPdoConn = $o->getMethod('getConnection'); - - /** @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/PdoTagAwareAdapterTest.php b/src/Symfony/Component/Cache/Tests/Adapter/PdoTagAwareAdapterTest.php new file mode 100644 index 000000000000..7f621785de55 --- /dev/null +++ b/src/Symfony/Component/Cache/Tests/Adapter/PdoTagAwareAdapterTest.php @@ -0,0 +1,32 @@ + + * + * 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 Psr\Cache\CacheItemPoolInterface; +use Symfony\Component\Cache\Adapter\AbstractAdapter; +use Symfony\Component\Cache\Adapter\PdoAdapter; +use Symfony\Component\Cache\Adapter\PdoTagAwareAdapter; + +/** + * @requires extension pdo_sqlite + * + * @group time-sensitive + */ +class PdoTagAwareAdapterTest extends AbstractPdoAdapterTest +{ + use TagAwareTestTrait; + + public function createCachePool(int $defaultLifetime = 0): CacheItemPoolInterface + { + return new PdoTagAwareAdapter('sqlite:'.self::$dbFile, 'ns', $defaultLifetime); + } +} diff --git a/src/Symfony/Component/Cache/Traits/PdoTrait.php b/src/Symfony/Component/Cache/Traits/PdoTrait.php new file mode 100644 index 000000000000..57fbc74a5ef7 --- /dev/null +++ b/src/Symfony/Component/Cache/Traits/PdoTrait.php @@ -0,0 +1,413 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Traits; + +use Symfony\Component\Cache\Adapter\DoctrineDbalAdapter; +use Symfony\Component\Cache\Marshaller\DefaultMarshaller; +use Symfony\Component\Cache\Marshaller\MarshallerInterface; +use Symfony\Component\Cache\PruneableInterface; +use Symfony\Contracts\Service\ResetInterface; + +/** + * @internal + */ +trait PdoTrait +{ + private const MAX_KEY_LENGTH = 255; + protected const NS_SEPARATOR = ':'; + + private MarshallerInterface $marshaller; + private \PDO $conn; + private string $dsn; + private string $driver; + private string $serverVersion; + private string $table = 'cache_items'; + private string $tagsTable = 'cache_tags'; + private string $idCol = 'item_id'; + private string $dataCol = 'item_data'; + private string $lifetimeCol = 'item_lifetime'; + private string $timeCol = 'item_time'; + private string $tagCol = 'item_tag'; + private ?string $username = null; + private ?string $password = null; + private array $connectionOptions = []; + private string $namespace; + + private function init(#[\SensitiveParameter] \PDO|string $connOrDsn, string $namespace = '', int $defaultLifetime = 0, array $options = [], ?MarshallerInterface $marshaller = null) + { + if (\is_string($connOrDsn) && str_contains($connOrDsn, '://')) { + throw new InvalidArgumentException(\sprintf('Usage of Doctrine DBAL URL with "%s" is not supported. Use a PDO DSN or "%s" instead.', __CLASS__, DoctrineDbalAdapter::class)); + } + + 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 \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)).', __CLASS__)); + } + + $this->conn = $connOrDsn; + } else { + $this->dsn = $connOrDsn; + } + + $this->maxIdLength = self::MAX_KEY_LENGTH; + $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->username = $options['db_username'] ?? $this->username; + $this->password = $options['db_password'] ?? $this->password; + $this->connectionOptions = $options['db_connection_options'] ?? $this->connectionOptions; + $this->namespace = $namespace; + $this->marshaller = $marshaller ?? new DefaultMarshaller(); + + parent::__construct($namespace, $defaultLifetime); + } + + public static function createConnection(#[\SensitiveParameter] string $dsn, array $options = []): \PDO|string + { + if ($options['lazy'] ?? true) { + return $dsn; + } + + $pdo = new \PDO($dsn); + $pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); + + return $pdo; + } + + + /** + * 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 \PDOException When the table already exists + * @throws \DomainException When an unsupported PDO driver is used + */ + private function createItemsTable(): void + { + $sql = match ($driver = $this->getDriver()) { + // We use varbinary for the ID column because it prevents unwanted conversions: + // - character set conversions between server and client + // - trailing space removal + // - case-insensitivity + // - language processing like é == e + 'mysql' => "CREATE TABLE $this->table ($this->idCol VARBINARY(255) NOT NULL PRIMARY KEY, $this->dataCol MEDIUMBLOB NOT NULL, $this->lifetimeCol INTEGER UNSIGNED, $this->timeCol INTEGER UNSIGNED NOT NULL) COLLATE utf8mb4_bin, ENGINE = InnoDB", + 'sqlite' => "CREATE TABLE $this->table ($this->idCol TEXT NOT NULL PRIMARY KEY, $this->dataCol BLOB NOT NULL, $this->lifetimeCol INTEGER, $this->timeCol INTEGER NOT NULL)", + 'pgsql' => "CREATE TABLE $this->table ($this->idCol VARCHAR(255) NOT NULL PRIMARY KEY, $this->dataCol BYTEA NOT NULL, $this->lifetimeCol INTEGER, $this->timeCol INTEGER NOT NULL)", + 'oci' => "CREATE TABLE $this->table ($this->idCol VARCHAR2(255) NOT NULL PRIMARY KEY, $this->dataCol BLOB NOT NULL, $this->lifetimeCol INTEGER, $this->timeCol INTEGER NOT NULL)", + 'sqlsrv' => "CREATE TABLE $this->table ($this->idCol VARCHAR(255) NOT NULL PRIMARY KEY, $this->dataCol VARBINARY(MAX) NOT NULL, $this->lifetimeCol INTEGER, $this->timeCol INTEGER NOT NULL)", + default => throw new \DomainException(\sprintf('Creating the cache table is currently not implemented for PDO driver "%s".', $driver)), + }; + + $this->getConnection()->exec($sql); + } + + protected function doFetch(array $ids): iterable + { + $connection = $this->getConnection(); + + $now = time(); + $expired = []; + + $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, \PDO::PARAM_INT); + foreach ($ids as $id) { + $stmt->bindValue(++$i, $id); + } + $result = $stmt->execute(); + + if (\is_object($result)) { + $result = $result->iterateNumeric(); + } else { + $stmt->setFetchMode(\PDO::FETCH_NUM); + $result = $stmt; + } + + 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 = 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, \PDO::PARAM_INT); + foreach ($expired as $id) { + $stmt->bindValue(++$i, $id); + } + $stmt->execute(); + } + } + + protected function doHave(string $id): bool + { + $connection = $this->getConnection(); + + $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(), \PDO::PARAM_INT); + $stmt->execute(); + + return (bool) $stmt->fetchColumn(); + } + + + protected function doSave(array $values, int $lifetime): array|bool + { + if (!$values = $this->marshaller->marshall($values, $failed)) { + return $failed; + } + + $conn = $this->getConnection(); + + $driver = $this->getDriver(); + $insertSql = "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time)"; + + switch (true) { + case 'mysql' === $driver: + $sql = $insertSql." ON DUPLICATE KEY UPDATE $this->dataCol = VALUES($this->dataCol), $this->lifetimeCol = VALUES($this->lifetimeCol), $this->timeCol = VALUES($this->timeCol)"; + break; + case 'oci' === $driver: + // 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' === $driver && 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' === $driver: + $sql = 'INSERT OR REPLACE'.substr($insertSql, 6); + break; + case 'pgsql' === $driver && 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: + $driver = null; + $sql = "UPDATE $this->table SET $this->dataCol = :data, $this->lifetimeCol = :lifetime, $this->timeCol = :time WHERE $this->idCol = :id"; + break; + } + + $now = time(); + $lifetime = $lifetime ?: null; + + $stmt = $this->prepareStatementWithFallback($sql, function () { + $this->createTable(); + }); + + // $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, \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, \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, \PDO::PARAM_LOB); + $insertStmt->bindValue(':lifetime', $lifetime, \PDO::PARAM_INT); + $insertStmt->bindValue(':time', $now, \PDO::PARAM_INT); + } + + foreach ($values as $id => $data) { + $this->executeStatementWithFallback($stmt, function () { + $this->createTagsTable(); + }); + + if (null === $driver && !$stmt->rowCount()) { + try { + $insertStmt->execute(); + } catch (\PDOException) { + // A concurrent write won, let it be + } + } + } + + return $failed; + } + + protected function doClear(string $namespace): bool + { + $conn = $this->getConnection(); + + if ('' === $namespace) { + if ('sqlite' === $this->getDriver()) { + $sql = "DELETE FROM $this->table"; + } else { + $sql = "TRUNCATE TABLE $this->table"; + } + } else { + $sql = "DELETE FROM $this->table WHERE $this->idCol LIKE '$namespace%'"; + } + + try { + $conn->exec($sql); + } catch (\PDOException) { + } + + return true; + } + + protected function doDelete(array $ids): bool + { + $sql = str_pad('', (\count($ids) << 1) - 1, '?,'); + $sql = "DELETE FROM $this->table WHERE $this->idCol IN ($sql)"; + try { + $stmt = $this->getConnection()->prepare($sql); + $stmt->execute(array_values($ids)); + } catch (\PDOException) { + } + + return true; + } + + /** + * @internal + */ + protected function getId(mixed $key): string + { + if ('pgsql' !== $this->getDriver()) { + return parent::getId($key); + } + + if (str_contains($key, "\0") || str_contains($key, '%') || !preg_match('//u', $key)) { + $key = rawurlencode($key); + } + + return parent::getId($key); + } + + private function getConnection(): \PDO + { + if (!isset($this->conn)) { + $this->conn = new \PDO($this->dsn, $this->username, $this->password, $this->connectionOptions); + $this->conn->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); + } + + return $this->conn; + } + + private function getDriver(): string + { + return $this->driver ??= $this->getConnection()->getAttribute(\PDO::ATTR_DRIVER_NAME); + } + + private function getServerVersion(): string + { + return $this->serverVersion ??= $this->getConnection()->getAttribute(\PDO::ATTR_SERVER_VERSION); + } + + private function isTableMissing(\PDOException $exception): bool + { + $driver = $this->getDriver(); + [$sqlState, $code] = $exception->errorInfo ?? [null, $exception->getCode()]; + + return match ($driver) { + 'pgsql' => '42P01' === $sqlState, + 'sqlite' => str_contains($exception->getMessage(), 'no such table:'), + 'oci' => 942 === $code, + 'sqlsrv' => 208 === $code, + 'mysql' => 1146 === $code, + default => false, + }; + } + + private function pruneExpiredItems(): bool + { + $deleteSql = "DELETE FROM $this->table WHERE $this->lifetimeCol + $this->timeCol <= :time"; + + if ('' !== $this->namespace) { + $deleteSql .= " AND $this->idCol LIKE :namespace"; + } + + $connection = $this->getConnection(); + + try { + $delete = $connection->prepare($deleteSql); + } catch (\PDOException) { + return true; + } + $delete->bindValue(':time', time(), \PDO::PARAM_INT); + + if ('' !== $this->namespace) { + $delete->bindValue(':namespace', \sprintf('%s%%', $this->namespace), \PDO::PARAM_STR); + } + try { + return $delete->execute(); + } catch (\PDOException) { + return true; + } + } + + + private function prepareStatementWithFallback(string $query, \Closure $createTable): \PDOStatement + { + $driver = $this->getDriver(); + $conn = $this->getConnection(); + + try { + return $conn->prepare($query); + } catch (\PDOException $e) { + if ($this->isTableMissing($e) && (!$conn->inTransaction() || \in_array($driver, ['pgsql', 'sqlite', 'sqlsrv'], true))) { + $createTable(); + } + + return $conn->prepare($query); + } + } + + private function executeStatementWithFallback(\PDOStatement $statement, \Closure $createTable): void + { + $driver = $this->getDriver(); + $conn = $this->getConnection(); + + try { + $statement->execute(); + } catch (\PDOException $e) { + if ($this->isTableMissing($e) && (!$conn->inTransaction() || \in_array($driver, ['pgsql', 'sqlite', 'sqlsrv'], true))) { + $createTable(); + } + + $statement->execute(); + } + } +} From a2b7817bf36c11317620f80a435536bdb85ae339 Mon Sep 17 00:00:00 2001 From: Yanick Witschi Date: Wed, 18 Sep 2024 18:25:40 +0200 Subject: [PATCH 08/18] CS --- src/Symfony/Component/Cache/Adapter/PdoAdapter.php | 1 - src/Symfony/Component/Cache/Adapter/PdoTagAwareAdapter.php | 4 ++-- src/Symfony/Component/Cache/Tests/Adapter/PdoAdapterTest.php | 1 - .../Component/Cache/Tests/Adapter/PdoTagAwareAdapterTest.php | 2 -- src/Symfony/Component/Cache/Traits/PdoTrait.php | 5 ----- 5 files changed, 2 insertions(+), 11 deletions(-) diff --git a/src/Symfony/Component/Cache/Adapter/PdoAdapter.php b/src/Symfony/Component/Cache/Adapter/PdoAdapter.php index caeb370abeef..956e32b09a55 100644 --- a/src/Symfony/Component/Cache/Adapter/PdoAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/PdoAdapter.php @@ -12,7 +12,6 @@ namespace Symfony\Component\Cache\Adapter; use Symfony\Component\Cache\Exception\InvalidArgumentException; -use Symfony\Component\Cache\Marshaller\DefaultMarshaller; use Symfony\Component\Cache\Marshaller\MarshallerInterface; use Symfony\Component\Cache\PruneableInterface; use Symfony\Component\Cache\Traits\PdoTrait; diff --git a/src/Symfony/Component/Cache/Adapter/PdoTagAwareAdapter.php b/src/Symfony/Component/Cache/Adapter/PdoTagAwareAdapter.php index 7ada2c364e8a..16df486e8eff 100644 --- a/src/Symfony/Component/Cache/Adapter/PdoTagAwareAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/PdoTagAwareAdapter.php @@ -12,7 +12,6 @@ namespace Symfony\Component\Cache\Adapter; use Symfony\Component\Cache\Exception\InvalidArgumentException; -use Symfony\Component\Cache\Marshaller\DefaultMarshaller; use Symfony\Component\Cache\Marshaller\MarshallerInterface; use Symfony\Component\Cache\PruneableInterface; use Symfony\Component\Cache\Traits\PdoTrait; @@ -22,6 +21,7 @@ class PdoTagAwareAdapter extends AbstractTagAwareAdapter implements PruneableInt use PdoTrait { doSave as public doSaveItem; } + /** * 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 @@ -132,7 +132,7 @@ protected function doSave(array $values, int $lifetime, array $addTagData = [], foreach ($addTagData as $tagId => $ids) { foreach ($ids as $id) { - if (in_array($id, $failed, true)) { + if (\in_array($id, $failed, true)) { continue; } diff --git a/src/Symfony/Component/Cache/Tests/Adapter/PdoAdapterTest.php b/src/Symfony/Component/Cache/Tests/Adapter/PdoAdapterTest.php index 32ef5808180d..335fb89a912f 100644 --- a/src/Symfony/Component/Cache/Tests/Adapter/PdoAdapterTest.php +++ b/src/Symfony/Component/Cache/Tests/Adapter/PdoAdapterTest.php @@ -12,7 +12,6 @@ namespace Symfony\Component\Cache\Tests\Adapter; use Psr\Cache\CacheItemPoolInterface; -use Symfony\Component\Cache\Adapter\AbstractAdapter; use Symfony\Component\Cache\Adapter\PdoAdapter; /** diff --git a/src/Symfony/Component/Cache/Tests/Adapter/PdoTagAwareAdapterTest.php b/src/Symfony/Component/Cache/Tests/Adapter/PdoTagAwareAdapterTest.php index 7f621785de55..10dee1ddc2a7 100644 --- a/src/Symfony/Component/Cache/Tests/Adapter/PdoTagAwareAdapterTest.php +++ b/src/Symfony/Component/Cache/Tests/Adapter/PdoTagAwareAdapterTest.php @@ -12,8 +12,6 @@ namespace Symfony\Component\Cache\Tests\Adapter; use Psr\Cache\CacheItemPoolInterface; -use Symfony\Component\Cache\Adapter\AbstractAdapter; -use Symfony\Component\Cache\Adapter\PdoAdapter; use Symfony\Component\Cache\Adapter\PdoTagAwareAdapter; /** diff --git a/src/Symfony/Component/Cache/Traits/PdoTrait.php b/src/Symfony/Component/Cache/Traits/PdoTrait.php index 57fbc74a5ef7..9e5f00781de2 100644 --- a/src/Symfony/Component/Cache/Traits/PdoTrait.php +++ b/src/Symfony/Component/Cache/Traits/PdoTrait.php @@ -14,8 +14,6 @@ use Symfony\Component\Cache\Adapter\DoctrineDbalAdapter; use Symfony\Component\Cache\Marshaller\DefaultMarshaller; use Symfony\Component\Cache\Marshaller\MarshallerInterface; -use Symfony\Component\Cache\PruneableInterface; -use Symfony\Contracts\Service\ResetInterface; /** * @internal @@ -89,7 +87,6 @@ public static function createConnection(#[\SensitiveParameter] string $dsn, arra return $pdo; } - /** * Creates the table to store cache items which can be called once for setup. * @@ -175,7 +172,6 @@ protected function doHave(string $id): bool return (bool) $stmt->fetchColumn(); } - protected function doSave(array $values, int $lifetime): array|bool { if (!$values = $this->marshaller->marshall($values, $failed)) { @@ -378,7 +374,6 @@ private function pruneExpiredItems(): bool } } - private function prepareStatementWithFallback(string $query, \Closure $createTable): \PDOStatement { $driver = $this->getDriver(); From 60a715e025757bf8ec36d9d3af439c5b68aa244a Mon Sep 17 00:00:00 2001 From: Yanick Witschi Date: Wed, 18 Sep 2024 18:28:34 +0200 Subject: [PATCH 09/18] Update changelog --- src/Symfony/Component/Cache/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Symfony/Component/Cache/CHANGELOG.md b/src/Symfony/Component/Cache/CHANGELOG.md index b5a5099b660d..b6caf2e6a6ab 100644 --- a/src/Symfony/Component/Cache/CHANGELOG.md +++ b/src/Symfony/Component/Cache/CHANGELOG.md @@ -6,7 +6,7 @@ CHANGELOG * `igbinary_serialize()` is not used by default when the igbinary extension is installed * Add optional `Psr\Clock\ClockInterface` parameter to `ArrayAdapter` - * Add `TagAwareCacheInterface` to `PdoAdapter` + * Add `PdoTagAwareAdapter` 7.1 --- From 293e8656833a0de5ad37ea0941348074dd150962 Mon Sep 17 00:00:00 2001 From: Yanick Witschi Date: Wed, 18 Sep 2024 18:31:19 +0200 Subject: [PATCH 10/18] Cleanup --- src/Symfony/Component/Cache/LockRegistry.php | 1 + src/Symfony/Component/Cache/Traits/PdoTrait.php | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Symfony/Component/Cache/LockRegistry.php b/src/Symfony/Component/Cache/LockRegistry.php index 6923b40b3465..a39a400b35c4 100644 --- a/src/Symfony/Component/Cache/LockRegistry.php +++ b/src/Symfony/Component/Cache/LockRegistry.php @@ -50,6 +50,7 @@ final class LockRegistry __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'NullAdapter.php', __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'ParameterNormalizer.php', __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'PdoAdapter.php', + __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'PdoTagAwareAdapter.php', __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'PhpArrayAdapter.php', __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'PhpFilesAdapter.php', __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'ProxyAdapter.php', diff --git a/src/Symfony/Component/Cache/Traits/PdoTrait.php b/src/Symfony/Component/Cache/Traits/PdoTrait.php index 9e5f00781de2..6e8dcafb9983 100644 --- a/src/Symfony/Component/Cache/Traits/PdoTrait.php +++ b/src/Symfony/Component/Cache/Traits/PdoTrait.php @@ -40,7 +40,7 @@ trait PdoTrait private array $connectionOptions = []; private string $namespace; - private function init(#[\SensitiveParameter] \PDO|string $connOrDsn, string $namespace = '', int $defaultLifetime = 0, array $options = [], ?MarshallerInterface $marshaller = null) + private function init(#[\SensitiveParameter] \PDO|string $connOrDsn, string $namespace = '', int $defaultLifetime = 0, array $options = [], ?MarshallerInterface $marshaller = null): void { if (\is_string($connOrDsn) && str_contains($connOrDsn, '://')) { throw new InvalidArgumentException(\sprintf('Usage of Doctrine DBAL URL with "%s" is not supported. Use a PDO DSN or "%s" instead.', __CLASS__, DoctrineDbalAdapter::class)); From 4628c49caea896a227c1ac845b3746c88af72554 Mon Sep 17 00:00:00 2001 From: Yanick Witschi Date: Wed, 18 Sep 2024 18:37:51 +0200 Subject: [PATCH 11/18] Cleanup --- src/Symfony/Component/Cache/Traits/PdoTrait.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Symfony/Component/Cache/Traits/PdoTrait.php b/src/Symfony/Component/Cache/Traits/PdoTrait.php index 6e8dcafb9983..dcdcac2c477f 100644 --- a/src/Symfony/Component/Cache/Traits/PdoTrait.php +++ b/src/Symfony/Component/Cache/Traits/PdoTrait.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Cache\Traits; use Symfony\Component\Cache\Adapter\DoctrineDbalAdapter; +use Symfony\Component\Cache\Exception\InvalidArgumentException; use Symfony\Component\Cache\Marshaller\DefaultMarshaller; use Symfony\Component\Cache\Marshaller\MarshallerInterface; @@ -246,7 +247,7 @@ protected function doSave(array $values, int $lifetime): array|bool foreach ($values as $id => $data) { $this->executeStatementWithFallback($stmt, function () { - $this->createTagsTable(); + $this->createTable(); }); if (null === $driver && !$stmt->rowCount()) { From 2c2663ab94a1775133e364f025af4cd5a643bdd1 Mon Sep 17 00:00:00 2001 From: Yanick Witschi Date: Thu, 19 Sep 2024 14:23:37 +0200 Subject: [PATCH 12/18] Cleanup --- src/Symfony/Component/Cache/Adapter/PdoAdapter.php | 5 ----- src/Symfony/Component/Cache/Adapter/PdoTagAwareAdapter.php | 3 ++- src/Symfony/Component/Cache/Traits/PdoTrait.php | 2 +- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/Symfony/Component/Cache/Adapter/PdoAdapter.php b/src/Symfony/Component/Cache/Adapter/PdoAdapter.php index 956e32b09a55..e967067008a3 100644 --- a/src/Symfony/Component/Cache/Adapter/PdoAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/PdoAdapter.php @@ -45,9 +45,4 @@ public function __construct(#[\SensitiveParameter] \PDO|string $connOrDsn, strin { $this->init($connOrDsn, $namespace, $defaultLifetime, $options, $marshaller); } - - public function prune(): bool - { - return $this->pruneExpiredItems(); - } } diff --git a/src/Symfony/Component/Cache/Adapter/PdoTagAwareAdapter.php b/src/Symfony/Component/Cache/Adapter/PdoTagAwareAdapter.php index 16df486e8eff..7b3cf5ac7b5a 100644 --- a/src/Symfony/Component/Cache/Adapter/PdoTagAwareAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/PdoTagAwareAdapter.php @@ -20,6 +20,7 @@ class PdoTagAwareAdapter extends AbstractTagAwareAdapter implements PruneableInt { use PdoTrait { doSave as public doSaveItem; + prune as public pruneItems; } /** @@ -89,7 +90,7 @@ private function createTables(): void public function prune(): bool { - return $this->pruneExpiredItems() && $this->pruneOrphanedTags(); + return $this->pruneItems() && $this->pruneOrphanedTags(); } protected function doSave(array $values, int $lifetime, array $addTagData = [], array $removeTagData = []): array diff --git a/src/Symfony/Component/Cache/Traits/PdoTrait.php b/src/Symfony/Component/Cache/Traits/PdoTrait.php index dcdcac2c477f..312e8cb028d1 100644 --- a/src/Symfony/Component/Cache/Traits/PdoTrait.php +++ b/src/Symfony/Component/Cache/Traits/PdoTrait.php @@ -348,7 +348,7 @@ private function isTableMissing(\PDOException $exception): bool }; } - private function pruneExpiredItems(): bool + public function prune(): bool { $deleteSql = "DELETE FROM $this->table WHERE $this->lifetimeCol + $this->timeCol <= :time"; From b67a05ade69191c2379e94b2da8a096ae016686e Mon Sep 17 00:00:00 2001 From: Yanick Witschi Date: Thu, 19 Sep 2024 16:35:36 +0200 Subject: [PATCH 13/18] Cleanup --- .../Component/Cache/Adapter/AbstractTagAwareAdapter.php | 8 ++++---- .../Component/Cache/Adapter/PdoTagAwareAdapter.php | 8 ++++++++ src/Symfony/Component/Cache/Traits/PdoTrait.php | 4 ++-- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/Symfony/Component/Cache/Adapter/AbstractTagAwareAdapter.php b/src/Symfony/Component/Cache/Adapter/AbstractTagAwareAdapter.php index 822c30f09bdb..9b767f9654d4 100644 --- a/src/Symfony/Component/Cache/Adapter/AbstractTagAwareAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/AbstractTagAwareAdapter.php @@ -35,7 +35,7 @@ abstract class AbstractTagAwareAdapter implements TagAwareAdapterInterface, TagA use AbstractAdapterTrait; use ContractsTrait; - private const TAGS_PREFIX = "\1tags\1"; + protected const TAGS_PREFIX = "\1tags\1"; protected function __construct(string $namespace = '', int $defaultLifetime = 0) { @@ -168,7 +168,7 @@ protected function doDeleteYieldTags(array $ids): iterable public function commit(): bool { $ok = true; - $byLifetime = (self::$mergeByLifetime)($this->deferred, $expiredIds, $this->getId(...), self::TAGS_PREFIX, $this->defaultLifetime); + $byLifetime = (self::$mergeByLifetime)($this->deferred, $expiredIds, $this->getId(...), static::TAGS_PREFIX, $this->defaultLifetime); $retry = $this->deferred = []; if ($expiredIds) { @@ -244,7 +244,7 @@ public function deleteItems(array $keys): bool try { foreach ($this->doDeleteYieldTags(array_values($ids)) as $id => $tags) { foreach ($tags as $tag) { - $tagData[$this->getId(self::TAGS_PREFIX.$tag)][] = $id; + $tagData[$this->getId(static::TAGS_PREFIX.$tag)][] = $id; } } } catch (\Exception) { @@ -283,7 +283,7 @@ public function invalidateTags(array $tags): bool $tagIds = []; foreach (array_unique($tags) as $tag) { - $tagIds[] = $this->getId(self::TAGS_PREFIX.$tag); + $tagIds[] = $this->getId(static::TAGS_PREFIX.$tag); } try { diff --git a/src/Symfony/Component/Cache/Adapter/PdoTagAwareAdapter.php b/src/Symfony/Component/Cache/Adapter/PdoTagAwareAdapter.php index 7b3cf5ac7b5a..918dc0214a03 100644 --- a/src/Symfony/Component/Cache/Adapter/PdoTagAwareAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/PdoTagAwareAdapter.php @@ -16,6 +16,9 @@ use Symfony\Component\Cache\PruneableInterface; use Symfony\Component\Cache\Traits\PdoTrait; +/** + * @author Yanick Witschi + */ class PdoTagAwareAdapter extends AbstractTagAwareAdapter implements PruneableInterface { use PdoTrait { @@ -23,6 +26,11 @@ class PdoTagAwareAdapter extends AbstractTagAwareAdapter implements PruneableInt prune as public pruneItems; } + /** + * No need for a prefix here, should improve lookup time. + */ + protected const TAGS_PREFIX = ''; + /** * 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 diff --git a/src/Symfony/Component/Cache/Traits/PdoTrait.php b/src/Symfony/Component/Cache/Traits/PdoTrait.php index 312e8cb028d1..9085a881f142 100644 --- a/src/Symfony/Component/Cache/Traits/PdoTrait.php +++ b/src/Symfony/Component/Cache/Traits/PdoTrait.php @@ -217,7 +217,7 @@ protected function doSave(array $values, int $lifetime): array|bool $lifetime = $lifetime ?: null; $stmt = $this->prepareStatementWithFallback($sql, function () { - $this->createTable(); + $this->createItemsTable(); }); // $id and $data are defined later in the loop. Binding is done by reference, values are read on execution. @@ -247,7 +247,7 @@ protected function doSave(array $values, int $lifetime): array|bool foreach ($values as $id => $data) { $this->executeStatementWithFallback($stmt, function () { - $this->createTable(); + $this->createItemsTable(); }); if (null === $driver && !$stmt->rowCount()) { From a7494f62d5eb55104b0d4b43603deb7b1449bf5c Mon Sep 17 00:00:00 2001 From: Yanick Witschi Date: Thu, 19 Sep 2024 18:15:51 +0200 Subject: [PATCH 14/18] Addressed feedback --- .../Cache/Adapter/PdoTagAwareAdapter.php | 17 +++++++++++------ src/Symfony/Component/Cache/Traits/PdoTrait.php | 2 -- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/Symfony/Component/Cache/Adapter/PdoTagAwareAdapter.php b/src/Symfony/Component/Cache/Adapter/PdoTagAwareAdapter.php index 918dc0214a03..d7e2972b4c76 100644 --- a/src/Symfony/Component/Cache/Adapter/PdoTagAwareAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/PdoTagAwareAdapter.php @@ -22,8 +22,8 @@ class PdoTagAwareAdapter extends AbstractTagAwareAdapter implements PruneableInterface { use PdoTrait { - doSave as public doSaveItem; - prune as public pruneItems; + doSave as private doSaveItem; + prune as private pruneItems; } /** @@ -31,6 +31,11 @@ class PdoTagAwareAdapter extends AbstractTagAwareAdapter implements PruneableInt */ protected const TAGS_PREFIX = ''; + private string $tagsTable = 'cache_tags'; + private string $tagCol = 'item_tag'; + private string $tagIdxName = 'idx_cache_tags_item_tag'; + + /** * 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 @@ -71,10 +76,10 @@ private function createTagsTable(): void // - trailing space removal // - case-insensitivity // - language processing like é == e - 'mysql' => "CREATE TABLE $this->tagsTable ($this->idCol VARBINARY(255) NOT NULL, $this->tagCol VARBINARY(255) NOT NULL, PRIMARY KEY($this->idCol, $this->tagCol), INDEX idx_id_col ($this->idCol), INDEX idx_tag_col ($this->tagCol)) COLLATE utf8mb4_bin, ENGINE = InnoDB", - 'sqlite' => "CREATE TABLE $this->tagsTable ($this->idCol TEXT NOT NULL, $this->tagCol TEXT NOT NULL, PRIMARY KEY ($this->idCol, $this->tagCol));CREATE INDEX idx_id_col ON $this->tagsTable($this->idCol);CREATE INDEX idx_tag_col ON $this->tagsTable($this->tagCol)", - 'pgsql', 'sqlsrv' => "CREATE TABLE $this->tagsTable ($this->idCol VARCHAR(255) NOT NULL, $this->tagCol VARCHAR(255) NOT NULL, PRIMARY KEY($this->idCol, $this->tagCol);CREATE INDEX idx_id_col ON $this->tagsTable($this->idCol);CREATE INDEX idx_tag_col ON $this->tagsTable($this->tagCol)", - 'oci' => "CREATE TABLE $this->tagsTable ($this->idCol VARCHAR2(255) NOT NULL, $this->tagCol VARCHAR2(255) NOT NULL, PRIMARY KEY($this->idCol, $this->tagCol);CREATE INDEX idx_id_col ON $this->tagsTable($this->idCol);CREATE INDEX idx_tag_col ON $this->tagsTable($this->tagCol)", + 'mysql' => "CREATE TABLE $this->tagsTable ($this->idCol VARBINARY(255) NOT NULL, $this->tagCol VARBINARY(255) NOT NULL, PRIMARY KEY($this->idCol, $this->tagCol), INDEX $this->tagIdxName($this->tagCol)) COLLATE utf8mb4_bin, ENGINE = InnoDB", + 'sqlite' => "CREATE TABLE $this->tagsTable ($this->idCol TEXT NOT NULL, $this->tagCol TEXT NOT NULL, PRIMARY KEY ($this->idCol, $this->tagCol));CREATE INDEX $this->tagIdxName ON $this->tagsTable($this->tagCol)", + 'pgsql', 'sqlsrv' => "CREATE TABLE $this->tagsTable ($this->idCol VARCHAR(255) NOT NULL, $this->tagCol VARCHAR(255) NOT NULL, PRIMARY KEY($this->idCol, $this->tagCol);CREATE INDEX $this->tagIdxName ON $this->tagsTable($this->tagCol)", + 'oci' => "CREATE TABLE $this->tagsTable ($this->idCol VARCHAR2(255) NOT NULL, $this->tagCol VARCHAR2(255) NOT NULL, PRIMARY KEY($this->idCol, $this->tagCol);CREATE INDEX $this->tagIdxName ON $this->tagsTable($this->tagCol)", default => throw new \DomainException(\sprintf('Creating the cache table is currently not implemented for PDO driver "%s".', $driver)), }; diff --git a/src/Symfony/Component/Cache/Traits/PdoTrait.php b/src/Symfony/Component/Cache/Traits/PdoTrait.php index 9085a881f142..91f2f201d5f0 100644 --- a/src/Symfony/Component/Cache/Traits/PdoTrait.php +++ b/src/Symfony/Component/Cache/Traits/PdoTrait.php @@ -30,12 +30,10 @@ trait PdoTrait private string $driver; private string $serverVersion; private string $table = 'cache_items'; - private string $tagsTable = 'cache_tags'; private string $idCol = 'item_id'; private string $dataCol = 'item_data'; private string $lifetimeCol = 'item_lifetime'; private string $timeCol = 'item_time'; - private string $tagCol = 'item_tag'; private ?string $username = null; private ?string $password = null; private array $connectionOptions = []; From ce6e16ce454337d401c5c4b5bfc86baaeceab256 Mon Sep 17 00:00:00 2001 From: Yanick Witschi Date: Thu, 19 Sep 2024 18:18:13 +0200 Subject: [PATCH 15/18] Addressed feedback --- src/Symfony/Component/Cache/Adapter/PdoTagAwareAdapter.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Symfony/Component/Cache/Adapter/PdoTagAwareAdapter.php b/src/Symfony/Component/Cache/Adapter/PdoTagAwareAdapter.php index d7e2972b4c76..fab229faf2a8 100644 --- a/src/Symfony/Component/Cache/Adapter/PdoTagAwareAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/PdoTagAwareAdapter.php @@ -198,6 +198,10 @@ protected function doSave(array $values, int $lifetime, array $addTagData = [], protected function doDeleteTagRelations(array $tagData): bool { foreach ($tagData as $tagId => $idList) { + if ([] === $idList) { + continue; + } + $placeholders = implode(',', array_fill(0, \count($idList), '?')); $stmt = $this->prepareStatementWithFallback("DELETE FROM $this->tagsTable WHERE $this->tagCol=:tagId AND $this->idCol IN ($placeholders);", function () { $this->createTagsTable(); From 820cf3313b7726c01d3345aee58fb1a9b84aef72 Mon Sep 17 00:00:00 2001 From: Yanick Witschi Date: Fri, 20 Sep 2024 08:51:15 +0200 Subject: [PATCH 16/18] Apply suggestions from code review Co-authored-by: Oskar Stark --- src/Symfony/Component/Cache/Adapter/PdoTagAwareAdapter.php | 6 +++--- src/Symfony/Component/Cache/Traits/PdoTrait.php | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Symfony/Component/Cache/Adapter/PdoTagAwareAdapter.php b/src/Symfony/Component/Cache/Adapter/PdoTagAwareAdapter.php index fab229faf2a8..9aa2eda0b086 100644 --- a/src/Symfony/Component/Cache/Adapter/PdoTagAwareAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/PdoTagAwareAdapter.php @@ -87,10 +87,10 @@ private function createTagsTable(): void } /** - * Creates the table to store cache items which can be called once for setup. + * Creates the tables 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. + * Cache IDs are saved in a column of maximum length 255. + * Cache data is saved in a BLOB. * * @throws \PDOException When the table already exists * @throws \DomainException When an unsupported PDO driver is used diff --git a/src/Symfony/Component/Cache/Traits/PdoTrait.php b/src/Symfony/Component/Cache/Traits/PdoTrait.php index 91f2f201d5f0..5cf724ec8ef9 100644 --- a/src/Symfony/Component/Cache/Traits/PdoTrait.php +++ b/src/Symfony/Component/Cache/Traits/PdoTrait.php @@ -89,7 +89,7 @@ public static function createConnection(#[\SensitiveParameter] string $dsn, arra /** * 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 + * Cache IDs are saved in a column of maximum length 255. Cache data is * saved in a BLOB. * * @throws \PDOException When the table already exists From 49d24d19472aa808b30db6c6dcb3f5df46ed37aa Mon Sep 17 00:00:00 2001 From: Yanick Witschi Date: Fri, 20 Sep 2024 12:02:03 +0200 Subject: [PATCH 17/18] Make tag table information configurable --- .../Component/Cache/Adapter/PdoTagAwareAdapter.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Symfony/Component/Cache/Adapter/PdoTagAwareAdapter.php b/src/Symfony/Component/Cache/Adapter/PdoTagAwareAdapter.php index 9aa2eda0b086..f518535edc11 100644 --- a/src/Symfony/Component/Cache/Adapter/PdoTagAwareAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/PdoTagAwareAdapter.php @@ -42,7 +42,7 @@ class PdoTagAwareAdapter extends AbstractTagAwareAdapter implements PruneableInt * cache is actually used. * * List of available options: - * * db_table: The name of the table [default: cache_items] + * * db_table: The name of the cache item 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] @@ -50,6 +50,9 @@ class PdoTagAwareAdapter extends AbstractTagAwareAdapter implements PruneableInt * * 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: []] + * * db_tags_table: The name of the tags table [default: cache_tags] + * * db_tags_col: The column where to store the tags [default: item_tag] + * * db_tags_tag_index_name: The index name for the tags column [default: idx_cache_tags_item_tag] * * @throws InvalidArgumentException When first argument is not PDO nor Connection nor string * @throws InvalidArgumentException When PDO error mode is not PDO::ERRMODE_EXCEPTION @@ -57,6 +60,10 @@ class PdoTagAwareAdapter extends AbstractTagAwareAdapter implements PruneableInt */ public function __construct(#[\SensitiveParameter] \PDO|string $connOrDsn, string $namespace = '', int $defaultLifetime = 0, array $options = [], ?MarshallerInterface $marshaller = null) { + $this->tagsTable = $options['db_tags_table'] ?? $this->tagsTable; + $this->tagCol = $options['db_tags_col'] ?? $this->tagCol; + $this->tagIdxName = $options['db_tags_tag_index_name'] ?? $this->tagIdxName; + $this->init($connOrDsn, $namespace, $defaultLifetime, $options, $marshaller); } From 16ed6ec491c3bb167fb6f603aba702f2e957c1c9 Mon Sep 17 00:00:00 2001 From: Yanick Witschi Date: Mon, 25 Nov 2024 19:10:55 +0100 Subject: [PATCH 18/18] CS --- src/Symfony/Component/Cache/Adapter/PdoTagAwareAdapter.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Symfony/Component/Cache/Adapter/PdoTagAwareAdapter.php b/src/Symfony/Component/Cache/Adapter/PdoTagAwareAdapter.php index f518535edc11..221cb1f63fd8 100644 --- a/src/Symfony/Component/Cache/Adapter/PdoTagAwareAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/PdoTagAwareAdapter.php @@ -35,7 +35,6 @@ class PdoTagAwareAdapter extends AbstractTagAwareAdapter implements PruneableInt private string $tagCol = 'item_tag'; private string $tagIdxName = 'idx_cache_tags_item_tag'; - /** * 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