Skip to content

Commit 203fcea

Browse files
authored
feat: bypass __set() for loaded relation assignment (#27)
* feat: bypass __set() for loaded relation assignment * refactor * phpstan baseline
1 parent 5da3387 commit 203fcea

File tree

7 files changed

+281
-20
lines changed

7 files changed

+281
-20
lines changed

phpstan-baseline.neon

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,12 @@ parameters:
120120
count: 5
121121
path: tests/_support/Models/ProfileModel.php
122122

123+
-
124+
message: '#^Call to an undefined method CodeIgniter\\Model\:\:getTable\(\)\.$#'
125+
identifier: method.notFound
126+
count: 5
127+
path: tests/_support/Models/StrictUserModel.php
128+
123129
-
124130
message: '#^Call to an undefined method CodeIgniter\\Model\:\:getTable\(\)\.$#'
125131
identifier: method.notFound

src/Traits/HasLazyRelations.php

Lines changed: 35 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace Michalsn\CodeIgniterNestedModel\Traits;
66

77
use CodeIgniter\Autoloader\FileLocatorInterface;
8+
use CodeIgniter\Model;
89
use Michalsn\CodeIgniterNestedModel\Enums\RelationTypes;
910

1011
trait HasLazyRelations
@@ -14,35 +15,34 @@ trait HasLazyRelations
1415
*/
1516
private array $handledRelations = [];
1617

18+
private ?Model $relationModel = null;
19+
private bool $relationModelResolved = false;
20+
1721
public function __get(string $key)
1822
{
19-
$result = parent::__get($key);
23+
if (array_key_exists($key, $this->attributes)) {
24+
return parent::__get($key);
25+
}
26+
27+
$model = $this->getRelationModel();
2028

21-
if ($result === null && ! isset($this->handledRelations[$key])) {
22-
$result = $this->handleRelation($key);
23-
$this->handledRelations[$key] = true;
29+
if ($model !== null && method_exists($model, $key)) {
30+
if (! isset($this->handledRelations[$key]) && ! array_key_exists($key, $this->attributes)) {
31+
$this->handleRelation($key, $model);
32+
$this->handledRelations[$key] = true;
33+
}
34+
35+
return $this->attributes[$key] ?? null;
2436
}
2537

26-
return $result;
38+
return parent::__get($key);
2739
}
2840

2941
/**
3042
* Load relation for the property.
3143
*/
32-
private function handleRelation(string $name)
44+
private function handleRelation(string $name, Model $model)
3345
{
34-
$className = $this->findModelClass();
35-
36-
if ($className === null) {
37-
return null;
38-
}
39-
40-
$model = model($className);
41-
42-
if (! method_exists($model, $name)) {
43-
return null;
44-
}
45-
4646
$relation = $model->{$name}();
4747

4848
if (in_array($relation->type, [RelationTypes::hasOne, RelationTypes::belongsTo], true)) {
@@ -65,6 +65,23 @@ private function handleRelation(string $name)
6565
return $this->attributes[$name];
6666
}
6767

68+
/**
69+
* Resolve the matching model once for the lifetime of the entity instance.
70+
*/
71+
private function getRelationModel(): ?Model
72+
{
73+
if ($this->relationModelResolved) {
74+
return $this->relationModel;
75+
}
76+
77+
$className = $this->findModelClass();
78+
79+
$this->relationModel = $className === null ? null : model($className);
80+
$this->relationModelResolved = true;
81+
82+
return $this->relationModel;
83+
}
84+
6885
/**
6986
* Search for the proper model.
7087
*

src/Traits/HasRelations.php

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -406,7 +406,13 @@ protected function relationsAfterFind(array $eventData): array
406406
}
407407
} else {
408408
foreach ($this->relations as $relationName => $relationObject) {
409-
$eventData['data']->{$relationName} = $this->getDataForRelationById($eventData['data']->{$relationObject->primaryKey}, $relationObject, $relationName);
409+
$relationValue = $this->getDataForRelationById($eventData['data']->{$relationObject->primaryKey}, $relationObject, $relationName);
410+
411+
if ($eventData['data'] instanceof Entity) {
412+
$this->setEntityRelation($eventData['data'], $relationName, $relationValue);
413+
} else {
414+
$eventData['data']->{$relationName} = $relationValue;
415+
}
410416
}
411417
}
412418
} else {
@@ -426,7 +432,13 @@ protected function relationsAfterFind(array $eventData): array
426432
if ($this->tempReturnType === 'array') {
427433
$data[$relationName] = $relationData[$data[$relationObject->primaryKey]] ?? [];
428434
} else {
429-
$data->{$relationName} = $relationData[$data->{$relationObject->primaryKey}] ?? [];
435+
$relationValue = $relationData[$data->{$relationObject->primaryKey}] ?? [];
436+
437+
if ($data instanceof Entity) {
438+
$this->setEntityRelation($data, $relationName, $relationValue);
439+
} else {
440+
$data->{$relationName} = $relationValue;
441+
}
430442
}
431443
}
432444
}
@@ -437,6 +449,19 @@ protected function relationsAfterFind(array $eventData): array
437449
return $eventData;
438450
}
439451

452+
/**
453+
* Store relation data on an entity without triggering strict __set() implementations.
454+
*/
455+
private function setEntityRelation(Entity $entity, string $relationName, mixed $value): void
456+
{
457+
$setter = function (string $name, mixed $relationValue): void {
458+
// @phpstan-ignore-next-line bound to Entity scope below
459+
$this->attributes[$name] = $relationValue;
460+
};
461+
462+
Closure::bind($setter, $entity, Entity::class)($relationName, $value);
463+
}
464+
440465
/**
441466
* Get relation data for a single item.
442467
*/
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests;
6+
7+
use CodeIgniter\Test\CIUnitTestCase;
8+
use CodeIgniter\Test\DatabaseTestTrait;
9+
use Tests\Support\Database\Seeds\SeedTests;
10+
use Tests\Support\Entities\Post;
11+
use Tests\Support\Entities\Profile;
12+
use Tests\Support\Entities\StrictUser;
13+
use Tests\Support\Models\StrictUserModel;
14+
15+
/**
16+
* @internal
17+
*/
18+
final class StrictEntityRelationsTest extends CIUnitTestCase
19+
{
20+
use DatabaseTestTrait;
21+
22+
protected $refresh = true;
23+
protected $namespace;
24+
protected $seed = SeedTests::class;
25+
26+
public function testEagerLoadsHasOneRelationOnStrictEntity(): void
27+
{
28+
$user = model(StrictUserModel::class)->with('profile')->find(1);
29+
30+
$this->assertInstanceOf(StrictUser::class, $user);
31+
$this->assertInstanceOf(Profile::class, $user->profile);
32+
$this->assertSame('United States', $user->profile->country);
33+
}
34+
35+
public function testEagerLoadsHasManyRelationOnStrictEntity(): void
36+
{
37+
$user = model(StrictUserModel::class)->with('posts')->find(1);
38+
39+
$this->assertInstanceOf(StrictUser::class, $user);
40+
$this->assertIsArray($user->posts);
41+
$this->assertInstanceOf(Post::class, $user->posts[0]);
42+
$this->assertSame('Title 1', $user->posts[0]->title);
43+
}
44+
45+
public function testLazyLoadsHasOneRelationOnStrictEntity(): void
46+
{
47+
$user = model(StrictUserModel::class)->find(1);
48+
49+
$this->assertInstanceOf(StrictUser::class, $user);
50+
$this->assertInstanceOf(Profile::class, $user->profile);
51+
$this->assertSame('United States', $user->profile->country);
52+
}
53+
54+
public function testLazyLoadsHasManyRelationOnStrictEntity(): void
55+
{
56+
$user = model(StrictUserModel::class)->find(1);
57+
58+
$this->assertInstanceOf(StrictUser::class, $user);
59+
$this->assertIsArray($user->posts);
60+
$this->assertInstanceOf(Post::class, $user->posts[0]);
61+
$this->assertSame('Title 1', $user->posts[0]->title);
62+
}
63+
}
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: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Support\Entities;
6+
7+
use Michalsn\CodeIgniterNestedModel\Traits\HasLazyRelations;
8+
9+
class StrictUser extends StrictEntity
10+
{
11+
use HasLazyRelations;
12+
13+
protected $attributes = [
14+
'id' => null,
15+
'username' => null,
16+
'company_id' => null,
17+
'country_id' => null,
18+
'created_at' => null,
19+
'updated_at' => null,
20+
];
21+
protected $datamap = [];
22+
protected $dates = ['created_at', 'updated_at'];
23+
protected $casts = [];
24+
}
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\Support\Models;
6+
7+
use CodeIgniter\Model;
8+
use Michalsn\CodeIgniterNestedModel\Relation;
9+
use Michalsn\CodeIgniterNestedModel\Traits\HasRelations;
10+
use Tests\Support\Entities\StrictUser;
11+
12+
class StrictUserModel extends Model
13+
{
14+
use HasRelations;
15+
16+
protected $table = 'users';
17+
protected $primaryKey = 'id';
18+
protected $useAutoIncrement = true;
19+
protected $returnType = StrictUser::class;
20+
protected $useSoftDeletes = false;
21+
protected $protectFields = true;
22+
protected $allowedFields = ['username', 'company_id', 'country_id'];
23+
protected bool $allowEmptyInserts = false;
24+
protected bool $updateOnlyChanged = true;
25+
protected array $casts = [];
26+
protected array $castHandlers = [];
27+
28+
// Dates
29+
protected $useTimestamps = true;
30+
protected $dateFormat = 'datetime';
31+
protected $createdField = 'created_at';
32+
protected $updatedField = 'updated_at';
33+
protected $deletedField = 'deleted_at';
34+
35+
// Validation
36+
protected $validationRules = [];
37+
protected $validationMessages = [];
38+
protected $skipValidation = false;
39+
protected $cleanValidationRules = true;
40+
41+
// Callbacks
42+
protected $allowCallbacks = true;
43+
protected $beforeInsert = [];
44+
protected $afterInsert = [];
45+
protected $beforeUpdate = [];
46+
protected $afterUpdate = [];
47+
protected $beforeFind = [];
48+
protected $afterFind = [];
49+
protected $beforeDelete = [];
50+
protected $afterDelete = [];
51+
52+
protected function initialize(): void
53+
{
54+
$this->initRelations();
55+
}
56+
57+
public function profile(): Relation
58+
{
59+
return $this->hasOne(ProfileModel::class);
60+
}
61+
62+
public function posts(): Relation
63+
{
64+
return $this->hasMany(PostModel::class);
65+
}
66+
}

0 commit comments

Comments
 (0)