Passed
Push — master ( da8729...ed7987 )
by Bruno
04:15
created

MigrationGenerator::checkMigrationCodeChange()   D

Complexity

Conditions 19
Paths 7

Size

Total Lines 46
Code Lines 30

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 279.1976

Importance

Changes 0
Metric Value
cc 19
eloc 30
nc 7
nop 1
dl 0
loc 46
ccs 3
cts 29
cp 0.1034
crap 279.1976
rs 4.5166
c 0
b 0
f 0

How to fix   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 declare(strict_types=1);
2
3
namespace Modelarium\Laravel\Targets;
4
5
use Formularium\CodeGenerator\LaravelEloquent\CodeGenerator as LaravelCodeGenerator;
6
use Formularium\Datatype;
7
use Formularium\Datatype\Datatype_enum;
8
use Formularium\Exception\ClassNotFoundException;
9
use Formularium\Factory\DatatypeFactory;
10
use Formularium\Field;
11
use Illuminate\Support\Str;
12
use GraphQL\Language\AST\DirectiveNode;
13
use GraphQL\Type\Definition\BooleanType;
14
use GraphQL\Type\Definition\CustomScalarType;
15
use GraphQL\Type\Definition\Directive;
16
use GraphQL\Type\Definition\EnumType;
17
use GraphQL\Type\Definition\FloatType;
18
use GraphQL\Type\Definition\IDType;
19
use GraphQL\Type\Definition\IntType;
20
use GraphQL\Type\Definition\ListOfType;
21
use GraphQL\Type\Definition\NonNull;
22
use GraphQL\Type\Definition\ObjectType;
23
use GraphQL\Type\Definition\StringType;
24
use GraphQL\Type\Definition\UnionType;
25
use Modelarium\BaseGenerator;
26
use Modelarium\Exception\Exception;
27
use Modelarium\Exception\ScalarNotFoundException;
28
use Modelarium\GeneratedCollection;
29
use Modelarium\GeneratedItem;
30
use Modelarium\Parser;
31
use Modelarium\Types\FormulariumScalarType;
32
use Nette\PhpGenerator\ClassType;
33
34
use function Safe\array_combine;
35
use function Safe\rsort;
36
use function Safe\date;
37
use function Safe\preg_match;
38
39
function getStringBetween(string $string, string $start, string $end): string
40
{
41
    $ini = mb_strpos($string, $start);
42
    if ($ini === false) {
43
        return '';
44
    }
45
    $ini += mb_strlen($start);
46
    $len = mb_strpos($string, $end, $ini) - $ini;
47
    return mb_substr($string, $ini, $len);
48
}
49
50
function endsWith(string $haystack, string $needle): bool
51
{
52
    return substr_compare($haystack, $needle, -strlen($needle)) === 0;
53
}
54
class MigrationGenerator extends BaseGenerator
55
{
56
    /**
57
     * @var string
58
     */
59
    protected $stubDir = __DIR__ . "/stubs/";
60
61
    protected const MODE_CREATE = 'create';
62
    protected const MODE_PATCH = 'patch';
63
    protected const MODE_NO_CHANGE = 'nochange';
64
65
    /**
66
     * Unique counter
67
     *
68
     * @var integer
69
     */
70
    public static $counter = 0;
71
72
    /**
73
     * @var ObjectType
74
     */
75
    protected $type = null;
76
77
    /**
78
     * @var GeneratedCollection
79
     */
80
    protected $collection = null;
81
82
    /**
83
     * Code used in the create() call
84
     *
85
     * @var string[]
86
     */
87
    public $createCode = [];
88
89
    /**
90
     * Code used post the create() call
91
     *
92
     * @var string[]
93
     */
94
    public $postCreateCode = [];
95
96
    /**
97
     * 'create' or 'patch'
98
     *
99
     * @var string
100
     */
101
    protected $mode = self::MODE_CREATE;
102
103
    /**
104
     * Code used in the create() call
105
     *
106
     * @var string
107
     */
108
    protected $currentModel = '';
109
110
    /**
111
     * The last migration code
112
     *
113
     * @var string
114
     */
115
    protected $lastMigrationCode = null;
116
117
    /**
118
     * Time stamp
119
     *
120
     * @var string
121
     */
122
    protected $stamp = '';
123
124 8
    public function generate(): GeneratedCollection
125
    {
126 8
        $this->collection = new GeneratedCollection();
127 8
        $this->currentModel = \GraphQL\Language\Printer::doPrint($this->type->astNode);
128 8
        $this->stamp = date('Y_m_d_His');
129 8
        $filename = $this->generateFilename($this->lowerName);
130
131 8
        if ($this->mode !== self::MODE_NO_CHANGE) {
132 8
            $code = $this->generateString();
133 8
            if ($this->checkMigrationCodeChange($code)) {
134 8
                $item = new GeneratedItem(
135 8
                    GeneratedItem::TYPE_MIGRATION,
136
                    $code,
137
                    $filename
138
                );
139 8
                $this->collection->prepend($item);
140
            }
141
        }
142 8
        return $this->collection;
143
    }
144
145
    /**
146
     * @param \GraphQL\Type\Definition\FieldDefinition $field
147
     * @param \GraphQL\Language\AST\NodeList<\GraphQL\Language\AST\DirectiveNode> $directives
148
     * @return void
149
     */
150 22
    protected function processBasetype(
151
        \GraphQL\Type\Definition\FieldDefinition $field,
152
        \GraphQL\Language\AST\NodeList $directives
153
    ): void {
154 22
        $fieldName = $field->name;
155
156 22
        $required = false;
157 22
        if ($field->getType() instanceof NonNull) {
158 22
            $required = true;
159 22
            $type = $field->getType()->getWrappedType();
160
        } else {
161 1
            $type = $field->getType();
162
        }
163
164 22
        $codeFragment = new MigrationCodeFragment();
165 22
        $lcg = new LaravelCodeGenerator();
166 22
        $formulariumField = null;
167
168 22
        if ($type instanceof IDType) {
169 22
            $codeFragment->appendBase('$table->bigIncrements("id")');
170 17
        } elseif ($type instanceof StringType) {
171 17
            $formulariumField = new Field(
172 17
                $fieldName,
173 17
                'string',
174 17
                [],
175 17
                [ Datatype::REQUIRED => ['value' => $required ] ]
176
            );
177 4
        } elseif ($type instanceof IntType) {
178 1
            $formulariumField = new Field(
179 1
                $fieldName,
180 1
                'integer',
181 1
                [],
182 1
                [ Datatype::REQUIRED => ['value' => $required ] ]
183
            );
184 4
        } elseif ($type instanceof BooleanType) {
185 1
            $formulariumField = new Field(
186 1
                $fieldName,
187 1
                'boolean',
188 1
                [],
189 1
                [ Datatype::REQUIRED => ['value' => $required ] ]
190
            );
191 4
        } elseif ($type instanceof FloatType) {
192 1
            $formulariumField = new Field(
193 1
                $fieldName,
194 1
                'float',
195 1
                [],
196 1
                [ Datatype::REQUIRED => ['value' => $required ] ]
197
            );
198 3
        } elseif ($type instanceof EnumType) {
199
            $this->processEnum($field, $type, $codeFragment);
200 3
        } elseif ($type instanceof UnionType) {
201
            return;
202 3
        } elseif ($type instanceof CustomScalarType) {
203 3
            $ourType = $this->parser->getScalarType($type->name);
204 3
            if (!$ourType) {
205
                throw new Exception("Null scalar type: " . get_class($type));
206 3
            } elseif (!is_a($ourType, FormulariumScalarType::class) &&
207 3
                !is_a($ourType, \Modelarium\Types\ScalarType::class)
208
            ) {
209
                throw new Exception("Invalid extended scalar type: " . get_class($type));
210
            }
211
            /**
212
             * @var FormulariumScalarType $ourType
213
             */
214 3
            $formulariumField = new Field(
215 3
                $fieldName,
216 3
                $ourType->getDatatype(),
217 3
                [],
218 3
                [ Datatype::REQUIRED => ['value' => $required ] ]
219
            );
220
        } elseif ($type instanceof ListOfType) {
221
            throw new Exception("Invalid field type: " . get_class($type));
222
        } else {
223
            throw new Exception("Invalid field type: " . get_class($type));
224
        }
225
    
226 22
        if ($formulariumField) {
227 17
            $codeFragment->appendBase(
228 17
                '$table->' . $lcg->field($formulariumField)
0 ignored issues
show
Bug introduced by
Are you sure $lcg->field($formulariumField) of type string|string[] can be used in concatenation? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

228
                '$table->' . /** @scrutinizer ignore-type */ $lcg->field($formulariumField)
Loading history...
229
            );
230 22
        } elseif (!($field->getType() instanceof NonNull)) {
231
            $codeFragment->appendBase('->nullable()');
232
        }
233
234 22
        foreach ($directives as $directive) {
235 1
            $name = $directive->name->value;
236 1
            if ($name === 'migrationSkip') { // special case
237
                return;
238
            }
239
240 1
            $className = $this->getDirectiveClass($name);
241 1
            if ($className) {
242 1
                $methodName = "$className::processMigrationFieldDirective";
243
                /** @phpstan-ignore-next-line */
244 1
                $methodName(
245 1
                    $this,
246
                    $field,
247
                    $directive,
248
                    $codeFragment
249
                );
250
            }
251
        }
252
253 22
        $this->createCode[] = $codeFragment->base . ';';
254 22
        $this->createCode = array_merge($this->createCode, $codeFragment->extraLines);
255 22
    }
256
257
    protected function processEnum(
258
        \GraphQL\Type\Definition\FieldDefinition $field,
259
        EnumType $type,
260
        MigrationCodeFragment $codeFragment
261
    ): void {
262
        $fieldName = $field->name;
263
        $ourType = $this->parser->getScalarType($type->name);
264
        $parsedValues = $type->config['values'];
265
266
        if (!$ourType) {
267
            $parsedKeys = array_keys($parsedValues);
268
            $enumValues = array_combine($parsedKeys, $parsedKeys);
269
270
            // let's create this for the user
271
            $code = DatatypeFactory::generate(
272
                $type->name,
273
                'enum',
274
                'App\\Datatypes',
275
                'Tests\\Unit',
276
                function (ClassType $enumClass) use ($enumValues) {
277
                    $enumClass->addConstant('CHOICES', $enumValues);
278
                    $enumClass->getMethod('__construct')->addBody('$this->choices = self::CHOICES;');
279
                }
280
            );
281
    
282
            $path = base_path('app/Datatypes');
0 ignored issues
show
Bug introduced by
The function base_path was not found. Maybe you did not declare it correctly or list all dependencies? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

282
            $path = /** @scrutinizer ignore-call */ base_path('app/Datatypes');
Loading history...
283
            $lowerTypeName = mb_strtolower($type->name);
284
285
            $retval = DatatypeFactory::generateFile(
286
                $code,
287
                $path,
288
                base_path('tests/Unit/')
289
            );
290
291
            $php = \Modelarium\Util::generateLighthouseTypeFile($lowerTypeName, 'App\\Datatypes\\Types');
292
            $filename = $path . "/Types/Datatype_{$lowerTypeName}.php";
293
            if (!is_dir($path . "/Types")) {
294
                \Safe\mkdir($path . "/Types", 0777, true);
295
            }
296
            \Safe\file_put_contents($filename, $php);
297
    
298
            // recreate scalars
299
            \Modelarium\Util::generateScalarFile('App\\Datatypes', base_path('graphql/types.graphql'));
300
301
            // load php files that were just created
302
            require_once($retval['filename']);
303
            require_once($filename);
304
            $this->parser->appendScalar($type->name, 'App\\Datatypes\\Types\\Datatype_' . $lowerTypeName);
305
            $ourType = $this->parser->getScalarType($type->name);
306
        }
307
        if (!($ourType instanceof FormulariumScalarType)) {
308
            throw new Exception("Enum {$type->name} {$fieldName} is not a FormulariumScalarType");
309
        }
310
311
        /**
312
         * @var FormulariumScalarType $ourType
313
         */
314
        /**
315
         * @var Datatype_enum $ourDatatype
316
         */
317
        $ourDatatype = $ourType->getDatatype();
318
        $currentChoices = $ourDatatype->getChoices();
319
        if (array_diff_key($currentChoices, $parsedValues) || array_diff_key($parsedValues, $currentChoices)) {
320
            // TODO???
321
            $this->warn('Enum had its possible values changed. Please review the datatype class.');
322
        }
323
324
        $lcg = new LaravelCodeGenerator();
325
        $codeFragment->appendBase(
326
            '$table->' . $lcg->field(
0 ignored issues
show
Bug introduced by
Are you sure $lcg->field(new Formular...urType->getDatatype())) of type string|string[] can be used in concatenation? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

326
            '$table->' . /** @scrutinizer ignore-type */ $lcg->field(
Loading history...
327
                new Field($fieldName, $ourType->getDatatype())
328
            )
329
        );
330
    }
331
332
    /**
333
     * @param \GraphQL\Type\Definition\FieldDefinition $field
334
     * @param \GraphQL\Language\AST\NodeList<\GraphQL\Language\AST\DirectiveNode> $directives
335
     * @return void
336
     */
337 8
    protected function processRelationship(
338
        \GraphQL\Type\Definition\FieldDefinition $field,
339
        \GraphQL\Language\AST\NodeList $directives
340
    ): void {
341 8
        list($type, $isRequired) = Parser::getUnwrappedType($field->getType());
342 8
        $typeName = $type->name;
343
344
        // special types that should be skipped.
345 8
        if ($typeName === 'Can') {
346
            return;
347
        }
348
349 8
        $codeFragment = new MigrationCodeFragment();
350
351 8
        foreach ($directives as $directive) {
352 8
            $name = $directive->name->value;
353 8
            if ($name === 'migrationSkip') {
354
                return;
355
            }
356
357 8
            $className = $this->getDirectiveClass($name);
358 8
            if ($className) {
359 8
                $methodName = "$className::processMigrationRelationshipDirective";
360
                /** @phpstan-ignore-next-line */
361 8
                $methodName(
362 8
                    $this,
363
                    $field,
364
                    $directive,
365
                    $codeFragment
366
                );
367
            }
368
        }
369
370 8
        if ($codeFragment->base) {
371 6
            if (!($field->getType() instanceof NonNull)) {
372
                $codeFragment->appendBase('->nullable()');
373
            }
374 6
            $this->createCode[] = '$table' . $codeFragment->base . ';';
375
        }
376
        
377 8
        $this->createCode = array_merge($this->createCode, $codeFragment->extraLines);
378 8
    }
379
380 22
    public function generateString(): string
381
    {
382 22
        foreach ($this->type->getFields() as $field) {
383 22
            $directives = $field->astNode->directives;
384 22
            $type = $field->getType();
385
            if (
386 22
                ($type instanceof ObjectType) ||
387 22
                ($type instanceof ListOfType) ||
388 22
                ($type instanceof UnionType) ||
389 22
                ($type instanceof NonNull && (
390 22
                    ($type->getWrappedType() instanceof ObjectType) ||
391 22
                    ($type->getWrappedType() instanceof ListOfType) ||
392 22
                    ($type->getWrappedType() instanceof UnionType)
393
                ))
394
            ) {
395
                // relationship
396 8
                $this->processRelationship($field, $directives);
397
            } else {
398 22
                $this->processBasetype($field, $directives);
399
            }
400
        }
401
402
        assert($this->type->astNode !== null);
403
        /**
404
         * @var \GraphQL\Language\AST\NodeList<\GraphQL\Language\AST\DirectiveNode>|null
405
         */
406 22
        $directives = $this->type->astNode->directives;
407 22
        if ($directives) {
0 ignored issues
show
introduced by
$directives is of type GraphQL\Language\AST\NodeList, thus it always evaluated to true.
Loading history...
408 22
            $this->processTypeDirectives($directives, 'Migration');
409
        }
410
411
        $context = [
412 22
            'dummytablename' => $this->tableName,
413
            'modelSchemaCode' => "# start graphql\n" .
414 22
                $this->currentModel .
415 22
                "\n# end graphql",
416
        ];
417
418 22
        if ($this->mode === self::MODE_CREATE) {
419 22
            $context['className'] = 'Create' . $this->studlyName . str_replace('_', '', $this->stamp);
420 22
            $context['upOperation'] = 'create';
421 22
            $context['downOperation'] = 'dropIfExists';
422 22
            $context['dummyCode'] = join("\n            ", $this->createCode);
423 22
            $context['dummyInverseCode'] = null;
424 22
            $context['dummyPostCreateCode'] = join("\n            ", $this->postCreateCode);
425
        } else {
426
            $context['className'] = 'Patch' . $this->studlyName . str_replace('_', '', $this->stamp);
427
            $context['upOperation'] = 'table';
428
            $context['downOperation'] = 'table';
429
            $context['dummyCode'] = '// TODO: write the patch please';
430
            $context['dummyInverseCode'] = '// TODO: write the inverse patch please';
431
            $context['dummyPostCreateCode'] = '';
432
        }
433
434 22
        return $this->templateStub('migration', $context);
435
    }
436
437
    /**
438
     * creates a many-to-many morph relationship table
439
     *
440
     * @param string $name
441
     * @param string $relation
442
     * @return string The table name.
443
     */
444 1
    public function generateManyToManyMorphTable(string $name, string $relation): string
445
    {
446
        $dummyCode = <<<EOF
447
448 1
            \$table->unsignedBigInteger("{$name}_id");
449 1
            \$table->unsignedBigInteger("{$relation}_id");
450 1
            \$table->string("{$relation}_type");
451
EOF;
452
        $context = [
453 1
            'dummyCode' => $dummyCode,
454 1
            'upOperation' => 'create',
455 1
            'downOperation' => 'dropIfExists',
456 1
            'dummytablename' => $this->getInflector()->pluralize($relation), // TODO: check, toTableName()?
457 1
            'modelSchemaCode' => ''
458
        ];
459 1
        $contents = $this->templateStub('migration', $context);
460
461 1
        $item = new GeneratedItem(
462 1
            GeneratedItem::TYPE_MIGRATION,
463
            $contents,
464 1
            $this->getBasePath(
465
                'database/migrations/' .
466 1
                $this->stamp .
467 1
                str_pad((string)(static::$counter++), 3, "0", STR_PAD_LEFT) . // so we keep the same order of types in schema
468 1
                '_' . $this->mode . '_' .
469 1
                $relation .
470 1
                '_table.php'
471
            )
472
        );
473 1
        $this->collection->push($item);
474
475 1
        return $context['dummytablename'];
476
    }
477
478
    /**
479
     * creates a many-to-many relationship table
480
     *
481
     * @param string $type1
482
     * @param string $type2
483
     * @return string The table name.
484
     */
485 1
    public function generateManyToManyTable(string $type1, string $type2): string
486
    {
487
        $dummyCode = <<<EOF
488
489
            \$table->increments("id");
490 1
            \$table->unsignedBigInteger("{$type1}_id")->references('id')->on('{$type1}');
491 1
            \$table->unsignedBigInteger("{$type2}_id")->references('id')->on('{$type2}');
492
EOF;
493
        $context = [
494 1
            'dummyCode' => $dummyCode,
495 1
            'upOperation' => 'create',
496 1
            'downOperation' => 'dropIfExists',
497 1
            'dummytablename' => "{$type1}_{$type2}",
498 1
            'className' => Str::studly($this->mode) . Str::studly($type1) . Str::studly($type2),
499 1
            'modelSchemaCode' => ''
500
        ];
501 1
        $contents = $this->templateStub('migration', $context);
502
503 1
        $item = new GeneratedItem(
504 1
            GeneratedItem::TYPE_MIGRATION,
505
            $contents,
506 1
            $this->getBasePath(
507
                'database/migrations/' .
508 1
                $this->stamp .
509 1
                str_pad((string)(static::$counter++), 3, "0", STR_PAD_LEFT) . // so we keep the same order of types in schema
510 1
                '_' . $this->mode . '_' .
511 1
                $type1 . '_' . $type2 .
512 1
                '_table.php'
513
            )
514
        );
515 1
        $this->collection->push($item);
516
517 1
        return $context['dummytablename'];
518
    }
519
520 8
    protected function generateFilename(string $basename): string
521
    {
522 8
        $this->mode = self::MODE_CREATE;
523 8
        $match = '/(patch|create)_' . preg_quote($basename) . '_(table|[0-9])/';
524
525 8
        $basepath = $this->getBasePath('database/migrations/');
526 8
        if (is_dir($basepath)) {
527
            $migrationFiles = \Safe\scandir($basepath);
528
            rsort($migrationFiles);
529
            foreach ($migrationFiles as $m) {
530
                if (!preg_match($match, $m)) {
531
                    continue;
532
                }
533
534
                // get source
535
                $this->lastMigrationCode = \Safe\file_get_contents($basepath . '/' . $m);
536
537
                // compare with this source
538
                $model = trim(getStringBetween($this->lastMigrationCode, '# start graphql', '# end graphql'));
539
540
                // if equal ignore and don't output file
541
                if ($model === trim($this->currentModel)) {
542
                    $this->mode = self::MODE_NO_CHANGE;
543
                } else {
544
                    // else we'll generate a diff and patch
545
                    $this->mode = self::MODE_PATCH;
546
                }
547
                break;
548
            }
549
        }
550
551 8
        return $this->getBasePath(
552
            'database/migrations/' .
553 8
            $this->stamp .
554 8
            str_pad((string)(static::$counter++), 3, "0", STR_PAD_LEFT) . // so we keep the same order of types in schema
555 8
            '_' . $this->mode . '_' .
556 8
            $basename . '_' .
557 8
            str_replace('_', '', $this->stamp) . '_' .
558 8
            'table' .
559 8
            '.php'
560
        );
561
    }
562
563
    /**
564
     * Compares with the latest migration
565
     *
566
     * @param string $newcode
567
     * @return boolean
568
     */
569 8
    protected function checkMigrationCodeChange(string $newcode): bool
570
    {
571 8
        if (!$this->lastMigrationCode) {
572 8
            return true;
573
        }
574
        $tokens = token_get_all($this->lastMigrationCode);
575
        for ($i=0, $z=count($tokens); $i < $z; $i++) {
576
            if (is_array($tokens[$i]) && $tokens[$i][0] === T_FUNCTION
577
                && is_array($tokens[$i+1]) && $tokens[$i+1][0] == T_WHITESPACE
578
                && is_array($tokens[$i+2]) && $tokens[$i+2][1] == 'up'
579
            ) {
580
                $accumulator = [];
581
                $braceDepth = 0;
582
                // collect tokens from function head through opening brace
583
                while ($tokens[$i] != '{' && ($i < $z)) {
584
                    $accumulator[] = is_array($tokens[$i]) ? $tokens[$i][1] : $tokens[$i];
585
                    $i++;
586
                }
587
                if ($i == $z) {
588
                    // handle error
589
                } else {
590
                    // note, accumulate, and position index past brace
591
                    $braceDepth = 1;
592
                    $accumulator[] = '{';
593
                    $i++;
594
                }
595
                while ($braceDepth > 0 && ($i < $z)) {
596
                    if (is_array($tokens[$i])) {
597
                        $accumulator[] = $tokens[$i][1];
598
                    } else {
599
                        $accumulator[] = $tokens[$i];
600
                        if ($tokens[$i] == '{') {
601
                            $braceDepth++;
602
                        } elseif ($tokens[$i] == '}') {
603
                            $braceDepth--;
604
                        }
605
                    }
606
                }
607
                $functionSrc = implode("", $accumulator);
608
                if ($functionSrc == $newcode) {
609
                    return false;
610
                }
611
            }
612
        }
613
614
        return true;
615
    }
616
}
617