Passed
Push — master ( 341a7c...2223cb )
by Bruno
03:55
created

MigrationGenerator   F

Complexity

Total Complexity 69

Size/Duplication

Total Lines 518
Duplicated Lines 0 %

Test Coverage

Coverage 61.57%

Importance

Changes 17
Bugs 7 Features 0
Metric Value
eloc 277
dl 0
loc 518
ccs 149
cts 242
cp 0.6157
rs 2.88
c 17
b 7
f 0
wmc 69

9 Methods

Rating   Name   Duplication   Size   Complexity  
A generateManyToManyTable() 0 33 1
D checkMigrationCodeChange() 0 46 19
B generateString() 0 55 11
A generate() 0 19 3
B processRelationship() 0 41 7
B processEnum() 0 69 6
C processBasetype() 0 66 16
A generateFilename() 0 40 5
A generateManyToManyMorphTable() 0 32 1

How to fix   Complexity   

Complex Class

Complex classes like MigrationGenerator often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use MigrationGenerator, and based on these observations, apply Extract Interface, too.

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

239
            $path = /** @scrutinizer ignore-call */ base_path('app/Datatypes');
Loading history...
240
            $lowerTypeName = mb_strtolower($type->name);
241
242
            $retval = DatatypeFactory::generateFile(
243
                $code,
244
                $path,
245
                base_path('tests/Unit/')
246
            );
247
248
            $php = \Modelarium\Util::generateLighthouseTypeFile($lowerTypeName, 'App\\Datatypes\\Types');
249
            $filename = $path . "/Types/Datatype_{$lowerTypeName}.php";
250
            if (!is_dir($path . "/Types")) {
251
                \Safe\mkdir($path . "/Types", 0777, true);
252
            }
253
            \Safe\file_put_contents($filename, $php);
254
    
255
            // recreate scalars
256
            \Modelarium\Util::generateScalarFile('App\\Datatypes', base_path('graphql/types.graphql'));
257
258
            // load php files that were just created
259
            require_once($retval['filename']);
260
            require_once($filename);
261
            $this->parser->appendScalar($type->name, 'App\\Datatypes\\Types\\Datatype_' . $lowerTypeName);
262
            $ourType = $this->parser->getScalarType($type->name);
263
        }
264
        if (!($ourType instanceof FormulariumScalarType)) {
265
            throw new Exception("Enum {$type->name} {$fieldName} is not a FormulariumScalarType");
266
        }
267
268
        /**
269
         * @var FormulariumScalarType $ourType
270
         */
271
        /**
272
         * @var Datatype_enum $ourDatatype
273
         */
274
        $ourDatatype = $ourType->getDatatype();
275
        $currentChoices = $ourDatatype->getChoices();
276
        if (array_diff_key($currentChoices, $parsedValues) || array_diff_key($parsedValues, $currentChoices)) {
277
            // TODO???
278
            $this->warn('Enum had its possible values changed. Please review the datatype class.');
279
        }
280
281
        $options = []; // TODO: from directives
282
        $codeFragment->appendBase('$table->'  . $ourType->getLaravelSQLType($fieldName, $options));
283
    }
284
285
    /**
286
     * @param \GraphQL\Type\Definition\FieldDefinition $field
287
     * @param \GraphQL\Language\AST\NodeList<\GraphQL\Language\AST\DirectiveNode> $directives
288
     * @return void
289
     */
290 8
    protected function processRelationship(
291
        \GraphQL\Type\Definition\FieldDefinition $field,
292
        \GraphQL\Language\AST\NodeList $directives
293
    ): void {
294 8
        list($type, $isRequired) = Parser::getUnwrappedType($field->getType());
295 8
        $typeName = $type->name;
296
297
        // special types that should be skipped.
298 8
        if ($typeName === 'Can') {
299
            return;
300
        }
301
302 8
        $codeFragment = new MigrationCodeFragment();
303
304 8
        foreach ($directives as $directive) {
305 8
            $name = $directive->name->value;
306 8
            if ($name === 'migrationSkip') {
307
                return;
308
            }
309
310 8
            $className = $this->getDirectiveClass($name);
311 8
            if ($className) {
312 8
                $methodName = "$className::processMigrationRelationshipDirective";
313
                /** @phpstan-ignore-next-line */
314 8
                $methodName(
315 8
                    $this,
316
                    $field,
317
                    $directive,
318
                    $codeFragment
319
                );
320
            }
321
        }
322
323 8
        if ($codeFragment->base) {
324 6
            if (!($field->getType() instanceof NonNull)) {
325
                $codeFragment->appendBase('->nullable()');
326
            }
327 6
            $this->createCode[] = '$table' . $codeFragment->base . ';';
328
        }
329
        
330 8
        $this->createCode = array_merge($this->createCode, $codeFragment->extraLines);
331 8
    }
332
333 22
    public function generateString(): string
334
    {
335 22
        foreach ($this->type->getFields() as $field) {
336 22
            $directives = $field->astNode->directives;
337 22
            $type = $field->getType();
338
            if (
339 22
                ($type instanceof ObjectType) ||
340 22
                ($type instanceof ListOfType) ||
341 22
                ($type instanceof UnionType) ||
342 22
                ($type instanceof NonNull && (
343 22
                    ($type->getWrappedType() instanceof ObjectType) ||
344 22
                    ($type->getWrappedType() instanceof ListOfType) ||
345 22
                    ($type->getWrappedType() instanceof UnionType)
346
                ))
347
            ) {
348
                // relationship
349 8
                $this->processRelationship($field, $directives);
350
            } else {
351 22
                $this->processBasetype($field, $directives);
352
            }
353
        }
354
355
        assert($this->type->astNode !== null);
356
        /**
357
         * @var \GraphQL\Language\AST\NodeList<\GraphQL\Language\AST\DirectiveNode>|null
358
         */
359 22
        $directives = $this->type->astNode->directives;
360 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...
361 22
            $this->processTypeDirectives($directives, 'Migration');
362
        }
363
364
        $context = [
365 22
            'dummytablename' => $this->tableName,
366
            'modelSchemaCode' => "# start graphql\n" .
367 22
                $this->currentModel .
368 22
                "\n# end graphql",
369
        ];
370
371 22
        if ($this->mode === self::MODE_CREATE) {
372 22
            $context['className'] = 'Create' . $this->studlyName;
373 22
            $context['upOperation'] = 'create';
374 22
            $context['downOperation'] = 'dropIfExists';
375 22
            $context['dummyCode'] = join("\n            ", $this->createCode);
376 22
            $context['dummyInverseCode'] = null;
377 22
            $context['dummyPostCreateCode'] = join("\n            ", $this->postCreateCode);
378
        } else {
379
            $context['className'] = 'Patch' . $this->studlyName . str_replace('_', '', $this->stamp);
380
            $context['upOperation'] = 'table';
381
            $context['downOperation'] = 'table';
382
            $context['dummyCode'] = '// TODO: write the patch please';
383
            $context['dummyInverseCode'] = '// TODO: write the inverse patch please';
384
            $context['dummyPostCreateCode'] = '';
385
        }
386
387 22
        return $this->templateStub('migration', $context);
388
    }
389
390
    /**
391
     * creates a many-to-many morph relationship table
392
     *
393
     * @param string $name
394
     * @param string $relation
395
     * @return string The table name.
396
     */
397 1
    public function generateManyToManyMorphTable(string $name, string $relation): string
398
    {
399
        $dummyCode = <<<EOF
400
401 1
            \$table->unsignedBigInteger("{$name}_id");
402 1
            \$table->unsignedBigInteger("{$relation}_id");
403 1
            \$table->string("{$relation}_type");
404
EOF;
405
        $context = [
406 1
            'dummyCode' => $dummyCode,
407 1
            'upOperation' => 'create',
408 1
            'downOperation' => 'dropIfExists',
409 1
            'dummytablename' => $this->getInflector()->pluralize($relation), // TODO: check, toTableName()?
410 1
            'modelSchemaCode' => ''
411
        ];
412 1
        $contents = $this->templateStub('migration', $context);
413
414 1
        $item = new GeneratedItem(
415 1
            GeneratedItem::TYPE_MIGRATION,
416
            $contents,
417 1
            $this->getBasePath(
418
                'database/migrations/' .
419 1
                $this->stamp .
420 1
                str_pad((string)(static::$counter++), 3, "0", STR_PAD_LEFT) . // so we keep the same order of types in schema
421 1
                '_' . $this->mode . '_' .
422 1
                $relation .
423 1
                '_table.php'
424
            )
425
        );
426 1
        $this->collection->push($item);
427
428 1
        return $context['dummytablename'];
429
    }
430
431
    /**
432
     * creates a many-to-many relationship table
433
     *
434
     * @param string $type1
435
     * @param string $type2
436
     * @return string The table name.
437
     */
438 1
    public function generateManyToManyTable(string $type1, string $type2): string
439
    {
440
        $dummyCode = <<<EOF
441
442
            \$table->increments("id");
443 1
            \$table->unsignedBigInteger("{$type1}_id")->references('id')->on('{$type1}');
444 1
            \$table->unsignedBigInteger("{$type2}_id")->references('id')->on('{$type2}');
445
EOF;
446
        $context = [
447 1
            'dummyCode' => $dummyCode,
448 1
            'upOperation' => 'create',
449 1
            'downOperation' => 'dropIfExists',
450 1
            'dummytablename' => "{$type1}_{$type2}",
451 1
            'className' => Str::studly($this->mode) . Str::studly($type1) . Str::studly($type2),
452 1
            'modelSchemaCode' => ''
453
        ];
454 1
        $contents = $this->templateStub('migration', $context);
455
456 1
        $item = new GeneratedItem(
457 1
            GeneratedItem::TYPE_MIGRATION,
458
            $contents,
459 1
            $this->getBasePath(
460
                'database/migrations/' .
461 1
                $this->stamp .
462 1
                str_pad((string)(static::$counter++), 3, "0", STR_PAD_LEFT) . // so we keep the same order of types in schema
463 1
                '_' . $this->mode . '_' .
464 1
                $type1 . '_' . $type2 .
465 1
                '_table.php'
466
            )
467
        );
468 1
        $this->collection->push($item);
469
470 1
        return $context['dummytablename'];
471
    }
472
473 8
    protected function generateFilename(string $basename): string
474
    {
475 8
        $this->mode = self::MODE_CREATE;
476 8
        $match = '/(patch|create)_' . preg_quote($basename) . '_(table|[0-9])/';
477
478 8
        $basepath = $this->getBasePath('database/migrations/');
479 8
        if (is_dir($basepath)) {
480
            $migrationFiles = \Safe\scandir($basepath);
481
            rsort($migrationFiles);
482
            foreach ($migrationFiles as $m) {
483
                if (!preg_match($match, $m)) {
484
                    continue;
485
                }
486
487
                // get source
488
                $this->lastMigrationCode = \Safe\file_get_contents($basepath . '/' . $m);
489
490
                // compare with this source
491
                $model = trim(getStringBetween($this->lastMigrationCode, '# start graphql', '# end graphql'));
492
493
                // if equal ignore and don't output file
494
                if ($model === trim($this->currentModel)) {
495
                    $this->mode = self::MODE_NO_CHANGE;
496
                } else {
497
                    // else we'll generate a diff and patch
498
                    $this->mode = self::MODE_PATCH;
499
                }
500
                break;
501
            }
502
        }
503
504 8
        return $this->getBasePath(
505
            'database/migrations/' .
506 8
            $this->stamp .
507 8
            str_pad((string)(static::$counter++), 3, "0", STR_PAD_LEFT) . // so we keep the same order of types in schema
508 8
            '_' . $this->mode . '_' .
509 8
            $basename . '_' .
510 8
            str_replace('_', '', $this->stamp) . '_' .
511 8
            'table' .
512 8
            '.php'
513
        );
514
    }
515
516
    /**
517
     * Compares with the latest migration
518
     *
519
     * @param string $newcode
520
     * @return boolean
521
     */
522 8
    protected function checkMigrationCodeChange(string $newcode): bool
523
    {
524 8
        if (!$this->lastMigrationCode) {
525 8
            return true;
526
        }
527
        $tokens = token_get_all($this->lastMigrationCode);
528
        for ($i=0,$z=count($tokens); $i<$z; $i++) {
529
            if (is_array($tokens[$i]) && $tokens[$i] === T_FUNCTION
530
                && is_array($tokens[$i+1]) && $tokens[$i+1][0] == T_WHITESPACE
531
                && is_array($tokens[$i+2]) && $tokens[$i+2][1] == 'up'
532
            ) {
533
                $accumulator = [];
534
                // collect tokens from function head through opening brace
535
                while ($tokens[$i] != '{' && ($i < $z)) {
536
                    $accumulator[] = is_array($tokens[$i]) ? $tokens[$i][1] : $tokens[$i];
537
                    $i++;
538
                }
539
                if ($i == $z) {
540
                    // handle error
541
                } else {
542
                    // note, accumulate, and position index past brace
543
                    $braceDepth = 1;
544
                    $accumulator[] = '{';
545
                    $i++;
546
                }
547
                while ($braceDepth > 0 && ($i < $z)) {
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $braceDepth does not seem to be defined for all execution paths leading up to this point.
Loading history...
548
                    if (is_array($tokens[$i])) {
549
                        $accumulator[] = $tokens[$i][1];
550
                    } else {
551
                        $accumulator[] = $tokens[$i];
552
                        if ($tokens[$i] == '{') {
553
                            $braceDepth++;
554
                        } elseif ($tokens[$i] == '}') {
555
                            $braceDepth--;
556
                        }
557
                    }
558
                }
559
                $functionSrc = implode(null, $accumulator);
0 ignored issues
show
Bug introduced by
null of type null is incompatible with the type string expected by parameter $glue of implode(). ( Ignorable by Annotation )

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

559
                $functionSrc = implode(/** @scrutinizer ignore-type */ null, $accumulator);
Loading history...
560
                var_dump($functionSrc, $newcode);
0 ignored issues
show
Security Debugging Code introduced by
var_dump($functionSrc, $newcode) looks like debug code. Are you sure you do not want to remove it?
Loading history...
561
                if ($functionSrc == $newcode) {
562
                    return false;
563
                }
564
            }
565
        }
566
567
        return true;
568
    }
569
}
570