From aaa3bbc513bca665c92d5255de80b641f95d2295 Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Sat, 4 Apr 2026 02:25:09 +0100 Subject: [PATCH 01/31] test(database): add tests for HasMany query() method --- .../Database/Builder/IsDatabaseModelTest.php | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/tests/Integration/Database/Builder/IsDatabaseModelTest.php b/tests/Integration/Database/Builder/IsDatabaseModelTest.php index b77566f31..322ca6c80 100644 --- a/tests/Integration/Database/Builder/IsDatabaseModelTest.php +++ b/tests/Integration/Database/Builder/IsDatabaseModelTest.php @@ -309,6 +309,85 @@ public function test_has_many_relations(): void $this->assertCount(2, $author->books); } + public function test_query_has_many_returns_scoped_results(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + ); + + $authorA = Author::create( + name: 'Author A', + type: AuthorType::A, + ); + + $authorB = Author::create( + name: 'Author B', + type: AuthorType::B, + ); + + Book::create(title: 'Book 1', author: $authorA); + Book::create(title: 'Book 2', author: $authorA); + Book::create(title: 'Book 3', author: $authorA); + Book::create(title: 'Other Book', author: $authorB); + + $books = $authorA->query('books')->all(); + + $this->assertCount(3, $books); + $this->assertContainsOnlyInstancesOf(Book::class, $books); + } + + public function test_query_has_many_supports_where(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + ); + + $author = Author::create( + name: 'Author A', + type: AuthorType::A, + ); + + Book::create(title: 'Alpha', author: $author); + Book::create(title: 'Beta', author: $author); + Book::create(title: 'Gamma', author: $author); + + $books = $author->query('books') + ->whereField('title', 'Beta') + ->all(); + + $this->assertCount(1, $books); + $this->assertSame('Beta', $books[0]->title); + } + + public function test_query_has_many_supports_limit(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + ); + + $author = Author::create( + name: 'Author A', + type: AuthorType::A, + ); + + Book::create(title: 'Book 1', author: $author); + Book::create(title: 'Book 2', author: $author); + Book::create(title: 'Book 3', author: $author); + + $books = $author->query('books')->limit(2)->all(); + + $this->assertCount(2, $books); + } + public function test_has_many_through_relation(): void { $this->database->migrate( From e16300a82408c496d6f4d9e92be4cd92aa568247 Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Sat, 4 Apr 2026 02:25:13 +0100 Subject: [PATCH 02/31] feat(database): add query() method for HasMany relations on models --- packages/database/src/IsDatabaseModel.php | 27 +++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/database/src/IsDatabaseModel.php b/packages/database/src/IsDatabaseModel.php index 873439ff5..725825717 100644 --- a/packages/database/src/IsDatabaseModel.php +++ b/packages/database/src/IsDatabaseModel.php @@ -13,6 +13,7 @@ use Tempest\Reflection\PropertyReflector; use Tempest\Router\IsBindingValue; use Tempest\Validation\SkipValidation; +use InvalidArgumentException; use UnitEnum; use function Tempest\Support\arr; @@ -264,6 +265,32 @@ public function refresh(): self return $this; } + /** + * Returns a query builder scoped to a HasMany relation on this model. + */ + public function query(string $relation): SelectQueryBuilder + { + $model = inspect($this); + $hasMany = $model->getHasMany($relation); + + if (! $hasMany instanceof HasMany) { + throw new InvalidArgumentException( + sprintf('Property "%s" is not a HasMany relation on %s.', $relation, $model->getName()), + ); + } + + $relatedClassName = $hasMany->property->getIterableType()->getName(); + $parentTable = $model->getTableName(); + $parentPK = $model->getPrimaryKey(); + $fk = $hasMany->ownerJoin ?? str($parentTable)->singularizeLastWord() . '_' . $parentPK; + $primaryKeyValue = $model->getPrimaryKeyProperty()->getValue($this); + + return query($relatedClassName) + ->onDatabase($this->onDatabase) + ->select() + ->whereField($fk, $primaryKeyValue); + } + /** * Loads the specified relations on the model instance. */ From 167082671d39a677c17d9a5d1d8a3dabf6a9fc71 Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Sat, 4 Apr 2026 02:34:29 +0100 Subject: [PATCH 03/31] test(database): add tests for HasManyThrough query() method --- .../Database/Builder/IsDatabaseModelTest.php | 65 +++++++++++++++++-- 1 file changed, 61 insertions(+), 4 deletions(-) diff --git a/tests/Integration/Database/Builder/IsDatabaseModelTest.php b/tests/Integration/Database/Builder/IsDatabaseModelTest.php index 322ca6c80..2a10ae93e 100644 --- a/tests/Integration/Database/Builder/IsDatabaseModelTest.php +++ b/tests/Integration/Database/Builder/IsDatabaseModelTest.php @@ -45,10 +45,16 @@ use Tests\Tempest\Fixtures\Models\AWithVirtual; use Tests\Tempest\Fixtures\Models\B; use Tests\Tempest\Fixtures\Models\C; +use Tests\Tempest\Fixtures\Migrations\CreateBookReviewTable; +use Tests\Tempest\Fixtures\Migrations\CreateReviewerTable; +use Tests\Tempest\Fixtures\Migrations\CreateTagTable; use Tests\Tempest\Fixtures\Modules\Books\Models\Author; use Tests\Tempest\Fixtures\Modules\Books\Models\AuthorType; use Tests\Tempest\Fixtures\Modules\Books\Models\Book; +use Tests\Tempest\Fixtures\Modules\Books\Models\BookReview; use Tests\Tempest\Fixtures\Modules\Books\Models\Isbn; +use Tests\Tempest\Fixtures\Modules\Books\Models\Reviewer; +use Tests\Tempest\Fixtures\Modules\Books\Models\Tag; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; use function Tempest\Database\query; @@ -333,7 +339,7 @@ public function test_query_has_many_returns_scoped_results(): void Book::create(title: 'Book 3', author: $authorA); Book::create(title: 'Other Book', author: $authorB); - $books = $authorA->query('books')->all(); + $books = $authorA->query(relation: 'books')->all(); $this->assertCount(3, $books); $this->assertContainsOnlyInstancesOf(Book::class, $books); @@ -357,8 +363,8 @@ public function test_query_has_many_supports_where(): void Book::create(title: 'Beta', author: $author); Book::create(title: 'Gamma', author: $author); - $books = $author->query('books') - ->whereField('title', 'Beta') + $books = $author->query(relation: 'books') + ->whereField(field: 'title', value: 'Beta') ->all(); $this->assertCount(1, $books); @@ -383,11 +389,62 @@ public function test_query_has_many_supports_limit(): void Book::create(title: 'Book 2', author: $author); Book::create(title: 'Book 3', author: $author); - $books = $author->query('books')->limit(2)->all(); + $books = $author->query(relation: 'books')->limit(limit: 2)->all(); $this->assertCount(2, $books); } + public function test_query_has_many_through_returns_scoped_results(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreateTagTable::class, + CreateBookReviewTable::class, + CreateReviewerTable::class, + ); + + $tagA = Tag::create(label: 'fantasy'); + $tagB = Tag::create(label: 'sci-fi'); + + $reviewA1 = BookReview::create(content: 'Great', tag: $tagA); + $reviewA2 = BookReview::create(content: 'Good', tag: $tagA); + $reviewB1 = BookReview::create(content: 'Meh', tag: $tagB); + + Reviewer::create(name: 'Alice', bookReview: $reviewA1); + Reviewer::create(name: 'Bob', bookReview: $reviewA2); + Reviewer::create(name: 'Charlie', bookReview: $reviewB1); + + $reviewers = $tagA->query(relation: 'reviewers')->all(); + + $this->assertCount(2, $reviewers); + $this->assertContainsOnlyInstancesOf(Reviewer::class, $reviewers); + } + + public function test_query_has_many_through_supports_where(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreateTagTable::class, + CreateBookReviewTable::class, + CreateReviewerTable::class, + ); + + $tag = Tag::create(label: 'fantasy'); + + $review1 = BookReview::create(content: 'Great', tag: $tag); + $review2 = BookReview::create(content: 'Good', tag: $tag); + + Reviewer::create(name: 'Alice', bookReview: $review1); + Reviewer::create(name: 'Bob', bookReview: $review2); + + $reviewers = $tag->query(relation: 'reviewers') + ->whereField(field: 'name', value: 'Alice') + ->all(); + + $this->assertCount(1, $reviewers); + $this->assertSame('Alice', $reviewers[0]->name); + } + public function test_has_many_through_relation(): void { $this->database->migrate( From 082668b4ce51038bd6603b549ec595247af4de12 Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Sat, 4 Apr 2026 02:34:35 +0100 Subject: [PATCH 04/31] feat(database): add HasManyThrough support to query() method with named args --- packages/database/src/IsDatabaseModel.php | 60 +++++++++++++++++------ 1 file changed, 44 insertions(+), 16 deletions(-) diff --git a/packages/database/src/IsDatabaseModel.php b/packages/database/src/IsDatabaseModel.php index 725825717..3df99b0ed 100644 --- a/packages/database/src/IsDatabaseModel.php +++ b/packages/database/src/IsDatabaseModel.php @@ -266,29 +266,57 @@ public function refresh(): self } /** - * Returns a query builder scoped to a HasMany relation on this model. + * Returns a query builder scoped to a collection relation on this model. */ public function query(string $relation): SelectQueryBuilder { - $model = inspect($this); - $hasMany = $model->getHasMany($relation); + $model = inspect(model: $this); + $primaryKeyValue = $model->getPrimaryKeyProperty()->getValue(object: $this); - if (! $hasMany instanceof HasMany) { - throw new InvalidArgumentException( - sprintf('Property "%s" is not a HasMany relation on %s.', $relation, $model->getName()), - ); + $hasMany = $model->getHasMany(name: $relation); + + if ($hasMany instanceof HasMany) { + $relatedClassName = $hasMany->property->getIterableType()->getName(); + $parentTable = $model->getTableName(); + $parentPK = $model->getPrimaryKey(); + $fk = $hasMany->ownerJoin ?? str(string: $parentTable)->singularizeLastWord() . '_' . $parentPK; + + return query(model: $relatedClassName) + ->onDatabase(databaseTag: $this->onDatabase) + ->select() + ->whereField(field: $fk, value: $primaryKeyValue); } - $relatedClassName = $hasMany->property->getIterableType()->getName(); - $parentTable = $model->getTableName(); - $parentPK = $model->getPrimaryKey(); - $fk = $hasMany->ownerJoin ?? str($parentTable)->singularizeLastWord() . '_' . $parentPK; - $primaryKeyValue = $model->getPrimaryKeyProperty()->getValue($this); + $hasManyThrough = $model->getHasManyThrough(name: $relation); + + if ($hasManyThrough instanceof HasManyThrough) { + $relatedClassName = $hasManyThrough->property->getIterableType()->getName(); + $intermediateModel = inspect(model: $hasManyThrough->through); + $intermediateTable = $intermediateModel->getTableName(); + $ownerTable = $model->getTableName(); + $ownerPK = $model->getPrimaryKey(); + $intermediatePK = $intermediateModel->getPrimaryKey(); + + $ownerFK = $hasManyThrough->ownerJoin ?? str(string: $ownerTable)->singularizeLastWord() . '_' . $ownerPK; + $targetFK = $hasManyThrough->throughOwnerJoin ?? str(string: $intermediateTable)->singularizeLastWord() . '_' . $intermediatePK; + + return query(model: $relatedClassName) + ->onDatabase(databaseTag: $this->onDatabase) + ->select() + ->join(sprintf( + 'INNER JOIN %s ON %s.%s = %s.%s', + $intermediateTable, + inspect(model: $relatedClassName)->getTableName(), + $targetFK, + $intermediateTable, + $intermediatePK, + )) + ->whereRaw(sprintf('%s.%s = ?', $intermediateTable, $ownerFK), $primaryKeyValue); + } - return query($relatedClassName) - ->onDatabase($this->onDatabase) - ->select() - ->whereField($fk, $primaryKeyValue); + throw new InvalidArgumentException( + message: sprintf('Property "%s" is not a collection relation on %s.', $relation, $model->getName()), + ); } /** From 91cfb4c7156212263a7001bde09cb6f01b63a62b Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Sat, 4 Apr 2026 02:42:34 +0100 Subject: [PATCH 05/31] test(database): add BelongsToMany query() tests and edge cases (explicit attr, unsaved model, nonexistent property, empty result) --- .../Database/Builder/IsDatabaseModelTest.php | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) diff --git a/tests/Integration/Database/Builder/IsDatabaseModelTest.php b/tests/Integration/Database/Builder/IsDatabaseModelTest.php index 2a10ae93e..829ef6b17 100644 --- a/tests/Integration/Database/Builder/IsDatabaseModelTest.php +++ b/tests/Integration/Database/Builder/IsDatabaseModelTest.php @@ -7,6 +7,7 @@ use Carbon\Carbon; use DateTime as NativeDateTime; use DateTimeImmutable; +use InvalidArgumentException; use Tempest\Database\BelongsTo; use Tempest\Database\Builder\QueryBuilders\QueryBuilder; use Tempest\Database\Exceptions\DeleteStatementWasInvalid; @@ -46,6 +47,7 @@ use Tests\Tempest\Fixtures\Models\B; use Tests\Tempest\Fixtures\Models\C; use Tests\Tempest\Fixtures\Migrations\CreateBookReviewTable; +use Tests\Tempest\Fixtures\Migrations\CreateBookTagTable; use Tests\Tempest\Fixtures\Migrations\CreateReviewerTable; use Tests\Tempest\Fixtures\Migrations\CreateTagTable; use Tests\Tempest\Fixtures\Modules\Books\Models\Author; @@ -445,6 +447,142 @@ public function test_query_has_many_through_supports_where(): void $this->assertSame('Alice', $reviewers[0]->name); } + public function test_query_belongs_to_many_returns_scoped_results(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + CreateTagTable::class, + CreateBookTagTable::class, + ); + + $author = Author::create(name: 'Author', type: AuthorType::A); + $book1 = Book::create(title: 'Book 1', author: $author); + $book2 = Book::create(title: 'Book 2', author: $author); + $book3 = Book::create(title: 'Book 3', author: $author); + + $tagA = Tag::create(label: 'fantasy'); + $tagB = Tag::create(label: 'sci-fi'); + + query(model: 'books_tags')->insert(['book_id' => $book1->id->value, 'tag_id' => $tagA->id->value])->execute(); + query(model: 'books_tags')->insert(['book_id' => $book2->id->value, 'tag_id' => $tagA->id->value])->execute(); + query(model: 'books_tags')->insert(['book_id' => $book3->id->value, 'tag_id' => $tagB->id->value])->execute(); + + $books = $tagA->query(relation: 'books')->all(); + + $this->assertCount(2, $books); + $this->assertContainsOnlyInstancesOf(Book::class, $books); + } + + public function test_query_belongs_to_many_supports_where(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + CreateTagTable::class, + CreateBookTagTable::class, + ); + + $author = Author::create(name: 'Author', type: AuthorType::A); + $book1 = Book::create(title: 'Alpha', author: $author); + $book2 = Book::create(title: 'Beta', author: $author); + + $tag = Tag::create(label: 'fantasy'); + + query(model: 'books_tags')->insert(['book_id' => $book1->id->value, 'tag_id' => $tag->id->value])->execute(); + query(model: 'books_tags')->insert(['book_id' => $book2->id->value, 'tag_id' => $tag->id->value])->execute(); + + $books = $tag->query(relation: 'books') + ->whereField(field: 'title', value: 'Alpha') + ->all(); + + $this->assertCount(1, $books); + $this->assertSame('Alpha', $books[0]->title); + } + + public function test_query_has_many_with_explicit_attribute(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreateTestUserMigration::class, + CreateTestPostMigration::class, + ); + + $user = TestUser::create(name: 'Alice'); + + query(model: 'test_posts') + ->insert(['title' => 'Post 1', 'body' => 'Body 1', 'test_user_id' => $user->id->value]) + ->execute(); + query(model: 'test_posts') + ->insert(['title' => 'Post 2', 'body' => 'Body 2', 'test_user_id' => $user->id->value]) + ->execute(); + + $posts = $user->query(relation: 'posts')->all(); + + $this->assertCount(2, $posts); + $this->assertContainsOnlyInstancesOf(TestPost::class, $posts); + } + + public function test_query_throws_for_non_collection_relation(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + ); + + $book = Book::create(title: 'Test'); + + $this->expectException(InvalidArgumentException::class); + + $book->query(relation: 'author'); + } + + public function test_query_throws_for_nonexistent_property(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + ); + + $author = Author::create(name: 'Author', type: AuthorType::A); + + $this->expectException(InvalidArgumentException::class); + + $author->query(relation: 'nonexistent'); + } + + public function test_query_throws_for_unsaved_model(): void + { + $author = new Author(name: 'Unsaved'); + + $this->expectException(InvalidArgumentException::class); + + $author->query(relation: 'books'); + } + + public function test_query_has_many_returns_empty_for_no_results(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + ); + + $author = Author::create(name: 'Author', type: AuthorType::A); + + $books = $author->query(relation: 'books')->all(); + + $this->assertCount(0, $books); + } + public function test_has_many_through_relation(): void { $this->database->migrate( From 76c6bdd4d96a1d0925e07a17ad06a536e16546bc Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Sat, 4 Apr 2026 02:42:38 +0100 Subject: [PATCH 06/31] feat(database): add BelongsToMany support and unsaved model guard to query() --- packages/database/src/IsDatabaseModel.php | 39 ++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/database/src/IsDatabaseModel.php b/packages/database/src/IsDatabaseModel.php index 3df99b0ed..85a5b80bf 100644 --- a/packages/database/src/IsDatabaseModel.php +++ b/packages/database/src/IsDatabaseModel.php @@ -271,7 +271,16 @@ public function refresh(): self public function query(string $relation): SelectQueryBuilder { $model = inspect(model: $this); - $primaryKeyValue = $model->getPrimaryKeyProperty()->getValue(object: $this); + + $primaryKeyProperty = $model->getPrimaryKeyProperty(); + + if ($primaryKeyProperty === null || ! $primaryKeyProperty->isInitialized(object: $this)) { + throw new InvalidArgumentException( + message: sprintf('Cannot query relations on %s without a primary key value.', $model->getName()), + ); + } + + $primaryKeyValue = $primaryKeyProperty->getValue(object: $this); $hasMany = $model->getHasMany(name: $relation); @@ -314,6 +323,34 @@ public function query(string $relation): SelectQueryBuilder ->whereRaw(sprintf('%s.%s = ?', $intermediateTable, $ownerFK), $primaryKeyValue); } + $belongsToMany = $model->getBelongsToMany(name: $relation); + + if ($belongsToMany instanceof BelongsToMany) { + $relatedClassName = $belongsToMany->property->getIterableType()->getName(); + $targetModel = inspect(model: $relatedClassName); + $ownerTable = $model->getTableName(); + $ownerPK = $model->getPrimaryKey(); + $targetTable = $targetModel->getTableName(); + $targetPK = $targetModel->getPrimaryKey(); + + $pivotTable = $belongsToMany->pivot ?? arr([$ownerTable, $targetTable])->sort()->implode('_')->toString(); + $ownerFK = $belongsToMany->ownerJoin ?? str(string: $ownerTable)->singularizeLastWord() . '_' . $ownerPK; + $targetFK = $belongsToMany->relatedOwnerJoin ?? str(string: $targetTable)->singularizeLastWord() . '_' . $targetPK; + + return query(model: $relatedClassName) + ->onDatabase(databaseTag: $this->onDatabase) + ->select() + ->join(sprintf( + 'INNER JOIN %s ON %s.%s = %s.%s', + $pivotTable, + $pivotTable, + $targetFK, + $targetTable, + $targetPK, + )) + ->whereRaw(sprintf('%s.%s = ?', $pivotTable, $ownerFK), $primaryKeyValue); + } + throw new InvalidArgumentException( message: sprintf('Property "%s" is not a collection relation on %s.', $relation, $model->getName()), ); From c8b90b8a8b9bce0df193408d9774781c72309c6c Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Sat, 4 Apr 2026 02:44:25 +0100 Subject: [PATCH 07/31] refactor(database): use getRelation() instead of separate getHasMany/getHasManyThrough/getBelongsToMany calls --- packages/database/src/IsDatabaseModel.php | 40 +++++++++-------------- 1 file changed, 16 insertions(+), 24 deletions(-) diff --git a/packages/database/src/IsDatabaseModel.php b/packages/database/src/IsDatabaseModel.php index 85a5b80bf..cbe6d8d2c 100644 --- a/packages/database/src/IsDatabaseModel.php +++ b/packages/database/src/IsDatabaseModel.php @@ -282,13 +282,13 @@ public function query(string $relation): SelectQueryBuilder $primaryKeyValue = $primaryKeyProperty->getValue(object: $this); - $hasMany = $model->getHasMany(name: $relation); + $relationObj = $model->getRelation(name: $relation); + $ownerTable = $model->getTableName(); + $ownerPK = $model->getPrimaryKey(); - if ($hasMany instanceof HasMany) { - $relatedClassName = $hasMany->property->getIterableType()->getName(); - $parentTable = $model->getTableName(); - $parentPK = $model->getPrimaryKey(); - $fk = $hasMany->ownerJoin ?? str(string: $parentTable)->singularizeLastWord() . '_' . $parentPK; + if ($relationObj instanceof HasMany) { + $relatedClassName = $relationObj->property->getIterableType()->getName(); + $fk = $relationObj->ownerJoin ?? str(string: $ownerTable)->singularizeLastWord() . '_' . $ownerPK; return query(model: $relatedClassName) ->onDatabase(databaseTag: $this->onDatabase) @@ -296,18 +296,14 @@ public function query(string $relation): SelectQueryBuilder ->whereField(field: $fk, value: $primaryKeyValue); } - $hasManyThrough = $model->getHasManyThrough(name: $relation); - - if ($hasManyThrough instanceof HasManyThrough) { - $relatedClassName = $hasManyThrough->property->getIterableType()->getName(); - $intermediateModel = inspect(model: $hasManyThrough->through); + if ($relationObj instanceof HasManyThrough) { + $relatedClassName = $relationObj->property->getIterableType()->getName(); + $intermediateModel = inspect(model: $relationObj->through); $intermediateTable = $intermediateModel->getTableName(); - $ownerTable = $model->getTableName(); - $ownerPK = $model->getPrimaryKey(); $intermediatePK = $intermediateModel->getPrimaryKey(); - $ownerFK = $hasManyThrough->ownerJoin ?? str(string: $ownerTable)->singularizeLastWord() . '_' . $ownerPK; - $targetFK = $hasManyThrough->throughOwnerJoin ?? str(string: $intermediateTable)->singularizeLastWord() . '_' . $intermediatePK; + $ownerFK = $relationObj->ownerJoin ?? str(string: $ownerTable)->singularizeLastWord() . '_' . $ownerPK; + $targetFK = $relationObj->throughOwnerJoin ?? str(string: $intermediateTable)->singularizeLastWord() . '_' . $intermediatePK; return query(model: $relatedClassName) ->onDatabase(databaseTag: $this->onDatabase) @@ -323,19 +319,15 @@ public function query(string $relation): SelectQueryBuilder ->whereRaw(sprintf('%s.%s = ?', $intermediateTable, $ownerFK), $primaryKeyValue); } - $belongsToMany = $model->getBelongsToMany(name: $relation); - - if ($belongsToMany instanceof BelongsToMany) { - $relatedClassName = $belongsToMany->property->getIterableType()->getName(); + if ($relationObj instanceof BelongsToMany) { + $relatedClassName = $relationObj->property->getIterableType()->getName(); $targetModel = inspect(model: $relatedClassName); - $ownerTable = $model->getTableName(); - $ownerPK = $model->getPrimaryKey(); $targetTable = $targetModel->getTableName(); $targetPK = $targetModel->getPrimaryKey(); - $pivotTable = $belongsToMany->pivot ?? arr([$ownerTable, $targetTable])->sort()->implode('_')->toString(); - $ownerFK = $belongsToMany->ownerJoin ?? str(string: $ownerTable)->singularizeLastWord() . '_' . $ownerPK; - $targetFK = $belongsToMany->relatedOwnerJoin ?? str(string: $targetTable)->singularizeLastWord() . '_' . $targetPK; + $pivotTable = $relationObj->pivot ?? arr([$ownerTable, $targetTable])->sort()->implode('_')->toString(); + $ownerFK = $relationObj->ownerJoin ?? str(string: $ownerTable)->singularizeLastWord() . '_' . $ownerPK; + $targetFK = $relationObj->relatedOwnerJoin ?? str(string: $targetTable)->singularizeLastWord() . '_' . $targetPK; return query(model: $relatedClassName) ->onDatabase(databaseTag: $this->onDatabase) From 90a898c7916d8994efe2692dedf92be243148fe9 Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Sat, 4 Apr 2026 02:45:45 +0100 Subject: [PATCH 08/31] refactor(database): use hasPrimaryKey() and getPrimaryKeyValue() instead of manual property check --- 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 cbe6d8d2c..320c12777 100644 --- a/packages/database/src/IsDatabaseModel.php +++ b/packages/database/src/IsDatabaseModel.php @@ -272,15 +272,13 @@ public function query(string $relation): SelectQueryBuilder { $model = inspect(model: $this); - $primaryKeyProperty = $model->getPrimaryKeyProperty(); - - if ($primaryKeyProperty === null || ! $primaryKeyProperty->isInitialized(object: $this)) { + if (! $model->hasPrimaryKey() || ! $model->getPrimaryKeyProperty()->isInitialized(object: $this)) { throw new InvalidArgumentException( message: sprintf('Cannot query relations on %s without a primary key value.', $model->getName()), ); } - $primaryKeyValue = $primaryKeyProperty->getValue(object: $this); + $primaryKeyValue = $model->getPrimaryKeyValue(); $relationObj = $model->getRelation(name: $relation); $ownerTable = $model->getTableName(); From de2d2e27b72dda45d33067090edbf438c36a2da1 Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Sat, 4 Apr 2026 02:51:26 +0100 Subject: [PATCH 09/31] refactor(database): extract query() builders into private methods with match dispatch --- packages/database/src/IsDatabaseModel.php | 122 +++++++++--------- .../Database/Builder/IsDatabaseModelTest.php | 24 ++-- 2 files changed, 76 insertions(+), 70 deletions(-) diff --git a/packages/database/src/IsDatabaseModel.php b/packages/database/src/IsDatabaseModel.php index 320c12777..20cd32b04 100644 --- a/packages/database/src/IsDatabaseModel.php +++ b/packages/database/src/IsDatabaseModel.php @@ -279,71 +279,77 @@ public function query(string $relation): SelectQueryBuilder } $primaryKeyValue = $model->getPrimaryKeyValue(); - - $relationObj = $model->getRelation(name: $relation); $ownerTable = $model->getTableName(); $ownerPK = $model->getPrimaryKey(); - if ($relationObj instanceof HasMany) { - $relatedClassName = $relationObj->property->getIterableType()->getName(); - $fk = $relationObj->ownerJoin ?? str(string: $ownerTable)->singularizeLastWord() . '_' . $ownerPK; + return match (true) { + ($resolved = $model->getRelation(name: $relation)) instanceof HasMany => $this->buildHasManyQuery($resolved, $ownerTable, $ownerPK, $primaryKeyValue), + $resolved instanceof HasManyThrough => $this->buildHasManyThroughQuery($resolved, $ownerTable, $ownerPK, $primaryKeyValue), + $resolved instanceof BelongsToMany => $this->buildBelongsToManyQuery($resolved, $ownerTable, $ownerPK, $primaryKeyValue), + default => throw new InvalidArgumentException( + message: sprintf('Property "%s" is not a collection relation on %s.', $relation, $model->getName()), + ), + }; + } - return query(model: $relatedClassName) - ->onDatabase(databaseTag: $this->onDatabase) - ->select() - ->whereField(field: $fk, value: $primaryKeyValue); - } + private function buildHasManyQuery(HasMany $relation, string $ownerTable, string $ownerPK, PrimaryKey $primaryKeyValue): SelectQueryBuilder + { + $relatedClassName = $relation->property->getIterableType()->getName(); + $fk = $relation->ownerJoin ?? str(string: $ownerTable)->singularizeLastWord() . '_' . $ownerPK; - if ($relationObj instanceof HasManyThrough) { - $relatedClassName = $relationObj->property->getIterableType()->getName(); - $intermediateModel = inspect(model: $relationObj->through); - $intermediateTable = $intermediateModel->getTableName(); - $intermediatePK = $intermediateModel->getPrimaryKey(); - - $ownerFK = $relationObj->ownerJoin ?? str(string: $ownerTable)->singularizeLastWord() . '_' . $ownerPK; - $targetFK = $relationObj->throughOwnerJoin ?? str(string: $intermediateTable)->singularizeLastWord() . '_' . $intermediatePK; - - return query(model: $relatedClassName) - ->onDatabase(databaseTag: $this->onDatabase) - ->select() - ->join(sprintf( - 'INNER JOIN %s ON %s.%s = %s.%s', - $intermediateTable, - inspect(model: $relatedClassName)->getTableName(), - $targetFK, - $intermediateTable, - $intermediatePK, - )) - ->whereRaw(sprintf('%s.%s = ?', $intermediateTable, $ownerFK), $primaryKeyValue); - } + return query(model: $relatedClassName) + ->onDatabase(databaseTag: $this->onDatabase) + ->select() + ->whereField(field: $fk, value: $primaryKeyValue); + } - if ($relationObj instanceof BelongsToMany) { - $relatedClassName = $relationObj->property->getIterableType()->getName(); - $targetModel = inspect(model: $relatedClassName); - $targetTable = $targetModel->getTableName(); - $targetPK = $targetModel->getPrimaryKey(); - - $pivotTable = $relationObj->pivot ?? arr([$ownerTable, $targetTable])->sort()->implode('_')->toString(); - $ownerFK = $relationObj->ownerJoin ?? str(string: $ownerTable)->singularizeLastWord() . '_' . $ownerPK; - $targetFK = $relationObj->relatedOwnerJoin ?? str(string: $targetTable)->singularizeLastWord() . '_' . $targetPK; - - return query(model: $relatedClassName) - ->onDatabase(databaseTag: $this->onDatabase) - ->select() - ->join(sprintf( - 'INNER JOIN %s ON %s.%s = %s.%s', - $pivotTable, - $pivotTable, - $targetFK, - $targetTable, - $targetPK, - )) - ->whereRaw(sprintf('%s.%s = ?', $pivotTable, $ownerFK), $primaryKeyValue); - } + private function buildHasManyThroughQuery(HasManyThrough $relation, string $ownerTable, string $ownerPK, PrimaryKey $primaryKeyValue): SelectQueryBuilder + { + $relatedClassName = $relation->property->getIterableType()->getName(); + $intermediateModel = inspect(model: $relation->through); + $intermediateTable = $intermediateModel->getTableName(); + $intermediatePK = $intermediateModel->getPrimaryKey(); - throw new InvalidArgumentException( - message: sprintf('Property "%s" is not a collection relation on %s.', $relation, $model->getName()), - ); + $ownerFK = $relation->ownerJoin ?? str(string: $ownerTable)->singularizeLastWord() . '_' . $ownerPK; + $targetFK = $relation->throughOwnerJoin ?? str(string: $intermediateTable)->singularizeLastWord() . '_' . $intermediatePK; + + return query(model: $relatedClassName) + ->onDatabase(databaseTag: $this->onDatabase) + ->select() + ->join(sprintf( + 'INNER JOIN %s ON %s.%s = %s.%s', + $intermediateTable, + inspect(model: $relatedClassName)->getTableName(), + $targetFK, + $intermediateTable, + $intermediatePK, + )) + ->whereRaw(sprintf('%s.%s = ?', $intermediateTable, $ownerFK), $primaryKeyValue); + } + + private function buildBelongsToManyQuery(BelongsToMany $relation, string $ownerTable, string $ownerPK, PrimaryKey $primaryKeyValue): SelectQueryBuilder + { + $relatedClassName = $relation->property->getIterableType()->getName(); + $targetModel = inspect(model: $relatedClassName); + $targetTable = $targetModel->getTableName(); + $targetPK = $targetModel->getPrimaryKey(); + + $pivotTable = $relation->pivot ?? arr([$ownerTable, $targetTable])->sort()->implode('_')->toString(); + $ownerFK = $relation->ownerJoin ?? str(string: $ownerTable)->singularizeLastWord() . '_' . $ownerPK; + $targetFK = $relation->relatedOwnerJoin ?? str(string: $targetTable)->singularizeLastWord() . '_' . $targetPK; + + return query(model: $relatedClassName) + ->onDatabase(databaseTag: $this->onDatabase) + ->select() + ->join(sprintf( + 'INNER JOIN %s ON %s.%s = %s.%s', + $pivotTable, + $pivotTable, + $targetFK, + $targetTable, + $targetPK, + )) + ->whereRaw(sprintf('%s.%s = ?', $pivotTable, $ownerFK), $primaryKeyValue); } /** diff --git a/tests/Integration/Database/Builder/IsDatabaseModelTest.php b/tests/Integration/Database/Builder/IsDatabaseModelTest.php index 829ef6b17..fdc422045 100644 --- a/tests/Integration/Database/Builder/IsDatabaseModelTest.php +++ b/tests/Integration/Database/Builder/IsDatabaseModelTest.php @@ -341,7 +341,7 @@ public function test_query_has_many_returns_scoped_results(): void Book::create(title: 'Book 3', author: $authorA); Book::create(title: 'Other Book', author: $authorB); - $books = $authorA->query(relation: 'books')->all(); + $books = $authorA->query( 'books')->all(); $this->assertCount(3, $books); $this->assertContainsOnlyInstancesOf(Book::class, $books); @@ -365,7 +365,7 @@ public function test_query_has_many_supports_where(): void Book::create(title: 'Beta', author: $author); Book::create(title: 'Gamma', author: $author); - $books = $author->query(relation: 'books') + $books = $author->query( 'books') ->whereField(field: 'title', value: 'Beta') ->all(); @@ -391,7 +391,7 @@ public function test_query_has_many_supports_limit(): void Book::create(title: 'Book 2', author: $author); Book::create(title: 'Book 3', author: $author); - $books = $author->query(relation: 'books')->limit(limit: 2)->all(); + $books = $author->query( 'books')->limit(limit: 2)->all(); $this->assertCount(2, $books); } @@ -416,7 +416,7 @@ public function test_query_has_many_through_returns_scoped_results(): void Reviewer::create(name: 'Bob', bookReview: $reviewA2); Reviewer::create(name: 'Charlie', bookReview: $reviewB1); - $reviewers = $tagA->query(relation: 'reviewers')->all(); + $reviewers = $tagA->query( 'reviewers')->all(); $this->assertCount(2, $reviewers); $this->assertContainsOnlyInstancesOf(Reviewer::class, $reviewers); @@ -439,7 +439,7 @@ public function test_query_has_many_through_supports_where(): void Reviewer::create(name: 'Alice', bookReview: $review1); Reviewer::create(name: 'Bob', bookReview: $review2); - $reviewers = $tag->query(relation: 'reviewers') + $reviewers = $tag->query( 'reviewers') ->whereField(field: 'name', value: 'Alice') ->all(); @@ -470,7 +470,7 @@ public function test_query_belongs_to_many_returns_scoped_results(): void query(model: 'books_tags')->insert(['book_id' => $book2->id->value, 'tag_id' => $tagA->id->value])->execute(); query(model: 'books_tags')->insert(['book_id' => $book3->id->value, 'tag_id' => $tagB->id->value])->execute(); - $books = $tagA->query(relation: 'books')->all(); + $books = $tagA->query( 'books')->all(); $this->assertCount(2, $books); $this->assertContainsOnlyInstancesOf(Book::class, $books); @@ -496,7 +496,7 @@ public function test_query_belongs_to_many_supports_where(): void query(model: 'books_tags')->insert(['book_id' => $book1->id->value, 'tag_id' => $tag->id->value])->execute(); query(model: 'books_tags')->insert(['book_id' => $book2->id->value, 'tag_id' => $tag->id->value])->execute(); - $books = $tag->query(relation: 'books') + $books = $tag->query( 'books') ->whereField(field: 'title', value: 'Alpha') ->all(); @@ -521,7 +521,7 @@ public function test_query_has_many_with_explicit_attribute(): void ->insert(['title' => 'Post 2', 'body' => 'Body 2', 'test_user_id' => $user->id->value]) ->execute(); - $posts = $user->query(relation: 'posts')->all(); + $posts = $user->query( 'posts')->all(); $this->assertCount(2, $posts); $this->assertContainsOnlyInstancesOf(TestPost::class, $posts); @@ -540,7 +540,7 @@ public function test_query_throws_for_non_collection_relation(): void $this->expectException(InvalidArgumentException::class); - $book->query(relation: 'author'); + $book->query( 'author'); } public function test_query_throws_for_nonexistent_property(): void @@ -555,7 +555,7 @@ public function test_query_throws_for_nonexistent_property(): void $this->expectException(InvalidArgumentException::class); - $author->query(relation: 'nonexistent'); + $author->query( 'nonexistent'); } public function test_query_throws_for_unsaved_model(): void @@ -564,7 +564,7 @@ public function test_query_throws_for_unsaved_model(): void $this->expectException(InvalidArgumentException::class); - $author->query(relation: 'books'); + $author->query( 'books'); } public function test_query_has_many_returns_empty_for_no_results(): void @@ -578,7 +578,7 @@ public function test_query_has_many_returns_empty_for_no_results(): void $author = Author::create(name: 'Author', type: AuthorType::A); - $books = $author->query(relation: 'books')->all(); + $books = $author->query( 'books')->all(); $this->assertCount(0, $books); } From 67e381d049b9ab88e0810ef94be984b3effcfe7b Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Sat, 4 Apr 2026 02:53:00 +0100 Subject: [PATCH 10/31] refactor(database): pull getRelation() call out of match expression --- packages/database/src/IsDatabaseModel.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/database/src/IsDatabaseModel.php b/packages/database/src/IsDatabaseModel.php index 20cd32b04..f6717da29 100644 --- a/packages/database/src/IsDatabaseModel.php +++ b/packages/database/src/IsDatabaseModel.php @@ -281,9 +281,10 @@ public function query(string $relation): SelectQueryBuilder $primaryKeyValue = $model->getPrimaryKeyValue(); $ownerTable = $model->getTableName(); $ownerPK = $model->getPrimaryKey(); + $resolved = $model->getRelation(name: $relation); return match (true) { - ($resolved = $model->getRelation(name: $relation)) instanceof HasMany => $this->buildHasManyQuery($resolved, $ownerTable, $ownerPK, $primaryKeyValue), + $resolved instanceof HasMany => $this->buildHasManyQuery($resolved, $ownerTable, $ownerPK, $primaryKeyValue), $resolved instanceof HasManyThrough => $this->buildHasManyThroughQuery($resolved, $ownerTable, $ownerPK, $primaryKeyValue), $resolved instanceof BelongsToMany => $this->buildBelongsToManyQuery($resolved, $ownerTable, $ownerPK, $primaryKeyValue), default => throw new InvalidArgumentException( From 32210758cd75e1a3fa75c50792907a07ef9bb0b7 Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Sat, 4 Apr 2026 03:21:58 +0100 Subject: [PATCH 11/31] refactor(database): move query() to Relation interface with QueryScope, return QueryBuilder for select/update/delete support --- packages/database/src/BelongsTo.php | 8 ++ packages/database/src/BelongsToMany.php | 33 ++++++++ .../HasWhereQueryBuilderMethods.php | 12 +++ .../Builder/QueryBuilders/QueryBuilder.php | 24 +++++- .../src/Builder/QueryBuilders/QueryScope.php | 10 +++ .../QueryBuilders/SupportsWhereStatements.php | 7 ++ .../Builder/QueryBuilders/WhereRawScope.php | 18 +++++ packages/database/src/HasMany.php | 22 +++++ packages/database/src/HasManyThrough.php | 32 ++++++++ packages/database/src/HasOne.php | 8 ++ packages/database/src/HasOneThrough.php | 8 ++ packages/database/src/IsDatabaseModel.php | 81 +++---------------- packages/database/src/Relation.php | 4 + .../Database/Builder/IsDatabaseModelTest.php | 27 ++++--- 14 files changed, 206 insertions(+), 88 deletions(-) create mode 100644 packages/database/src/Builder/QueryBuilders/QueryScope.php create mode 100644 packages/database/src/Builder/QueryBuilders/WhereRawScope.php diff --git a/packages/database/src/BelongsTo.php b/packages/database/src/BelongsTo.php index e0143a797..a9442ff96 100644 --- a/packages/database/src/BelongsTo.php +++ b/packages/database/src/BelongsTo.php @@ -5,7 +5,10 @@ namespace Tempest\Database; use Attribute; +use BadMethodCallException; use Tempest\Database\Builder\ModelInspector; +use Tempest\Database\Builder\QueryBuilders\QueryBuilder; +use UnitEnum; use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn; use Tempest\Database\QueryStatements\FieldStatement; use Tempest\Database\QueryStatements\JoinStatement; @@ -191,4 +194,9 @@ private function getOwnerJoin(ModelInspector $ownerModel): string $this->getOwnerFieldName(), ); } + + public function query(PrimaryKey $primaryKey, null|string|UnitEnum $onDatabase = null): QueryBuilder + { + throw new BadMethodCallException(message: 'Cannot query a BelongsTo relation. Use HasMany on the inverse side.'); + } } diff --git a/packages/database/src/BelongsToMany.php b/packages/database/src/BelongsToMany.php index 6b3b36f95..6ce5e4fb9 100644 --- a/packages/database/src/BelongsToMany.php +++ b/packages/database/src/BelongsToMany.php @@ -6,13 +6,17 @@ use Attribute; use Tempest\Database\Builder\ModelInspector; +use Tempest\Database\Builder\QueryBuilders\QueryBuilder; +use Tempest\Database\Builder\QueryBuilders\WhereRawScope; use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn; use Tempest\Database\QueryStatements\FieldStatement; use Tempest\Database\QueryStatements\JoinStatement; use Tempest\Database\QueryStatements\WhereExistsStatement; use Tempest\Reflection\PropertyReflector; use Tempest\Support\Arr\ImmutableArray; +use UnitEnum; +use function Tempest\Database\query; use function Tempest\Support\arr; use function Tempest\Support\str; @@ -394,4 +398,33 @@ public function getExistsStatement(): WhereExistsStatement ), ); } + + public function query(PrimaryKey $primaryKey, null|string|UnitEnum $onDatabase = null): QueryBuilder + { + $ownerModel = inspect(model: $this->property->getClass()); + $targetModel = inspect(model: $this->property->getIterableType()->asClass()); + $relatedClassName = $this->property->getIterableType()->getName(); + $ownerTable = $ownerModel->getTableName(); + $ownerPK = $ownerModel->getPrimaryKey(); + $targetTable = $targetModel->getTableName(); + $targetPK = $targetModel->getPrimaryKey(); + + $pivotTable = $this->pivot ?? arr([$ownerTable, $targetTable])->sort()->implode('_')->toString(); + $ownerFK = $this->ownerJoin ?? str(string: $ownerTable)->singularizeLastWord() . '_' . $ownerPK; + $targetFK = $this->relatedOwnerJoin ?? str(string: $targetTable)->singularizeLastWord() . '_' . $targetPK; + + return query(model: $relatedClassName) + ->onDatabase(databaseTag: $onDatabase) + ->scope(new WhereRawScope( + statement: sprintf( + '%s.%s IN (SELECT %s FROM %s WHERE %s = ?)', + $targetTable, + $targetPK, + $targetFK, + $pivotTable, + $ownerFK, + ), + binding: $primaryKey, + )); + } } diff --git a/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php b/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php index 3dbd5cae3..77510d508 100644 --- a/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php +++ b/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php @@ -15,6 +15,18 @@ trait HasWhereQueryBuilderMethods use HasConvenientWhereMethods; use HasWhereRelationMethods; + /** + * @param QueryScope[] $scopes + */ + public function applyScopes(array $scopes): self + { + foreach ($scopes as $scope) { + $scope->apply(builder: $this); + } + + return $this; + } + protected function appendWhere(WhereStatement|WhereGroupStatement|WhereExistsStatement $where): void { $this->wheres->offsetSet(null, $where); diff --git a/packages/database/src/Builder/QueryBuilders/QueryBuilder.php b/packages/database/src/Builder/QueryBuilders/QueryBuilder.php index c3d4e46bf..3fe50f08c 100644 --- a/packages/database/src/Builder/QueryBuilders/QueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/QueryBuilder.php @@ -19,11 +19,27 @@ final class QueryBuilder { use OnDatabase; + /** @var QueryScope[] */ + private array $scopes = []; + /** @param class-string|TModel|string $model */ public function __construct( private readonly string|object $model, ) {} + /** + * Adds a scope that will be applied to any query builder created from this instance. + * + * @return self + */ + public function scope(QueryScope $scope): self + { + $this->scopes[] = $scope; + + return $this; + } + + /** * Creates a `SELECT` query builder for retrieving records from the database. * @@ -41,7 +57,7 @@ public function select(string ...$columns): SelectQueryBuilder return new SelectQueryBuilder( model: $this->model, fields: $columns !== [] ? arr($columns)->unique() : null, - )->onDatabase($this->onDatabase); + )->onDatabase($this->onDatabase)->applyScopes($this->scopes); } /** @@ -88,7 +104,7 @@ public function update(mixed ...$values): UpdateQueryBuilder model: $this->model, values: $values, serializerFactory: get(SerializerFactory::class), - )->onDatabase($this->onDatabase); + )->onDatabase($this->onDatabase)->applyScopes($this->scopes); } /** @@ -106,7 +122,7 @@ public function update(mixed ...$values): UpdateQueryBuilder */ public function delete(): DeleteQueryBuilder { - return new DeleteQueryBuilder($this->model)->onDatabase($this->onDatabase); + return new DeleteQueryBuilder($this->model)->onDatabase($this->onDatabase)->applyScopes($this->scopes); } /** @@ -124,7 +140,7 @@ public function count(?string $column = null): CountQueryBuilder return new CountQueryBuilder( model: $this->model, column: $column, - )->onDatabase($this->onDatabase); + )->onDatabase($this->onDatabase)->applyScopes($this->scopes); } /** diff --git a/packages/database/src/Builder/QueryBuilders/QueryScope.php b/packages/database/src/Builder/QueryBuilders/QueryScope.php new file mode 100644 index 000000000..9e12c85ab --- /dev/null +++ b/packages/database/src/Builder/QueryBuilders/QueryScope.php @@ -0,0 +1,10 @@ + */ public function orWhere(string $field, mixed $value, WhereOperator $operator = WhereOperator::EQUALS): self; + + /** + * Adds a raw WHERE condition to the query. + * + * @return self + */ + public function whereRaw(string $statement, mixed ...$bindings): self; } diff --git a/packages/database/src/Builder/QueryBuilders/WhereRawScope.php b/packages/database/src/Builder/QueryBuilders/WhereRawScope.php new file mode 100644 index 000000000..62afe6413 --- /dev/null +++ b/packages/database/src/Builder/QueryBuilders/WhereRawScope.php @@ -0,0 +1,18 @@ +whereRaw($this->statement, $this->binding); + } +} diff --git a/packages/database/src/HasMany.php b/packages/database/src/HasMany.php index 956473f90..e24c23b91 100644 --- a/packages/database/src/HasMany.php +++ b/packages/database/src/HasMany.php @@ -6,13 +6,17 @@ use Attribute; use Tempest\Database\Builder\ModelInspector; +use Tempest\Database\Builder\QueryBuilders\QueryBuilder; +use Tempest\Database\Builder\QueryBuilders\WhereRawScope; use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn; use Tempest\Database\QueryStatements\FieldStatement; use Tempest\Database\QueryStatements\JoinStatement; use Tempest\Database\QueryStatements\WhereExistsStatement; use Tempest\Reflection\PropertyReflector; use Tempest\Support\Arr\ImmutableArray; +use UnitEnum; +use function Tempest\Database\query; use function Tempest\Support\str; #[Attribute(Attribute::TARGET_PROPERTY)] @@ -178,6 +182,24 @@ public function getExistsStatement(): WhereExistsStatement ); } + public function query(PrimaryKey $primaryKey, null|string|UnitEnum $onDatabase = null): QueryBuilder + { + $relatedClassName = $this->property->getIterableType()->getName(); + $parentModel = inspect(model: $this->property->getClass()); + $parentTable = $parentModel->getTableName(); + $parentPK = $parentModel->getPrimaryKey(); + $fk = $this->ownerJoin ?? str(string: $parentTable)->singularizeLastWord() . '_' . $parentPK; + + $relatedTable = inspect(model: $relatedClassName)->getTableName(); + + return query(model: $relatedClassName) + ->onDatabase(databaseTag: $onDatabase) + ->scope(new WhereRawScope( + statement: sprintf('%s.%s = ?', $relatedTable, $fk), + binding: $primaryKey, + )); + } + private function isSelfReferencing(): bool { $relationModel = inspect($this->property->getIterableType()->asClass()); diff --git a/packages/database/src/HasManyThrough.php b/packages/database/src/HasManyThrough.php index 695124697..96296dac7 100644 --- a/packages/database/src/HasManyThrough.php +++ b/packages/database/src/HasManyThrough.php @@ -6,13 +6,17 @@ use Attribute; use Tempest\Database\Builder\ModelInspector; +use Tempest\Database\Builder\QueryBuilders\QueryBuilder; +use Tempest\Database\Builder\QueryBuilders\WhereRawScope; use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn; use Tempest\Database\QueryStatements\FieldStatement; use Tempest\Database\QueryStatements\JoinStatement; use Tempest\Database\QueryStatements\WhereExistsStatement; use Tempest\Reflection\PropertyReflector; use Tempest\Support\Arr\ImmutableArray; +use UnitEnum; +use function Tempest\Database\query; use function Tempest\Support\str; #[Attribute(flags: Attribute::TARGET_PROPERTY)] @@ -360,4 +364,32 @@ public function getExistsStatement(): WhereExistsStatement ), ); } + + public function query(PrimaryKey $primaryKey, null|string|UnitEnum $onDatabase = null): QueryBuilder + { + $relatedClassName = $this->property->getIterableType()->getName(); + $ownerModel = inspect(model: $this->property->getClass()); + $intermediateModel = inspect(model: $this->through); + $intermediateTable = $intermediateModel->getTableName(); + $ownerTable = $ownerModel->getTableName(); + $ownerPK = $ownerModel->getPrimaryKey(); + $intermediatePK = $intermediateModel->getPrimaryKey(); + $relatedTable = inspect(model: $relatedClassName)->getTableName(); + + $ownerFK = $this->ownerJoin ?? str(string: $ownerTable)->singularizeLastWord() . '_' . $ownerPK; + $targetFK = $this->throughOwnerJoin ?? str(string: $intermediateTable)->singularizeLastWord() . '_' . $intermediatePK; + + return query(model: $relatedClassName) + ->onDatabase(databaseTag: $onDatabase) + ->scope(new WhereRawScope( + statement: sprintf( + '%s IN (SELECT %s FROM %s WHERE %s = ?)', + $relatedTable . '.' . $targetFK, + $intermediatePK, + $intermediateTable, + $ownerFK, + ), + binding: $primaryKey, + )); + } } diff --git a/packages/database/src/HasOne.php b/packages/database/src/HasOne.php index e09670741..6498af967 100644 --- a/packages/database/src/HasOne.php +++ b/packages/database/src/HasOne.php @@ -5,7 +5,10 @@ namespace Tempest\Database; use Attribute; +use BadMethodCallException; use Tempest\Database\Builder\ModelInspector; +use Tempest\Database\Builder\QueryBuilders\QueryBuilder; +use UnitEnum; use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn; use Tempest\Database\QueryStatements\FieldStatement; use Tempest\Database\QueryStatements\JoinStatement; @@ -189,4 +192,9 @@ private function getRelationJoin(ModelInspector $relationModel): string $primaryKey, ); } + + public function query(PrimaryKey $primaryKey, null|string|UnitEnum $onDatabase = null): QueryBuilder + { + throw new BadMethodCallException(message: 'Cannot query a HasOne relation.'); + } } diff --git a/packages/database/src/HasOneThrough.php b/packages/database/src/HasOneThrough.php index 44c044aae..89701c326 100644 --- a/packages/database/src/HasOneThrough.php +++ b/packages/database/src/HasOneThrough.php @@ -5,7 +5,10 @@ namespace Tempest\Database; use Attribute; +use BadMethodCallException; use Tempest\Database\Builder\ModelInspector; +use Tempest\Database\Builder\QueryBuilders\QueryBuilder; +use UnitEnum; use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn; use Tempest\Database\QueryStatements\FieldStatement; use Tempest\Database\QueryStatements\JoinStatement; @@ -317,4 +320,9 @@ public function getExistsStatement(): WhereExistsStatement ), ); } + + public function query(PrimaryKey $primaryKey, null|string|UnitEnum $onDatabase = null): QueryBuilder + { + throw new BadMethodCallException(message: 'Cannot query a HasOneThrough relation.'); + } } diff --git a/packages/database/src/IsDatabaseModel.php b/packages/database/src/IsDatabaseModel.php index f6717da29..aaaecc9fe 100644 --- a/packages/database/src/IsDatabaseModel.php +++ b/packages/database/src/IsDatabaseModel.php @@ -268,7 +268,7 @@ public function refresh(): self /** * Returns a query builder scoped to a collection relation on this model. */ - public function query(string $relation): SelectQueryBuilder + public function query(string $relation): QueryBuilder { $model = inspect(model: $this); @@ -278,79 +278,18 @@ public function query(string $relation): SelectQueryBuilder ); } - $primaryKeyValue = $model->getPrimaryKeyValue(); - $ownerTable = $model->getTableName(); - $ownerPK = $model->getPrimaryKey(); $resolved = $model->getRelation(name: $relation); - return match (true) { - $resolved instanceof HasMany => $this->buildHasManyQuery($resolved, $ownerTable, $ownerPK, $primaryKeyValue), - $resolved instanceof HasManyThrough => $this->buildHasManyThroughQuery($resolved, $ownerTable, $ownerPK, $primaryKeyValue), - $resolved instanceof BelongsToMany => $this->buildBelongsToManyQuery($resolved, $ownerTable, $ownerPK, $primaryKeyValue), - default => throw new InvalidArgumentException( - message: sprintf('Property "%s" is not a collection relation on %s.', $relation, $model->getName()), - ), - }; - } - - private function buildHasManyQuery(HasMany $relation, string $ownerTable, string $ownerPK, PrimaryKey $primaryKeyValue): SelectQueryBuilder - { - $relatedClassName = $relation->property->getIterableType()->getName(); - $fk = $relation->ownerJoin ?? str(string: $ownerTable)->singularizeLastWord() . '_' . $ownerPK; - - return query(model: $relatedClassName) - ->onDatabase(databaseTag: $this->onDatabase) - ->select() - ->whereField(field: $fk, value: $primaryKeyValue); - } - - private function buildHasManyThroughQuery(HasManyThrough $relation, string $ownerTable, string $ownerPK, PrimaryKey $primaryKeyValue): SelectQueryBuilder - { - $relatedClassName = $relation->property->getIterableType()->getName(); - $intermediateModel = inspect(model: $relation->through); - $intermediateTable = $intermediateModel->getTableName(); - $intermediatePK = $intermediateModel->getPrimaryKey(); - - $ownerFK = $relation->ownerJoin ?? str(string: $ownerTable)->singularizeLastWord() . '_' . $ownerPK; - $targetFK = $relation->throughOwnerJoin ?? str(string: $intermediateTable)->singularizeLastWord() . '_' . $intermediatePK; - - return query(model: $relatedClassName) - ->onDatabase(databaseTag: $this->onDatabase) - ->select() - ->join(sprintf( - 'INNER JOIN %s ON %s.%s = %s.%s', - $intermediateTable, - inspect(model: $relatedClassName)->getTableName(), - $targetFK, - $intermediateTable, - $intermediatePK, - )) - ->whereRaw(sprintf('%s.%s = ?', $intermediateTable, $ownerFK), $primaryKeyValue); - } - - private function buildBelongsToManyQuery(BelongsToMany $relation, string $ownerTable, string $ownerPK, PrimaryKey $primaryKeyValue): SelectQueryBuilder - { - $relatedClassName = $relation->property->getIterableType()->getName(); - $targetModel = inspect(model: $relatedClassName); - $targetTable = $targetModel->getTableName(); - $targetPK = $targetModel->getPrimaryKey(); - - $pivotTable = $relation->pivot ?? arr([$ownerTable, $targetTable])->sort()->implode('_')->toString(); - $ownerFK = $relation->ownerJoin ?? str(string: $ownerTable)->singularizeLastWord() . '_' . $ownerPK; - $targetFK = $relation->relatedOwnerJoin ?? str(string: $targetTable)->singularizeLastWord() . '_' . $targetPK; + if ($resolved === null) { + throw new InvalidArgumentException( + message: sprintf('Property "%s" is not a relation on %s.', $relation, $model->getName()), + ); + } - return query(model: $relatedClassName) - ->onDatabase(databaseTag: $this->onDatabase) - ->select() - ->join(sprintf( - 'INNER JOIN %s ON %s.%s = %s.%s', - $pivotTable, - $pivotTable, - $targetFK, - $targetTable, - $targetPK, - )) - ->whereRaw(sprintf('%s.%s = ?', $pivotTable, $ownerFK), $primaryKeyValue); + return $resolved->query( + primaryKey: $model->getPrimaryKeyValue(), + onDatabase: $this->onDatabase, + ); } /** diff --git a/packages/database/src/Relation.php b/packages/database/src/Relation.php index 0a27f7756..911356a19 100644 --- a/packages/database/src/Relation.php +++ b/packages/database/src/Relation.php @@ -2,10 +2,12 @@ namespace Tempest\Database; +use Tempest\Database\Builder\QueryBuilders\QueryBuilder; use Tempest\Database\QueryStatements\JoinStatement; use Tempest\Database\QueryStatements\WhereExistsStatement; use Tempest\Reflection\PropertyAttribute; use Tempest\Support\Arr\ImmutableArray; +use UnitEnum; interface Relation extends PropertyAttribute { @@ -22,4 +24,6 @@ public function getSelectFields(): ImmutableArray; public function getJoinStatement(): JoinStatement; public function getExistsStatement(): WhereExistsStatement; + + public function query(PrimaryKey $primaryKey, null|string|UnitEnum $onDatabase = null): QueryBuilder; } diff --git a/tests/Integration/Database/Builder/IsDatabaseModelTest.php b/tests/Integration/Database/Builder/IsDatabaseModelTest.php index fdc422045..4a202187e 100644 --- a/tests/Integration/Database/Builder/IsDatabaseModelTest.php +++ b/tests/Integration/Database/Builder/IsDatabaseModelTest.php @@ -7,6 +7,7 @@ use Carbon\Carbon; use DateTime as NativeDateTime; use DateTimeImmutable; +use BadMethodCallException; use InvalidArgumentException; use Tempest\Database\BelongsTo; use Tempest\Database\Builder\QueryBuilders\QueryBuilder; @@ -341,7 +342,7 @@ public function test_query_has_many_returns_scoped_results(): void Book::create(title: 'Book 3', author: $authorA); Book::create(title: 'Other Book', author: $authorB); - $books = $authorA->query( 'books')->all(); + $books = $authorA->query('books')->select()->all(); $this->assertCount(3, $books); $this->assertContainsOnlyInstancesOf(Book::class, $books); @@ -365,7 +366,7 @@ public function test_query_has_many_supports_where(): void Book::create(title: 'Beta', author: $author); Book::create(title: 'Gamma', author: $author); - $books = $author->query( 'books') + $books = $author->query('books')->select() ->whereField(field: 'title', value: 'Beta') ->all(); @@ -391,7 +392,7 @@ public function test_query_has_many_supports_limit(): void Book::create(title: 'Book 2', author: $author); Book::create(title: 'Book 3', author: $author); - $books = $author->query( 'books')->limit(limit: 2)->all(); + $books = $author->query('books')->select()->limit(limit: 2)->all(); $this->assertCount(2, $books); } @@ -416,7 +417,7 @@ public function test_query_has_many_through_returns_scoped_results(): void Reviewer::create(name: 'Bob', bookReview: $reviewA2); Reviewer::create(name: 'Charlie', bookReview: $reviewB1); - $reviewers = $tagA->query( 'reviewers')->all(); + $reviewers = $tagA->query('reviewers')->select()->all(); $this->assertCount(2, $reviewers); $this->assertContainsOnlyInstancesOf(Reviewer::class, $reviewers); @@ -439,7 +440,7 @@ public function test_query_has_many_through_supports_where(): void Reviewer::create(name: 'Alice', bookReview: $review1); Reviewer::create(name: 'Bob', bookReview: $review2); - $reviewers = $tag->query( 'reviewers') + $reviewers = $tag->query('reviewers')->select() ->whereField(field: 'name', value: 'Alice') ->all(); @@ -470,7 +471,7 @@ public function test_query_belongs_to_many_returns_scoped_results(): void query(model: 'books_tags')->insert(['book_id' => $book2->id->value, 'tag_id' => $tagA->id->value])->execute(); query(model: 'books_tags')->insert(['book_id' => $book3->id->value, 'tag_id' => $tagB->id->value])->execute(); - $books = $tagA->query( 'books')->all(); + $books = $tagA->query('books')->select()->all(); $this->assertCount(2, $books); $this->assertContainsOnlyInstancesOf(Book::class, $books); @@ -496,7 +497,7 @@ public function test_query_belongs_to_many_supports_where(): void query(model: 'books_tags')->insert(['book_id' => $book1->id->value, 'tag_id' => $tag->id->value])->execute(); query(model: 'books_tags')->insert(['book_id' => $book2->id->value, 'tag_id' => $tag->id->value])->execute(); - $books = $tag->query( 'books') + $books = $tag->query('books')->select() ->whereField(field: 'title', value: 'Alpha') ->all(); @@ -521,7 +522,7 @@ public function test_query_has_many_with_explicit_attribute(): void ->insert(['title' => 'Post 2', 'body' => 'Body 2', 'test_user_id' => $user->id->value]) ->execute(); - $posts = $user->query( 'posts')->all(); + $posts = $user->query('posts')->select()->all(); $this->assertCount(2, $posts); $this->assertContainsOnlyInstancesOf(TestPost::class, $posts); @@ -538,9 +539,9 @@ public function test_query_throws_for_non_collection_relation(): void $book = Book::create(title: 'Test'); - $this->expectException(InvalidArgumentException::class); + $this->expectException(BadMethodCallException::class); - $book->query( 'author'); + $book->query('author'); } public function test_query_throws_for_nonexistent_property(): void @@ -555,7 +556,7 @@ public function test_query_throws_for_nonexistent_property(): void $this->expectException(InvalidArgumentException::class); - $author->query( 'nonexistent'); + $author->query('nonexistent'); } public function test_query_throws_for_unsaved_model(): void @@ -564,7 +565,7 @@ public function test_query_throws_for_unsaved_model(): void $this->expectException(InvalidArgumentException::class); - $author->query( 'books'); + $author->query('books'); } public function test_query_has_many_returns_empty_for_no_results(): void @@ -578,7 +579,7 @@ public function test_query_has_many_returns_empty_for_no_results(): void $author = Author::create(name: 'Author', type: AuthorType::A); - $books = $author->query( 'books')->all(); + $books = $author->query('books')->select()->all(); $this->assertCount(0, $books); } From 906073650360beb27bad9ea4ef3deb1f031332eb Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Sat, 4 Apr 2026 03:23:39 +0100 Subject: [PATCH 12/31] test(database): add count, update, and delete tests for query() across all relation types --- .../Database/Builder/IsDatabaseModelTest.php | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/tests/Integration/Database/Builder/IsDatabaseModelTest.php b/tests/Integration/Database/Builder/IsDatabaseModelTest.php index 4a202187e..8243927c7 100644 --- a/tests/Integration/Database/Builder/IsDatabaseModelTest.php +++ b/tests/Integration/Database/Builder/IsDatabaseModelTest.php @@ -584,6 +584,123 @@ public function test_query_has_many_returns_empty_for_no_results(): void $this->assertCount(0, $books); } + public function test_query_has_many_count(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + ); + + $authorA = Author::create(name: 'Author A', type: AuthorType::A); + $authorB = Author::create(name: 'Author B', type: AuthorType::B); + + Book::create(title: 'Book 1', author: $authorA); + Book::create(title: 'Book 2', author: $authorA); + Book::create(title: 'Book 3', author: $authorA); + Book::create(title: 'Other', author: $authorB); + + $count = $authorA->query('books')->count()->execute(); + + $this->assertSame(3, $count); + } + + public function test_query_has_many_update(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + ); + + $authorA = Author::create(name: 'Author A', type: AuthorType::A); + $authorB = Author::create(name: 'Author B', type: AuthorType::B); + + Book::create(title: 'Old Title 1', author: $authorA); + Book::create(title: 'Old Title 2', author: $authorA); + Book::create(title: 'Keep This', author: $authorB); + + $authorA->query('books')->update(title: 'Updated')->execute(); + + $booksA = $authorA->query('books')->select()->all(); + $booksB = $authorB->query('books')->select()->all(); + + $this->assertSame('Updated', $booksA[0]->title); + $this->assertSame('Updated', $booksA[1]->title); + $this->assertSame('Keep This', $booksB[0]->title); + } + + public function test_query_has_many_delete(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + ); + + $authorA = Author::create(name: 'Author A', type: AuthorType::A); + $authorB = Author::create(name: 'Author B', type: AuthorType::B); + + Book::create(title: 'Book 1', author: $authorA); + Book::create(title: 'Book 2', author: $authorA); + Book::create(title: 'Keep This', author: $authorB); + + $authorA->query('books')->delete()->execute(); + + $this->assertSame(0, $authorA->query('books')->count()->execute()); + $this->assertSame(1, $authorB->query('books')->count()->execute()); + } + + public function test_query_has_many_through_count(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreateTagTable::class, + CreateBookReviewTable::class, + CreateReviewerTable::class, + ); + + $tagA = Tag::create(label: 'fantasy'); + $tagB = Tag::create(label: 'sci-fi'); + + $reviewA1 = BookReview::create(content: 'Great', tag: $tagA); + $reviewA2 = BookReview::create(content: 'Good', tag: $tagA); + $reviewB1 = BookReview::create(content: 'Meh', tag: $tagB); + + Reviewer::create(name: 'Alice', bookReview: $reviewA1); + Reviewer::create(name: 'Bob', bookReview: $reviewA2); + Reviewer::create(name: 'Charlie', bookReview: $reviewB1); + + $this->assertSame(2, $tagA->query('reviewers')->count()->execute()); + $this->assertSame(1, $tagB->query('reviewers')->count()->execute()); + } + + public function test_query_belongs_to_many_count(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + CreateTagTable::class, + CreateBookTagTable::class, + ); + + $author = Author::create(name: 'Author', type: AuthorType::A); + $book1 = Book::create(title: 'Book 1', author: $author); + $book2 = Book::create(title: 'Book 2', author: $author); + + $tag = Tag::create(label: 'fantasy'); + + query(model: 'books_tags')->insert(['book_id' => $book1->id->value, 'tag_id' => $tag->id->value])->execute(); + query(model: 'books_tags')->insert(['book_id' => $book2->id->value, 'tag_id' => $tag->id->value])->execute(); + + $this->assertSame(2, $tag->query('books')->count()->execute()); + } + public function test_has_many_through_relation(): void { $this->database->migrate( From c92e9ed3a15bfab191cc672a7d63f1f4a9bf068e Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Sat, 4 Apr 2026 03:24:58 +0100 Subject: [PATCH 13/31] test(database): add update and delete tests for HasManyThrough and BelongsToMany query() --- .../Database/Builder/IsDatabaseModelTest.php | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/tests/Integration/Database/Builder/IsDatabaseModelTest.php b/tests/Integration/Database/Builder/IsDatabaseModelTest.php index 8243927c7..6d5481a6a 100644 --- a/tests/Integration/Database/Builder/IsDatabaseModelTest.php +++ b/tests/Integration/Database/Builder/IsDatabaseModelTest.php @@ -701,6 +701,119 @@ public function test_query_belongs_to_many_count(): void $this->assertSame(2, $tag->query('books')->count()->execute()); } + public function test_query_has_many_through_update(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreateTagTable::class, + CreateBookReviewTable::class, + CreateReviewerTable::class, + ); + + $tagA = Tag::create(label: 'fantasy'); + $tagB = Tag::create(label: 'sci-fi'); + + $reviewA = BookReview::create(content: 'Great', tag: $tagA); + $reviewB = BookReview::create(content: 'Meh', tag: $tagB); + + Reviewer::create(name: 'Alice', bookReview: $reviewA); + Reviewer::create(name: 'Bob', bookReview: $reviewB); + + $tagA->query('reviewers')->update(name: 'Updated')->execute(); + + $reviewersA = $tagA->query('reviewers')->select()->all(); + $reviewersB = $tagB->query('reviewers')->select()->all(); + + $this->assertSame('Updated', $reviewersA[0]->name); + $this->assertSame('Bob', $reviewersB[0]->name); + } + + public function test_query_has_many_through_delete(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreateTagTable::class, + CreateBookReviewTable::class, + CreateReviewerTable::class, + ); + + $tagA = Tag::create(label: 'fantasy'); + $tagB = Tag::create(label: 'sci-fi'); + + $reviewA = BookReview::create(content: 'Great', tag: $tagA); + $reviewB = BookReview::create(content: 'Meh', tag: $tagB); + + Reviewer::create(name: 'Alice', bookReview: $reviewA); + Reviewer::create(name: 'Bob', bookReview: $reviewB); + + $tagA->query('reviewers')->delete()->execute(); + + $this->assertSame(0, $tagA->query('reviewers')->count()->execute()); + $this->assertSame(1, $tagB->query('reviewers')->count()->execute()); + } + + public function test_query_belongs_to_many_delete(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + CreateTagTable::class, + CreateBookTagTable::class, + ); + + $author = Author::create(name: 'Author', type: AuthorType::A); + $book1 = Book::create(title: 'Book 1', author: $author); + $book2 = Book::create(title: 'Book 2', author: $author); + $book3 = Book::create(title: 'Book 3', author: $author); + + $tagA = Tag::create(label: 'fantasy'); + $tagB = Tag::create(label: 'sci-fi'); + + query(model: 'books_tags')->insert(['book_id' => $book1->id->value, 'tag_id' => $tagA->id->value])->execute(); + query(model: 'books_tags')->insert(['book_id' => $book2->id->value, 'tag_id' => $tagA->id->value])->execute(); + query(model: 'books_tags')->insert(['book_id' => $book3->id->value, 'tag_id' => $tagB->id->value])->execute(); + + $tagA->query('books')->delete()->execute(); + + $this->assertSame(0, $tagA->query('books')->count()->execute()); + $this->assertSame(1, $tagB->query('books')->count()->execute()); + } + + public function test_query_belongs_to_many_update(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + CreateTagTable::class, + CreateBookTagTable::class, + ); + + $author = Author::create(name: 'Author', type: AuthorType::A); + $book1 = Book::create(title: 'Old 1', author: $author); + $book2 = Book::create(title: 'Old 2', author: $author); + $book3 = Book::create(title: 'Keep', author: $author); + + $tagA = Tag::create(label: 'fantasy'); + $tagB = Tag::create(label: 'sci-fi'); + + query(model: 'books_tags')->insert(['book_id' => $book1->id->value, 'tag_id' => $tagA->id->value])->execute(); + query(model: 'books_tags')->insert(['book_id' => $book2->id->value, 'tag_id' => $tagA->id->value])->execute(); + query(model: 'books_tags')->insert(['book_id' => $book3->id->value, 'tag_id' => $tagB->id->value])->execute(); + + $tagA->query('books')->update(title: 'Updated')->execute(); + + $booksA = $tagA->query('books')->select()->all(); + $booksB = $tagB->query('books')->select()->all(); + + $this->assertSame('Updated', $booksA[0]->title); + $this->assertSame('Updated', $booksA[1]->title); + $this->assertSame('Keep', $booksB[0]->title); + } + public function test_has_many_through_relation(): void { $this->database->migrate( From d91df17879636bba053f72029ca9c78dc87ababf Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Sat, 4 Apr 2026 03:31:49 +0100 Subject: [PATCH 14/31] fix(database): add missing named arguments in QueryBuilder scope calls --- .../database/src/Builder/QueryBuilders/QueryBuilder.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/database/src/Builder/QueryBuilders/QueryBuilder.php b/packages/database/src/Builder/QueryBuilders/QueryBuilder.php index 3fe50f08c..e6641909a 100644 --- a/packages/database/src/Builder/QueryBuilders/QueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/QueryBuilder.php @@ -57,7 +57,7 @@ public function select(string ...$columns): SelectQueryBuilder return new SelectQueryBuilder( model: $this->model, fields: $columns !== [] ? arr($columns)->unique() : null, - )->onDatabase($this->onDatabase)->applyScopes($this->scopes); + )->onDatabase(databaseTag: $this->onDatabase)->applyScopes(scopes: $this->scopes); } /** @@ -104,7 +104,7 @@ public function update(mixed ...$values): UpdateQueryBuilder model: $this->model, values: $values, serializerFactory: get(SerializerFactory::class), - )->onDatabase($this->onDatabase)->applyScopes($this->scopes); + )->onDatabase(databaseTag: $this->onDatabase)->applyScopes(scopes: $this->scopes); } /** @@ -122,7 +122,7 @@ public function update(mixed ...$values): UpdateQueryBuilder */ public function delete(): DeleteQueryBuilder { - return new DeleteQueryBuilder($this->model)->onDatabase($this->onDatabase)->applyScopes($this->scopes); + return new DeleteQueryBuilder(model: $this->model)->onDatabase(databaseTag: $this->onDatabase)->applyScopes(scopes: $this->scopes); } /** @@ -140,7 +140,7 @@ public function count(?string $column = null): CountQueryBuilder return new CountQueryBuilder( model: $this->model, column: $column, - )->onDatabase($this->onDatabase)->applyScopes($this->scopes); + )->onDatabase(databaseTag: $this->onDatabase)->applyScopes(scopes: $this->scopes); } /** From cc07f5d7cde12ebed6ba9eaca2ead1e7cba945d5 Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Sat, 4 Apr 2026 03:37:59 +0100 Subject: [PATCH 15/31] refactor(database): add WhereFieldScope for HasMany, keep WhereRawScope for subquery relations --- .../Builder/QueryBuilders/WhereFieldScope.php | 18 ++++++++++++++++++ packages/database/src/HasMany.php | 9 ++------- 2 files changed, 20 insertions(+), 7 deletions(-) create mode 100644 packages/database/src/Builder/QueryBuilders/WhereFieldScope.php diff --git a/packages/database/src/Builder/QueryBuilders/WhereFieldScope.php b/packages/database/src/Builder/QueryBuilders/WhereFieldScope.php new file mode 100644 index 000000000..0635950a8 --- /dev/null +++ b/packages/database/src/Builder/QueryBuilders/WhereFieldScope.php @@ -0,0 +1,18 @@ +whereField(field: $this->field, value: $this->value); + } +} diff --git a/packages/database/src/HasMany.php b/packages/database/src/HasMany.php index e24c23b91..68aa796a3 100644 --- a/packages/database/src/HasMany.php +++ b/packages/database/src/HasMany.php @@ -7,7 +7,7 @@ use Attribute; use Tempest\Database\Builder\ModelInspector; use Tempest\Database\Builder\QueryBuilders\QueryBuilder; -use Tempest\Database\Builder\QueryBuilders\WhereRawScope; +use Tempest\Database\Builder\QueryBuilders\WhereFieldScope; use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn; use Tempest\Database\QueryStatements\FieldStatement; use Tempest\Database\QueryStatements\JoinStatement; @@ -190,14 +190,9 @@ public function query(PrimaryKey $primaryKey, null|string|UnitEnum $onDatabase = $parentPK = $parentModel->getPrimaryKey(); $fk = $this->ownerJoin ?? str(string: $parentTable)->singularizeLastWord() . '_' . $parentPK; - $relatedTable = inspect(model: $relatedClassName)->getTableName(); - return query(model: $relatedClassName) ->onDatabase(databaseTag: $onDatabase) - ->scope(new WhereRawScope( - statement: sprintf('%s.%s = ?', $relatedTable, $fk), - binding: $primaryKey, - )); + ->scope(new WhereFieldScope(field: $fk, value: $primaryKey)); } private function isSelfReferencing(): bool From 4061af00f99f50dec3fdf67fe1946909857d4588 Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Sat, 4 Apr 2026 03:41:03 +0100 Subject: [PATCH 16/31] fix(database): add missing named argument for scope() calls in relation query methods --- packages/database/src/BelongsTo.php | 2 +- packages/database/src/BelongsToMany.php | 3 +-- .../Builder/QueryBuilders/QueryBuilder.php | 17 +++++++++----- packages/database/src/HasMany.php | 3 +-- packages/database/src/HasManyThrough.php | 3 +-- packages/database/src/HasOne.php | 2 +- packages/database/src/HasOneThrough.php | 2 +- packages/database/src/IsDatabaseModel.php | 2 +- .../Database/Builder/IsDatabaseModelTest.php | 22 ++++++++++++------- 9 files changed, 33 insertions(+), 23 deletions(-) diff --git a/packages/database/src/BelongsTo.php b/packages/database/src/BelongsTo.php index a9442ff96..adf863f0d 100644 --- a/packages/database/src/BelongsTo.php +++ b/packages/database/src/BelongsTo.php @@ -8,13 +8,13 @@ use BadMethodCallException; use Tempest\Database\Builder\ModelInspector; use Tempest\Database\Builder\QueryBuilders\QueryBuilder; -use UnitEnum; use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn; use Tempest\Database\QueryStatements\FieldStatement; use Tempest\Database\QueryStatements\JoinStatement; use Tempest\Database\QueryStatements\WhereExistsStatement; use Tempest\Reflection\PropertyReflector; use Tempest\Support\Arr\ImmutableArray; +use UnitEnum; use function Tempest\Support\str; diff --git a/packages/database/src/BelongsToMany.php b/packages/database/src/BelongsToMany.php index 6ce5e4fb9..e8ce4b088 100644 --- a/packages/database/src/BelongsToMany.php +++ b/packages/database/src/BelongsToMany.php @@ -16,7 +16,6 @@ use Tempest\Support\Arr\ImmutableArray; use UnitEnum; -use function Tempest\Database\query; use function Tempest\Support\arr; use function Tempest\Support\str; @@ -415,7 +414,7 @@ public function query(PrimaryKey $primaryKey, null|string|UnitEnum $onDatabase = return query(model: $relatedClassName) ->onDatabase(databaseTag: $onDatabase) - ->scope(new WhereRawScope( + ->scope(scope: new WhereRawScope( statement: sprintf( '%s.%s IN (SELECT %s FROM %s WHERE %s = ?)', $targetTable, diff --git a/packages/database/src/Builder/QueryBuilders/QueryBuilder.php b/packages/database/src/Builder/QueryBuilders/QueryBuilder.php index e6641909a..889510e02 100644 --- a/packages/database/src/Builder/QueryBuilders/QueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/QueryBuilder.php @@ -39,7 +39,6 @@ public function scope(QueryScope $scope): self return $this; } - /** * Creates a `SELECT` query builder for retrieving records from the database. * @@ -57,7 +56,9 @@ public function select(string ...$columns): SelectQueryBuilder return new SelectQueryBuilder( model: $this->model, fields: $columns !== [] ? arr($columns)->unique() : null, - )->onDatabase(databaseTag: $this->onDatabase)->applyScopes(scopes: $this->scopes); + ) + ->onDatabase(databaseTag: $this->onDatabase) + ->applyScopes(scopes: $this->scopes); } /** @@ -104,7 +105,9 @@ public function update(mixed ...$values): UpdateQueryBuilder model: $this->model, values: $values, serializerFactory: get(SerializerFactory::class), - )->onDatabase(databaseTag: $this->onDatabase)->applyScopes(scopes: $this->scopes); + ) + ->onDatabase(databaseTag: $this->onDatabase) + ->applyScopes(scopes: $this->scopes); } /** @@ -122,7 +125,9 @@ public function update(mixed ...$values): UpdateQueryBuilder */ public function delete(): DeleteQueryBuilder { - return new DeleteQueryBuilder(model: $this->model)->onDatabase(databaseTag: $this->onDatabase)->applyScopes(scopes: $this->scopes); + return new DeleteQueryBuilder(model: $this->model) + ->onDatabase(databaseTag: $this->onDatabase) + ->applyScopes(scopes: $this->scopes); } /** @@ -140,7 +145,9 @@ public function count(?string $column = null): CountQueryBuilder return new CountQueryBuilder( model: $this->model, column: $column, - )->onDatabase(databaseTag: $this->onDatabase)->applyScopes(scopes: $this->scopes); + ) + ->onDatabase(databaseTag: $this->onDatabase) + ->applyScopes(scopes: $this->scopes); } /** diff --git a/packages/database/src/HasMany.php b/packages/database/src/HasMany.php index 68aa796a3..131ed75ed 100644 --- a/packages/database/src/HasMany.php +++ b/packages/database/src/HasMany.php @@ -16,7 +16,6 @@ use Tempest\Support\Arr\ImmutableArray; use UnitEnum; -use function Tempest\Database\query; use function Tempest\Support\str; #[Attribute(Attribute::TARGET_PROPERTY)] @@ -192,7 +191,7 @@ public function query(PrimaryKey $primaryKey, null|string|UnitEnum $onDatabase = return query(model: $relatedClassName) ->onDatabase(databaseTag: $onDatabase) - ->scope(new WhereFieldScope(field: $fk, value: $primaryKey)); + ->scope(scope: new WhereFieldScope(field: $fk, value: $primaryKey)); } private function isSelfReferencing(): bool diff --git a/packages/database/src/HasManyThrough.php b/packages/database/src/HasManyThrough.php index 96296dac7..8ca17315f 100644 --- a/packages/database/src/HasManyThrough.php +++ b/packages/database/src/HasManyThrough.php @@ -16,7 +16,6 @@ use Tempest\Support\Arr\ImmutableArray; use UnitEnum; -use function Tempest\Database\query; use function Tempest\Support\str; #[Attribute(flags: Attribute::TARGET_PROPERTY)] @@ -381,7 +380,7 @@ public function query(PrimaryKey $primaryKey, null|string|UnitEnum $onDatabase = return query(model: $relatedClassName) ->onDatabase(databaseTag: $onDatabase) - ->scope(new WhereRawScope( + ->scope(scope: new WhereRawScope( statement: sprintf( '%s IN (SELECT %s FROM %s WHERE %s = ?)', $relatedTable . '.' . $targetFK, diff --git a/packages/database/src/HasOne.php b/packages/database/src/HasOne.php index 6498af967..ce929c57a 100644 --- a/packages/database/src/HasOne.php +++ b/packages/database/src/HasOne.php @@ -8,13 +8,13 @@ use BadMethodCallException; use Tempest\Database\Builder\ModelInspector; use Tempest\Database\Builder\QueryBuilders\QueryBuilder; -use UnitEnum; use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn; use Tempest\Database\QueryStatements\FieldStatement; use Tempest\Database\QueryStatements\JoinStatement; use Tempest\Database\QueryStatements\WhereExistsStatement; use Tempest\Reflection\PropertyReflector; use Tempest\Support\Arr\ImmutableArray; +use UnitEnum; use function Tempest\Support\str; diff --git a/packages/database/src/HasOneThrough.php b/packages/database/src/HasOneThrough.php index 89701c326..8380ddab1 100644 --- a/packages/database/src/HasOneThrough.php +++ b/packages/database/src/HasOneThrough.php @@ -8,13 +8,13 @@ use BadMethodCallException; use Tempest\Database\Builder\ModelInspector; use Tempest\Database\Builder\QueryBuilders\QueryBuilder; -use UnitEnum; use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn; use Tempest\Database\QueryStatements\FieldStatement; use Tempest\Database\QueryStatements\JoinStatement; use Tempest\Database\QueryStatements\WhereExistsStatement; use Tempest\Reflection\PropertyReflector; use Tempest\Support\Arr\ImmutableArray; +use UnitEnum; use function Tempest\Support\str; diff --git a/packages/database/src/IsDatabaseModel.php b/packages/database/src/IsDatabaseModel.php index aaaecc9fe..ebd9ef6db 100644 --- a/packages/database/src/IsDatabaseModel.php +++ b/packages/database/src/IsDatabaseModel.php @@ -4,6 +4,7 @@ namespace Tempest\Database; +use InvalidArgumentException; use Tempest\Database\Builder\QueryBuilders\CountQueryBuilder; use Tempest\Database\Builder\QueryBuilders\InsertQueryBuilder; use Tempest\Database\Builder\QueryBuilders\QueryBuilder; @@ -13,7 +14,6 @@ use Tempest\Reflection\PropertyReflector; use Tempest\Router\IsBindingValue; use Tempest\Validation\SkipValidation; -use InvalidArgumentException; use UnitEnum; use function Tempest\Support\arr; diff --git a/tests/Integration/Database/Builder/IsDatabaseModelTest.php b/tests/Integration/Database/Builder/IsDatabaseModelTest.php index 6d5481a6a..71a7f5c78 100644 --- a/tests/Integration/Database/Builder/IsDatabaseModelTest.php +++ b/tests/Integration/Database/Builder/IsDatabaseModelTest.php @@ -4,10 +4,10 @@ namespace Tests\Tempest\Integration\Database\Builder; +use BadMethodCallException; use Carbon\Carbon; use DateTime as NativeDateTime; use DateTimeImmutable; -use BadMethodCallException; use InvalidArgumentException; use Tempest\Database\BelongsTo; use Tempest\Database\Builder\QueryBuilders\QueryBuilder; @@ -36,10 +36,14 @@ use Tempest\Validation\Rules\IsBetween; use Tempest\Validation\SkipValidation; use Tests\Tempest\Fixtures\Migrations\CreateAuthorTable; +use Tests\Tempest\Fixtures\Migrations\CreateBookReviewTable; use Tests\Tempest\Fixtures\Migrations\CreateBookTable; +use Tests\Tempest\Fixtures\Migrations\CreateBookTagTable; use Tests\Tempest\Fixtures\Migrations\CreateChapterTable; use Tests\Tempest\Fixtures\Migrations\CreateIsbnTable; use Tests\Tempest\Fixtures\Migrations\CreatePublishersTable; +use Tests\Tempest\Fixtures\Migrations\CreateReviewerTable; +use Tests\Tempest\Fixtures\Migrations\CreateTagTable; use Tests\Tempest\Fixtures\Models\A; use Tests\Tempest\Fixtures\Models\AWithEager; use Tests\Tempest\Fixtures\Models\AWithLazy; @@ -47,10 +51,6 @@ use Tests\Tempest\Fixtures\Models\AWithVirtual; use Tests\Tempest\Fixtures\Models\B; use Tests\Tempest\Fixtures\Models\C; -use Tests\Tempest\Fixtures\Migrations\CreateBookReviewTable; -use Tests\Tempest\Fixtures\Migrations\CreateBookTagTable; -use Tests\Tempest\Fixtures\Migrations\CreateReviewerTable; -use Tests\Tempest\Fixtures\Migrations\CreateTagTable; use Tests\Tempest\Fixtures\Modules\Books\Models\Author; use Tests\Tempest\Fixtures\Modules\Books\Models\AuthorType; use Tests\Tempest\Fixtures\Modules\Books\Models\Book; @@ -366,7 +366,9 @@ public function test_query_has_many_supports_where(): void Book::create(title: 'Beta', author: $author); Book::create(title: 'Gamma', author: $author); - $books = $author->query('books')->select() + $books = $author + ->query('books') + ->select() ->whereField(field: 'title', value: 'Beta') ->all(); @@ -440,7 +442,9 @@ public function test_query_has_many_through_supports_where(): void Reviewer::create(name: 'Alice', bookReview: $review1); Reviewer::create(name: 'Bob', bookReview: $review2); - $reviewers = $tag->query('reviewers')->select() + $reviewers = $tag + ->query('reviewers') + ->select() ->whereField(field: 'name', value: 'Alice') ->all(); @@ -497,7 +501,9 @@ public function test_query_belongs_to_many_supports_where(): void query(model: 'books_tags')->insert(['book_id' => $book1->id->value, 'tag_id' => $tag->id->value])->execute(); query(model: 'books_tags')->insert(['book_id' => $book2->id->value, 'tag_id' => $tag->id->value])->execute(); - $books = $tag->query('books')->select() + $books = $tag + ->query('books') + ->select() ->whereField(field: 'title', value: 'Alpha') ->all(); From da368e166e1c9fabf2afbc27682ed1f653e41002 Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Sat, 4 Apr 2026 04:02:43 +0100 Subject: [PATCH 17/31] fix(database): add ON DELETE CASCADE to books_tags pivot migration for BelongsToMany delete support --- tests/Fixtures/Migrations/CreateBookTagTable.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/Fixtures/Migrations/CreateBookTagTable.php b/tests/Fixtures/Migrations/CreateBookTagTable.php index d1e952d8d..12bb0890c 100644 --- a/tests/Fixtures/Migrations/CreateBookTagTable.php +++ b/tests/Fixtures/Migrations/CreateBookTagTable.php @@ -6,6 +6,7 @@ use Tempest\Database\MigratesDown; use Tempest\Database\MigratesUp; +use Tempest\Database\QueryStatements\OnDelete; use Tempest\Database\QueryStatement; use Tempest\Database\QueryStatements\CreateTableStatement; use Tempest\Database\QueryStatements\DropTableStatement; @@ -18,8 +19,8 @@ public function up(): QueryStatement { return new CreateTableStatement(tableName: 'books_tags') ->primary() - ->belongsTo(local: 'books_tags.book_id', foreign: 'books.id') - ->belongsTo(local: 'books_tags.tag_id', foreign: 'tags.id'); + ->belongsTo(local: 'books_tags.book_id', foreign: 'books.id', onDelete: OnDelete::CASCADE) + ->belongsTo(local: 'books_tags.tag_id', foreign: 'tags.id', onDelete: OnDelete::CASCADE); } public function down(): QueryStatement From 329f1b64d9469917accb4309913df65c30bf273a Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Sat, 4 Apr 2026 04:03:40 +0100 Subject: [PATCH 18/31] docs(database): add documentation for query() method on relation properties --- docs/1-essentials/03-database.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/docs/1-essentials/03-database.md b/docs/1-essentials/03-database.md index 64eb3f168..c75c35b97 100644 --- a/docs/1-essentials/03-database.md +++ b/docs/1-essentials/03-database.md @@ -757,6 +757,35 @@ query(model: Author::class)->delete()->whereDoesntHave(relation: 'books')->execu query(model: Author::class)->update(verified: true)->whereHas(relation: 'books')->execute(); ``` +### Querying relation properties + +Use `query()` on a model instance to get a query builder scoped to a collection relation. This is similar to Laravel's `$author->books()` pattern: + +```php +// Select with constraints +$books = $author->query('books')->select()->whereField(field: 'title', value: 'Timeline Taxi')->all(); +$books = $author->query('books')->select()->limit(limit: 5)->all(); + +// Count related records +$count = $author->query('books')->count()->execute(); + +// Update scoped to relation +$author->query('books')->update(title: 'Updated')->execute(); + +// Delete scoped to relation +$author->query('books')->delete()->execute(); +``` + +The `query()` method works with `HasMany`, `HasManyThrough`, and `BelongsToMany` relations — any relation that returns a collection. Calling it on a singular relation like `BelongsTo` or `HasOne` will throw an exception. + +```php +// HasManyThrough +$tag->query('reviewers')->select()->all(); + +// BelongsToMany +$tag->query('books')->select()->all(); +``` + ## Migrations When persisting objects to the database, a table is required to store the data. A migration is a file that instructs the framework how to manage the database schema. From 515073c4820339f151c2ed1972b9d46d09961115 Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Sat, 4 Apr 2026 04:06:01 +0100 Subject: [PATCH 19/31] docs(database): clarify difference between global query() and instance query() for relations --- docs/1-essentials/03-database.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/1-essentials/03-database.md b/docs/1-essentials/03-database.md index c75c35b97..695c70c7d 100644 --- a/docs/1-essentials/03-database.md +++ b/docs/1-essentials/03-database.md @@ -759,7 +759,7 @@ query(model: Author::class)->update(verified: true)->whereHas(relation: 'books') ### Querying relation properties -Use `query()` on a model instance to get a query builder scoped to a collection relation. This is similar to Laravel's `$author->books()` pattern: +While the global `query(Model::class)` function creates a query builder for any model, calling `query()` on a model _instance_ returns a query builder scoped to a specific relation. The returned `QueryBuilder` is pre-filtered to only include records belonging to that model instance: ```php // Select with constraints From 2815b4cc2dacfc084410d0c66a3cf32f02230250 Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Sat, 4 Apr 2026 04:06:59 +0100 Subject: [PATCH 20/31] docs(database): clarify query() comes from IsDatabaseModel trait, not a public instance method --- docs/1-essentials/03-database.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/1-essentials/03-database.md b/docs/1-essentials/03-database.md index 695c70c7d..63ea035b1 100644 --- a/docs/1-essentials/03-database.md +++ b/docs/1-essentials/03-database.md @@ -759,7 +759,7 @@ query(model: Author::class)->update(verified: true)->whereHas(relation: 'books') ### Querying relation properties -While the global `query(Model::class)` function creates a query builder for any model, calling `query()` on a model _instance_ returns a query builder scoped to a specific relation. The returned `QueryBuilder` is pre-filtered to only include records belonging to that model instance: +While the global `query(Model::class)` function creates a query builder for any model, models using the `IsDatabaseModel` trait also have a `query()` method that returns a query builder scoped to a specific relation. The returned `QueryBuilder` is pre-filtered to only include records belonging to that model: ```php // Select with constraints From 742192cf262e10f00d0029fb780fa70966585665 Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Sat, 4 Apr 2026 04:15:10 +0100 Subject: [PATCH 21/31] test(database): add BelongsTo select and count tests for query() --- .../Database/Builder/IsDatabaseModelTest.php | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/tests/Integration/Database/Builder/IsDatabaseModelTest.php b/tests/Integration/Database/Builder/IsDatabaseModelTest.php index 71a7f5c78..4a71ce6ee 100644 --- a/tests/Integration/Database/Builder/IsDatabaseModelTest.php +++ b/tests/Integration/Database/Builder/IsDatabaseModelTest.php @@ -534,7 +534,7 @@ public function test_query_has_many_with_explicit_attribute(): void $this->assertContainsOnlyInstancesOf(TestPost::class, $posts); } - public function test_query_throws_for_non_collection_relation(): void + public function test_query_belongs_to_select(): void { $this->database->migrate( CreateMigrationsTable::class, @@ -543,11 +543,28 @@ public function test_query_throws_for_non_collection_relation(): void CreateBookTable::class, ); - $book = Book::create(title: 'Test'); + $author = Author::create(name: 'Target Author', type: AuthorType::A); + $book = Book::create(title: 'Test', author: $author); - $this->expectException(BadMethodCallException::class); + $result = $book->query('author')->select()->first(); - $book->query('author'); + $this->assertInstanceOf(Author::class, $result); + $this->assertSame('Target Author', $result->name); + } + + public function test_query_belongs_to_count(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + ); + + $author = Author::create(name: 'Author', type: AuthorType::A); + $book = Book::create(title: 'Test', author: $author); + + $this->assertSame(1, $book->query('author')->count()->execute()); } public function test_query_throws_for_nonexistent_property(): void From b62dd25aca60d8c7da6afadaa7bbbd5d97c9ef41 Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Sat, 4 Apr 2026 04:15:15 +0100 Subject: [PATCH 22/31] feat(database): implement BelongsTo query() with subquery scope --- packages/database/src/BelongsTo.php | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/database/src/BelongsTo.php b/packages/database/src/BelongsTo.php index adf863f0d..42a06b808 100644 --- a/packages/database/src/BelongsTo.php +++ b/packages/database/src/BelongsTo.php @@ -5,9 +5,9 @@ namespace Tempest\Database; use Attribute; -use BadMethodCallException; use Tempest\Database\Builder\ModelInspector; use Tempest\Database\Builder\QueryBuilders\QueryBuilder; +use Tempest\Database\Builder\QueryBuilders\WhereRawScope; use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn; use Tempest\Database\QueryStatements\FieldStatement; use Tempest\Database\QueryStatements\JoinStatement; @@ -16,6 +16,7 @@ use Tempest\Support\Arr\ImmutableArray; use UnitEnum; +use function Tempest\Database\query; use function Tempest\Support\str; #[Attribute(Attribute::TARGET_PROPERTY)] @@ -197,6 +198,28 @@ private function getOwnerJoin(ModelInspector $ownerModel): string public function query(PrimaryKey $primaryKey, null|string|UnitEnum $onDatabase = null): QueryBuilder { - throw new BadMethodCallException(message: 'Cannot query a BelongsTo relation. Use HasMany on the inverse side.'); + $relatedClassName = $this->property->getType()->getName(); + $relatedModel = inspect(model: $this->property->getType()->asClass()); + $ownerModel = inspect(model: $this->property->getClass()); + $relatedTable = $relatedModel->getTableName(); + $relatedPK = $relatedModel->getPrimaryKey(); + $ownerTable = $ownerModel->getTableName(); + $ownerPK = $ownerModel->getPrimaryKey(); + $fk = $this->getOwnerFieldName(); + + return query(model: $relatedClassName) + ->onDatabase(databaseTag: $onDatabase) + ->scope(scope: new WhereRawScope( + statement: sprintf( + '%s.%s = (SELECT %s FROM %s WHERE %s.%s = ?)', + $relatedTable, + $relatedPK, + $fk, + $ownerTable, + $ownerTable, + $ownerPK, + ), + binding: $primaryKey, + )); } } From bc8d8fe0766e57745f8a512ec35c58d141e5a286 Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Sat, 4 Apr 2026 04:17:20 +0100 Subject: [PATCH 23/31] test(database): add HasOne select, count, update, delete tests for query() --- .../Database/Builder/IsDatabaseModelTest.php | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/tests/Integration/Database/Builder/IsDatabaseModelTest.php b/tests/Integration/Database/Builder/IsDatabaseModelTest.php index 4a71ce6ee..8f2b19216 100644 --- a/tests/Integration/Database/Builder/IsDatabaseModelTest.php +++ b/tests/Integration/Database/Builder/IsDatabaseModelTest.php @@ -567,6 +567,86 @@ public function test_query_belongs_to_count(): void $this->assertSame(1, $book->query('author')->count()->execute()); } + public function test_query_has_one_select(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + CreateIsbnTable::class, + ); + + $book = Book::create(title: 'Test Book'); + Isbn::new(value: '978-123', book: $book)->save(); + + $result = $book->query('isbn')->select()->first(); + + $this->assertInstanceOf(Isbn::class, $result); + $this->assertSame('978-123', $result->value); + } + + public function test_query_has_one_count(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + CreateIsbnTable::class, + ); + + $book = Book::create(title: 'Test Book'); + Isbn::new(value: '978-123', book: $book)->save(); + + $this->assertSame(1, $book->query('isbn')->count()->execute()); + } + + public function test_query_has_one_update(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + CreateIsbnTable::class, + ); + + $bookA = Book::create(title: 'Book A'); + $bookB = Book::create(title: 'Book B'); + Isbn::new(value: 'old-isbn', book: $bookA)->save(); + Isbn::new(value: 'keep-isbn', book: $bookB)->save(); + + $bookA->query('isbn')->update(value: 'new-isbn')->execute(); + + $isbnA = $bookA->query('isbn')->select()->first(); + $isbnB = $bookB->query('isbn')->select()->first(); + + $this->assertSame('new-isbn', $isbnA->value); + $this->assertSame('keep-isbn', $isbnB->value); + } + + public function test_query_has_one_delete(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + CreateIsbnTable::class, + ); + + $bookA = Book::create(title: 'Book A'); + $bookB = Book::create(title: 'Book B'); + Isbn::new(value: 'isbn-a', book: $bookA)->save(); + Isbn::new(value: 'isbn-b', book: $bookB)->save(); + + $bookA->query('isbn')->delete()->execute(); + + $this->assertSame(0, $bookA->query('isbn')->count()->execute()); + $this->assertSame(1, $bookB->query('isbn')->count()->execute()); + } + public function test_query_throws_for_nonexistent_property(): void { $this->database->migrate( From be9bbb02ade2c0e3dfbc08eac835c2efaf67a01c Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Sat, 4 Apr 2026 04:17:20 +0100 Subject: [PATCH 24/31] feat(database): implement HasOne query() with WhereFieldScope --- packages/database/src/HasOne.php | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/database/src/HasOne.php b/packages/database/src/HasOne.php index ce929c57a..9c9706ffe 100644 --- a/packages/database/src/HasOne.php +++ b/packages/database/src/HasOne.php @@ -5,9 +5,9 @@ namespace Tempest\Database; use Attribute; -use BadMethodCallException; use Tempest\Database\Builder\ModelInspector; use Tempest\Database\Builder\QueryBuilders\QueryBuilder; +use Tempest\Database\Builder\QueryBuilders\WhereFieldScope; use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn; use Tempest\Database\QueryStatements\FieldStatement; use Tempest\Database\QueryStatements\JoinStatement; @@ -16,6 +16,7 @@ use Tempest\Support\Arr\ImmutableArray; use UnitEnum; +use function Tempest\Database\query; use function Tempest\Support\str; #[Attribute(Attribute::TARGET_PROPERTY)] @@ -195,6 +196,14 @@ private function getRelationJoin(ModelInspector $relationModel): string public function query(PrimaryKey $primaryKey, null|string|UnitEnum $onDatabase = null): QueryBuilder { - throw new BadMethodCallException(message: 'Cannot query a HasOne relation.'); + $relatedClassName = $this->property->getType()->getName(); + $parentModel = inspect(model: $this->property->getClass()); + $parentTable = $parentModel->getTableName(); + $parentPK = $parentModel->getPrimaryKey(); + $fk = $this->ownerJoin ?? str(string: $parentTable)->singularizeLastWord() . '_' . $parentPK; + + return query(model: $relatedClassName) + ->onDatabase(databaseTag: $onDatabase) + ->scope(scope: new WhereFieldScope(field: $fk, value: $primaryKey)); } } From c121684ffeee61d37d20cd85b2b3cf14d81b8eba Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Sat, 4 Apr 2026 04:19:15 +0100 Subject: [PATCH 25/31] test(database): add HasOneThrough select, count, update, delete tests for query() --- .../Database/Builder/IsDatabaseModelTest.php | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/tests/Integration/Database/Builder/IsDatabaseModelTest.php b/tests/Integration/Database/Builder/IsDatabaseModelTest.php index 8f2b19216..7dd542d1e 100644 --- a/tests/Integration/Database/Builder/IsDatabaseModelTest.php +++ b/tests/Integration/Database/Builder/IsDatabaseModelTest.php @@ -647,6 +647,88 @@ public function test_query_has_one_delete(): void $this->assertSame(1, $bookB->query('isbn')->count()->execute()); } + public function test_query_has_one_through_select(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreateTagTable::class, + CreateBookReviewTable::class, + CreateReviewerTable::class, + ); + + $tag = Tag::create(label: 'fantasy'); + $review = BookReview::create(content: 'Great', tag: $tag); + Reviewer::create(name: 'Alice', bookReview: $review); + + $result = $tag->query('topReviewer')->select()->first(); + + $this->assertInstanceOf(Reviewer::class, $result); + $this->assertSame('Alice', $result->name); + } + + public function test_query_has_one_through_count(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreateTagTable::class, + CreateBookReviewTable::class, + CreateReviewerTable::class, + ); + + $tag = Tag::create(label: 'fantasy'); + $review = BookReview::create(content: 'Great', tag: $tag); + Reviewer::create(name: 'Alice', bookReview: $review); + + $this->assertSame(1, $tag->query('topReviewer')->count()->execute()); + } + + public function test_query_has_one_through_update(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreateTagTable::class, + CreateBookReviewTable::class, + CreateReviewerTable::class, + ); + + $tagA = Tag::create(label: 'fantasy'); + $tagB = Tag::create(label: 'sci-fi'); + $reviewA = BookReview::create(content: 'Great', tag: $tagA); + $reviewB = BookReview::create(content: 'Meh', tag: $tagB); + Reviewer::create(name: 'Alice', bookReview: $reviewA); + Reviewer::create(name: 'Bob', bookReview: $reviewB); + + $tagA->query('topReviewer')->update(name: 'Updated')->execute(); + + $resultA = $tagA->query('topReviewer')->select()->first(); + $resultB = $tagB->query('topReviewer')->select()->first(); + + $this->assertSame('Updated', $resultA->name); + $this->assertSame('Bob', $resultB->name); + } + + public function test_query_has_one_through_delete(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreateTagTable::class, + CreateBookReviewTable::class, + CreateReviewerTable::class, + ); + + $tagA = Tag::create(label: 'fantasy'); + $tagB = Tag::create(label: 'sci-fi'); + $reviewA = BookReview::create(content: 'Great', tag: $tagA); + $reviewB = BookReview::create(content: 'Meh', tag: $tagB); + Reviewer::create(name: 'Alice', bookReview: $reviewA); + Reviewer::create(name: 'Bob', bookReview: $reviewB); + + $tagA->query('topReviewer')->delete()->execute(); + + $this->assertSame(0, $tagA->query('topReviewer')->count()->execute()); + $this->assertSame(1, $tagB->query('topReviewer')->count()->execute()); + } + public function test_query_throws_for_nonexistent_property(): void { $this->database->migrate( From f1442b8dc9b78ec7b66157737393a248dee6294c Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Sat, 4 Apr 2026 04:19:16 +0100 Subject: [PATCH 26/31] feat(database): implement HasOneThrough query() with subquery scope --- packages/database/src/HasOneThrough.php | 29 +++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/database/src/HasOneThrough.php b/packages/database/src/HasOneThrough.php index 8380ddab1..d22d28d00 100644 --- a/packages/database/src/HasOneThrough.php +++ b/packages/database/src/HasOneThrough.php @@ -5,9 +5,9 @@ namespace Tempest\Database; use Attribute; -use BadMethodCallException; use Tempest\Database\Builder\ModelInspector; use Tempest\Database\Builder\QueryBuilders\QueryBuilder; +use Tempest\Database\Builder\QueryBuilders\WhereRawScope; use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn; use Tempest\Database\QueryStatements\FieldStatement; use Tempest\Database\QueryStatements\JoinStatement; @@ -16,6 +16,7 @@ use Tempest\Support\Arr\ImmutableArray; use UnitEnum; +use function Tempest\Database\query; use function Tempest\Support\str; #[Attribute(flags: Attribute::TARGET_PROPERTY)] @@ -323,6 +324,30 @@ public function getExistsStatement(): WhereExistsStatement public function query(PrimaryKey $primaryKey, null|string|UnitEnum $onDatabase = null): QueryBuilder { - throw new BadMethodCallException(message: 'Cannot query a HasOneThrough relation.'); + $relatedClassName = $this->property->getType()->getName(); + $ownerModel = inspect(model: $this->property->getClass()); + $intermediateModel = inspect(model: $this->through); + $intermediateTable = $intermediateModel->getTableName(); + $ownerTable = $ownerModel->getTableName(); + $ownerPK = $ownerModel->getPrimaryKey(); + $intermediatePK = $intermediateModel->getPrimaryKey(); + $relatedTable = inspect(model: $relatedClassName)->getTableName(); + + $ownerFK = $this->ownerJoin ?? str(string: $ownerTable)->singularizeLastWord() . '_' . $ownerPK; + $targetFK = $this->throughOwnerJoin ?? str(string: $intermediateTable)->singularizeLastWord() . '_' . $intermediatePK; + + return query(model: $relatedClassName) + ->onDatabase(databaseTag: $onDatabase) + ->scope(scope: new WhereRawScope( + statement: sprintf( + '%s.%s IN (SELECT %s FROM %s WHERE %s = ?)', + $relatedTable, + $targetFK, + $intermediatePK, + $intermediateTable, + $ownerFK, + ), + binding: $primaryKey, + )); } } From 8aadfb1c09b90c319f0772e0c1b638d165beac26 Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Sat, 4 Apr 2026 04:19:43 +0100 Subject: [PATCH 27/31] docs(database): update query() docs to reflect all relation types now supported --- docs/1-essentials/03-database.md | 14 +++++++++++--- packages/database/src/BelongsTo.php | 1 - packages/database/src/HasOne.php | 1 - packages/database/src/HasOneThrough.php | 1 - tests/Fixtures/Migrations/CreateBookTagTable.php | 2 +- .../Database/Builder/IsDatabaseModelTest.php | 1 - 6 files changed, 12 insertions(+), 8 deletions(-) diff --git a/docs/1-essentials/03-database.md b/docs/1-essentials/03-database.md index 63ea035b1..a427d90d0 100644 --- a/docs/1-essentials/03-database.md +++ b/docs/1-essentials/03-database.md @@ -776,13 +776,21 @@ $author->query('books')->update(title: 'Updated')->execute(); $author->query('books')->delete()->execute(); ``` -The `query()` method works with `HasMany`, `HasManyThrough`, and `BelongsToMany` relations — any relation that returns a collection. Calling it on a singular relation like `BelongsTo` or `HasOne` will throw an exception. +The `query()` method works with all relation types: ```php -// HasManyThrough +// HasMany / HasOne — simple FK on related table +$author->query('books')->select()->all(); +$book->query('isbn')->select()->first(); + +// BelongsTo — subquery through owner's FK +$book->query('author')->select()->first(); + +// HasManyThrough / HasOneThrough — subquery through intermediate table $tag->query('reviewers')->select()->all(); +$tag->query('topReviewer')->select()->first(); -// BelongsToMany +// BelongsToMany — subquery through pivot table $tag->query('books')->select()->all(); ``` diff --git a/packages/database/src/BelongsTo.php b/packages/database/src/BelongsTo.php index 42a06b808..262460892 100644 --- a/packages/database/src/BelongsTo.php +++ b/packages/database/src/BelongsTo.php @@ -16,7 +16,6 @@ use Tempest\Support\Arr\ImmutableArray; use UnitEnum; -use function Tempest\Database\query; use function Tempest\Support\str; #[Attribute(Attribute::TARGET_PROPERTY)] diff --git a/packages/database/src/HasOne.php b/packages/database/src/HasOne.php index 9c9706ffe..a21ec3fdd 100644 --- a/packages/database/src/HasOne.php +++ b/packages/database/src/HasOne.php @@ -16,7 +16,6 @@ use Tempest\Support\Arr\ImmutableArray; use UnitEnum; -use function Tempest\Database\query; use function Tempest\Support\str; #[Attribute(Attribute::TARGET_PROPERTY)] diff --git a/packages/database/src/HasOneThrough.php b/packages/database/src/HasOneThrough.php index d22d28d00..9cc553fad 100644 --- a/packages/database/src/HasOneThrough.php +++ b/packages/database/src/HasOneThrough.php @@ -16,7 +16,6 @@ use Tempest\Support\Arr\ImmutableArray; use UnitEnum; -use function Tempest\Database\query; use function Tempest\Support\str; #[Attribute(flags: Attribute::TARGET_PROPERTY)] diff --git a/tests/Fixtures/Migrations/CreateBookTagTable.php b/tests/Fixtures/Migrations/CreateBookTagTable.php index 12bb0890c..ea3a4e4f6 100644 --- a/tests/Fixtures/Migrations/CreateBookTagTable.php +++ b/tests/Fixtures/Migrations/CreateBookTagTable.php @@ -6,10 +6,10 @@ use Tempest\Database\MigratesDown; use Tempest\Database\MigratesUp; -use Tempest\Database\QueryStatements\OnDelete; use Tempest\Database\QueryStatement; use Tempest\Database\QueryStatements\CreateTableStatement; use Tempest\Database\QueryStatements\DropTableStatement; +use Tempest\Database\QueryStatements\OnDelete; final class CreateBookTagTable implements MigratesUp, MigratesDown { diff --git a/tests/Integration/Database/Builder/IsDatabaseModelTest.php b/tests/Integration/Database/Builder/IsDatabaseModelTest.php index 7dd542d1e..a422c309f 100644 --- a/tests/Integration/Database/Builder/IsDatabaseModelTest.php +++ b/tests/Integration/Database/Builder/IsDatabaseModelTest.php @@ -4,7 +4,6 @@ namespace Tests\Tempest\Integration\Database\Builder; -use BadMethodCallException; use Carbon\Carbon; use DateTime as NativeDateTime; use DateTimeImmutable; From d6d4ec2f4f76f335b69bba33767da1ee9d46af29 Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Sat, 4 Apr 2026 04:52:31 +0100 Subject: [PATCH 28/31] test(database): add whereHas and whereDoesntHave chaining tests for query() --- .../Database/Builder/IsDatabaseModelTest.php | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/tests/Integration/Database/Builder/IsDatabaseModelTest.php b/tests/Integration/Database/Builder/IsDatabaseModelTest.php index a422c309f..f1f6e6fc2 100644 --- a/tests/Integration/Database/Builder/IsDatabaseModelTest.php +++ b/tests/Integration/Database/Builder/IsDatabaseModelTest.php @@ -10,6 +10,7 @@ use InvalidArgumentException; use Tempest\Database\BelongsTo; use Tempest\Database\Builder\QueryBuilders\QueryBuilder; +use Tempest\Database\Builder\QueryBuilders\SelectQueryBuilder; use Tempest\Database\Exceptions\DeleteStatementWasInvalid; use Tempest\Database\Exceptions\RelationWasMissing; use Tempest\Database\Exceptions\ValueWasMissing; @@ -54,6 +55,7 @@ use Tests\Tempest\Fixtures\Modules\Books\Models\AuthorType; use Tests\Tempest\Fixtures\Modules\Books\Models\Book; use Tests\Tempest\Fixtures\Modules\Books\Models\BookReview; +use Tests\Tempest\Fixtures\Modules\Books\Models\Chapter; use Tests\Tempest\Fixtures\Modules\Books\Models\Isbn; use Tests\Tempest\Fixtures\Modules\Books\Models\Reviewer; use Tests\Tempest\Fixtures\Modules\Books\Models\Tag; @@ -728,6 +730,117 @@ public function test_query_has_one_through_delete(): void $this->assertSame(1, $tagB->query('topReviewer')->count()->execute()); } + public function test_query_has_many_with_where_has(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + CreateChapterTable::class, + ); + + $author = Author::create(name: 'Author', type: AuthorType::A); + $bookWithChapters = Book::create(title: 'With Chapters', author: $author); + Book::create(title: 'No Chapters', author: $author); + + Chapter::new(title: 'Chapter 1', contents: 'Content', book: $bookWithChapters)->save(); + + $books = $author + ->query('books') + ->select() + ->whereHas(relation: 'chapters') + ->all(); + + $this->assertCount(1, $books); + $this->assertSame('With Chapters', $books[0]->title); + } + + public function test_query_has_many_with_where_doesnt_have_and_where_field(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + CreateChapterTable::class, + ); + + $author = Author::create(name: 'Author', type: AuthorType::A); + $bookA = Book::create(title: 'Alpha', author: $author); + Book::create(title: 'Beta', author: $author); + Book::create(title: 'Gamma', author: $author); + + Chapter::new(title: 'Ch 1', contents: 'Content', book: $bookA)->save(); + + $books = $author + ->query('books') + ->select() + ->whereDoesntHave(relation: 'chapters') + ->whereField(field: 'title', value: 'Beta') + ->all(); + + $this->assertCount(1, $books); + $this->assertSame('Beta', $books[0]->title); + } + + public function test_query_has_many_with_where_has_callback(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + CreateChapterTable::class, + ); + + $author = Author::create(name: 'Author', type: AuthorType::A); + $bookA = Book::create(title: 'Book A', author: $author); + $bookB = Book::create(title: 'Book B', author: $author); + + Chapter::new(title: 'Intro', contents: 'Content', book: $bookA)->save(); + Chapter::new(title: 'Advanced Topics', contents: 'Content', book: $bookB)->save(); + + $books = $author + ->query('books') + ->select() + ->whereHas(relation: 'chapters', callback: function (SelectQueryBuilder $q): void { + $q->whereField(field: 'title', value: 'Advanced Topics'); + }) + ->all(); + + $this->assertCount(1, $books); + $this->assertSame('Book B', $books[0]->title); + } + + public function test_query_has_many_with_where_doesnt_have_callback(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + CreateChapterTable::class, + ); + + $author = Author::create(name: 'Author', type: AuthorType::A); + $bookA = Book::create(title: 'Book A', author: $author); + Book::create(title: 'Book B', author: $author); + + Chapter::new(title: 'Draft', contents: 'WIP', book: $bookA)->save(); + + $books = $author + ->query('books') + ->select() + ->whereDoesntHave(relation: 'chapters', callback: function (SelectQueryBuilder $q): void { + $q->whereField(field: 'title', value: 'Draft'); + }) + ->all(); + + $this->assertCount(1, $books); + $this->assertSame('Book B', $books[0]->title); + } + public function test_query_throws_for_nonexistent_property(): void { $this->database->migrate( From 5684a1cc5ac89bcfdf50d259dfc43c3fbf6c084a Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Sat, 4 Apr 2026 04:52:37 +0100 Subject: [PATCH 29/31] refactor(database): use resolvePivotTable() in BelongsToMany::query() instead of duplicating pivot logic --- packages/database/src/BelongsToMany.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/database/src/BelongsToMany.php b/packages/database/src/BelongsToMany.php index e8ce4b088..12cf61c62 100644 --- a/packages/database/src/BelongsToMany.php +++ b/packages/database/src/BelongsToMany.php @@ -408,7 +408,7 @@ public function query(PrimaryKey $primaryKey, null|string|UnitEnum $onDatabase = $targetTable = $targetModel->getTableName(); $targetPK = $targetModel->getPrimaryKey(); - $pivotTable = $this->pivot ?? arr([$ownerTable, $targetTable])->sort()->implode('_')->toString(); + $pivotTable = $this->resolvePivotTable(ownerModel: $ownerModel, targetModel: $targetModel); $ownerFK = $this->ownerJoin ?? str(string: $ownerTable)->singularizeLastWord() . '_' . $ownerPK; $targetFK = $this->relatedOwnerJoin ?? str(string: $targetTable)->singularizeLastWord() . '_' . $targetPK; From 1007539d6b42bcba05bcef12e79276dd4d326417 Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Thu, 9 Apr 2026 14:34:37 +0100 Subject: [PATCH 30/31] refactor(database): replace generic exceptions with custom PrimaryKeyWasNotInitialized and PropertyWasNotARelation --- .../Exceptions/PrimaryKeyWasNotInitialized.php | 16 ++++++++++++++++ .../src/Exceptions/PropertyWasNotARelation.php | 17 +++++++++++++++++ packages/database/src/IsDatabaseModel.php | 11 ++++------- .../Database/Builder/IsDatabaseModelTest.php | 7 ++++--- 4 files changed, 41 insertions(+), 10 deletions(-) create mode 100644 packages/database/src/Exceptions/PrimaryKeyWasNotInitialized.php create mode 100644 packages/database/src/Exceptions/PropertyWasNotARelation.php diff --git a/packages/database/src/Exceptions/PrimaryKeyWasNotInitialized.php b/packages/database/src/Exceptions/PrimaryKeyWasNotInitialized.php new file mode 100644 index 000000000..c2ee269b2 --- /dev/null +++ b/packages/database/src/Exceptions/PrimaryKeyWasNotInitialized.php @@ -0,0 +1,16 @@ +hasPrimaryKey() || ! $model->getPrimaryKeyProperty()->isInitialized(object: $this)) { - throw new InvalidArgumentException( - message: sprintf('Cannot query relations on %s without a primary key value.', $model->getName()), - ); + throw new PrimaryKeyWasNotInitialized(model: $model->getName()); } $resolved = $model->getRelation(name: $relation); if ($resolved === null) { - throw new InvalidArgumentException( - message: sprintf('Property "%s" is not a relation on %s.', $relation, $model->getName()), - ); + throw new PropertyWasNotARelation(property: $relation, model: $model->getName()); } return $resolved->query( diff --git a/tests/Integration/Database/Builder/IsDatabaseModelTest.php b/tests/Integration/Database/Builder/IsDatabaseModelTest.php index f1f6e6fc2..00b48990d 100644 --- a/tests/Integration/Database/Builder/IsDatabaseModelTest.php +++ b/tests/Integration/Database/Builder/IsDatabaseModelTest.php @@ -7,11 +7,12 @@ use Carbon\Carbon; use DateTime as NativeDateTime; use DateTimeImmutable; -use InvalidArgumentException; use Tempest\Database\BelongsTo; use Tempest\Database\Builder\QueryBuilders\QueryBuilder; use Tempest\Database\Builder\QueryBuilders\SelectQueryBuilder; use Tempest\Database\Exceptions\DeleteStatementWasInvalid; +use Tempest\Database\Exceptions\PrimaryKeyWasNotInitialized; +use Tempest\Database\Exceptions\PropertyWasNotARelation; use Tempest\Database\Exceptions\RelationWasMissing; use Tempest\Database\Exceptions\ValueWasMissing; use Tempest\Database\HasMany; @@ -851,7 +852,7 @@ public function test_query_throws_for_nonexistent_property(): void $author = Author::create(name: 'Author', type: AuthorType::A); - $this->expectException(InvalidArgumentException::class); + $this->expectException(PropertyWasNotARelation::class); $author->query('nonexistent'); } @@ -860,7 +861,7 @@ public function test_query_throws_for_unsaved_model(): void { $author = new Author(name: 'Unsaved'); - $this->expectException(InvalidArgumentException::class); + $this->expectException(PrimaryKeyWasNotInitialized::class); $author->query('books'); } From 71ee424db9d9e4eb0c3f56a7149d01faa2af7d77 Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Thu, 9 Apr 2026 15:58:46 +0100 Subject: [PATCH 31/31] fix(upgrade): run rector tests in separate processes to prevent OOM at 1G memory limit --- packages/upgrade/tests/Tempest20/Tempest20RectorTest.php | 2 ++ packages/upgrade/tests/Tempest28/Tempest28RectorTest.php | 2 ++ packages/upgrade/tests/Tempest3/Tempest3RectorTest.php | 2 ++ packages/upgrade/tests/Tempest34/Tempest34RectorTest.php | 2 ++ 4 files changed, 8 insertions(+) diff --git a/packages/upgrade/tests/Tempest20/Tempest20RectorTest.php b/packages/upgrade/tests/Tempest20/Tempest20RectorTest.php index 71df9adb7..4ac3452b3 100644 --- a/packages/upgrade/tests/Tempest20/Tempest20RectorTest.php +++ b/packages/upgrade/tests/Tempest20/Tempest20RectorTest.php @@ -2,9 +2,11 @@ namespace Tempest\Upgrade\Tests\Tempest20; +use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses; use PHPUnit\Framework\TestCase; use Tempest\Upgrade\Tests\RectorTester; +#[RunTestsInSeparateProcesses] final class Tempest20RectorTest extends TestCase { private RectorTester $rector { diff --git a/packages/upgrade/tests/Tempest28/Tempest28RectorTest.php b/packages/upgrade/tests/Tempest28/Tempest28RectorTest.php index 2f830009b..c17ee7175 100644 --- a/packages/upgrade/tests/Tempest28/Tempest28RectorTest.php +++ b/packages/upgrade/tests/Tempest28/Tempest28RectorTest.php @@ -2,9 +2,11 @@ namespace Tempest\Upgrade\Tests\Tempest28; +use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses; use PHPUnit\Framework\TestCase; use Tempest\Upgrade\Tests\RectorTester; +#[RunTestsInSeparateProcesses] final class Tempest28RectorTest extends TestCase { private RectorTester $rector { diff --git a/packages/upgrade/tests/Tempest3/Tempest3RectorTest.php b/packages/upgrade/tests/Tempest3/Tempest3RectorTest.php index 021655122..343d874b8 100644 --- a/packages/upgrade/tests/Tempest3/Tempest3RectorTest.php +++ b/packages/upgrade/tests/Tempest3/Tempest3RectorTest.php @@ -2,9 +2,11 @@ namespace Tempest\Upgrade\Tests\Tempest3; +use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses; use PHPUnit\Framework\TestCase; use Tempest\Upgrade\Tests\RectorTester; +#[RunTestsInSeparateProcesses] final class Tempest3RectorTest extends TestCase { private RectorTester $rector { diff --git a/packages/upgrade/tests/Tempest34/Tempest34RectorTest.php b/packages/upgrade/tests/Tempest34/Tempest34RectorTest.php index cc23e309e..9720f726a 100644 --- a/packages/upgrade/tests/Tempest34/Tempest34RectorTest.php +++ b/packages/upgrade/tests/Tempest34/Tempest34RectorTest.php @@ -2,9 +2,11 @@ namespace Tempest\Upgrade\Tests\Tempest34; +use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses; use PHPUnit\Framework\TestCase; use Tempest\Upgrade\Tests\RectorTester; +#[RunTestsInSeparateProcesses] final class Tempest34RectorTest extends TestCase { private RectorTester $rector {