Completed
Push — ezp_30797 ( c9135e...a01256 )
by
unknown
19:32
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
    /**
50
     * @var \eZ\Bundle\EzPublishCoreBundle\ApiLoader\RepositoryConfigurationProvider
51
     */
52
    private $repositoryConfigurationProvider;
53
54
    /** @var \Doctrine\DBAL\Driver\Connection */
55
    private $connection;
56
57
    public function __construct(
58
        Repository $repository,
59
        RepositoryConfigurationProvider $repositoryConfigurationProvider,
60
        Connection $connection
61
    ) {
62
        $this->repository = $repository;
63
        $this->repositoryConfigurationProvider = $repositoryConfigurationProvider;
64
        $this->connection = $connection;
65
66
        parent::__construct();
67
    }
68
69
    protected function configure()
70
    {
71
        $beforeRunningHints = self::BEFORE_RUNNING_HINTS;
72
        $this
73
            ->setName('ezplatform:content:cleanup-versions')
74
            ->setDescription('Remove unwanted content versions. It keeps published version untouched. By default, it keeps also the last archived/draft version.')
75
            ->addOption(
76
                'status',
77
                't',
78
                InputOption::VALUE_OPTIONAL,
79
                sprintf(
80
                    "Select which version types should be removed: '%s', '%s', '%s'.",
81
                    self::VERSION_DRAFT,
82
                    self::VERSION_ARCHIVED,
83
                    self::VERSION_ALL
84
                ),
85
                self::VERSION_ALL
86
            )
87
            ->addOption(
88
                'keep',
89
                'k',
90
                InputOption::VALUE_OPTIONAL,
91
                "Sets number of the most recent versions (both drafts and archived) which won't be removed.",
92
                'config_default'
93
            )
94
            ->addOption(
95
                'user',
96
                'u',
97
                InputOption::VALUE_OPTIONAL,
98
                'eZ Platform username (with Role containing at least Content policies: remove, read, versionread)',
99
                self::DEFAULT_REPOSITORY_USER
100
            )
101
            ->addOption(
102
                'excluded-content-types',
103
                null,
104
                InputOption::VALUE_OPTIONAL,
105
                'Comma separated list of ContentType identifiers of which versions should not be removed, for instance `article`.',
106
                self::DEFAULT_EXCLUDED_CONTENT_TYPES
107
            )->setHelp(
108
                <<<EOT
109
The command <info>%command.name%</info> reduces content versions to a minimum. 
110
It keeps published version untouched, and by default it keeps also the last archived/draft version.
111
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.
112
113
{$beforeRunningHints}
114
EOT
115
            );
116
    }
117
118
    protected function execute(InputInterface $input, OutputInterface $output)
119
    {
120
        // We don't load repo services or config resolver before execute() to avoid loading before SiteAccess is set.
121
        $keep = $input->getOption('keep');
122
        if ($keep === 'config_default') {
123
            $config = $this->repositoryConfigurationProvider->getRepositoryConfig();
124
            $keep = $config['options']['default_version_archive_limit'];
125
        }
126
127
        if (($keep = (int) $keep) < 0) {
128
            throw new InvalidArgumentException(
129
                'keep',
130
                'Keep value can not be negative.'
131
            );
132
        }
133
134
        $userService = $this->repository->getUserService();
135
        $contentService = $this->repository->getContentService();
136
        $permissionResolver = $this->repository->getPermissionResolver();
137
138
        $permissionResolver->setCurrentUserReference(
139
            $userService->loadUserByLogin($input->getOption('user'))
140
        );
141
142
        $status = $input->getOption('status');
143
144
        $excludedContentTypeIdentifiers = explode(',', $input->getOption('excluded-content-types'));
145
        $contentIds = $this->getObjectsIds($keep, $status, $excludedContentTypeIdentifiers);
146
        $contentIdsCount = count($contentIds);
147
148
        if ($contentIdsCount === 0) {
149
            $output->writeln('<info>There is no Content matching given criteria.</info>');
150
151
            return;
152
        }
153
154
        $output->writeln(sprintf(
155
            '<info>Found %d Content IDs matching given criteria.</info>',
156
            $contentIdsCount
157
        ));
158
159
        $displayProgressBar = !($output->isVerbose() || $output->isVeryVerbose() || $output->isDebug());
160
161
        if ($displayProgressBar) {
162
            $progressBar = new ProgressBar($output, $contentIdsCount);
163
            $progressBar->setFormat(
164
                '%current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s% %memory:6s%' . PHP_EOL
165
            );
166
            $progressBar->start();
167
        }
168
169
        $removedVersionsCounter = 0;
170
171
        $removeAll = $status === self::VERSION_ALL;
172
        $removeDrafts = $status === self::VERSION_DRAFT;
173
        $removeArchived = $status === self::VERSION_ARCHIVED;
174
175
        foreach ($contentIds as $contentId) {
176
            try {
177
                $contentInfo = $contentService->loadContentInfo((int) $contentId);
178
                $versions = $contentService->loadVersions($contentInfo);
179
                $versionsCount = count($versions);
180
181
                $output->writeln(sprintf(
182
                    '<info>Content %d has %d version(s)</info>',
183
                    (int) $contentId,
184
                    $versionsCount
185
                ), OutputInterface::VERBOSITY_VERBOSE);
186
187
                $versions = array_filter($versions, function ($version) use ($removeAll, $removeDrafts, $removeArchived) {
188
                    if (
189
                        ($removeAll && $version->status !== VersionInfo::STATUS_PUBLISHED) ||
190
                        ($removeDrafts && $version->status === VersionInfo::STATUS_DRAFT) ||
191
                        ($removeArchived && $version->status === VersionInfo::STATUS_ARCHIVED)
192
                    ) {
193
                        return true;
194
                    }
195
                });
196
197
                if ($keep > 0) {
198
                    $versions = array_slice($versions, 0, -$keep);
199
                }
200
201
                $output->writeln(sprintf(
202
                    "Found %d content's (%d) version(s) to remove.",
203
                    count($versions),
204
                    (int) $contentId
205
                ), OutputInterface::VERBOSITY_VERBOSE);
206
207
                /** @var \eZ\Publish\API\Repository\Values\Content\VersionInfo $version */
208
                foreach ($versions as $version) {
209
                    $contentService->deleteVersion($version);
210
                    ++$removedVersionsCounter;
211
                    $output->writeln(sprintf(
212
                        "Content's (%d) version (%d) has been deleted.",
213
                        $contentInfo->id,
214
                        $version->id
215
                    ), OutputInterface::VERBOSITY_VERBOSE);
216
                }
217
218
                if ($displayProgressBar) {
219
                    $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...
220
                }
221
            } catch (Exception $e) {
222
                $output->writeln(sprintf(
223
                    '<error>%s</error>',
224
                    $e->getMessage()
225
                ));
226
            }
227
        }
228
229
        $output->writeln(sprintf(
230
            '<info>Removed %d unwanted contents version(s) from %d content(s).</info>',
231
            $removedVersionsCounter,
232
            $contentIdsCount
233
        ));
234
    }
235
236
    /**
237
     * @param int $keep
238
     * @param string $status
239
     * @param string[] $excludedContentTypes
240
     *
241
     * @return array
242
     *
243
     * @throws \eZ\Publish\Core\Base\Exceptions\InvalidArgumentException
244
     */
245
    protected function getObjectsIds($keep, $status, $excludedContentTypes = [])
246
    {
247
        $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...
248
                ->select('c.id')
249
                ->from('ezcontentobject', 'c')
250
                ->join('c', 'ezcontentobject_version', 'v', 'v.contentobject_id = c.id')
251
                ->join('c', 'ezcontentclass', 'cl', 'cl.id = c.contentclass_id')
252
                ->groupBy('c.id', 'v.status')
253
                ->having('count(c.id) > :keep');
254
        $query->setParameter('keep', $keep);
255
256
        if ($status !== self::VERSION_ALL) {
257
            $query->where('v.status = :status');
258
            $query->setParameter('status', $this->mapStatusToVersionInfoStatus($status));
259
        } else {
260
            $query->andWhere('v.status != :status');
261
            $query->setParameter('status', $this->mapStatusToVersionInfoStatus(self::VERSION_PUBLISHED));
262
        }
263
264
        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...
265
            $expr = $query->expr();
266
            $query
267
                ->andWhere(
268
                    $expr->notIn(
269
                        'cl.identifier',
270
                        ':contentTypes'
271
                    )
272
                )->setParameter(':contentTypes', $excludedContentTypes, Connection::PARAM_STR_ARRAY);
273
        }
274
275
        $stmt = $query->execute();
276
277
        return $stmt->fetchAll(PDO::FETCH_COLUMN);
278
    }
279
280
    /**
281
     * @param string $status
282
     *
283
     * @return int
284
     *
285
     * @throws \eZ\Publish\Core\Base\Exceptions\InvalidArgumentException
286
     */
287
    private function mapStatusToVersionInfoStatus($status)
288
    {
289
        if (array_key_exists($status, self::VERSION_STATUS)) {
290
            return self::VERSION_STATUS[$status];
291
        }
292
293
        throw new InvalidArgumentException(
294
            'status',
295
            sprintf(
296
                "Status %s can't be mapped to VersionInfo status.",
297
                $status
298
            )
299
        );
300
    }
301
}
302