diff --git a/src/Symfony/Component/Cache/Adapter/AbstractTagAwareAdapter.php b/src/Symfony/Component/Cache/Adapter/AbstractTagAwareAdapter.php index 822c30f09bdb..9b767f9654d4 100644 --- a/src/Symfony/Component/Cache/Adapter/AbstractTagAwareAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/AbstractTagAwareAdapter.php @@ -35,7 +35,7 @@ abstract class AbstractTagAwareAdapter implements TagAwareAdapterInterface, TagA use AbstractAdapterTrait; use ContractsTrait; - private const TAGS_PREFIX = "\1tags\1"; + protected const TAGS_PREFIX = "\1tags\1"; protected function __construct(string $namespace = '', int $defaultLifetime = 0) { @@ -168,7 +168,7 @@ protected function doDeleteYieldTags(array $ids): iterable public function commit(): bool { $ok = true; - $byLifetime = (self::$mergeByLifetime)($this->deferred, $expiredIds, $this->getId(...), self::TAGS_PREFIX, $this->defaultLifetime); + $byLifetime = (self::$mergeByLifetime)($this->deferred, $expiredIds, $this->getId(...), static::TAGS_PREFIX, $this->defaultLifetime); $retry = $this->deferred = []; if ($expiredIds) { @@ -244,7 +244,7 @@ public function deleteItems(array $keys): bool try { foreach ($this->doDeleteYieldTags(array_values($ids)) as $id => $tags) { foreach ($tags as $tag) { - $tagData[$this->getId(self::TAGS_PREFIX.$tag)][] = $id; + $tagData[$this->getId(static::TAGS_PREFIX.$tag)][] = $id; } } } catch (\Exception) { @@ -283,7 +283,7 @@ public function invalidateTags(array $tags): bool $tagIds = []; foreach (array_unique($tags) as $tag) { - $tagIds[] = $this->getId(self::TAGS_PREFIX.$tag); + $tagIds[] = $this->getId(static::TAGS_PREFIX.$tag); } try { diff --git a/src/Symfony/Component/Cache/Adapter/PdoAdapter.php b/src/Symfony/Component/Cache/Adapter/PdoAdapter.php index 525e2c6db602..e967067008a3 100644 --- a/src/Symfony/Component/Cache/Adapter/PdoAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/PdoAdapter.php @@ -12,28 +12,15 @@ 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 PdoAdapter extends AbstractAdapter implements PruneableInterface { - private const MAX_KEY_LENGTH = 255; - - private MarshallerInterface $marshaller; - private \PDO $conn; - private string $dsn; - private string $driver; - private string $serverVersion; - private string $table = 'cache_items'; - private string $idCol = 'item_id'; - private string $dataCol = 'item_data'; - private string $lifetimeCol = 'item_lifetime'; - private string $timeCol = 'item_time'; - 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 @@ -56,343 +43,6 @@ class PdoAdapter extends AbstractAdapter 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); - } - - public function prune(): 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|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; - 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); - } - - // $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) { - try { - $stmt->execute(); - } catch (\PDOException $e) { - if ($this->isTableMissing($e) && (!$conn->inTransaction() || \in_array($driver, ['pgsql', 'sqlite', 'sqlsrv'], true))) { - $this->createTable(); - } - $stmt->execute(); - } - 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, - }; + $this->init($connOrDsn, $namespace, $defaultLifetime, $options, $marshaller); } } diff --git a/src/Symfony/Component/Cache/Adapter/PdoTagAwareAdapter.php b/src/Symfony/Component/Cache/Adapter/PdoTagAwareAdapter.php new file mode 100644 index 000000000000..221cb1f63fd8 --- /dev/null +++ b/src/Symfony/Component/Cache/Adapter/PdoTagAwareAdapter.php @@ -0,0 +1,254 @@ + + * + * 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\MarshallerInterface; +use Symfony\Component\Cache\PruneableInterface; +use Symfony\Component\Cache\Traits\PdoTrait; + +/** + * @author Yanick Witschi + */ +class PdoTagAwareAdapter extends AbstractTagAwareAdapter implements PruneableInterface +{ + use PdoTrait { + doSave as private doSaveItem; + prune as private pruneItems; + } + + /** + * No need for a prefix here, should improve lookup time. + */ + 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 + * cache is actually used. + * + * List of available options: + * * 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] + * * 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: []] + * * 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 + * @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->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); + } + + /** + * 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 $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)), + }; + + $this->getConnection()->exec($sql); + } + + /** + * Creates the tables to store cache items which can be called once for setup. + * + * 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 + */ + private function createTables(): void + { + $this->createItemsTable(); + $this->createTagsTable(); + } + + public function prune(): bool + { + return $this->pruneItems() && $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) { + 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(); + }); + + $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/CHANGELOG.md b/src/Symfony/Component/Cache/CHANGELOG.md index 7f7cfa42dbe4..b6caf2e6a6ab 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 `PdoTagAwareAdapter` 7.1 --- diff --git a/src/Symfony/Component/Cache/LockRegistry.php b/src/Symfony/Component/Cache/LockRegistry.php index 6923b40b3465..a39a400b35c4 100644 --- a/src/Symfony/Component/Cache/LockRegistry.php +++ b/src/Symfony/Component/Cache/LockRegistry.php @@ -50,6 +50,7 @@ final class LockRegistry __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'NullAdapter.php', __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'ParameterNormalizer.php', __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'PdoAdapter.php', + __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'PdoTagAwareAdapter.php', __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'PhpArrayAdapter.php', __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'PhpFilesAdapter.php', __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'ProxyAdapter.php', diff --git a/src/Symfony/Component/Cache/Tests/Adapter/AbstractPdoAdapterTest.php b/src/Symfony/Component/Cache/Tests/Adapter/AbstractPdoAdapterTest.php new file mode 100644 index 000000000000..bfacf9a24abd --- /dev/null +++ b/src/Symfony/Component/Cache/Tests/Adapter/AbstractPdoAdapterTest.php @@ -0,0 +1,140 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Tests\Adapter; + +use Psr\Cache\CacheItemPoolInterface; +use Symfony\Component\Cache\Adapter\AbstractAdapter; +use Symfony\Component\Cache\Adapter\PdoAdapter; +use Symfony\Component\Cache\Adapter\PdoTagAwareAdapter; + +/** + * @requires extension pdo_sqlite + * + * @group time-sensitive + */ +abstract class AbstractPdoAdapterTest extends AdapterTestCase +{ + protected static string $dbFile; + + public static function setUpBeforeClass(): void + { + self::$dbFile = tempnam(sys_get_temp_dir(), 'sf_sqlite_cache'); + + $pool = new PdoAdapter('sqlite:'.self::$dbFile); + $pool->createTable(); + } + + public static function tearDownAfterClass(): void + { + @unlink(self::$dbFile); + } + + abstract public function createCachePool(int $defaultLifetime = 0): CacheItemPoolInterface; + + public function testCreateConnectionReturnsStringWithLazyTrue() + { + self::assertSame('sqlite:'.self::$dbFile, AbstractAdapter::createConnection('sqlite:'.self::$dbFile)); + } + + public function testCreateConnectionReturnsPDOWithLazyFalse() + { + self::assertInstanceOf(\PDO::class, AbstractAdapter::createConnection('sqlite:'.self::$dbFile, ['lazy' => false])); + } + + public function testCleanupExpiredItems() + { + $pdo = new \PDO('sqlite:'.self::$dbFile); + + $getCacheItemCount = fn () => (int) $pdo->query('SELECT COUNT(*) FROM cache_items')->fetch(\PDO::FETCH_COLUMN); + + $this->assertSame(0, $getCacheItemCount()); + + $cache = $this->createCachePool(); + + $item = $cache->getItem('some_nice_key'); + $item->expiresAfter(1); + $item->set(1); + + $cache->save($item); + $this->assertSame(1, $getCacheItemCount()); + + sleep(2); + + $newItem = $cache->getItem($item->getKey()); + $this->assertFalse($newItem->isHit()); + $this->assertSame(0, $getCacheItemCount(), 'PDOAdapter must clean up expired items'); + } + + /** + * @dataProvider provideDsnSQLite + */ + public function testDsnWithSQLite(string $dsn, ?string $file = null) + { + try { + $pool = new PdoAdapter($dsn); + + $item = $pool->getItem('key'); + $item->set('value'); + $this->assertTrue($pool->save($item)); + } finally { + if (null !== $file) { + @unlink($file); + } + } + } + + public static function provideDsnSQLite() + { + $dbFile = tempnam(sys_get_temp_dir(), 'sf_sqlite_cache'); + yield 'SQLite file' => ['sqlite:'.$dbFile.'2', $dbFile.'2']; + yield 'SQLite in memory' => ['sqlite::memory:']; + } + + /** + * @requires extension pdo_pgsql + * + * @group integration + */ + public function testDsnWithPostgreSQL() + { + if (!$host = getenv('POSTGRES_HOST')) { + $this->markTestSkipped('Missing POSTGRES_HOST env variable'); + } + + $dsn = 'pgsql:host='.$host.';user=postgres;password=password'; + + try { + $pool = new PdoAdapter($dsn); + + $item = $pool->getItem('key'); + $item->set('value'); + $this->assertTrue($pool->save($item)); + } finally { + $pdo = new \PDO($dsn); + $pdo->exec('DROP TABLE IF EXISTS cache_items'); + } + } + + protected function isPruned(PdoAdapter|PdoTagAwareAdapter $cache, string $name): bool + { + $o = new \ReflectionObject($cache); + + $getPdoConn = $o->getMethod('getConnection'); + + /** @var \PDOStatement $select */ + $select = $getPdoConn->invoke($cache)->prepare('SELECT 1 FROM cache_items WHERE item_id LIKE :id'); + $select->bindValue(':id', \sprintf('%%%s', $name)); + $select->execute(); + + return 1 !== (int) $select->fetch(\PDO::FETCH_COLUMN); + } +} diff --git a/src/Symfony/Component/Cache/Tests/Adapter/PdoAdapterTest.php b/src/Symfony/Component/Cache/Tests/Adapter/PdoAdapterTest.php index 49f3da83fe23..335fb89a912f 100644 --- a/src/Symfony/Component/Cache/Tests/Adapter/PdoAdapterTest.php +++ b/src/Symfony/Component/Cache/Tests/Adapter/PdoAdapterTest.php @@ -12,7 +12,6 @@ namespace Symfony\Component\Cache\Tests\Adapter; use Psr\Cache\CacheItemPoolInterface; -use Symfony\Component\Cache\Adapter\AbstractAdapter; use Symfony\Component\Cache\Adapter\PdoAdapter; /** @@ -20,123 +19,10 @@ * * @group time-sensitive */ -class PdoAdapterTest extends AdapterTestCase +class PdoAdapterTest extends AbstractPdoAdapterTest { - protected static string $dbFile; - - public static function setUpBeforeClass(): void - { - self::$dbFile = tempnam(sys_get_temp_dir(), 'sf_sqlite_cache'); - - $pool = new PdoAdapter('sqlite:'.self::$dbFile); - $pool->createTable(); - } - - public static function tearDownAfterClass(): void - { - @unlink(self::$dbFile); - } - public function createCachePool(int $defaultLifetime = 0): CacheItemPoolInterface { return new PdoAdapter('sqlite:'.self::$dbFile, 'ns', $defaultLifetime); } - - public function testCreateConnectionReturnsStringWithLazyTrue() - { - self::assertSame('sqlite:'.self::$dbFile, AbstractAdapter::createConnection('sqlite:'.self::$dbFile)); - } - - public function testCreateConnectionReturnsPDOWithLazyFalse() - { - self::assertInstanceOf(\PDO::class, AbstractAdapter::createConnection('sqlite:'.self::$dbFile, ['lazy' => false])); - } - - public function testCleanupExpiredItems() - { - $pdo = new \PDO('sqlite:'.self::$dbFile); - - $getCacheItemCount = fn () => (int) $pdo->query('SELECT COUNT(*) FROM cache_items')->fetch(\PDO::FETCH_COLUMN); - - $this->assertSame(0, $getCacheItemCount()); - - $cache = $this->createCachePool(); - - $item = $cache->getItem('some_nice_key'); - $item->expiresAfter(1); - $item->set(1); - - $cache->save($item); - $this->assertSame(1, $getCacheItemCount()); - - sleep(2); - - $newItem = $cache->getItem($item->getKey()); - $this->assertFalse($newItem->isHit()); - $this->assertSame(0, $getCacheItemCount(), 'PDOAdapter must clean up expired items'); - } - - /** - * @dataProvider provideDsnSQLite - */ - public function testDsnWithSQLite(string $dsn, ?string $file = null) - { - try { - $pool = new PdoAdapter($dsn); - - $item = $pool->getItem('key'); - $item->set('value'); - $this->assertTrue($pool->save($item)); - } finally { - if (null !== $file) { - @unlink($file); - } - } - } - - public static function provideDsnSQLite() - { - $dbFile = tempnam(sys_get_temp_dir(), 'sf_sqlite_cache'); - yield 'SQLite file' => ['sqlite:'.$dbFile.'2', $dbFile.'2']; - yield 'SQLite in memory' => ['sqlite::memory:']; - } - - /** - * @requires extension pdo_pgsql - * - * @group integration - */ - public function testDsnWithPostgreSQL() - { - if (!$host = getenv('POSTGRES_HOST')) { - $this->markTestSkipped('Missing POSTGRES_HOST env variable'); - } - - $dsn = 'pgsql:host='.$host.';user=postgres;password=password'; - - try { - $pool = new PdoAdapter($dsn); - - $item = $pool->getItem('key'); - $item->set('value'); - $this->assertTrue($pool->save($item)); - } finally { - $pdo = new \PDO($dsn); - $pdo->exec('DROP TABLE IF EXISTS cache_items'); - } - } - - protected function isPruned(PdoAdapter $cache, string $name): bool - { - $o = new \ReflectionObject($cache); - - $getPdoConn = $o->getMethod('getConnection'); - - /** @var \PDOStatement $select */ - $select = $getPdoConn->invoke($cache)->prepare('SELECT 1 FROM cache_items WHERE item_id LIKE :id'); - $select->bindValue(':id', \sprintf('%%%s', $name)); - $select->execute(); - - return 1 !== (int) $select->fetch(\PDO::FETCH_COLUMN); - } } diff --git a/src/Symfony/Component/Cache/Tests/Adapter/PdoTagAwareAdapterTest.php b/src/Symfony/Component/Cache/Tests/Adapter/PdoTagAwareAdapterTest.php new file mode 100644 index 000000000000..10dee1ddc2a7 --- /dev/null +++ b/src/Symfony/Component/Cache/Tests/Adapter/PdoTagAwareAdapterTest.php @@ -0,0 +1,30 @@ + + * + * 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\PdoTagAwareAdapter; + +/** + * @requires extension pdo_sqlite + * + * @group time-sensitive + */ +class PdoTagAwareAdapterTest extends AbstractPdoAdapterTest +{ + use TagAwareTestTrait; + + public function createCachePool(int $defaultLifetime = 0): CacheItemPoolInterface + { + return new PdoTagAwareAdapter('sqlite:'.self::$dbFile, 'ns', $defaultLifetime); + } +} diff --git a/src/Symfony/Component/Cache/Traits/PdoTrait.php b/src/Symfony/Component/Cache/Traits/PdoTrait.php new file mode 100644 index 000000000000..5cf724ec8ef9 --- /dev/null +++ b/src/Symfony/Component/Cache/Traits/PdoTrait.php @@ -0,0 +1,407 @@ + + * + * 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\Exception\InvalidArgumentException; +use Symfony\Component\Cache\Marshaller\DefaultMarshaller; +use Symfony\Component\Cache\Marshaller\MarshallerInterface; + +/** + * @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 $idCol = 'item_id'; + private string $dataCol = 'item_data'; + private string $lifetimeCol = 'item_lifetime'; + private string $timeCol = 'item_time'; + 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): 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)); + } + + 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 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 + */ + 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->createItemsTable(); + }); + + // $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->createItemsTable(); + }); + + 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, + }; + } + + public function prune(): 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(); + } + } +} 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