Completed
Push — ezp-30882-thumbnail ( 274ed9...d4335b )
by
unknown
14:43
created

CleanupVersionsCommand::execute()   F

Complexity

Conditions 18
Paths 352

Size

Total Lines 117

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 18
nc 352
nop 2
dl 0
loc 117
rs 1.7066
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 installation offline, during the script execution the database should not be modified.
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('Remove unwanted content versions. It keeps published version untouched. By default, it keeps also 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 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 ContentType identifiers of which 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 it keeps also 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 size of dataset.
110
111
{$beforeRunningHints}
112
EOT
113
            );
114
    }
115
116
    protected function execute(InputInterface $input, OutputInterface $output)
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 can not 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'))
138
        );
139
140
        $status = $input->getOption('status');
141
142
        $excludedContentTypeIdentifiers = explode(',', $input->getOption('excluded-content-types'));
143
        $contentIds = $this->getObjectsIds($keep, $status, $excludedContentTypeIdentifiers);
144
        $contentIdsCount = count($contentIds);
145
146
        if ($contentIdsCount === 0) {
147
            $output->writeln('<info>There is no Content matching given criteria.</info>');
148
149
            return;
150
        }
151
152
        $output->writeln(sprintf(
153
            '<info>Found %d Content IDs matching given criteria.</info>',
154
            $contentIdsCount
155
        ));
156
157
        $displayProgressBar = !($output->isVerbose() || $output->isVeryVerbose() || $output->isDebug());
158
159
        if ($displayProgressBar) {
160
            $progressBar = new ProgressBar($output, $contentIdsCount);
161
            $progressBar->setFormat(
162
                '%current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s% %memory:6s%' . PHP_EOL
163
            );
164
            $progressBar->start();
165
        }
166
167
        $removedVersionsCounter = 0;
168
169
        $removeAll = $status === self::VERSION_ALL;
170
        $removeDrafts = $status === self::VERSION_DRAFT;
171
        $removeArchived = $status === self::VERSION_ARCHIVED;
172
173
        foreach ($contentIds as $contentId) {
174
            try {
175
                $contentInfo = $contentService->loadContentInfo((int) $contentId);
176
                $versions = $contentService->loadVersions($contentInfo);
177
                $versionsCount = count($versions);
178
179
                $output->writeln(sprintf(
180
                    '<info>Content %d has %d version(s)</info>',
181
                    (int) $contentId,
182
                    $versionsCount
183
                ), OutputInterface::VERBOSITY_VERBOSE);
184
185
                $versions = array_filter($versions, function ($version) use ($removeAll, $removeDrafts, $removeArchived) {
186
                    if (
187
                        ($removeAll && $version->status !== VersionInfo::STATUS_PUBLISHED) ||
188
                        ($removeDrafts && $version->status === VersionInfo::STATUS_DRAFT) ||
189
                        ($removeArchived && $version->status === VersionInfo::STATUS_ARCHIVED)
190
                    ) {
191
                        return true;
192
                    }
193
                });
194
195
                if ($keep > 0) {
196
                    $versions = array_slice($versions, 0, -$keep);
197
                }
198
199
                $output->writeln(sprintf(
200
                    "Found %d content's (%d) version(s) to remove.",
201
                    count($versions),
202
                    (int) $contentId
203
                ), OutputInterface::VERBOSITY_VERBOSE);
204
205
                /** @var \eZ\Publish\API\Repository\Values\Content\VersionInfo $version */
206
                foreach ($versions as $version) {
207
                    $contentService->deleteVersion($version);
208
                    ++$removedVersionsCounter;
209
                    $output->writeln(sprintf(
210
                        "Content's (%d) version (%d) has been deleted.",
211
                        $contentInfo->id,
212
                        $version->id
213
                    ), OutputInterface::VERBOSITY_VERBOSE);
214
                }
215
216
                if ($displayProgressBar) {
217
                    $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...
218
                }
219
            } catch (Exception $e) {
220
                $output->writeln(sprintf(
221
                    '<error>%s</error>',
222
                    $e->getMessage()
223
                ));
224
            }
225
        }
226
227
        $output->writeln(sprintf(
228
            '<info>Removed %d unwanted contents version(s) from %d content(s).</info>',
229
            $removedVersionsCounter,
230
            $contentIdsCount
231
        ));
232
    }
233
234
    /**
235
     * @param int $keep
236
     * @param string $status
237
     * @param string[] $excludedContentTypes
238
     *
239
     * @return array
240
     *
241
     * @throws \eZ\Publish\Core\Base\Exceptions\InvalidArgumentException
242
     */
243
    protected function getObjectsIds($keep, $status, $excludedContentTypes = [])
244
    {
245
        $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...
246
                ->select('c.id')
247
                ->from('ezcontentobject', 'c')
248
                ->join('c', 'ezcontentobject_version', 'v', 'v.contentobject_id = c.id')
249
                ->join('c', 'ezcontentclass', 'cl', 'cl.id = c.contentclass_id')
250
                ->groupBy('c.id', 'v.status')
251
                ->having('count(c.id) > :keep');
252
        $query->setParameter('keep', $keep);
253
254
        if ($status !== self::VERSION_ALL) {
255
            $query->where('v.status = :status');
256
            $query->setParameter('status', $this->mapStatusToVersionInfoStatus($status));
257
        } else {
258
            $query->andWhere('v.status != :status');
259
            $query->setParameter('status', $this->mapStatusToVersionInfoStatus(self::VERSION_PUBLISHED));
260
        }
261
262
        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...
263
            $expr = $query->expr();
264
            $query
265
                ->andWhere(
266
                    $expr->notIn(
267
                        'cl.identifier',
268
                        ':contentTypes'
269
                    )
270
                )->setParameter(':contentTypes', $excludedContentTypes, Connection::PARAM_STR_ARRAY);
271
        }
272
273
        $stmt = $query->execute();
274
275
        return $stmt->fetchAll(PDO::FETCH_COLUMN);
276
    }
277
278
    /**
279
     * @param string $status
280
     *
281
     * @return int
282
     *
283
     * @throws \eZ\Publish\Core\Base\Exceptions\InvalidArgumentException
284
     */
285
    private function mapStatusToVersionInfoStatus($status)
286
    {
287
        if (array_key_exists($status, self::VERSION_STATUS)) {
288
            return self::VERSION_STATUS[$status];
289
        }
290
291
        throw new InvalidArgumentException(
292
            'status',
293
            sprintf(
294
                "Status %s can't be mapped to VersionInfo status.",
295
                $status
296
            )
297
        );
298
    }
299
}
300