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