Passed
Push — master ( 4e6115...77d3fa )
by Bruno
10:05 queued 05:29
created

MigrationGenerator   F

Complexity

Total Complexity 71

Size/Duplication

Total Lines 529
Duplicated Lines 0 %

Test Coverage

Coverage 74.33%

Importance

Changes 12
Bugs 7 Features 0
Metric Value
eloc 295
c 12
b 7
f 0
dl 0
loc 529
ccs 197
cts 265
cp 0.7433
rs 2.7199
wmc 71

9 Methods

Rating   Name   Duplication   Size   Complexity  
A generate() 0 15 2
A generateManyToManyTable() 0 31 1
A processDirectives() 0 12 3
B generateString() 0 48 11
F processRelationship() 0 135 26
B processEnum() 0 66 6
C processBasetype() 0 66 16
A generateFilename() 0 37 5
A generateManyToManyMorphTable() 0 30 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 8
    public function generate(): GeneratedCollection
107
    {
108 8
        $this->collection = new GeneratedCollection();
109 8
        $this->currentModel = \GraphQL\Language\Printer::doPrint($this->type->astNode);
110 8
        $filename = $this->generateFilename($this->lowerName);
111
112 8
        if ($this->mode !== self::MODE_NO_CHANGE) {
113 8
            $item = new GeneratedItem(
114 8
                GeneratedItem::TYPE_MIGRATION,
115 8
                $this->generateString(),
116
                $filename
117
            );
118 8
            $this->collection->prepend($item);
119
        }
120 8
        return $this->collection;
121
    }
122
123 21
    protected function processBasetype(
124
        \GraphQL\Type\Definition\FieldDefinition $field,
125
        \GraphQL\Language\AST\NodeList $directives
126
    ): void {
127 21
        $fieldName = $field->name;
128
129 21
        if ($field->type instanceof NonNull) {
130 21
            $type = $field->type->getWrappedType();
131
        } else {
132 1
            $type = $field->type;
133
        }
134
135 21
        $codeFragment = new MigrationCodeFragment();
136
137 21
        if ($type instanceof IDType) {
138 21
            $codeFragment->appendBase('$table->bigIncrements("id")');
139 16
        } elseif ($type instanceof StringType) {
140 16
            $codeFragment->appendBase('$table->string("' . $fieldName . '")');
141 4
        } elseif ($type instanceof IntType) {
142 1
            $codeFragment->appendBase('$table->integer("' . $fieldName . '")');
143 4
        } elseif ($type instanceof BooleanType) {
144 1
            $codeFragment->appendBase('$table->boolean("' . $fieldName . '")');
145 4
        } elseif ($type instanceof FloatType) {
146 1
            $codeFragment->appendBase('$table->float("' . $fieldName . '")');
147 3
        } elseif ($type instanceof EnumType) {
148
            $this->processEnum($type, $codeFragment);
149 3
        } elseif ($type instanceof UnionType) {
150
            return;
151 3
        } elseif ($type instanceof CustomScalarType) {
152 3
            $ourType = $this->parser->getScalarType($type->name);
153 3
            if (!$ourType) {
154
                throw new Exception("Invalid extended scalar type: " . get_class($type));
155
            }
156 3
            $options = []; // TODO: from directives
157 3
            $codeFragment->appendBase('$table->' . $ourType->getLaravelSQLType($fieldName, $options));
158
        } elseif ($type instanceof ListOfType) {
159
            throw new Exception("Invalid field type: " . get_class($type));
160
        } else {
161
            throw new Exception("Invalid field type: " . get_class($type));
162
        }
163
164 21
        if (!($field->type instanceof NonNull)) {
165 1
            $codeFragment->appendBase('->nullable()');
166
        }
167
168 21
        foreach ($directives as $directive) {
169 2
            $name = $directive->name->value;
170 2
            if ($name === 'migrationSkip') { // special case
171
                return;
172
            }
173
174 2
            $className = $this->getDirectiveClass($name);
175 2
            if ($className) {
176 1
                $methodName = "$className::processMigrationFieldDirective";
177
                /** @phpstan-ignore-next-line */
178 1
                $methodName(
179 1
                    $this,
180
                    $field,
181
                    $directive,
182
                    $codeFragment
183
                );
184
            }
185
        }
186
187 21
        $this->createCode[] = $codeFragment->base . ';';
188 21
        $this->createCode = array_merge($this->createCode, $codeFragment->extraLines);
189 21
    }
190
191
    protected function processEnum(
192
        EnumType $type,
193
        MigrationCodeFragment $codeFragment
194
    ) {
195
        $ourType = $this->parser->getScalarType($type->name);
196
        $parsedValues = $type->config['values'];
197
198
        if (!$ourType) {
199
            $parsedKeys = array_keys($parsedValues);
200
            $enumValues = array_combine($parsedKeys, $parsedKeys);
201
202
            // let's create this for the user
203
            $code = DatatypeFactory::generate(
204
                $type->name,
205
                'enum',
206
                'App\\Datatypes',
207
                'Tests\\Unit',
208
                function (ClassType $enumClass) use ($enumValues) {
209
                    $enumClass->addConstant('CHOICES', $enumValues);
210
                    $enumClass->getMethod('__construct')->addBody('$this->choices = self::CHOICES;');
211
                }
212
            );
213
    
214
            $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

214
            $path = /** @scrutinizer ignore-call */ base_path('app/Datatypes');
Loading history...
215
            $lowerTypeName = mb_strtolower($type->name);
216
217
            $retval = DatatypeFactory::generateFile(
218
                $code,
219
                $path,
220
                base_path('tests/Unit/')
221
            );
222
223
            $php = \Modelarium\Util::generateLighthouseTypeFile($lowerTypeName, 'App\\Datatypes\\Types');
224
            $filename = $path . "/Types/Datatype_{$lowerTypeName}.php";
225
            if (!is_dir($path . "/Types")) {
226
                \Safe\mkdir($path . "/Types", 0777, true);
227
            }
228
            \Safe\file_put_contents($filename, $php);
229
    
230
            // recreate scalars
231
            \Modelarium\Util::generateScalarFile('App\\Datatypes', base_path('graphql/types.graphql'));
232
233
            // load php files that were just created
234
            require_once($retval['filename']);
235
            require_once($filename);
236
            $this->parser->appendScalar($type->name, 'App\\Datatypes\\Types\\Datatype_' . $lowerTypeName);
237
            $ourType = $this->parser->getScalarType($type->name);
238
        }
239
        if (!($ourType instanceof FormulariumScalarType)) {
240
            throw new Exception("Enum {$type->name} {$fieldName} is not a FormulariumScalarType");
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $fieldName seems to be never defined.
Loading history...
241
        }
242
243
        /**
244
         * @var FormulariumScalarType $ourType
245
         */
246
        /**
247
         * @var Datatype_enum $ourDatatype
248
         */
249
        $ourDatatype = $ourType->getDatatype();
250
        $currentChoices = $ourDatatype->getChoices();
251
        if (array_diff_key($currentChoices, $parsedValues) || array_diff_key($parsedValues, $currentChoices)) {
252
            // TODO???
253
        }
254
255
        $options = []; // TODO: from directives
256
        $codeFragment->appendBase('$table->'  . $ourType->getLaravelSQLType($fieldName, $options));
257
    }
258
259 8
    protected function processRelationship(
260
        \GraphQL\Type\Definition\FieldDefinition $field,
261
        \GraphQL\Language\AST\NodeList $directives
262
    ): void {
263 8
        $lowerName = mb_strtolower($this->getInflector()->singularize($field->name));
264 8
        $lowerNamePlural = $this->getInflector()->pluralize($lowerName);
265 8
        $fieldName = $lowerName . '_id';
266
267 8
        list($type, $isRequired) = Parser::getUnwrappedType($field->type);
268 8
        $typeName = $type->name;
269 8
        $tableName = self::toTableName($typeName);
270
271 8
        $base = null;
272 8
        $extra = [];
273
274
        // special types that should be skipped.
275 8
        if ($typeName === 'Can') {
276
            return;
277
        }
278
279 8
        $isManyToMany = false;
280 8
        foreach ($directives as $directive) {
281 8
            $name = $directive->name->value;
282 8
            if ($name === 'migrationSkip') {
283
                return;
284
            }
285
286
            // TODO: separate classes
287
            // $className = $this->getDirectiveClass($name);
288
            // if ($className) {
289
            //     $methodName = "$className::processMigrationRelationshipDirective";
290
            //     /** @phpstan-igno re-next-line */
291
            //     $methodName(
292
            //         $this,
293
            //         $field,
294
            //         $directive
295
            //     );
296
            // }
297
298 8
            switch ($name) {
299 8
            case 'migrationUniqueIndex':
300
                $extra[] = '$table->unique("' . $fieldName . '");';
301
                break;
302 8
            case 'migrationIndex':
303
                $extra[] = '$table->index("' . $fieldName . '");';
304
                break;
305 8
            case 'belongsTo':
306 4
                $targetType = $this->parser->getType($typeName);
307 4
                if (!$targetType) {
308
                    throw new Exception("Cannot get type {$typeName} as a relationship to {$this->baseName}");
309 4
                } elseif (!($targetType instanceof ObjectType)) {
310
                    throw new Exception("{$typeName} is not a type for a relationship to {$this->baseName}");
311
                }
312
                // we don't know what is the reverse relationship name at this point. so let's guess all possibilities
313
                try {
314 4
                    $targetField = $targetType->getField($tableName);
315 4
                } catch (\GraphQL\Error\InvariantViolation $e) {
316
                    try {
317 4
                        $targetField = $targetType->getField($this->tableName);
318 3
                    } catch (\GraphQL\Error\InvariantViolation $e) {
319
                        // one to one
320 3
                        $targetField = $targetType->getField($this->lowerName);
321
                    }
322
                }
323
324 4
                $targetDirectives = $targetField->astNode->directives;
325 4
                foreach ($targetDirectives as $targetDirective) {
326 4
                    switch ($targetDirective->name->value) {
327 4
                    case 'hasOne':
328 1
                    case 'hasMany':
329 4
                        $base = '$table->unsignedBigInteger("' . $fieldName . '")';
330 4
                    break;
331
                    }
332
                }
333 4
                break;
334
335 8
            case 'belongsToMany':
336 1
                $type1 = $this->lowerName;
337 1
                $type2 = $lowerName;
338
339
                // we only generate once, so use a comparison for that
340 1
                $isManyToMany = true;
341 1
                if (strcasecmp($type1, $type2) < 0) {
342 1
                    $this->generateManyToManyTable($type1, $type2);
343
                }
344 1
                break;
345
346 7
            case 'morphTo':
347 2
                $relation = Parser::getDirectiveArgumentByName($directive, 'relation', $lowerName);
348 2
                $base = '$table->unsignedBigInteger("' . $relation . '_id")';
349 2
                $extra[] = '$table->string("' . $relation . '_type")' .
350 2
                    ($isRequired ? '' : '->nullable()') . ';';
351 2
                break;
352
353 7
            case 'morphedByMany':
354 1
                $isManyToMany = true;
355 1
                $relation = Parser::getDirectiveArgumentByName($directive, 'relation', $lowerName);
356 1
                $this->generateManyToManyMorphTable($this->lowerName, $relation);
357 1
                break;
358
            }
359
        }
360
361 8
        foreach ($directives as $directive) {
362 8
            $name = $directive->name->value;
363
            switch ($name) {
364 8
            case 'migrationForeign':
365
                
366 4
                if (!$isManyToMany) {
367 4
                    $arguments = array_merge(
368
                        [
369 4
                            'references' => 'id',
370 4
                            'on' => $lowerNamePlural
371
                        ],
372 4
                        Parser::getDirectiveArguments($directive)
373
                    );
374
    
375 4
                    $extra[] = '$table->foreign("' . $fieldName . '")' .
376 4
                        "->references(\"{$arguments['references']}\")" .
377 4
                        "->on(\"{$arguments['on']}\")" .
378 4
                        (($arguments['onDelete'] ?? '') ? "->onDelete(\"{$arguments['onDelete']}\")" : '') .
379 4
                        (($arguments['onUpdate'] ?? '') ? "->onUpdate(\"{$arguments['onUpdate']}\")" : '') .
380 4
                        ';';
381
                }
382 4
                break;
383
            }
384
        }
385
386 8
        if ($base) {
387 6
            if (!($field->type instanceof NonNull)) {
388
                $base .= '->nullable()';
389
            }
390 6
            $base .= ';';
391 6
            $this->createCode[] = $base;
392
        }
393 8
        $this->createCode = array_merge($this->createCode, $extra);
394 8
    }
395
396 21
    protected function processDirectives(
397
        \GraphQL\Language\AST\NodeList $directives
398
    ): void {
399 21
        foreach ($directives as $directive) {
400 6
            $name = $directive->name->value;
401 6
            $className = $this->getDirectiveClass($name);
402 6
            if ($className) {
403 6
                $methodName = "$className::processMigrationTypeDirective";
404
                /** @phpstan-ignore-next-line */
405 6
                $methodName(
406 6
                    $this,
407
                    $directive
408
                );
409
            }
410
        }
411 21
    }
412
413 21
    public function generateString(): string
414
    {
415 21
        foreach ($this->type->getFields() as $field) {
416 21
            $directives = $field->astNode->directives;
417
            if (
418 21
                ($field->type instanceof ObjectType) ||
419 21
                ($field->type instanceof ListOfType) ||
420 21
                ($field->type instanceof UnionType) ||
421 21
                ($field->type instanceof NonNull && (
422 21
                    ($field->type->getWrappedType() instanceof ObjectType) ||
423 21
                    ($field->type->getWrappedType() instanceof ListOfType) ||
424 21
                    ($field->type->getWrappedType() instanceof UnionType)
425
                ))
426
            ) {
427
                // relationship
428 8
                $this->processRelationship($field, $directives);
429
            } else {
430 21
                $this->processBasetype($field, $directives);
431
            }
432
        }
433
434
        assert($this->type->astNode !== null);
435
        /**
436
         * @var \GraphQL\Language\AST\NodeList|null
437
         */
438 21
        $directives = $this->type->astNode->directives;
439 21
        if ($directives) {
440 21
            $this->processDirectives($directives);
0 ignored issues
show
Bug introduced by
$directives of type GraphQL\Language\AST\DirectiveNode[] is incompatible with the type GraphQL\Language\AST\NodeList expected by parameter $directives of Modelarium\Laravel\Targe...or::processDirectives(). ( Ignorable by Annotation )

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

440
            $this->processDirectives(/** @scrutinizer ignore-type */ $directives);
Loading history...
441
        }
442
443
        $context = [
444 21
            'dummytablename' => $this->tableName,
445
            'modelSchemaCode' => "# start graphql\n" .
446 21
                $this->currentModel .
447 21
                "\n# end graphql",
448
        ];
449
450 21
        if ($this->mode === self::MODE_CREATE) {
451 21
            $context['className'] = 'Create' . $this->studlyName;
452 21
            $context['dummyCode'] = join("\n            ", $this->createCode);
453 21
            $context['dummyPostCreateCode'] = join("\n            ", $this->postCreateCode);
454
        } else {
455
            $context['className'] = 'Patch' . $this->studlyName . date('YmdHis');
456
            $context['dummyCode'] = '// TODO: write the patch please';
457
            $context['dummyPostCreateCode'] = '';
458
        }
459
460 21
        return $this->templateStub('migration', $context);
461
    }
462
463
    /**
464
     * creates a many-to-many morph relationship table
465
     *
466
     * @param string $name
467
     * @param string $relation
468
     * @return string The table name.
469
     */
470 1
    protected function generateManyToManyMorphTable(string $name, string $relation): string
471
    {
472
        $dummyCode = <<<EOF
473
474 1
            \$table->unsignedBigInteger("{$name}_id");
475 1
            \$table->unsignedBigInteger("{$relation}_id");
476 1
            \$table->string("{$relation}_type");
477
EOF;
478
        $context = [
479 1
            'dummyCode' => $dummyCode,
480 1
            'dummytablename' => $this->getInflector()->pluralize($relation), // TODO: check, toTableName()?
481 1
            'modelSchemaCode' => ''
482
        ];
483 1
        $contents = $this->templateStub('migration', $context);
484
485 1
        $item = new GeneratedItem(
486 1
            GeneratedItem::TYPE_MIGRATION,
487
            $contents,
488 1
            $this->getBasePath(
489
                'database/migrations/' .
490 1
                date('Y_m_d_His') .
491 1
                str_pad((string)(static::$counter++), 3, "0", STR_PAD_LEFT) . // so we keep the same order of types in schema
492 1
                '_' . $this->mode . '_' .
493 1
                $relation .
494 1
                '_table.php'
495
            )
496
        );
497 1
        $this->collection->push($item);
498
499 1
        return $context['dummytablename'];
500
    }
501
502
    /**
503
     * creates a many-to-many relationship table
504
     *
505
     * @param string $type1
506
     * @param string $type2
507
     * @return string The table name.
508
     */
509 1
    protected function generateManyToManyTable(string $type1, string $type2): string
510
    {
511
        $dummyCode = <<<EOF
512
513
            \$table->increments("id");
514 1
            \$table->unsignedBigInteger("{$type1}_id")->references('id')->on('{$type1}');
515 1
            \$table->unsignedBigInteger("{$type2}_id")->references('id')->on('{$type2}');
516
EOF;
517
        $context = [
518 1
            'dummyCode' => $dummyCode,
519 1
            'dummytablename' => "{$type1}_{$type2}",
520 1
            'className' => Str::studly($this->mode) . Str::studly($type1) . Str::studly($type2),
521 1
            'modelSchemaCode' => ''
522
        ];
523 1
        $contents = $this->templateStub('migration', $context);
524
525 1
        $item = new GeneratedItem(
526 1
            GeneratedItem::TYPE_MIGRATION,
527
            $contents,
528 1
            $this->getBasePath(
529
                'database/migrations/' .
530 1
                date('Y_m_d_His') .
531 1
                str_pad((string)(static::$counter++), 3, "0", STR_PAD_LEFT) . // so we keep the same order of types in schema
532 1
                '_' . $this->mode . '_' .
533 1
                $type1 . '_' . $type2 .
534 1
                '_table.php'
535
            )
536
        );
537 1
        $this->collection->push($item);
538
539 1
        return $context['dummytablename'];
540
    }
541
542 8
    protected function generateFilename(string $basename): string
543
    {
544 8
        $this->mode = self::MODE_CREATE;
545 8
        $match = '_' . $basename . '_table.php';
546
547 8
        $basepath = $this->getBasePath('database/migrations/');
548 8
        if (is_dir($basepath)) {
549
            $migrationFiles = \Safe\scandir($basepath);
550
            rsort($migrationFiles);
551
            foreach ($migrationFiles as $m) {
552
                if (!endsWith($m, $match)) {
553
                    continue;
554
                }
555
556
                // get source
557
                $data = \Safe\file_get_contents($basepath . '/' . $m);
558
559
                // compare with this source
560
                $model = trim(getStringBetween($data, '# start graphql', '# end graphql'));
561
562
                // if equal ignore and don't output file
563
                if ($model === $this->currentModel) {
564
                    $this->mode = self::MODE_NO_CHANGE;
565
                } else {
566
                    // else we'll generate a diff and patch
567
                    $this->mode = self::MODE_PATCH;
568
                }
569
                break;
570
            }
571
        }
572
573 8
        return $this->getBasePath(
574
            'database/migrations/' .
575 8
            date('Y_m_d_His') .
576 8
            str_pad((string)(static::$counter++), 3, "0", STR_PAD_LEFT) . // so we keep the same order of types in schema
577 8
            '_' . $this->mode . '_' .
578 8
            $basename . '_table.php'
579
        );
580
    }
581
}
582