Passed
Push — master ( a266d2...fe3e3c )
by Bruno
03:11
created

MigrationGenerator::processRelationship()   B

Complexity

Conditions 7
Paths 11

Size

Total Lines 41
Code Lines 22

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 17
CRAP Score 7.1653

Importance

Changes 5
Bugs 1 Features 0
Metric Value
cc 7
eloc 22
c 5
b 1
f 0
nc 11
nop 2
dl 0
loc 41
ccs 17
cts 20
cp 0.85
crap 7.1653
rs 8.6346
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
    /**
124
     * @param \GraphQL\Type\Definition\FieldDefinition $field
125
     * @param \GraphQL\Language\AST\NodeList<\GraphQL\Language\AST\DirectiveNode> $directives
126
     * @return void
127
     */
128 22
    protected function processBasetype(
129
        \GraphQL\Type\Definition\FieldDefinition $field,
130
        \GraphQL\Language\AST\NodeList $directives
131
    ): void {
132 22
        $fieldName = $field->name;
133
134 22
        if ($field->getType() instanceof NonNull) {
135 22
            $type = $field->getType()->getWrappedType();
136
        } else {
137 1
            $type = $field->getType();
138
        }
139
140 22
        $codeFragment = new MigrationCodeFragment();
141
142 22
        if ($type instanceof IDType) {
143 22
            $codeFragment->appendBase('$table->bigIncrements("id")');
144 17
        } elseif ($type instanceof StringType) {
145 17
            $codeFragment->appendBase('$table->string("' . $fieldName . '")');
146 4
        } elseif ($type instanceof IntType) {
147 1
            $codeFragment->appendBase('$table->integer("' . $fieldName . '")');
148 4
        } elseif ($type instanceof BooleanType) {
149 1
            $codeFragment->appendBase('$table->boolean("' . $fieldName . '")');
150 4
        } elseif ($type instanceof FloatType) {
151 1
            $codeFragment->appendBase('$table->float("' . $fieldName . '")');
152 3
        } elseif ($type instanceof EnumType) {
153
            $this->processEnum($field, $type, $codeFragment);
154 3
        } elseif ($type instanceof UnionType) {
155
            return;
156 3
        } elseif ($type instanceof CustomScalarType) {
157 3
            $ourType = $this->parser->getScalarType($type->name);
158 3
            if (!$ourType) {
159
                throw new Exception("Invalid extended scalar type: " . get_class($type));
160
            }
161 3
            $options = []; // TODO: from directives
162 3
            $codeFragment->appendBase('$table->' . $ourType->getLaravelSQLType($fieldName, $options));
163
        } elseif ($type instanceof ListOfType) {
164
            throw new Exception("Invalid field type: " . get_class($type));
165
        } else {
166
            throw new Exception("Invalid field type: " . get_class($type));
167
        }
168
169 22
        if (!($field->getType() instanceof NonNull)) {
170 1
            $codeFragment->appendBase('->nullable()');
171
        }
172
173 22
        foreach ($directives as $directive) {
174 1
            $name = $directive->name->value;
175 1
            if ($name === 'migrationSkip') { // special case
176
                return;
177
            }
178
179 1
            $className = $this->getDirectiveClass($name);
180 1
            if ($className) {
181 1
                $methodName = "$className::processMigrationFieldDirective";
182
                /** @phpstan-ignore-next-line */
183 1
                $methodName(
184 1
                    $this,
185
                    $field,
186
                    $directive,
187
                    $codeFragment
188
                );
189
            }
190
        }
191
192 22
        $this->createCode[] = $codeFragment->base . ';';
193 22
        $this->createCode = array_merge($this->createCode, $codeFragment->extraLines);
194 22
    }
195
196
    protected function processEnum(
197
        \GraphQL\Type\Definition\FieldDefinition $field,
198
        EnumType $type,
199
        MigrationCodeFragment $codeFragment
200
    ): void {
201
        $fieldName = $field->name;
202
        $ourType = $this->parser->getScalarType($type->name);
203
        $parsedValues = $type->config['values'];
204
205
        if (!$ourType) {
206
            $parsedKeys = array_keys($parsedValues);
207
            $enumValues = array_combine($parsedKeys, $parsedKeys);
208
209
            // let's create this for the user
210
            $code = DatatypeFactory::generate(
211
                $type->name,
212
                'enum',
213
                'App\\Datatypes',
214
                'Tests\\Unit',
215
                function (ClassType $enumClass) use ($enumValues) {
216
                    $enumClass->addConstant('CHOICES', $enumValues);
217
                    $enumClass->getMethod('__construct')->addBody('$this->choices = self::CHOICES;');
218
                }
219
            );
220
    
221
            $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

221
            $path = /** @scrutinizer ignore-call */ base_path('app/Datatypes');
Loading history...
222
            $lowerTypeName = mb_strtolower($type->name);
223
224
            $retval = DatatypeFactory::generateFile(
225
                $code,
226
                $path,
227
                base_path('tests/Unit/')
228
            );
229
230
            $php = \Modelarium\Util::generateLighthouseTypeFile($lowerTypeName, 'App\\Datatypes\\Types');
231
            $filename = $path . "/Types/Datatype_{$lowerTypeName}.php";
232
            if (!is_dir($path . "/Types")) {
233
                \Safe\mkdir($path . "/Types", 0777, true);
234
            }
235
            \Safe\file_put_contents($filename, $php);
236
    
237
            // recreate scalars
238
            \Modelarium\Util::generateScalarFile('App\\Datatypes', base_path('graphql/types.graphql'));
239
240
            // load php files that were just created
241
            require_once($retval['filename']);
242
            require_once($filename);
243
            $this->parser->appendScalar($type->name, 'App\\Datatypes\\Types\\Datatype_' . $lowerTypeName);
244
            $ourType = $this->parser->getScalarType($type->name);
245
        }
246
        if (!($ourType instanceof FormulariumScalarType)) {
247
            throw new Exception("Enum {$type->name} {$fieldName} is not a FormulariumScalarType");
248
        }
249
250
        /**
251
         * @var FormulariumScalarType $ourType
252
         */
253
        /**
254
         * @var Datatype_enum $ourDatatype
255
         */
256
        $ourDatatype = $ourType->getDatatype();
257
        $currentChoices = $ourDatatype->getChoices();
258
        if (array_diff_key($currentChoices, $parsedValues) || array_diff_key($parsedValues, $currentChoices)) {
259
            // TODO???
260
            $this->warn('Enum had its possible values changed. Please review the datatype class.');
261
        }
262
263
        $options = []; // TODO: from directives
264
        $codeFragment->appendBase('$table->'  . $ourType->getLaravelSQLType($fieldName, $options));
265
    }
266
267
    /**
268
     * @param \GraphQL\Type\Definition\FieldDefinition $field
269
     * @param \GraphQL\Language\AST\NodeList<\GraphQL\Language\AST\DirectiveNode> $directives
270
     * @return void
271
     */
272 8
    protected function processRelationship(
273
        \GraphQL\Type\Definition\FieldDefinition $field,
274
        \GraphQL\Language\AST\NodeList $directives
275
    ): void {
276 8
        list($type, $isRequired) = Parser::getUnwrappedType($field->getType());
277 8
        $typeName = $type->name;
278
279
        // special types that should be skipped.
280 8
        if ($typeName === 'Can') {
281
            return;
282
        }
283
284 8
        $codeFragment = new MigrationCodeFragment();
285
286 8
        foreach ($directives as $directive) {
287 8
            $name = $directive->name->value;
288 8
            if ($name === 'migrationSkip') {
289
                return;
290
            }
291
292 8
            $className = $this->getDirectiveClass($name);
293 8
            if ($className) {
294 8
                $methodName = "$className::processMigrationRelationshipDirective";
295
                /** @phpstan-ignore-next-line */
296 8
                $methodName(
297 8
                    $this,
298
                    $field,
299
                    $directive,
300
                    $codeFragment
301
                );
302
            }
303
        }
304
305 8
        if ($codeFragment->base) {
306 6
            if (!($field->getType() instanceof NonNull)) {
307
                $codeFragment->appendBase('->nullable()');
308
            }
309 6
            $this->createCode[] = '$table' . $codeFragment->base . ';';
310
        }
311
        
312 8
        $this->createCode = array_merge($this->createCode, $codeFragment->extraLines);
313 8
    }
314
315 22
    public function generateString(): string
316
    {
317 22
        foreach ($this->type->getFields() as $field) {
318 22
            $directives = $field->astNode->directives;
319 22
            $type = $field->getType();
320
            if (
321 22
                ($type instanceof ObjectType) ||
322 22
                ($type instanceof ListOfType) ||
323 22
                ($type instanceof UnionType) ||
324 22
                ($type instanceof NonNull && (
325 22
                    ($type->getWrappedType() instanceof ObjectType) ||
326 22
                    ($type->getWrappedType() instanceof ListOfType) ||
327 22
                    ($type->getWrappedType() instanceof UnionType)
328
                ))
329
            ) {
330
                // relationship
331 8
                $this->processRelationship($field, $directives);
332
            } else {
333 22
                $this->processBasetype($field, $directives);
334
            }
335
        }
336
337
        assert($this->type->astNode !== null);
338
        /**
339
         * @var \GraphQL\Language\AST\NodeList<\GraphQL\Language\AST\DirectiveNode>|null
340
         */
341 22
        $directives = $this->type->astNode->directives;
342 22
        if ($directives) {
0 ignored issues
show
introduced by
$directives is of type GraphQL\Language\AST\NodeList, thus it always evaluated to true.
Loading history...
343 22
            $this->processTypeDirectives($directives, 'Migration');
344
        }
345
346
        $context = [
347 22
            'dummytablename' => $this->tableName,
348
            'modelSchemaCode' => "# start graphql\n" .
349 22
                $this->currentModel .
350 22
                "\n# end graphql",
351
        ];
352
353 22
        if ($this->mode === self::MODE_CREATE) {
354 22
            $context['className'] = 'Create' . $this->studlyName;
355 22
            $context['dummyCode'] = join("\n            ", $this->createCode);
356 22
            $context['dummyPostCreateCode'] = join("\n            ", $this->postCreateCode);
357
        } else {
358
            $context['className'] = 'Patch' . $this->studlyName . date('YmdHis');
359
            $context['dummyCode'] = '// TODO: write the patch please';
360
            $context['dummyPostCreateCode'] = '';
361
        }
362
363 22
        return $this->templateStub('migration', $context);
364
    }
365
366
    /**
367
     * creates a many-to-many morph relationship table
368
     *
369
     * @param string $name
370
     * @param string $relation
371
     * @return string The table name.
372
     */
373 1
    public function generateManyToManyMorphTable(string $name, string $relation): string
374
    {
375
        $dummyCode = <<<EOF
376
377 1
            \$table->unsignedBigInteger("{$name}_id");
378 1
            \$table->unsignedBigInteger("{$relation}_id");
379 1
            \$table->string("{$relation}_type");
380
EOF;
381
        $context = [
382 1
            'dummyCode' => $dummyCode,
383 1
            'dummytablename' => $this->getInflector()->pluralize($relation), // TODO: check, toTableName()?
384 1
            'modelSchemaCode' => ''
385
        ];
386 1
        $contents = $this->templateStub('migration', $context);
387
388 1
        $item = new GeneratedItem(
389 1
            GeneratedItem::TYPE_MIGRATION,
390
            $contents,
391 1
            $this->getBasePath(
392
                'database/migrations/' .
393 1
                date('Y_m_d_His') .
394 1
                str_pad((string)(static::$counter++), 3, "0", STR_PAD_LEFT) . // so we keep the same order of types in schema
395 1
                '_' . $this->mode . '_' .
396 1
                $relation .
397 1
                '_table.php'
398
            )
399
        );
400 1
        $this->collection->push($item);
401
402 1
        return $context['dummytablename'];
403
    }
404
405
    /**
406
     * creates a many-to-many relationship table
407
     *
408
     * @param string $type1
409
     * @param string $type2
410
     * @return string The table name.
411
     */
412 1
    public function generateManyToManyTable(string $type1, string $type2): string
413
    {
414
        $dummyCode = <<<EOF
415
416
            \$table->increments("id");
417 1
            \$table->unsignedBigInteger("{$type1}_id")->references('id')->on('{$type1}');
418 1
            \$table->unsignedBigInteger("{$type2}_id")->references('id')->on('{$type2}');
419
EOF;
420
        $context = [
421 1
            'dummyCode' => $dummyCode,
422 1
            'dummytablename' => "{$type1}_{$type2}",
423 1
            'className' => Str::studly($this->mode) . Str::studly($type1) . Str::studly($type2),
424 1
            'modelSchemaCode' => ''
425
        ];
426 1
        $contents = $this->templateStub('migration', $context);
427
428 1
        $item = new GeneratedItem(
429 1
            GeneratedItem::TYPE_MIGRATION,
430
            $contents,
431 1
            $this->getBasePath(
432
                'database/migrations/' .
433 1
                date('Y_m_d_His') .
434 1
                str_pad((string)(static::$counter++), 3, "0", STR_PAD_LEFT) . // so we keep the same order of types in schema
435 1
                '_' . $this->mode . '_' .
436 1
                $type1 . '_' . $type2 .
437 1
                '_table.php'
438
            )
439
        );
440 1
        $this->collection->push($item);
441
442 1
        return $context['dummytablename'];
443
    }
444
445 8
    protected function generateFilename(string $basename): string
446
    {
447 8
        $this->mode = self::MODE_CREATE;
448 8
        $match = '_' . $basename . '_table.php';
449
450 8
        $basepath = $this->getBasePath('database/migrations/');
451 8
        if (is_dir($basepath)) {
452
            $migrationFiles = \Safe\scandir($basepath);
453
            rsort($migrationFiles);
454
            foreach ($migrationFiles as $m) {
455
                if (!endsWith($m, $match)) {
456
                    continue;
457
                }
458
459
                // get source
460
                $data = \Safe\file_get_contents($basepath . '/' . $m);
461
462
                // compare with this source
463
                $model = trim(getStringBetween($data, '# start graphql', '# end graphql'));
464
465
                // if equal ignore and don't output file
466
                if ($model === $this->currentModel) {
467
                    $this->mode = self::MODE_NO_CHANGE;
468
                } else {
469
                    // else we'll generate a diff and patch
470
                    $this->mode = self::MODE_PATCH;
471
                }
472
                break;
473
            }
474
        }
475
476 8
        return $this->getBasePath(
477
            'database/migrations/' .
478 8
            date('Y_m_d_His') .
479 8
            str_pad((string)(static::$counter++), 3, "0", STR_PAD_LEFT) . // so we keep the same order of types in schema
480 8
            '_' . $this->mode . '_' .
481 8
            $basename . '_table.php'
482
        );
483
    }
484
}
485