TYPO3CMS  8
 All Classes Namespaces Files Functions Variables Pages
DeletedRecordsCommand.php
Go to the documentation of this file.
1 <?php
2 declare(strict_types=1);
3 namespace TYPO3\CMS\Lowlevel\Command;
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;
30 
34 class DeletedRecordsCommand extends Command
35 {
36 
40  public function configure()
41  {
42  $this
43  ->setDescription('Permanently deletes all records marked as "deleted" in the database.')
44  ->setHelp('Traverse page tree and find and flush deleted records. If you want to get more detailed information, use the --verbose option.')
45  ->addOption(
46  'pid',
47  'p',
48  InputOption::VALUE_REQUIRED,
49  'Setting start page in page tree. Default is the page tree root, 0 (zero)'
50  )
51  ->addOption(
52  'depth',
53  'd',
54  InputOption::VALUE_REQUIRED,
55  'Setting traversal depth. 0 (zero) will only analyse start page (see --pid), 1 will traverse one level of subpages etc.'
56  )
57  ->addOption(
58  'dry-run',
59  null,
60  InputOption::VALUE_NONE,
61  'If this option is set, the records will not actually be deleted, but just the output which records would be deleted are shown'
62  );
63  }
64 
73  protected function execute(InputInterface $input, OutputInterface $output)
74  {
75  // The backend user needs super-powers because datahandler is executed
76  $previouslyAppliedAdminRights = $this->getBackendUser()->user['admin'];
77  $this->getBackendUser()->user['admin'] = 1;
78 
79  $io = new SymfonyStyle($input, $output);
80  $io->title($this->getDescription());
81 
82  $startingPoint = 0;
83  if ($input->hasOption('pid') && MathUtility::canBeInterpretedAsInteger($input->getOption('pid'))) {
84  $startingPoint = MathUtility::forceIntegerInRange((int)$input->getOption('pid'), 0);
85  }
86 
87  $depth = 1000;
88  if ($input->hasOption('depth') && MathUtility::canBeInterpretedAsInteger($input->getOption('depth'))) {
89  $depth = MathUtility::forceIntegerInRange((int)$input->getOption('depth'), 0);
90  }
91 
92  if ($io->isVerbose()) {
93  $io->section('Searching the database now for deleted records.');
94  }
95 
96  // type unsafe comparison and explicit boolean setting on purpose
97  $dryRun = $input->hasOption('dry-run') && $input->getOption('dry-run') != false ? true : false;
98 
99  // find all records that should be deleted
100  $deletedRecords = $this->findAllFlaggedRecordsInPage($startingPoint, $depth);
101 
102  if (!$io->isQuiet()) {
103  $totalAmountOfTables = count($deletedRecords);
104  $totalAmountOfRecords = 0;
105  foreach ($deletedRecords as $tableName => $itemsInTable) {
106  $totalAmountOfRecords += count($itemsInTable);
107 
108  if ($io->isVeryVerbose()) {
109  $io->writeln('Found ' . count($itemsInTable) . ' deleted records in table "' . $tableName . '".');
110  }
111  }
112  $io->note('Found ' . $totalAmountOfRecords . ' records in ' . $totalAmountOfTables . ' database tables ready to be deleted.');
113  }
114 
115  $io->section('Deletion process starting now.' . ($dryRun ? ' (Not deleting now, just a dry run)' : ''));
116 
117  // actually permanently delete them
118  $this->deleteRecords($deletedRecords, $dryRun, $io);
119 
120  // Restore backend user administration rights
121  $this->getBackendUser()->user['admin'] = $previouslyAppliedAdminRights;
122 
123  $io->success('All done!');
124  }
125 
137  protected function findAllFlaggedRecordsInPage(int $pageId, int $depth, array $deletedRecords = []): array
138  {
140  $queryBuilderForPages = GeneralUtility::makeInstance(ConnectionPool::class)
141  ->getQueryBuilderForTable('pages');
142  $queryBuilderForPages->getRestrictions()->removeAll();
143 
144  $pageId = (int)$pageId;
145  if ($pageId > 0) {
146  $pageRecordIsDeleted = $queryBuilderForPages
147  ->select('uid', 'deleted')
148  ->from('pages')
149  ->where(
150  $queryBuilderForPages->expr()->andX(
151  $queryBuilderForPages->expr()->eq(
152  'uid',
153  $queryBuilderForPages->createNamedParameter($pageId, \PDO::PARAM_INT)
154  ),
155  $queryBuilderForPages->expr()->neq('deleted', 0)
156  )
157  )
158  ->execute();
159 
160  // Register if page itself is deleted
161  if ($pageRecordIsDeleted->rowCount() > 0) {
162  $deletedRecords['pages'][$pageId] = $pageId;
163  }
164  }
165 
166  $databaseTables = $this->getTablesWithDeletedFlags();
167  // Traverse tables of records that belongs to page
168  foreach ($databaseTables as $tableName => $deletedField) {
169  // Select all records belonging to page
170  $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
171  ->getQueryBuilderForTable($tableName);
172 
173  $queryBuilder->getRestrictions()->removeAll();
174 
175  $result = $queryBuilder
176  ->select('uid', $deletedField)
177  ->from($tableName)
178  ->where($queryBuilder->expr()->eq(
179  'pid',
180  $queryBuilder->createNamedParameter($pageId, \PDO::PARAM_INT))
181  )
182  ->execute();
183 
184  while ($recordOnPage = $result->fetch()) {
185  // Register record as deleted
186  if ($recordOnPage[$deletedField]) {
187  $deletedRecords[$tableName][$recordOnPage['uid']] = $recordOnPage['uid'];
188  }
189  // Add any versions of those records
190  $versions = BackendUtility::selectVersionsOfRecord($tableName, $recordOnPage['uid'],
191  'uid,t3ver_wsid,t3ver_count,' . $deletedField, null, true) ?: [];
192  if (is_array($versions)) {
193  foreach ($versions as $verRec) {
194  // Mark as deleted
195  if (!$verRec['_CURRENT_VERSION'] && $verRec[$deletedField]) {
196  $deletedRecords[$tableName][$verRec['uid']] = $verRec['uid'];
197  }
198  }
199  }
200  }
201  }
202 
203  // Find subpages to root ID and go recursive
204  if ($depth > 0) {
205  $depth--;
206  $result = $queryBuilderForPages
207  ->select('uid')
208  ->from('pages')
209  ->where(
210  $queryBuilderForPages->expr()->eq('pid', $pageId)
211  )
212  ->orderBy('sorting')
213  ->execute();
214 
215  while ($subPage = $result->fetch()) {
216  $deletedRecords = $this->findAllFlaggedRecordsInPage($subPage['uid'], $depth, $deletedRecords);
217  }
218  }
219 
220  // Add any versions of the page
221  if ($pageId > 0) {
223  'pages',
224  $pageId,
225  'uid,t3ver_oid,t3ver_wsid,t3ver_count',
226  null,
227  true
228  ) ?: [];
229  if (is_array($versions)) {
230  foreach ($versions as $verRec) {
231  if (!$verRec['_CURRENT_VERSION']) {
232  $deletedRecords = $this->findAllFlaggedRecordsInPage($verRec['uid'], $depth, $deletedRecords);
233  }
234  }
235  }
236  }
237 
238  return $deletedRecords;
239  }
240 
247  protected function getTablesWithDeletedFlags(): array
248  {
249  static $tables;
250  if (!is_array($tables)) {
251  $tables = [];
252  foreach ($GLOBALS['TCA'] as $tableName => $configuration) {
253  if ($tableName !== 'pages' && isset($GLOBALS['TCA'][$tableName]['ctrl']['delete'])) {
254  $tables[$tableName] = $GLOBALS['TCA'][$tableName]['ctrl']['delete'];
255  }
256  }
257  ksort($tables);
258  }
259  return $tables;
260  }
261 
270  protected function deleteRecords(array $deletedRecords, bool $dryRun, SymfonyStyle $io)
271  {
272  // Putting "pages" table in the bottom
273  if (isset($deletedRecords['pages'])) {
274  $_pages = $deletedRecords['pages'];
275  unset($deletedRecords['pages']);
276  // To delete sub pages first assuming they are accumulated from top of page tree.
277  $deletedRecords['pages'] = array_reverse($_pages);
278  }
279 
280  // set up the data handler instance
281  $dataHandler = GeneralUtility::makeInstance(DataHandler::class);
282  $dataHandler->start([], []);
283 
284  // Loop through all tables and their records
285  foreach ($deletedRecords as $table => $list) {
286  if ($io->isVerbose()) {
287  $io->writeln('Flushing ' . count($list) . ' deleted records from table "' . $table . '"');
288  }
289  foreach ($list as $uid) {
290  if ($io->isVeryVerbose()) {
291  $io->writeln('Flushing record "' . $table . ':' . $uid . '"');
292  }
293  if (!$dryRun) {
294  // Notice, we are deleting pages with no regard to subpages/subrecords - we do this since they
295  // should also be included in the set of deleted pages of course (no un-deleted record can exist
296  // under a deleted page...)
297  $dataHandler->deleteRecord($table, $uid, true, true);
298  // Return errors if any:
299  if (!empty($dataHandler->errorLog)) {
300  $errorMessage = array_merge(['DataHandler reported an error'], $dataHandler->errorLog);
301  $io->error($errorMessage);
302  } elseif (!$io->isQuiet()) {
303  $io->writeln('Permanently deleted record "' . $table . ':' . $uid . '".');
304  }
305  }
306  }
307  }
308  }
309 
315  {
316  return $GLOBALS['BE_USER'];
317  }
318 }
deleteRecords(array $deletedRecords, bool $dryRun, SymfonyStyle $io)
static selectVersionsOfRecord($table, $uid, $fields= '*', $workspace=0, $includeDeletedRecords=false, $row=null)
if(TYPO3_MODE=== 'BE') $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tsfebeuserauth.php']['frontendEditingController']['default']
execute(InputInterface $input, OutputInterface $output)
static makeInstance($className,...$constructorArguments)
static forceIntegerInRange($theInt, $min, $max=2000000000, $defaultValue=0)
Definition: MathUtility.php:31