Passed
Push — master ( df5659...59487d )
by Bruno
04:26 queued 01:12
created

MigrationGenerator::generateString()   B

Complexity

Conditions 11
Paths 12

Size

Total Lines 48
Code Lines 31

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 23
CRAP Score 11.1859

Importance

Changes 0
Metric Value
cc 11
eloc 31
nc 12
nop 0
dl 0
loc 48
ccs 23
cts 26
cp 0.8846
crap 11.1859
rs 7.3166
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\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 21
        $extra = [];
0 ignored issues
show
Unused Code introduced by
The assignment to $extra is dead and can be removed.
Loading history...
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
        $codeFragment = new MigrationCodeFragment();
139
140 21
        if ($type instanceof IDType) {
141 21
            $codeFragment->appendBase('$table->bigIncrements("id")');
142 16
        } elseif ($type instanceof StringType) {
143 16
            $codeFragment->appendBase('$table->string("' . $fieldName . '")');
144 4
        } elseif ($type instanceof IntType) {
145 1
            $codeFragment->appendBase('$table->integer("' . $fieldName . '")');
146 4
        } elseif ($type instanceof BooleanType) {
147 1
            $codeFragment->appendBase('$table->boolean("' . $fieldName . '")');
148 4
        } elseif ($type instanceof FloatType) {
149 1
            $codeFragment->appendBase('$table->float("' . $fieldName . '")');
150 3
        } elseif ($type instanceof EnumType) {
151
            $ourType = $this->parser->getScalarType($type->name);
152
            $parsedValues = $type->config['values'];
153
154
            if (!$ourType) {
155
                $parsedKeys = array_keys($parsedValues);
156
                $enumValues = array_combine($parsedKeys, $parsedKeys);
157
158
                // let's create this for the user
159
                $code = DatatypeFactory::generate(
160
                    $type->name,
161
                    'enum',
162
                    'App\\Datatypes',
163
                    'Tests\\Unit',
164
                    function (ClassType $enumClass) use ($enumValues) {
165
                        $enumClass->addConstant('CHOICES', $enumValues);
166
                        $enumClass->getMethod('__construct')->addBody('$this->choices = self::CHOICES;');
167
                    }
168
                );
169
        
170
                $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

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

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