DoctrineDbalAdapter.php 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448
  1. <?php
  2. /*
  3. * This file is part of the Symfony package.
  4. *
  5. * (c) Fabien Potencier <fabien@symfony.com>
  6. *
  7. * For the full copyright and license information, please view the LICENSE
  8. * file that was distributed with this source code.
  9. */
  10. namespace Symfony\Component\Cache\Adapter;
  11. use Doctrine\DBAL\ArrayParameterType;
  12. use Doctrine\DBAL\Configuration;
  13. use Doctrine\DBAL\Connection;
  14. use Doctrine\DBAL\Driver\ServerInfoAwareConnection;
  15. use Doctrine\DBAL\DriverManager;
  16. use Doctrine\DBAL\Exception as DBALException;
  17. use Doctrine\DBAL\Exception\TableNotFoundException;
  18. use Doctrine\DBAL\ParameterType;
  19. use Doctrine\DBAL\Schema\DefaultSchemaManagerFactory;
  20. use Doctrine\DBAL\Schema\Schema;
  21. use Doctrine\DBAL\ServerVersionProvider;
  22. use Doctrine\DBAL\Tools\DsnParser;
  23. use Symfony\Component\Cache\Exception\InvalidArgumentException;
  24. use Symfony\Component\Cache\Marshaller\DefaultMarshaller;
  25. use Symfony\Component\Cache\Marshaller\MarshallerInterface;
  26. use Symfony\Component\Cache\PruneableInterface;
  27. class DoctrineDbalAdapter extends AbstractAdapter implements PruneableInterface
  28. {
  29. protected $maxIdLength = 255;
  30. private $marshaller;
  31. private $conn;
  32. private $platformName;
  33. private $serverVersion;
  34. private $table = 'cache_items';
  35. private $idCol = 'item_id';
  36. private $dataCol = 'item_data';
  37. private $lifetimeCol = 'item_lifetime';
  38. private $timeCol = 'item_time';
  39. private $namespace;
  40. /**
  41. * You can either pass an existing database Doctrine DBAL Connection or
  42. * a DSN string that will be used to connect to the database.
  43. *
  44. * The cache table is created automatically when possible.
  45. * Otherwise, use the createTable() method.
  46. *
  47. * List of available options:
  48. * * db_table: The name of the table [default: cache_items]
  49. * * db_id_col: The column where to store the cache id [default: item_id]
  50. * * db_data_col: The column where to store the cache data [default: item_data]
  51. * * db_lifetime_col: The column where to store the lifetime [default: item_lifetime]
  52. * * db_time_col: The column where to store the timestamp [default: item_time]
  53. *
  54. * @param Connection|string $connOrDsn
  55. *
  56. * @throws InvalidArgumentException When namespace contains invalid characters
  57. */
  58. public function __construct($connOrDsn, string $namespace = '', int $defaultLifetime = 0, array $options = [], ?MarshallerInterface $marshaller = null)
  59. {
  60. if (isset($namespace[0]) && preg_match('#[^-+.A-Za-z0-9]#', $namespace, $match)) {
  61. throw new InvalidArgumentException(sprintf('Namespace contains "%s" but only characters in [-+.A-Za-z0-9] are allowed.', $match[0]));
  62. }
  63. if ($connOrDsn instanceof Connection) {
  64. $this->conn = $connOrDsn;
  65. } elseif (\is_string($connOrDsn)) {
  66. if (!class_exists(DriverManager::class)) {
  67. throw new InvalidArgumentException('Failed to parse DSN. Try running "composer require doctrine/dbal".');
  68. }
  69. if (class_exists(DsnParser::class)) {
  70. $params = (new DsnParser([
  71. 'db2' => 'ibm_db2',
  72. 'mssql' => 'pdo_sqlsrv',
  73. 'mysql' => 'pdo_mysql',
  74. 'mysql2' => 'pdo_mysql',
  75. 'postgres' => 'pdo_pgsql',
  76. 'postgresql' => 'pdo_pgsql',
  77. 'pgsql' => 'pdo_pgsql',
  78. 'sqlite' => 'pdo_sqlite',
  79. 'sqlite3' => 'pdo_sqlite',
  80. ]))->parse($connOrDsn);
  81. } else {
  82. $params = ['url' => $connOrDsn];
  83. }
  84. $config = new Configuration();
  85. if (class_exists(DefaultSchemaManagerFactory::class)) {
  86. $config->setSchemaManagerFactory(new DefaultSchemaManagerFactory());
  87. }
  88. $this->conn = DriverManager::getConnection($params, $config);
  89. } else {
  90. throw new \TypeError(sprintf('Argument 1 passed to "%s()" must be "%s" or string, "%s" given.', __METHOD__, Connection::class, get_debug_type($connOrDsn)));
  91. }
  92. $this->table = $options['db_table'] ?? $this->table;
  93. $this->idCol = $options['db_id_col'] ?? $this->idCol;
  94. $this->dataCol = $options['db_data_col'] ?? $this->dataCol;
  95. $this->lifetimeCol = $options['db_lifetime_col'] ?? $this->lifetimeCol;
  96. $this->timeCol = $options['db_time_col'] ?? $this->timeCol;
  97. $this->namespace = $namespace;
  98. $this->marshaller = $marshaller ?? new DefaultMarshaller();
  99. parent::__construct($namespace, $defaultLifetime);
  100. }
  101. /**
  102. * Creates the table to store cache items which can be called once for setup.
  103. *
  104. * Cache ID are saved in a column of maximum length 255. Cache data is
  105. * saved in a BLOB.
  106. *
  107. * @throws DBALException When the table already exists
  108. */
  109. public function createTable()
  110. {
  111. $schema = new Schema();
  112. $this->addTableToSchema($schema);
  113. foreach ($schema->toSql($this->conn->getDatabasePlatform()) as $sql) {
  114. $this->conn->executeStatement($sql);
  115. }
  116. }
  117. /**
  118. * {@inheritdoc}
  119. */
  120. public function configureSchema(Schema $schema, Connection $forConnection): void
  121. {
  122. // only update the schema for this connection
  123. if ($forConnection !== $this->conn) {
  124. return;
  125. }
  126. if ($schema->hasTable($this->table)) {
  127. return;
  128. }
  129. $this->addTableToSchema($schema);
  130. }
  131. /**
  132. * {@inheritdoc}
  133. */
  134. public function prune(): bool
  135. {
  136. $deleteSql = "DELETE FROM $this->table WHERE $this->lifetimeCol + $this->timeCol <= ?";
  137. $params = [time()];
  138. $paramTypes = [ParameterType::INTEGER];
  139. if ('' !== $this->namespace) {
  140. $deleteSql .= " AND $this->idCol LIKE ?";
  141. $params[] = sprintf('%s%%', $this->namespace);
  142. $paramTypes[] = ParameterType::STRING;
  143. }
  144. try {
  145. $this->conn->executeStatement($deleteSql, $params, $paramTypes);
  146. } catch (TableNotFoundException $e) {
  147. }
  148. return true;
  149. }
  150. /**
  151. * {@inheritdoc}
  152. */
  153. protected function doFetch(array $ids): iterable
  154. {
  155. $now = time();
  156. $expired = [];
  157. $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 (?)";
  158. $result = $this->conn->executeQuery($sql, [
  159. $now,
  160. $ids,
  161. ], [
  162. ParameterType::INTEGER,
  163. class_exists(ArrayParameterType::class) ? ArrayParameterType::STRING : Connection::PARAM_STR_ARRAY,
  164. ])->iterateNumeric();
  165. foreach ($result as $row) {
  166. if (null === $row[1]) {
  167. $expired[] = $row[0];
  168. } else {
  169. yield $row[0] => $this->marshaller->unmarshall(\is_resource($row[1]) ? stream_get_contents($row[1]) : $row[1]);
  170. }
  171. }
  172. if ($expired) {
  173. $sql = "DELETE FROM $this->table WHERE $this->lifetimeCol + $this->timeCol <= ? AND $this->idCol IN (?)";
  174. $this->conn->executeStatement($sql, [
  175. $now,
  176. $expired,
  177. ], [
  178. ParameterType::INTEGER,
  179. class_exists(ArrayParameterType::class) ? ArrayParameterType::STRING : Connection::PARAM_STR_ARRAY,
  180. ]);
  181. }
  182. }
  183. /**
  184. * {@inheritdoc}
  185. */
  186. protected function doHave(string $id): bool
  187. {
  188. $sql = "SELECT 1 FROM $this->table WHERE $this->idCol = ? AND ($this->lifetimeCol IS NULL OR $this->lifetimeCol + $this->timeCol > ?)";
  189. $result = $this->conn->executeQuery($sql, [
  190. $id,
  191. time(),
  192. ], [
  193. ParameterType::STRING,
  194. ParameterType::INTEGER,
  195. ]);
  196. return (bool) $result->fetchOne();
  197. }
  198. /**
  199. * {@inheritdoc}
  200. */
  201. protected function doClear(string $namespace): bool
  202. {
  203. if ('' === $namespace) {
  204. if ('sqlite' === $this->getPlatformName()) {
  205. $sql = "DELETE FROM $this->table";
  206. } else {
  207. $sql = "TRUNCATE TABLE $this->table";
  208. }
  209. } else {
  210. $sql = "DELETE FROM $this->table WHERE $this->idCol LIKE '$namespace%'";
  211. }
  212. try {
  213. $this->conn->executeStatement($sql);
  214. } catch (TableNotFoundException $e) {
  215. }
  216. return true;
  217. }
  218. /**
  219. * {@inheritdoc}
  220. */
  221. protected function doDelete(array $ids): bool
  222. {
  223. $sql = "DELETE FROM $this->table WHERE $this->idCol IN (?)";
  224. try {
  225. $this->conn->executeStatement($sql, [array_values($ids)], [class_exists(ArrayParameterType::class) ? ArrayParameterType::STRING : Connection::PARAM_STR_ARRAY]);
  226. } catch (TableNotFoundException $e) {
  227. }
  228. return true;
  229. }
  230. /**
  231. * {@inheritdoc}
  232. */
  233. protected function doSave(array $values, int $lifetime)
  234. {
  235. if (!$values = $this->marshaller->marshall($values, $failed)) {
  236. return $failed;
  237. }
  238. $platformName = $this->getPlatformName();
  239. $insertSql = "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (?, ?, ?, ?)";
  240. switch (true) {
  241. case 'mysql' === $platformName:
  242. $sql = $insertSql." ON DUPLICATE KEY UPDATE $this->dataCol = VALUES($this->dataCol), $this->lifetimeCol = VALUES($this->lifetimeCol), $this->timeCol = VALUES($this->timeCol)";
  243. break;
  244. case 'oci' === $platformName:
  245. // DUAL is Oracle specific dummy table
  246. $sql = "MERGE INTO $this->table USING DUAL ON ($this->idCol = ?) ".
  247. "WHEN NOT MATCHED THEN INSERT ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (?, ?, ?, ?) ".
  248. "WHEN MATCHED THEN UPDATE SET $this->dataCol = ?, $this->lifetimeCol = ?, $this->timeCol = ?";
  249. break;
  250. case 'sqlsrv' === $platformName && version_compare($this->getServerVersion(), '10', '>='):
  251. // MERGE is only available since SQL Server 2008 and must be terminated by semicolon
  252. // It also requires HOLDLOCK according to http://weblogs.sqlteam.com/dang/archive/2009/01/31/UPSERT-Race-Condition-With-MERGE.aspx
  253. $sql = "MERGE INTO $this->table WITH (HOLDLOCK) USING (SELECT 1 AS dummy) AS src ON ($this->idCol = ?) ".
  254. "WHEN NOT MATCHED THEN INSERT ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (?, ?, ?, ?) ".
  255. "WHEN MATCHED THEN UPDATE SET $this->dataCol = ?, $this->lifetimeCol = ?, $this->timeCol = ?;";
  256. break;
  257. case 'sqlite' === $platformName:
  258. $sql = 'INSERT OR REPLACE'.substr($insertSql, 6);
  259. break;
  260. case 'pgsql' === $platformName && version_compare($this->getServerVersion(), '9.5', '>='):
  261. $sql = $insertSql." ON CONFLICT ($this->idCol) DO UPDATE SET ($this->dataCol, $this->lifetimeCol, $this->timeCol) = (EXCLUDED.$this->dataCol, EXCLUDED.$this->lifetimeCol, EXCLUDED.$this->timeCol)";
  262. break;
  263. default:
  264. $platformName = null;
  265. $sql = "UPDATE $this->table SET $this->dataCol = ?, $this->lifetimeCol = ?, $this->timeCol = ? WHERE $this->idCol = ?";
  266. break;
  267. }
  268. $now = time();
  269. $lifetime = $lifetime ?: null;
  270. try {
  271. $stmt = $this->conn->prepare($sql);
  272. } catch (TableNotFoundException $e) {
  273. if (!$this->conn->isTransactionActive() || \in_array($platformName, ['pgsql', 'sqlite', 'sqlsrv'], true)) {
  274. $this->createTable();
  275. }
  276. $stmt = $this->conn->prepare($sql);
  277. }
  278. if ('sqlsrv' === $platformName || 'oci' === $platformName) {
  279. $bind = static function ($id, $data) use ($stmt) {
  280. $stmt->bindValue(1, $id);
  281. $stmt->bindValue(2, $id);
  282. $stmt->bindValue(3, $data, ParameterType::LARGE_OBJECT);
  283. $stmt->bindValue(6, $data, ParameterType::LARGE_OBJECT);
  284. };
  285. $stmt->bindValue(4, $lifetime, ParameterType::INTEGER);
  286. $stmt->bindValue(5, $now, ParameterType::INTEGER);
  287. $stmt->bindValue(7, $lifetime, ParameterType::INTEGER);
  288. $stmt->bindValue(8, $now, ParameterType::INTEGER);
  289. } elseif (null !== $platformName) {
  290. $bind = static function ($id, $data) use ($stmt) {
  291. $stmt->bindValue(1, $id);
  292. $stmt->bindValue(2, $data, ParameterType::LARGE_OBJECT);
  293. };
  294. $stmt->bindValue(3, $lifetime, ParameterType::INTEGER);
  295. $stmt->bindValue(4, $now, ParameterType::INTEGER);
  296. } else {
  297. $stmt->bindValue(2, $lifetime, ParameterType::INTEGER);
  298. $stmt->bindValue(3, $now, ParameterType::INTEGER);
  299. $insertStmt = $this->conn->prepare($insertSql);
  300. $insertStmt->bindValue(3, $lifetime, ParameterType::INTEGER);
  301. $insertStmt->bindValue(4, $now, ParameterType::INTEGER);
  302. $bind = static function ($id, $data) use ($stmt, $insertStmt) {
  303. $stmt->bindValue(1, $data, ParameterType::LARGE_OBJECT);
  304. $stmt->bindValue(4, $id);
  305. $insertStmt->bindValue(1, $id);
  306. $insertStmt->bindValue(2, $data, ParameterType::LARGE_OBJECT);
  307. };
  308. }
  309. foreach ($values as $id => $data) {
  310. $bind($id, $data);
  311. try {
  312. $rowCount = $stmt->executeStatement();
  313. } catch (TableNotFoundException $e) {
  314. if (!$this->conn->isTransactionActive() || \in_array($platformName, ['pgsql', 'sqlite', 'sqlsrv'], true)) {
  315. $this->createTable();
  316. }
  317. $rowCount = $stmt->executeStatement();
  318. }
  319. if (null === $platformName && 0 === $rowCount) {
  320. try {
  321. $insertStmt->executeStatement();
  322. } catch (DBALException $e) {
  323. // A concurrent write won, let it be
  324. }
  325. }
  326. }
  327. return $failed;
  328. }
  329. /**
  330. * @internal
  331. */
  332. protected function getId($key)
  333. {
  334. if ('pgsql' !== $this->getPlatformName()) {
  335. return parent::getId($key);
  336. }
  337. if (str_contains($key, "\0") || str_contains($key, '%') || !preg_match('//u', $key)) {
  338. $key = rawurlencode($key);
  339. }
  340. return parent::getId($key);
  341. }
  342. private function getPlatformName(): string
  343. {
  344. if (isset($this->platformName)) {
  345. return $this->platformName;
  346. }
  347. $platform = $this->conn->getDatabasePlatform();
  348. switch (true) {
  349. case $platform instanceof \Doctrine\DBAL\Platforms\MySQLPlatform:
  350. case $platform instanceof \Doctrine\DBAL\Platforms\MySQL57Platform:
  351. return $this->platformName = 'mysql';
  352. case $platform instanceof \Doctrine\DBAL\Platforms\SqlitePlatform:
  353. return $this->platformName = 'sqlite';
  354. case $platform instanceof \Doctrine\DBAL\Platforms\PostgreSQLPlatform:
  355. case $platform instanceof \Doctrine\DBAL\Platforms\PostgreSQL94Platform:
  356. return $this->platformName = 'pgsql';
  357. case $platform instanceof \Doctrine\DBAL\Platforms\OraclePlatform:
  358. return $this->platformName = 'oci';
  359. case $platform instanceof \Doctrine\DBAL\Platforms\SQLServerPlatform:
  360. case $platform instanceof \Doctrine\DBAL\Platforms\SQLServer2012Platform:
  361. return $this->platformName = 'sqlsrv';
  362. default:
  363. return $this->platformName = \get_class($platform);
  364. }
  365. }
  366. private function getServerVersion(): string
  367. {
  368. if (isset($this->serverVersion)) {
  369. return $this->serverVersion;
  370. }
  371. if ($this->conn instanceof ServerVersionProvider || $this->conn instanceof ServerInfoAwareConnection) {
  372. return $this->serverVersion = $this->conn->getServerVersion();
  373. }
  374. // The condition should be removed once support for DBAL <3.3 is dropped
  375. $conn = method_exists($this->conn, 'getNativeConnection') ? $this->conn->getNativeConnection() : $this->conn->getWrappedConnection();
  376. return $this->serverVersion = $conn->getAttribute(\PDO::ATTR_SERVER_VERSION);
  377. }
  378. private function addTableToSchema(Schema $schema): void
  379. {
  380. $types = [
  381. 'mysql' => 'binary',
  382. 'sqlite' => 'text',
  383. ];
  384. $table = $schema->createTable($this->table);
  385. $table->addColumn($this->idCol, $types[$this->getPlatformName()] ?? 'string', ['length' => 255]);
  386. $table->addColumn($this->dataCol, 'blob', ['length' => 16777215]);
  387. $table->addColumn($this->lifetimeCol, 'integer', ['unsigned' => true, 'notnull' => false]);
  388. $table->addColumn($this->timeCol, 'integer', ['unsigned' => true]);
  389. $table->setPrimaryKey([$this->idCol]);
  390. }
  391. }