diff --git a/packages/database/src/Mappers/SelectModelMapper.php b/packages/database/src/Mappers/SelectModelMapper.php index 9143c8ae2..1f03bd110 100644 --- a/packages/database/src/Mappers/SelectModelMapper.php +++ b/packages/database/src/Mappers/SelectModelMapper.php @@ -83,13 +83,15 @@ private function values(ModelInspector $model, array $data): array $relation = $model->getRelation($key); if ($relation instanceof BelongsTo || $relation instanceof HasOne || $relation instanceof HasOneThrough) { - if ($relation->property->isNullable() && array_filter($data[$relation->name] ?? []) === []) { - $data[$relation->name] = null; - } elseif (is_array($data[$relation->name] ?? null)) { + if (is_array($data[$relation->name] ?? null)) { $relationModel = inspect($relation); $data[$relation->name] = $this->values($relationModel, $data[$relation->name]); } + if ($relation->property->isNullable() && $this->relationDataIsEmpty($data[$relation->name] ?? null)) { + $data[$relation->name] = null; + } + continue; } @@ -108,6 +110,25 @@ private function values(ModelInspector $model, array $data): array return $data; } + private function relationDataIsEmpty(mixed $value): bool + { + if ($value === null) { + return true; + } + + if (! is_array($value)) { + return false; + } + + foreach ($value as $item) { + if (! $this->relationDataIsEmpty($item)) { + return false; + } + } + + return true; + } + public function normalizeRow(ModelInspector $model, array $row, MutableArray $data): array { $mainTable = $model->getTableName(); diff --git a/tests/Integration/Database/Mappers/SelectModelMapperTest.php b/tests/Integration/Database/Mappers/SelectModelMapperTest.php index 1f7bad643..209469368 100644 --- a/tests/Integration/Database/Mappers/SelectModelMapperTest.php +++ b/tests/Integration/Database/Mappers/SelectModelMapperTest.php @@ -3,8 +3,11 @@ namespace Tests\Tempest\Integration\Database\Mappers; use PHPUnit\Framework\Attributes\Test; +use Tempest\Database\BelongsTo; use Tempest\Database\BelongsToMany; +use Tempest\Database\Eager; use Tempest\Database\Exceptions\RelationWasMissing; +use Tempest\Database\HasOne; use Tempest\Database\IsDatabaseModel; use Tempest\Database\Mappers\SelectModelMapper; use Tempest\Database\Migrations\CreateMigrationsTable; @@ -362,6 +365,25 @@ public function array_of_serialized_enums(): void $this->assertSame(EnumToBeMappedToArray::USER, $users[0]->roles[1]); } + #[Test] + public function nullable_eager_relation_with_nested_empty_relations_maps_to_null(): void + { + $users = map([ + [ + 'users.id' => 1, + 'users.name' => 'Rado', + 'profile.id' => null, + 'profile.createdBy.id' => null, + 'profile.createdBy.name' => null, + 'profile.updatedBy.id' => null, + 'profile.updatedBy.name' => null, + ], + ])->with(mapper: SelectModelMapper::class)->to(to: UserWithNullableEagerProfile::class); + + $this->assertSame(expected: 'Rado', actual: $users[0]->name); + $this->assertNull(actual: $users[0]->profile); + } + private function data(): array { return [ @@ -505,6 +527,40 @@ final class ObjectWithArrayEnumProperty public array $roles; } +#[Table(name: 'users')] +final class UserWithNullableEagerProfile +{ + use IsDatabaseModel; + + public string $name; + + #[Eager] + #[HasOne] + public ?NullableEagerProfile $profile = null; +} + +#[Table(name: 'profiles')] +final class NullableEagerProfile +{ + use IsDatabaseModel; + + #[Eager] + #[BelongsTo] + public ?NullableEagerUserLookup $createdBy = null; + + #[Eager] + #[BelongsTo] + public ?NullableEagerUserLookup $updatedBy = null; +} + +#[Table(name: 'users')] +final class NullableEagerUserLookup +{ + use IsDatabaseModel; + + public string $name; +} + enum EnumToBeMappedToArray: string { case ADMIN = 'admin';