From b484e510ab2c34da7f54d7d46f28ab01ec91fbf2 Mon Sep 17 00:00:00 2001 From: michalsn Date: Tue, 10 Mar 2026 19:59:15 +0100 Subject: [PATCH 1/3] feat: bypass __set() for loaded relation assignment --- src/Traits/HasLazyRelations.php | 30 +++++++++-- src/Traits/HasRelations.php | 29 +++++++++- tests/StrictEntityRelationsTest.php | 63 ++++++++++++++++++++++ tests/_support/Entities/StrictEntity.php | 60 +++++++++++++++++++++ tests/_support/Entities/StrictUser.php | 24 +++++++++ tests/_support/Models/StrictUserModel.php | 66 +++++++++++++++++++++++ 6 files changed, 265 insertions(+), 7 deletions(-) create mode 100644 tests/StrictEntityRelationsTest.php create mode 100644 tests/_support/Entities/StrictEntity.php create mode 100644 tests/_support/Entities/StrictUser.php create mode 100644 tests/_support/Models/StrictUserModel.php diff --git a/src/Traits/HasLazyRelations.php b/src/Traits/HasLazyRelations.php index 60aaca0..461552d 100644 --- a/src/Traits/HasLazyRelations.php +++ b/src/Traits/HasLazyRelations.php @@ -16,14 +16,20 @@ trait HasLazyRelations public function __get(string $key) { - $result = parent::__get($key); + if (array_key_exists($key, $this->attributes)) { + return parent::__get($key); + } + + if ($this->isRelation($key)) { + if (! isset($this->handledRelations[$key]) && ! array_key_exists($key, $this->attributes)) { + $this->handleRelation($key); + $this->handledRelations[$key] = true; + } - if ($result === null && ! isset($this->handledRelations[$key])) { - $result = $this->handleRelation($key); - $this->handledRelations[$key] = true; + return $this->attributes[$key] ?? null; } - return $result; + return parent::__get($key); } /** @@ -65,6 +71,20 @@ private function handleRelation(string $name) return $this->attributes[$name]; } + /** + * Check if the property is a declared relation on the matching model. + */ + private function isRelation(string $name): bool + { + $className = $this->findModelClass(); + + if ($className === null) { + return false; + } + + return method_exists(model($className), $name); + } + /** * Search for the proper model. * diff --git a/src/Traits/HasRelations.php b/src/Traits/HasRelations.php index af3c3d9..85e32e4 100644 --- a/src/Traits/HasRelations.php +++ b/src/Traits/HasRelations.php @@ -406,7 +406,13 @@ protected function relationsAfterFind(array $eventData): array } } else { foreach ($this->relations as $relationName => $relationObject) { - $eventData['data']->{$relationName} = $this->getDataForRelationById($eventData['data']->{$relationObject->primaryKey}, $relationObject, $relationName); + $relationValue = $this->getDataForRelationById($eventData['data']->{$relationObject->primaryKey}, $relationObject, $relationName); + + if ($eventData['data'] instanceof Entity) { + $this->setEntityRelation($eventData['data'], $relationName, $relationValue); + } else { + $eventData['data']->{$relationName} = $relationValue; + } } } } else { @@ -426,7 +432,13 @@ protected function relationsAfterFind(array $eventData): array if ($this->tempReturnType === 'array') { $data[$relationName] = $relationData[$data[$relationObject->primaryKey]] ?? []; } else { - $data->{$relationName} = $relationData[$data->{$relationObject->primaryKey}] ?? []; + $relationValue = $relationData[$data->{$relationObject->primaryKey}] ?? []; + + if ($data instanceof Entity) { + $this->setEntityRelation($data, $relationName, $relationValue); + } else { + $data->{$relationName} = $relationValue; + } } } } @@ -437,6 +449,19 @@ protected function relationsAfterFind(array $eventData): array return $eventData; } + /** + * Store relation data on an entity without triggering strict __set() implementations. + */ + private function setEntityRelation(Entity $entity, string $relationName, mixed $value): void + { + $setter = function (string $name, mixed $relationValue): void { + // @phpstan-ignore-next-line bound to Entity scope below + $this->attributes[$name] = $relationValue; + }; + + Closure::bind($setter, $entity, Entity::class)($relationName, $value); + } + /** * Get relation data for a single item. */ diff --git a/tests/StrictEntityRelationsTest.php b/tests/StrictEntityRelationsTest.php new file mode 100644 index 0000000..f6cf8cd --- /dev/null +++ b/tests/StrictEntityRelationsTest.php @@ -0,0 +1,63 @@ +with('profile')->find(1); + + $this->assertInstanceOf(StrictUser::class, $user); + $this->assertInstanceOf(Profile::class, $user->profile); + $this->assertSame('United States', $user->profile->country); + } + + public function testEagerLoadsHasManyRelationOnStrictEntity(): void + { + $user = model(StrictUserModel::class)->with('posts')->find(1); + + $this->assertInstanceOf(StrictUser::class, $user); + $this->assertIsArray($user->posts); + $this->assertInstanceOf(Post::class, $user->posts[0]); + $this->assertSame('Title 1', $user->posts[0]->title); + } + + public function testLazyLoadsHasOneRelationOnStrictEntity(): void + { + $user = model(StrictUserModel::class)->find(1); + + $this->assertInstanceOf(StrictUser::class, $user); + $this->assertInstanceOf(Profile::class, $user->profile); + $this->assertSame('United States', $user->profile->country); + } + + public function testLazyLoadsHasManyRelationOnStrictEntity(): void + { + $user = model(StrictUserModel::class)->find(1); + + $this->assertInstanceOf(StrictUser::class, $user); + $this->assertIsArray($user->posts); + $this->assertInstanceOf(Post::class, $user->posts[0]); + $this->assertSame('Title 1', $user->posts[0]->title); + } +} diff --git a/tests/_support/Entities/StrictEntity.php b/tests/_support/Entities/StrictEntity.php new file mode 100644 index 0000000..6b8bd4e --- /dev/null +++ b/tests/_support/Entities/StrictEntity.php @@ -0,0 +1,60 @@ +filterAttributes($data) : []); + } + + public function fill(?array $data = null) + { + return parent::fill(is_array($data) ? $this->filterAttributes($data) : []); + } + + public function injectRawData(array $data) + { + return parent::injectRawData(array_merge($this->attributes, $this->filterAttributes($data))); + } + + public function __set(string $key, $value = null) + { + $attribute = $this->mapProperty($key); + + if (! array_key_exists($attribute, $this->attributes)) { + throw new LogicException(sprintf('Attribute "%s" is not defined.', $attribute)); + } + + parent::__set($key, $value); + } + + public function __get(string $key) + { + $attribute = $this->mapProperty($key); + + if (! array_key_exists($attribute, $this->attributes)) { + throw new LogicException(sprintf('Attribute "%s" is not defined.', $attribute)); + } + + return parent::__get($key); + } + + /** + * @param array $attributes + * + * @return array + */ + protected function filterAttributes(array $attributes): array + { + return array_intersect_key($attributes, $this->attributes); + } +} diff --git a/tests/_support/Entities/StrictUser.php b/tests/_support/Entities/StrictUser.php new file mode 100644 index 0000000..106ca3c --- /dev/null +++ b/tests/_support/Entities/StrictUser.php @@ -0,0 +1,24 @@ + null, + 'username' => null, + 'company_id' => null, + 'country_id' => null, + 'created_at' => null, + 'updated_at' => null, + ]; + protected $datamap = []; + protected $dates = ['created_at', 'updated_at']; + protected $casts = []; +} diff --git a/tests/_support/Models/StrictUserModel.php b/tests/_support/Models/StrictUserModel.php new file mode 100644 index 0000000..6c76d8c --- /dev/null +++ b/tests/_support/Models/StrictUserModel.php @@ -0,0 +1,66 @@ +initRelations(); + } + + public function profile(): Relation + { + return $this->hasOne(ProfileModel::class); + } + + public function posts(): Relation + { + return $this->hasMany(PostModel::class); + } +} From 9b98ea069ff13de182ad4221f55f7b02f133b8ad Mon Sep 17 00:00:00 2001 From: michalsn Date: Tue, 10 Mar 2026 20:46:51 +0100 Subject: [PATCH 2/3] refactor --- src/Traits/HasLazyRelations.php | 39 +++++++++++++++------------------ 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/src/Traits/HasLazyRelations.php b/src/Traits/HasLazyRelations.php index 461552d..8b92d10 100644 --- a/src/Traits/HasLazyRelations.php +++ b/src/Traits/HasLazyRelations.php @@ -5,6 +5,7 @@ namespace Michalsn\CodeIgniterNestedModel\Traits; use CodeIgniter\Autoloader\FileLocatorInterface; +use CodeIgniter\Model; use Michalsn\CodeIgniterNestedModel\Enums\RelationTypes; trait HasLazyRelations @@ -14,15 +15,20 @@ trait HasLazyRelations */ private array $handledRelations = []; + private ?Model $relationModel = null; + private bool $relationModelResolved = false; + public function __get(string $key) { if (array_key_exists($key, $this->attributes)) { return parent::__get($key); } - if ($this->isRelation($key)) { + $model = $this->getRelationModel(); + + if ($model !== null && method_exists($model, $key)) { if (! isset($this->handledRelations[$key]) && ! array_key_exists($key, $this->attributes)) { - $this->handleRelation($key); + $this->handleRelation($key, $model); $this->handledRelations[$key] = true; } @@ -35,20 +41,8 @@ public function __get(string $key) /** * Load relation for the property. */ - private function handleRelation(string $name) + private function handleRelation(string $name, Model $model) { - $className = $this->findModelClass(); - - if ($className === null) { - return null; - } - - $model = model($className); - - if (! method_exists($model, $name)) { - return null; - } - $relation = $model->{$name}(); if (in_array($relation->type, [RelationTypes::hasOne, RelationTypes::belongsTo], true)) { @@ -72,17 +66,20 @@ private function handleRelation(string $name) } /** - * Check if the property is a declared relation on the matching model. + * Resolve the matching model once for the lifetime of the entity instance. */ - private function isRelation(string $name): bool + private function getRelationModel(): ?Model { + if ($this->relationModelResolved) { + return $this->relationModel; + } + $className = $this->findModelClass(); - if ($className === null) { - return false; - } + $this->relationModel = $className === null ? null : model($className); + $this->relationModelResolved = true; - return method_exists(model($className), $name); + return $this->relationModel; } /** From 60f8f617e6881c501972765385789bdf65575dd2 Mon Sep 17 00:00:00 2001 From: michalsn Date: Tue, 10 Mar 2026 20:48:25 +0100 Subject: [PATCH 3/3] phpstan baseline --- phpstan-baseline.neon | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index d996750..ed65e61 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -120,6 +120,12 @@ parameters: count: 5 path: tests/_support/Models/ProfileModel.php + - + message: '#^Call to an undefined method CodeIgniter\\Model\:\:getTable\(\)\.$#' + identifier: method.notFound + count: 5 + path: tests/_support/Models/StrictUserModel.php + - message: '#^Call to an undefined method CodeIgniter\\Model\:\:getTable\(\)\.$#' identifier: method.notFound