Passed
Push — master ( a5264c...701562 )
by Bruno
15:03 queued 07:41
created

MigrationGenerator::generateString()   B

Complexity

Conditions 11
Paths 12

Size

Total Lines 48
Code Lines 31

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 24
CRAP Score 11.1659

Importance

Changes 0
Metric Value
cc 11
eloc 31
nc 12
nop 0
dl 0
loc 48
rs 7.3166
c 0
b 0
f 0
ccs 24
cts 27
cp 0.8889
crap 11.1659

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
34
function getStringBetween(string $string, string $start, string $end): string
35
{
36
    $ini = mb_strpos($string, $start);
37
    if ($ini === false) {
38
        return '';
39
    }
40
    $ini += mb_strlen($start);
41
    $len = mb_strpos($string, $end, $ini) - $ini;
42
    return mb_substr($string, $ini, $len);
43
}
44
45
function endsWith(string $haystack, string $needle): bool
46
{
47
    return substr_compare($haystack, $needle, -strlen($needle)) === 0;
48
}
49
class MigrationGenerator extends BaseGenerator
50
{
51
    /**
52
     * @var string
53
     */
54
    protected $stubDir = __DIR__ . "/stubs/";
55
56
    protected const MODE_CREATE = 'create';
57
    protected const MODE_PATCH = 'patch';
58
    protected const MODE_NO_CHANGE = 'nochange';
59
60
    /**
61
     * Unique counter
62
     *
63
     * @var integer
64
     */
65
    public static $counter = 0;
66
67
    /**
68
     * @var ObjectType
69
     */
70
    protected $type = null;
71
72
    /**
73
     * @var GeneratedCollection
74
     */
75
    protected $collection = null;
76
77
    /**
78
     * Code used in the create() call
79
     *
80
     * @var string[]
81
     */
82
    protected $createCode = [];
83
84
    /**
85
     * Code used post the create() call
86
     *
87
     * @var string[]
88
     */
89
    protected $postCreateCode = [];
90
91
    /**
92
     * 'create' or 'patch'
93
     *
94
     * @var string
95
     */
96
    protected $mode = self::MODE_CREATE;
97
98
    /**
99
     * Code used in the create() call
100
     *
101
     * @var string
102
     */
103
    protected $currentModel = '';
104
105 8
    public function generate(): GeneratedCollection
106
    {
107 8
        $this->collection = new GeneratedCollection();
108 8
        $this->currentModel = \GraphQL\Language\Printer::doPrint($this->type->astNode);
109 8
        $filename = $this->generateFilename($this->lowerName);
110
111 8
        if ($this->mode !== self::MODE_NO_CHANGE) {
112 8
            $item = new GeneratedItem(
113 8
                GeneratedItem::TYPE_MIGRATION,
114 8
                $this->generateString(),
115 8
                $filename
116
            );
117 8
            $this->collection->prepend($item);
118
        }
119 8
        return $this->collection;
120
    }
121
122 21
    protected function processBasetype(
123
        \GraphQL\Type\Definition\FieldDefinition $field,
124
        \GraphQL\Language\AST\NodeList $directives
125
    ): void {
126 21
        $fieldName = $field->name;
127 21
        $extra = [];
128
129
        // TODO: scalars
130
131 21
        if ($field->type instanceof NonNull) {
132 21
            $type = $field->type->getWrappedType();
133
        } else {
134 1
            $type = $field->type;
135
        }
136
137 21
        $base = '';
138 21
        if ($type instanceof IDType) {
139 21
            $base = '$table->bigIncrements("id")';
140 16
        } elseif ($type instanceof StringType) {
141 16
            $base = '$table->string("' . $fieldName . '")';
142 4
        } elseif ($type instanceof IntType) {
143 1
            $base = '$table->integer("' . $fieldName . '")';
144 4
        } elseif ($type instanceof BooleanType) {
145 1
            $base = '$table->bool("' . $fieldName . '")';
146 4
        } elseif ($type instanceof FloatType) {
147 1
            $base = '$table->float("' . $fieldName . '")';
148 3
        } elseif ($type instanceof EnumType) {
149
            $ourType = $this->parser->getScalarType($type->name);
150
            $parsedValues = $type->config['values'];
151
152
            if (!$ourType) {
153
                $parsedKeys = array_keys($parsedValues);
154
                $enumValues = array_combine($parsedKeys, $parsedKeys);
155
156
                // let's create this for the user
157
                $code = DatatypeFactory::generate(
158
                    $type->name,
159
                    'enum',
160
                    'App\\Datatypes',
161
                    'Tests\\Unit',
162
                    function (ClassType $enumClass) use ($enumValues) {
163
                        $enumClass->addConstant('CHOICES', $enumValues);
164
                        $enumClass->getMethod('__construct')->addBody('$this->choices = self::CHOICES;');
165
                    }
166
                );
167
        
168
                $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

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

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