CleanupVersionsCommand::execute()   F
last analyzed

Complexity

Conditions 15
Paths 1254

Size

Total Lines 120

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 15
nc 1254
nop 2
dl 0
loc 120
rs 1.3999
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
/**
4
 * @copyright Copyright (C) eZ Systems AS. All rights reserved.
5
 * @license For full copyright and license information view LICENSE file distributed with this source code.
6
 */
7
namespace eZ\Bundle\EzPublishCoreBundle\Command;
8
9
use Doctrine\DBAL\Connection;
10
use Exception;
11
use eZ\Bundle\EzPublishCoreBundle\ApiLoader\RepositoryConfigurationProvider;
12
use eZ\Publish\API\Repository\Repository;
13
use eZ\Publish\API\Repository\Values\Content\VersionInfo;
14
use eZ\Publish\Core\Base\Exceptions\InvalidArgumentException;
15
use PDO;
16
use Symfony\Component\Console\Command\Command;
17
use Symfony\Component\Console\Helper\ProgressBar;
18
use Symfony\Component\Console\Input\InputInterface;
19
use Symfony\Component\Console\Input\InputOption;
20
use Symfony\Component\Console\Output\OutputInterface;
21
22
class CleanupVersionsCommand extends Command
23
{
24
    const DEFAULT_REPOSITORY_USER = 'admin';
25
    const DEFAULT_EXCLUDED_CONTENT_TYPES = 'user';
26
27
    const BEFORE_RUNNING_HINTS = <<<EOT
28
<error>Before you continue:</error>
29
- Make sure to back up your database.
30
- Take the installation offline. The database should not be modified while the script is being executed.
31
- Run this command without memory limit.
32
- Run this command in production environment using <info>--env=prod</info>
33
EOT;
34
35
    const VERSION_DRAFT = 'draft';
36
    const VERSION_ARCHIVED = 'archived';
37
    const VERSION_PUBLISHED = 'published';
38
    const VERSION_ALL = 'all';
39
40
    const VERSION_STATUS = [
41
        self::VERSION_DRAFT => VersionInfo::STATUS_DRAFT,
42
        self::VERSION_ARCHIVED => VersionInfo::STATUS_ARCHIVED,
43
        self::VERSION_PUBLISHED => VersionInfo::STATUS_PUBLISHED,
44
    ];
45
46
    /** @var \eZ\Publish\API\Repository\Repository */
47
    private $repository;
48
49
    /** @var \eZ\Bundle\EzPublishCoreBundle\ApiLoader\RepositoryConfigurationProvider */
50
    private $repositoryConfigurationProvider;
51
52
    /** @var \Doctrine\DBAL\Driver\Connection */
53
    private $connection;
54
55
    public function __construct(
56
        Repository $repository,
57
        RepositoryConfigurationProvider $repositoryConfigurationProvider,
58
        Connection $connection
59
    ) {
60
        $this->repository = $repository;
61
        $this->repositoryConfigurationProvider = $repositoryConfigurationProvider;
62
        $this->connection = $connection;
63
64
        parent::__construct();
65
    }
66
67
    protected function configure()
68
    {
69
        $beforeRunningHints = self::BEFORE_RUNNING_HINTS;
70
        $this
71
            ->setName('ezplatform:content:cleanup-versions')
72
            ->setDescription('Removes unwanted content versions. Keeps the published version untouched. By default, also keeps the last archived/draft version.')
73
            ->addOption(
74
                'status',
75
                't',
76
                InputOption::VALUE_OPTIONAL,
77
                sprintf(
78
                    "Select which version types should be removed: '%s', '%s', '%s'.",
79
                    self::VERSION_DRAFT,
80
                    self::VERSION_ARCHIVED,
81
                    self::VERSION_ALL
82
                ),
83
                self::VERSION_ALL
84
            )
85
            ->addOption(
86
                'keep',
87
                'k',
88
                InputOption::VALUE_OPTIONAL,
89
                "Sets the number of the most recent versions (both drafts and archived) which won't be removed.",
90
                'config_default'
91
            )
92
            ->addOption(
93
                'user',
94
                'u',
95
                InputOption::VALUE_OPTIONAL,
96
                'eZ Platform username (with Role containing at least content policies: remove, read, versionread)',
97
                self::DEFAULT_REPOSITORY_USER
98
            )
99
            ->addOption(
100
                'excluded-content-types',
101
                null,
102
                InputOption::VALUE_OPTIONAL,
103
                'Comma-separated list of Content Type identifiers whose versions should not be removed, for instance `article`.',
104
                self::DEFAULT_EXCLUDED_CONTENT_TYPES
105
            )->setHelp(
106
                <<<EOT
107
The command <info>%command.name%</info> reduces content versions to a minimum. 
108
It keeps published version untouched, and by default also keeps the last archived/draft version.
109
Note: This script can potentially run for a very long time, and in Symfony dev environment it will consume memory exponentially with the size of the dataset.
110
111
{$beforeRunningHints}
112
EOT
113
            );
114
    }
115
116
    protected function execute(InputInterface $input, OutputInterface $output): int
117
    {
118
        // We don't load repo services or config resolver before execute() to avoid loading before SiteAccess is set.
119
        $keep = $input->getOption('keep');
120
        if ($keep === 'config_default') {
121
            $config = $this->repositoryConfigurationProvider->getRepositoryConfig();
122
            $keep = $config['options']['default_version_archive_limit'];
123
        }
124
125
        if (($keep = (int) $keep) < 0) {
126
            throw new InvalidArgumentException(
127
                'keep',
128
                'Keep value cannot be negative.'
129
            );
130
        }
131
132
        $userService = $this->repository->getUserService();
133
        $contentService = $this->repository->getContentService();
134
        $permissionResolver = $this->repository->getPermissionResolver();
135
136
        $permissionResolver->setCurrentUserReference(
137
            $userService->loadUserByLogin($input->getOption('user'))
0 ignored issues
show
Deprecated Code introduced by
The method eZ\Publish\API\Repositor...vice::loadUserByLogin() has been deprecated with message: since eZ Platform 2.5, will be dropped in the next major version as authentication may depend on various user providers. Use UserService::checkUserCredentials() instead.

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
138
        );
139
140
        $status = $input->getOption('status');
141
142
        $excludedContentTypes = (string) $input->getOption('excluded-content-types');
143
        if ($excludedContentTypes === '') {
144
            $excludedContentTypes = self::DEFAULT_EXCLUDED_CONTENT_TYPES;
145
        }
146
        $excludedContentTypeIdentifiers = explode(',', $excludedContentTypes);
147
        $contentIds = $this->getObjectsIds($keep, $status, $excludedContentTypeIdentifiers);
148
        $contentIdsCount = count($contentIds);
149
150
        if ($contentIdsCount === 0) {
151
            $output->writeln('<info>There is no content matching the given Criteria.</info>');
152
153
            return 0;
154
        }
155
156
        $output->writeln(sprintf(
157
            '<info>Found %d Content IDs matching the given Criteria.</info>',
158
            $contentIdsCount
159
        ));
160
161
        $displayProgressBar = !($output->isVerbose() || $output->isVeryVerbose() || $output->isDebug());
162
163
        if ($displayProgressBar) {
164
            $progressBar = new ProgressBar($output, $contentIdsCount);
165
            $progressBar->setFormat(
166
                '%current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s% %memory:6s%' . PHP_EOL
167
            );
168
            $progressBar->start();
169
        }
170
171
        $removedVersionsCounter = 0;
172
173
        $removeAll = $status === self::VERSION_ALL;
174
175
        foreach ($contentIds as $contentId) {
176
            try {
177
                $contentInfo = $contentService->loadContentInfo((int) $contentId);
178
                $versions = $contentService->loadVersions(
179
                    $contentInfo,
180
                    $removeAll ? null : $this->mapStatusToVersionInfoStatus($status)
181
                );
182
                $versionsCount = count($versions);
183
184
                $output->writeln(sprintf(
185
                    '<info>Content %d has %d version(s)</info>',
186
                    (int) $contentId,
187
                    $versionsCount
188
                ), OutputInterface::VERBOSITY_VERBOSE);
189
190
                if ($removeAll) {
191
                    $versions = array_filter($versions, static function (VersionInfo $version) {
192
                        return $version->status !== VersionInfo::STATUS_PUBLISHED;
193
                    });
194
                }
195
196
                if ($keep > 0) {
197
                    $versions = array_slice($versions, 0, -$keep);
198
                }
199
200
                $output->writeln(sprintf(
201
                    'Found %d content (%d) version(s) to remove.',
202
                    count($versions),
203
                    (int) $contentId
204
                ), OutputInterface::VERBOSITY_VERBOSE);
205
206
                /** @var \eZ\Publish\API\Repository\Values\Content\VersionInfo $version */
207
                foreach ($versions as $version) {
208
                    $contentService->deleteVersion($version);
209
                    ++$removedVersionsCounter;
210
                    $output->writeln(sprintf(
211
                        'Content (%d) version (%d) has been deleted.',
212
                        $contentInfo->id,
213
                        $version->id
214
                    ), OutputInterface::VERBOSITY_VERBOSE);
215
                }
216
217
                if ($displayProgressBar) {
218
                    $progressBar->advance(1);
0 ignored issues
show
Bug introduced by
The variable $progressBar does not seem to be defined for all execution paths leading up to this point.

If you define a variable conditionally, it can happen that it is not defined for all execution paths.

Let’s take a look at an example:

function myFunction($a) {
    switch ($a) {
        case 'foo':
            $x = 1;
            break;

        case 'bar':
            $x = 2;
            break;
    }

    // $x is potentially undefined here.
    echo $x;
}

In the above example, the variable $x is defined if you pass “foo” or “bar” as argument for $a. However, since the switch statement has no default case statement, if you pass any other value, the variable $x would be undefined.

Available Fixes

  1. Check for existence of the variable explicitly:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        if (isset($x)) { // Make sure it's always set.
            echo $x;
        }
    }
    
  2. Define a default value for the variable:

    function myFunction($a) {
        $x = ''; // Set a default which gets overridden for certain paths.
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        echo $x;
    }
    
  3. Add a value for the missing path:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
    
            // We add support for the missing case.
            default:
                $x = '';
                break;
        }
    
        echo $x;
    }
    
Loading history...
219
                }
220
            } catch (Exception $e) {
221
                $output->writeln(sprintf(
222
                    '<error>%s</error>',
223
                    $e->getMessage()
224
                ));
225
            }
226
        }
227
228
        $output->writeln(sprintf(
229
            '<info>Removed %d unwanted contents version(s) from %d Content item(s).</info>',
230
            $removedVersionsCounter,
231
            $contentIdsCount
232
        ));
233
234
        return 0;
235
    }
236
237
    /**
238
     * @param int $keep
239
     * @param string $status
240
     * @param string[] $excludedContentTypes
241
     *
242
     * @return array
243
     *
244
     * @throws \eZ\Publish\Core\Base\Exceptions\InvalidArgumentException
245
     */
246
    protected function getObjectsIds($keep, $status, $excludedContentTypes = [])
247
    {
248
        $query = $this->connection->createQueryBuilder()
0 ignored issues
show
Bug introduced by
It seems like you code against a concrete implementation and not the interface Doctrine\DBAL\Driver\Connection as the method createQueryBuilder() does only exist in the following implementations of said interface: Doctrine\DBAL\Connection, Doctrine\DBAL\Connections\MasterSlaveConnection, Doctrine\DBAL\Portability\Connection, Doctrine\DBAL\Sharding\PoolingShardConnection.

Let’s take a look at an example:

interface User
{
    /** @return string */
    public function getPassword();
}

class MyUser implements User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different implementation of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the interface:

    interface User
    {
        /** @return string */
        public function getPassword();
    
        /** @return string */
        public function getDisplayName();
    }
    
Loading history...
249
                ->select('c.id')
250
                ->from('ezcontentobject', 'c')
251
                ->join('c', 'ezcontentobject_version', 'v', 'v.contentobject_id = c.id')
252
                ->join('c', 'ezcontentclass', 'cl', 'cl.id = c.contentclass_id')
253
                ->groupBy('c.id', 'v.status')
254
                ->having('count(c.id) > :keep');
255
        $query->setParameter('keep', $keep);
256
257
        if ($status !== self::VERSION_ALL) {
258
            $query->where('v.status = :status');
259
            $query->setParameter('status', $this->mapStatusToVersionInfoStatus($status));
260
        } else {
261
            $query->andWhere('v.status != :status');
262
            $query->setParameter('status', $this->mapStatusToVersionInfoStatus(self::VERSION_PUBLISHED));
263
        }
264
265
        if ($excludedContentTypes) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $excludedContentTypes of type string[] is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
266
            $expr = $query->expr();
267
            $query
268
                ->andWhere(
269
                    $expr->notIn(
270
                        'cl.identifier',
271
                        ':contentTypes'
272
                    )
273
                )->setParameter(':contentTypes', $excludedContentTypes, Connection::PARAM_STR_ARRAY);
274
        }
275
276
        $stmt = $query->execute();
277
278
        return $stmt->fetchAll(PDO::FETCH_COLUMN);
279
    }
280
281
    /**
282
     * @param string $status
283
     *
284
     * @return int
285
     *
286
     * @throws \eZ\Publish\Core\Base\Exceptions\InvalidArgumentException
287
     */
288
    private function mapStatusToVersionInfoStatus($status)
289
    {
290
        if (array_key_exists($status, self::VERSION_STATUS)) {
291
            return self::VERSION_STATUS[$status];
292
        }
293
294
        throw new InvalidArgumentException(
295
            'status',
296
            sprintf(
297
                'Status %s cannot be mapped to a VersionInfo status.',
298
                $status
299
            )
300
        );
301
    }
302
}
303