[TASK] Doctrine: Migrate cache Typo3DatabaseBackend
[Packages/TYPO3.CMS.git] / typo3 / sysext / core / Classes / Cache / Backend / Typo3DatabaseBackend.php
1 <?php
2 namespace TYPO3\CMS\Core\Cache\Backend;
3
4 /*
5 * This file is part of the TYPO3 CMS project.
6 *
7 * It is free software; you can redistribute it and/or modify it under
8 * the terms of the GNU General Public License, either version 2
9 * of the License, or any later version.
10 *
11 * For the full copyright and license information, please read the
12 * LICENSE.txt file that was distributed with this source code.
13 *
14 * The TYPO3 project - inspiring people to share!
15 */
16
17 use TYPO3\CMS\Core\Cache\Exception;
18 use TYPO3\CMS\Core\Cache\Exception\InvalidDataException;
19 use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
20 use TYPO3\CMS\Core\Database\Connection;
21 use TYPO3\CMS\Core\Database\ConnectionPool;
22 use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;
23 use TYPO3\CMS\Core\Utility\GeneralUtility;
24
25 /**
26 * A caching backend which stores cache entries in database tables
27 * @api
28 */
29 class Typo3DatabaseBackend extends AbstractBackend implements TaggableBackendInterface
30 {
31 /**
32 * @var int Timestamp of 2038-01-01)
33 */
34 const FAKED_UNLIMITED_EXPIRE = 2145909600;
35 /**
36 * @var string Name of the cache data table
37 */
38 protected $cacheTable;
39
40 /**
41 * @var string Name of the cache tags table
42 */
43 protected $tagsTable;
44
45 /**
46 * @var bool Indicates whether data is compressed or not (requires php zlib)
47 */
48 protected $compression = false;
49
50 /**
51 * @var int -1 to 9, indicates zlib compression level: -1 = default level 6, 0 = no compression, 9 maximum compression
52 */
53 protected $compressionLevel = -1;
54
55 /**
56 * @var int Maximum lifetime to stay with expire field below FAKED_UNLIMITED_LIFETIME
57 */
58 protected $maximumLifetime;
59
60 /**
61 * Set cache frontend instance and calculate data and tags table name
62 *
63 * @param FrontendInterface $cache The frontend for this backend
64 * @return void
65 * @api
66 */
67 public function setCache(FrontendInterface $cache)
68 {
69 parent::setCache($cache);
70 $this->cacheTable = 'cf_' . $this->cacheIdentifier;
71 $this->tagsTable = 'cf_' . $this->cacheIdentifier . '_tags';
72 $this->maximumLifetime = self::FAKED_UNLIMITED_EXPIRE - $GLOBALS['EXEC_TIME'];
73 }
74
75 /**
76 * Saves data in a cache file.
77 *
78 * @param string $entryIdentifier An identifier for this specific cache entry
79 * @param string $data The data to be stored
80 * @param array $tags Tags to associate with this cache entry
81 * @param int $lifetime Lifetime of this cache entry in seconds. If NULL is specified, the default lifetime is used. "0" means unlimited lifetime.
82 * @return void
83 * @throws Exception if no cache frontend has been set.
84 * @throws InvalidDataException if the data to be stored is not a string.
85 */
86 public function set($entryIdentifier, $data, array $tags = array(), $lifetime = null)
87 {
88 $this->throwExceptionIfFrontendDoesNotExist();
89 if (!is_string($data)) {
90 throw new InvalidDataException(
91 'The specified data is of type "' . gettype($data) . '" but a string is expected.',
92 1236518298
93 );
94 }
95 if (is_null($lifetime)) {
96 $lifetime = $this->defaultLifetime;
97 }
98 if ($lifetime === 0 || $lifetime > $this->maximumLifetime) {
99 $lifetime = $this->maximumLifetime;
100 }
101 $expires = $GLOBALS['EXEC_TIME'] + $lifetime;
102 $this->remove($entryIdentifier);
103 if ($this->compression) {
104 $data = gzcompress($data, $this->compressionLevel);
105 }
106 GeneralUtility::makeInstance(ConnectionPool::class)
107 ->getConnectionForTable($this->cacheTable)
108 ->insert(
109 $this->cacheTable,
110 [
111 'identifier' => $entryIdentifier,
112 'expires' => $expires,
113 'content' => $data,
114 ]
115 );
116 if (!empty($tags)) {
117 $fields = array();
118 $fields[] = 'identifier';
119 $fields[] = 'tag';
120 $tagRows = array();
121 foreach ($tags as $tag) {
122 $tagRow = [];
123 $tagRow[] = $entryIdentifier;
124 $tagRow[] = $tag;
125 $tagRows[] = $tagRow;
126 }
127 GeneralUtility::makeInstance(ConnectionPool::class)
128 ->getConnectionForTable($this->tagsTable)
129 ->bulkInsert($this->tagsTable, $tagRows, $fields);
130 }
131 }
132
133 /**
134 * Loads data from a cache file.
135 *
136 * @param string $entryIdentifier An identifier which describes the cache entry to load
137 * @return mixed The cache entry's data as a string or FALSE if the cache entry could not be loaded
138 */
139 public function get($entryIdentifier)
140 {
141 $this->throwExceptionIfFrontendDoesNotExist();
142 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
143 ->getQueryBuilderForTable($this->cacheTable);
144 $cacheRow = $queryBuilder->select('content')
145 ->from($this->cacheTable)
146 ->where(
147 $queryBuilder->expr()->eq('identifier', $queryBuilder->createNamedParameter($entryIdentifier)),
148 $queryBuilder->expr()->gte('expires', (int)$GLOBALS['EXEC_TIME'])
149 )
150 ->execute()
151 ->fetch();
152 $content = '';
153 if (!empty($cacheRow)) {
154 $content = $cacheRow['content'];
155 }
156 if ($this->compression && (string)$content !== '') {
157 $content = gzuncompress($content);
158 }
159 return !empty($cacheRow) ? $content : false;
160 }
161
162 /**
163 * Checks if a cache entry with the specified identifier exists.
164 *
165 * @param string $entryIdentifier Specifies the identifier to check for existence
166 * @return bool TRUE if such an entry exists, FALSE if not
167 */
168 public function has($entryIdentifier)
169 {
170 $this->throwExceptionIfFrontendDoesNotExist();
171 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
172 ->getQueryBuilderForTable($this->cacheTable);
173 $count = $queryBuilder->count('*')
174 ->from($this->cacheTable)
175 ->where(
176 $queryBuilder->expr()->eq('identifier', $queryBuilder->createNamedParameter($entryIdentifier)),
177 $queryBuilder->expr()->gte('expires', (int)$GLOBALS['EXEC_TIME'])
178 )
179 ->execute()
180 ->fetchColumn(0);
181 return (bool)$count;
182 }
183
184 /**
185 * Removes all cache entries matching the specified identifier.
186 * Usually this only affects one entry.
187 *
188 * @param string $entryIdentifier Specifies the cache entry to remove
189 * @return bool TRUE if (at least) an entry could be removed or FALSE if no entry was found
190 */
191 public function remove($entryIdentifier)
192 {
193 $this->throwExceptionIfFrontendDoesNotExist();
194 $numberOfRowsRemoved = GeneralUtility::makeInstance(ConnectionPool::class)
195 ->getConnectionForTable($this->cacheTable)
196 ->delete(
197 $this->cacheTable,
198 ['identifier' => $entryIdentifier]
199 );
200 GeneralUtility::makeInstance(ConnectionPool::class)
201 ->getConnectionForTable($this->tagsTable)
202 ->delete(
203 $this->tagsTable,
204 ['identifier' => $entryIdentifier]
205 );
206 return (bool)$numberOfRowsRemoved;
207 }
208
209 /**
210 * Finds and returns all cache entries which are tagged by the specified tag.
211 *
212 * @param string $tag The tag to search for
213 * @return array An array with identifiers of all matching entries. An empty array if no entries matched
214 */
215 public function findIdentifiersByTag($tag)
216 {
217 $this->throwExceptionIfFrontendDoesNotExist();
218 $cacheEntryIdentifiers = [];
219 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
220 ->getQueryBuilderForTable($this->tagsTable);
221 $result = $queryBuilder->select($this->cacheTable . '.identifier')
222 ->from($this->cacheTable)
223 ->from($this->tagsTable)
224 ->where(
225 $queryBuilder->expr()->eq($this->cacheTable . '.identifier', $queryBuilder->quoteIdentifier($this->tagsTable . '.identifier')),
226 $queryBuilder->expr()->eq($this->tagsTable . '.tag', $queryBuilder->createNamedParameter($tag)),
227 $queryBuilder->expr()->gte($this->cacheTable . '.expires', (int)$GLOBALS['EXEC_TIME'])
228 )
229 ->execute();
230 while ($row = $result->fetch()) {
231 $cacheEntryIdentifiers[$row['identifier']] = $row['identifier'];
232 }
233 return $cacheEntryIdentifiers;
234 }
235
236 /**
237 * Removes all cache entries of this cache.
238 *
239 * @return void
240 */
241 public function flush()
242 {
243 $this->throwExceptionIfFrontendDoesNotExist();
244 GeneralUtility::makeInstance(ConnectionPool::class)
245 ->getConnectionForTable($this->cacheTable)
246 ->truncate($this->cacheTable);
247 GeneralUtility::makeInstance(ConnectionPool::class)
248 ->getConnectionForTable($this->tagsTable)
249 ->truncate($this->tagsTable);
250 }
251
252 /**
253 * Removes all cache entries of this cache which are tagged by the specified tag.
254 *
255 * @param string $tag The tag the entries must have
256 * @return void
257 */
258 public function flushByTag($tag)
259 {
260 $this->throwExceptionIfFrontendDoesNotExist();
261
262 $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($this->cacheTable);
263 if ($this->isConnectionMysql($connection)) {
264 // Use a optimized query on mysql ... don't use on your own
265 // * ansi sql does not know about multi table delete
266 // * doctrine query builder does not support join on delete()
267 $connection->executeQuery(
268 'DELETE tags2, cache1'
269 . ' FROM ' . $this->tagsTable . ' AS tags1'
270 . ' JOIN ' . $this->tagsTable . ' AS tags2 ON tags1.identifier = tags2.identifier'
271 . ' JOIN ' . $this->cacheTable . ' AS cache1 ON tags1.identifier = cache1.identifier'
272 . ' WHERE tags1.tag = ?',
273 [$tag]
274 );
275 } else {
276 $queryBuilder = $connection->createQueryBuilder();
277 $result = $queryBuilder->select('identifier')
278 ->from($this->tagsTable)
279 ->where($queryBuilder->expr()->eq('tag', $queryBuilder->createNamedParameter($tag)))
280 // group by is like DISTINCT and used here to suppress possible duplicate identifiers
281 ->groupBy('identifier')
282 ->execute();
283 $cacheEntryIdentifiers = [];
284 while ($row = $result->fetch()) {
285 $cacheEntryIdentifiers[] = $row['identifier'];
286 }
287 $quotedIdentifiers = $queryBuilder->createNamedParameter($cacheEntryIdentifiers, Connection::PARAM_STR_ARRAY);
288 $queryBuilder->delete($this->cacheTable)
289 ->where($queryBuilder->expr()->in('identifier', $quotedIdentifiers))
290 ->execute();
291 $queryBuilder->delete($this->tagsTable)
292 ->where($queryBuilder->expr()->in('identifier', $quotedIdentifiers))
293 ->execute();
294 }
295 }
296
297 /**
298 * Does garbage collection
299 *
300 * @return void
301 */
302 public function collectGarbage()
303 {
304 $this->throwExceptionIfFrontendDoesNotExist();
305
306 $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($this->cacheTable);
307 if ($this->isConnectionMysql($connection)) {
308 // Use a optimized query on mysql ... don't use on your own
309 // * ansi sql does not know about multi table delete
310 // * doctrine query builder does not support join on delete()
311 // First delete all expired rows from cache table and their connected tag rows
312 $connection->executeQuery(
313 'DELETE cache, tags'
314 . ' FROM ' . $this->cacheTable . ' AS cache'
315 . ' LEFT OUTER JOIN ' . $this->tagsTable . ' AS tags ON cache.identifier = tags.identifier'
316 . ' WHERE cache.expires < ?',
317 [(int)$GLOBALS['EXEC_TIME']]
318 );
319 // Then delete possible "orphaned" rows from tags table - tags that have no cache row for whatever reason
320 $connection->executeQuery(
321 'DELETE tags'
322 . ' FROM ' . $this->tagsTable . ' AS tags'
323 . ' LEFT OUTER JOIN ' . $this->cacheTable . ' as cache ON tags.identifier = cache.identifier'
324 . ' WHERE cache.identifier IS NULL'
325 );
326 } else {
327 $queryBuilder = $connection->createQueryBuilder();
328 $result = $queryBuilder->select('identifier')
329 ->from($this->cacheTable)
330 ->where($queryBuilder->expr()->lt('expires', (int)$GLOBALS['EXEC_TIME']))
331 // group by is like DISTINCT and used here to suppress possible duplicate identifiers
332 ->groupBy('identifier')
333 ->execute();
334
335 // Get identifiers of expired cache entries
336 $cacheEntryIdentifiers = [];
337 while ($row = $result->fetch()) {
338 $cacheEntryIdentifiers[] = $row['identifier'];
339 }
340 if (!empty($cacheEntryIdentifiers)) {
341 // Delete tag rows connected to expired cache entries
342 $quotedIdentifiers = $queryBuilder->createNamedParameter($cacheEntryIdentifiers, Connection::PARAM_STR_ARRAY);
343 $queryBuilder->delete($this->tagsTable)
344 ->where($queryBuilder->expr()->in('identifier', $quotedIdentifiers))
345 ->execute();
346 }
347 $queryBuilder->delete($this->cacheTable)
348 ->where($queryBuilder->expr()->lt('expires', (int)$GLOBALS['EXEC_TIME']))
349 ->execute();
350
351 // Find out which "orphaned" tags rows exists that have no cache row and delete those, too.
352 $queryBuilder = $connection->createQueryBuilder();
353 $result = $queryBuilder->select('tags.identifier')
354 ->from($this->tagsTable, 'tags')
355 ->leftJoin(
356 'tags',
357 $this->cacheTable,
358 'cache',
359 $queryBuilder->expr()->eq('tags.identifier', $queryBuilder->quoteIdentifier('cache.identifier'))
360 )
361 ->where($queryBuilder->expr()->isNull('cache.identifier'))
362 ->groupBy('tags.identifier')
363 ->execute();
364 $tagsEntryIdentifiers = [];
365 while ($row = $result->fetch()) {
366 $tagsEntryIdentifiers[] = $row['identifier'];
367 }
368 if (!empty($tagsEntryIdentifiers)) {
369 $quotedIdentifiers = $queryBuilder->createNamedParameter($tagsEntryIdentifiers, Connection::PARAM_STR_ARRAY);
370 $queryBuilder->delete($this->tagsTable)
371 ->where($queryBuilder->expr()->in('identifier', $quotedIdentifiers))
372 ->execute();
373 }
374 }
375 }
376
377 /**
378 * Returns the table where the cache entries are stored.
379 *
380 * @return string The cache table.
381 */
382 public function getCacheTable()
383 {
384 $this->throwExceptionIfFrontendDoesNotExist();
385 return $this->cacheTable;
386 }
387
388 /**
389 * Gets the table where cache tags are stored.
390 *
391 * @return string Name of the table storing tags
392 */
393 public function getTagsTable()
394 {
395 $this->throwExceptionIfFrontendDoesNotExist();
396 return $this->tagsTable;
397 }
398
399 /**
400 * Enable data compression
401 *
402 * @param bool $compression TRUE to enable compression
403 */
404 public function setCompression($compression)
405 {
406 $this->compression = $compression;
407 }
408
409 /**
410 * Set data compression level.
411 * If compression is enabled and this is not set,
412 * gzcompress default level will be used
413 *
414 * @param int -1 to 9: Compression level
415 */
416 public function setCompressionLevel($compressionLevel)
417 {
418 if ($compressionLevel >= -1 && $compressionLevel <= 9) {
419 $this->compressionLevel = $compressionLevel;
420 }
421 }
422
423 /**
424 * This database backend uses some optimized queries for mysql
425 * to get maximum performance.
426 *
427 * @param Connection $connection
428 * @return bool
429 */
430 protected function isConnectionMysql(Connection $connection): bool
431 {
432 $serverVersion = $connection->getServerVersion();
433 return (bool)(strpos($serverVersion, 'MySQL') === 0);
434 }
435
436 /**
437 * Check if required frontend instance exists
438 *
439 * @throws Exception If there is no frontend instance in $this->cache
440 * @return void
441 */
442 protected function throwExceptionIfFrontendDoesNotExist()
443 {
444 if (!$this->cache instanceof FrontendInterface) {
445 throw new Exception('No cache frontend has been set via setCache() yet.', 1236518288);
446 }
447 }
448
449 /**
450 * Calculate needed table definitions for this cache.
451 * This helper method is used by install tool and extension manager
452 * and is not part of the public API!
453 *
454 * @return string SQL of table definitions
455 */
456 public function getTableDefinitions()
457 {
458 $cacheTableSql = file_get_contents(
459 ExtensionManagementUtility::extPath('core') .
460 'Resources/Private/Sql/Cache/Backend/Typo3DatabaseBackendCache.sql'
461 );
462 $requiredTableStructures = str_replace('###CACHE_TABLE###', $this->cacheTable, $cacheTableSql) . LF . LF;
463 $tagsTableSql = file_get_contents(
464 ExtensionManagementUtility::extPath('core') .
465 'Resources/Private/Sql/Cache/Backend/Typo3DatabaseBackendTags.sql'
466 );
467 $requiredTableStructures .= str_replace('###TAGS_TABLE###', $this->tagsTable, $tagsTableSql) . LF;
468 return $requiredTableStructures;
469 }
470 }