Completed
Push — master ( c4e93d...f0fa26 )
by
unknown
79:49 queued 61:06
created

getTimestampBasedFields()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 34

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
nc 4
nop 2
dl 0
loc 34
rs 9.376
c 0
b 0
f 0
1
<?php
2
/**
3
 * This file is part of the eZ Publish Kernel package.
4
 *
5
 * @copyright Copyright (C) eZ Systems AS. All rights reserved
6
 * @license For full copyright and license information view LICENSE file distributed with this source code
7
 */
8
namespace eZ\Bundle\EzPublishCoreBundle\Command;
9
10
use DateTime;
11
use DateTimeZone;
12
use Doctrine\DBAL\Connection;
13
use RuntimeException;
14
use Symfony\Component\Console\Command\Command;
15
use Symfony\Component\Process\Process;
16
use Symfony\Component\Console\Input\InputInterface;
17
use Symfony\Component\Console\Output\OutputInterface;
18
use Symfony\Component\Console\Input\InputArgument;
19
use Symfony\Component\Console\Input\InputOption;
20
use Symfony\Component\Console\Helper\ProgressBar;
21
use Symfony\Component\Console\Question\ConfirmationQuestion;
22
use Symfony\Component\Process\PhpExecutableFinder;
23
use PDO;
24
25
class UpdateTimestampsToUTCCommand extends Command
26
{
27
    const MAX_TIMESTAMP_VALUE = 2147483647;
28
    const DEFAULT_ITERATION_COUNT = 100;
29
    const MODES = [
30
        'date' => ['ezdate'],
31
        'datetime' => ['ezdatetime'],
32
        'all' => ['ezdate', 'ezdatetime'],
33
    ];
34
35
    /** @var int */
36
    protected $done = 0;
37
38
    /** @var string */
39
    protected $timezone;
40
41
    /** @var string */
42
    private $mode;
43
44
    /** @var string */
45
    private $from;
46
47
    /** @var string */
48
    private $to;
49
50
    /** @var \Doctrine\DBAL\Connection */
51
    private $connection;
52
53
    /** @var string */
54
    private $phpPath;
55
56
    /** @var bool */
57
    private $dryRun;
58
59
    public function __construct(Connection $connection)
60
    {
61
        $this->connection = $connection;
62
        parent::__construct();
63
    }
64
65
    protected function configure()
66
    {
67
        $this
68
            ->setName('ezplatform:timestamps:to-utc')
69
            ->setDescription('Updates ezdate and ezdatetime timestamps to UTC')
70
            ->addArgument(
71
                'timezone',
72
                InputArgument::OPTIONAL,
73
                'Original timestamp\'s TimeZone',
74
                null
75
            )
76
            ->addOption(
77
                'dry-run',
78
                null,
79
                InputOption::VALUE_NONE,
80
                'Execute a dry run'
81
            )
82
            ->addOption(
83
                'mode',
84
                null,
85
                InputOption::VALUE_REQUIRED,
86
                'Select conversion scope: date, datetime, all',
87
                'all'
88
            )
89
            ->addOption(
90
                'from',
91
                null,
92
                InputOption::VALUE_REQUIRED,
93
                'Only versions AFTER this date will be converted',
94
                null
95
            )
96
            ->addOption(
97
                'to',
98
                null,
99
                InputOption::VALUE_REQUIRED,
100
                'Only versions BEFORE this date will be converted',
101
                null
102
            )
103
            ->addOption(
104
                'offset',
105
                null,
106
                InputArgument::OPTIONAL,
107
                'Offset for updating records',
108
                0
109
            )
110
            ->addOption(
111
                'iteration-count',
112
                null,
113
                InputArgument::OPTIONAL,
114
                'Limit how many records get updated by a single process',
115
                self::DEFAULT_ITERATION_COUNT
116
            )
117
            ->setHelp(
118
                <<<'EOT'
119
The command <info>%command.name%</info> updates field
120
data_int in configured Legacy Storage database for a given Field Type.
121
122
This is to be used either when migrating from eZ Publish to eZ Platform 
123
(when using platform backend instead of legacy), or when upgrading legacy 
124
to v2019.03 which has been adapted to use UTC.
125
126
<warning>The database should not be modified while the script is being executed.
127
128
You are advised to create a backup or execute a dry run before 
129
proceeding with the actual update.</warning>
130
131
<warning>This command should only be ran ONCE.</warning>
132
133
Since this script can potentially run for a very long time, to avoid memory
134
exhaustion run it in production environment using <info>--env=prod</info> switch.
135
EOT
136
            );
137
    }
138
139
    /**
140
     * @param \Symfony\Component\Console\Input\InputInterface $input
141
     * @param \Symfony\Component\Console\Output\OutputInterface $output
142
     */
143
    protected function execute(InputInterface $input, OutputInterface $output)
144
    {
145
        $iterationCount = (int) $input->getOption('iteration-count');
146
        $this->dryRun = $input->getOption('dry-run');
0 ignored issues
show
Documentation Bug introduced by
It seems like $input->getOption('dry-run') can also be of type string or array<integer,string>. However, the property $dryRun is declared as type boolean. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
147
        $this->mode = $input->getOption('mode');
0 ignored issues
show
Documentation Bug introduced by
It seems like $input->getOption('mode') can also be of type array<integer,string> or boolean. However, the property $mode is declared as type string. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
148
149
        if (!array_key_exists($this->mode, self::MODES)) {
150
            $output->writeln(
151
                sprintf('The selected mode is not supported. Use one of the following modes: %s', implode(', ', array_keys(self::MODES)))
152
            );
153
154
            return;
155
        }
156
157
        $from = $input->getOption('from');
158
        $to = $input->getOption('to');
159
160
        if ($from && !$this->validateDateTimeString($from, $output)) {
161
            return;
162
        }
163
        if ($to && !$this->validateDateTimeString($to, $output)) {
164
            return;
165
        }
166
        if ($from) {
167
            $this->from = $this->dateStringToTimestamp($from);
0 ignored issues
show
Documentation Bug introduced by
The property $from was declared of type string, but $this->dateStringToTimestamp($from) is of type integer. Maybe add a type cast?

This check looks for assignments to scalar types that may be of the wrong type.

To ensure the code behaves as expected, it may be a good idea to add an explicit type cast.

$answer = 42;

$correct = false;

$correct = (bool) $answer;
Loading history...
168
        }
169
        if ($to) {
170
            $this->to = $this->dateStringToTimestamp($to);
0 ignored issues
show
Documentation Bug introduced by
The property $to was declared of type string, but $this->dateStringToTimestamp($to) is of type integer. Maybe add a type cast?

This check looks for assignments to scalar types that may be of the wrong type.

To ensure the code behaves as expected, it may be a good idea to add an explicit type cast.

$answer = 42;

$correct = false;

$correct = (bool) $answer;
Loading history...
171
        }
172
173
        $consoleScript = $_SERVER['argv'][0];
174
175
        if (getenv('INNER_CALL')) {
176
            $this->timezone = $input->getArgument('timezone');
0 ignored issues
show
Documentation Bug introduced by
It seems like $input->getArgument('timezone') can also be of type array<integer,string>. However, the property $timezone is declared as type string. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
177
            $this->processTimestamps((int) $input->getOption('offset'), $iterationCount, $output);
178
            $output->writeln($this->done);
179
        } else {
180
            $timezone = $input->getArgument('timezone');
181
            $this->timezone = $this->validateTimezone($timezone, $output);
0 ignored issues
show
Bug introduced by
It seems like $timezone defined by $input->getArgument('timezone') on line 180 can also be of type array<integer,string> or null; however, eZ\Bundle\EzPublishCoreB...and::validateTimezone() does only seem to accept string, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
182
183
            $output->writeln([
184
                sprintf('Converting timestamps for fields: %s', implode(', ', self::MODES[$this->mode])),
185
                'Calculating number of Field values to update...',
186
            ]);
187
            $count = $this->countTimestampBasedFields();
188
            $output->writeln([
189
                sprintf('Found %d total Field values for update', $count),
190
                '',
191
            ]);
192
193
            if ($count == 0) {
194
                $output->writeln('Nothing to process, exiting.');
195
196
                return;
197
            }
198
199
            $helper = $this->getHelper('question');
200
            $question = new ConfirmationQuestion(
201
                '<question>Are you sure you want to proceed?</question> ',
202
                false
203
            );
204
205
            if (!$helper->ask($input, $output, $question)) {
206
                $output->writeln('');
207
208
                return;
209
            }
210
211
            $progressBar = $this->getProgressBar($count, $output);
212
            $progressBar->start();
213
214
            for ($offset = 0; $offset < $count; $offset += $iterationCount) {
215
                $processScriptFragments = [
216
                    $this->getPhpPath(),
217
                    $consoleScript,
218
                    $this->getName(),
219
                    $this->timezone,
220
                    '--mode=' . $this->mode,
221
                    '--offset=' . $offset,
222
                    '--iteration-count=' . $iterationCount,
223
                ];
224
225
                if ($from) {
226
                    $processScriptFragments[] = '--from=' . $from;
227
                }
228
                if ($to) {
229
                    $processScriptFragments[] = '--to=' . $to;
230
                }
231
                if ($this->dryRun) {
232
                    $processScriptFragments[] = '--dry-run';
233
                }
234
235
                $process = new Process(
236
                    implode(' ', $processScriptFragments)
237
                );
238
239
                $process->setEnv(['INNER_CALL' => 1]);
240
                $process->run();
241
242
                if (!$process->isSuccessful()) {
243
                    throw new RuntimeException($process->getErrorOutput());
244
                }
245
246
                $doneInProcess = (int) $process->getOutput();
247
                $this->done += $doneInProcess;
248
249
                $progressBar->advance($doneInProcess);
250
            }
251
252
            $progressBar->finish();
253
            $output->writeln([
254
                '',
255
                sprintf('Done: %d', $this->done),
256
            ]);
257
        }
258
    }
259
260
    /**
261
     * @param int $offset
262
     * @param int $limit
263
     * @param \Symfony\Component\Console\Output\OutputInterface $output
264
     */
265
    protected function processTimestamps($offset, $limit, $output)
0 ignored issues
show
Unused Code introduced by
The parameter $output is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
266
    {
267
        $timestampBasedFields = $this->getTimestampBasedFields($offset, $limit);
268
269
        $dateTimeInUTC = new DateTime();
270
        $dateTimeInUTC->setTimezone(new DateTimeZone('UTC'));
271
272
        foreach ($timestampBasedFields as $timestampBasedField) {
273
            $timestamp = $timestampBasedField['data_int'];
274
            $dateTimeInUTC->setTimestamp($timestamp);
275
            $newTimestamp = $this->convertToUtcTimestamp($timestamp);
276
277
            //failsafe for int field limitation (dates/datetimes after 01/19/2038 @ 4:14am (UTC))
278
            if ($newTimestamp <= self::MAX_TIMESTAMP_VALUE && !$this->dryRun) {
279
                $this->updateTimestampToUTC($timestampBasedField['id'], $timestampBasedField['version'], $newTimestamp);
280
            }
281
            ++$this->done;
282
        }
283
    }
284
285
    /**
286
     * @param int $offset
287
     * @param int $limit
288
     *
289
     * @return array
290
     */
291
    protected function getTimestampBasedFields($offset, $limit)
292
    {
293
        $query = $this->connection->createQueryBuilder();
294
        $query
295
            ->select('a.id, a.version, a.data_int')
296
            ->from('ezcontentobject_attribute', 'a')
297
            ->join('a', 'ezcontentobject_version', 'v', 'a.contentobject_id = v.contentobject_id')
298
            ->where(
299
                $query->expr()->in(
300
                    'a.data_type_string',
301
                    $query->createNamedParameter(self::MODES[$this->mode], Connection::PARAM_STR_ARRAY)
302
                )
303
            )
304
            ->andWhere('a.data_int is not null')
305
            ->andWhere('a.data_int > 0')
306
            ->andWhere('v.version = a.version')
307
            ->setFirstResult($offset)
308
            ->setMaxResults($limit);
309
310
        if ($this->from) {
311
            $query
312
                ->andWhere('v.modified >= :fromTimestamp')
313
                ->setParameter('fromTimestamp', $this->from);
314
        }
315
        if ($this->to) {
316
            $query
317
                ->andWhere('v.modified <= :toTimestamp')
318
                ->setParameter('toTimestamp', $this->to);
319
        }
320
321
        $statement = $query->execute();
322
323
        return $statement->fetchAll(PDO::FETCH_ASSOC);
324
    }
325
326
    /**
327
     * Counts affected timestamp based fields using captured "mode", "from" and "to" command options.
328
     *
329
     * @return int
330
     */
331
    protected function countTimestampBasedFields()
332
    {
333
        $query = $this->connection->createQueryBuilder();
334
        $query
335
            ->select('count(*) as count')
336
            ->from('ezcontentobject_attribute', 'a')
337
            ->join('a', 'ezcontentobject_version', 'v', 'a.contentobject_id = v.contentobject_id')
338
            ->where(
339
                $query->expr()->in(
340
                    'a.data_type_string',
341
                    $query->createNamedParameter(self::MODES[$this->mode], Connection::PARAM_STR_ARRAY)
342
                )
343
            )
344
            ->andWhere('a.data_int is not null')
345
            ->andWhere('a.data_int > 0')
346
            ->andWhere('v.version = a.version');
347
348
        if ($this->from) {
349
            $query
350
                ->andWhere('v.modified >= :fromTimestamp')
351
                ->setParameter('fromTimestamp', $this->from);
352
        }
353
        if ($this->to) {
354
            $query
355
                ->andWhere('v.modified <= :toTimestamp')
356
                ->setParameter('toTimestamp', $this->to);
357
        }
358
359
        $statement = $query->execute();
360
361
        return (int) $statement->fetchColumn();
362
    }
363
364
    /**
365
     * @param int $timestamp
366
     * @return int
367
     */
368
    protected function convertToUtcTimestamp($timestamp)
369
    {
370
        $dateTimeZone = new DateTimeZone($this->timezone);
371
        $dateTimeZoneUTC = new DateTimeZone('UTC');
372
373
        $dateTime = new DateTime(null, $dateTimeZone);
374
        $dateTime->setTimestamp($timestamp);
375
        $dateTimeUTC = new DateTime($dateTime->format('Y-m-d H:i:s'), $dateTimeZoneUTC);
376
377
        return $dateTimeUTC->getTimestamp();
378
    }
379
380
    /**
381
     * @param string $dateTimeString
382
     * @param OutputInterface $output
383
     * @return bool
384
     */
385
    protected function validateDateTimeString($dateTimeString, OutputInterface $output)
386
    {
387
        try {
388
            new \DateTime($dateTimeString);
389
        } catch (\Exception $exception) {
390
            $output->writeln('The --from and --to options must be a valid Date string.');
391
392
            return false;
393
        }
394
395
        return true;
396
    }
397
398
    /**
399
     * @param string $timezone
400
     * @param OutputInterface $output
401
     * @return string
402
     */
403
    protected function validateTimezone($timezone, OutputInterface $output)
404
    {
405
        if (!$timezone) {
406
            $timezone = date_default_timezone_get();
407
            $output->writeln([
408
                sprintf('No Timezone set, using server Timezone: %s', $timezone),
409
                '',
410
            ]);
411
        } else {
412
            if (!\in_array($timezone, timezone_identifiers_list())) {
413
                $output->writeln([
414
                    sprintf('%s is not correct Timezone.', $timezone),
415
                    '',
416
                ]);
417
418
                return;
419
            }
420
421
            $output->writeln([
422
                sprintf('Using timezone: %s', $timezone),
423
                '',
424
            ]);
425
        }
426
427
        return $timezone;
428
    }
429
430
    /**
431
     * Return configured progress bar helper.
432
     *
433
     * @param int $maxSteps
434
     * @param \Symfony\Component\Console\Output\OutputInterface $output
435
     *
436
     * @return \Symfony\Component\Console\Helper\ProgressBar
437
     */
438
    protected function getProgressBar($maxSteps, OutputInterface $output)
439
    {
440
        $progressBar = new ProgressBar($output, $maxSteps);
441
        $progressBar->setFormat(
442
            ' %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s% %memory:6s%'
443
        );
444
445
        return $progressBar;
446
    }
447
448
    /**
449
     * @param int $contentAttributeId
450
     * @param int $contentAttributeVersion
451
     * @param int $newTimestamp
452
     */
453
    protected function updateTimestampToUTC(
454
        $contentAttributeId,
455
        $contentAttributeVersion,
456
        $newTimestamp
457
    ) {
458
        $query = $this->connection->createQueryBuilder();
459
        $query
460
            ->update('ezcontentobject_attribute', 'a')
461
            ->set('a.data_int', $newTimestamp)
462
            ->set('a.sort_key_int', $newTimestamp)
463
            ->where('a.id = :id')
464
            ->andWhere('a.version = :version')
465
            ->setParameter(':id', $contentAttributeId)
466
            ->setParameter(':version', $contentAttributeVersion);
467
468
        $query->execute();
469
    }
470
471
    /**
472
     * @return string
473
     */
474 View Code Duplication
    private function getPhpPath()
475
    {
476
        if ($this->phpPath) {
477
            return $this->phpPath;
478
        }
479
        $phpFinder = new PhpExecutableFinder();
480
        $this->phpPath = $phpFinder->find();
0 ignored issues
show
Documentation Bug introduced by
It seems like $phpFinder->find() can also be of type false. However, the property $phpPath is declared as type string. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
481
        if (!$this->phpPath) {
482
            throw new RuntimeException(
483
                'The php executable could not be found. It is needed for executing parallel subprocesses, so add it to your PATH environment variable and try again'
484
            );
485
        }
486
487
        return $this->phpPath;
488
    }
489
490
    /**
491
     * @param $dateString string
492
     * @throws \Exception
493
     * @return int
494
     */
495
    private function dateStringToTimestamp($dateString)
496
    {
497
        $date = new \DateTime($dateString);
498
499
        return $date->getTimestamp();
500
    }
501
}
502