[BUGFIX] Fix several typos in php comments
[Packages/TYPO3.CMS.git] / typo3 / sysext / lowlevel / Classes / Command / DeletedRecordsCommand.php
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;
23 use TYPO3\CMS\Backend\Utility\BackendUtility;
24 use TYPO3\CMS\Core\Core\Bootstrap;
25 use TYPO3\CMS\Core\Database\ConnectionPool;
26 use TYPO3\CMS\Core\Database\Query\QueryBuilder;
27 use TYPO3\CMS\Core\DataHandling\DataHandler;
28 use TYPO3\CMS\Core\Utility\GeneralUtility;
29 use TYPO3\CMS\Core\Utility\MathUtility;
30
31 /**
32 * Force-deletes all records in the database which have a deleted=1 flag
33 */
34 class DeletedRecordsCommand extends Command
35 {
36
37 /**
38 * Configure the command by defining the name, options and arguments
39 */
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 analyze 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
65 /**
66 * Executes the command to find and permanently delete records which are marked as deleted
67 *
68 * @param InputInterface $input
69 * @param OutputInterface $output
70 */
71 protected function execute(InputInterface $input, OutputInterface $output)
72 {
73 // Make sure the _cli_ user is loaded
74 Bootstrap::initializeBackendAuthentication();
75
76 $io = new SymfonyStyle($input, $output);
77 $io->title($this->getDescription());
78
79 $startingPoint = 0;
80 if ($input->hasOption('pid') && MathUtility::canBeInterpretedAsInteger($input->getOption('pid'))) {
81 $startingPoint = MathUtility::forceIntegerInRange((int)$input->getOption('pid'), 0);
82 }
83
84 $depth = 1000;
85 if ($input->hasOption('depth') && MathUtility::canBeInterpretedAsInteger($input->getOption('depth'))) {
86 $depth = MathUtility::forceIntegerInRange((int)$input->getOption('depth'), 0);
87 }
88
89 if ($io->isVerbose()) {
90 $io->section('Searching the database now for deleted records.');
91 }
92
93 // type unsafe comparison and explicit boolean setting on purpose
94 $dryRun = $input->hasOption('dry-run') && $input->getOption('dry-run') != false ? true : false;
95
96 // find all records that should be deleted
97 $deletedRecords = $this->findAllFlaggedRecordsInPage($startingPoint, $depth);
98
99 if (!$io->isQuiet()) {
100 $totalAmountOfTables = count($deletedRecords);
101 $totalAmountOfRecords = 0;
102 foreach ($deletedRecords as $tableName => $itemsInTable) {
103 $totalAmountOfRecords += count($itemsInTable);
104
105 if ($io->isVeryVerbose()) {
106 $io->writeln('Found ' . count($itemsInTable) . ' deleted records in table "' . $tableName . '".');
107 }
108 }
109 $io->note('Found ' . $totalAmountOfRecords . ' records in ' . $totalAmountOfTables . ' database tables ready to be deleted.');
110 }
111
112 $io->section('Deletion process starting now.' . ($dryRun ? ' (Not deleting now, just a dry run)' : ''));
113
114 // actually permanently delete them
115 $this->deleteRecords($deletedRecords, $dryRun, $io);
116
117 $io->success('All done!');
118 }
119
120 /**
121 * Recursive traversal of page tree to fetch all records marked as "deleted",
122 * via option $GLOBALS[TCA][$tableName][ctrl][delete]
123 * This also takes deleted versioned records into account.
124 *
125 * @param int $pageId the uid of the pages record (can also be 0)
126 * @param int $depth The current depth of levels to go down
127 * @param array $deletedRecords the records that are already marked as deleted (used when going recursive)
128 *
129 * @return array the modified $deletedRecords array
130 */
131 protected function findAllFlaggedRecordsInPage(int $pageId, int $depth, array $deletedRecords = []): array
132 {
133 /** @var QueryBuilder $queryBuilderForPages */
134 $queryBuilderForPages = GeneralUtility::makeInstance(ConnectionPool::class)
135 ->getQueryBuilderForTable('pages');
136 $queryBuilderForPages->getRestrictions()->removeAll();
137
138 $pageId = (int)$pageId;
139 if ($pageId > 0) {
140 $queryBuilderForPages
141 ->select('uid', 'deleted')
142 ->from('pages')
143 ->where(
144 $queryBuilderForPages->expr()->andX(
145 $queryBuilderForPages->expr()->eq(
146 'uid',
147 $queryBuilderForPages->createNamedParameter($pageId, \PDO::PARAM_INT)
148 ),
149 $queryBuilderForPages->expr()->neq('deleted', 0)
150 )
151 )
152 ->execute();
153 $rowCount = $queryBuilderForPages
154 ->count('uid')
155 ->execute()
156 ->fetchColumn(0);
157 // Register if page itself is deleted
158 if ($rowCount > 0) {
159 $deletedRecords['pages'][$pageId] = $pageId;
160 }
161 }
162
163 $databaseTables = $this->getTablesWithDeletedFlags();
164 // Traverse tables of records that belongs to page
165 foreach ($databaseTables as $tableName => $deletedField) {
166 // Select all records belonging to page
167 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
168 ->getQueryBuilderForTable($tableName);
169
170 $queryBuilder->getRestrictions()->removeAll();
171
172 $result = $queryBuilder
173 ->select('uid', $deletedField)
174 ->from($tableName)
175 ->where(
176 $queryBuilder->expr()->eq(
177 'pid',
178 $queryBuilder->createNamedParameter($pageId, \PDO::PARAM_INT)
179 )
180 )
181 ->execute();
182
183 while ($recordOnPage = $result->fetch()) {
184 // Register record as deleted
185 if ($recordOnPage[$deletedField]) {
186 $deletedRecords[$tableName][$recordOnPage['uid']] = $recordOnPage['uid'];
187 }
188 // Add any versions of those records
189 $versions = BackendUtility::selectVersionsOfRecord(
190 $tableName,
191 $recordOnPage['uid'],
192 'uid,t3ver_wsid,t3ver_count,' . $deletedField,
193 null,
194 true
195 ) ?: [];
196 if (is_array($versions)) {
197 foreach ($versions as $verRec) {
198 // Mark as deleted
199 if (!$verRec['_CURRENT_VERSION'] && $verRec[$deletedField]) {
200 $deletedRecords[$tableName][$verRec['uid']] = $verRec['uid'];
201 }
202 }
203 }
204 }
205 }
206
207 // Find subpages to root ID and go recursive
208 if ($depth > 0) {
209 $depth--;
210 $result = $queryBuilderForPages
211 ->select('uid')
212 ->from('pages')
213 ->where(
214 $queryBuilderForPages->expr()->eq('pid', $pageId)
215 )
216 ->orderBy('sorting')
217 ->execute();
218
219 while ($subPage = $result->fetch()) {
220 $deletedRecords = $this->findAllFlaggedRecordsInPage($subPage['uid'], $depth, $deletedRecords);
221 }
222 }
223
224 // Add any versions of the page
225 if ($pageId > 0) {
226 $versions = BackendUtility::selectVersionsOfRecord(
227 'pages',
228 $pageId,
229 'uid,t3ver_oid,t3ver_wsid,t3ver_count',
230 null,
231 true
232 ) ?: [];
233 if (is_array($versions)) {
234 foreach ($versions as $verRec) {
235 if (!$verRec['_CURRENT_VERSION']) {
236 $deletedRecords = $this->findAllFlaggedRecordsInPage($verRec['uid'], $depth, $deletedRecords);
237 }
238 }
239 }
240 }
241
242 return $deletedRecords;
243 }
244
245 /**
246 * Fetches all tables registered in the TCA with a deleted
247 * and that are not pages (which are handled separately)
248 *
249 * @return array an associative array with the table as key and the
250 */
251 protected function getTablesWithDeletedFlags(): array
252 {
253 $tables = [];
254 foreach ($GLOBALS['TCA'] as $tableName => $configuration) {
255 if ($tableName !== 'pages' && isset($GLOBALS['TCA'][$tableName]['ctrl']['delete'])) {
256 $tables[$tableName] = $GLOBALS['TCA'][$tableName]['ctrl']['delete'];
257 }
258 }
259 ksort($tables);
260 return $tables;
261 }
262
263 /**
264 * Deletes records via DataHandler
265 *
266 * @param array $deletedRecords two level array with tables and uids
267 * @param bool $dryRun check if the records should NOT be deleted (use --dry-run to avoid)
268 * @param SymfonyStyle $io
269 */
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 }