Passed
Push — master ( 09030e...cab6b5 )
by Bruno
08:31
created

MigrationGenerator::generateManyToManyTable()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 31
Code Lines 24

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 12
CRAP Score 1

Importance

Changes 3
Bugs 2 Features 0
Metric Value
cc 1
eloc 24
nc 1
nop 2
dl 0
loc 31
ccs 12
cts 12
cp 1
crap 1
rs 9.536
c 3
b 2
f 0
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 22
    protected function processBasetype(
124
        \GraphQL\Type\Definition\FieldDefinition $field,
125
        \GraphQL\Language\AST\NodeList $directives
126
    ): void {
127 22
        $fieldName = $field->name;
128
129 22
        if ($field->type instanceof NonNull) {
130 22
            $type = $field->type->getWrappedType();
131
        } else {
132 1
            $type = $field->type;
133
        }
134
135 22
        $codeFragment = new MigrationCodeFragment();
136
137 22
        if ($type instanceof IDType) {
138 22
            $codeFragment->appendBase('$table->bigIncrements("id")');
139 17
        } elseif ($type instanceof StringType) {
140 17
            $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($field, $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 22
        if (!($field->type instanceof NonNull)) {
165 1
            $codeFragment->appendBase('->nullable()');
166
        }
167
168 22
        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 22
        $this->createCode[] = $codeFragment->base . ';';
188 22
        $this->createCode = array_merge($this->createCode, $codeFragment->extraLines);
189 22
    }
190
191
    protected function processEnum(
192
        \GraphQL\Type\Definition\FieldDefinition $field,
193
        EnumType $type,
194
        MigrationCodeFragment $codeFragment
195
    ): void {
196
        $fieldName = $field->name;
197
        $ourType = $this->parser->getScalarType($type->name);
198
        $parsedValues = $type->config['values'];
199
200
        if (!$ourType) {
201
            $parsedKeys = array_keys($parsedValues);
202
            $enumValues = array_combine($parsedKeys, $parsedKeys);
203
204
            // let's create this for the user
205
            $code = DatatypeFactory::generate(
206
                $type->name,
207
                'enum',
208
                'App\\Datatypes',
209
                'Tests\\Unit',
210
                function (ClassType $enumClass) use ($enumValues) {
211
                    $enumClass->addConstant('CHOICES', $enumValues);
212
                    $enumClass->getMethod('__construct')->addBody('$this->choices = self::CHOICES;');
213
                }
214
            );
215
    
216
            $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

216
            $path = /** @scrutinizer ignore-call */ base_path('app/Datatypes');
Loading history...
217
            $lowerTypeName = mb_strtolower($type->name);
218
219
            $retval = DatatypeFactory::generateFile(
220
                $code,
221
                $path,
222
                base_path('tests/Unit/')
223
            );
224
225
            $php = \Modelarium\Util::generateLighthouseTypeFile($lowerTypeName, 'App\\Datatypes\\Types');
226
            $filename = $path . "/Types/Datatype_{$lowerTypeName}.php";
227
            if (!is_dir($path . "/Types")) {
228
                \Safe\mkdir($path . "/Types", 0777, true);
229
            }
230
            \Safe\file_put_contents($filename, $php);
231
    
232
            // recreate scalars
233
            \Modelarium\Util::generateScalarFile('App\\Datatypes', base_path('graphql/types.graphql'));
234
235
            // load php files that were just created
236
            require_once($retval['filename']);
237
            require_once($filename);
238
            $this->parser->appendScalar($type->name, 'App\\Datatypes\\Types\\Datatype_' . $lowerTypeName);
239
            $ourType = $this->parser->getScalarType($type->name);
240
        }
241
        if (!($ourType instanceof FormulariumScalarType)) {
242
            throw new Exception("Enum {$type->name} {$fieldName} is not a FormulariumScalarType");
243
        }
244
245
        /**
246
         * @var FormulariumScalarType $ourType
247
         */
248
        /**
249
         * @var Datatype_enum $ourDatatype
250
         */
251
        $ourDatatype = $ourType->getDatatype();
252
        $currentChoices = $ourDatatype->getChoices();
253
        if (array_diff_key($currentChoices, $parsedValues) || array_diff_key($parsedValues, $currentChoices)) {
254
            // TODO???
255
        }
256
257
        $options = []; // TODO: from directives
258
        $codeFragment->appendBase('$table->'  . $ourType->getLaravelSQLType($fieldName, $options));
259
    }
260
261 8
    protected function processRelationship(
262
        \GraphQL\Type\Definition\FieldDefinition $field,
263
        \GraphQL\Language\AST\NodeList $directives
264
    ): void {
265 8
        $lowerName = mb_strtolower($this->getInflector()->singularize($field->name));
266 8
        $lowerNamePlural = $this->getInflector()->pluralize($lowerName);
267 8
        $fieldName = $lowerName . '_id';
268
269 8
        list($type, $isRequired) = Parser::getUnwrappedType($field->type);
270 8
        $typeName = $type->name;
271 8
        $tableName = self::toTableName($typeName);
0 ignored issues
show
Unused Code introduced by
The assignment to $tableName is dead and can be removed.
Loading history...
272
273
        // special types that should be skipped.
274 8
        if ($typeName === 'Can') {
275
            return;
276
        }
277
278 8
        $codeFragment = new MigrationCodeFragment();
279
280 8
        $isManyToMany = false;
281 8
        foreach ($directives as $directive) {
282 8
            $name = $directive->name->value;
283 8
            if ($name === 'migrationSkip') {
284
                return;
285
            }
286
287 8
            $className = $this->getDirectiveClass($name);
288 8
            if ($className) {
289 8
                $methodName = "$className::processMigrationRelationshipDirective";
290
                /** @phpstan-ignore-next-line */
291 8
                $methodName(
292 8
                    $this,
293
                    $field,
294
                    $directive,
295
                    $codeFragment
296
                );
297
            }
298
299
            // TODO: handle isManyToMany for migrationForeign
300 8
            switch ($name) {
301 8
            case 'belongToMany':
302 8
            case 'morphedByMany':
303 1
                $isManyToMany = true;
304 1
                break;
305
            }
306
        }
307
308
        // TODO: move this to the a separate class
309 8
        foreach ($directives as $directive) {
310 8
            $name = $directive->name->value;
311
            switch ($name) {
312 8
            case 'migrationForeign':
313 4
                if (!$isManyToMany) {
314 4
                    $arguments = array_merge(
315
                        [
316 4
                            'references' => 'id',
317 4
                            'on' => $lowerNamePlural
318
                        ],
319 4
                        Parser::getDirectiveArguments($directive)
320
                    );
321
    
322 4
                    $codeFragment->appendExtraLine(
323 4
                        '$table->foreign("' . $fieldName . '")' .
324 4
                        "->references(\"{$arguments['references']}\")" .
325 4
                        "->on(\"{$arguments['on']}\")" .
326 4
                        (($arguments['onDelete'] ?? '') ? "->onDelete(\"{$arguments['onDelete']}\")" : '') .
327 4
                        (($arguments['onUpdate'] ?? '') ? "->onUpdate(\"{$arguments['onUpdate']}\")" : '') .
328 4
                        ';'
329
                    );
330
                }
331 4
                break;
332
            }
333
        }
334
335 8
        if ($codeFragment->base) {
336 6
            if (!($field->type instanceof NonNull)) {
337
                $codeFragment->appendBase('->nullable()');
338
            }
339 6
            $this->createCode[] = '$table' . $codeFragment->base . ';';
340
        }
341
        
342 8
        $this->createCode = array_merge($this->createCode, $codeFragment->extraLines);
343 8
    }
344
345 22
    public function generateString(): string
346
    {
347
        foreach ($this->type->getFields() as $field) {
348 22
            $directives = $field->astNode->directives;
349 7
            if (
350 7
                ($field->type instanceof ObjectType) ||
351 7
                ($field->type instanceof ListOfType) ||
352 7
                ($field->type instanceof UnionType) ||
353
                ($field->type instanceof NonNull && (
354 7
                    ($field->type->getWrappedType() instanceof ObjectType) ||
355 7
                    ($field->type->getWrappedType() instanceof ListOfType) ||
356
                    ($field->type->getWrappedType() instanceof UnionType)
357
                ))
358
            ) {
359
                // relationship
360 22
                $this->processRelationship($field, $directives);
361
            } else {
362 22
                $this->processBasetype($field, $directives);
363
            }
364 22
        }
365 22
366
        assert($this->type->astNode !== null);
367 22
        /**
368 22
         * @var \GraphQL\Language\AST\NodeList|null
369 22
         */
370 22
        $directives = $this->type->astNode->directives;
371 22
        if ($directives) {
372 22
            $this->processTypeDirectives($directives, 'Migration');
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\BaseGenerator::processTypeDirectives(). ( Ignorable by Annotation )

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

372
            $this->processTypeDirectives(/** @scrutinizer ignore-type */ $directives, 'Migration');
Loading history...
373 22
        }
374
375
        $context = [
376
            'dummytablename' => $this->tableName,
377 8
            'modelSchemaCode' => "# start graphql\n" .
378
                $this->currentModel .
379 22
                "\n# end graphql",
380
        ];
381
382
        if ($this->mode === self::MODE_CREATE) {
383
            $context['className'] = 'Create' . $this->studlyName;
384
            $context['dummyCode'] = join("\n            ", $this->createCode);
385
            $context['dummyPostCreateCode'] = join("\n            ", $this->postCreateCode);
386
        } else {
387 22
            $context['className'] = 'Patch' . $this->studlyName . date('YmdHis');
388 22
            $context['dummyCode'] = '// TODO: write the patch please';
389 22
            $context['dummyPostCreateCode'] = '';
390
        }
391
392
        return $this->templateStub('migration', $context);
393 22
    }
394
395 22
    /**
396 22
     * creates a many-to-many morph relationship table
397
     *
398
     * @param string $name
399 22
     * @param string $relation
400 22
     * @return string The table name.
401 22
     */
402 22
    public function generateManyToManyMorphTable(string $name, string $relation): string
403
    {
404
        $dummyCode = <<<EOF
405
406
            \$table->unsignedBigInteger("{$name}_id");
407
            \$table->unsignedBigInteger("{$relation}_id");
408
            \$table->string("{$relation}_type");
409 22
EOF;
410
        $context = [
411
            'dummyCode' => $dummyCode,
412
            'dummytablename' => $this->getInflector()->pluralize($relation), // TODO: check, toTableName()?
413
            'modelSchemaCode' => ''
414
        ];
415
        $contents = $this->templateStub('migration', $context);
416
417
        $item = new GeneratedItem(
418
            GeneratedItem::TYPE_MIGRATION,
419 1
            $contents,
420
            $this->getBasePath(
421
                'database/migrations/' .
422
                date('Y_m_d_His') .
423 1
                str_pad((string)(static::$counter++), 3, "0", STR_PAD_LEFT) . // so we keep the same order of types in schema
424 1
                '_' . $this->mode . '_' .
425 1
                $relation .
426
                '_table.php'
427
            )
428 1
        );
429 1
        $this->collection->push($item);
430 1
431
        return $context['dummytablename'];
432 1
    }
433
434 1
    /**
435 1
     * creates a many-to-many relationship table
436
     *
437 1
     * @param string $type1
438
     * @param string $type2
439 1
     * @return string The table name.
440 1
     */
441 1
    public function generateManyToManyTable(string $type1, string $type2): string
442 1
    {
443 1
        $dummyCode = <<<EOF
444
445
            \$table->increments("id");
446 1
            \$table->unsignedBigInteger("{$type1}_id")->references('id')->on('{$type1}');
447
            \$table->unsignedBigInteger("{$type2}_id")->references('id')->on('{$type2}');
448 1
EOF;
449
        $context = [
450
            'dummyCode' => $dummyCode,
451
            'dummytablename' => "{$type1}_{$type2}",
452
            'className' => Str::studly($this->mode) . Str::studly($type1) . Str::studly($type2),
453
            'modelSchemaCode' => ''
454
        ];
455
        $contents = $this->templateStub('migration', $context);
456
457
        $item = new GeneratedItem(
458 1
            GeneratedItem::TYPE_MIGRATION,
459
            $contents,
460
            $this->getBasePath(
461
                'database/migrations/' .
462
                date('Y_m_d_His') .
463 1
                str_pad((string)(static::$counter++), 3, "0", STR_PAD_LEFT) . // so we keep the same order of types in schema
464 1
                '_' . $this->mode . '_' .
465
                $type1 . '_' . $type2 .
466
                '_table.php'
467 1
            )
468 1
        );
469 1
        $this->collection->push($item);
470 1
471
        return $context['dummytablename'];
472 1
    }
473
474 1
    protected function generateFilename(string $basename): string
475 1
    {
476
        $this->mode = self::MODE_CREATE;
477 1
        $match = '_' . $basename . '_table.php';
478
479 1
        $basepath = $this->getBasePath('database/migrations/');
480 1
        if (is_dir($basepath)) {
481 1
            $migrationFiles = \Safe\scandir($basepath);
482 1
            rsort($migrationFiles);
483 1
            foreach ($migrationFiles as $m) {
484
                if (!endsWith($m, $match)) {
485
                    continue;
486 1
                }
487
488 1
                // get source
489
                $data = \Safe\file_get_contents($basepath . '/' . $m);
490
491 8
                // compare with this source
492
                $model = trim(getStringBetween($data, '# start graphql', '# end graphql'));
493 8
494 8
                // if equal ignore and don't output file
495
                if ($model === $this->currentModel) {
496 8
                    $this->mode = self::MODE_NO_CHANGE;
497 8
                } else {
498
                    // else we'll generate a diff and patch
499
                    $this->mode = self::MODE_PATCH;
500
                }
501
                break;
502
            }
503
        }
504
505
        return $this->getBasePath(
506
            'database/migrations/' .
507
            date('Y_m_d_His') .
508
            str_pad((string)(static::$counter++), 3, "0", STR_PAD_LEFT) . // so we keep the same order of types in schema
509
            '_' . $this->mode . '_' .
510
            $basename . '_table.php'
511
        );
512
    }
513
}
514