Skip to content

Commit b2e0c75

Browse files
fix(database): handle loading circular eager relations (#1556)
1 parent b5f4185 commit b2e0c75

File tree

6 files changed

+198
-5
lines changed

6 files changed

+198
-5
lines changed

packages/database/src/Builder/ModelInspector.php

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -365,7 +365,7 @@ public function getSelectFields(): ImmutableArray
365365
return $selectFields;
366366
}
367367

368-
public function resolveRelations(string $relationString, string $parent = ''): array
368+
public function resolveRelations(string $relationString, string $parent = '', array $visitedPaths = []): array
369369
{
370370
if ($relationString === '') {
371371
return [];
@@ -384,6 +384,11 @@ public function resolveRelations(string $relationString, string $parent = ''): a
384384
unset($relationNames[0]);
385385

386386
$relationModel = inspect($currentRelation);
387+
$modelType = $relationModel->getName();
388+
389+
if (in_array($modelType, $visitedPaths, true)) {
390+
return [$currentRelationName => $currentRelation->setParent($parent)];
391+
}
387392

388393
$newRelationString = implode('.', $relationNames);
389394
$currentRelation->setParent($parent);
@@ -395,10 +400,13 @@ public function resolveRelations(string $relationString, string $parent = ''): a
395400

396401
$relations = [$currentRelationName => $currentRelation];
397402

398-
return [...$relations, ...$relationModel->resolveRelations($newRelationString, $newParent)];
403+
return [
404+
...$relations,
405+
...$relationModel->resolveRelations($newRelationString, $newParent, [...$visitedPaths, $this->getName()]),
406+
];
399407
}
400408

401-
public function resolveEagerRelations(string $parent = ''): array
409+
public function resolveEagerRelations(string $parent = '', array $visitedPaths = []): array
402410
{
403411
if (! $this->isObjectModel()) {
404412
return [];
@@ -418,14 +426,23 @@ public function resolveEagerRelations(string $parent = ''): array
418426
continue;
419427
}
420428

421-
$relations[$property->getName()] = $currentRelation->setParent($parent);
422429
$newParent = ltrim(sprintf(
423430
'%s.%s',
424431
$parent,
425432
$currentRelationName,
426433
), '.');
427434

428-
foreach (inspect($currentRelation)->resolveEagerRelations($newParent) as $name => $nestedEagerRelation) {
435+
$relationModel = inspect($currentRelation);
436+
$modelType = $relationModel->getName();
437+
438+
if (in_array($modelType, $visitedPaths, true)) {
439+
continue;
440+
}
441+
442+
$relations[$property->getName()] = $currentRelation->setParent($parent);
443+
$newVisitedPaths = [...$visitedPaths, $this->getName()];
444+
445+
foreach ($relationModel->resolveEagerRelations($newParent, $newVisitedPaths) as $name => $nestedEagerRelation) {
429446
$relations[$name] = $nestedEagerRelation;
430447
}
431448
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Tempest\Fixtures\Migrations;
6+
7+
use Tempest\Database\MigratesDown;
8+
use Tempest\Database\MigratesUp;
9+
use Tempest\Database\QueryStatement;
10+
use Tempest\Database\QueryStatements\CreateTableStatement;
11+
use Tempest\Database\QueryStatements\DropTableStatement;
12+
use Tests\Tempest\Fixtures\Models\ProfileWithEager;
13+
14+
final class CreateProfileWithEagerTable implements MigratesUp, MigratesDown
15+
{
16+
private(set) string $name = '0000-00-06_create_profiles_table';
17+
18+
public function up(): QueryStatement
19+
{
20+
return CreateTableStatement::forModel(ProfileWithEager::class)
21+
->primary()
22+
->text('bio')
23+
->belongsTo('profiles.user_id', 'users.id', nullable: true);
24+
}
25+
26+
public function down(): QueryStatement
27+
{
28+
return DropTableStatement::forModel(ProfileWithEager::class);
29+
}
30+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Tempest\Fixtures\Migrations;
6+
7+
use Tempest\Database\MigratesDown;
8+
use Tempest\Database\MigratesUp;
9+
use Tempest\Database\QueryStatement;
10+
use Tempest\Database\QueryStatements\CreateTableStatement;
11+
use Tempest\Database\QueryStatements\DropTableStatement;
12+
use Tests\Tempest\Fixtures\Models\UserWithEager;
13+
14+
final class CreateUserWithEagerTable implements MigratesUp, MigratesDown
15+
{
16+
private(set) string $name = '0000-00-05_create_users_table';
17+
18+
public function up(): QueryStatement
19+
{
20+
return CreateTableStatement::forModel(UserWithEager::class)
21+
->primary()
22+
->text('name');
23+
}
24+
25+
public function down(): QueryStatement
26+
{
27+
return DropTableStatement::forModel(UserWithEager::class);
28+
}
29+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Tempest\Fixtures\Models;
6+
7+
use Tempest\Database\BelongsTo;
8+
use Tempest\Database\Eager;
9+
use Tempest\Database\IsDatabaseModel;
10+
use Tempest\Database\PrimaryKey;
11+
use Tempest\Database\Table;
12+
13+
#[Table('profiles')]
14+
final class ProfileWithEager
15+
{
16+
use IsDatabaseModel;
17+
18+
public function __construct(
19+
public PrimaryKey $id,
20+
public string $bio,
21+
public ?int $user_id,
22+
#[Eager]
23+
#[BelongsTo]
24+
public ?UserWithEager $user = null,
25+
) {}
26+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Tempest\Fixtures\Models;
6+
7+
use Tempest\Database\Eager;
8+
use Tempest\Database\HasOne;
9+
use Tempest\Database\IsDatabaseModel;
10+
use Tempest\Database\PrimaryKey;
11+
use Tempest\Database\Table;
12+
13+
#[Table('users')]
14+
final class UserWithEager
15+
{
16+
use IsDatabaseModel;
17+
18+
public function __construct(
19+
public PrimaryKey $id,
20+
public string $name,
21+
#[Eager]
22+
#[HasOne]
23+
public ?ProfileWithEager $profile = null,
24+
) {}
25+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Tempest\Integration\Database;
6+
7+
use Tempest\Database\Migrations\CreateMigrationsTable;
8+
use Tests\Tempest\Fixtures\Migrations\CreateProfileWithEagerTable;
9+
use Tests\Tempest\Fixtures\Migrations\CreateUserWithEagerTable;
10+
use Tests\Tempest\Fixtures\Models\ProfileWithEager;
11+
use Tests\Tempest\Fixtures\Models\UserWithEager;
12+
use Tests\Tempest\Integration\FrameworkIntegrationTestCase;
13+
14+
use function Tempest\Database\inspect;
15+
use function Tempest\Database\query;
16+
17+
/**
18+
* @internal
19+
*/
20+
final class CircularEagerLoadingTest extends FrameworkIntegrationTestCase
21+
{
22+
public function test_circular_eager_loading_does_not_cause_infinite_loop(): void
23+
{
24+
$userInspector = inspect(UserWithEager::class);
25+
$eagerRelations = $userInspector->resolveEagerRelations();
26+
27+
$this->assertArrayHasKey('profile', $eagerRelations);
28+
$this->assertArrayNotHasKey('profile.user', $eagerRelations);
29+
$this->assertCount(1, $eagerRelations);
30+
}
31+
32+
public function test_circular_with_relations_does_not_cause_infinite_loop(): void
33+
{
34+
$userInspector = inspect(UserWithEager::class);
35+
$relations = $userInspector->resolveRelations('profile.user.profile');
36+
37+
$this->assertArrayHasKey('profile', $relations);
38+
$this->assertArrayHasKey('user', $relations);
39+
$this->assertCount(2, $relations);
40+
}
41+
42+
public function test_it_saves_and_loads_relations_without_causing_infinite_loop(): void
43+
{
44+
$this->migrate(
45+
CreateMigrationsTable::class,
46+
CreateUserWithEagerTable::class,
47+
CreateProfileWithEagerTable::class,
48+
);
49+
50+
$user = query(UserWithEager::class)->create(
51+
name: 'John Doe',
52+
);
53+
54+
$profile = query(ProfileWithEager::class)->create(
55+
bio: 'Test',
56+
user_id: $user->id->value,
57+
);
58+
59+
$profile = query(ProfileWithEager::class)->findById($profile->id);
60+
61+
$user = query(UserWithEager::class)->findById($user->id);
62+
63+
$this->assertTrue($profile->id->equals($user->profile->id));
64+
$this->assertTrue($user->id->equals($profile->user->id));
65+
}
66+
}

0 commit comments

Comments
 (0)