Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 35 additions & 18 deletions src/Traits/HasLazyRelations.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Michalsn\CodeIgniterNestedModel\Traits;

use CodeIgniter\Autoloader\FileLocatorInterface;
use CodeIgniter\Model;
use Michalsn\CodeIgniterNestedModel\Enums\RelationTypes;

trait HasLazyRelations
Expand All @@ -14,35 +15,34 @@ trait HasLazyRelations
*/
private array $handledRelations = [];

private ?Model $relationModel = null;
private bool $relationModelResolved = false;

public function __get(string $key)
{
$result = parent::__get($key);
if (array_key_exists($key, $this->attributes)) {
return parent::__get($key);
}

$model = $this->getRelationModel();

if ($result === null && ! isset($this->handledRelations[$key])) {
$result = $this->handleRelation($key);
$this->handledRelations[$key] = true;
if ($model !== null && method_exists($model, $key)) {
if (! isset($this->handledRelations[$key]) && ! array_key_exists($key, $this->attributes)) {
$this->handleRelation($key, $model);
$this->handledRelations[$key] = true;
}

return $this->attributes[$key] ?? null;
}

return $result;
return parent::__get($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)) {
Expand All @@ -65,6 +65,23 @@ private function handleRelation(string $name)
return $this->attributes[$name];
}

/**
* Resolve the matching model once for the lifetime of the entity instance.
*/
private function getRelationModel(): ?Model
{
if ($this->relationModelResolved) {
return $this->relationModel;
}

$className = $this->findModelClass();

$this->relationModel = $className === null ? null : model($className);
$this->relationModelResolved = true;

return $this->relationModel;
}

/**
* Search for the proper model.
*
Expand Down
29 changes: 27 additions & 2 deletions src/Traits/HasRelations.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
}
}
}
}
Expand All @@ -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.
*/
Expand Down
63 changes: 63 additions & 0 deletions tests/StrictEntityRelationsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

declare(strict_types=1);

namespace Tests;

use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\DatabaseTestTrait;
use Tests\Support\Database\Seeds\SeedTests;
use Tests\Support\Entities\Post;
use Tests\Support\Entities\Profile;
use Tests\Support\Entities\StrictUser;
use Tests\Support\Models\StrictUserModel;

/**
* @internal
*/
final class StrictEntityRelationsTest extends CIUnitTestCase
{
use DatabaseTestTrait;

protected $refresh = true;
protected $namespace;
protected $seed = SeedTests::class;

public function testEagerLoadsHasOneRelationOnStrictEntity(): void
{
$user = model(StrictUserModel::class)->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);
}
}
60 changes: 60 additions & 0 deletions tests/_support/Entities/StrictEntity.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

declare(strict_types=1);

namespace Tests\Support\Entities;

use CodeIgniter\Entity\Entity;
use LogicException;

abstract class StrictEntity extends Entity
{
protected $dates = [];

public function __construct(?array $data = null)
{
parent::__construct(is_array($data) ? $this->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<string, mixed> $attributes
*
* @return array<string, mixed>
*/
protected function filterAttributes(array $attributes): array
{
return array_intersect_key($attributes, $this->attributes);
}
}
24 changes: 24 additions & 0 deletions tests/_support/Entities/StrictUser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace Tests\Support\Entities;

use Michalsn\CodeIgniterNestedModel\Traits\HasLazyRelations;

class StrictUser extends StrictEntity
{
use HasLazyRelations;

protected $attributes = [
'id' => 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 = [];
}
66 changes: 66 additions & 0 deletions tests/_support/Models/StrictUserModel.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

declare(strict_types=1);

namespace Tests\Support\Models;

use CodeIgniter\Model;
use Michalsn\CodeIgniterNestedModel\Relation;
use Michalsn\CodeIgniterNestedModel\Traits\HasRelations;
use Tests\Support\Entities\StrictUser;

class StrictUserModel extends Model
{
use HasRelations;

protected $table = 'users';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = StrictUser::class;
protected $useSoftDeletes = false;
protected $protectFields = true;
protected $allowedFields = ['username', 'company_id', 'country_id'];
protected bool $allowEmptyInserts = false;
protected bool $updateOnlyChanged = true;
protected array $casts = [];
protected array $castHandlers = [];

// Dates
protected $useTimestamps = true;
protected $dateFormat = 'datetime';
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $deletedField = 'deleted_at';

// Validation
protected $validationRules = [];
protected $validationMessages = [];
protected $skipValidation = false;
protected $cleanValidationRules = true;

// Callbacks
protected $allowCallbacks = true;
protected $beforeInsert = [];
protected $afterInsert = [];
protected $beforeUpdate = [];
protected $afterUpdate = [];
protected $beforeFind = [];
protected $afterFind = [];
protected $beforeDelete = [];
protected $afterDelete = [];

protected function initialize(): void
{
$this->initRelations();
}

public function profile(): Relation
{
return $this->hasOne(ProfileModel::class);
}

public function posts(): Relation
{
return $this->hasMany(PostModel::class);
}
}