WorkspaceVersionRecordsCommand::execute()   F
last analyzed

Complexity

Conditions 31
Paths 13056

Size

Total Lines 123
Code Lines 72

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 31
eloc 72
nc 13056
nop 2
dl 0
loc 123
rs 0
c 0
b 0
f 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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
18
namespace TYPO3\CMS\Workspaces\Command;
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;
26
use TYPO3\CMS\Core\Core\Bootstrap;
27
use TYPO3\CMS\Core\Database\ConnectionPool;
28
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
29
use TYPO3\CMS\Core\DataHandling\DataHandler;
30
use TYPO3\CMS\Core\Utility\GeneralUtility;
31
use TYPO3\CMS\Core\Utility\MathUtility;
32
33
/**
34
 * Fetches all versions in the database, and checks for integrity
35
 */
36
class WorkspaceVersionRecordsCommand extends Command
37
{
38
    /**
39
     * List of all workspaces
40
     * @var array
41
     */
42
    protected $allWorkspaces = [0 => 'Live Workspace'];
43
44
    /**
45
     * @var ConnectionPool
46
     */
47
    private $connectionPool;
48
49
    public function __construct(ConnectionPool $connectionPool)
50
    {
51
        $this->connectionPool = $connectionPool;
52
        parent::__construct();
53
    }
54
55
    /**
56
     * Array with all records found when traversing the database
57
     * @var array
58
     */
59
    protected $foundRecords = [
60
        // All versions of records found
61
        // Subset of "all" which are offline versions (t3ver_oid > 0) [Informational]
62
        'all_versioned_records' => [],
63
        // All records that has been published and can therefore be removed permanently
64
        // Subset of "versions" that is a count of 1 or more (has been published) [Informational]
65
        'published_versions' => [],
66
        // All versions that are offline versions in the Live workspace. You may wish to flush these if you only use
67
        // workspaces for versioning since then you might find lots of versions piling up in the live workspace which
68
        // have simply been disconnected from the workspace before they were published.
69
        'versions_in_live' => [],
70
        // Versions that has lost their connection to a workspace in TYPO3.
71
        // Subset of "versions" that doesn't belong to an existing workspace [Warning: Fix by move to live workspace]
72
        'invalid_workspace' => []
73
    ];
74
75
    /**
76
     * Configuring the command options
77
     */
78
    public function configure()
79
    {
80
        $this
81
            ->setHelp('Traverse page tree and find versioned records. Also list all versioned records, additionally with some inconsistencies in the database, which can cleaned up with the "action" option. If you want to get more detailed information, use the --verbose option.')
82
            ->addOption(
83
                'pid',
84
                'p',
85
                InputOption::VALUE_REQUIRED,
86
                'Setting start page in page tree. Default is the page tree root, 0 (zero)'
87
            )
88
            ->addOption(
89
                'depth',
90
                'd',
91
                InputOption::VALUE_REQUIRED,
92
                'Setting traversal depth. 0 (zero) will only analyze start page (see --pid), 1 will traverse one level of subpages etc.'
93
            )
94
            ->addOption(
95
                'dry-run',
96
                null,
97
                InputOption::VALUE_NONE,
98
                'If this option is set, the records will not actually be deleted/modified, but just the output which records would be touched are shown'
99
            )
100
            ->addOption(
101
                'action',
102
                null,
103
                InputOption::VALUE_OPTIONAL,
104
                'Specify which action should be taken. Set it to "versions_in_live", "published_versions" or "invalid_workspace"'
105
            );
106
    }
107
108
    /**
109
     * Executes the command to find versioned records
110
     *
111
     * @param InputInterface $input
112
     * @param OutputInterface $output
113
     * @return int
114
     */
115
    protected function execute(InputInterface $input, OutputInterface $output)
116
    {
117
        // Make sure the _cli_ user is loaded
118
        Bootstrap::initializeBackendAuthentication();
119
120
        $io = new SymfonyStyle($input, $output);
121
        $io->title($this->getDescription());
122
123
        $startingPoint = 0;
124
        if ($input->hasOption('pid') && MathUtility::canBeInterpretedAsInteger($input->getOption('pid'))) {
125
            $startingPoint = MathUtility::forceIntegerInRange((int)$input->getOption('pid'), 0);
126
        }
127
128
        $depth = 1000;
129
        if ($input->hasOption('depth') && MathUtility::canBeInterpretedAsInteger($input->getOption('depth'))) {
130
            $depth = MathUtility::forceIntegerInRange((int)$input->getOption('depth'), 0);
131
        }
132
133
        $action = '';
134
        if ($input->hasOption('action') && !empty($input->getOption('action'))) {
135
            $action = $input->getOption('action');
136
        }
137
138
        // type unsafe comparison and explicit boolean setting on purpose
139
        $dryRun = $input->hasOption('dry-run') && $input->getOption('dry-run') != false ? true : false;
140
141
        if ($io->isVerbose()) {
142
            $io->section('Searching the database now for versioned records.');
143
        }
144
145
        $this->loadAllWorkspaceRecords();
146
147
        // Find all records that are versioned
148
        $this->traversePageTreeForVersionedRecords($startingPoint, $depth);
149
        // Sort recStats (for diff'able displays)
150
        foreach ($this->foundRecords as $kk => $vv) {
151
            foreach ($this->foundRecords[$kk] as $tables => $recArrays) {
152
                ksort($this->foundRecords[$kk][$tables]);
153
            }
154
            ksort($this->foundRecords[$kk]);
155
        }
156
157
        if (!$io->isQuiet()) {
158
            $numberOfVersionedRecords = 0;
159
            foreach ($this->foundRecords['all_versioned_records'] as $records) {
160
                $numberOfVersionedRecords += count($records);
161
            }
162
163
            $io->section('Found ' . $numberOfVersionedRecords . ' versioned records in the database.');
164
            if ($io->isVeryVerbose()) {
165
                foreach ($this->foundRecords['all_versioned_records'] as $table => $records) {
166
                    $io->writeln('Table "' . $table . '"');
167
                    $io->listing($records);
168
                }
169
            }
170
171
            $numberOfPublishedVersions = 0;
172
            foreach ($this->foundRecords['published_versions'] as $records) {
173
                $numberOfPublishedVersions += count($records);
174
            }
175
            $io->section('Found ' . $numberOfPublishedVersions . ' versioned records that have been published.');
176
            if ($io->isVeryVerbose()) {
177
                foreach ($this->foundRecords['published_versions'] as $table => $records) {
178
                    $io->writeln('Table "' . $table . '"');
179
                    $io->listing($records);
180
                }
181
            }
182
183
            $numberOfVersionsInLiveWorkspace = 0;
184
            foreach ($this->foundRecords['versions_in_live'] as $records) {
185
                $numberOfVersionsInLiveWorkspace += count($records);
186
            }
187
            $io->section('Found ' . $numberOfVersionsInLiveWorkspace . ' versioned records that are in the live workspace.');
188
            if ($io->isVeryVerbose()) {
189
                foreach ($this->foundRecords['versions_in_live'] as $table => $records) {
190
                    $io->writeln('Table "' . $table . '"');
191
                    $io->listing($records);
192
                }
193
            }
194
195
            $numberOfVersionsWithInvalidWorkspace = 0;
196
            foreach ($this->foundRecords['invalid_workspace'] as $records) {
197
                $numberOfVersionsWithInvalidWorkspace += count($records);
198
            }
199
            $io->section('Found ' . $numberOfVersionsWithInvalidWorkspace . ' versioned records with an invalid workspace.');
200
            if ($io->isVeryVerbose()) {
201
                foreach ($this->foundRecords['invalid_workspace'] as $table => $records) {
202
                    $io->writeln('Table "' . $table . '"');
203
                    $io->listing($records);
204
                }
205
            }
206
        }
207
208
        // Actually permanently delete / update records
209
        switch ($action) {
210
            // All versions that are offline versions in the Live workspace. You may wish to flush these if you only use
211
            // workspaces for versioning since then you might find lots of versions piling up in the live workspace which
212
            // have simply been disconnected from the workspace before they were published.
213
            case 'versions_in_live':
214
                $io->section('Deleting versioned records in live workspace now. ' . ($dryRun ? ' (Not deleting now, just a dry run)' : ''));
215
                $this->deleteRecords($this->foundRecords['versions_in_live'], $dryRun, $io);
216
                break;
217
218
            // All records that has been published and can therefore be removed permanently
219
            // Subset of "versions" that is a count of 1 or more (has been published)
220
            case 'published_versions':
221
                $io->section('Deleting published records in live workspace now. ' . ($dryRun ? ' (Not deleting now, just a dry run)' : ''));
222
                $this->deleteRecords($this->foundRecords['published_versions'], $dryRun, $io);
223
                break;
224
225
            // Versions that has lost their connection to a workspace in TYPO3.
226
            // Subset of "versions" that doesn't belong to an existing workspace [Warning: Fix by move to live workspace]
227
            case 'invalid_workspace':
228
                $io->section('Moving versions in invalid workspaces to live workspace now. ' . ($dryRun ? ' (Not deleting now, just a dry run)' : ''));
229
                $this->resetRecordsWithoutValidWorkspace($this->foundRecords['invalid_workspace'], $dryRun, $io);
230
                break;
231
232
            default:
233
                $io->note('No action specified, just displaying statistics. See --action option for details.');
234
                break;
235
        }
236
        $io->success('All done!');
237
        return 0;
238
    }
239
240
    /**
241
     * Recursive traversal of page tree, fetching ALL versioned records found in the database
242
     *
243
     * @param int $rootID Page root id (must be online, valid page record - or zero for page tree root)
244
     * @param int $depth Depth
245
     * @param bool $isInsideVersionedPage DON'T set from outside, internal. (indicates we are inside a version of a page)
246
     * @param bool $rootIsVersion DON'T set from outside, internal. Indicates that rootID is a version of a page
247
     */
248
    protected function traversePageTreeForVersionedRecords(int $rootID, int $depth, bool $isInsideVersionedPage = false, bool $rootIsVersion = false)
249
    {
250
        $queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages');
251
        $queryBuilder->getRestrictions()->removeAll();
252
253
        $pageRecord = $queryBuilder
254
            ->select(
255
                'deleted',
256
                'title',
257
                't3ver_wsid'
258
            )
259
            ->from('pages')
260
            ->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($rootID, \PDO::PARAM_INT)))
261
            ->execute()
262
            ->fetch();
263
264
        // If rootIsVersion is set it means that the input rootID is that of a version of a page. See below where the recursive call is made.
265
        if ($rootIsVersion) {
266
            $workspaceId = (int)$pageRecord['t3ver_wsid'];
267
            $this->foundRecords['all_versioned_records']['pages'][$rootID] = $rootID;
268
            // If it has been published and is in archive now...
269
            if ($workspaceId === 0) {
270
                $this->foundRecords['versions_in_live']['pages'][$rootID] = $rootID;
271
            }
272
            // If it doesn't belong to a workspace...
273
            if (!isset($this->allWorkspaces[$workspaceId])) {
274
                $this->foundRecords['invalid_workspace']['pages'][$rootID] = $rootID;
275
            }
276
        }
277
        // Only check for records if not inside a version
278
        if (!$isInsideVersionedPage) {
279
            // Traverse tables of records that belongs to page
280
            $tableNames = $this->getAllVersionableTables();
281
            foreach ($tableNames as $tableName) {
282
                if ($tableName !== 'pages') {
283
                    // Select all records belonging to page:
284
                    $queryBuilder = $this->connectionPool
285
                        ->getQueryBuilderForTable($tableName);
286
287
                    $queryBuilder->getRestrictions()->removeAll();
288
289
                    $result = $queryBuilder
290
                        ->select('uid')
291
                        ->from($tableName)
292
                        ->where(
293
                            $queryBuilder->expr()->eq(
294
                                'pid',
295
                                $queryBuilder->createNamedParameter($rootID, \PDO::PARAM_INT)
296
                            )
297
                        )
298
                        ->execute();
299
                    while ($rowSub = $result->fetch()) {
300
                        // Add any versions of those records
301
                        $versions = BackendUtility::selectVersionsOfRecord($tableName, $rowSub['uid'], 'uid,t3ver_wsid' . ($GLOBALS['TCA'][$tableName]['ctrl']['delete'] ? ',' . $GLOBALS['TCA'][$tableName]['ctrl']['delete'] : ''), null, true);
302
                        if (is_array($versions)) {
303
                            foreach ($versions as $verRec) {
304
                                if (!$verRec['_CURRENT_VERSION']) {
305
                                    // Register version
306
                                    $this->foundRecords['all_versioned_records'][$tableName][$verRec['uid']] = $verRec['uid'];
307
                                    $workspaceId = (int)$verRec['t3ver_wsid'];
308
                                    if ($workspaceId === 0) {
309
                                        $this->foundRecords['versions_in_live'][$tableName][$verRec['uid']] = $verRec['uid'];
310
                                    }
311
                                    if (!isset($this->allWorkspaces[$workspaceId])) {
312
                                        $this->foundRecords['invalid_workspace'][$tableName][$verRec['uid']] = $verRec['uid'];
313
                                    }
314
                                }
315
                            }
316
                        }
317
                    }
318
                }
319
            }
320
        }
321
        // Find subpages to root ID and traverse (only when rootID is not a version or is a branch-version):
322
        if ($depth > 0) {
323
            $depth--;
324
            $queryBuilder = $this->connectionPool
325
                ->getQueryBuilderForTable('pages');
326
327
            $queryBuilder->getRestrictions()->removeAll();
328
            $queryBuilder->getRestrictions()->add(GeneralUtility::makeInstance(DeletedRestriction::class));
329
330
            $queryBuilder
331
                ->select('uid')
332
                ->from('pages')
333
                ->where(
334
                    $queryBuilder->expr()->eq(
335
                        'pid',
336
                        $queryBuilder->createNamedParameter($rootID, \PDO::PARAM_INT)
337
                    )
338
                )
339
                ->orderBy('sorting');
340
341
            $result = $queryBuilder->execute();
342
            while ($row = $result->fetch()) {
343
                $this->traversePageTreeForVersionedRecords((int)$row['uid'], $depth, $isInsideVersionedPage, false);
344
            }
345
        }
346
        // Add any versions of pages
347
        if ($rootID > 0) {
348
            $versions = BackendUtility::selectVersionsOfRecord('pages', $rootID, 'uid,t3ver_oid,t3ver_wsid', null, true);
349
            if (is_array($versions)) {
350
                foreach ($versions as $verRec) {
351
                    if (!$verRec['_CURRENT_VERSION']) {
352
                        $this->traversePageTreeForVersionedRecords((int)$verRec['uid'], $depth, true, true);
353
                    }
354
                }
355
            }
356
        }
357
    }
358
359
    /**************************
360
     * actions / delete methods
361
     **************************/
362
363
    /**
364
     * Deletes records via DataHandler
365
     *
366
     * @param array $records two level array with tables and uids
367
     * @param bool $dryRun check if the records should NOT be deleted (use --dry-run to avoid)
368
     * @param SymfonyStyle $io
369
     */
370
    protected function deleteRecords(array $records, bool $dryRun, SymfonyStyle $io)
371
    {
372
        // Putting "pages" table in the bottom
373
        if (isset($records['pages'])) {
374
            $_pages = $records['pages'];
375
            unset($records['pages']);
376
            // To delete sub pages first assuming they are accumulated from top of page tree.
377
            $records['pages'] = array_reverse($_pages);
378
        }
379
380
        // Set up the data handler instance
381
        $dataHandler = GeneralUtility::makeInstance(DataHandler::class);
382
        $dataHandler->start([], []);
383
384
        // Traversing records
385
        foreach ($records as $table => $uidsInTable) {
386
            if ($io->isVerbose()) {
387
                $io->writeln('Flushing published records from table "' . $table . '"');
388
            }
389
            foreach ($uidsInTable as $uid) {
390
                if ($io->isVeryVerbose()) {
391
                    $io->writeln('Flushing record "' . $table . ':' . $uid . '"');
392
                }
393
                if (!$dryRun) {
394
                    $dataHandler->deleteEl($table, $uid, true, true);
395
                    if (!empty($dataHandler->errorLog)) {
396
                        $errorMessage = array_merge(['DataHandler reported an error'], $dataHandler->errorLog);
397
                        $io->error($errorMessage);
398
                    } elseif (!$io->isQuiet()) {
399
                        $io->writeln('Flushed published record "' . $table . ':' . $uid . '".');
400
                    }
401
                }
402
            }
403
        }
404
    }
405
406
    /**
407
     * Set the workspace ID to "0" (= live) for records that have a workspace not found
408
     * in the system (e.g. hard deleted in the database)
409
     *
410
     * @param array $records array with array of table and uid of each record
411
     * @param bool $dryRun check if the records should NOT be deleted (use --dry-run to avoid)
412
     * @param SymfonyStyle $io
413
     */
414
    protected function resetRecordsWithoutValidWorkspace(array $records, bool $dryRun, SymfonyStyle $io)
415
    {
416
        foreach ($records as $table => $uidsInTable) {
417
            if ($io->isVerbose()) {
418
                $io->writeln('Resetting workspace to zero for records from table "' . $table . '"');
419
            }
420
            foreach ($uidsInTable as $uid) {
421
                if ($io->isVeryVerbose()) {
422
                    $io->writeln('Flushing record "' . $table . ':' . $uid . '"');
423
                }
424
                if (!$dryRun) {
425
                    $queryBuilder = $this->connectionPool
426
                        ->getQueryBuilderForTable($table);
427
428
                    $queryBuilder
429
                        ->update($table)
430
                        ->where(
431
                            $queryBuilder->expr()->eq(
432
                                'uid',
433
                                $queryBuilder->createNamedParameter($uid, \PDO::PARAM_INT)
434
                            )
435
                        )
436
                        ->set('t3ver_wsid', 0)
437
                        ->execute();
438
                    if (!$io->isQuiet()) {
439
                        $io->writeln('Flushed record "' . $table . ':' . $uid . '".');
440
                    }
441
                }
442
            }
443
        }
444
    }
445
446
    /**
447
     * HELPER FUNCTIONS
448
     */
449
450
    /**
451
     * Fetches all sys_workspace records from the database
452
     *
453
     * @return array all workspaces with UID as key, and the title as value
454
     */
455
    protected function loadAllWorkspaceRecords(): array
456
    {
457
        $queryBuilder = $this->connectionPool
458
            ->getQueryBuilderForTable('sys_workspace');
459
460
        $queryBuilder->getRestrictions()
461
            ->removeAll()
462
            ->add(GeneralUtility::makeInstance(DeletedRestriction::class));
463
464
        $result = $queryBuilder
465
            ->select('uid', 'title')
466
            ->from('sys_workspace')
467
            ->execute();
468
469
        while ($workspaceRecord = $result->fetch()) {
470
            $this->allWorkspaces[(int)$workspaceRecord['uid']] = $workspaceRecord['title'];
471
        }
472
        return $this->allWorkspaces;
473
    }
474
475
    /**
476
     * Returns all TCA tables where workspaces is enabled
477
     *
478
     * @return array
479
     */
480
    protected function getAllVersionableTables(): array
481
    {
482
        $tables = [];
483
        foreach ($GLOBALS['TCA'] as $tableName => $config) {
484
            if (BackendUtility::isTableWorkspaceEnabled($tableName)) {
485
                $tables[] = $tableName;
486
            }
487
        }
488
        return $tables;
489
    }
490
}
491