Skip to content

Commit 716e3e9

Browse files
Infer belongsTo relationships (#652)
1 parent ee3f35d commit 716e3e9

13 files changed

+339
-44
lines changed

src/Generators/ModelGenerator.php

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,7 @@ private function hiddenColumns(array $columns)
308308
);
309309
}
310310

311-
private function castableColumns(array $columns)
311+
private function castableColumns(array $columns): array
312312
{
313313
return array_filter(
314314
array_map(
@@ -331,7 +331,7 @@ private function dateColumns(array $columns)
331331
);
332332
}
333333

334-
private function castForColumn(Column $column)
334+
private function castForColumn(Column $column): ?string
335335
{
336336
if ($column->dataType() === 'date') {
337337
return 'date';
@@ -345,7 +345,7 @@ private function castForColumn(Column $column)
345345
return 'timestamp';
346346
}
347347

348-
if (stripos($column->dataType(), 'integer') || $column->dataType() === 'id') {
348+
if (stripos($column->dataType(), 'integer') || in_array($column->dataType(), ['id', 'foreign'])) {
349349
return 'integer';
350350
}
351351

@@ -364,6 +364,8 @@ private function castForColumn(Column $column)
364364
if ($column->dataType() === 'json') {
365365
return 'array';
366366
}
367+
368+
return null;
367369
}
368370

369371
private function pretty_print_array(array $data, $assoc = true)

src/Lexers/ModelLexer.php

Lines changed: 87 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -171,8 +171,16 @@ private function buildModel(string $name, array $columns)
171171
if (isset($columns['relationships'])) {
172172
if (is_array($columns['relationships'])) {
173173
foreach ($columns['relationships'] as $type => $relationships) {
174-
foreach (explode(',', $relationships) as $reference) {
175-
$model->addRelationship(self::$relationships[strtolower($type)], trim($reference));
174+
foreach (explode(',', $relationships) as $relationship) {
175+
$type = self::$relationships[strtolower($type)];
176+
$model->addRelationship($type, trim($relationship));
177+
178+
if ($type === 'belongsTo') {
179+
$column = $this->columnNameFromRelationship($relationship);
180+
if (isset($columns[$column]) && !str_contains($columns[$column], ' foreign') && !str_contains($columns[$column], ' id')) {
181+
$columns[$column] .= ' id:' . Str::before($relationship, ':');
182+
}
183+
}
176184
}
177185
}
178186
}
@@ -195,33 +203,10 @@ private function buildModel(string $name, array $columns)
195203
foreach ($columns as $name => $definition) {
196204
$column = $this->buildColumn($name, $definition);
197205
$model->addColumn($column);
198-
199-
$foreign = collect($column->modifiers())->filter(fn ($modifier) => collect($modifier)->containsStrict('foreign') || collect($modifier)->has('foreign'))->flatten()->first();
200-
201-
if (
202-
($column->name() !== 'id' && $column->dataType() === 'id')
203-
|| ($column->dataType() === 'uuid' && Str::endsWith($column->name(), '_id'))
204-
|| $foreign
205-
) {
206-
$reference = $column->name();
207-
208-
if ($foreign && $foreign !== 'foreign') {
209-
$table = $foreign;
210-
$key = 'id';
211-
212-
if (Str::contains($foreign, '.')) {
213-
[$table, $key] = explode('.', $foreign);
214-
}
215-
216-
$reference = Str::singular($table) . ($key === 'id' ? '' : '.' . $key) . ':' . $column->name();
217-
} elseif ($column->attributes()) {
218-
$reference = $column->attributes()[0] . ':' . $column->name();
219-
}
220-
221-
$model->addRelationship('belongsTo', $reference);
222-
}
223206
}
224207

208+
$this->inferMissingBelongsToRelationships($model);
209+
225210
return $model;
226211
}
227212

@@ -270,4 +255,79 @@ private function buildColumn(string $name, string $definition)
270255

271256
return new Column($name, $data_type, $modifiers, $attributes ?? []);
272257
}
258+
259+
/**
260+
* Here we infer additional `belongsTo` relationships. First by checking
261+
* for those defined in `relationships`. Then by reviewing the model
262+
* columns which follow the conventional naming of `model_id`.
263+
*/
264+
private function inferMissingBelongsToRelationships(Model $model): void
265+
{
266+
foreach ($model->relationships()['belongsTo'] ?? [] as $relationship) {
267+
$column = $this->columnNameFromRelationship($relationship);
268+
269+
$attributes = [];
270+
if (str_contains($relationship, ':')) {
271+
$attributes = [Str::before($relationship, ':')];
272+
}
273+
274+
if (!$model->hasColumn($column)) {
275+
$model->addColumn(new Column($column, 'id', attributes: $attributes));
276+
}
277+
}
278+
279+
foreach ($model->columns() as $column) {
280+
$foreign = $column->isForeignKey();
281+
282+
if (
283+
($column->name() !== 'id' && $column->dataType() === 'id')
284+
|| ($column->dataType() === 'uuid' && Str::endsWith($column->name(), '_id'))
285+
|| $foreign
286+
) {
287+
$reference = $column->name();
288+
289+
if ($foreign && $foreign !== 'foreign') {
290+
$table = $foreign;
291+
$key = 'id';
292+
293+
if (Str::contains($foreign, '.')) {
294+
[$table, $key] = explode('.', $foreign);
295+
}
296+
297+
$reference = Str::singular($table) . ($key === 'id' ? '' : '.' . $key) . ':' . $column->name();
298+
} elseif ($column->attributes()) {
299+
$reference = $column->attributes()[0] . ':' . $column->name();
300+
}
301+
302+
if (!$this->hasBelongsToRelationship($model, $reference)) {
303+
$model->addRelationship('belongsTo', $reference);
304+
}
305+
}
306+
}
307+
}
308+
309+
private function columnNameFromRelationship(string $relationship): string
310+
{
311+
$model = $relationship;
312+
if (str_contains($relationship, ':')) {
313+
$model = Str::after($relationship, ':');
314+
}
315+
316+
if (str_contains($relationship, '\\')) {
317+
$model = Str::afterLast($relationship, '\\');
318+
}
319+
320+
return Str::snake($model) . '_id';
321+
}
322+
323+
private function hasBelongsToRelationship(Model $model, string $reference): bool
324+
{
325+
foreach ($model->relationships()['belongsTo'] ?? [] as $relationship) {
326+
if (Str::after($reference, ':') === $this->columnNameFromRelationship($relationship)) {
327+
return true;
328+
}
329+
}
330+
331+
return false;
332+
}
273333
}

tests/Feature/Generators/MigrationGeneratorTest.php

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use Blueprint\Generators\MigrationGenerator;
77
use Blueprint\Tree;
88
use Carbon\Carbon;
9+
use PHPUnit\Framework\Attributes\DataProvider;
910
use PHPUnit\Framework\Attributes\Test;
1011
use Symfony\Component\Finder\SplFileInfo;
1112
use Tests\TestCase;
@@ -45,6 +46,30 @@ public function output_writes_nothing_for_empty_tree(): void
4546
$this->assertEquals([], $this->subject->output(new Tree(['models' => []])));
4647
}
4748

49+
#[Test]
50+
#[DataProvider('modelTreeDataProvider')]
51+
public function output_generates_migrations($definition, $path, $model): void
52+
{
53+
$this->filesystem->expects('stub')
54+
->with('migration.stub')
55+
->andReturn($this->stub('migration.stub'));
56+
57+
$now = Carbon::now();
58+
Carbon::setTestNow($now);
59+
60+
$timestamp_path = str_replace('timestamp', $now->format('Y_m_d_His'), $path);
61+
62+
$this->filesystem->expects('exists')->andReturn(false);
63+
64+
$this->filesystem->expects('put')
65+
->with($timestamp_path, $this->fixture($model));
66+
67+
$tokens = $this->blueprint->parse($this->fixture($definition));
68+
$tree = $this->blueprint->analyze($tokens);
69+
70+
$this->assertEquals(['created' => [$timestamp_path]], $this->subject->output($tree));
71+
}
72+
4873
#[Test]
4974
public function output_writes_migration_for_foreign_shorthand(): void
5075
{
@@ -606,7 +631,7 @@ public function output_omits_length_for_integers(): void
606631
$this->assertEquals(['created' => [$timestamp_path]], $this->subject->output($tree));
607632
}
608633

609-
public function modelTreeDataProvider()
634+
public static function modelTreeDataProvider()
610635
{
611636
return [
612637
['drafts/readme-example.yaml', 'database/migrations/timestamp_create_posts_table.php', 'migrations/readme-example.php'],
@@ -616,7 +641,7 @@ public function modelTreeDataProvider()
616641
['drafts/soft-deletes.yaml', 'database/migrations/timestamp_create_comments_table.php', 'migrations/soft-deletes.php'],
617642
['drafts/with-timezones.yaml', 'database/migrations/timestamp_create_comments_table.php', 'migrations/with-timezones.php'],
618643
['drafts/relationships.yaml', 'database/migrations/timestamp_create_comments_table.php', 'migrations/relationships.php'],
619-
['drafts/indexes.yaml', 'database/migrations/timestamp_create_posts_table.php', 'migrations/indexes.php'],
644+
['drafts/models-with-custom-namespace.yaml', 'database/migrations/timestamp_create_categories_table.php', 'migrations/models-with-custom-namespace.php'],
620645
['drafts/custom-indexes.yaml', 'database/migrations/timestamp_create_cooltables_table.php', 'migrations/custom-indexes.php'],
621646
['drafts/unconventional.yaml', 'database/migrations/timestamp_create_teams_table.php', 'migrations/unconventional.php'],
622647
['drafts/optimize.yaml', 'database/migrations/timestamp_create_optimizes_table.php', 'migrations/optimize.php'],
@@ -633,6 +658,7 @@ public function modelTreeDataProvider()
633658
['drafts/foreign-with-class.yaml', 'database/migrations/timestamp_create_events_table.php', 'migrations/foreign-with-class.php'],
634659
['drafts/full-text.yaml', 'database/migrations/timestamp_create_posts_table.php', 'migrations/full-text.php'],
635660
['drafts/model-with-meta.yaml', 'database/migrations/timestamp_create_post_table.php', 'migrations/model-with-meta.php'],
661+
['drafts/infer-belongsto.yaml', 'database/migrations/timestamp_create_conferences_table.php', 'migrations/infer-belongsto.php'],
636662
];
637663
}
638664
}

tests/Feature/Generators/ModelGeneratorTest.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -609,6 +609,7 @@ public static function modelTreeDataProvider(): array
609609
['drafts/alias-relationships.yaml', 'app/Models/Salesman.php', 'models/alias-relationships.php'],
610610
['drafts/uuid-shorthand-invalid-relationship.yaml', 'app/Models/AgeCohort.php', 'models/uuid-shorthand-invalid-relationship.php'],
611611
['drafts/model-with-meta.yaml', 'app/Models/Post.php', 'models/model-with-meta.php'],
612+
['drafts/infer-belongsto.yaml', 'app/Models/Conference.php', 'models/infer-belongsto.php'],
612613
];
613614
}
614615

tests/Feature/Generators/Statements/ResourceGeneratorTest.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,8 @@ public function output_writes_nested_resource(): void
136136
->with('resource.stub')
137137
->andReturn(file_get_contents('stubs/resource.stub'));
138138

139-
$this->filesystem->shouldReceive('exists')
139+
$this->filesystem->expects('exists')
140+
->twice()
140141
->with('app/Http/Resources/Api')
141142
->andReturns(false, true);
142143
$this->filesystem->expects('makeDirectory')
@@ -170,7 +171,8 @@ public function output_api_resource_pagination(): void
170171
->with('resource.stub')
171172
->andReturn(file_get_contents('stubs/resource.stub'));
172173

173-
$this->files->shouldReceive('exists')
174+
$this->files->expects('exists')
175+
->twice()
174176
->with('app/Http/Resources')
175177
->andReturns(false, true);
176178
$this->files->expects('makeDirectory')

tests/Feature/Lexers/ModelLexerTest.php

Lines changed: 93 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,7 @@
99

1010
final class ModelLexerTest extends TestCase
1111
{
12-
/**
13-
* @var ModelLexer
14-
*/
15-
private $subject;
12+
private ModelLexer $subject;
1613

1714
protected function setUp(): void
1815
{
@@ -653,6 +650,98 @@ public function it_sets_meta_data(): void
653650
$this->assertCount(0, $model->relationships());
654651
}
655652

653+
#[Test]
654+
public function it_infers_belongsTo_columns(): void
655+
{
656+
$tokens = [
657+
'models' => [
658+
'Conference' => [
659+
'venue_id' => 'unsigned bigInteger',
660+
'relationships' => [
661+
'belongsTo' => 'Venue, Region, \\App\\Models\\User',
662+
],
663+
],
664+
],
665+
];
666+
667+
$actual = $this->subject->analyze($tokens);
668+
669+
$this->assertIsArray($actual['models']);
670+
$this->assertCount(1, $actual['models']);
671+
672+
$model = $actual['models']['Conference'];
673+
$this->assertEquals('Conference', $model->name());
674+
$this->assertArrayHasKey('belongsTo', $model->relationships());
675+
$this->assertTrue($model->usesTimestamps());
676+
677+
$columns = $model->columns();
678+
$this->assertCount(4, $columns);
679+
$this->assertEquals('id', $columns['id']->name());
680+
$this->assertEquals('id', $columns['id']->dataType());
681+
$this->assertEquals([], $columns['id']->modifiers());
682+
$this->assertEquals('venue_id', $columns['venue_id']->name());
683+
$this->assertEquals('id', $columns['venue_id']->dataType());
684+
$this->assertEquals(['unsigned'], $columns['venue_id']->modifiers());
685+
$this->assertEquals(['Venue'], $columns['venue_id']->attributes());
686+
$this->assertEquals('region_id', $columns['region_id']->name());
687+
$this->assertEquals('id', $columns['region_id']->dataType());
688+
$this->assertEquals([], $columns['region_id']->modifiers());
689+
$this->assertEquals([], $columns['region_id']->attributes());
690+
$this->assertEquals('user_id', $columns['user_id']->name());
691+
$this->assertEquals('id', $columns['user_id']->dataType());
692+
$this->assertEquals([], $columns['user_id']->modifiers());
693+
694+
$relationships = $model->relationships();
695+
$this->assertCount(1, $relationships);
696+
$this->assertEquals(['Venue', 'Region', '\\App\\Models\\User'], $relationships['belongsTo']);
697+
}
698+
699+
#[Test]
700+
public function it_handles_relationship_aliases(): void
701+
{
702+
$tokens = [
703+
'models' => [
704+
'Salesman' => [
705+
'customer_id' => 'id',
706+
'company_id' => 'id:Organization',
707+
'relationships' => [
708+
'belongsTo' => 'User:Lead, Client:Customer',
709+
],
710+
],
711+
],
712+
];
713+
714+
$actual = $this->subject->analyze($tokens);
715+
716+
$this->assertIsArray($actual['models']);
717+
$this->assertCount(1, $actual['models']);
718+
719+
$model = $actual['models']['Salesman'];
720+
$this->assertEquals('Salesman', $model->name());
721+
$this->assertArrayHasKey('belongsTo', $model->relationships());
722+
$this->assertTrue($model->usesTimestamps());
723+
724+
$columns = $model->columns();
725+
$this->assertCount(4, $columns);
726+
$this->assertEquals('id', $columns['id']->name());
727+
$this->assertEquals('id', $columns['id']->dataType());
728+
$this->assertEquals([], $columns['id']->modifiers());
729+
$this->assertEquals('customer_id', $columns['customer_id']->name());
730+
$this->assertEquals('id', $columns['customer_id']->dataType());
731+
$this->assertEquals([], $columns['customer_id']->modifiers());
732+
$this->assertEquals('company_id', $columns['company_id']->name());
733+
$this->assertEquals('id', $columns['company_id']->dataType());
734+
$this->assertEquals([], $columns['company_id']->modifiers());
735+
$this->assertEquals('lead_id', $columns['lead_id']->name());
736+
$this->assertEquals('id', $columns['lead_id']->dataType());
737+
$this->assertEquals([], $columns['lead_id']->modifiers());
738+
$this->assertEquals(['User'], $columns['lead_id']->attributes());
739+
740+
$relationships = $model->relationships();
741+
$this->assertCount(1, $relationships);
742+
$this->assertEquals(['User:Lead', 'Client:Customer', 'Organization:company_id'], $relationships['belongsTo']);
743+
}
744+
656745
public static function dataTypeAttributesDataProvider(): array
657746
{
658747
return [

tests/fixtures/drafts/alias-relationships.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,5 @@ models:
33
name: string
44
relationships:
55
hasOne: User:Lead
6-
hasMany: class_name:method_name
7-
belongsTo: class_name:method_name
6+
hasMany: ManyModel:ManyAlias
7+
belongsTo: BelongsModel:BelongsAlias
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
models:
2+
Conference:
3+
name: string
4+
venue_id: unsigned bigInteger
5+
relationships:
6+
belongsTo: Venue, Region

0 commit comments

Comments
 (0)