diff --git a/docs/1-essentials/03-database.md b/docs/1-essentials/03-database.md index 64eb3f1681..a427d90d03 100644 --- a/docs/1-essentials/03-database.md +++ b/docs/1-essentials/03-database.md @@ -757,6 +757,43 @@ query(model: Author::class)->delete()->whereDoesntHave(relation: 'books')->execu query(model: Author::class)->update(verified: true)->whereHas(relation: 'books')->execute(); ``` +### Querying relation properties + +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 +$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 all relation types: + +```php +// 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 — subquery through pivot table +$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. diff --git a/packages/database/src/BelongsTo.php b/packages/database/src/BelongsTo.php index e0143a7971..2624608929 100644 --- a/packages/database/src/BelongsTo.php +++ b/packages/database/src/BelongsTo.php @@ -6,12 +6,15 @@ 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\Support\str; @@ -191,4 +194,31 @@ private function getOwnerJoin(ModelInspector $ownerModel): string $this->getOwnerFieldName(), ); } + + public function query(PrimaryKey $primaryKey, null|string|UnitEnum $onDatabase = null): QueryBuilder + { + $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, + )); + } } diff --git a/packages/database/src/BelongsToMany.php b/packages/database/src/BelongsToMany.php index 6b3b36f958..12cf61c623 100644 --- a/packages/database/src/BelongsToMany.php +++ b/packages/database/src/BelongsToMany.php @@ -6,12 +6,15 @@ 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\Support\arr; use function Tempest\Support\str; @@ -394,4 +397,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->resolvePivotTable(ownerModel: $ownerModel, targetModel: $targetModel); + $ownerFK = $this->ownerJoin ?? str(string: $ownerTable)->singularizeLastWord() . '_' . $ownerPK; + $targetFK = $this->relatedOwnerJoin ?? str(string: $targetTable)->singularizeLastWord() . '_' . $targetPK; + + return query(model: $relatedClassName) + ->onDatabase(databaseTag: $onDatabase) + ->scope(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 3dbd5cae3d..77510d5081 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 c3d4e46bfd..889510e02c 100644 --- a/packages/database/src/Builder/QueryBuilders/QueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/QueryBuilder.php @@ -19,11 +19,26 @@ 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 +56,9 @@ public function select(string ...$columns): SelectQueryBuilder return new SelectQueryBuilder( model: $this->model, fields: $columns !== [] ? arr($columns)->unique() : null, - )->onDatabase($this->onDatabase); + ) + ->onDatabase(databaseTag: $this->onDatabase) + ->applyScopes(scopes: $this->scopes); } /** @@ -88,7 +105,9 @@ public function update(mixed ...$values): UpdateQueryBuilder model: $this->model, values: $values, serializerFactory: get(SerializerFactory::class), - )->onDatabase($this->onDatabase); + ) + ->onDatabase(databaseTag: $this->onDatabase) + ->applyScopes(scopes: $this->scopes); } /** @@ -106,7 +125,9 @@ public function update(mixed ...$values): UpdateQueryBuilder */ public function delete(): DeleteQueryBuilder { - return new DeleteQueryBuilder($this->model)->onDatabase($this->onDatabase); + return new DeleteQueryBuilder(model: $this->model) + ->onDatabase(databaseTag: $this->onDatabase) + ->applyScopes(scopes: $this->scopes); } /** @@ -124,7 +145,9 @@ public function count(?string $column = null): CountQueryBuilder return new CountQueryBuilder( model: $this->model, column: $column, - )->onDatabase($this->onDatabase); + ) + ->onDatabase(databaseTag: $this->onDatabase) + ->applyScopes(scopes: $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 0000000000..9e12c85abe --- /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/WhereFieldScope.php b/packages/database/src/Builder/QueryBuilders/WhereFieldScope.php new file mode 100644 index 0000000000..0635950a89 --- /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/Builder/QueryBuilders/WhereRawScope.php b/packages/database/src/Builder/QueryBuilders/WhereRawScope.php new file mode 100644 index 0000000000..62afe64138 --- /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/Exceptions/PrimaryKeyWasNotInitialized.php b/packages/database/src/Exceptions/PrimaryKeyWasNotInitialized.php new file mode 100644 index 0000000000..c2ee269b2e --- /dev/null +++ b/packages/database/src/Exceptions/PrimaryKeyWasNotInitialized.php @@ -0,0 +1,16 @@ +property->getIterableType()->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)); + } + 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 6951246977..8ca17315fd 100644 --- a/packages/database/src/HasManyThrough.php +++ b/packages/database/src/HasManyThrough.php @@ -6,12 +6,15 @@ 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\Support\str; @@ -360,4 +363,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(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 e096707416..a21ec3fdd2 100644 --- a/packages/database/src/HasOne.php +++ b/packages/database/src/HasOne.php @@ -6,12 +6,15 @@ use Attribute; 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; use Tempest\Database\QueryStatements\WhereExistsStatement; use Tempest\Reflection\PropertyReflector; use Tempest\Support\Arr\ImmutableArray; +use UnitEnum; use function Tempest\Support\str; @@ -189,4 +192,17 @@ private function getRelationJoin(ModelInspector $relationModel): string $primaryKey, ); } + + public function query(PrimaryKey $primaryKey, null|string|UnitEnum $onDatabase = null): QueryBuilder + { + $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)); + } } diff --git a/packages/database/src/HasOneThrough.php b/packages/database/src/HasOneThrough.php index 44c044aae9..9cc553fad7 100644 --- a/packages/database/src/HasOneThrough.php +++ b/packages/database/src/HasOneThrough.php @@ -6,12 +6,15 @@ 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\Support\str; @@ -317,4 +320,33 @@ public function getExistsStatement(): WhereExistsStatement ), ); } + + public function query(PrimaryKey $primaryKey, null|string|UnitEnum $onDatabase = null): QueryBuilder + { + $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, + )); + } } diff --git a/packages/database/src/IsDatabaseModel.php b/packages/database/src/IsDatabaseModel.php index 873439ff54..311007f2ca 100644 --- a/packages/database/src/IsDatabaseModel.php +++ b/packages/database/src/IsDatabaseModel.php @@ -8,6 +8,8 @@ use Tempest\Database\Builder\QueryBuilders\InsertQueryBuilder; use Tempest\Database\Builder\QueryBuilders\QueryBuilder; use Tempest\Database\Builder\QueryBuilders\SelectQueryBuilder; +use Tempest\Database\Exceptions\PrimaryKeyWasNotInitialized; +use Tempest\Database\Exceptions\PropertyWasNotARelation; use Tempest\Database\Exceptions\RelationWasMissing; use Tempest\Database\Exceptions\ValueWasMissing; use Tempest\Reflection\PropertyReflector; @@ -264,6 +266,29 @@ public function refresh(): self return $this; } + /** + * Returns a query builder scoped to a collection relation on this model. + */ + public function query(string $relation): QueryBuilder + { + $model = inspect(model: $this); + + if (! $model->hasPrimaryKey() || ! $model->getPrimaryKeyProperty()->isInitialized(object: $this)) { + throw new PrimaryKeyWasNotInitialized(model: $model->getName()); + } + + $resolved = $model->getRelation(name: $relation); + + if ($resolved === null) { + throw new PropertyWasNotARelation(property: $relation, model: $model->getName()); + } + + return $resolved->query( + primaryKey: $model->getPrimaryKeyValue(), + onDatabase: $this->onDatabase, + ); + } + /** * Loads the specified relations on the model instance. */ diff --git a/packages/database/src/Relation.php b/packages/database/src/Relation.php index 0a27f77566..911356a191 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/packages/upgrade/tests/Tempest20/Tempest20RectorTest.php b/packages/upgrade/tests/Tempest20/Tempest20RectorTest.php index 71df9adb79..4ac3452b3a 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 2f830009bd..c17ee7175a 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 0216551227..343d874b83 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 cc23e309ed..9720f726ad 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 { diff --git a/tests/Fixtures/Migrations/CreateBookTagTable.php b/tests/Fixtures/Migrations/CreateBookTagTable.php index d1e952d8de..ea3a4e4f63 100644 --- a/tests/Fixtures/Migrations/CreateBookTagTable.php +++ b/tests/Fixtures/Migrations/CreateBookTagTable.php @@ -9,6 +9,7 @@ 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 { @@ -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 diff --git a/tests/Integration/Database/Builder/IsDatabaseModelTest.php b/tests/Integration/Database/Builder/IsDatabaseModelTest.php index b77566f314..00b48990d1 100644 --- a/tests/Integration/Database/Builder/IsDatabaseModelTest.php +++ b/tests/Integration/Database/Builder/IsDatabaseModelTest.php @@ -9,7 +9,10 @@ use DateTimeImmutable; 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; @@ -34,10 +37,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; @@ -48,7 +55,11 @@ 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\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; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; use function Tempest\Database\query; @@ -309,6 +320,798 @@ 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')->select()->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') + ->select() + ->whereField(field: 'title', value: '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')->select()->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('reviewers')->select()->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('reviewers') + ->select() + ->whereField(field: 'name', value: 'Alice') + ->all(); + + $this->assertCount(1, $reviewers); + $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('books')->select()->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('books') + ->select() + ->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('posts')->select()->all(); + + $this->assertCount(2, $posts); + $this->assertContainsOnlyInstancesOf(TestPost::class, $posts); + } + + public function test_query_belongs_to_select(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + ); + + $author = Author::create(name: 'Target Author', type: AuthorType::A); + $book = Book::create(title: 'Test', author: $author); + + $result = $book->query('author')->select()->first(); + + $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_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_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_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( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + ); + + $author = Author::create(name: 'Author', type: AuthorType::A); + + $this->expectException(PropertyWasNotARelation::class); + + $author->query('nonexistent'); + } + + public function test_query_throws_for_unsaved_model(): void + { + $author = new Author(name: 'Unsaved'); + + $this->expectException(PrimaryKeyWasNotInitialized::class); + + $author->query('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('books')->select()->all(); + + $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_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(