‪TYPO3CMS  ‪main
OrphanRecordsCommand.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;
32 
36 #[AsCommand('cleanup:orphanrecords', 'Find and delete records that have lost their connection with the page tree.')]
37 class ‪OrphanRecordsCommand extends Command
38 {
39  public function ‪__construct(private readonly ‪ConnectionPool $connectionPool)
40  {
41  parent::__construct();
42  }
43 
47  public function ‪configure()
48  {
49  $this
50  ->setHelp('Assumption: All actively used records on the website from TCA configured tables are located in the page tree exclusively.
51 
52 All records managed by TYPO3 via the TCA array configuration has to belong to a page in the page tree, either directly or indirectly as a version of another record.
53 VERY TIME, CPU and MEMORY intensive operation since the full page tree is looked up!
54 
55 Automatic Repair of Errors:
56 - Silently deleting the orphaned records. In theory they should not be used anywhere in the system, but there could be references. See below for more details on this matter.
57 
58 Manual repair suggestions:
59 - Possibly re-connect orphaned records to page tree by setting their "pid" field to a valid page id. A lookup in the sys_refindex table can reveal if there are references to an orphaned record. If there are such references (from records that are not themselves orphans) you might consider to re-connect the record to the page tree, otherwise it should be safe to delete it.
60 
61  If you want to get more detailed information, use the --verbose option.')
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  }
69 
74  protected function ‪execute(InputInterface $input, OutputInterface ‪$output): int
75  {
76  // Make sure the _cli_ user is loaded
78 
79  $io = new SymfonyStyle($input, ‪$output);
80  $io->title($this->getDescription());
81 
82  if ($io->isVerbose()) {
83  $io->section('Searching the database now for orphaned records.');
84  }
85 
86  $dryRun = $input->hasOption('dry-run') && (bool)$input->getOption('dry-run') !== false;
87 
88  // find all records that should be deleted
89  $allRecords = $this->‪findAllConnectedRecordsInPage(0, 10000);
90 
91  // Find orphans
92  $orphans = [];
93  foreach (array_keys(‪$GLOBALS['TCA']) as $tableName) {
94  $idList = [0];
95  if (is_array($allRecords[$tableName] ?? false) && !empty($allRecords[$tableName])) {
96  $idList = $allRecords[$tableName];
97  }
98  // Select all records that are NOT connected
99  $queryBuilder = $this->connectionPool
100  ->getQueryBuilderForTable($tableName);
101  $queryBuilder->getRestrictions()->removeAll();
102  $queryBuilder
103  ->from($tableName)
104  ->where(
105  $queryBuilder->expr()->notIn(
106  'uid',
107  // do not use named parameter here as the list can get too long
108  array_map('intval', $idList)
109  )
110  );
111 
112  $countQueryBuilder = clone $queryBuilder;
113  $rowCount = $countQueryBuilder->count('uid')->executeQuery()->fetchOne();
114  if ($rowCount) {
115  $queryBuilder->select('uid')->orderBy('uid');
116  $result = $queryBuilder->executeQuery();
117 
118  $orphans[$tableName] = [];
119  while ($orphanRecord = $result->fetchAssociative()) {
120  $orphans[$tableName][$orphanRecord['uid']] = $orphanRecord['uid'];
121  }
122 
123  if (count($orphans[$tableName])) {
124  $io->note('Found ' . count($orphans[$tableName]) . ' orphan records in table "' . $tableName . '" with following ids: ' . implode(', ', $orphans[$tableName]));
125  }
126  }
127  }
128 
129  if (count($orphans)) {
130  $io->section('Deletion process starting now.' . ($dryRun ? ' (Not deleting now, just a dry run)' : ''));
131 
132  // Actually permanently delete them
133  $this->‪deleteRecords($orphans, $dryRun, $io);
134 
135  $io->success('All done!');
136  } else {
137  $io->success('No orphan records found.');
138  }
139  return Command::SUCCESS;
140  }
141 
153  protected function ‪findAllConnectedRecordsInPage(int $pageId, int $depth, array $allRecords = []): array
154  {
155  // Register page
156  if ($pageId > 0) {
157  $allRecords['pages'][$pageId] = $pageId;
158  }
159  // Traverse tables of records that belongs to page
160  foreach (array_keys(‪$GLOBALS['TCA']) as $tableName) {
162  if ($tableName !== 'pages') {
163  // Select all records belonging to page:
164  $queryBuilder = $this->connectionPool
165  ->getQueryBuilderForTable($tableName);
166 
167  $queryBuilder->getRestrictions()->removeAll();
168 
169  $result = $queryBuilder
170  ->select('uid')
171  ->from($tableName)
172  ->where(
173  $queryBuilder->expr()->eq(
174  'pid',
175  $queryBuilder->createNamedParameter($pageId, ‪Connection::PARAM_INT)
176  )
177  )
178  ->executeQuery();
179 
180  while ($rowSub = $result->fetchAssociative()) {
181  $allRecords[$tableName][$rowSub['uid']] = $rowSub['uid'];
182  // Add any versions of those records:
183  $versions = BackendUtility::selectVersionsOfRecord($tableName, $rowSub['uid'], 'uid,t3ver_wsid', null, true);
184  if (is_array($versions)) {
185  foreach ($versions as $verRec) {
186  if (!($verRec['_CURRENT_VERSION'] ?? false)) {
187  $allRecords[$tableName][$verRec['uid']] = $verRec['uid'];
188  }
189  }
190  }
191  }
192  }
193  }
194  // Find subpages to root ID and traverse (only when rootID is not a version or is a branch-version):
195  if ($depth > 0) {
196  $depth--;
197  $queryBuilder = $this->connectionPool
198  ->getQueryBuilderForTable('pages');
199 
200  $queryBuilder->getRestrictions()->removeAll();
201 
202  $result = $queryBuilder
203  ->select('uid')
204  ->from('pages')
205  ->where(
206  $queryBuilder->expr()->eq(
207  'pid',
208  $queryBuilder->createNamedParameter($pageId, ‪Connection::PARAM_INT)
209  )
210  )
211  ->orderBy('sorting')
212  ->executeQuery();
213 
214  while ($row = $result->fetchAssociative()) {
215  $allRecords = $this->‪findAllConnectedRecordsInPage((int)$row['uid'], $depth, $allRecords);
216  }
217  }
218 
219  // Add any versions of pages
220  if ($pageId > 0) {
221  $versions = BackendUtility::selectVersionsOfRecord('pages', $pageId, 'uid,t3ver_oid,t3ver_wsid', null, true);
222  if (is_array($versions)) {
223  foreach ($versions as $verRec) {
224  if (!($verRec['_CURRENT_VERSION'] ?? false)) {
225  $allRecords = $this->‪findAllConnectedRecordsInPage((int)$verRec['uid'], $depth, $allRecords);
226  }
227  }
228  }
229  }
230  return $allRecords;
231  }
232 
239  protected function ‪deleteRecords(array $orphanedRecords, bool $dryRun, SymfonyStyle $io): void
240  {
241  // Putting "pages" table in the bottom
242  if (isset($orphanedRecords['pages'])) {
243  $_pages = $orphanedRecords['pages'];
244  unset($orphanedRecords['pages']);
245  // To delete sub pages first assuming they are accumulated from top of page tree.
246  $orphanedRecords['pages'] = array_reverse($_pages);
247  }
248 
249  // set up the data handler instance
250  $dataHandler = GeneralUtility::makeInstance(DataHandler::class);
251  $dataHandler->start([], []);
252 
253  // Loop through all tables and their records
254  foreach ($orphanedRecords as $table => $list) {
255  if ($io->isVerbose()) {
256  $io->writeln('Flushing ' . count($list) . ' orphaned records from table "' . $table . '"');
257  }
258  foreach ($list as ‪$uid) {
259  if ($io->isVeryVerbose()) {
260  $io->writeln('Flushing record "' . $table . ':' . ‪$uid . '"');
261  }
262  if (!$dryRun) {
263  // Notice, we are deleting pages with no regard to subpages/subrecords - we do this since they
264  // should also be included in the set of deleted pages of course (no un-deleted record can exist
265  // under a deleted page...)
266  $dataHandler->deleteRecord($table, (int)‪$uid, true, true);
267  // Return errors if any:
268  if (!empty($dataHandler->errorLog)) {
269  $errorMessage = array_merge(['DataHandler reported an error'], $dataHandler->errorLog);
270  $io->error($errorMessage);
271  } elseif (!$io->isQuiet()) {
272  $io->writeln('Permanently deleted orphaned record "' . $table . ':' . ‪$uid . '".');
273  }
274  }
275  }
276  }
277  }
278 }
‪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\OrphanRecordsCommand\configure
‪configure()
Definition: OrphanRecordsCommand.php:47
‪TYPO3\CMS\Lowlevel\Command\OrphanRecordsCommand
Definition: OrphanRecordsCommand.php:38
‪TYPO3\CMS\Lowlevel\Command\OrphanRecordsCommand\deleteRecords
‪deleteRecords(array $orphanedRecords, bool $dryRun, SymfonyStyle $io)
Definition: OrphanRecordsCommand.php:239
‪TYPO3\CMS\Lowlevel\Command\OrphanRecordsCommand\findAllConnectedRecordsInPage
‪array findAllConnectedRecordsInPage(int $pageId, int $depth, array $allRecords=[])
Definition: OrphanRecordsCommand.php:153
‪TYPO3\CMS\Lowlevel\Command\OrphanRecordsCommand\__construct
‪__construct(private readonly ConnectionPool $connectionPool)
Definition: OrphanRecordsCommand.php:39
‪TYPO3\CMS\Lowlevel\Command\OrphanRecordsCommand\execute
‪execute(InputInterface $input, OutputInterface $output)
Definition: OrphanRecordsCommand.php:74
‪$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
Definition: CleanFlexFormsCommand.php:18
‪TYPO3\CMS\Core\Database\ConnectionPool
Definition: ConnectionPool.php:46
‪TYPO3\CMS\Core\Utility\GeneralUtility
Definition: GeneralUtility.php:52
‪TYPO3\CMS\Core\Core\Bootstrap\initializeBackendAuthentication
‪static initializeBackendAuthentication()
Definition: Bootstrap.php:527