Skip to content

Commit 3d5be51

Browse files
committed
avoid entity magic methods when hydrating loaded relations
1 parent df2a28b commit 3d5be51

12 files changed

Lines changed: 372 additions & 20 deletions

rector.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,9 @@
9696

9797
// May load view files directly when detecting classes
9898
StringClassNameToClassConstantRector::class,
99+
CompleteDynamicPropertiesRector::class => [
100+
__DIR__ . '/src/Relations/Relation.php',
101+
],
99102
]);
100103

101104
// auto import fully qualified class names

src/Relations/BelongsTo.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ public function associate(int|object|string $parent): bool
215215
// Update the loaded relation in memory ONLY if parent is an object
216216
// This preserves any nested relations on the old entity when passing an integer
217217
if ($this->relationName !== null && is_object($parent)) {
218-
$this->parentEntity->{$this->relationName} = $parent;
218+
$this->setEntityRelation($this->parentEntity, $this->relationName, $parent);
219219
}
220220

221221
// Sync original state since we've persisted to database
@@ -252,7 +252,7 @@ public function dissociate(): bool
252252

253253
// Clear the loaded relation in memory
254254
if ($this->relationName !== null) {
255-
$this->parentEntity->{$this->relationName} = null;
255+
$this->setEntityRelation($this->parentEntity, $this->relationName, null);
256256
}
257257

258258
// Sync original state since we've persisted to database

src/Relations/Relation.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,12 +270,31 @@ protected function getPrimaryKeyValue(array|object $result): mixed
270270
protected function attachRelationToResult(array|object &$result, string $relationName, mixed $data): void
271271
{
272272
if (is_object($result)) {
273+
if ($result instanceof Entity) {
274+
$this->setEntityRelation($result, $relationName, $data);
275+
276+
return;
277+
}
278+
273279
$result->{$relationName} = $data;
274280
} else {
275281
$result[$relationName] = $data;
276282
}
277283
}
278284

285+
/**
286+
* Store relation data on an entity without triggering strict __set() implementations.
287+
*/
288+
protected function setEntityRelation(Entity $entity, string $relationName, mixed $value): void
289+
{
290+
$setter = function (string $name, mixed $relationValue): void {
291+
// @phpstan-ignore-next-line Bound to Entity scope below.
292+
$this->attributes[$name] = $relationValue;
293+
};
294+
295+
Closure::bind($setter, $entity, Entity::class)($relationName, $value);
296+
}
297+
279298
/**
280299
* Eager load this relation for multiple parent results
281300
*

src/Traits/HasLazyRelations.php

Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use App\Entities\Profile;
99
use App\Entities\User;
1010
use Closure;
11+
use CodeIgniter\Model;
1112

1213
/**
1314
* Enables lazy loading of relations in entities.
@@ -36,6 +37,9 @@ trait HasLazyRelations
3637
*/
3738
private array $loadedRelations = [];
3839

40+
private ?Model $relationModel = null;
41+
private bool $relationModelResolved = false;
42+
3943
/**
4044
* Override property access to enable lazy loading
4145
*
@@ -45,14 +49,21 @@ trait HasLazyRelations
4549
*/
4650
public function __get(string $key)
4751
{
48-
$result = parent::__get($key);
52+
if (array_key_exists($key, $this->attributes)) {
53+
return parent::__get($key);
54+
}
55+
56+
$model = $this->getRelationModel();
4957

50-
// If result is null and not yet loaded, try lazy loading the relation
51-
if ($result === null && ! isset($this->loadedRelations[$key])) {
52-
$result = $this->handleRelation($key);
58+
if ($model !== null && method_exists($model, $key)) {
59+
if (! isset($this->loadedRelations[$key]) && ! array_key_exists($key, $this->attributes)) {
60+
return $this->handleRelation($key, $model);
61+
}
62+
63+
return $this->attributes[$key] ?? null;
5364
}
5465

55-
return $result;
66+
return parent::__get($key);
5667
}
5768

5869
/**
@@ -65,20 +76,8 @@ public function __get(string $key)
6576
*
6677
* @return mixed The loaded relation data or null
6778
*/
68-
private function handleRelation(string $name): mixed
79+
private function handleRelation(string $name, Model $model): mixed
6980
{
70-
$className = $this->findModelClass();
71-
72-
if ($className === null) {
73-
return null;
74-
}
75-
76-
$model = model($className);
77-
78-
if (! method_exists($model, $name)) {
79-
return null;
80-
}
81-
8281
$relation = $model->{$name}();
8382

8483
// Use the relation's own lazyLoad method which knows how to query correctly
@@ -95,6 +94,23 @@ private function handleRelation(string $name): mixed
9594
return $this->attributes[$name];
9695
}
9796

97+
/**
98+
* Resolve the matching model once for the lifetime of the entity instance.
99+
*/
100+
private function getRelationModel(): ?Model
101+
{
102+
if ($this->relationModelResolved) {
103+
return $this->relationModel;
104+
}
105+
106+
$className = $this->findModelClass();
107+
108+
$this->relationModel = $className === null ? null : model($className);
109+
$this->relationModelResolved = true;
110+
111+
return $this->relationModel;
112+
}
113+
98114
/**
99115
* Mark a relation as loaded with metadata
100116
*
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests;
6+
7+
use CodeIgniter\Test\DatabaseTestTrait;
8+
use Tests\Support\Database\Seeds\SeedTests;
9+
use Tests\Support\Entities\Country;
10+
use Tests\Support\Entities\Profile;
11+
use Tests\Support\Entities\StrictImage;
12+
use Tests\Support\Entities\StrictStudent;
13+
use Tests\Support\Entities\StrictUser;
14+
use Tests\Support\Entities\User;
15+
use Tests\Support\Models\CountryModel;
16+
use Tests\Support\Models\StrictImageModel;
17+
use Tests\Support\Models\StrictStudentModel;
18+
use Tests\Support\Models\StrictUserModel;
19+
use Tests\Support\TestCase;
20+
21+
/**
22+
* @internal
23+
*/
24+
final class StrictEntityRelationsTest extends TestCase
25+
{
26+
use DatabaseTestTrait;
27+
28+
protected $refresh = true;
29+
protected $namespace;
30+
protected $seed = SeedTests::class;
31+
32+
public function testEagerLoadsHasOneRelationOnStrictEntity(): void
33+
{
34+
$user = model(StrictUserModel::class)->with('profile')->find(1);
35+
36+
$this->assertInstanceOf(StrictUser::class, $user);
37+
$this->assertInstanceOf(Profile::class, $user->profile);
38+
$this->assertSame('1', $user->profile->user_id);
39+
}
40+
41+
public function testEagerLoadsHasManyRelationOnStrictEntity(): void
42+
{
43+
$user = model(StrictUserModel::class)->with('posts')->find(1);
44+
45+
$this->assertInstanceOf(StrictUser::class, $user);
46+
$this->assertCount(2, $user->posts);
47+
$this->assertSame('Getting Started with CodeIgniter 4', $user->posts[0]->title);
48+
}
49+
50+
public function testLazyLoadsHasOneRelationOnStrictEntity(): void
51+
{
52+
$user = model(StrictUserModel::class)->find(1);
53+
54+
$this->assertInstanceOf(StrictUser::class, $user);
55+
$this->assertInstanceOf(Profile::class, $user->profile);
56+
$this->assertSame('1', $user->profile->user_id);
57+
}
58+
59+
public function testLazyLoadsHasManyRelationOnStrictEntity(): void
60+
{
61+
$user = model(StrictUserModel::class)->find(1);
62+
63+
$this->assertInstanceOf(StrictUser::class, $user);
64+
$this->assertCount(2, $user->posts);
65+
$this->assertSame('Getting Started with CodeIgniter 4', $user->posts[0]->title);
66+
}
67+
68+
public function testAssociateUpdatesLoadedBelongsToRelationOnStrictEntity(): void
69+
{
70+
$user = model(StrictUserModel::class)->find(1);
71+
$country = model(CountryModel::class)->find(2);
72+
73+
$this->assertInstanceOf(StrictUser::class, $user);
74+
$this->assertInstanceOf(Country::class, $country);
75+
76+
$result = $user->country()->associate($country);
77+
78+
$this->assertTrue($result);
79+
$this->assertSame('2', (string) $user->country_id);
80+
$this->assertInstanceOf(Country::class, $user->country);
81+
$this->assertSame('2', $user->country->id);
82+
}
83+
84+
public function testEagerLoadsMorphToRelationOnStrictEntity(): void
85+
{
86+
$image = model(StrictImageModel::class)->with('imageable')->find(1);
87+
88+
$this->assertInstanceOf(StrictImage::class, $image);
89+
$this->assertInstanceOf(User::class, $image->imageable);
90+
$this->assertSame('1', $image->imageable->id);
91+
}
92+
93+
public function testLazyLoadsMorphToRelationOnStrictEntity(): void
94+
{
95+
$image = model(StrictImageModel::class)->find(1);
96+
97+
$this->assertInstanceOf(StrictImage::class, $image);
98+
$this->assertInstanceOf(User::class, $image->imageable);
99+
$this->assertSame('1', $image->imageable->id);
100+
}
101+
102+
public function testEagerLoadsBelongsToManyRelationOnStrictEntity(): void
103+
{
104+
$student = model(StrictStudentModel::class)->with('courses')->find(1);
105+
106+
$this->assertInstanceOf(StrictStudent::class, $student);
107+
$this->assertCount(3, $student->courses);
108+
$this->assertSame('Introduction to Programming', $student->courses[0]->title);
109+
}
110+
111+
public function testLazyLoadsBelongsToManyRelationOnStrictEntity(): void
112+
{
113+
$student = model(StrictStudentModel::class)->find(1);
114+
115+
$this->assertInstanceOf(StrictStudent::class, $student);
116+
$this->assertCount(3, $student->courses);
117+
$this->assertSame('Introduction to Programming', $student->courses[0]->title);
118+
}
119+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Support\Entities;
6+
7+
use CodeIgniter\Entity\Entity;
8+
use LogicException;
9+
10+
abstract class StrictEntity extends Entity
11+
{
12+
protected $dates = [];
13+
14+
public function __construct(?array $data = null)
15+
{
16+
parent::__construct(is_array($data) ? $this->filterAttributes($data) : []);
17+
}
18+
19+
public function fill(?array $data = null)
20+
{
21+
return parent::fill(is_array($data) ? $this->filterAttributes($data) : []);
22+
}
23+
24+
public function injectRawData(array $data)
25+
{
26+
return parent::injectRawData(array_merge($this->attributes, $this->filterAttributes($data)));
27+
}
28+
29+
public function __set(string $key, $value = null)
30+
{
31+
$attribute = $this->mapProperty($key);
32+
33+
if (! array_key_exists($attribute, $this->attributes)) {
34+
throw new LogicException(sprintf('Attribute "%s" is not defined.', $attribute));
35+
}
36+
37+
parent::__set($key, $value);
38+
}
39+
40+
public function __get(string $key)
41+
{
42+
$attribute = $this->mapProperty($key);
43+
44+
if (! array_key_exists($attribute, $this->attributes)) {
45+
throw new LogicException(sprintf('Attribute "%s" is not defined.', $attribute));
46+
}
47+
48+
return parent::__get($key);
49+
}
50+
51+
/**
52+
* @param array<string, mixed> $attributes
53+
*
54+
* @return array<string, mixed>
55+
*/
56+
protected function filterAttributes(array $attributes): array
57+
{
58+
return array_intersect_key($attributes, $this->attributes);
59+
}
60+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Support\Entities;
6+
7+
use Michalsn\CodeIgniterRelations\Relations\MorphTo;
8+
use Michalsn\CodeIgniterRelations\Traits\HasLazyRelations;
9+
10+
/**
11+
* @property object|null $imageable
12+
*
13+
* @method MorphTo imageable()
14+
*/
15+
class StrictImage extends StrictEntity
16+
{
17+
use HasLazyRelations;
18+
19+
protected $attributes = [
20+
'id' => null,
21+
'imageable_type' => null,
22+
'imageable_id' => null,
23+
'url' => null,
24+
'alt_text' => null,
25+
'created_at' => null,
26+
'updated_at' => null,
27+
];
28+
protected $datamap = [];
29+
protected $dates = ['created_at', 'updated_at'];
30+
protected $casts = [];
31+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Support\Entities;
6+
7+
use Michalsn\CodeIgniterRelations\Relations\BelongsToMany;
8+
use Michalsn\CodeIgniterRelations\Traits\HasLazyRelations;
9+
10+
/**
11+
* @property list<Course> $courses
12+
*
13+
* @method BelongsToMany courses()
14+
*/
15+
class StrictStudent extends StrictEntity
16+
{
17+
use HasLazyRelations;
18+
19+
protected $attributes = [
20+
'id' => null,
21+
'name' => null,
22+
'email' => null,
23+
'enrollment_date' => null,
24+
'created_at' => null,
25+
'updated_at' => null,
26+
];
27+
protected $datamap = [];
28+
protected $dates = ['created_at', 'updated_at', 'enrollment_date'];
29+
protected $casts = [
30+
'id' => 'int',
31+
];
32+
}

0 commit comments

Comments
 (0)