‪TYPO3CMS  9.5
OrphanRecordsCommand.php
Go to the documentation of this file.
1 <?php
2 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 
18 use Symfony\Component\Console\Command\Command;
19 use Symfony\Component\Console\Input\InputInterface;
20 use Symfony\Component\Console\Input\InputOption;
21 use Symfony\Component\Console\Output\OutputInterface;
22 use Symfony\Component\Console\Style\SymfonyStyle;
28 
32 class ‪OrphanRecordsCommand extends Command
33 {
34 
38  public function ‪configure()
39  {
40  $this
41  ->setDescription('Find and delete records that have lost their connection with the page tree.')
42  ->setHelp('Assumption: All actively used records on the website from TCA configured tables are located in the page tree exclusively.
43 
44 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.
45 VERY TIME, CPU and MEMORY intensive operation since the full page tree is looked up!
46 
47 Automatic Repair of Errors:
48 - 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.
49 
50 Manual repair suggestions:
51 - 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 a 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.
52 
53  If you want to get more detailed information, use the --verbose option.')
54  ->addOption(
55  'dry-run',
56  null,
57  InputOption::VALUE_NONE,
58  'If this option is set, the records will not actually be deleted, but just the output which records would be deleted are shown'
59  );
60  }
61 
69  protected function ‪execute(InputInterface $input, OutputInterface ‪$output)
70  {
71  // Make sure the _cli_ user is loaded
73 
74  $io = new SymfonyStyle($input, ‪$output);
75  $io->title($this->getDescription());
76 
77  if ($io->isVerbose()) {
78  $io->section('Searching the database now for orphaned records.');
79  }
80 
81  // type unsafe comparison and explicit boolean setting on purpose
82  $dryRun = $input->hasOption('dry-run') && $input->getOption('dry-run') != false ? true : false;
83 
84  // find all records that should be deleted
85  $allRecords = $this->‪findAllConnectedRecordsInPage(0, 10000);
86 
87  // Find orphans
88  $orphans = [];
89  foreach (array_keys(‪$GLOBALS['TCA']) as $tableName) {
90  $idList = [0];
91  if (is_array($allRecords[$tableName]) && !empty($allRecords[$tableName])) {
92  $idList = $allRecords[$tableName];
93  }
94  // Select all records that are NOT connected
95  $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
96  ->getQueryBuilderForTable($tableName);
97 
98  $result = $queryBuilder
99  ->select('uid')
100  ->from($tableName)
101  ->where(
102  $queryBuilder->expr()->notIn(
103  'uid',
104  // do not use named parameter here as the list can get too long
105  array_map('intval', $idList)
106  )
107  )
108  ->orderBy('uid')
109  ->execute();
110 
111  $rowCount = $queryBuilder->count('uid')->execute()->fetchColumn(0);
112  if ($rowCount) {
113  $orphans[$tableName] = [];
114  while ($orphanRecord = $result->fetch()) {
115  $orphans[$tableName][$orphanRecord['uid']] = $orphanRecord['uid'];
116  }
117 
118  if (count($orphans[$tableName])) {
119  $io->note('Found ' . count($orphans[$tableName]) . ' orphan records in table "' . $tableName . '" with following ids: ' . implode(', ', $orphans[$tableName]));
120  }
121  }
122  }
123 
124  if (count($orphans)) {
125  $io->section('Deletion process starting now.' . ($dryRun ? ' (Not deleting now, just a dry run)' : ''));
126 
127  // Actually permanently delete them
128  $this->‪deleteRecords($orphans, $dryRun, $io);
129 
130  $io->success('All done!');
131  } else {
132  $io->success('No orphan records found.');
133  }
134  }
135 
147  protected function ‪findAllConnectedRecordsInPage(int $pageId, int $depth, array $allRecords = []): array
148  {
149  // Register page
150  if ($pageId > 0) {
151  $allRecords['pages'][$pageId] = $pageId;
152  }
153  // Traverse tables of records that belongs to page
154  foreach (array_keys(‪$GLOBALS['TCA']) as $tableName) {
155  if ($tableName !== 'pages') {
156  // Select all records belonging to page:
157  $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
158  ->getQueryBuilderForTable($tableName);
159 
160  $queryBuilder->getRestrictions()->removeAll();
161 
162  $result = $queryBuilder
163  ->select('uid')
164  ->from($tableName)
165  ->where(
166  $queryBuilder->expr()->eq(
167  'pid',
168  $queryBuilder->createNamedParameter($pageId, \PDO::PARAM_INT)
169  )
170  )
171  ->execute();
172 
173  while ($rowSub = $result->fetch()) {
174  $allRecords[$tableName][$rowSub['uid']] = $rowSub['uid'];
175  // Add any versions of those records:
176  $versions = ‪BackendUtility::selectVersionsOfRecord($tableName, $rowSub['uid'], 'uid,t3ver_wsid,t3ver_count', null, true);
177  if (is_array($versions)) {
178  foreach ($versions as $verRec) {
179  if (!$verRec['_CURRENT_VERSION']) {
180  $allRecords[$tableName][$verRec['uid']] = $verRec['uid'];
181  }
182  }
183  }
184  }
185  }
186  }
187  // Find subpages to root ID and traverse (only when rootID is not a version or is a branch-version):
188  if ($depth > 0) {
189  $depth--;
190  $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
191  ->getQueryBuilderForTable('pages');
192 
193  $queryBuilder->getRestrictions()->removeAll();
194 
195  $result = $queryBuilder
196  ->select('uid')
197  ->from('pages')
198  ->where(
199  $queryBuilder->expr()->eq(
200  'pid',
201  $queryBuilder->createNamedParameter($pageId, \PDO::PARAM_INT)
202  )
203  )
204  ->orderBy('sorting')
205  ->execute();
206 
207  while ($row = $result->fetch()) {
208  $allRecords = $this->‪findAllConnectedRecordsInPage((int)$row['uid'], $depth, $allRecords);
209  }
210  }
211 
212  // Add any versions of pages
213  if ($pageId > 0) {
214  $versions = ‪BackendUtility::selectVersionsOfRecord('pages', $pageId, 'uid,t3ver_oid,t3ver_wsid,t3ver_count', null, true);
215  if (is_array($versions)) {
216  foreach ($versions as $verRec) {
217  if (!$verRec['_CURRENT_VERSION']) {
218  $allRecords = $this->‪findAllConnectedRecordsInPage((int)$verRec['uid'], $depth, $allRecords);
219  }
220  }
221  }
222  }
223  return $allRecords;
224  }
225 
233  protected function ‪deleteRecords(array $orphanedRecords, bool $dryRun, SymfonyStyle $io)
234  {
235  // Putting "pages" table in the bottom
236  if (isset($orphanedRecords['pages'])) {
237  $_pages = $orphanedRecords['pages'];
238  unset($orphanedRecords['pages']);
239  // To delete sub pages first assuming they are accumulated from top of page tree.
240  $orphanedRecords['pages'] = array_reverse($_pages);
241  }
242 
243  // set up the data handler instance
244  $dataHandler = GeneralUtility::makeInstance(DataHandler::class);
245  $dataHandler->start([], []);
246 
247  // Loop through all tables and their records
248  foreach ($orphanedRecords as $table => $list) {
249  if ($io->isVerbose()) {
250  $io->writeln('Flushing ' . count($list) . ' orphaned records from table "' . $table . '"');
251  }
252  foreach ($list as $uid) {
253  if ($io->isVeryVerbose()) {
254  $io->writeln('Flushing record "' . $table . ':' . $uid . '"');
255  }
256  if (!$dryRun) {
257  // Notice, we are deleting pages with no regard to subpages/subrecords - we do this since they
258  // should also be included in the set of deleted pages of course (no un-deleted record can exist
259  // under a deleted page...)
260  $dataHandler->deleteRecord($table, $uid, true, true);
261  // Return errors if any:
262  if (!empty($dataHandler->errorLog)) {
263  $errorMessage = array_merge(['DataHandler reported an error'], $dataHandler->errorLog);
264  $io->error($errorMessage);
265  } elseif (!$io->isQuiet()) {
266  $io->writeln('Permanently deleted orphaned record "' . $table . ':' . $uid . '".');
267  }
268  }
269  }
270  }
271  }
272 }
‪TYPO3\CMS\Core\DataHandling\DataHandler
Definition: DataHandler.php:81
‪TYPO3\CMS\Lowlevel\Command\OrphanRecordsCommand\configure
‪configure()
Definition: OrphanRecordsCommand.php:38
‪TYPO3\CMS\Lowlevel\Command\OrphanRecordsCommand
Definition: OrphanRecordsCommand.php:33
‪TYPO3\CMS\Lowlevel\Command\OrphanRecordsCommand\deleteRecords
‪deleteRecords(array $orphanedRecords, bool $dryRun, SymfonyStyle $io)
Definition: OrphanRecordsCommand.php:233
‪TYPO3\CMS\Core\Core\Bootstrap\initializeBackendAuthentication
‪static Bootstrap null initializeBackendAuthentication($proceedIfNoUserIsLoggedIn=false)
Definition: Bootstrap.php:974
‪TYPO3\CMS\Lowlevel\Command\OrphanRecordsCommand\findAllConnectedRecordsInPage
‪array findAllConnectedRecordsInPage(int $pageId, int $depth, array $allRecords=[])
Definition: OrphanRecordsCommand.php:147
‪TYPO3\CMS\Lowlevel\Command\OrphanRecordsCommand\execute
‪execute(InputInterface $input, OutputInterface $output)
Definition: OrphanRecordsCommand.php:69
‪TYPO3\CMS\Backend\Utility\BackendUtility
Definition: BackendUtility.php:72
‪$output
‪$output
Definition: annotationChecker.php:113
‪$GLOBALS
‪$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['adminpanel']['modules']
Definition: ext_localconf.php:5
‪TYPO3\CMS\Core\Core\Bootstrap
Definition: Bootstrap.php:50
‪TYPO3\CMS\Backend\Utility\BackendUtility\selectVersionsOfRecord
‪static array null selectVersionsOfRecord( $table, $uid, $fields=' *', $workspace=0, $includeDeletedRecords=false, $row=null)
Definition: BackendUtility.php:3891
‪TYPO3\CMS\Lowlevel\Command
Definition: CleanFlexFormsCommand.php:3
‪TYPO3\CMS\Core\Database\ConnectionPool
Definition: ConnectionPool.php:44
‪TYPO3\CMS\Core\Utility\GeneralUtility
Definition: GeneralUtility.php:45