Completed
Push — master ( bc1002...bc42f8 )
by Łukasz
03:30
created

ProvisionCommand   B

Complexity

Total Complexity 36

Size/Duplication

Total Lines 436
Duplicated Lines 0 %

Coupling/Cohesion

Components 2
Dependencies 14

Importance

Changes 1
Bugs 1 Features 0
Metric Value
wmc 36
lcom 2
cbo 14
dl 0
loc 436
rs 8.8
c 1
b 1
f 0

17 Methods

Rating   Name   Duplication   Size   Complexity  
B __construct() 0 25 1
A configure() 0 14 1
A execute() 0 19 2
A start() 0 6 1
C fetchCandidates() 0 39 7
A buildAbsolutePath() 0 10 2
A loadDotEnv() 0 13 2
A initializeDotEnv() 0 16 1
A getDotEnvFilepath() 0 4 1
A checkAndSetConnectionParameters() 0 23 2
A processWorkingDirectory() 0 9 1
A executeCandidateScript() 0 22 3
B printAllCandidates() 0 32 5
A processQueuedCandidates() 0 11 3
A deployCandidate() 0 16 2
A finish() 0 5 1
A terminate() 0 5 1
1
<?php
2
3
namespace Tworzenieweb\SqlProvisioner\Command;
4
5
use Dotenv\Dotenv;
6
use Symfony\Component\Console\Command\Command;
7
use Symfony\Component\Console\Input\InputArgument;
8
use Symfony\Component\Console\Input\InputInterface;
9
use Symfony\Component\Console\Input\InputOption;
10
use Symfony\Component\Console\Output\OutputInterface;
11
use Symfony\Component\Console\Style\SymfonyStyle;
12
use Symfony\Component\Filesystem\Filesystem;
13
use Tworzenieweb\SqlProvisioner\Check\HasSyntaxCorrectCheck;
14
use Tworzenieweb\SqlProvisioner\Database\Connection;
15
use Tworzenieweb\SqlProvisioner\Database\Exception as DatabaseException;
16
use Tworzenieweb\SqlProvisioner\Database\Executor;
17
use Tworzenieweb\SqlProvisioner\Check\HasDbDeployCheck;
18
use Tworzenieweb\SqlProvisioner\Filesystem\CandidatesFinder;
19
use Tworzenieweb\SqlProvisioner\Filesystem\Exception;
20
use Tworzenieweb\SqlProvisioner\Formatter\Sql;
21
use Tworzenieweb\SqlProvisioner\Model\Candidate;
22
use Tworzenieweb\SqlProvisioner\Model\CandidateBuilder;
23
use Tworzenieweb\SqlProvisioner\Processor\CandidateProcessor;
24
25
/**
26
 * @author Luke Adamczewski
27
 * @package Tworzenieweb\SqlProvisioner\Command
28
 */
29
class ProvisionCommand extends Command
30
{
31
    const HELP_MESSAGE = <<<'EOF'
32
The <info>%command.name% [path-to-folder]</info> command will scan the content of [path-to-folder] directory.
33
 
34
The script will look for <info>.env</info> file containing connection information in format:
35
<comment>
36
DATABASE_USER=[user]
37
DATABASE_PASSWORD=[password]
38
DATABASE_HOST=[host]
39
DATABASE_PORT=[port]
40
DATABASE_NAME=[database]
41
PROVISIONING_TABLE=changelog_database_deployments
42
PROVISIONING_TABLE_CANDIDATE_NUMBER_COLUMN=deploy_script_number
43
</comment>
44
45
If you want to create initial .env use <info>--init</info>
46
47
<info>%command.name% --init [path-to-folder]</info>
48
49
The next step is searching for sql files and trying to queue them in numerical order.
50
First n-th digits of a filename will be treated as candidate number. 
51
This will be used then to check in database if a certain file was already deployed (PROVISIONING_TABLE_CANDIDATE_NUMBER_COLUMN).
52
Before the insert, it will print the formatted output of a file and result of internal syntax check.
53
Then you can either skip or execute each.
54
55
If you would like to skip already provisioned candidates use <info>--skip-provisioned</info>
56
EOF;
57
58
    const MANDATORY_ENV_VARIABLES = [
59
        'DATABASE_USER',
60
        'DATABASE_PASSWORD',
61
        'DATABASE_NAME',
62
        'DATABASE_PORT',
63
        'DATABASE_HOST',
64
        'PROVISIONING_TABLE',
65
        'PROVISIONING_TABLE_CANDIDATE_NUMBER_COLUMN',
66
    ];
67
    const TABLE_HEADERS = ['FILENAME', 'STATUS'];
68
69
    /** @var Candidate[] */
70
    private $workingDirectoryCandidates;
71
72
    /** @var Sql */
73
    private $sqlFormatter;
74
75
    /** @var Filesystem */
76
    private $filesystem;
77
78
    /** @var string */
79
    private $workingDirectory;
80
81
    /** @var CandidatesFinder */
82
    private $finder;
83
84
    /** @var SymfonyStyle */
85
    private $io;
86
87
    /** @var Connection */
88
    private $connection;
89
90
    /** @var CandidateProcessor */
91
    private $processor;
92
93
    /** @var Executor */
94
    private $executor;
95
96
    /** @var boolean */
97
    private $skipProvisionedCandidates;
98
99
    /** @var CandidateBuilder */
100
    private $builder;
101
102
    /** @var bool */
103
    private $hasQueuedCandidates;
104
105
    /** @var integer */
106
    private $queuedCandidatesCount;
107
108
    /** @var array */
109
    private $errorMessages;
110
111
112
113
    /**
114
     * @param string $name
115
     * @param Connection $connection
116
     * @param Sql $sqlFormatter
117
     * @param CandidatesFinder $finder
118
     * @param CandidateProcessor $processor
119
     * @param CandidateBuilder $builder
120
     * @param Executor $executor
121
     */
122
    public function __construct(
123
        $name,
124
        Connection $connection,
125
        Sql $sqlFormatter,
126
        CandidatesFinder $finder,
127
        CandidateProcessor $processor,
128
        CandidateBuilder $builder,
129
        Executor $executor
130
    ) {
131
        $this->connection = $connection;
132
        $this->sqlFormatter = $sqlFormatter;
133
        $this->filesystem = new Filesystem();
134
        $this->finder = $finder;
135
        $this->processor = $processor;
136
        $this->builder = $builder;
137
        $this->executor = $executor;
138
139
        $this->workingDirectoryCandidates = [];
140
        $this->skipProvisionedCandidates = false;
141
        $this->hasQueuedCandidates = false;
142
        $this->queuedCandidatesCount = 0;
143
        $this->errorMessages = [];
144
145
        parent::__construct($name);
146
    }
147
148
149
150
    protected function configure()
151
    {
152
        $this
153
            ->setDescription('Execute the content of *.sql files from given')
154
            ->setHelp(self::HELP_MESSAGE);
155
        $this->addOption('init', null, InputOption::VALUE_NONE, 'Initialize .env in given directory');
156
        $this->addOption(
157
            'skip-provisioned',
158
            null,
159
            InputOption::VALUE_NONE,
160
            'Skip provisioned candidates from printing'
161
        );
162
        $this->addArgument('path', InputArgument::REQUIRED, 'Path to dbdeploys folder');
163
    }
164
165
166
167
    /**
168
     * @param InputInterface $input
169
     * @param OutputInterface $output
170
     * @return int
171
     */
172
    protected function execute(InputInterface $input, OutputInterface $output)
173
    {
174
        $this->start($input, $output);
175
        $this->io->section('Working directory processing');
176
177
        if ($input->getOption('skip-provisioned')) {
178
            $this->skipProvisionedCandidates = true;
179
            $this->io->warning('Hiding of provisioned candidates ENABLED');
180
        }
181
182
        $path = $input->getArgument('path');
183
        $this->workingDirectory = $this->buildAbsolutePath($path);
184
185
        $this->loadDotEnv($input);
186
        $this->processWorkingDirectory();
187
        $this->finish();
188
189
        return 0;
190
    }
191
192
193
194
    /**
195
     * @param InputInterface $input
196
     * @param OutputInterface $output
197
     */
198
    protected function start(InputInterface $input, OutputInterface $output)
199
    {
200
        $this->io = new SymfonyStyle($input, $output);
201
        $this->io->title('SQL Provisioner');
202
        $this->io->block(sprintf('Provisioning started at %s', date('Y-m-d H:i:s')));
203
    }
204
205
206
207
    protected function fetchCandidates()
208
    {
209
        $index = 1;
210
        foreach ($this->finder->find($this->workingDirectory) as $candidateFile) {
211
            $candidate = $this->builder->build($candidateFile);
212
            array_push($this->workingDirectoryCandidates, $candidate);
213
214
            if ($this->processor->isValid($candidate)) {
215
                $candidate->markAsQueued();
216
                $candidate->setIndex($index++);
217
                $this->hasQueuedCandidates = true;
218
                $this->queuedCandidatesCount++;
219
            } else {
220
                $candidate->markAsIgnored($this->processor->getLastError());
221
                $lastErrorMessage = $this->processor->getLastErrorMessage();
222
                if (!empty($lastErrorMessage)) {
223
                    array_push($this->errorMessages, $lastErrorMessage);
224
                }
225
            }
226
        }
227
228
        $this->io->text(sprintf('<info>%d</info> files found', count($this->workingDirectoryCandidates)));
229
230
        if (count($this->workingDirectoryCandidates) === 0) {
231
            throw Exception::noFilesInDirectory($this->workingDirectory);
232
        }
233
234
        if (!empty($this->errorMessages)) {
235
            $this->io->warning(sprintf('Detected %d syntax checking issues', count($this->errorMessages)));
236
            $this->printAllCandidates();
237
            $this->io->writeln(sprintf('<error>%s</error>', implode("\n", $this->errorMessages)));
238
            $this->finish();
239
        }
240
241
        if (false === $this->hasQueuedCandidates) {
242
            $this->io->block('All candidates scripts were executed already.');
243
            $this->finish();
244
        }
245
    }
246
247
248
249
    /**
250
     * @param $path
251
     * @return string
252
     */
253
    private function buildAbsolutePath($path)
254
    {
255
        $absolutePath = $path;
256
257
        if (!$this->filesystem->isAbsolutePath($path)) {
258
            $absolutePath = realpath($path);
259
        }
260
261
        return $absolutePath;
262
    }
263
264
265
266
    /**
267
     * @param InputInterface $input
268
     */
269
    private function loadDotEnv(InputInterface $input)
270
    {
271
        if ($input->getOption('init')) {
272
            $this->initializeDotEnv();
273
            $this->io->success(sprintf('Initial .env file created in %s', $this->workingDirectory));
274
            die(0);
275
        }
276
277
        (new Dotenv($this->workingDirectory))->load();
278
        $this->io->success(sprintf('%s file parsed', $this->getDotEnvFilepath()));
279
280
        $this->checkAndSetConnectionParameters();
281
    }
282
283
284
285
    private function initializeDotEnv()
286
    {
287
        $initialDotEnvFilepath = $this->getDotEnvFilepath();
288
        $this->filesystem->dumpFile(
289
            $initialDotEnvFilepath,
290
            <<<DRAFT
291
DATABASE_USER=[user]
292
DATABASE_PASSWORD=[password]
293
DATABASE_HOST=[host]
294
DATABASE_PORT=[port]
295
DATABASE_NAME=[database]
296
PROVISIONING_TABLE=changelog_database_deployments
297
PROVISIONING_TABLE_CANDIDATE_NUMBER_COLUMN=deploy_script_number
298
DRAFT
299
        );
300
    }
301
302
303
304
    /**
305
     * @return string
306
     */
307
    private function getDotEnvFilepath()
308
    {
309
        return $this->workingDirectory . '/.env';
310
    }
311
312
313
314
    private function checkAndSetConnectionParameters()
315
    {
316
        $hasAllKeys = count(
317
                array_intersect_key(
318
                    array_flip(self::MANDATORY_ENV_VARIABLES),
319
                    $_ENV
320
                )
321
            ) === count(self::MANDATORY_ENV_VARIABLES);
322
323
        if (!$hasAllKeys) {
324
            throw new \LogicException('Provided .env is missing the mandatory keys');
325
        }
326
327
        $this->connection->setDatabaseName($_ENV['DATABASE_NAME']);
328
        $this->connection->setHost($_ENV['DATABASE_HOST']);
329
        $this->connection->setUser($_ENV['DATABASE_USER']);
330
        $this->connection->setPassword($_ENV['DATABASE_PASSWORD']);
331
        $this->connection->setProvisioningTable($_ENV['PROVISIONING_TABLE']);
332
        $this->connection->setCriteriaColumn($_ENV['PROVISIONING_TABLE_CANDIDATE_NUMBER_COLUMN']);
333
        $this->connection->getCurrentConnection();
334
335
        $this->io->success(sprintf('Connection with `%s` established', $_ENV['DATABASE_NAME']));
336
    }
337
338
339
340
    private function processWorkingDirectory()
341
    {
342
        $this->io->newLine(2);
343
        $this->io->section('Candidates processing');
344
345
        $this->fetchCandidates();
346
        $this->printAllCandidates();
347
        $this->processQueuedCandidates();
348
    }
349
350
351
352
    /**
353
     * @param Candidate $candidate
354
     */
355
    private function executeCandidateScript(Candidate $candidate)
356
    {
357
        $this->io->warning(
358
            sprintf(
359
                'PROCESSING [%d/%d] %s',
360
                $candidate->getIndex(),
361
                $this->queuedCandidatesCount,
362
                $candidate->getName()
363
            )
364
        );
365
        $this->io->text($this->sqlFormatter->format($candidate->getContent()));
366
        $action = $this->io->choice('What action to perform', ['DEPLOY', 'SKIP', 'QUIT']);
367
368
        switch ($action) {
369
            case 'DEPLOY':
370
                $this->deployCandidate($candidate);
371
                break;
372
            case 'QUIT':
373
                $this->finish();
374
                break;
375
        }
376
    }
377
378
379
380
    private function printAllCandidates()
381
    {
382
        $self = $this;
383
        $rows = array_map(
384
            function (Candidate $candidate) use ($self) {
385
                $status = $candidate->getStatus();
386
387
                switch ($status) {
388
                    case Candidate::STATUS_QUEUED:
389
                        $status = sprintf('<comment>%s</comment>', $status);
390
                        break;
391
                    case HasDbDeployCheck::ERROR_STATUS:
392
                        if ($self->skipProvisionedCandidates) {
393
                            return null;
394
                        }
395
                        break;
396
                    case HasSyntaxCorrectCheck::ERROR_STATUS:
397
                        $status = sprintf('<error>%s</error>', $status);
398
                        break;
399
                }
400
401
                return [$candidate->getName(), $status];
402
            },
403
            $this->workingDirectoryCandidates
404
        );
405
406
        $this->io->table(
407
            self::TABLE_HEADERS,
408
            array_filter($rows)
409
        );
410
        $this->io->newLine(3);
411
    }
412
413
414
415
    private function processQueuedCandidates()
416
    {
417
        while (!empty($this->workingDirectoryCandidates)) {
418
            $candidate = array_shift($this->workingDirectoryCandidates);
419
420
            if ($candidate->isQueued()) {
421
                $this->executeCandidateScript($candidate);
422
            }
423
        }
424
        $this->io->writeln('<info>All candidates scripts were executed</info>');
425
    }
426
427
428
429
    /**
430
     * @param Candidate $candidate
431
     */
432
    private function deployCandidate(Candidate $candidate)
433
    {
434
        try {
435
            $this->executor->execute($candidate);
436
        } catch (DatabaseException $databaseException) {
437
            $this->io->error($databaseException->getMessage());
438
            $this->io->writeln(
439
                sprintf(
440
                    "<bg=yellow>%s\n\r%s</>",
441
                    $databaseException->getPrevious()->getMessage(),
442
                    $candidate->getContent()
443
                )
444
            );
445
            $this->terminate();
446
        }
447
    }
448
449
450
451
    private function finish()
452
    {
453
        $this->io->text(sprintf('Provisioning ended at %s', date('Y-m-d H:i:s')));
454
        die(0);
455
    }
456
457
458
459
    private function terminate()
460
    {
461
        $this->io->text(sprintf('Provisioning ended with error at %s', date('Y-m-d H:i:s')));
462
        die(1);
463
    }
464
}