‪TYPO3CMS  ‪main
DeletedRecordsCommand.php
Go to the documentation of this file.
1 <?php
2 
3 declare(strict_types=1);
4 
5 /*
6  * This file is part of the TYPO3 CMS project.
7  *
8  * It is free software; you can redistribute it and/or modify it under
9  * the terms of the GNU General Public License, either version 2
10  * of the License, or any later version.
11  *
12  * For the full copyright and license information, please read the
13  * LICENSE.txt file that was distributed with this source code.
14  *
15  * The TYPO3 project - inspiring people to share!
16  */
17 
19 
20 use Symfony\Component\Console\Attribute\AsCommand;
21 use Symfony\Component\Console\Command\Command;
22 use Symfony\Component\Console\Input\InputInterface;
23 use Symfony\Component\Console\Input\InputOption;
24 use Symfony\Component\Console\Output\OutputInterface;
25 use Symfony\Component\Console\Style\SymfonyStyle;
26 use TYPO3\CMS\Backend\Utility\BackendUtility;
33 
37 #[AsCommand('cleanup:deletedrecords', 'Permanently deletes all records marked as "deleted" in the database.')]
38 class ‪DeletedRecordsCommand extends Command
39 {
40  public function ‪__construct(private readonly ‪ConnectionPool $connectionPool)
41  {
42  parent::__construct();
43  }
44 
48  public function ‪configure()
49  {
50  $this
51  ->setHelp('Traverse page tree and find and flush deleted records. If you want to get more detailed information, use the --verbose option.')
52  ->addOption(
53  'pid',
54  'p',
55  InputOption::VALUE_REQUIRED,
56  'Setting start page in page tree. Default is the page tree root, 0 (zero)'
57  )
58  ->addOption(
59  'depth',
60  'd',
61  InputOption::VALUE_REQUIRED,
62  'Setting traversal depth. 0 (zero) will only analyze start page (see --pid), 1 will traverse one level of subpages etc.'
63  )
64  ->addOption(
65  'dry-run',
66  null,
67  InputOption::VALUE_NONE,
68  'If this option is set, the records will not actually be deleted, but just the output which records would be deleted are shown'
69  )
70  ->addOption(
71  'min-age',
72  'm',
73  InputOption::VALUE_REQUIRED,
74  'Minimum age in days records need to be marked for deletion before actual deletion',
75  0
76  );
77  }
78 
82  protected function ‪execute(InputInterface $input, OutputInterface ‪$output): int
83  {
84  // Make sure the _cli_ user is loaded
86 
87  $io = new SymfonyStyle($input, ‪$output);
88  $io->title($this->getDescription());
89 
90  $startingPoint = 0;
91  if ($input->hasOption('pid') && ‪MathUtility::canBeInterpretedAsInteger($input->getOption('pid'))) {
92  $startingPoint = ‪MathUtility::forceIntegerInRange((int)$input->getOption('pid'), 0);
93  }
94 
95  $depth = 1000;
96  if ($input->hasOption('depth') && ‪MathUtility::canBeInterpretedAsInteger($input->getOption('depth'))) {
97  $depth = ‪MathUtility::forceIntegerInRange((int)$input->getOption('depth'), 0);
98  }
99 
100  $minimumAge = 0;
101  if (‪MathUtility::canBeInterpretedAsInteger($input->getOption('min-age'))) {
102  $minimumAge = ‪MathUtility::forceIntegerInRange((int)$input->getOption('min-age'), 0);
103  }
104  $maximumTimestamp = ‪$GLOBALS['EXEC_TIME'] - ($minimumAge * 86400);
105 
106  if ($io->isVerbose()) {
107  $io->section('Searching the database now for deleted records.');
108  }
109 
110  $dryRun = $input->hasOption('dry-run') && (bool)$input->getOption('dry-run') !== false;
111 
112  // find all records that should be deleted
113  $deletedRecords = $this->‪findAllFlaggedRecordsInPage($startingPoint, $depth, $maximumTimestamp);
114 
115  if (!$io->isQuiet()) {
116  $totalAmountOfTables = count($deletedRecords);
117  $totalAmountOfRecords = 0;
118  foreach ($deletedRecords as $tableName => $itemsInTable) {
119  $totalAmountOfRecords += count($itemsInTable);
120 
121  if ($io->isVeryVerbose()) {
122  $io->writeln('Found ' . count($itemsInTable) . ' deleted records in table "' . $tableName . '".');
123  }
124  }
125  $io->note('Found ' . $totalAmountOfRecords . ' records in ' . $totalAmountOfTables . ' database tables ready to be deleted.');
126  }
127 
128  $io->section('Deletion process starting now.' . ($dryRun ? ' (Not deleting now, just a dry run)' : ''));
129 
130  // actually permanently delete them
131  $this->‪deleteRecords($deletedRecords, $dryRun, $io);
132 
133  $io->success('All done!');
134  return Command::SUCCESS;
135  }
136 
149  protected function ‪findAllFlaggedRecordsInPage(int $pageId, int $depth, int $maximumTimestamp, array $deletedRecords = []): array
150  {
151  $queryBuilderForPages = $this->connectionPool
152  ->getQueryBuilderForTable('pages');
153  $queryBuilderForPages->getRestrictions()->removeAll();
154 
155  if ($pageId > 0) {
156  $pageQuery = $queryBuilderForPages
157  ->count('uid')
158  ->from('pages')
159  ->where(
160  $queryBuilderForPages->expr()->and(
161  $queryBuilderForPages->expr()->eq(
162  'uid',
163  $queryBuilderForPages->createNamedParameter($pageId, ‪Connection::PARAM_INT)
164  ),
165  $queryBuilderForPages->expr()->neq(
166  'deleted',
167  $queryBuilderForPages->createNamedParameter(0, ‪Connection::PARAM_INT)
168  )
169  )
170  );
171 
172  if ($maximumTimestamp > 0) {
173  $pageQuery->andWhere(
174  $queryBuilderForPages->expr()->lt(
175  'tstamp',
176  $queryBuilderForPages->createNamedParameter($maximumTimestamp, ‪Connection::PARAM_INT)
177  )
178  );
179  }
180 
181  // Register if page itself is deleted
182  if ($pageQuery->executeQuery()->fetchOne() > 0) {
183  $deletedRecords['pages'][$pageId] = $pageId;
184  }
185  }
186 
187  $databaseTables = $this->‪getTablesWithFlag('delete');
188  $databaseTablesWithTstamp = $this->‪getTablesWithFlag('tstamp');
189  // Traverse tables of records that belongs to page
190  foreach ($databaseTables as $tableName => $deletedField) {
191  // Select all records belonging to page
192  $queryBuilder = $this->connectionPool
193  ->getQueryBuilderForTable($tableName);
194 
195  $queryBuilder->getRestrictions()->removeAll();
196 
197  $query = $queryBuilder
198  ->select('uid', $deletedField)
199  ->from($tableName)
200  ->where(
201  $queryBuilder->expr()->eq(
202  'pid',
203  $queryBuilder->createNamedParameter($pageId, ‪Connection::PARAM_INT)
204  )
205  );
206  if (!isset($deletedRecords['pages'][$pageId])
207  && $maximumTimestamp > 0
208  && array_key_exists($tableName, $databaseTablesWithTstamp)
209  ) {
210  $query->andWhere(
211  $queryBuilder->expr()->lt(
212  $databaseTablesWithTstamp[$tableName],
213  $queryBuilder->createNamedParameter($maximumTimestamp, ‪Connection::PARAM_INT)
214  )
215  );
216  }
217  $result = $query->executeQuery();
218 
219  while ($recordOnPage = $result->fetchAssociative()) {
220  // Register record as deleted
221  if ($recordOnPage[$deletedField]) {
222  $deletedRecords[$tableName][$recordOnPage['uid']] = $recordOnPage['uid'];
223  }
224  // Add any versions of those records
225  $versions = BackendUtility::selectVersionsOfRecord(
226  $tableName,
227  $recordOnPage['uid'],
228  'uid,t3ver_wsid,' . $deletedField,
229  null,
230  true
231  ) ?: [];
232  if (is_array($versions)) {
233  foreach ($versions as $verRec) {
234  // Mark as deleted
235  if (!($verRec['_CURRENT_VERSION'] ?? false) && $verRec[$deletedField]) {
236  $deletedRecords[$tableName][$verRec['uid']] = $verRec['uid'];
237  }
238  }
239  }
240  }
241  }
242 
243  // Find subpages to root ID and go recursive
244  if ($depth > 0) {
245  $depth--;
246  $result = $queryBuilderForPages
247  ->select('uid')
248  ->from('pages')
249  ->where(
250  $queryBuilderForPages->expr()->eq('pid', $pageId)
251  )
252  ->orderBy('sorting')
253  ->executeQuery();
254 
255  while ($subPage = $result->fetchAssociative()) {
256  $deletedRecords = $this->‪findAllFlaggedRecordsInPage($subPage['uid'], $depth, $maximumTimestamp, $deletedRecords);
257  }
258  }
259 
260  // Add any versions of the page
261  if ($pageId > 0) {
262  $versions = BackendUtility::selectVersionsOfRecord(
263  'pages',
264  $pageId,
265  'uid,t3ver_oid,t3ver_wsid',
266  null,
267  true
268  ) ?: [];
269  if (is_array($versions)) {
270  foreach ($versions as $verRec) {
271  if (!($verRec['_CURRENT_VERSION'] ?? false)) {
272  $deletedRecords = $this->‪findAllFlaggedRecordsInPage($verRec['uid'], $depth, $maximumTimestamp, $deletedRecords);
273  }
274  }
275  }
276  }
277 
278  return $deletedRecords;
279  }
280 
284  protected function ‪getTablesWithFlag(string $flag): array
285  {
286  $tables = [];
287  foreach (‪$GLOBALS['TCA'] as $tableName => $configuration) {
288  if ($tableName !== 'pages' && isset(‪$GLOBALS['TCA'][$tableName]['ctrl'][$flag])) {
289  $tables[$tableName] = ‪$GLOBALS['TCA'][$tableName]['ctrl'][$flag];
290  }
291  }
292  ksort($tables);
293  return $tables;
294  }
295 
302  protected function ‪deleteRecords(array $deletedRecords, bool $dryRun, SymfonyStyle $io): void
303  {
304  // Putting "pages" table in the bottom
305  if (isset($deletedRecords['pages'])) {
306  $_pages = $deletedRecords['pages'];
307  unset($deletedRecords['pages']);
308  // To delete sub pages first assuming they are accumulated from top of page tree.
309  $deletedRecords['pages'] = array_reverse($_pages);
310  }
311 
312  // set up the data handler instance
313  $dataHandler = GeneralUtility::makeInstance(DataHandler::class);
314  $dataHandler->start([], []);
315 
316  // Loop through all tables and their records
317  foreach ($deletedRecords as $table => $list) {
318  if ($io->isVerbose()) {
319  $io->writeln('Flushing ' . count($list) . ' deleted records from table "' . $table . '"');
320  }
321  foreach ($list as ‪$uid) {
322  if ($io->isVeryVerbose()) {
323  $io->writeln('Flushing record "' . $table . ':' . ‪$uid . '"');
324  }
325  if (!$dryRun) {
326  // Notice, we are deleting pages with no regard to subpages/subrecords - we do this since they
327  // should also be included in the set of deleted pages of course (no un-deleted record can exist
328  // under a deleted page...)
329  $dataHandler->deleteRecord($table, (int)‪$uid, true, true);
330  // Return errors if any:
331  if (!empty($dataHandler->errorLog)) {
332  $errorMessage = array_merge(['DataHandler reported an error'], $dataHandler->errorLog);
333  $io->error($errorMessage);
334  } elseif (!$io->isQuiet()) {
335  $io->writeln('Permanently deleted record "' . $table . ':' . ‪$uid . '".');
336  }
337  }
338  }
339  }
340  }
341 }
‪TYPO3\CMS\Core\DataHandling\DataHandler
Definition: DataHandler.php:94
‪TYPO3\CMS\Core\Database\Connection\PARAM_INT
‪const PARAM_INT
Definition: Connection.php:52
‪TYPO3\CMS\Lowlevel\Command\DeletedRecordsCommand
Definition: DeletedRecordsCommand.php:39
‪TYPO3\CMS\Lowlevel\Command\DeletedRecordsCommand\findAllFlaggedRecordsInPage
‪array findAllFlaggedRecordsInPage(int $pageId, int $depth, int $maximumTimestamp, array $deletedRecords=[])
Definition: DeletedRecordsCommand.php:149
‪TYPO3\CMS\Lowlevel\Command\DeletedRecordsCommand\execute
‪execute(InputInterface $input, OutputInterface $output)
Definition: DeletedRecordsCommand.php:82
‪TYPO3\CMS\Core\Utility\MathUtility\canBeInterpretedAsInteger
‪static bool canBeInterpretedAsInteger(mixed $var)
Definition: MathUtility.php:69
‪TYPO3\CMS\Lowlevel\Command\DeletedRecordsCommand\configure
‪configure()
Definition: DeletedRecordsCommand.php:48
‪TYPO3\CMS\Lowlevel\Command\DeletedRecordsCommand\deleteRecords
‪deleteRecords(array $deletedRecords, bool $dryRun, SymfonyStyle $io)
Definition: DeletedRecordsCommand.php:302
‪$output
‪$output
Definition: annotationChecker.php:114
‪TYPO3\CMS\Core\Database\Connection
Definition: Connection.php:41
‪TYPO3\CMS\Webhooks\Message\$uid
‪identifier readonly int $uid
Definition: PageModificationMessage.php:35
‪$GLOBALS
‪$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['adminpanel']['modules']
Definition: ext_localconf.php:25
‪TYPO3\CMS\Core\Core\Bootstrap
Definition: Bootstrap.php:62
‪TYPO3\CMS\Lowlevel\Command\DeletedRecordsCommand\__construct
‪__construct(private readonly ConnectionPool $connectionPool)
Definition: DeletedRecordsCommand.php:40
‪TYPO3\CMS\Lowlevel\Command
Definition: CleanFlexFormsCommand.php:18
‪TYPO3\CMS\Core\Utility\MathUtility
Definition: MathUtility.php:24
‪TYPO3\CMS\Lowlevel\Command\DeletedRecordsCommand\getTablesWithFlag
‪getTablesWithFlag(string $flag)
Definition: DeletedRecordsCommand.php:284
‪TYPO3\CMS\Core\Database\ConnectionPool
Definition: ConnectionPool.php:46
‪TYPO3\CMS\Core\Utility\MathUtility\forceIntegerInRange
‪static int forceIntegerInRange(mixed $theInt, int $min, int $max=2000000000, int $defaultValue=0)
Definition: MathUtility.php:34
‪TYPO3\CMS\Core\Utility\GeneralUtility
Definition: GeneralUtility.php:52
‪TYPO3\CMS\Core\Core\Bootstrap\initializeBackendAuthentication
‪static initializeBackendAuthentication()
Definition: Bootstrap.php:527