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 525e2c6db6020..fc7bc89414ed6 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 49f3da83fe234..3be980544a096 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 7f7cfa42dbe45..b5a5099b660d6 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 fc7bc89414ed6..9bcf499b9f907 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 9bcf499b9f907..556b1c85d3db0 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 556b1c85d3db0..22d6870cb69c9 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 22d6870cb69c9..b032145b4c0a1 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 b032145b4c0a1..caeb370abeef8 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 0000000000000..7ada2c364e8a7 --- /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 0000000000000..bfacf9a24abda --- /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 3be980544a096..32ef5808180d6 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 0000000000000..7f621785de557 --- /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 0000000000000..57fbc74a5ef72 --- /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 caeb370abeef8..956e32b09a55e 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 7ada2c364e8a7..16df486e8eff9 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 32ef5808180d6..335fb89a912fc 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 7f621785de557..10dee1ddc2a7b 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 57fbc74a5ef72..9e5f00781de2c 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 b5a5099b660d6..b6caf2e6a6abb 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 6923b40b3465d..a39a400b35c4b 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 9e5f00781de2c..6e8dcafb9983d 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 6e8dcafb9983d..dcdcac2c477f5 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 956e32b09a55e..e967067008a35 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 16df486e8eff9..7b3cf5ac7b5a4 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 dcdcac2c477f5..312e8cb028d10 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 822c30f09bdbd..9b767f9654d4a 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 7b3cf5ac7b5a4..918dc0214a03a 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 312e8cb028d10..9085a881f142b 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 918dc0214a03a..d7e2972b4c767 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 9085a881f142b..91f2f201d5f0f 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 d7e2972b4c767..fab229faf2a80 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 fab229faf2a80..9aa2eda0b0860 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 91f2f201d5f0f..5cf724ec8ef97 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 9aa2eda0b0860..f518535edc11d 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 f518535edc11d..221cb1f63fd84 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 pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy