Passed
Push — master ( 253428...340885 )
by Bruno
03:15
created

MigrationGenerator   F

Complexity

Total Complexity 80

Size/Duplication

Total Lines 542
Duplicated Lines 0 %

Test Coverage

Coverage 76.01%

Importance

Changes 7
Bugs 3 Features 0
Metric Value
wmc 80
eloc 324
c 7
b 3
f 0
dl 0
loc 542
ccs 225
cts 296
cp 0.7601
rs 2

8 Methods

Rating   Name   Duplication   Size   Complexity  
A generateManyToManyTable() 0 31 1
B generateString() 0 48 11
A generate() 0 15 2
F processRelationship() 0 114 25
F processBasetype() 0 129 22
A generateManyToManyMorphTable() 0 30 1
C processDirectives() 0 52 13
A generateFilename() 0 37 5

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
    protected $createCode = [];
84
85
    /**
86
     * Code used post the create() call
87
     *
88
     * @var string[]
89
     */
90
    protected $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 21
        $extra = [];
129
130
        // TODO: scalars
131
132 21
        if ($field->type instanceof NonNull) {
133 21
            $type = $field->type->getWrappedType();
134
        } else {
135 1
            $type = $field->type;
136
        }
137
138 21
        $base = '';
139 21
        if ($type instanceof IDType) {
140 21
            $base = '$table->bigIncrements("id")';
141 16
        } elseif ($type instanceof StringType) {
142 16
            $base = '$table->string("' . $fieldName . '")';
143 4
        } elseif ($type instanceof IntType) {
144 1
            $base = '$table->integer("' . $fieldName . '")';
145 4
        } elseif ($type instanceof BooleanType) {
146 1
            $base = '$table->bool("' . $fieldName . '")';
147 4
        } elseif ($type instanceof FloatType) {
148 1
            $base = '$table->float("' . $fieldName . '")';
149 3
        } elseif ($type instanceof EnumType) {
150
            $ourType = $this->parser->getScalarType($type->name);
151
            $parsedValues = $type->config['values'];
152
153
            if (!$ourType) {
154
                $parsedKeys = array_keys($parsedValues);
155
                $enumValues = array_combine($parsedKeys, $parsedKeys);
156
157
                // let's create this for the user
158
                $code = DatatypeFactory::generate(
159
                    $type->name,
160
                    'enum',
161
                    'App\\Datatypes',
162
                    'Tests\\Unit',
163
                    function (ClassType $enumClass) use ($enumValues) {
164
                        $enumClass->addConstant('CHOICES', $enumValues);
165
                        $enumClass->getMethod('__construct')->addBody('$this->choices = self::CHOICES;');
166
                    }
167
                );
168
        
169
                $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

169
                $path = /** @scrutinizer ignore-call */ base_path('app/Datatypes');
Loading history...
170
                $lowerTypeName = mb_strtolower($type->name);
171
172
                $retval = DatatypeFactory::generateFile(
173
                    $code,
174
                    $path,
175
                    base_path('tests/Unit/')
176
                );
177
178
                $php = \Modelarium\Util::generateLighthouseTypeFile($lowerTypeName, 'App\\Datatypes\\Types');
179
                $filename = $path . "/Types/Datatype_{$lowerTypeName}.php";
180
                if (!is_dir($path . "/Types")) {
181
                    \Safe\mkdir($path . "/Types", 0777, true);
182
                }
183
                \Safe\file_put_contents($filename, $php);
184
        
185
                // recreate scalars
186
                \Modelarium\Util::generateScalarFile('App\\Datatypes', base_path('graphql/types.graphql'));
187
188
                // load php files that were just created
189
                require_once($retval['filename']);
190
                require_once($filename);
191
                $this->parser->appendScalar($type->name, 'App\\Datatypes\\Types\\Datatype_' . $lowerTypeName);
192
                $ourType = $this->parser->getScalarType($type->name);
193
            }
194
            if (!($ourType instanceof FormulariumScalarType)) {
195
                throw new Exception("Enum {$type->name} {$fieldName} is not a FormulariumScalarType");
196
            }
197
198
            /**
199
             * @var FormulariumScalarType $ourType
200
             */
201
            /**
202
             * @var Datatype_enum $ourDatatype
203
             */
204
            $ourDatatype = $ourType->getDatatype();
205
            $currentChoices = $ourDatatype->getChoices();
206
            if (array_diff_key($currentChoices, $parsedValues) || array_diff_key($parsedValues, $currentChoices)) {
207
                // TODO???
208
            }
209 3
        } elseif ($type instanceof UnionType) {
210
            return;
211 3
        } elseif ($type instanceof CustomScalarType) {
212 3
            $ourType = $this->parser->getScalarType($type->name);
213 3
            if (!$ourType) {
214
                throw new Exception("Invalid extended scalar type: " . get_class($type));
215
            }
216 3
            $options = []; // TODO: from directives
217 3
            $base = '$table->' . $ourType->getLaravelSQLType($fieldName, $options);
218
        } elseif ($type instanceof ListOfType) {
219
            throw new Exception("Invalid field type: " . get_class($type));
220
        } else {
221
            throw new Exception("Invalid field type: " . get_class($type));
222
        }
223
224 21
        if (!($field->type instanceof NonNull)) {
225 1
            $base .= '->nullable()';
226
        }
227
228 21
        foreach ($directives as $directive) {
229
            /**
230
             * @var DirectiveNode $directive
231
             */
232 2
            $name = $directive->name->value;
233 2
            switch ($name) {
234 2
            case 'migrationUniqueIndex':
235 1
                $extra[] = '$table->unique("' . $fieldName . '");';
236 1
                break;
237 1
            case 'migrationIndex':
238
                $extra[] = '$table->index("' . $fieldName . '");';
239
                break;
240 1
            case 'migrationDefaultValue':
241
                $x = ''; // TODO
242
                Parser::getDirectiveArgumentByName($directive, 'value');
243
                $base .= '->default(' . $x . ')';
244
                throw new Exception('Default value not implemented yet');
245
                // break;
246
            }
247
        }
248 21
        $base .= ';';
249
250 21
        $this->createCode[] = $base;
251 21
        $this->createCode = array_merge($this->createCode, $extra);
252 21
    }
253
254 8
    protected function processRelationship(
255
        \GraphQL\Type\Definition\FieldDefinition $field,
256
        \GraphQL\Language\AST\NodeList $directives
257
    ): void {
258 8
        $lowerName = mb_strtolower($this->getInflector()->singularize($field->name));
259 8
        $lowerNamePlural = $this->getInflector()->pluralize($lowerName);
260 8
        $fieldName = $lowerName . '_id';
261
262 8
        list($type, $isRequired) = Parser::getUnwrappedType($field->type);
263 8
        $typeName = $type->name;
264
265 8
        $base = null;
266 8
        $extra = [];
267
268
        // special types that should be skipped.
269 8
        if ($typeName === 'Can') {
270
            return;
271
        }
272
273 8
        $isManyToMany = false;
274 8
        foreach ($directives as $directive) {
275 8
            $name = $directive->name->value;
276 8
            switch ($name) {
277 8
            case 'migrationSkip':
278
                return;
279 8
            case 'migrationUniqueIndex':
280
                $extra[] = '$table->unique("' . $fieldName . '");';
281
                break;
282 8
            case 'migrationIndex':
283
                $extra[] = '$table->index("' . $fieldName . '");';
284
                break;
285 8
            case 'belongsTo':
286 4
                $targetType = $this->parser->getType($typeName);
287 4
                if (!$targetType) {
288
                    throw new Exception("Cannot get type {$typeName} as a relationship to {$this->baseName}");
289 4
                } elseif (!($targetType instanceof ObjectType)) {
290
                    throw new Exception("{$typeName} is not a type for a relationship to {$this->baseName}");
291
                }
292
                try {
293 4
                    $targetField = $targetType->getField($this->lowerName); // TODO: might have another name than lowerName
294 1
                } catch (\GraphQL\Error\InvariantViolation $e) {
295 1
                    $targetField = $targetType->getField($this->lowerNamePlural);
296
                }
297
298 4
                $targetDirectives = $targetField->astNode->directives;
299 4
                foreach ($targetDirectives as $targetDirective) {
300 4
                    switch ($targetDirective->name->value) {
301 4
                    case 'hasOne':
302 1
                    case 'hasMany':
303 4
                        $base = '$table->unsignedBigInteger("' . $fieldName . '")';
304 4
                    break;
305
                    }
306
                }
307 4
                break;
308
309 8
            case 'belongsToMany':
310 1
                $type1 = $this->lowerName;
311 1
                $type2 = $lowerName;
312
313
                // we only generate once, so use a comparison for that
314 1
                $isManyToMany = true;
315 1
                if (strcasecmp($type1, $type2) < 0) {
316 1
                    $this->generateManyToManyTable($type1, $type2);
317
                }
318 1
                break;
319
320 7
            case 'morphTo':
321 2
                $relation = Parser::getDirectiveArgumentByName($directive, 'relation', $lowerName);
322 2
                $base = '$table->unsignedBigInteger("' . $relation . '_id")';
323 2
                $extra[] = '$table->string("' . $relation . '_type")' .
324 2
                    ($isRequired ? '' : '->nullable()') . ';';
325 2
                break;
326
327 7
            case 'morphedByMany':
328 1
                $isManyToMany = true;
329 1
                $relation = Parser::getDirectiveArgumentByName($directive, 'relation', $lowerName);
330 1
                $this->generateManyToManyMorphTable($this->lowerName, $relation);
331 1
                break;
332
            }
333
        }
334
335 8
        foreach ($directives as $directive) {
336 8
            $name = $directive->name->value;
337
            switch ($name) {
338 8
            case 'migrationForeign':
339
                
340 4
                if (!$isManyToMany) {
341 4
                    $arguments = array_merge(
342
                        [
343 4
                            'references' => 'id',
344 4
                            'on' => $lowerNamePlural
345
                        ],
346 4
                        Parser::getDirectiveArguments($directive)
347
                    );
348
    
349 4
                    $extra[] = '$table->foreign("' . $fieldName . '")' .
350 4
                        "->references(\"{$arguments['references']}\")" .
351 4
                        "->on(\"{$arguments['on']}\")" .
352 4
                        (($arguments['onDelete'] ?? '') ? "->onDelete(\"{$arguments['onDelete']}\")" : '') .
353 4
                        (($arguments['onUpdate'] ?? '') ? "->onUpdate(\"{$arguments['onUpdate']}\")" : '') .
354 4
                        ';';
355
                }
356 4
                break;
357
            }
358
        }
359
360 8
        if ($base) {
361 6
            if (!($field->type instanceof NonNull)) {
362
                $base .= '->nullable()';
363
            }
364 6
            $base .= ';';
365 6
            $this->createCode[] = $base;
366
        }
367 8
        $this->createCode = array_merge($this->createCode, $extra);
368 8
    }
369
370 21
    protected function processDirectives(
371
        \GraphQL\Language\AST\NodeList $directives
372
    ): void {
373 21
        foreach ($directives as $directive) {
374 6
            $name = $directive->name->value;
375 6
            switch ($name) {
376 6
            case 'migrationSoftDeletes':
377 3
                $this->createCode[] ='$table->softDeletes();';
378 3
                break;
379 6
            case 'migrationPrimaryIndex':
380
                // TODO
381
                throw new Exception("Primary index is not implemented yet");
382 6
            case 'migrationIndex':
383 1
                $values = $directive->arguments[0]->value->values;
384
385 1
                $indexFields = [];
386 1
                foreach ($values as $value) {
387 1
                    $indexFields[] = $value->value;
388
                }
389 1
                if (!count($indexFields)) {
390
                    throw new Exception("You must provide at least one field to an index");
391
                }
392 1
                $this->createCode[] ='$table->index("' . implode('", "', $indexFields) .'");';
393 1
                break;
394 5
            case 'migrationSpatialIndex':
395 1
                $this->createCode[] ='$table->spatialIndex("' . $directive->arguments[0]->value->value .'");';
396 1
                break;
397
398 4
            case 'migrationFulltextIndex':
399 1
                $values = $directive->arguments[0]->value->values;
400
401 1
                $indexFields = [];
402 1
                foreach ($values as $value) {
403 1
                    $indexFields[] = $value->value;
404
                }
405
406 1
                if (!count($indexFields)) {
407
                    throw new Exception("You must provide at least one field to a full text index");
408
                }
409 1
                $this->postCreateCode[] = "DB::statement('ALTER TABLE " .
410 1
                    $this->lowerNamePlural .
411 1
                    " ADD FULLTEXT fulltext_index (\"" .
412 1
                    implode('", "', $indexFields) .
413 1
                    "\")');";
414 1
                break;
415 3
            case 'migrationRememberToken':
416 3
                $this->createCode[] ='$table->rememberToken();';
417 3
                break;
418 3
            case 'migrationTimestamps':
419 3
                $this->createCode[] ='$table->timestamps();';
420 3
                break;
421
            default:
422
            }
423
        }
424 21
    }
425
426 21
    public function generateString(): string
427
    {
428 21
        foreach ($this->type->getFields() as $field) {
429 21
            $directives = $field->astNode->directives;
430
            if (
431 21
                ($field->type instanceof ObjectType) ||
432 21
                ($field->type instanceof ListOfType) ||
433 21
                ($field->type instanceof UnionType) ||
434 21
                ($field->type instanceof NonNull && (
435 21
                    ($field->type->getWrappedType() instanceof ObjectType) ||
436 21
                    ($field->type->getWrappedType() instanceof ListOfType) ||
437 21
                    ($field->type->getWrappedType() instanceof UnionType)
438
                ))
439
            ) {
440
                // relationship
441 8
                $this->processRelationship($field, $directives);
442
            } else {
443 21
                $this->processBasetype($field, $directives);
444
            }
445
        }
446
447
        assert($this->type->astNode !== null);
448
        /**
449
         * @var \GraphQL\Language\AST\NodeList|null
450
         */
451 21
        $directives = $this->type->astNode->directives;
452 21
        if ($directives) {
453 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

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