From 6ec87ab8aa833175f1919851c8bbc0c9a679de03 Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Sat, 21 Mar 2026 17:12:05 +0000 Subject: [PATCH 1/8] test: add belongs_to_many unset relation test --- .../Mappers/SelectModelMapperTest.php | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/tests/Integration/Database/Mappers/SelectModelMapperTest.php b/tests/Integration/Database/Mappers/SelectModelMapperTest.php index 6b3f4c63b4..0a81133f98 100644 --- a/tests/Integration/Database/Mappers/SelectModelMapperTest.php +++ b/tests/Integration/Database/Mappers/SelectModelMapperTest.php @@ -2,11 +2,22 @@ namespace Tests\Tempest\Integration\Database\Mappers; +use Tempest\Database\Exceptions\RelationWasMissing; use Tempest\Database\Mappers\SelectModelMapper; +use Tempest\Database\Migrations\CreateMigrationsTable; +use Tests\Tempest\Fixtures\Migrations\CreateAuthorTable; +use Tests\Tempest\Fixtures\Migrations\CreateBookTable; +use Tests\Tempest\Fixtures\Migrations\CreateBookTagTable; +use Tests\Tempest\Fixtures\Migrations\CreatePublishersTable; +use Tests\Tempest\Fixtures\Migrations\CreateTagTable; use Tests\Tempest\Fixtures\Modules\Books\Models\Author; use Tests\Tempest\Fixtures\Modules\Books\Models\Book; +use Tests\Tempest\Fixtures\Modules\Books\Models\Tag; +use Tests\Tempest\Fixtures\Modules\Books\Models\TagWithEagerBooks; +use Tests\Tempest\Fixtures\Modules\Books\Models\TagWithLazyBooks; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; +use function Tempest\Database\inspect; use function Tempest\Mapper\map; final class SelectModelMapperTest extends FrameworkIntegrationTestCase @@ -103,6 +114,107 @@ public function test_deeply_nested_has_many_map(): void $this->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_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_array_of_serialized_enums(): void { $users = map([['id' => 1, 'roles' => json_encode(['admin', 'user'])]]) From b4d15fd95c327a8f0101b41defb67d9316be9417 Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Sat, 21 Mar 2026 17:12:15 +0000 Subject: [PATCH 2/8] feat: unset default BelongsToMany relations when not loaded --- .../src/Mappers/SelectModelMapper.php | 15 ++-- .../Mappers/SelectModelMapperTest.php | 81 +++++++++++++++++++ 2 files changed, 88 insertions(+), 8 deletions(-) diff --git a/packages/database/src/Mappers/SelectModelMapper.php b/packages/database/src/Mappers/SelectModelMapper.php index acfac74a3c..8abf51a1a9 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,7 +82,7 @@ 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; } @@ -142,6 +137,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/Integration/Database/Mappers/SelectModelMapperTest.php b/tests/Integration/Database/Mappers/SelectModelMapperTest.php index 0a81133f98..9c4116f385 100644 --- a/tests/Integration/Database/Mappers/SelectModelMapperTest.php +++ b/tests/Integration/Database/Mappers/SelectModelMapperTest.php @@ -167,6 +167,48 @@ public function test_untagged_belongs_to_many_not_loaded_is_unset(): void $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 = [ @@ -215,6 +257,45 @@ public function test_belongs_to_many_loaded_with_no_results_returns_empty_array( $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')); + } + public function test_array_of_serialized_enums(): void { $users = map([['id' => 1, 'roles' => json_encode(['admin', 'user'])]]) From c61ff66770bcf8dd84763b84e27b48072cc8a763 Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Sat, 21 Mar 2026 17:32:58 +0000 Subject: [PATCH 3/8] test: add tag models with eager and lazy loading --- .../Books/Models/TagWithEagerBooks.php | 23 +++++++++++++++++++ .../Modules/Books/Models/TagWithLazyBooks.php | 23 +++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 tests/Fixtures/Modules/Books/Models/TagWithEagerBooks.php create mode 100644 tests/Fixtures/Modules/Books/Models/TagWithLazyBooks.php 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 @@ + Date: Sat, 21 Mar 2026 17:57:59 +0000 Subject: [PATCH 4/8] refactor: simplify property type checking logic --- packages/database/src/IsDatabaseModel.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) 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); } From 75046c229fbaa075b81bf181eae5a8ccab6be2f9 Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Sat, 21 Mar 2026 19:14:45 +0000 Subject: [PATCH 5/8] test: add CreateIsbnTable migration to validation test --- tests/Integration/Http/ValidationResponseTest.php | 2 ++ 1 file changed, 2 insertions(+) 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( From 16d248d0896b6c007b86d1a6c6101f82932075bf Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Sat, 21 Mar 2026 19:14:49 +0000 Subject: [PATCH 6/8] fix: load additional book relations in validation test --- tests/Fixtures/Controllers/ValidationController.php | 2 +- tests/Integration/Database/Mappers/SelectModelMapperTest.php | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) 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/Integration/Database/Mappers/SelectModelMapperTest.php b/tests/Integration/Database/Mappers/SelectModelMapperTest.php index 9c4116f385..b797e40681 100644 --- a/tests/Integration/Database/Mappers/SelectModelMapperTest.php +++ b/tests/Integration/Database/Mappers/SelectModelMapperTest.php @@ -294,6 +294,11 @@ public function test_has_many_not_loaded_is_unset(): void $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_array_of_serialized_enums(): void From d11c59e5e9d7681287bfe8cb378a20e850d50233 Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Sat, 21 Mar 2026 21:10:20 +0000 Subject: [PATCH 7/8] test: add nested belongs to many relation test --- .../Mappers/SelectModelMapperTest.php | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/tests/Integration/Database/Mappers/SelectModelMapperTest.php b/tests/Integration/Database/Mappers/SelectModelMapperTest.php index b797e40681..fb8905f032 100644 --- a/tests/Integration/Database/Mappers/SelectModelMapperTest.php +++ b/tests/Integration/Database/Mappers/SelectModelMapperTest.php @@ -2,9 +2,12 @@ namespace Tests\Tempest\Integration\Database\Mappers; +use Tempest\Database\BelongsToMany; use Tempest\Database\Exceptions\RelationWasMissing; +use Tempest\Database\IsDatabaseModel; use Tempest\Database\Mappers\SelectModelMapper; use Tempest\Database\Migrations\CreateMigrationsTable; +use Tempest\Database\Table; use Tests\Tempest\Fixtures\Migrations\CreateAuthorTable; use Tests\Tempest\Fixtures\Migrations\CreateBookTable; use Tests\Tempest\Fixtures\Migrations\CreateBookTagTable; @@ -301,6 +304,38 @@ public function test_has_many_not_loaded_is_unset(): void $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'])]]) @@ -460,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; +} From e733691ae932870e470813b8256748926358bc57 Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Sat, 21 Mar 2026 21:10:30 +0000 Subject: [PATCH 8/8] feat: map relation data for belongs-to and has-one --- packages/database/src/Mappers/SelectModelMapper.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/database/src/Mappers/SelectModelMapper.php b/packages/database/src/Mappers/SelectModelMapper.php index 8abf51a1a9..9143c8ae2c 100644 --- a/packages/database/src/Mappers/SelectModelMapper.php +++ b/packages/database/src/Mappers/SelectModelMapper.php @@ -85,6 +85,9 @@ private function values(ModelInspector $model, array $data): array 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;