diff options
Diffstat (limited to 'vendor/symfony/cache/Adapter/PdoAdapter.php')
-rw-r--r-- | vendor/symfony/cache/Adapter/PdoAdapter.php | 398 |
1 files changed, 398 insertions, 0 deletions
diff --git a/vendor/symfony/cache/Adapter/PdoAdapter.php b/vendor/symfony/cache/Adapter/PdoAdapter.php new file mode 100644 index 0000000..b18428d --- /dev/null +++ b/vendor/symfony/cache/Adapter/PdoAdapter.php | |||
@@ -0,0 +1,398 @@ | |||
1 | <?php | ||
2 | |||
3 | /* | ||
4 | * This file is part of the Symfony package. | ||
5 | * | ||
6 | * (c) Fabien Potencier <fabien@symfony.com> | ||
7 | * | ||
8 | * For the full copyright and license information, please view the LICENSE | ||
9 | * file that was distributed with this source code. | ||
10 | */ | ||
11 | |||
12 | namespace Symfony\Component\Cache\Adapter; | ||
13 | |||
14 | use Symfony\Component\Cache\Exception\InvalidArgumentException; | ||
15 | use Symfony\Component\Cache\Marshaller\DefaultMarshaller; | ||
16 | use Symfony\Component\Cache\Marshaller\MarshallerInterface; | ||
17 | use Symfony\Component\Cache\PruneableInterface; | ||
18 | |||
19 | class PdoAdapter extends AbstractAdapter implements PruneableInterface | ||
20 | { | ||
21 | private const MAX_KEY_LENGTH = 255; | ||
22 | |||
23 | private MarshallerInterface $marshaller; | ||
24 | private \PDO $conn; | ||
25 | private string $dsn; | ||
26 | private string $driver; | ||
27 | private string $serverVersion; | ||
28 | private string $table = 'cache_items'; | ||
29 | private string $idCol = 'item_id'; | ||
30 | private string $dataCol = 'item_data'; | ||
31 | private string $lifetimeCol = 'item_lifetime'; | ||
32 | private string $timeCol = 'item_time'; | ||
33 | private ?string $username = null; | ||
34 | private ?string $password = null; | ||
35 | private array $connectionOptions = []; | ||
36 | private string $namespace; | ||
37 | |||
38 | /** | ||
39 | * You can either pass an existing database connection as PDO instance or | ||
40 | * a DSN string that will be used to lazy-connect to the database when the | ||
41 | * cache is actually used. | ||
42 | * | ||
43 | * List of available options: | ||
44 | * * db_table: The name of the table [default: cache_items] | ||
45 | * * db_id_col: The column where to store the cache id [default: item_id] | ||
46 | * * db_data_col: The column where to store the cache data [default: item_data] | ||
47 | * * db_lifetime_col: The column where to store the lifetime [default: item_lifetime] | ||
48 | * * db_time_col: The column where to store the timestamp [default: item_time] | ||
49 | * * db_username: The username when lazy-connect [default: ''] | ||
50 | * * db_password: The password when lazy-connect [default: ''] | ||
51 | * * db_connection_options: An array of driver-specific connection options [default: []] | ||
52 | * | ||
53 | * @throws InvalidArgumentException When first argument is not PDO nor Connection nor string | ||
54 | * @throws InvalidArgumentException When PDO error mode is not PDO::ERRMODE_EXCEPTION | ||
55 | * @throws InvalidArgumentException When namespace contains invalid characters | ||
56 | */ | ||
57 | public function __construct(#[\SensitiveParameter] \PDO|string $connOrDsn, string $namespace = '', int $defaultLifetime = 0, array $options = [], ?MarshallerInterface $marshaller = null) | ||
58 | { | ||
59 | if (\is_string($connOrDsn) && str_contains($connOrDsn, '://')) { | ||
60 | throw new InvalidArgumentException(sprintf('Usage of Doctrine DBAL URL with "%s" is not supported. Use a PDO DSN or "%s" instead.', __CLASS__, DoctrineDbalAdapter::class)); | ||
61 | } | ||
62 | |||
63 | if (isset($namespace[0]) && preg_match('#[^-+.A-Za-z0-9]#', $namespace, $match)) { | ||
64 | throw new InvalidArgumentException(sprintf('Namespace contains "%s" but only characters in [-+.A-Za-z0-9] are allowed.', $match[0])); | ||
65 | } | ||
66 | |||
67 | if ($connOrDsn instanceof \PDO) { | ||
68 | if (\PDO::ERRMODE_EXCEPTION !== $connOrDsn->getAttribute(\PDO::ATTR_ERRMODE)) { | ||
69 | 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__)); | ||
70 | } | ||
71 | |||
72 | $this->conn = $connOrDsn; | ||
73 | } else { | ||
74 | $this->dsn = $connOrDsn; | ||
75 | } | ||
76 | |||
77 | $this->maxIdLength = self::MAX_KEY_LENGTH; | ||
78 | $this->table = $options['db_table'] ?? $this->table; | ||
79 | $this->idCol = $options['db_id_col'] ?? $this->idCol; | ||
80 | $this->dataCol = $options['db_data_col'] ?? $this->dataCol; | ||
81 | $this->lifetimeCol = $options['db_lifetime_col'] ?? $this->lifetimeCol; | ||
82 | $this->timeCol = $options['db_time_col'] ?? $this->timeCol; | ||
83 | $this->username = $options['db_username'] ?? $this->username; | ||
84 | $this->password = $options['db_password'] ?? $this->password; | ||
85 | $this->connectionOptions = $options['db_connection_options'] ?? $this->connectionOptions; | ||
86 | $this->namespace = $namespace; | ||
87 | $this->marshaller = $marshaller ?? new DefaultMarshaller(); | ||
88 | |||
89 | parent::__construct($namespace, $defaultLifetime); | ||
90 | } | ||
91 | |||
92 | public static function createConnection(#[\SensitiveParameter] string $dsn, array $options = []): \PDO|string | ||
93 | { | ||
94 | if ($options['lazy'] ?? true) { | ||
95 | return $dsn; | ||
96 | } | ||
97 | |||
98 | $pdo = new \PDO($dsn); | ||
99 | $pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); | ||
100 | |||
101 | return $pdo; | ||
102 | } | ||
103 | |||
104 | /** | ||
105 | * Creates the table to store cache items which can be called once for setup. | ||
106 | * | ||
107 | * Cache ID are saved in a column of maximum length 255. Cache data is | ||
108 | * saved in a BLOB. | ||
109 | * | ||
110 | * @throws \PDOException When the table already exists | ||
111 | * @throws \DomainException When an unsupported PDO driver is used | ||
112 | */ | ||
113 | public function createTable(): void | ||
114 | { | ||
115 | $sql = match ($driver = $this->getDriver()) { | ||
116 | // We use varbinary for the ID column because it prevents unwanted conversions: | ||
117 | // - character set conversions between server and client | ||
118 | // - trailing space removal | ||
119 | // - case-insensitivity | ||
120 | // - language processing like é == e | ||
121 | '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", | ||
122 | '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)", | ||
123 | '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)", | ||
124 | '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)", | ||
125 | '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)", | ||
126 | default => throw new \DomainException(sprintf('Creating the cache table is currently not implemented for PDO driver "%s".', $driver)), | ||
127 | }; | ||
128 | |||
129 | $this->getConnection()->exec($sql); | ||
130 | } | ||
131 | |||
132 | public function prune(): bool | ||
133 | { | ||
134 | $deleteSql = "DELETE FROM $this->table WHERE $this->lifetimeCol + $this->timeCol <= :time"; | ||
135 | |||
136 | if ('' !== $this->namespace) { | ||
137 | $deleteSql .= " AND $this->idCol LIKE :namespace"; | ||
138 | } | ||
139 | |||
140 | $connection = $this->getConnection(); | ||
141 | |||
142 | try { | ||
143 | $delete = $connection->prepare($deleteSql); | ||
144 | } catch (\PDOException) { | ||
145 | return true; | ||
146 | } | ||
147 | $delete->bindValue(':time', time(), \PDO::PARAM_INT); | ||
148 | |||
149 | if ('' !== $this->namespace) { | ||
150 | $delete->bindValue(':namespace', sprintf('%s%%', $this->namespace), \PDO::PARAM_STR); | ||
151 | } | ||
152 | try { | ||
153 | return $delete->execute(); | ||
154 | } catch (\PDOException) { | ||
155 | return true; | ||
156 | } | ||
157 | } | ||
158 | |||
159 | protected function doFetch(array $ids): iterable | ||
160 | { | ||
161 | $connection = $this->getConnection(); | ||
162 | |||
163 | $now = time(); | ||
164 | $expired = []; | ||
165 | |||
166 | $sql = str_pad('', (\count($ids) << 1) - 1, '?,'); | ||
167 | $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)"; | ||
168 | $stmt = $connection->prepare($sql); | ||
169 | $stmt->bindValue($i = 1, $now, \PDO::PARAM_INT); | ||
170 | foreach ($ids as $id) { | ||
171 | $stmt->bindValue(++$i, $id); | ||
172 | } | ||
173 | $result = $stmt->execute(); | ||
174 | |||
175 | if (\is_object($result)) { | ||
176 | $result = $result->iterateNumeric(); | ||
177 | } else { | ||
178 | $stmt->setFetchMode(\PDO::FETCH_NUM); | ||
179 | $result = $stmt; | ||
180 | } | ||
181 | |||
182 | foreach ($result as $row) { | ||
183 | if (null === $row[1]) { | ||
184 | $expired[] = $row[0]; | ||
185 | } else { | ||
186 | yield $row[0] => $this->marshaller->unmarshall(\is_resource($row[1]) ? stream_get_contents($row[1]) : $row[1]); | ||
187 | } | ||
188 | } | ||
189 | |||
190 | if ($expired) { | ||
191 | $sql = str_pad('', (\count($expired) << 1) - 1, '?,'); | ||
192 | $sql = "DELETE FROM $this->table WHERE $this->lifetimeCol + $this->timeCol <= ? AND $this->idCol IN ($sql)"; | ||
193 | $stmt = $connection->prepare($sql); | ||
194 | $stmt->bindValue($i = 1, $now, \PDO::PARAM_INT); | ||
195 | foreach ($expired as $id) { | ||
196 | $stmt->bindValue(++$i, $id); | ||
197 | } | ||
198 | $stmt->execute(); | ||
199 | } | ||
200 | } | ||
201 | |||
202 | protected function doHave(string $id): bool | ||
203 | { | ||
204 | $connection = $this->getConnection(); | ||
205 | |||
206 | $sql = "SELECT 1 FROM $this->table WHERE $this->idCol = :id AND ($this->lifetimeCol IS NULL OR $this->lifetimeCol + $this->timeCol > :time)"; | ||
207 | $stmt = $connection->prepare($sql); | ||
208 | |||
209 | $stmt->bindValue(':id', $id); | ||
210 | $stmt->bindValue(':time', time(), \PDO::PARAM_INT); | ||
211 | $stmt->execute(); | ||
212 | |||
213 | return (bool) $stmt->fetchColumn(); | ||
214 | } | ||
215 | |||
216 | protected function doClear(string $namespace): bool | ||
217 | { | ||
218 | $conn = $this->getConnection(); | ||
219 | |||
220 | if ('' === $namespace) { | ||
221 | if ('sqlite' === $this->getDriver()) { | ||
222 | $sql = "DELETE FROM $this->table"; | ||
223 | } else { | ||
224 | $sql = "TRUNCATE TABLE $this->table"; | ||
225 | } | ||
226 | } else { | ||
227 | $sql = "DELETE FROM $this->table WHERE $this->idCol LIKE '$namespace%'"; | ||
228 | } | ||
229 | |||
230 | try { | ||
231 | $conn->exec($sql); | ||
232 | } catch (\PDOException) { | ||
233 | } | ||
234 | |||
235 | return true; | ||
236 | } | ||
237 | |||
238 | protected function doDelete(array $ids): bool | ||
239 | { | ||
240 | $sql = str_pad('', (\count($ids) << 1) - 1, '?,'); | ||
241 | $sql = "DELETE FROM $this->table WHERE $this->idCol IN ($sql)"; | ||
242 | try { | ||
243 | $stmt = $this->getConnection()->prepare($sql); | ||
244 | $stmt->execute(array_values($ids)); | ||
245 | } catch (\PDOException) { | ||
246 | } | ||
247 | |||
248 | return true; | ||
249 | } | ||
250 | |||
251 | protected function doSave(array $values, int $lifetime): array|bool | ||
252 | { | ||
253 | if (!$values = $this->marshaller->marshall($values, $failed)) { | ||
254 | return $failed; | ||
255 | } | ||
256 | |||
257 | $conn = $this->getConnection(); | ||
258 | |||
259 | $driver = $this->getDriver(); | ||
260 | $insertSql = "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time)"; | ||
261 | |||
262 | switch (true) { | ||
263 | case 'mysql' === $driver: | ||
264 | $sql = $insertSql." ON DUPLICATE KEY UPDATE $this->dataCol = VALUES($this->dataCol), $this->lifetimeCol = VALUES($this->lifetimeCol), $this->timeCol = VALUES($this->timeCol)"; | ||
265 | break; | ||
266 | case 'oci' === $driver: | ||
267 | // DUAL is Oracle specific dummy table | ||
268 | $sql = "MERGE INTO $this->table USING DUAL ON ($this->idCol = ?) ". | ||
269 | "WHEN NOT MATCHED THEN INSERT ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (?, ?, ?, ?) ". | ||
270 | "WHEN MATCHED THEN UPDATE SET $this->dataCol = ?, $this->lifetimeCol = ?, $this->timeCol = ?"; | ||
271 | break; | ||
272 | case 'sqlsrv' === $driver && version_compare($this->getServerVersion(), '10', '>='): | ||
273 | // MERGE is only available since SQL Server 2008 and must be terminated by semicolon | ||
274 | // It also requires HOLDLOCK according to http://weblogs.sqlteam.com/dang/archive/2009/01/31/UPSERT-Race-Condition-With-MERGE.aspx | ||
275 | $sql = "MERGE INTO $this->table WITH (HOLDLOCK) USING (SELECT 1 AS dummy) AS src ON ($this->idCol = ?) ". | ||
276 | "WHEN NOT MATCHED THEN INSERT ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (?, ?, ?, ?) ". | ||
277 | "WHEN MATCHED THEN UPDATE SET $this->dataCol = ?, $this->lifetimeCol = ?, $this->timeCol = ?;"; | ||
278 | break; | ||
279 | case 'sqlite' === $driver: | ||
280 | $sql = 'INSERT OR REPLACE'.substr($insertSql, 6); | ||
281 | break; | ||
282 | case 'pgsql' === $driver && version_compare($this->getServerVersion(), '9.5', '>='): | ||
283 | $sql = $insertSql." ON CONFLICT ($this->idCol) DO UPDATE SET ($this->dataCol, $this->lifetimeCol, $this->timeCol) = (EXCLUDED.$this->dataCol, EXCLUDED.$this->lifetimeCol, EXCLUDED.$this->timeCol)"; | ||
284 | break; | ||
285 | default: | ||
286 | $driver = null; | ||
287 | $sql = "UPDATE $this->table SET $this->dataCol = :data, $this->lifetimeCol = :lifetime, $this->timeCol = :time WHERE $this->idCol = :id"; | ||
288 | break; | ||
289 | } | ||
290 | |||
291 | $now = time(); | ||
292 | $lifetime = $lifetime ?: null; | ||
293 | try { | ||
294 | $stmt = $conn->prepare($sql); | ||
295 | } catch (\PDOException $e) { | ||
296 | if ($this->isTableMissing($e) && (!$conn->inTransaction() || \in_array($driver, ['pgsql', 'sqlite', 'sqlsrv'], true))) { | ||
297 | $this->createTable(); | ||
298 | } | ||
299 | $stmt = $conn->prepare($sql); | ||
300 | } | ||
301 | |||
302 | // $id and $data are defined later in the loop. Binding is done by reference, values are read on execution. | ||
303 | if ('sqlsrv' === $driver || 'oci' === $driver) { | ||
304 | $stmt->bindParam(1, $id); | ||
305 | $stmt->bindParam(2, $id); | ||
306 | $stmt->bindParam(3, $data, \PDO::PARAM_LOB); | ||
307 | $stmt->bindValue(4, $lifetime, \PDO::PARAM_INT); | ||
308 | $stmt->bindValue(5, $now, \PDO::PARAM_INT); | ||
309 | $stmt->bindParam(6, $data, \PDO::PARAM_LOB); | ||
310 | $stmt->bindValue(7, $lifetime, \PDO::PARAM_INT); | ||
311 | $stmt->bindValue(8, $now, \PDO::PARAM_INT); | ||
312 | } else { | ||
313 | $stmt->bindParam(':id', $id); | ||
314 | $stmt->bindParam(':data', $data, \PDO::PARAM_LOB); | ||
315 | $stmt->bindValue(':lifetime', $lifetime, \PDO::PARAM_INT); | ||
316 | $stmt->bindValue(':time', $now, \PDO::PARAM_INT); | ||
317 | } | ||
318 | if (null === $driver) { | ||
319 | $insertStmt = $conn->prepare($insertSql); | ||
320 | |||
321 | $insertStmt->bindParam(':id', $id); | ||
322 | $insertStmt->bindParam(':data', $data, \PDO::PARAM_LOB); | ||
323 | $insertStmt->bindValue(':lifetime', $lifetime, \PDO::PARAM_INT); | ||
324 | $insertStmt->bindValue(':time', $now, \PDO::PARAM_INT); | ||
325 | } | ||
326 | |||
327 | foreach ($values as $id => $data) { | ||
328 | try { | ||
329 | $stmt->execute(); | ||
330 | } catch (\PDOException $e) { | ||
331 | if ($this->isTableMissing($e) && (!$conn->inTransaction() || \in_array($driver, ['pgsql', 'sqlite', 'sqlsrv'], true))) { | ||
332 | $this->createTable(); | ||
333 | } | ||
334 | $stmt->execute(); | ||
335 | } | ||
336 | if (null === $driver && !$stmt->rowCount()) { | ||
337 | try { | ||
338 | $insertStmt->execute(); | ||
339 | } catch (\PDOException) { | ||
340 | // A concurrent write won, let it be | ||
341 | } | ||
342 | } | ||
343 | } | ||
344 | |||
345 | return $failed; | ||
346 | } | ||
347 | |||
348 | /** | ||
349 | * @internal | ||
350 | */ | ||
351 | protected function getId(mixed $key): string | ||
352 | { | ||
353 | if ('pgsql' !== $this->getDriver()) { | ||
354 | return parent::getId($key); | ||
355 | } | ||
356 | |||
357 | if (str_contains($key, "\0") || str_contains($key, '%') || !preg_match('//u', $key)) { | ||
358 | $key = rawurlencode($key); | ||
359 | } | ||
360 | |||
361 | return parent::getId($key); | ||
362 | } | ||
363 | |||
364 | private function getConnection(): \PDO | ||
365 | { | ||
366 | if (!isset($this->conn)) { | ||
367 | $this->conn = new \PDO($this->dsn, $this->username, $this->password, $this->connectionOptions); | ||
368 | $this->conn->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); | ||
369 | } | ||
370 | |||
371 | return $this->conn; | ||
372 | } | ||
373 | |||
374 | private function getDriver(): string | ||
375 | { | ||
376 | return $this->driver ??= $this->getConnection()->getAttribute(\PDO::ATTR_DRIVER_NAME); | ||
377 | } | ||
378 | |||
379 | private function getServerVersion(): string | ||
380 | { | ||
381 | return $this->serverVersion ??= $this->getConnection()->getAttribute(\PDO::ATTR_SERVER_VERSION); | ||
382 | } | ||
383 | |||
384 | private function isTableMissing(\PDOException $exception): bool | ||
385 | { | ||
386 | $driver = $this->getDriver(); | ||
387 | [$sqlState, $code] = $exception->errorInfo ?? [null, $exception->getCode()]; | ||
388 | |||
389 | return match ($driver) { | ||
390 | 'pgsql' => '42P01' === $sqlState, | ||
391 | 'sqlite' => str_contains($exception->getMessage(), 'no such table:'), | ||
392 | 'oci' => 942 === $code, | ||
393 | 'sqlsrv' => 208 === $code, | ||
394 | 'mysql' => 1146 === $code, | ||
395 | default => false, | ||
396 | }; | ||
397 | } | ||
398 | } | ||