diff --git a/packages/database/src/IsDatabaseModel.php b/packages/database/src/IsDatabaseModel.php index ed8a9b3ac0..71def875e6 100644 --- a/packages/database/src/IsDatabaseModel.php +++ b/packages/database/src/IsDatabaseModel.php @@ -347,13 +347,11 @@ public function __get(string $name): mixed return $property->getValue($this); } - $type = $property->getType(); - - if ($type->isRelation()) { + if (inspect(model: $this)->isRelation(name: $name)) { throw new RelationWasMissing($this, $name); } - if ($type->isBuiltIn()) { + if ($property->getType()->isBuiltIn()) { throw new ValueWasMissing($this, $name); } diff --git a/packages/database/src/Mappers/SelectModelMapper.php b/packages/database/src/Mappers/SelectModelMapper.php index acfac74a3c..9143c8ae2c 100644 --- a/packages/database/src/Mappers/SelectModelMapper.php +++ b/packages/database/src/Mappers/SelectModelMapper.php @@ -5,6 +5,7 @@ use Tempest\Database\BelongsTo; use Tempest\Database\BelongsToMany; use Tempest\Database\Builder\ModelInspector; +use Tempest\Database\Eager; use Tempest\Database\HasMany; use Tempest\Database\HasManyThrough; use Tempest\Database\HasOne; @@ -48,13 +49,7 @@ public function map(mixed $from, mixed $to): array foreach ($objects as $i => $object) { foreach ($model->getRelations() as $relation) { - // When a nullable BelongsTo relation wasn't loaded, we need to make sure to unset it if it has a default value. - // If we wouldn't do this, the default value would overwrite the "unloaded" value on the next time saving the model - if (! $relation instanceof BelongsTo) { - continue; - } - - if (! $relation->property->isNullable()) { + if ($relation->property->hasAttribute(Eager::class)) { continue; } @@ -87,9 +82,12 @@ private function values(ModelInspector $model, array $data): array foreach ($data as $key => $value) { $relation = $model->getRelation($key); - if ($relation instanceof BelongsTo) { + 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)) { + $relationModel = inspect($relation); + $data[$relation->name] = $this->values($relationModel, $data[$relation->name]); } continue; @@ -142,6 +140,10 @@ public function normalizeRow(ModelInspector $model, array $row, MutableArray $da $originalKey .= $relation->name . '.'; if ($hasManyId === null) { + if ($data->get($key . $relation->name) === null) { + $data->set($key . $relation->name, []); + } + break; } diff --git a/tests/Fixtures/Controllers/ValidationController.php b/tests/Fixtures/Controllers/ValidationController.php index d1d118899b..d51dde6de0 100644 --- a/tests/Fixtures/Controllers/ValidationController.php +++ b/tests/Fixtures/Controllers/ValidationController.php @@ -55,7 +55,7 @@ public function book(Book $book): Response #[Post(uri: '/test-validation-responses-json/{book}')] public function updateBook(BookRequest $request, Book $book): Response { - $book->load('author'); + $book->load('author', 'chapters', 'isbn'); $book->update(title: $request->get('title')); diff --git a/tests/Fixtures/Modules/Books/Models/TagWithEagerBooks.php b/tests/Fixtures/Modules/Books/Models/TagWithEagerBooks.php new file mode 100644 index 0000000000..3e1a7b4ffb --- /dev/null +++ b/tests/Fixtures/Modules/Books/Models/TagWithEagerBooks.php @@ -0,0 +1,23 @@ +assertCount(1, $authors[0]->books); } + public function test_lazy_belongs_to_many_not_eager_loaded_is_unset(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + CreateTagTable::class, + CreateBookTagTable::class, + ); + + Tag::create( + label: 'PHP', + books: [ + Book::new(title: 'Book One'), + Book::new(title: 'Book Two'), + ], + ); + + $tags = TagWithLazyBooks::select()->all(); + + $tag = $tags[0]; + + $this->assertSame(expected: 'PHP', actual: $tag->label); + $this->assertFalse(condition: inspect(model: $tag)->isRelationLoaded(relation: 'books')); + + $this->assertCount(expectedCount: 2, haystack: $tag->books); + $this->assertSame(expected: 'Book One', actual: $tag->books[0]->title); + $this->assertSame(expected: 'Book Two', actual: $tag->books[1]->title); + } + + public function test_untagged_belongs_to_many_not_loaded_is_unset(): void + { + $data = [ + [ + 'tags.id' => 1, + 'tags.label' => 'PHP', + ], + ]; + + $tags = map($data)->with(mapper: SelectModelMapper::class)->to(to: Tag::class); + + $tag = $tags[0]; + + $this->assertSame(expected: 'PHP', actual: $tag->label); + $this->assertFalse(condition: inspect(model: $tag)->isRelationLoaded(relation: 'books')); + + $this->expectException(RelationWasMissing::class); + // Accessing unset property triggers RelationWasMissing + /** @phpstan-ignore expr.resultUnused */ + $tag->books; + } + + public function test_has_many_through_not_loaded_is_unset(): void + { + $data = [ + [ + 'tags.id' => 1, + 'tags.label' => 'PHP', + ], + ]; + + $tags = map($data)->with(mapper: SelectModelMapper::class)->to(to: Tag::class); + + $tag = $tags[0]; + + $this->assertFalse(condition: inspect(model: $tag)->isRelationLoaded(relation: 'reviewers')); + + $this->expectException(RelationWasMissing::class); + // Accessing unset property triggers RelationWasMissing + /** @phpstan-ignore expr.resultUnused */ + $tag->reviewers; + } + + public function test_has_one_through_not_loaded_is_unset(): void + { + $data = [ + [ + 'tags.id' => 1, + 'tags.label' => 'PHP', + ], + ]; + + $tags = map($data)->with(mapper: SelectModelMapper::class)->to(to: Tag::class); + + $tag = $tags[0]; + + $this->assertFalse(condition: inspect(model: $tag)->isRelationLoaded(relation: 'topReviewer')); + + $this->expectException(RelationWasMissing::class); + // Accessing unset property triggers RelationWasMissing + /** @phpstan-ignore expr.resultUnused */ + $tag->topReviewer; + } + + public function test_eager_belongs_to_many_loaded_has_books(): void + { + $data = [ + [ + 'tags.id' => 1, + 'tags.label' => 'PHP', + 'books.id' => 1, + 'books.title' => 'LOTR 1', + ], + [ + 'tags.id' => 1, + 'tags.label' => 'PHP', + 'books.id' => 2, + 'books.title' => 'LOTR 2', + ], + ]; + + $tags = map($data)->with(mapper: SelectModelMapper::class)->to(to: TagWithEagerBooks::class); + + $tag = $tags[0]; + + $this->assertSame(expected: 'PHP', actual: $tag->label); + $this->assertTrue(condition: inspect(model: $tag)->isRelationLoaded(relation: 'books')); + $this->assertCount(expectedCount: 2, haystack: $tag->books); + $this->assertSame(expected: 'LOTR 1', actual: $tag->books[0]->title); + $this->assertSame(expected: 'LOTR 2', actual: $tag->books[1]->title); + } + + public function test_belongs_to_many_loaded_with_no_results_returns_empty_array(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + CreateTagTable::class, + CreateBookTagTable::class, + ); + + Tag::create(label: 'PHP'); + + $tag = TagWithLazyBooks::select()->with('books')->first(); + + $this->assertSame(expected: 'PHP', actual: $tag->label); + $this->assertTrue(condition: inspect(model: $tag)->isRelationLoaded(relation: 'books')); + $this->assertSame(expected: [], actual: $tag->books); + } + + public function test_has_one_not_loaded_is_unset(): void + { + $data = [ + [ + 'books.id' => 1, + 'books.title' => 'LOTR', + ], + ]; + + $books = map($data)->with(mapper: SelectModelMapper::class)->to(to: Book::class); + + $book = $books[0]; + + $this->assertSame(expected: 'LOTR', actual: $book->title); + $this->assertFalse(condition: inspect(model: $book)->isRelationLoaded(relation: 'isbn')); + + $this->expectException(RelationWasMissing::class); + // Accessing unset property triggers RelationWasMissing + /** @phpstan-ignore expr.resultUnused */ + $book->isbn; + } + + public function test_has_many_not_loaded_is_unset(): void + { + $data = [ + [ + 'books.id' => 1, + 'books.title' => 'LOTR', + ], + ]; + + $books = map($data)->with(mapper: SelectModelMapper::class)->to(to: Book::class); + + $book = $books[0]; + + $this->assertSame(expected: 'LOTR', actual: $book->title); + $this->assertFalse(condition: inspect(model: $book)->isRelationLoaded(relation: 'chapters')); + + $this->expectException(RelationWasMissing::class); + // Accessing unset property triggers RelationWasMissing + /** @phpstan-ignore expr.resultUnused */ + $book->chapters; + } + + public function test_nested_belongs_to_many_on_belongs_to(): void + { + $data = [ + [ + 'parent_with_role.id' => 1, + 'parent_with_role.name' => 'John', + 'role.id' => 1, + 'role.name' => 'admin', + 'role.permissions.id' => 1, + 'role.permissions.label' => 'create', + ], + [ + 'parent_with_role.id' => 1, + 'parent_with_role.name' => 'John', + 'role.id' => 1, + 'role.name' => 'admin', + 'role.permissions.id' => 2, + 'role.permissions.label' => 'delete', + ], + ]; + + $users = map($data)->with(mapper: SelectModelMapper::class)->to(to: ParentWithRole::class); + + $user = $users[0]; + + $this->assertSame(expected: 'John', actual: $user->name); + $this->assertSame(expected: 'admin', actual: $user->role->name); + $this->assertCount(expectedCount: 2, haystack: $user->role->permissions); + $this->assertSame(expected: 'create', actual: $user->role->permissions[0]->label); + $this->assertSame(expected: 'delete', actual: $user->role->permissions[1]->label); + } + public function test_array_of_serialized_enums(): void { $users = map([['id' => 1, 'roles' => json_encode(['admin', 'user'])]]) @@ -262,3 +495,33 @@ enum EnumToBeMappedToArray: string case ADMIN = 'admin'; case USER = 'user'; } + +#[Table(name: 'parent_with_role')] +final class ParentWithRole +{ + use IsDatabaseModel; + + public string $name; + + public ?RoleWithPermissions $role = null; +} + +#[Table(name: 'roles')] +final class RoleWithPermissions +{ + use IsDatabaseModel; + + public string $name; + + /** @var \Tests\Tempest\Integration\Database\Mappers\Permission[] */ + #[BelongsToMany] + public array $permissions = []; +} + +#[Table(name: 'permissions')] +final class Permission +{ + use IsDatabaseModel; + + public string $label; +} diff --git a/tests/Integration/Http/ValidationResponseTest.php b/tests/Integration/Http/ValidationResponseTest.php index 918ed888a2..77104e5cec 100644 --- a/tests/Integration/Http/ValidationResponseTest.php +++ b/tests/Integration/Http/ValidationResponseTest.php @@ -11,6 +11,7 @@ use Tests\Tempest\Fixtures\Migrations\CreateAuthorTable; use Tests\Tempest\Fixtures\Migrations\CreateBookTable; use Tests\Tempest\Fixtures\Migrations\CreateChapterTable; +use Tests\Tempest\Fixtures\Migrations\CreateIsbnTable; use Tests\Tempest\Fixtures\Migrations\CreatePublishersTable; use Tests\Tempest\Fixtures\Modules\Books\Models\Author; use Tests\Tempest\Fixtures\Modules\Books\Models\Book; @@ -60,6 +61,7 @@ public function test_update_book(): void CreateAuthorTable::class, CreateBookTable::class, CreateChapterTable::class, + CreateIsbnTable::class, ); $book = Book::create(