diff --git a/docs/1-essentials/03-database.md b/docs/1-essentials/03-database.md index 3ed5a4503..64eb3f168 100644 --- a/docs/1-essentials/03-database.md +++ b/docs/1-essentials/03-database.md @@ -699,6 +699,64 @@ $books[0]->chapters[2]->delete(); ::: +### Filtering by relations + +Use `whereHas` and `whereDoesntHave` to filter models based on whether related records exist: + +```php +// Authors who have at least one book +$authors = Author::select() + ->whereHas(relation: 'books') + ->all(); + +// Authors who have no books +$authors = Author::select() + ->whereDoesntHave(relation: 'books') + ->all(); +``` + +Add a callback to constrain the related records: + +```php +// Authors who have a published book +$authors = Author::select() + ->whereHas(relation: 'books', callback: function (SelectQueryBuilder $query): void { + $query->whereField(field: 'published', value: true); + }) + ->all(); +``` + +Use `operator` and `count` for count-based filtering: + +```php +// Authors with 3 or more books +$authors = Author::select() + ->whereHas(relation: 'books', operator: WhereOperator::GREATER_THAN_OR_EQUAL, count: 3) + ->all(); +``` + +Dot notation supports nested relations: + +```php +// Authors who have books with chapters +$authors = Author::select() + ->whereHas(relation: 'books.chapters') + ->all(); +``` + +These methods work on all query builders: + +```php +// Count authors with books +$count = Author::count()->whereHas(relation: 'books')->execute(); + +// Delete authors without books +query(model: Author::class)->delete()->whereDoesntHave(relation: 'books')->execute(); + +// Update authors who have books +query(model: Author::class)->update(verified: true)->whereHas(relation: 'books')->execute(); +``` + ## 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 948afd607..437d9b2ef 100644 --- a/packages/database/src/BelongsTo.php +++ b/packages/database/src/BelongsTo.php @@ -9,6 +9,7 @@ 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; @@ -137,6 +138,24 @@ private function getRelationJoin(ModelInspector $relationModel, string $tableAli return sprintf('%s.%s', $tableReference, $primaryKey); } + public function getExistsStatement(): WhereExistsStatement + { + $relatedModel = inspect(model: $this->property->getType()->asClass()); + $parentModel = inspect(model: $this->property->getClass()); + + $relatedTable = $relatedModel->getTableName(); + $parentTable = $parentModel->getTableName(); + $relatedPK = $relatedModel->getPrimaryKey(); + + $fk = $this->getOwnerFieldName(); + + return new WhereExistsStatement( + relatedTable: $relatedTable, + relatedModelName: $relatedModel->getName(), + condition: "{$relatedTable}.{$relatedPK} = {$parentTable}.{$fk}", + ); + } + private function isSelfReferencing(): bool { $relationModel = inspect($this->property->getType()->asClass()); diff --git a/packages/database/src/BelongsToMany.php b/packages/database/src/BelongsToMany.php index c901c2860..cd2206396 100644 --- a/packages/database/src/BelongsToMany.php +++ b/packages/database/src/BelongsToMany.php @@ -9,6 +9,7 @@ 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; @@ -364,4 +365,21 @@ private function resolveRelatedRelationJoin(ModelInspector $targetModel, string $primaryKey, ); } + + public function getExistsStatement(): WhereExistsStatement + { + $ownerModel = inspect(model: $this->property->getClass()); + $targetModel = inspect(model: $this->property->getIterableType()->asClass()); + $pivotTable = $this->resolvePivotTable(ownerModel: $ownerModel, targetModel: $targetModel); + + $ownerPK = $ownerModel->getPrimaryKey(); + $ownerTable = $ownerModel->getTableName(); + $fk = $this->ownerJoin ?? str(string: $ownerTable)->singularizeLastWord()->append(suffix: "_{$ownerPK}"); + + return new WhereExistsStatement( + relatedTable: $pivotTable, + relatedModelName: $targetModel->getName(), + condition: "{$pivotTable}.{$fk} = {$ownerTable}.{$ownerPK}", + ); + } } diff --git a/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php b/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php index a21d6e101..3dbd5cae3 100644 --- a/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php +++ b/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php @@ -4,6 +4,7 @@ use Closure; use Tempest\Database\Builder\WhereOperator; +use Tempest\Database\QueryStatements\WhereExistsStatement; use Tempest\Database\QueryStatements\WhereGroupStatement; use Tempest\Database\QueryStatements\WhereStatement; @@ -12,8 +13,9 @@ trait HasWhereQueryBuilderMethods { use HasConvenientWhereMethods; + use HasWhereRelationMethods; - protected function appendWhere(WhereStatement|WhereGroupStatement $where): void + protected function appendWhere(WhereStatement|WhereGroupStatement|WhereExistsStatement $where): void { $this->wheres->offsetSet(null, $where); } diff --git a/packages/database/src/Builder/QueryBuilders/HasWhereRelationMethods.php b/packages/database/src/Builder/QueryBuilders/HasWhereRelationMethods.php new file mode 100644 index 000000000..5620ecbec --- /dev/null +++ b/packages/database/src/Builder/QueryBuilders/HasWhereRelationMethods.php @@ -0,0 +1,173 @@ +=` (defaults), uses an `EXISTS` subquery. + * Otherwise, uses a `COUNT(*)` subquery with the given operator and count. + * + * @phpstan-param (?Closure(SelectQueryBuilder): void) $callback + * + * @return static + */ + public function whereHas( + string $relation, + ?Closure $callback = null, + string|WhereOperator $operator = WhereOperator::GREATER_THAN_OR_EQUAL, + int $count = 1, + ): self { + return $this->addHasCondition( + relation: $relation, + callback: $callback, + operator: WhereOperator::fromOperator(value: $operator), + count: $count, + connector: WhereConnector::AND, + negate: false, + ); + } + + /** + * Adds a `WHERE NOT EXISTS` condition for a relation. + * + * @phpstan-param (?Closure(SelectQueryBuilder): void) $callback + * + * @return static + */ + public function whereDoesntHave( + string $relation, + ?Closure $callback = null, + ): self { + return $this->addHasCondition( + relation: $relation, + callback: $callback, + operator: WhereOperator::GREATER_THAN_OR_EQUAL, + count: 1, + connector: WhereConnector::AND, + negate: true, + ); + } + + /** + * Adds an `OR WHERE EXISTS` condition for a relation. + * + * @phpstan-param (?Closure(SelectQueryBuilder): void) $callback + * + * @return static + */ + public function orWhereHas( + string $relation, + ?Closure $callback = null, + string|WhereOperator $operator = WhereOperator::GREATER_THAN_OR_EQUAL, + int $count = 1, + ): self { + return $this->addHasCondition( + relation: $relation, + callback: $callback, + operator: WhereOperator::fromOperator(value: $operator), + count: $count, + connector: WhereConnector::OR, + negate: false, + ); + } + + /** + * Adds an `OR WHERE NOT EXISTS` condition for a relation. + * + * @phpstan-param (?Closure(SelectQueryBuilder): void) $callback + * + * @return static + */ + public function orWhereDoesntHave( + string $relation, + ?Closure $callback = null, + ): self { + return $this->addHasCondition( + relation: $relation, + callback: $callback, + operator: WhereOperator::GREATER_THAN_OR_EQUAL, + count: 1, + connector: WhereConnector::OR, + negate: true, + ); + } + + private function addHasCondition( + string $relation, + ?Closure $callback, + WhereOperator $operator, + int $count, + WhereConnector $connector, + bool $negate, + ): self { + $parts = str(string: $relation)->explode( + separator: '.', + limit: 2, + ); + $relationName = (string) $parts[0]; + $nestedPath = isset($parts[1]) + ? (string) $parts[1] + : null; + + $existsStatement = $this->model + ->getRelation(name: $relationName) + ->getExistsStatement(); + + $useCount = ! $negate && ($count !== 1 || $operator !== WhereOperator::GREATER_THAN_OR_EQUAL); + + /** @var ImmutableArray $innerWheres */ + $innerWheres = arr(); + /** @var ImmutableArray $innerBindings */ + $innerBindings = arr(); + + if ($nestedPath !== null) { + $innerBuilder = new SelectQueryBuilder(model: $existsStatement->relatedModelName); + $innerBuilder->whereHas( + relation: $nestedPath, + callback: $callback, + ); + + $innerWheres = $innerBuilder->wheres; + $innerBindings = arr(input: $innerBuilder->bindings); + } elseif ($callback instanceof Closure) { + $innerBuilder = new SelectQueryBuilder(model: $existsStatement->relatedModelName); + $callback($innerBuilder); + + $innerWheres = $innerBuilder->wheres; + $innerBindings = arr(input: $innerBuilder->bindings); + } + + $whereExists = new WhereExistsStatement( + relatedTable: $existsStatement->relatedTable, + relatedModelName: $existsStatement->relatedModelName, + condition: $existsStatement->condition, + innerWheres: $innerWheres, + negate: $negate, + useCount: $useCount, + operator: $operator, + count: $count, + ); + + if ($this->wheres->isNotEmpty()) { + $this->appendWhere(where: new WhereStatement(where: $connector->value)); + } + + $this->appendWhere(where: $whereExists); + $this->bind(...$innerBindings->toArray()); + + return $this; + } +} diff --git a/packages/database/src/Builder/WhereConnector.php b/packages/database/src/Builder/WhereConnector.php new file mode 100644 index 000000000..33ede94f3 --- /dev/null +++ b/packages/database/src/Builder/WhereConnector.php @@ -0,0 +1,9 @@ +property->getIterableType()->asClass()); + $parentModel = inspect(model: $this->property->getClass()); + + $relatedTable = $relatedModel->getTableName(); + $parentTable = $parentModel->getTableName(); + $parentPK = $parentModel->getPrimaryKey(); + + $fk = $this->ownerJoin ?? str(string: $parentTable)->singularizeLastWord()->append(suffix: "_{$parentPK}"); + + return new WhereExistsStatement( + relatedTable: $relatedTable, + relatedModelName: $relatedModel->getName(), + condition: "{$relatedTable}.{$fk} = {$parentTable}.{$parentPK}", + ); + } + 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 610812b45..60f843454 100644 --- a/packages/database/src/HasManyThrough.php +++ b/packages/database/src/HasManyThrough.php @@ -9,6 +9,7 @@ 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; @@ -328,4 +329,22 @@ private function resolveThroughRelationJoin(ModelInspector $intermediateModel): $primaryKey, ); } + + public function getExistsStatement(): WhereExistsStatement + { + $ownerModel = inspect(model: $this->property->getClass()); + $intermediateModel = inspect(model: $this->through); + + $intermediateTable = $intermediateModel->getTableName(); + $ownerTable = $ownerModel->getTableName(); + $ownerPK = $ownerModel->getPrimaryKey(); + + $fk = $this->ownerJoin ?? str(string: $ownerTable)->singularizeLastWord()->append(suffix: "_{$ownerPK}"); + + return new WhereExistsStatement( + relatedTable: $intermediateTable, + relatedModelName: inspect(model: $this->property->getIterableType()->asClass())->getName(), + condition: "{$intermediateTable}.{$fk} = {$ownerTable}.{$ownerPK}", + ); + } } diff --git a/packages/database/src/HasOne.php b/packages/database/src/HasOne.php index 8b1fb9d6e..b800df938 100644 --- a/packages/database/src/HasOne.php +++ b/packages/database/src/HasOne.php @@ -9,6 +9,7 @@ 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; @@ -125,6 +126,24 @@ private function getOwnerJoin(ModelInspector $ownerModel, ModelInspector $relati ); } + public function getExistsStatement(): WhereExistsStatement + { + $relatedModel = inspect(model: $this->property->getType()->asClass()); + $parentModel = inspect(model: $this->property->getClass()); + + $relatedTable = $relatedModel->getTableName(); + $parentTable = $parentModel->getTableName(); + $parentPK = $parentModel->getPrimaryKey(); + + $fk = $this->ownerJoin ?? str(string: $parentTable)->singularizeLastWord()->append(suffix: "_{$parentPK}"); + + return new WhereExistsStatement( + relatedTable: $relatedTable, + relatedModelName: $relatedModel->getName(), + condition: "{$relatedTable}.{$fk} = {$parentTable}.{$parentPK}", + ); + } + private function isSelfReferencing(): bool { $relationModel = inspect($this->property->getType()->asClass()); diff --git a/packages/database/src/HasOneThrough.php b/packages/database/src/HasOneThrough.php index 5d23f2d23..6c20af2d6 100644 --- a/packages/database/src/HasOneThrough.php +++ b/packages/database/src/HasOneThrough.php @@ -9,6 +9,7 @@ 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; @@ -285,4 +286,22 @@ private function resolveThroughRelationJoin(ModelInspector $intermediateModel): $primaryKey, ); } + + public function getExistsStatement(): WhereExistsStatement + { + $ownerModel = inspect(model: $this->property->getClass()); + $intermediateModel = inspect(model: $this->through); + + $intermediateTable = $intermediateModel->getTableName(); + $ownerTable = $ownerModel->getTableName(); + $ownerPK = $ownerModel->getPrimaryKey(); + + $fk = $this->ownerJoin ?? str(string: $ownerTable)->singularizeLastWord()->append(suffix: "_{$ownerPK}"); + + return new WhereExistsStatement( + relatedTable: $intermediateTable, + relatedModelName: inspect(model: $this->property->getType()->asClass())->getName(), + condition: "{$intermediateTable}.{$fk} = {$ownerTable}.{$ownerPK}", + ); + } } diff --git a/packages/database/src/QueryStatements/SelectStatement.php b/packages/database/src/QueryStatements/SelectStatement.php index 926b5afc5..bd18fe9da 100644 --- a/packages/database/src/QueryStatements/SelectStatement.php +++ b/packages/database/src/QueryStatements/SelectStatement.php @@ -77,7 +77,7 @@ public function compile(DatabaseDialect $dialect): string if ($this->where->isNotEmpty()) { $query[] = 'WHERE ' . $this->where - ->map(fn (WhereStatement|WhereGroupStatement $where) => $where->compile($dialect)) + ->map(fn (WhereStatement|WhereGroupStatement|WhereExistsStatement $where) => $where->compile($dialect)) ->filter(fn (string $compiled) => $compiled !== '') ->implode(' '); } diff --git a/packages/database/src/QueryStatements/UpdateStatement.php b/packages/database/src/QueryStatements/UpdateStatement.php index 0a46b886d..fee9dfbba 100644 --- a/packages/database/src/QueryStatements/UpdateStatement.php +++ b/packages/database/src/QueryStatements/UpdateStatement.php @@ -40,7 +40,7 @@ public function compile(DatabaseDialect $dialect): string if ($this->where->isNotEmpty()) { $query[] = 'WHERE ' . $this->where - ->map(fn (WhereStatement|WhereGroupStatement $where) => $where->compile($dialect)) + ->map(fn (WhereStatement|WhereGroupStatement|WhereExistsStatement $where) => $where->compile($dialect)) ->filter(fn (string $compiled) => $compiled !== '') ->implode(' '); } diff --git a/packages/database/src/QueryStatements/WhereExistsStatement.php b/packages/database/src/QueryStatements/WhereExistsStatement.php new file mode 100644 index 000000000..4c3f6f173 --- /dev/null +++ b/packages/database/src/QueryStatements/WhereExistsStatement.php @@ -0,0 +1,57 @@ +condition); + + if ($this->innerWheres->isNotEmpty()) { + $compiled = $this->innerWheres + ->map(map: fn ( + QueryStatement $where, + ) => $where->compile(dialect: $dialect)) + ->filter( + filter: fn ( + string $compiled, + ) => $compiled !== '', + ) + ->implode(glue: ' ') + ->toString(); + + if ($compiled !== '') { + $whereClause = $whereClause->append(suffix: " AND {$compiled}"); + } + } + + if ($this->useCount) { + return "(SELECT COUNT(*) FROM {$this->relatedTable} WHERE {$whereClause}) {$this->operator->value} {$this->count}"; + } + + $keyword = $this->negate + ? 'NOT EXISTS' + : 'EXISTS'; + + return "{$keyword} (SELECT 1 FROM {$this->relatedTable} WHERE {$whereClause})"; + } +} diff --git a/packages/database/src/Relation.php b/packages/database/src/Relation.php index ea40f63a6..3c33c01c4 100644 --- a/packages/database/src/Relation.php +++ b/packages/database/src/Relation.php @@ -3,6 +3,7 @@ namespace Tempest\Database; use Tempest\Database\QueryStatements\JoinStatement; +use Tempest\Database\QueryStatements\WhereExistsStatement; use Tempest\Reflection\PropertyAttribute; use Tempest\Support\Arr\ImmutableArray; @@ -15,4 +16,6 @@ public function setParent(string $name): self; public function getSelectFields(): ImmutableArray; public function getJoinStatement(): JoinStatement; + + public function getExistsStatement(): WhereExistsStatement; } diff --git a/tests/Fixtures/Migrations/CreateBookReviewTable.php b/tests/Fixtures/Migrations/CreateBookReviewTable.php new file mode 100644 index 000000000..f122c1d84 --- /dev/null +++ b/tests/Fixtures/Migrations/CreateBookReviewTable.php @@ -0,0 +1,30 @@ +primary() + ->text(name: 'content') + ->belongsTo(local: 'book_reviews.tag_id', foreign: 'tags.id'); + } + + public function down(): QueryStatement + { + return DropTableStatement::forModel(modelClass: BookReview::class); + } +} diff --git a/tests/Fixtures/Migrations/CreateReviewerTable.php b/tests/Fixtures/Migrations/CreateReviewerTable.php new file mode 100644 index 000000000..00da80e64 --- /dev/null +++ b/tests/Fixtures/Migrations/CreateReviewerTable.php @@ -0,0 +1,30 @@ +primary() + ->text(name: 'name') + ->belongsTo(local: 'reviewers.book_review_id', foreign: 'book_reviews.id'); + } + + public function down(): QueryStatement + { + return DropTableStatement::forModel(modelClass: Reviewer::class); + } +} diff --git a/tests/Integration/Database/Builder/WhereHasTest.php b/tests/Integration/Database/Builder/WhereHasTest.php new file mode 100644 index 000000000..72b6ecc48 --- /dev/null +++ b/tests/Integration/Database/Builder/WhereHasTest.php @@ -0,0 +1,859 @@ +whereHas(relation: 'books') + ->compile(); + + $this->assertSameWithoutBackticks( + 'SELECT authors.id AS authors.id, authors.name AS authors.name, authors.type AS authors.type, authors.publisher_id AS authors.publisher_id FROM authors WHERE EXISTS (SELECT 1 FROM books WHERE books.author_id = authors.id)', + $sql, + ); + } + + #[Test] + public function where_has_with_callback_compiles_constrained_subquery(): void + { + $sql = Author::select() + ->whereHas( + relation: 'books', + callback: function ( + SelectQueryBuilder $query, + ): void { + $query->whereField( + field: 'title', + value: 'LOTR 1', + ); + }, + ) + ->compile(); + + $this->assertSameWithoutBackticks( + 'SELECT authors.id AS authors.id, authors.name AS authors.name, authors.type AS authors.type, authors.publisher_id AS authors.publisher_id FROM authors WHERE EXISTS (SELECT 1 FROM books WHERE books.author_id = authors.id AND books.title = ?)', + $sql, + ); + } + + #[Test] + public function where_doesnt_have_compiles_not_exists_subquery(): void + { + $sql = Author::select() + ->whereDoesntHave(relation: 'books') + ->compile(); + + $this->assertSameWithoutBackticks( + 'SELECT authors.id AS authors.id, authors.name AS authors.name, authors.type AS authors.type, authors.publisher_id AS authors.publisher_id FROM authors WHERE NOT EXISTS (SELECT 1 FROM books WHERE books.author_id = authors.id)', + $sql, + ); + } + + #[Test] + public function where_doesnt_have_with_callback_compiles_constrained_subquery(): void + { + $sql = Author::select() + ->whereDoesntHave( + relation: 'books', + callback: function ( + SelectQueryBuilder $query, + ): void { + $query->whereField( + field: 'title', + value: 'LOTR 1', + ); + }, + ) + ->compile(); + + $this->assertSameWithoutBackticks( + 'SELECT authors.id AS authors.id, authors.name AS authors.name, authors.type AS authors.type, authors.publisher_id AS authors.publisher_id FROM authors WHERE NOT EXISTS (SELECT 1 FROM books WHERE books.author_id = authors.id AND books.title = ?)', + $sql, + ); + } + + #[Test] + public function where_has_combined_with_other_where_clauses(): void + { + $sql = Author::select() + ->whereField( + field: 'name', + value: 'Tolkien', + ) + ->whereHas(relation: 'books') + ->compile(); + + $this->assertSameWithoutBackticks( + 'SELECT authors.id AS authors.id, authors.name AS authors.name, authors.type AS authors.type, authors.publisher_id AS authors.publisher_id FROM authors WHERE authors.name = ? AND EXISTS (SELECT 1 FROM books WHERE books.author_id = authors.id)', + $sql, + ); + } + + #[Test] + public function or_where_has_compiles_or_exists(): void + { + $sql = Author::select() + ->whereField( + field: 'name', + value: 'Tolkien', + ) + ->orWhereHas(relation: 'books') + ->compile(); + + $this->assertSameWithoutBackticks( + 'SELECT authors.id AS authors.id, authors.name AS authors.name, authors.type AS authors.type, authors.publisher_id AS authors.publisher_id FROM authors WHERE authors.name = ? OR EXISTS (SELECT 1 FROM books WHERE books.author_id = authors.id)', + $sql, + ); + } + + #[Test] + public function or_where_doesnt_have_compiles_or_not_exists(): void + { + $sql = Author::select() + ->whereField( + field: 'name', + value: 'Tolkien', + ) + ->orWhereDoesntHave(relation: 'books') + ->compile(); + + $this->assertSameWithoutBackticks( + 'SELECT authors.id AS authors.id, authors.name AS authors.name, authors.type AS authors.type, authors.publisher_id AS authors.publisher_id FROM authors WHERE authors.name = ? OR NOT EXISTS (SELECT 1 FROM books WHERE books.author_id = authors.id)', + $sql, + ); + } + + #[Test] + public function where_has_on_belongs_to_relation(): void + { + $sql = Book::select() + ->whereHas(relation: 'author') + ->compile(); + + $this->assertSameWithoutBackticks( + 'SELECT books.id AS books.id, books.title AS books.title, books.author_id AS books.author_id FROM books WHERE EXISTS (SELECT 1 FROM authors WHERE authors.id = books.author_id)', + $sql, + ); + } + + #[Test] + public function where_has_on_belongs_to_with_callback(): void + { + $sql = Book::select() + ->whereHas( + relation: 'author', + callback: function ( + SelectQueryBuilder $query, + ): void { + $query->whereField( + field: 'name', + value: 'Tolkien', + ); + }, + ) + ->compile(); + + $this->assertSameWithoutBackticks( + 'SELECT books.id AS books.id, books.title AS books.title, books.author_id AS books.author_id FROM books WHERE EXISTS (SELECT 1 FROM authors WHERE authors.id = books.author_id AND authors.name = ?)', + $sql, + ); + } + + #[Test] + public function where_has_with_where_group_callback(): void + { + $sql = Author::select() + ->whereHas(relation: 'books', callback: function (SelectQueryBuilder $query): void { + $query + ->whereField(field: 'title', value: 'LOTR 1') + ->orWhereGroup(callback: function ($group): void { + $group->whereField(field: 'title', value: 'Timeline Taxi'); + }); + }) + ->compile(); + + $this->assertSameWithoutBackticks( + 'SELECT authors.id AS authors.id, authors.name AS authors.name, authors.type AS authors.type, authors.publisher_id AS authors.publisher_id FROM authors WHERE EXISTS (SELECT 1 FROM books WHERE books.author_id = authors.id AND books.title = ? OR books.title = ?)', + $sql, + ); + } + + #[Test] + public function where_has_with_where_group_callback_returns_correct_results(): void + { + $this->seed(); + + $authors = Author::select() + ->whereHas(relation: 'books', callback: function (SelectQueryBuilder $query): void { + $query->whereGroup(callback: function ($group): void { + $group->whereField(field: 'title', value: 'LOTR 1') + ->orWhere(field: 'title', value: 'Timeline Taxi'); + }); + }) + ->all(); + + $this->assertCount(2, $authors); + $this->assertSame('Brent', $authors[0]->name); + $this->assertSame('Tolkien', $authors[1]->name); + } + + #[Test] + public function where_has_on_has_one_relation(): void + { + $sql = Book::select() + ->whereHas(relation: 'isbn') + ->compile(); + + $this->assertSameWithoutBackticks( + 'SELECT books.id AS books.id, books.title AS books.title, books.author_id AS books.author_id FROM books WHERE EXISTS (SELECT 1 FROM isbns WHERE isbns.book_id = books.id)', + $sql, + ); + } + + #[Test] + public function where_has_on_belongs_to_many_relation(): void + { + $sql = Tag::select() + ->whereHas(relation: 'books') + ->compile(); + + $this->assertSameWithoutBackticks( + 'SELECT tags.id AS tags.id, tags.label AS tags.label FROM tags WHERE EXISTS (SELECT 1 FROM books_tags WHERE books_tags.tag_id = tags.id)', + $sql, + ); + } + + #[Test] + public function where_has_on_has_many_through_relation(): void + { + $sql = Tag::select() + ->whereHas(relation: 'reviewers') + ->compile(); + + $this->assertSameWithoutBackticks( + 'SELECT tags.id AS tags.id, tags.label AS tags.label FROM tags WHERE EXISTS (SELECT 1 FROM book_reviews WHERE book_reviews.tag_id = tags.id)', + $sql, + ); + } + + #[Test] + public function where_has_on_has_one_through_relation(): void + { + $sql = Tag::select() + ->whereHas(relation: 'topReviewer') + ->compile(); + + $this->assertSameWithoutBackticks( + 'SELECT tags.id AS tags.id, tags.label AS tags.label FROM tags WHERE EXISTS (SELECT 1 FROM book_reviews WHERE book_reviews.tag_id = tags.id)', + $sql, + ); + } + + #[Test] + public function where_has_with_count_compiles_count_subquery(): void + { + $sql = Author::select() + ->whereHas( + relation: 'books', + operator: WhereOperator::GREATER_THAN_OR_EQUAL, + count: 3, + ) + ->compile(); + + $this->assertSameWithoutBackticks( + 'SELECT authors.id AS authors.id, authors.name AS authors.name, authors.type AS authors.type, authors.publisher_id AS authors.publisher_id FROM authors WHERE (SELECT COUNT(*) FROM books WHERE books.author_id = authors.id) >= 3', + $sql, + ); + } + + #[Test] + public function where_has_with_count_and_callback(): void + { + $sql = Author::select() + ->whereHas( + relation: 'books', + callback: function ( + SelectQueryBuilder $query, + ): void { + $query->whereField( + field: 'title', + value: 'LOTR 1', + operator: 'LIKE', + ); + }, + operator: WhereOperator::GREATER_THAN_OR_EQUAL, + count: 2, + ) + ->compile(); + + $this->assertSameWithoutBackticks( + 'SELECT authors.id AS authors.id, authors.name AS authors.name, authors.type AS authors.type, authors.publisher_id AS authors.publisher_id FROM authors WHERE (SELECT COUNT(*) FROM books WHERE books.author_id = authors.id AND books.title LIKE ?) >= 2', + $sql, + ); + } + + #[Test] + public function where_has_with_count_equals_zero(): void + { + $sql = Author::select() + ->whereHas( + relation: 'books', + operator: WhereOperator::EQUALS, + count: 0, + ) + ->compile(); + + $this->assertSameWithoutBackticks( + 'SELECT authors.id AS authors.id, authors.name AS authors.name, authors.type AS authors.type, authors.publisher_id AS authors.publisher_id FROM authors WHERE (SELECT COUNT(*) FROM books WHERE books.author_id = authors.id) = 0', + $sql, + ); + } + + #[Test] + public function or_where_has_with_count(): void + { + $sql = Author::select() + ->whereField( + field: 'name', + value: 'Nobody', + ) + ->orWhereHas( + relation: 'books', + operator: WhereOperator::GREATER_THAN_OR_EQUAL, + count: 3, + ) + ->compile(); + + $this->assertSameWithoutBackticks( + 'SELECT authors.id AS authors.id, authors.name AS authors.name, authors.type AS authors.type, authors.publisher_id AS authors.publisher_id FROM authors WHERE authors.name = ? OR (SELECT COUNT(*) FROM books WHERE books.author_id = authors.id) >= 3', + $sql, + ); + } + + #[Test] + public function where_has_nested_relation_compiles_nested_exists(): void + { + $sql = Author::select() + ->whereHas(relation: 'books.chapters') + ->compile(); + + $this->assertSameWithoutBackticks( + 'SELECT authors.id AS authors.id, authors.name AS authors.name, authors.type AS authors.type, authors.publisher_id AS authors.publisher_id FROM authors WHERE EXISTS (SELECT 1 FROM books WHERE books.author_id = authors.id AND EXISTS (SELECT 1 FROM chapters WHERE chapters.book_id = books.id))', + $sql, + ); + } + + #[Test] + public function where_has_nested_relation_with_callback(): void + { + $sql = Author::select() + ->whereHas( + relation: 'books.chapters', + callback: function ( + SelectQueryBuilder $query, + ): void { + $query->whereField( + field: 'title', + value: 'Chapter 1', + ); + }, + ) + ->compile(); + + $this->assertSameWithoutBackticks( + 'SELECT authors.id AS authors.id, authors.name AS authors.name, authors.type AS authors.type, authors.publisher_id AS authors.publisher_id FROM authors WHERE EXISTS (SELECT 1 FROM books WHERE books.author_id = authors.id AND EXISTS (SELECT 1 FROM chapters WHERE chapters.book_id = books.id AND chapters.title = ?))', + $sql, + ); + } + + #[Test] + public function where_doesnt_have_nested_relation(): void + { + $sql = Author::select() + ->whereDoesntHave(relation: 'books.chapters') + ->compile(); + + $this->assertSameWithoutBackticks( + 'SELECT authors.id AS authors.id, authors.name AS authors.name, authors.type AS authors.type, authors.publisher_id AS authors.publisher_id FROM authors WHERE NOT EXISTS (SELECT 1 FROM books WHERE books.author_id = authors.id AND EXISTS (SELECT 1 FROM chapters WHERE chapters.book_id = books.id))', + $sql, + ); + } + + #[Test] + public function where_has_returns_models_with_related_records(): void + { + $this->seed(); + + $authors = Author::select() + ->whereHas(relation: 'books') + ->all(); + + $this->assertCount( + 2, + $authors, + ); + $this->assertSame( + 'Brent', + $authors[0]->name, + ); + $this->assertSame( + 'Tolkien', + $authors[1]->name, + ); + } + + #[Test] + public function where_has_with_callback_filters_by_related_records(): void + { + $this->seed(); + + $authors = Author::select() + ->whereHas( + relation: 'books', + callback: function ( + SelectQueryBuilder $query, + ): void { + $query->whereField( + field: 'title', + value: 'Timeline Taxi', + ); + }, + ) + ->all(); + + $this->assertCount( + 1, + $authors, + ); + $this->assertSame( + 'Brent', + $authors[0]->name, + ); + } + + #[Test] + public function where_doesnt_have_returns_models_without_related_records(): void + { + $this->seed(); + + $authors = Author::select() + ->whereDoesntHave(relation: 'books') + ->all(); + + $this->assertCount( + 1, + $authors, + ); + $this->assertSame( + 'Nobody', + $authors[0]->name, + ); + } + + #[Test] + public function where_has_nested_returns_models_with_deeply_related_records(): void + { + $this->seed(); + + $authors = Author::select() + ->whereHas(relation: 'books.chapters') + ->all(); + + $this->assertCount( + 1, + $authors, + ); + $this->assertSame( + 'Tolkien', + $authors[0]->name, + ); + } + + #[Test] + public function where_doesnt_have_nested_returns_models_without_deeply_related_records(): void + { + $this->seed(); + + $authors = Author::select() + ->whereDoesntHave(relation: 'books.chapters') + ->all(); + + $this->assertCount( + 2, + $authors, + ); + $this->assertSame( + 'Brent', + $authors[0]->name, + ); + $this->assertSame( + 'Nobody', + $authors[1]->name, + ); + } + + #[Test] + public function where_has_with_count_returns_authors_with_enough_books(): void + { + $this->seed(); + + $authors = Author::select() + ->whereHas( + relation: 'books', + operator: WhereOperator::GREATER_THAN_OR_EQUAL, + count: 3, + ) + ->all(); + + $this->assertCount( + 1, + $authors, + ); + $this->assertSame( + 'Tolkien', + $authors[0]->name, + ); + } + + #[Test] + public function where_has_with_count_and_callback_filters_correctly(): void + { + $this->seed(); + + $authors = Author::select() + ->whereHas( + relation: 'books', + callback: function ( + SelectQueryBuilder $query, + ): void { + $query->whereLike( + field: 'title', + value: 'LOTR%', + ); + }, + operator: WhereOperator::GREATER_THAN_OR_EQUAL, + count: 2, + ) + ->all(); + + $this->assertCount( + 1, + $authors, + ); + $this->assertSame( + 'Tolkien', + $authors[0]->name, + ); + } + + #[Test] + public function where_doesnt_have_with_callback_returns_models_without_matching_related_records(): void + { + $this->seed(); + + $authors = Author::select() + ->whereDoesntHave( + relation: 'books', + callback: function ( + SelectQueryBuilder $query, + ): void { + $query->whereField( + field: 'title', + value: 'Timeline Taxi', + ); + }, + ) + ->all(); + + $this->assertCount( + 2, + $authors, + ); + $this->assertSame( + 'Tolkien', + $authors[0]->name, + ); + $this->assertSame( + 'Nobody', + $authors[1]->name, + ); + } + + #[Test] + public function where_has_on_has_one_returns_models_with_related_record(): void + { + $this->seed(); + + $books = Book::select() + ->whereHas(relation: 'isbn') + ->all(); + + $this->assertCount(2, $books); + $this->assertSame('LOTR 1', $books[0]->title); + $this->assertSame('Timeline Taxi', $books[1]->title); + } + + #[Test] + public function where_doesnt_have_on_has_one(): void + { + $this->seed(); + + $books = Book::select() + ->whereDoesntHave(relation: 'isbn') + ->all(); + + $this->assertCount(2, $books); + $this->assertSame('LOTR 2', $books[0]->title); + $this->assertSame('LOTR 3', $books[1]->title); + } + + #[Test] + public function where_has_on_belongs_to_many_returns_models_with_related_records(): void + { + $this->seed(); + + $tags = Tag::select() + ->whereHas(relation: 'books') + ->all(); + + $this->assertCount(1, $tags); + $this->assertSame('fantasy', $tags[0]->label); + } + + #[Test] + public function where_doesnt_have_on_belongs_to_many(): void + { + $this->seed(); + + $tags = Tag::select() + ->whereDoesntHave(relation: 'books') + ->all(); + + $this->assertCount(1, $tags); + $this->assertSame('orphan', $tags[0]->label); + } + + #[Test] + public function where_has_on_has_many_through_returns_models_with_related_records(): void + { + $this->seed(); + + $tags = Tag::select() + ->whereHas(relation: 'reviewers') + ->all(); + + $this->assertCount(1, $tags); + $this->assertSame('fantasy', $tags[0]->label); + } + + #[Test] + public function where_doesnt_have_on_has_many_through(): void + { + $this->seed(); + + $tags = Tag::select() + ->whereDoesntHave(relation: 'reviewers') + ->all(); + + $this->assertCount(1, $tags); + $this->assertSame('orphan', $tags[0]->label); + } + + #[Test] + public function where_has_on_has_one_through_returns_models_with_related_record(): void + { + $this->seed(); + + $tags = Tag::select() + ->whereHas(relation: 'topReviewer') + ->all(); + + $this->assertCount(1, $tags); + $this->assertSame('fantasy', $tags[0]->label); + } + + #[Test] + public function count_with_where_has(): void + { + $this->seed(); + + $count = Author::count() + ->whereHas(relation: 'books') + ->execute(); + + $this->assertSame( + 2, + $count, + ); + } + + #[Test] + public function count_with_where_has_compiles_correctly(): void + { + $sql = Author::count() + ->whereHas(relation: 'books') + ->compile(); + + $this->assertSameWithoutBackticks( + 'SELECT COUNT(*) AS count FROM authors WHERE EXISTS (SELECT 1 FROM books WHERE books.author_id = authors.id)', + $sql, + ); + } + + #[Test] + public function delete_with_where_has_compiles_correctly(): void + { + $sql = query(model: Author::class) + ->delete() + ->whereHas(relation: 'books') + ->compile(); + + $this->assertSameWithoutBackticks( + 'DELETE FROM authors WHERE EXISTS (SELECT 1 FROM books WHERE books.author_id = authors.id)', + $sql, + ); + } + + #[Test] + public function delete_with_where_doesnt_have_removes_correct_records(): void + { + $this->seed(); + + query(model: Author::class) + ->delete() + ->whereDoesntHave(relation: 'books') + ->execute(); + + $authors = Author::select() + ->all(); + + $this->assertCount( + 2, + $authors, + ); + $this->assertSame( + 'Brent', + $authors[0]->name, + ); + $this->assertSame( + 'Tolkien', + $authors[1]->name, + ); + } + + #[Test] + public function update_with_where_has_compiles_correctly(): void + { + $sql = query(model: Author::class) + ->update(name: 'Updated') + ->whereHas(relation: 'books') + ->compile(); + + $this->assertSameWithoutBackticks( + 'UPDATE authors SET name = ? WHERE EXISTS (SELECT 1 FROM books WHERE books.author_id = authors.id)', + $sql, + ); + } + + #[Test] + public function update_with_where_has_updates_correct_records(): void + { + $this->seed(); + + query(model: Author::class) + ->update(name: 'Has Books') + ->whereHas(relation: 'books') + ->execute(); + + $authors = Author::select() + ->orderBy(field: 'id') + ->all(); + + $this->assertSame( + 'Has Books', + $authors[0]->name, + ); + $this->assertSame( + 'Has Books', + $authors[1]->name, + ); + $this->assertSame( + 'Nobody', + $authors[2]->name, + ); + } + + private function seed(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + CreateChapterTable::class, + CreateIsbnTable::class, + CreateTagTable::class, + CreateBookTagTable::class, + CreateBookReviewTable::class, + CreateReviewerTable::class, + ); + + $brent = Author::create(name: 'Brent'); + $tolkien = Author::create(name: 'Tolkien'); + Author::create(name: 'Nobody'); + + $lotr1 = Book::create(title: 'LOTR 1', author: $tolkien); + Book::create(title: 'LOTR 2', author: $tolkien); + Book::create(title: 'LOTR 3', author: $tolkien); + $timelineTaxi = Book::create(title: 'Timeline Taxi', author: $brent); + + Chapter::create(title: 'Chapter 1', book: $lotr1); + Chapter::create(title: 'Chapter 2', book: $lotr1); + + Isbn::create(value: 'isbn-lotr-1', book: $lotr1); + Isbn::create(value: 'isbn-tt', book: $timelineTaxi); + + $fantasy = Tag::create(label: 'fantasy'); + Tag::create(label: 'orphan'); + + query(model: 'books_tags') + ->insert(['book_id' => 1, 'tag_id' => 1]) + ->execute(); + + $review = BookReview::create(content: 'Great book', tag: $fantasy); + Reviewer::create(name: 'Alice', bookReview: $review); + } +}